diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..f7c371e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Require FortifyRoot admins to review all repository changes. +* @sayonfortify @manas-fortifyroot diff --git a/README.md b/README.md index b7c122f..b417c39 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ This table lists the launch-supported instrumentation exposed through `fortifyro | LangGraph | via `Instruments.LANGCHAIN` | install with app | Covered through LangChain/OpenAI launch path | Yes | Yes | Provider-dependent | Same supported path as LangChain | | LlamaIndex | `Instruments.LLAMA_INDEX` | `llamaindex` | `llama-index >=0.14.12,<0.15.0` | Yes | Yes | Yes | Prompt + completion, including streaming paths | -For provider-role behavior, routed providers such as OpenRouter, LiteLLM, Bedrock, Azure OpenAI, and planned/mapper-supported providers, see [Provider Support](docs/PROVIDERS.md). That document is the source of truth for what is launch-certified versus planned. +For provider-role behavior, routed providers such as OpenRouter, LiteLLM, Bedrock, Azure OpenAI, and planned/mapper-supported providers, see [Provider Support](https://github.com/FortifyRoot/ocelle-py/blob/main/docs/PROVIDERS.md). That document is the source of truth for what is launch-certified versus planned. ## Runtime Safety @@ -217,4 +217,4 @@ Safety rules can still run when configured; content tracing controls what is exp Apache License, Version 2.0. -FortifyRoot Ocelle includes code derived from [OpenLLMetry](https://github.com/traceloop/openllmetry) and `traceloop-sdk` by Traceloop, licensed under the Apache License, Version 2.0. The license text and attribution are retained in [LICENSE](LICENSE). +FortifyRoot Ocelle includes code derived from [OpenLLMetry](https://github.com/traceloop/openllmetry) and `traceloop-sdk` by Traceloop, licensed under the Apache License, Version 2.0. The license text and attribution are retained in [LICENSE](https://github.com/FortifyRoot/ocelle-py/blob/main/LICENSE). diff --git a/pyproject.toml b/pyproject.toml index 74c2fa0..158958c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "fortifyroot-ocelle" -version = "1.0.0" +version = "1.0.1" description = "FortifyRoot Ocelle SDK for LLM observability" authors = ["FortifyRoot "] license = "Apache-2.0" diff --git a/scripts/vendor_openllmetry.py b/scripts/vendor_openllmetry.py index 8628f72..f415b29 100644 --- a/scripts/vendor_openllmetry.py +++ b/scripts/vendor_openllmetry.py @@ -581,7 +581,14 @@ def get_git_info(ol_repo: Path) -> dict: """Get git commit info from OpenLLMetry repository.""" import subprocess - info = {"commit": "unknown", "branch": "unknown", "tag": "unknown"} + info = { + "commit": "unknown", + "branch": "unknown", + "tag": "unknown", + "base_tag": "unknown", + "dirty": False, + "dirty_files": [], + } try: result = subprocess.run( @@ -603,7 +610,24 @@ def get_git_info(ol_repo: Path) -> dict: cwd=ol_repo, capture_output=True, text=True ) if result.returncode == 0: - info["tag"] = result.stdout.strip() + tag = result.stdout.strip() + info["tag"] = tag + info["base_tag"] = tag + + result = subprocess.run( + ['git', 'status', '--porcelain'], + cwd=ol_repo, capture_output=True, text=True + ) + if result.returncode == 0: + dirty_files = [ + line[3:] + for line in result.stdout.splitlines() + if len(line) > 3 + ] + info["dirty"] = bool(dirty_files) + info["dirty_files"] = dirty_files + if dirty_files: + info["tag"] = "dirty-working-tree" except FileNotFoundError: pass @@ -623,6 +647,9 @@ def write_manifest(vendor_root: Path, ol_repo: Path, vendored_packages: List[str "git_commit": git_info["commit"], "git_branch": git_info["branch"], "git_tag": git_info["tag"], + "git_base_tag": git_info["base_tag"], + "git_dirty": git_info["dirty"], + "git_dirty_files": git_info["dirty_files"], "instrumentation_package_policy": { f"opentelemetry-instrumentation-{pkg.replace('_', '-')}": should_vendor for pkg, should_vendor in sorted(OL_INSTRUMENTATION_PACKAGES.items()) diff --git a/src/fortifyroot/_internal/safety/engine.py b/src/fortifyroot/_internal/safety/engine.py index 588b51d..77c09c5 100644 --- a/src/fortifyroot/_internal/safety/engine.py +++ b/src/fortifyroot/_internal/safety/engine.py @@ -2,7 +2,6 @@ from __future__ import annotations -import concurrent.futures import importlib import logging import re @@ -32,11 +31,9 @@ _USING_RE2 = True except ImportError: - _re_engine = re # type: ignore[assignment] + _re_engine = None # type: ignore[assignment] _USING_RE2 = False -REGEX_EVAL_TIMEOUT_SECONDS = 5 - _udf_detectors_enabled = False @@ -63,6 +60,10 @@ def set_udf_detectors_enabled(enabled: bool) -> None: "fortifyroot.safety.udf_errors", description="Number of safety UDF load or execution errors.", ) +_REGEX_RULES_SKIPPED_COUNTER = _METER.create_counter( + "fortifyroot.safety.regex_rules_skipped", + description="Number of regex safety rules skipped before evaluation.", +) SDK_LANGUAGE_PYTHON = "PYTHON" MAX_REGEX_PATTERN_LENGTH = 1024 @@ -163,6 +164,21 @@ def _compile_rule(rule: SafetyRule, default_action: str) -> CompiledSafetyRule | udf_detector = None if isinstance(rule.matcher, RegexMatcher): + if not _USING_RE2 or _re_engine is None: + logger.error( + "Skipping regex safety rule because google-re2 is unavailable; " + "regex-based safety masking is degraded: %s", + rule.name, + ) + _REGEX_RULES_SKIPPED_COUNTER.add( + 1, + attributes={ + "reason": "re2_unavailable", + "category": rule.category, + "severity": rule.severity, + }, + ) + return None if len(rule.matcher.pattern) > MAX_REGEX_PATTERN_LENGTH: logger.warning( "Skipping safety rule with oversized regex: %s", @@ -171,7 +187,7 @@ def _compile_rule(rule: SafetyRule, default_action: str) -> CompiledSafetyRule | return None try: regex = _re_engine.compile(rule.matcher.pattern) - except (re.error, Exception): + except Exception: logger.warning("Skipping safety rule with invalid regex: %s", rule.name) return None elif isinstance(rule.matcher, StringListMatcher): @@ -246,30 +262,26 @@ def _safe_finditer( pattern: re.Pattern[str], text: str, rule_name: str, -) -> list[re.Match[str]]: - """Return all matches for *pattern* against *text*. +) -> Iterable[re.Match[str]]: + """Return a lazy match iterator for *pattern* against *text*. - When ``re2`` is available the call is direct (re2 has built-in - backtracking protection). With stdlib ``re`` the evaluation is - delegated to a thread so we can enforce a timeout. + Regex safety rules are compiled only when google-re2 is available. This + fallback branch is retained as a defense-in-depth guard for tests and + unexpected runtime mutation, but it never executes untrusted stdlib regexes. """ if _USING_RE2: - return list(pattern.finditer(text)) + return pattern.finditer(text) - def _do_finditer() -> list[re.Match[str]]: - return list(pattern.finditer(text)) - - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - future = pool.submit(_do_finditer) - try: - return future.result(timeout=REGEX_EVAL_TIMEOUT_SECONDS) - except concurrent.futures.TimeoutError: - logger.warning( - "Regex evaluation timed out after %ds for rule %s; returning empty findings", - REGEX_EVAL_TIMEOUT_SECONDS, - rule_name, - ) - raise _RegexTimeoutError() from None + logger.error( + "Skipping regex evaluation for rule %s because google-re2 is unavailable; " + "regex-based safety masking is degraded", + rule_name, + ) + _REGEX_RULES_SKIPPED_COUNTER.add( + 1, + attributes={"reason": "re2_unavailable_runtime"}, + ) + raise _RegexTimeoutError() from None def _evaluate_rule( diff --git a/src/fortifyroot/_internal/safety/runtime.py b/src/fortifyroot/_internal/safety/runtime.py index ccaccc4..46623c9 100644 --- a/src/fortifyroot/_internal/safety/runtime.py +++ b/src/fortifyroot/_internal/safety/runtime.py @@ -42,6 +42,10 @@ "fortifyroot.safety.config_fetch_failures", description="Number of FortifyRoot safety config fetch failures.", ) +_EMPTY_SNAPSHOT_START_COUNTER = _METER.create_counter( + "fortifyroot.safety.empty_snapshot_starts", + description="Number of SDK starts that registered safety handlers without a loaded config snapshot.", +) MAX_CONFIG_RESPONSE_BYTES = 1_048_576 # 1 MB DEFAULT_CONFIG_POLL_INTERVAL_SECONDS = 60 @@ -49,7 +53,7 @@ API_KEY_HEADER = "X-API-Key" REQUEST_TIMEOUT_SECONDS = 5 FORTIFYROOT_API_BASE_URL = "https://api.fortifyroot.com" -LOCAL_FORTIFYROOT_DEV_HOSTS = {"localhost", "host.docker.internal"} +LOCAL_FORTIFYROOT_DEV_HOSTS = {"localhost"} FORTIFYROOT_API_HOST_SUFFIX = "api.fortifyroot.com" AUTH_HTTP_STATUS_CODES = {401, 403} @@ -341,7 +345,11 @@ def _log_fetch_error( return if initial: - logger.warning( + _EMPTY_SNAPSHOT_START_COUNTER.add( + 1, + attributes={"reason": exc.__class__.__name__}, + ) + logger.error( "Initial FortifyRoot safety config fetch failed; starting with empty safety snapshot: %s", exc, ) diff --git a/src/fortifyroot/_vendor/VENDOR_DEPENDENCIES.json b/src/fortifyroot/_vendor/VENDOR_DEPENDENCIES.json index 9654231..f4a8222 100644 --- a/src/fortifyroot/_vendor/VENDOR_DEPENDENCIES.json +++ b/src/fortifyroot/_vendor/VENDOR_DEPENDENCIES.json @@ -52,7 +52,7 @@ }, "test": { "anthropic": ">=0.25.2,<0.83.0", - "boto3": ">=1.34.120,<2", + "boto3": ">=1.35.49,<2", "chromadb": ">=0.5.23,<0.6.0", "google-genai": ">=1.58.0,<1.59.0", "langchain": ">=1.0.0,<2.0.0", diff --git a/src/fortifyroot/_vendor/VENDOR_MANIFEST.json b/src/fortifyroot/_vendor/VENDOR_MANIFEST.json index 15712fd..121c6f7 100644 --- a/src/fortifyroot/_vendor/VENDOR_MANIFEST.json +++ b/src/fortifyroot/_vendor/VENDOR_MANIFEST.json @@ -1,9 +1,12 @@ { - "vendored_at": "2026-06-27T19:30:27.492730", + "vendored_at": "2026-07-06T21:20:43.609893", "openllmetry_version": "0.52.6", - "git_commit": "86389b2e0d35", + "git_commit": "ea31a61a6518", "git_branch": "HEAD", - "git_tag": "fr-v0.52.6.33", + "git_tag": "fr-v0.52.6.34", + "git_base_tag": "fr-v0.52.6.34", + "git_dirty": false, + "git_dirty_files": [], "instrumentation_package_policy": { "opentelemetry-instrumentation-agno": false, "opentelemetry-instrumentation-alephalpha": false, diff --git a/src/fortifyroot/_vendor/tracer/sdk/exporters/auth_warnings.py b/src/fortifyroot/_vendor/tracer/sdk/exporters/auth_warnings.py index 2103cbe..896de2a 100644 --- a/src/fortifyroot/_vendor/tracer/sdk/exporters/auth_warnings.py +++ b/src/fortifyroot/_vendor/tracer/sdk/exporters/auth_warnings.py @@ -51,8 +51,17 @@ def _endpoint_label(endpoint: Any) -> str: if not raw_endpoint: return "unknown endpoint" parsed = urlparse(raw_endpoint) - if parsed.netloc: - return parsed.netloc + if parsed.hostname: + host = parsed.hostname + if ":" in host and not host.startswith("["): + host = f"[{host}]" + try: + port = parsed.port + except ValueError: + return raw_endpoint + if port is not None: + return f"{host}:{port}" + return host return raw_endpoint diff --git a/src/fortifyroot/_vendor/tracer/sdk/logging/logging.py b/src/fortifyroot/_vendor/tracer/sdk/logging/logging.py index f616b7e..3e7b63e 100644 --- a/src/fortifyroot/_vendor/tracer/sdk/logging/logging.py +++ b/src/fortifyroot/_vendor/tracer/sdk/logging/logging.py @@ -2,6 +2,7 @@ # This file has been modified by FortifyRoot. # Original source: https://github.com/traceloop/openllmetry +import ipaddress import logging from typing import Dict, Optional, Any, cast from urllib.parse import urlparse @@ -17,6 +18,59 @@ FortifyRootHTTPLogExporter as HTTPExporter, ) +LOCAL_EXPORT_HOSTS = {"localhost"} + + +def _is_local_export_host(host: Optional[str]) -> bool: + if not host: + return False + normalized = host.strip().lower() + if normalized in LOCAL_EXPORT_HOSTS: + return True + try: + return ipaddress.ip_address(normalized).is_loopback + except ValueError: + return False + + +def _resolve_grpc_exporter_endpoint(endpoint: str) -> tuple[str, bool]: + trimmed_endpoint = endpoint.strip() + if "://" not in trimmed_endpoint: + return trimmed_endpoint, False + + parsed = urlparse(trimmed_endpoint) + if parsed.username or parsed.password: + raise ValueError("OTLP exporter endpoints must not include username or password") + scheme = parsed.scheme.lower() + if scheme == "grpcs": + return parsed.netloc, False + if scheme == "grpc": + if not _is_local_export_host(parsed.hostname): + raise ValueError( + "grpc:// OTLP export is insecure and is allowed only for local " + "development endpoints; use grpcs:// or https:// for remote collectors" + ) + return parsed.netloc, True + + raise ValueError( + f"Unsupported OTLP exporter endpoint scheme {parsed.scheme!r}; " + "use https://, http://, grpcs://, grpc://localhost, or a bare secure gRPC host:port" + ) + + +def _validate_http_exporter_endpoint(endpoint: str) -> None: + parsed = urlparse(endpoint.strip()) + if parsed.username or parsed.password: + raise ValueError("OTLP exporter endpoints must not include username or password") + if parsed.scheme.lower() != "http": + return + if _is_local_export_host(parsed.hostname): + return + raise ValueError( + "http:// OTLP export is insecure and is allowed only for local " + "development endpoints; use https:// for remote collectors" + ) + _FORTIFYROOT_LOGGING_HANDLER_MARKER = "_fortifyroot_logging_handler" _FORTIFYROOT_INTERNAL_EXPORTER_LOGGER_PREFIX = "fortifyroot.sdk.exporters." @@ -75,13 +129,18 @@ def get_logging_provider(cls) -> Optional[LoggerProvider]: def init_logging_exporter(endpoint: str, headers: Dict[str, str]) -> LogExporter: trimmed_endpoint = endpoint.strip() - if urlparse(trimmed_endpoint).scheme.lower() in {"http", "https"}: + scheme = urlparse(trimmed_endpoint).scheme.lower() + if scheme in {"http", "https"}: + _validate_http_exporter_endpoint(trimmed_endpoint) base_url = trimmed_endpoint.rstrip("/") if not base_url.endswith("/v1/logs"): base_url = f"{base_url}/v1/logs" return cast(LogExporter, HTTPExporter(endpoint=base_url, headers=headers)) - else: - return cast(LogExporter, GRPCExporter(endpoint=trimmed_endpoint, headers=headers)) + grpc_endpoint, insecure = _resolve_grpc_exporter_endpoint(trimmed_endpoint) + return cast( + LogExporter, + GRPCExporter(endpoint=grpc_endpoint, headers=headers, insecure=insecure), + ) def init_logging_provider( diff --git a/src/fortifyroot/_vendor/tracer/sdk/metrics/metrics.py b/src/fortifyroot/_vendor/tracer/sdk/metrics/metrics.py index 1c1cf2d..ed98d16 100644 --- a/src/fortifyroot/_vendor/tracer/sdk/metrics/metrics.py +++ b/src/fortifyroot/_vendor/tracer/sdk/metrics/metrics.py @@ -2,6 +2,7 @@ # This file has been modified by FortifyRoot. # Original source: https://github.com/traceloop/openllmetry +import ipaddress from collections.abc import Sequence from typing import Dict, Optional, Any from urllib.parse import urlparse @@ -21,6 +22,59 @@ FortifyRootHTTPMetricExporter as HTTPExporter, ) +LOCAL_EXPORT_HOSTS = {"localhost"} + + +def _is_local_export_host(host: Optional[str]) -> bool: + if not host: + return False + normalized = host.strip().lower() + if normalized in LOCAL_EXPORT_HOSTS: + return True + try: + return ipaddress.ip_address(normalized).is_loopback + except ValueError: + return False + + +def _resolve_grpc_exporter_endpoint(endpoint: str) -> tuple[str, bool]: + trimmed_endpoint = endpoint.strip() + if "://" not in trimmed_endpoint: + return trimmed_endpoint, False + + parsed = urlparse(trimmed_endpoint) + if parsed.username or parsed.password: + raise ValueError("OTLP exporter endpoints must not include username or password") + scheme = parsed.scheme.lower() + if scheme == "grpcs": + return parsed.netloc, False + if scheme == "grpc": + if not _is_local_export_host(parsed.hostname): + raise ValueError( + "grpc:// OTLP export is insecure and is allowed only for local " + "development endpoints; use grpcs:// or https:// for remote collectors" + ) + return parsed.netloc, True + + raise ValueError( + f"Unsupported OTLP exporter endpoint scheme {parsed.scheme!r}; " + "use https://, http://, grpcs://, grpc://localhost, or a bare secure gRPC host:port" + ) + + +def _validate_http_exporter_endpoint(endpoint: str) -> None: + parsed = urlparse(endpoint.strip()) + if parsed.username or parsed.password: + raise ValueError("OTLP exporter endpoints must not include username or password") + if parsed.scheme.lower() != "http": + return + if _is_local_export_host(parsed.hostname): + return + raise ValueError( + "http:// OTLP export is insecure and is allowed only for local " + "development endpoints; use https:// for remote collectors" + ) + class MetricsWrapper(object): resource_attributes: Dict[Any, Any] = {} @@ -63,13 +117,15 @@ def set_static_params( def init_metrics_exporter(endpoint: str, headers: Dict[str, str]) -> MetricExporter: trimmed_endpoint = endpoint.strip() - if urlparse(trimmed_endpoint).scheme.lower() in {"http", "https"}: + scheme = urlparse(trimmed_endpoint).scheme.lower() + if scheme in {"http", "https"}: + _validate_http_exporter_endpoint(trimmed_endpoint) base_url = trimmed_endpoint.rstrip("/") if not base_url.endswith("/v1/metrics"): base_url = f"{base_url}/v1/metrics" return HTTPExporter(endpoint=base_url, headers=headers) - else: - return GRPCExporter(endpoint=trimmed_endpoint, headers=headers) + grpc_endpoint, insecure = _resolve_grpc_exporter_endpoint(trimmed_endpoint) + return GRPCExporter(endpoint=grpc_endpoint, headers=headers, insecure=insecure) def init_metrics_provider( diff --git a/src/fortifyroot/_vendor/tracer/sdk/tracing/tracing.py b/src/fortifyroot/_vendor/tracer/sdk/tracing/tracing.py index 4533dd9..8db71fc 100644 --- a/src/fortifyroot/_vendor/tracer/sdk/tracing/tracing.py +++ b/src/fortifyroot/_vendor/tracer/sdk/tracing/tracing.py @@ -3,6 +3,7 @@ # Original source: https://github.com/traceloop/openllmetry import atexit +import ipaddress import logging import os from urllib.parse import urlparse @@ -44,6 +45,7 @@ TRACER_NAME = "fortifyroot.tracer" +LOCAL_EXPORT_HOSTS = {"localhost"} EXCLUDED_URLS = """ iam.cloud.ibm.com, dataplatform.cloud.ibm.com, @@ -65,6 +67,57 @@ openaipublic.blob.core.windows.net""" +def _is_local_export_host(host: Optional[str]) -> bool: + if not host: + return False + normalized = host.strip().lower() + if normalized in LOCAL_EXPORT_HOSTS: + return True + try: + return ipaddress.ip_address(normalized).is_loopback + except ValueError: + return False + + +def _resolve_grpc_exporter_endpoint(api_endpoint: str) -> tuple[str, bool]: + trimmed_endpoint = api_endpoint.strip() + if "://" not in trimmed_endpoint: + return trimmed_endpoint, False + + parsed = urlparse(trimmed_endpoint) + if parsed.username or parsed.password: + raise ValueError("OTLP exporter endpoints must not include username or password") + scheme = parsed.scheme.lower() + if scheme == "grpcs": + return parsed.netloc, False + if scheme == "grpc": + if not _is_local_export_host(parsed.hostname): + raise ValueError( + "grpc:// OTLP export is insecure and is allowed only for local " + "development endpoints; use grpcs:// or https:// for remote collectors" + ) + return parsed.netloc, True + + raise ValueError( + f"Unsupported OTLP exporter endpoint scheme {parsed.scheme!r}; " + "use https://, http://, grpcs://, grpc://localhost, or a bare secure gRPC host:port" + ) + + +def _validate_http_exporter_endpoint(api_endpoint: str) -> None: + parsed = urlparse(api_endpoint.strip()) + if parsed.username or parsed.password: + raise ValueError("OTLP exporter endpoints must not include username or password") + if parsed.scheme.lower() != "http": + return + if _is_local_export_host(parsed.hostname): + return + raise ValueError( + "http:// OTLP export is insecure and is allowed only for local " + "development endpoints; use https:// for remote collectors" + ) + + class TracerWrapper(object): resource_attributes: dict = {} enable_content_tracing: bool = True @@ -334,9 +387,9 @@ def init_spans_exporter(api_endpoint: str, headers: Dict[str, str]) -> SpanExpor Supported schemes: - http:// or https:// → HTTP exporter - - grpc:// → gRPC exporter (insecure) + - grpc://localhost → gRPC exporter (insecure, local development only) - grpcs:// → gRPC exporter (secure/TLS) - - No scheme → gRPC exporter (insecure, for backward compatibility) + - No scheme → gRPC exporter (secure/TLS) Args: api_endpoint: The endpoint URL (with or without scheme) @@ -349,6 +402,7 @@ def init_spans_exporter(api_endpoint: str, headers: Dict[str, str]) -> SpanExpor match parsed.scheme.lower(): case "http" | "https": + _validate_http_exporter_endpoint(api_endpoint) base_url = api_endpoint.strip().rstrip('/') if not base_url.endswith('/v1/traces'): endpoint = f"{base_url}/v1/traces" @@ -356,17 +410,19 @@ def init_spans_exporter(api_endpoint: str, headers: Dict[str, str]) -> SpanExpor endpoint = base_url return HTTPExporter(endpoint=endpoint, headers=headers) case "grpc": + grpc_endpoint, insecure = _resolve_grpc_exporter_endpoint(api_endpoint) return GRPCExporter( - endpoint=parsed.netloc, headers=headers, insecure=True + endpoint=grpc_endpoint, headers=headers, insecure=insecure ) case "grpcs": + grpc_endpoint, insecure = _resolve_grpc_exporter_endpoint(api_endpoint) return GRPCExporter( - endpoint=parsed.netloc, headers=headers, insecure=False + endpoint=grpc_endpoint, headers=headers, insecure=insecure ) case _: - # No scheme → default to insecure gRPC for backward compatibility + grpc_endpoint, insecure = _resolve_grpc_exporter_endpoint(api_endpoint) return GRPCExporter( - endpoint=api_endpoint.strip(), headers=headers, insecure=True + endpoint=grpc_endpoint, headers=headers, insecure=insecure ) diff --git a/src/fortifyroot/core.py b/src/fortifyroot/core.py index 56ba0b1..1112532 100644 --- a/src/fortifyroot/core.py +++ b/src/fortifyroot/core.py @@ -12,6 +12,7 @@ import platform import sys import uuid +import ipaddress from urllib.parse import urlsplit from typing import Callable, Dict, List, Optional, Set, TypedDict, cast @@ -72,6 +73,7 @@ FORTIFYROOT_SDK_LANGUAGE_HEADER.lower(), FORTIFYROOT_SDK_LANGUAGE_VERSION_HEADER.lower(), } +LOCAL_EXPORT_HOSTS = {"localhost"} def _get_process_service_instance_id() -> str: @@ -305,6 +307,59 @@ def _normalize_http_otlp_endpoint(endpoint: str, suffix: str) -> str: return normalized +def _is_local_export_host(host: Optional[str]) -> bool: + if not host: + return False + normalized = host.strip().lower() + if normalized in LOCAL_EXPORT_HOSTS: + return True + try: + return ipaddress.ip_address(normalized).is_loopback + except ValueError: + return False + + +def _resolve_grpc_exporter_endpoint(endpoint: str) -> tuple[str, bool]: + """Return (grpc_endpoint, insecure) for supported gRPC endpoint forms.""" + trimmed = endpoint.strip() + if "://" not in trimmed: + return trimmed, False + + parsed = urlsplit(trimmed) + if parsed.username or parsed.password: + raise ValueError("OTLP exporter endpoints must not include username or password") + scheme = parsed.scheme.lower() + if scheme == "grpcs": + return parsed.netloc, False + if scheme == "grpc": + if not _is_local_export_host(parsed.hostname): + raise ValueError( + "grpc:// OTLP export is insecure and is allowed only for local " + "development endpoints; use grpcs:// or https:// for remote collectors" + ) + return parsed.netloc, True + + raise ValueError( + f"Unsupported OTLP exporter endpoint scheme {parsed.scheme!r}; " + "use https://, http://, grpcs://, grpc://localhost, or a bare secure gRPC host:port" + ) + + +def _validate_http_exporter_endpoint(endpoint: str) -> None: + """Reject plaintext HTTP export except for local development collectors.""" + parsed = urlsplit(endpoint.strip()) + if parsed.username or parsed.password: + raise ValueError("OTLP exporter endpoints must not include username or password") + if parsed.scheme.lower() != "http": + return + if _is_local_export_host(parsed.hostname): + return + raise ValueError( + "http:// OTLP export is insecure and is allowed only for local " + "development endpoints; use https:// for remote collectors" + ) + + def _cumulative_preferred_temporality() -> Dict[type, "AggregationTemporality"]: """Force CUMULATIVE temporality for Counter / Histogram / ObservableCounter. @@ -353,6 +408,7 @@ def _init_default_metrics_exporter( OTLPMetricExporter as HTTPMetricExporter, ) + _validate_http_exporter_endpoint(endpoint) return cast( MetricExporter, HTTPMetricExporter( @@ -366,12 +422,13 @@ def _init_default_metrics_exporter( OTLPMetricExporter as GRPCMetricExporter, ) + grpc_endpoint, insecure = _resolve_grpc_exporter_endpoint(endpoint) return cast( MetricExporter, GRPCMetricExporter( - endpoint=parsed.netloc, + endpoint=grpc_endpoint, headers=headers, - insecure=True, + insecure=insecure, preferred_temporality=preferred_temporality, ), ) @@ -380,12 +437,13 @@ def _init_default_metrics_exporter( OTLPMetricExporter as GRPCMetricExporter, ) + grpc_endpoint, insecure = _resolve_grpc_exporter_endpoint(endpoint) return cast( MetricExporter, GRPCMetricExporter( - endpoint=parsed.netloc, + endpoint=grpc_endpoint, headers=headers, - insecure=False, + insecure=insecure, preferred_temporality=preferred_temporality, ), ) @@ -394,12 +452,13 @@ def _init_default_metrics_exporter( OTLPMetricExporter as GRPCMetricExporter, ) + grpc_endpoint, insecure = _resolve_grpc_exporter_endpoint(endpoint) return cast( MetricExporter, GRPCMetricExporter( - endpoint=endpoint.strip(), + endpoint=grpc_endpoint, headers=headers, - insecure=True, + insecure=insecure, preferred_temporality=preferred_temporality, ), ) @@ -418,6 +477,7 @@ def _init_default_logging_exporter( OTLPLogExporter as HTTPLogExporter, ) + _validate_http_exporter_endpoint(endpoint) return cast( LogExporter, HTTPLogExporter( @@ -430,12 +490,13 @@ def _init_default_logging_exporter( OTLPLogExporter as GRPCLogExporter, ) + grpc_endpoint, insecure = _resolve_grpc_exporter_endpoint(endpoint) return cast( LogExporter, GRPCLogExporter( - endpoint=parsed.netloc, + endpoint=grpc_endpoint, headers=headers, - insecure=True, + insecure=insecure, ), ) case "grpcs": @@ -443,12 +504,13 @@ def _init_default_logging_exporter( OTLPLogExporter as GRPCLogExporter, ) + grpc_endpoint, insecure = _resolve_grpc_exporter_endpoint(endpoint) return cast( LogExporter, GRPCLogExporter( - endpoint=parsed.netloc, + endpoint=grpc_endpoint, headers=headers, - insecure=False, + insecure=insecure, ), ) case _: @@ -456,12 +518,13 @@ def _init_default_logging_exporter( OTLPLogExporter as GRPCLogExporter, ) + grpc_endpoint, insecure = _resolve_grpc_exporter_endpoint(endpoint) return cast( LogExporter, GRPCLogExporter( - endpoint=endpoint.strip(), + endpoint=grpc_endpoint, headers=headers, - insecure=True, + insecure=insecure, ), ) @@ -487,6 +550,13 @@ def _validate_default_export_auth( metrics_enabled = _is_enabled_from_env("FORTIFYROOT_METRICS_ENABLED", True) logging_enabled = _is_enabled_from_env("FORTIFYROOT_LOGGING_ENABLED", False) + if exporter is None and processors is None: + _validate_http_exporter_endpoint(trace_endpoint) + if exporter is None and metrics_exporter is None and metrics_enabled: + _validate_http_exporter_endpoint(metrics_endpoint) + if exporter is None and logging_exporter is None and logging_enabled: + _validate_http_exporter_endpoint(logging_endpoint) + missing_signals: List[str] = [] if ( exporter is None @@ -947,8 +1017,7 @@ class _TraceloopOptionalInitKwargs(TypedDict, total=False): # propagator=propagator, **tl_kwargs, ) - if allow_udf_detectors: - set_udf_detectors_enabled(True) + set_udf_detectors_enabled(allow_udf_detectors) configure_global_safety_runtime( enabled=enabled, diff --git a/tests/test_init.py b/tests/test_init.py index cc1e940..c029425 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -728,7 +728,7 @@ def test_init_uses_grpc_logging_exporter_for_grpc_logging_endpoint(self): os.environ, { "FORTIFYROOT_LOGGING_ENABLED": "true", - "FORTIFYROOT_LOGGING_ENDPOINT": "grpc://logs.example.com:4317", + "FORTIFYROOT_LOGGING_ENDPOINT": "grpc://localhost:4317", }, clear=False, ): @@ -754,7 +754,7 @@ def test_init_uses_grpc_logging_exporter_for_grpc_logging_endpoint(self): ) logging_exporter_cls.assert_called_once_with( - endpoint="logs.example.com:4317", + endpoint="localhost:4317", headers={"Authorization": "Bearer fr-key"}, insecure=True, ) @@ -1097,8 +1097,8 @@ def test_appends_suffix_when_missing(self): class TestMetricsExporterSchemes: """Tests for _init_default_metrics_exporter scheme branches.""" - def test_grpc_scheme_creates_insecure_grpc_metrics_exporter(self): - """Test that grpc:// scheme creates an insecure gRPC metrics exporter.""" + def test_local_grpc_scheme_creates_insecure_grpc_metrics_exporter(self): + """Test that grpc:// is allowed for local development endpoints.""" from fortifyroot.ocelle import init default_processor = mock.Mock() @@ -1108,7 +1108,7 @@ def test_grpc_scheme_creates_insecure_grpc_metrics_exporter(self): os.environ, { "FORTIFYROOT_METRICS_ENABLED": "true", - "FORTIFYROOT_METRICS_ENDPOINT": "grpc://metrics.example.com:4317", + "FORTIFYROOT_METRICS_ENDPOINT": "grpc://localhost:4317", }, clear=False, ): @@ -1130,24 +1130,23 @@ def test_grpc_scheme_creates_insecure_grpc_metrics_exporter(self): ) metric_exporter_cls.assert_called_once_with( - endpoint="metrics.example.com:4317", + endpoint="localhost:4317", headers={"Authorization": "Bearer fr-key"}, insecure=True, preferred_temporality=_EXPECTED_TEMPORALITY, ) - def test_unknown_scheme_creates_insecure_grpc_metrics_exporter(self): - """Test that an unknown scheme falls back to insecure gRPC metrics exporter.""" + def test_remote_grpc_scheme_is_rejected_for_metrics_exporter(self): + """Test that plaintext gRPC is rejected for remote metrics endpoints.""" from fortifyroot.ocelle import init default_processor = mock.Mock() - created_metrics_exporter = mock.Mock() with mock.patch.dict( os.environ, { "FORTIFYROOT_METRICS_ENABLED": "true", - "FORTIFYROOT_METRICS_ENDPOINT": "custom://metrics.example.com:4317", + "FORTIFYROOT_METRICS_ENDPOINT": "grpc://metrics.example.com:4317", }, clear=False, ): @@ -1158,22 +1157,40 @@ def test_unknown_scheme_creates_insecure_grpc_metrics_exporter(self): ), mock.patch("fortifyroot.core.Traceloop.init"), mock.patch("fortifyroot.core.configure_global_safety_runtime"), - mock.patch( - "opentelemetry.exporter.otlp.proto.grpc.metric_exporter.OTLPMetricExporter", - return_value=created_metrics_exporter, - ) as metric_exporter_cls, ): - init( - app_name="fortifyroot-test", - api_key="fr-key", - ) + with pytest.raises(ValueError, match="grpc:// OTLP export is insecure"): + init( + app_name="fortifyroot-test", + api_key="fr-key", + ) - metric_exporter_cls.assert_called_once_with( - endpoint="custom://metrics.example.com:4317", - headers={"Authorization": "Bearer fr-key"}, - insecure=True, - preferred_temporality=_EXPECTED_TEMPORALITY, - ) + def test_unknown_scheme_rejected_for_metrics_exporter(self): + """Test that unknown schemes fail fast instead of falling back to gRPC.""" + from fortifyroot.ocelle import init + + default_processor = mock.Mock() + + with mock.patch.dict( + os.environ, + { + "FORTIFYROOT_METRICS_ENABLED": "true", + "FORTIFYROOT_METRICS_ENDPOINT": "custom://metrics.example.com:4317", + }, + clear=False, + ): + with ( + mock.patch( + "fortifyroot.core.Traceloop.get_default_span_processor", + return_value=default_processor, + ), + mock.patch("fortifyroot.core.Traceloop.init"), + mock.patch("fortifyroot.core.configure_global_safety_runtime"), + ): + with pytest.raises(ValueError, match="Unsupported OTLP exporter endpoint scheme"): + init( + app_name="fortifyroot-test", + api_key="fr-key", + ) class TestLoggingExporterSchemes: @@ -1221,12 +1238,11 @@ def test_grpcs_scheme_creates_secure_grpc_logging_exporter(self): insecure=False, ) - def test_unknown_scheme_creates_insecure_grpc_logging_exporter(self): - """Test that an unknown scheme falls back to insecure gRPC logging exporter.""" + def test_unknown_scheme_rejected_for_logging_exporter(self): + """Test that unknown schemes fail fast instead of falling back to gRPC.""" from fortifyroot.ocelle import init default_processor = mock.Mock() - created_logging_exporter = mock.Mock() with mock.patch.dict( os.environ, @@ -1243,30 +1259,93 @@ def test_unknown_scheme_creates_insecure_grpc_logging_exporter(self): ), mock.patch("fortifyroot.core.Traceloop.init"), mock.patch("fortifyroot.core.configure_global_safety_runtime"), - mock.patch( - "opentelemetry.exporter.otlp.proto.grpc._log_exporter.OTLPLogExporter", - return_value=created_logging_exporter, - ) as logging_exporter_cls, mock.patch( "fortifyroot.core._init_default_metrics_exporter", return_value=mock.Mock(), ), ): - init( - app_name="fortifyroot-test", - api_key="fr-key", - ) - - logging_exporter_cls.assert_called_once_with( - endpoint="custom://logs.example.com:4317", - headers={"Authorization": "Bearer fr-key"}, - insecure=True, - ) + with pytest.raises(ValueError, match="Unsupported OTLP exporter endpoint scheme"): + init( + app_name="fortifyroot-test", + api_key="fr-key", + ) class TestValidateDefaultExportAuth: """Tests for _validate_default_export_auth edge cases.""" + def test_rejects_plaintext_http_for_remote_trace_endpoint(self): + """Default trace export must not send bearer auth over remote HTTP.""" + from fortifyroot.ocelle import init + + with mock.patch.dict( + os.environ, + { + "FORTIFYROOT_METRICS_ENABLED": "false", + "FORTIFYROOT_LOGGING_ENABLED": "false", + }, + clear=False, + ): + with ( + mock.patch("fortifyroot.core.Traceloop.get_default_span_processor"), + mock.patch("fortifyroot.core.Traceloop.init"), + mock.patch("fortifyroot.core.configure_global_safety_runtime"), + ): + with pytest.raises(ValueError, match="http:// OTLP export is insecure"): + init( + app_name="fortifyroot-test", + api_endpoint="http://api.fortifyroot.com", + api_key="fr-key", + ) + + def test_rejects_plaintext_http_for_remote_metrics_endpoint(self): + """Per-signal metrics endpoint must not silently downgrade to HTTP.""" + from fortifyroot.ocelle import init + + with mock.patch.dict( + os.environ, + { + "FORTIFYROOT_METRICS_ENABLED": "true", + "FORTIFYROOT_LOGGING_ENABLED": "false", + "FORTIFYROOT_METRICS_ENDPOINT": "http://metrics.example.com:4318", + }, + clear=False, + ): + with ( + mock.patch("fortifyroot.core.Traceloop.get_default_span_processor"), + mock.patch("fortifyroot.core.Traceloop.init"), + mock.patch("fortifyroot.core.configure_global_safety_runtime"), + ): + with pytest.raises(ValueError, match="http:// OTLP export is insecure"): + init( + app_name="fortifyroot-test", + api_key="fr-key", + ) + + def test_rejects_endpoint_userinfo(self): + """Endpoint URLs must not carry credentials in userinfo.""" + from fortifyroot.ocelle import init + + with mock.patch.dict( + os.environ, + { + "FORTIFYROOT_METRICS_ENABLED": "false", + "FORTIFYROOT_LOGGING_ENABLED": "false", + }, + clear=False, + ): + with ( + mock.patch("fortifyroot.core.Traceloop.get_default_span_processor"), + mock.patch("fortifyroot.core.Traceloop.init"), + mock.patch("fortifyroot.core.configure_global_safety_runtime"), + ): + with pytest.raises(ValueError, match="must not include username or password"): + init( + app_name="fortifyroot-test", + api_endpoint="https://user:secret@api.fortifyroot.com", + api_key="fr-key", + ) + def test_requires_auth_for_managed_fortifyroot_logging_endpoint(self): """Test that FortifyRoot Ocelle logging export requires auth.""" from fortifyroot.ocelle import init @@ -1414,6 +1493,30 @@ def test_init_allow_udf_detectors_via_env_var(self): assert safety_engine._udf_detectors_enabled is True + def test_init_without_udf_detectors_disables_previous_opt_in(self): + """Reinitializing with defaults must restore the safe UDF setting.""" + from fortifyroot.ocelle import init + from fortifyroot._internal.safety import engine as safety_engine + + with ( + mock.patch("fortifyroot.core.Traceloop.get_default_span_processor", return_value=mock.Mock()), + mock.patch("fortifyroot.core.Traceloop.init"), + mock.patch("fortifyroot.core.configure_global_safety_runtime"), + ): + init( + app_name="fortifyroot-test", + api_key="fr-key", + allow_udf_detectors=True, + ) + assert safety_engine._udf_detectors_enabled is True + + init( + app_name="fortifyroot-test", + api_key="fr-key", + ) + + assert safety_engine._udf_detectors_enabled is False + class TestSetAssociationProperties: """Tests for set_association_properties (line 723 coverage).""" diff --git a/tests/test_metrics_temporality.py b/tests/test_metrics_temporality.py index 933bafd..e8f566f 100644 --- a/tests/test_metrics_temporality.py +++ b/tests/test_metrics_temporality.py @@ -12,9 +12,9 @@ Fix (in ``fortifyroot.core``): ``_init_default_metrics_exporter`` now builds a ``preferred_temporality`` mapping via ``_cumulative_preferred_temporality`` that pins every numeric instrument -to CUMULATIVE, and threads it into all four construction paths (HTTP, -gRPC insecure via scheme=``grpc``, gRPC secure via scheme=``grpcs``, -fallback gRPC for unknown schemes). +to CUMULATIVE, and threads it into every supported construction path (HTTP, +local-dev gRPC via scheme=``grpc``, secure gRPC via scheme=``grpcs``, and +bare secure gRPC host:port endpoints). These tests lock the fix in place so a future refactor cannot silently regress back to the library default. If a legitimate reason arises to relax @@ -111,15 +111,28 @@ def test_https_scheme_passes_cumulative_mapping(self): _, kwargs = cls.call_args assert kwargs["preferred_temporality"] == _cumulative_preferred_temporality() - def test_grpc_insecure_scheme_passes_cumulative_mapping(self): + def test_local_grpc_insecure_scheme_passes_cumulative_mapping(self): with mock.patch( "opentelemetry.exporter.otlp.proto.grpc.metric_exporter.OTLPMetricExporter" ) as cls: _init_default_metrics_exporter( - "grpc://metrics.example.com:4317", + "grpc://localhost:4317", + headers={"Authorization": "Bearer key"}, + ) + _, kwargs = cls.call_args + assert kwargs["preferred_temporality"] == _cumulative_preferred_temporality() + assert kwargs["insecure"] is True + + def test_ipv6_loopback_grpc_insecure_scheme_passes_cumulative_mapping(self): + with mock.patch( + "opentelemetry.exporter.otlp.proto.grpc.metric_exporter.OTLPMetricExporter" + ) as cls: + _init_default_metrics_exporter( + "grpc://[::1]:4317", headers={"Authorization": "Bearer key"}, ) _, kwargs = cls.call_args + assert kwargs["endpoint"] == "[::1]:4317" assert kwargs["preferred_temporality"] == _cumulative_preferred_temporality() assert kwargs["insecure"] is True @@ -135,17 +148,61 @@ def test_grpcs_secure_scheme_passes_cumulative_mapping(self): assert kwargs["preferred_temporality"] == _cumulative_preferred_temporality() assert kwargs["insecure"] is False - def test_unknown_scheme_falls_back_to_grpc_with_cumulative(self): - """Fallback path for bare hostnames or uncommon schemes.""" + def test_bare_endpoint_uses_secure_grpc_with_cumulative(self): + """Bare host:port remains supported but is secure by default.""" with mock.patch( "opentelemetry.exporter.otlp.proto.grpc.metric_exporter.OTLPMetricExporter" ) as cls: _init_default_metrics_exporter( - "otlp://metrics.example.com:4317", + "metrics.example.com:4317", headers={"Authorization": "Bearer key"}, ) _, kwargs = cls.call_args assert kwargs["preferred_temporality"] == _cumulative_preferred_temporality() + assert kwargs["endpoint"] == "metrics.example.com:4317" + assert kwargs["insecure"] is False + + def test_remote_grpc_insecure_scheme_is_rejected(self): + with pytest.raises(ValueError, match="grpc:// OTLP export is insecure"): + _init_default_metrics_exporter( + "grpc://metrics.example.com:4317", + headers={"Authorization": "Bearer key"}, + ) + + def test_unknown_scheme_is_rejected(self): + with pytest.raises(ValueError, match="Unsupported OTLP exporter endpoint scheme"): + _init_default_metrics_exporter( + "otlp://metrics.example.com:4317", + headers={"Authorization": "Bearer key"}, + ) + + def test_remote_plaintext_http_is_rejected(self): + with pytest.raises(ValueError, match="http:// OTLP export is insecure"): + _init_default_metrics_exporter( + "http://metrics.example.com:4318", + headers={"Authorization": "Bearer key"}, + ) + + def test_host_docker_internal_plaintext_http_is_rejected(self): + with pytest.raises(ValueError, match="http:// OTLP export is insecure"): + _init_default_metrics_exporter( + "http://host.docker.internal:4318", + headers={"Authorization": "Bearer key"}, + ) + + def test_host_docker_internal_plaintext_grpc_is_rejected(self): + with pytest.raises(ValueError, match="grpc:// OTLP export is insecure"): + _init_default_metrics_exporter( + "grpc://host.docker.internal:4317", + headers={"Authorization": "Bearer key"}, + ) + + def test_endpoint_userinfo_is_rejected(self): + with pytest.raises(ValueError, match="must not include username or password"): + _init_default_metrics_exporter( + "https://user:secret@metrics.example.com:4318", + headers={"Authorization": "Bearer key"}, + ) # --------------------------------------------------------------------------- diff --git a/tests/test_safety_engine.py b/tests/test_safety_engine.py index 6aa0f0e..1c2c1d2 100644 --- a/tests/test_safety_engine.py +++ b/tests/test_safety_engine.py @@ -829,6 +829,50 @@ def test_compile_snapshot_skips_oversized_regex_patterns_and_caps_regex_matches( assert result.overall_action == SafetyDecision.ALLOW.value +def test_regex_match_cap_does_not_materialize_all_matches(): + class FakeMatch: + def __init__(self, index: int): + self._index = index + + def start(self): + return self._index + + def end(self): + return self._index + 1 + + class FakePattern: + def __init__(self): + self.requested = 0 + + def finditer(self, text): + del text + for index in range(safety_engine.MAX_REGEX_MATCHES + 1): + self.requested += 1 + if self.requested > safety_engine.MAX_REGEX_MATCHES: + raise AssertionError("regex evaluator pulled past the match cap") + yield FakeMatch(index) + + pattern = FakePattern() + rule = CompiledSafetyRule( + name="near_universal", + category="SECRET", + severity="LOW", + action="MASK", + regex=pattern, + list_values=(), + list_ignore_case=False, + udf_detector=None, + ) + + findings = safety_engine._evaluate_rule( + rule, + "x" * (safety_engine.MAX_REGEX_MATCHES + 1), + ) + + assert len(findings) == safety_engine.MAX_REGEX_MATCHES + assert pattern.requested == safety_engine.MAX_REGEX_MATCHES + + def test_engine_records_rule_evaluation_mask_and_udf_error_metrics(): rules_counter = mock.Mock() findings_counter = mock.Mock() @@ -1280,28 +1324,69 @@ def test_load_detector_returns_none_for_missing_module_when_enabled(self, caplog # ---- D-18: ReDoS residual - re2 with timeout fallback ---- -class TestRegexTimeoutFallback: - """Tests for _safe_finditer and the re2/stdlib fallback logic.""" +class TestRegexRe2Requirement: + """Tests for the ReDoS-safe google-re2 requirement.""" - def test_safe_finditer_returns_matches_with_stdlib_re(self): - """_safe_finditer should return matches when using stdlib re.""" - import re + def test_compile_snapshot_skips_regex_rules_when_re2_is_unavailable(self, caplog): + """Regex rules are skipped instead of falling back to unsafe stdlib re.""" + payload = { + "config_profile": { + "id": "cfg-no-re2", + "version": 1, + "etag": "etag-no-re2", + "config": { + "safety_config": { + "enabled": True, + "default_action": "MASK", + "rules": [ + { + "name": "email", + "category": "PII", + "severity": "HIGH", + "enabled": True, + "regex": r"[a-z]+@[a-z]+\.com", + }, + { + "name": "secret_word", + "category": "SECRET", + "severity": "HIGH", + "enabled": True, + "list": {"values": ["secret"], "ignore_case": True}, + }, + ], + } + }, + } + } - from fortifyroot._internal.safety.engine import _safe_finditer + parsed = parse_sdk_config_response(payload) - pattern = re.compile(r"\d+") - matches = _safe_finditer(pattern, "abc 123 def 456", "test_rule") - assert len(matches) == 2 - assert matches[0].group() == "123" - assert matches[1].group() == "456" - - def test_safe_finditer_returns_empty_on_timeout(self, caplog): - """_safe_finditer should raise _RegexTimeoutError on timeout and log a warning.""" - import concurrent.futures + with ( + caplog.at_level(logging.ERROR), + mock.patch("fortifyroot._internal.safety.engine._USING_RE2", False), + mock.patch("fortifyroot._internal.safety.engine._re_engine", None), + mock.patch( + "fortifyroot._internal.safety.engine._REGEX_RULES_SKIPPED_COUNTER" + ) as skipped_counter, + ): + snapshot = compile_snapshot(parsed.config_profile) + + assert [rule.name for rule in snapshot.rules] == ["secret_word"] + assert any("regex-based safety masking is degraded" in record.message for record in caplog.records) + skipped_counter.add.assert_called_once_with( + 1, + attributes={ + "reason": "re2_unavailable", + "category": "PII", + "severity": "HIGH", + }, + ) + + def test_safe_finditer_refuses_stdlib_regex_when_re2_is_unavailable(self, caplog): + """_safe_finditer raises without executing stdlib regex patterns.""" import re from fortifyroot._internal.safety.engine import ( - REGEX_EVAL_TIMEOUT_SECONDS, _RegexTimeoutError, _safe_finditer, ) @@ -1309,23 +1394,20 @@ def test_safe_finditer_returns_empty_on_timeout(self, caplog): pattern = re.compile(r"\d+") with ( - caplog.at_level(logging.WARNING), + caplog.at_level(logging.ERROR), mock.patch("fortifyroot._internal.safety.engine._USING_RE2", False), mock.patch( - "fortifyroot._internal.safety.engine.concurrent.futures.ThreadPoolExecutor" - ) as mock_pool_cls, + "fortifyroot._internal.safety.engine._REGEX_RULES_SKIPPED_COUNTER" + ) as skipped_counter, ): - mock_pool = mock.MagicMock() - mock_pool_cls.return_value.__enter__ = mock.Mock(return_value=mock_pool) - mock_pool_cls.return_value.__exit__ = mock.Mock(return_value=False) - mock_future = mock.Mock() - mock_future.result.side_effect = concurrent.futures.TimeoutError() - mock_pool.submit.return_value = mock_future - with pytest.raises(_RegexTimeoutError): _safe_finditer(pattern, "abc 123", "slow_rule") - assert any("timed out" in record.message for record in caplog.records) + assert any("regex-based safety masking is degraded" in record.message for record in caplog.records) + skipped_counter.add.assert_called_once_with( + 1, + attributes={"reason": "re2_unavailable_runtime"}, + ) def test_evaluate_rule_returns_empty_on_regex_timeout(self): """D-18: When regex evaluation times out, _evaluate_rule returns empty findings.""" @@ -1362,13 +1444,6 @@ def test_using_re2_flag_and_re_engine(self): assert _USING_RE2 is True assert _re_engine is re2 - def test_regex_eval_timeout_constant_exists(self): - """REGEX_EVAL_TIMEOUT_SECONDS should be 5.""" - from fortifyroot._internal.safety.engine import REGEX_EVAL_TIMEOUT_SECONDS - - assert REGEX_EVAL_TIMEOUT_SECONDS == 5 - - # ---- D-20: No match count cap for string list matcher ---- diff --git a/tests/test_safety_runtime.py b/tests/test_safety_runtime.py index f27a9c1..cd34c9f 100644 --- a/tests/test_safety_runtime.py +++ b/tests/test_safety_runtime.py @@ -393,8 +393,14 @@ def test_safety_runtime_starts_with_empty_snapshot_when_initial_fetch_fails(capl hdrs=None, fp=None, ) + empty_snapshot_counter = mock.Mock() with ( - caplog.at_level(logging.WARNING), + caplog.at_level(logging.ERROR), + mock.patch.object( + safety_runtime, + "_EMPTY_SNAPSHOT_START_COUNTER", + mock.Mock(add=empty_snapshot_counter), + ), mock.patch( "fortifyroot._internal.safety.runtime.urllib.request.urlopen", side_effect=error, @@ -404,6 +410,10 @@ def test_safety_runtime_starts_with_empty_snapshot_when_initial_fetch_fails(capl runtime.start() assert "Initial FortifyRoot safety config fetch failed; starting with empty safety snapshot" in caplog.text + empty_snapshot_counter.assert_called_once_with( + 1, + attributes={"reason": "SafetyConfigFetchError"}, + ) assert get_prompt_safety_handler() is not None assert get_completion_safety_handler() is not None assert get_completion_safety_stream_factory() is not None @@ -508,7 +518,7 @@ def test_configure_global_safety_runtime_skips_when_endpoint_is_not_fortifyroot( ("http://127.0.0.1:8080", True), ("http://[::1]:8080", True), ("https://localhost:8080", True), - ("http://host.docker.internal:8080", True), + ("http://host.docker.internal:8080", False), ("http://api.fortifyroot.com", False), ("https://collector.example.com", False), ("http://collector.example.com", False), diff --git a/tests/test_vendored_auth_warnings.py b/tests/test_vendored_auth_warnings.py index 0dcf6ca..407b79e 100644 --- a/tests/test_vendored_auth_warnings.py +++ b/tests/test_vendored_auth_warnings.py @@ -3,6 +3,7 @@ from fortifyroot._vendor.tracer.sdk.exporters.auth_warnings import ( _AuthWarningClientProxy, + _endpoint_label, reset_auth_warning_state_for_tests, ) @@ -83,6 +84,21 @@ def test_vendored_http_exporters_warn_on_auth_failure_once( assert "Telemetry will not reach FortifyRoot" not in warning +def test_vendored_endpoint_label_strips_userinfo(): + assert _endpoint_label("https://user:secret@collector.example.com:4318/v1/traces") == ( + "collector.example.com:4318" + ) + + +def test_vendored_endpoint_label_formats_ipv6_host(): + assert _endpoint_label("grpcs://[::1]:4317") == "[::1]:4317" + + +def test_vendored_endpoint_label_handles_invalid_port(): + endpoint = "https://collector.example.com:99999/v1/traces" + assert _endpoint_label(endpoint) == endpoint + + def test_vendored_grpc_exporter_warns_on_auth_failure(caplog): reset_auth_warning_state_for_tests() diff --git a/tests/test_vendored_imports.py b/tests/test_vendored_imports.py index 14a4f86..0558e88 100644 --- a/tests/test_vendored_imports.py +++ b/tests/test_vendored_imports.py @@ -243,6 +243,9 @@ def test_vendor_manifest_exists(self): manifest = json.loads(manifest_path.read_text()) assert "openllmetry_version" in manifest assert "packages" in manifest + if manifest.get("git_dirty"): + assert manifest.get("git_tag") == "dirty-working-tree" + assert manifest.get("git_base_tag") class TestFluentApi: