diff --git a/datadog_checks_downloader/changelog.d/23937.fixed b/datadog_checks_downloader/changelog.d/23937.fixed new file mode 100644 index 0000000000000..a06be241b4976 --- /dev/null +++ b/datadog_checks_downloader/changelog.d/23937.fixed @@ -0,0 +1 @@ +Use the versioned ``wheelsmith/v1`` v2 target namespace and tighten pointer validation. diff --git a/datadog_checks_downloader/datadog_checks/downloader/download_v2.py b/datadog_checks_downloader/datadog_checks/downloader/download_v2.py index 82557de31f4c9..e84743aacb4a0 100644 --- a/datadog_checks_downloader/datadog_checks/downloader/download_v2.py +++ b/datadog_checks_downloader/datadog_checks/downloader/download_v2.py @@ -10,8 +10,11 @@ import importlib.resources import json import logging +import posixpath +import re import tempfile import urllib.request +from collections.abc import Mapping from pathlib import Path from tuf.ngclient import Updater @@ -33,6 +36,10 @@ WHEEL_FETCH_TIMEOUT_SECONDS = 60 REQUIRED_POINTER_KEYS = ('digest', 'length', 'wheel_path') +V2_POINTER_TARGET_DELEGATION = 'wheelsmith' +V2_POINTER_TARGET_SCHEMA_VERSION = 'v1' +V2_POINTER_TARGET_PREFIX = f'{V2_POINTER_TARGET_DELEGATION}/{V2_POINTER_TARGET_SCHEMA_VERSION}' +SHA256_HEX_RE = re.compile(r'^[0-9a-f]{64}$') class TUFPointerDownloader: @@ -62,7 +69,7 @@ def _make_updater(self, metadata_dir: Path, target_dir: Path) -> Updater: @staticmethod def _target_path(project: str, version: str | None) -> str: name = version if version is not None else 'latest' - return f'{project}/{name}.json' + return f'{V2_POINTER_TARGET_PREFIX}/{project}/{name}.json' @staticmethod def _wheel_filename(project: str, version: str) -> str: @@ -74,10 +81,26 @@ def _direct_wheel_url(self, project: str, version: str) -> str: @staticmethod def _validate_pointer(project: str, pointer: dict) -> None: + if not isinstance(pointer, Mapping): + raise MalformedPointerError(project, 'pointer') + for key in REQUIRED_POINTER_KEYS: if key not in pointer: raise MalformedPointerError(project, key) - if not pointer['wheel_path'].startswith('/'): + + digest = pointer['digest'] + if not isinstance(digest, str) or not SHA256_HEX_RE.match(digest): + raise MalformedPointerError(project, 'digest') + + length = pointer['length'] + if not isinstance(length, int) or isinstance(length, bool) or length < 0: + raise MalformedPointerError(project, 'length') + + wheel_path = pointer['wheel_path'] + if not isinstance(wheel_path, str) or not wheel_path.startswith('/') or wheel_path.startswith('//'): + raise MalformedPointerError(project, 'wheel_path') + normalized = posixpath.normpath(wheel_path) + if normalized != wheel_path: raise MalformedPointerError(project, 'wheel_path') @staticmethod diff --git a/datadog_checks_downloader/tests/_v2_synth_repo.py b/datadog_checks_downloader/tests/_v2_synth_repo.py new file mode 100644 index 0000000000000..59c1af15fa074 --- /dev/null +++ b/datadog_checks_downloader/tests/_v2_synth_repo.py @@ -0,0 +1,158 @@ +# (C) Datadog, Inc. 2024-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) + +"""Synthetic TUF repository builder for v2 downloader tests.""" + +from __future__ import annotations + +import threading +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import datetime, timezone +from functools import partial +from http.server import SimpleHTTPRequestHandler +from pathlib import Path +from socketserver import TCPServer + +from securesystemslib.keys import generate_ed25519_key +from securesystemslib.signer import SSlibKey, SSlibSigner +from tuf.api.metadata import ( + DelegatedRole, + Delegations, + Metadata, + MetaFile, + Role, + Root, + Snapshot, + TargetFile, + Targets, + Timestamp, +) + +SPEC_VERSION = '1.0.31' +EXPIRY = datetime(2099, 1, 1, tzinfo=timezone.utc) +TOP_LEVEL_ROLES = ('root', 'targets', 'snapshot', 'timestamp') + + +def _make_signers(role_names: tuple[str, ...]) -> tuple[dict[str, SSlibSigner], dict[str, SSlibKey]]: + signers: dict[str, SSlibSigner] = {} + public_keys: dict[str, SSlibKey] = {} + for role in role_names: + priv = generate_ed25519_key() + signers[role] = SSlibSigner(priv) + public_keys[role] = SSlibKey.from_securesystemslib_key(priv) + return signers, public_keys + + +def _write_target_blob(targets_dir: Path, target_path: str, blob: bytes, hash_hex: str) -> None: + dirname, _, basename = target_path.rpartition('/') + hashed_basename = f'{hash_hex}.{basename}' + on_disk_path = targets_dir / dirname / hashed_basename + on_disk_path.parent.mkdir(parents=True, exist_ok=True) + on_disk_path.write_bytes(blob) + + +def build_delegated_repo( + root_dir: Path, + delegated_targets: dict[str, bytes], + delegated_role_name: str = 'pointers', + paths: list[str] | None = None, + path_hash_prefixes: list[str] | None = None, +) -> None: + """Materialize a signed v2-style TUF repo with one delegated targets role.""" + if (paths is None) == (path_hash_prefixes is None): + raise ValueError('exactly one of paths or path_hash_prefixes must be set') + + metadata_dir = root_dir / 'metadata' + targets_dir = root_dir / 'targets' + metadata_dir.mkdir(parents=True, exist_ok=True) + targets_dir.mkdir(parents=True, exist_ok=True) + + signers, public_keys = _make_signers(TOP_LEVEL_ROLES + (delegated_role_name,)) + + target_files: dict[str, TargetFile] = {} + for target_path, blob in delegated_targets.items(): + tf = TargetFile.from_data(target_path, blob, hash_algorithms=['sha256']) + target_files[target_path] = tf + _write_target_blob(targets_dir, target_path, blob, next(iter(tf.hashes.values()))) + + delegated_targets_md = Metadata( + signed=Targets(version=1, spec_version=SPEC_VERSION, expires=EXPIRY, targets=target_files), + ) + delegated_targets_md.sign(signers[delegated_role_name]) + delegated_targets_md.to_file(str(metadata_dir / f'1.{delegated_role_name}.json')) + + delegated_role = DelegatedRole( + name=delegated_role_name, + keyids=[public_keys[delegated_role_name].keyid], + threshold=1, + terminating=False, + paths=paths, + path_hash_prefixes=path_hash_prefixes, + ) + delegations = Delegations( + keys={public_keys[delegated_role_name].keyid: public_keys[delegated_role_name]}, + roles={delegated_role_name: delegated_role}, + ) + + top_targets_md = Metadata( + signed=Targets(version=1, spec_version=SPEC_VERSION, expires=EXPIRY, delegations=delegations), + ) + top_targets_md.sign(signers['targets']) + top_targets_md.to_file(str(metadata_dir / '1.targets.json')) + + snapshot_md = Metadata( + signed=Snapshot( + version=1, + spec_version=SPEC_VERSION, + expires=EXPIRY, + meta={ + 'targets.json': MetaFile(version=1), + f'{delegated_role_name}.json': MetaFile(version=1), + }, + ), + ) + snapshot_md.sign(signers['snapshot']) + snapshot_md.to_file(str(metadata_dir / '1.snapshot.json')) + + timestamp_md = Metadata( + signed=Timestamp(version=1, spec_version=SPEC_VERSION, expires=EXPIRY, snapshot_meta=MetaFile(version=1)), + ) + timestamp_md.sign(signers['timestamp']) + timestamp_md.to_file(str(metadata_dir / 'timestamp.json')) + + roles = {name: Role(keyids=[public_keys[name].keyid], threshold=1) for name in TOP_LEVEL_ROLES} + root_keys = {public_keys[name].keyid: public_keys[name] for name in TOP_LEVEL_ROLES} + root_md = Metadata( + signed=Root( + version=1, + spec_version=SPEC_VERSION, + expires=EXPIRY, + keys=root_keys, + roles=roles, + consistent_snapshot=True, + ), + ) + root_md.sign(signers['root']) + root_md.to_file(str(metadata_dir / '1.root.json')) + root_md.to_file(str(metadata_dir / 'root.json')) + + +class _ReuseTCPServer(TCPServer): + allow_reuse_address = True + + +@contextmanager +def serve_directory(directory: Path) -> Iterator[str]: + """Serve ``directory`` over HTTP for the duration of the context.""" + handler = partial(SimpleHTTPRequestHandler, directory=str(directory)) + with _ReuseTCPServer(('127.0.0.1', 0), handler) as httpd: + port = httpd.server_address[1] + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + yield f'http://127.0.0.1:{port}' + finally: + httpd.shutdown() + thread.join(timeout=2) diff --git a/datadog_checks_downloader/tests/test_v2_downloader.py b/datadog_checks_downloader/tests/test_v2_downloader.py index db1977db2be0d..b81a1466d36d6 100644 --- a/datadog_checks_downloader/tests/test_v2_downloader.py +++ b/datadog_checks_downloader/tests/test_v2_downloader.py @@ -14,7 +14,11 @@ from tuf.api.exceptions import DownloadError from datadog_checks.downloader import cli -from datadog_checks.downloader.download_v2 import TUFPointerDownloader +from datadog_checks.downloader.download_v2 import ( + V2_POINTER_TARGET_DELEGATION, + V2_POINTER_TARGET_PREFIX, + TUFPointerDownloader, +) from datadog_checks.downloader.exceptions import ( DigestMismatch, LengthMismatch, @@ -25,6 +29,8 @@ TargetNotFoundError, ) +from ._v2_synth_repo import build_delegated_repo, serve_directory + pytestmark = pytest.mark.offline PROJECT = 'datadog-postgres' @@ -46,7 +52,10 @@ def _mock_tuf_updater(pointer: dict) -> MagicMock: - pointer_bytes = json.dumps(pointer).encode() + return _mock_tuf_updater_with_pointer_bytes(json.dumps(pointer).encode()) + + +def _mock_tuf_updater_with_pointer_bytes(pointer_bytes: bytes) -> MagicMock: mock_updater = MagicMock() mock_updater.get_targetinfo.return_value = MagicMock() @@ -84,8 +93,8 @@ class TestTargetResolution: @pytest.mark.parametrize( 'version,expected_target', [ - pytest.param(VERSION, f'{PROJECT}/{VERSION}.json', id='explicit-version'), - pytest.param(None, f'{PROJECT}/latest.json', id='missing-version'), + pytest.param(VERSION, f'wheelsmith/v1/{PROJECT}/{VERSION}.json', id='explicit-version'), + pytest.param(None, f'wheelsmith/v1/{PROJECT}/latest.json', id='missing-version'), ], ) def test_get_pointer_requests_expected_target(self, mock_urlopen, mock_updater_cls, version, expected_target): @@ -172,6 +181,96 @@ def test_raises_when_wheel_path_missing_leading_slash(self, mock_urlopen, mock_u downloader.download(PROJECT, version=VERSION, dest_dir=tmp_path) mock_urlopen.assert_not_called() + @pytest.mark.parametrize( + 'pointer_bytes', + [ + pytest.param(b'["digest", "length", "wheel_path"]', id='list'), + pytest.param(b'"not an object"', id='string'), + ], + ) + def test_raises_when_pointer_payload_is_not_object(self, mock_urlopen, mock_updater_cls, tmp_path, pointer_bytes): + mock_updater_cls.return_value = _mock_tuf_updater_with_pointer_bytes(pointer_bytes) + + downloader = TUFPointerDownloader(repository_url=REPO_URL) + with pytest.raises(MalformedPointerError, match='pointer'): + downloader.download(PROJECT, version=VERSION, dest_dir=tmp_path) + mock_urlopen.assert_not_called() + + @pytest.mark.parametrize( + 'wheel_path', + [ + pytest.param(f'//evil.example.com/wheels/{PROJECT}/{WHEEL_NAME}', id='scheme-bypass-double-slash'), + pytest.param(f'/wheels/../{PROJECT}/{WHEEL_NAME}', id='parent-dir-segment'), + pytest.param(f'/wheels/{PROJECT}/../{WHEEL_NAME}', id='parent-dir-segment-trailing'), + pytest.param(f'/wheels//{PROJECT}/{WHEEL_NAME}', id='empty-segment'), + ], + ) + def test_rejects_path_traversal_or_scheme_bypass_in_wheel_path( + self, mock_urlopen, mock_updater_cls, tmp_path, wheel_path + ): + bad_pointer = {**POINTER, 'wheel_path': wheel_path} + mock_updater_cls.return_value = _mock_tuf_updater(bad_pointer) + + downloader = TUFPointerDownloader(repository_url=REPO_URL) + with pytest.raises(MalformedPointerError, match='wheel_path'): + downloader.download(PROJECT, version=VERSION, dest_dir=tmp_path) + mock_urlopen.assert_not_called() + + @pytest.mark.parametrize( + 'pointer_digest', + [ + pytest.param(123, id='non-string'), + pytest.param('not-hex!' * 8, id='non-hex'), + pytest.param('a' * 63, id='too-short'), + pytest.param('a' * 65, id='too-long'), + pytest.param(WHEEL_DIGEST.upper(), id='uppercase'), + ], + ) + def test_rejects_invalid_digest(self, mock_urlopen, mock_updater_cls, tmp_path, pointer_digest): + bad_pointer = {**POINTER, 'digest': pointer_digest} + mock_updater_cls.return_value = _mock_tuf_updater(bad_pointer) + + downloader = TUFPointerDownloader(repository_url=REPO_URL) + with pytest.raises(MalformedPointerError, match='digest'): + downloader.download(PROJECT, version=VERSION, dest_dir=tmp_path) + mock_urlopen.assert_not_called() + + @pytest.mark.parametrize( + 'pointer_length', + [ + pytest.param('27', id='string'), + pytest.param(-1, id='negative'), + pytest.param(True, id='bool-true'), + pytest.param(None, id='none'), + ], + ) + def test_rejects_invalid_length(self, mock_urlopen, mock_updater_cls, tmp_path, pointer_length): + bad_pointer = {**POINTER, 'length': pointer_length} + mock_updater_cls.return_value = _mock_tuf_updater(bad_pointer) + + downloader = TUFPointerDownloader(repository_url=REPO_URL) + with pytest.raises(MalformedPointerError, match='length'): + downloader.download(PROJECT, version=VERSION, dest_dir=tmp_path) + mock_urlopen.assert_not_called() + + def test_extra_unknown_keys_are_forward_compatible(self, mock_urlopen, mock_updater_cls, tmp_path): + forward_compat = {**POINTER, 'future_feature': {'enabled': True}, 'signing_metadata_url': '/x'} + mock_updater_cls.return_value = _mock_tuf_updater(forward_compat) + + downloader = TUFPointerDownloader(repository_url=REPO_URL) + wheel_path = downloader.download(PROJECT, version=VERSION, dest_dir=tmp_path) + assert wheel_path.read_bytes() == WHEEL_CONTENT + + def test_zero_length_wheel_is_allowed(self, mock_urlopen, mock_updater_cls, tmp_path): + empty_digest = hashlib.sha256(b'').hexdigest() + empty_pointer = {**POINTER, 'digest': empty_digest, 'length': 0} + mock_updater_cls.return_value = _mock_tuf_updater(empty_pointer) + mock_urlopen.return_value = _mock_response(b'') + + downloader = TUFPointerDownloader(repository_url=REPO_URL) + wheel_path = downloader.download(PROJECT, version=VERSION, dest_dir=tmp_path) + assert wheel_path.read_bytes() == b'' + class TestNetworkErrorMidDownload: def test_http_error_propagates(self, mock_urlopen, mock_updater_cls, tmp_path): @@ -213,6 +312,109 @@ def test_direct_download_requires_explicit_version(self, tmp_path): downloader.download(PROJECT, dest_dir=tmp_path) +class TestUpdaterContract: + """Lock in the v2 target-path storage contract.""" + + def test_get_targetinfo_called_with_prefixed_path_only(self, mock_urlopen, mock_updater_cls): + downloader = TUFPointerDownloader(repository_url=REPO_URL) + downloader.get_pointer(PROJECT, version=VERSION) + + mock_updater = mock_updater_cls.return_value + call = mock_updater.get_targetinfo.call_args + assert call.args == (f'wheelsmith/v1/{PROJECT}/{VERSION}.json',) + assert call.kwargs == {} + + def test_target_path_uses_stable_wheelsmith_namespace(self, mock_urlopen, mock_updater_cls): + downloader = TUFPointerDownloader(repository_url=REPO_URL) + downloader.get_pointer(PROJECT, version=VERSION) + + target_path = mock_updater_cls.return_value.get_targetinfo.call_args.args[0] + assert target_path.startswith('wheelsmith/v1/') + assert not target_path.startswith('targets/') + assert not target_path.startswith('wheels-signer-') + + +def _patch_bootstrap_to_use(repo_root: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Make TUFPointerDownloader trust the synthetic repo's root.json instead of the bundled one.""" + + def fake_bootstrap(self, metadata_dir: Path) -> None: + (metadata_dir / 'root.json').write_bytes((repo_root / 'metadata' / 'root.json').read_bytes()) + + monkeypatch.setattr(TUFPointerDownloader, '_bootstrap_metadata_dir', fake_bootstrap) + + +class TestDelegationTraversal: + """Test v2 target resolution through delegated TUF metadata.""" + + @staticmethod + def _make_pointer_target(project: str, version: str) -> tuple[str, bytes, dict]: + wheel = b'synthetic wheel for delegation test' + wheel_name = f'{project.replace("-", "_")}-{version}-py3-none-any.whl' + pointer = { + 'digest': hashlib.sha256(wheel).hexdigest(), + 'length': len(wheel), + 'version': version, + 'repository': REPO_URL, + 'wheel_path': f'/wheels/{project}/{wheel_name}', + } + return wheel_name, wheel, pointer + + def test_resolves_through_paths_delegation_without_naming_role(self, monkeypatch, tmp_path): + project, version = 'datadog-postgres', '14.0.0' + _, _, pointer = self._make_pointer_target(project, version) + + repo = tmp_path / 'repo' + build_delegated_repo( + repo, + delegated_targets={f'{V2_POINTER_TARGET_PREFIX}/{project}/{version}.json': json.dumps(pointer).encode()}, + delegated_role_name=V2_POINTER_TARGET_DELEGATION, + paths=[f'{V2_POINTER_TARGET_PREFIX}/{project}/*'], + ) + _patch_bootstrap_to_use(repo, monkeypatch) + + with serve_directory(repo) as url: + downloader = TUFPointerDownloader(repository_url=url) + assert downloader.get_pointer(project, version=version) == pointer + + def test_resolves_through_hash_prefix_delegation(self, monkeypatch, tmp_path): + project, version = 'datadog-postgres', '14.0.0' + target_path = f'{V2_POINTER_TARGET_PREFIX}/{project}/{version}.json' + _, _, pointer = self._make_pointer_target(project, version) + + prefix = hashlib.sha256(target_path.encode()).hexdigest()[:2] + + repo = tmp_path / 'repo' + build_delegated_repo( + repo, + delegated_targets={target_path: json.dumps(pointer).encode()}, + delegated_role_name=V2_POINTER_TARGET_DELEGATION, + path_hash_prefixes=[prefix], + ) + _patch_bootstrap_to_use(repo, monkeypatch) + + with serve_directory(repo) as url: + downloader = TUFPointerDownloader(repository_url=url) + assert downloader.get_pointer(project, version=version) == pointer + + def test_unmatched_target_path_raises_not_found(self, monkeypatch, tmp_path): + project, version = 'datadog-postgres', '14.0.0' + _, _, pointer = self._make_pointer_target(project, version) + + repo = tmp_path / 'repo' + build_delegated_repo( + repo, + delegated_targets={f'{V2_POINTER_TARGET_PREFIX}/{project}/{version}.json': json.dumps(pointer).encode()}, + delegated_role_name=V2_POINTER_TARGET_DELEGATION, + paths=[f'{V2_POINTER_TARGET_PREFIX}/datadog-postgres/*'], + ) + _patch_bootstrap_to_use(repo, monkeypatch) + + with serve_directory(repo) as url: + downloader = TUFPointerDownloader(repository_url=url) + with pytest.raises(TargetNotFoundError, match='datadog-redis'): + downloader.get_pointer('datadog-redis', version=version) + + class TestInstantiateV2Downloader: def test_rejects_non_datadog_package(self, monkeypatch): monkeypatch.setattr('sys.argv', ['downloader', 'requests']) diff --git a/kubelet/metadata.csv b/kubelet/metadata.csv index f82a55aee1c9f..62688be61ad84 100644 --- a/kubelet/metadata.csv +++ b/kubelet/metadata.csv @@ -15,8 +15,8 @@ kubernetes.cpu.cfs.throttled.seconds,gauge,,,,Total time duration the container kubernetes.cpu.capacity,gauge,,core,,The number of cores in this machine (available until kubernetes v1.18),0,kubernetes,k8s.cpu.capacity, kubernetes.cpu.usage.total,gauge,,nanocore,,The number of cores used,-1,kubernetes,k8s.cpu, kubernetes.cpu.limits,gauge,,core,,The limit of cpu cores set,0,kubernetes,k8s.cpu.limits, -kubernetes.pod.cpu.request,gauge,,core,,The pod-level requested CPU cores,0,kubernetes,k8s.pod.cpu.request, -kubernetes.pod.cpu.limit,gauge,,core,,The pod-level CPU core limit,0,kubernetes,k8s.pod.cpu.limit, +kubernetes.pod.cpu.requests,gauge,,core,,The pod-level requested CPU cores,0,kubernetes,k8s.pod.cpu.request, +kubernetes.pod.cpu.limits,gauge,,core,,The pod-level CPU core limit,0,kubernetes,k8s.pod.cpu.limit, kubernetes.cpu.requests,gauge,,core,,The requested cpu cores,0,kubernetes,k8s.cpu.requests, kubernetes.filesystem.usage,gauge,,byte,,The amount of disk used,-1,kubernetes,k8s.disk.usage, kubernetes.filesystem.usage_pct,gauge,,fraction,,The percentage of disk used,-1,kubernetes,k8s.disk.used_pct, @@ -26,8 +26,8 @@ kubernetes.memory.capacity,gauge,,byte,,The amount of memory (in bytes) in this kubernetes.memory.limits,gauge,,byte,,The limit of memory set,0,kubernetes,k8s.mem.limits, kubernetes.memory.sw_limit,gauge,,byte,,The limit of swap space set,0,kubernetes,k8s.mem.sw_limit, kubernetes.memory.requests,gauge,,byte,,The requested memory,0,kubernetes,k8s.mem.requests, -kubernetes.pod.memory.request,gauge,,byte,,The pod-level requested memory in bytes,0,kubernetes,k8s.pod.mem.request, -kubernetes.pod.memory.limit,gauge,,byte,,The pod-level memory limit in bytes,0,kubernetes,k8s.pod.mem.limit, +kubernetes.pod.memory.requests,gauge,,byte,,The pod-level requested memory in bytes,0,kubernetes,k8s.pod.mem.request, +kubernetes.pod.memory.limits,gauge,,byte,,The pod-level memory limit in bytes,0,kubernetes,k8s.pod.mem.limit, kubernetes.memory.usage,gauge,,byte,,Current memory usage in bytes including all memory regardless of when it was accessed,-1,kubernetes,k8s.mem, kubernetes.memory.working_set,gauge,,byte,,Current working set in bytes - this is what the OOM killer is watching for,-1,kubernetes,k8s.mem.ws, kubernetes.memory.cache,gauge,,byte,,The amount of memory that is being used to cache data from disk (e.g. memory contents that can be associated precisely with a block on a block device),-1,kubernetes,k8s.mem.cache, diff --git a/release.json b/release.json index e7ebea6ea3ed4..d34c4dce1941f 100644 --- a/release.json +++ b/release.json @@ -1,3 +1,3 @@ { - "current_milestone": "7.81.0" + "current_milestone": "7.82.0" } diff --git a/windows_certificate/README.md b/windows_certificate/README.md index 9eb4609fb340b..39ac11d89395c 100644 --- a/windows_certificate/README.md +++ b/windows_certificate/README.md @@ -72,6 +72,26 @@ The `policy_validation_flags` [suppress specific validation errors][12] that may The integration automatically tags all metrics and service checks with the name of the store in the `certificate_store:` tag. Certificate metrics and service checks are tagged with the certificate's subjects, thumbprints and serial numbers. CRL metrics and service checks are tagged with the CRL's issuer and thumbprint. +Beginning with Agent v7.80, six opt-in flags expose additional certificate metadata as tags on per-certificate metrics and service checks. Each flag defaults to `false`. Set the value to `true` in your instance configuration to emit the corresponding tags. + +| Flag | Tags emitted | +| --- | --- | +| `certificate_template_tag` | `certificate_template`, `certificate_template_oid`, `certificate_template_major_version`, `certificate_template_minor_version` | +| `enhanced_key_usage_tag` | `enhanced_key_usage` (one tag per EKU OID; well-known OIDs use short names) | +| `friendly_name_tag` | `friendly_name` | +| `subject_alternative_names_tag` | `subject_alt_name_dns`, `subject_alt_name_ip`, `subject_alt_name_email`, `subject_alt_name_uri` | +| `issuer_tag` | `issuer_CN`, `issuer_O`, `issuer_OU`, and other issuer Distinguished Name components when present | +| `signature_algorithm_tag` | `signature_algorithm` | + +Example configuration that enables the issuer and signature algorithm tags: + +```yaml +instances: + - certificate_store: ROOT + issuer_tag: true + signature_algorithm_tag: true +``` + ### Validation [Run the Agent's status subcommand][6] and look for `windows_certificate` under the Checks section.