secureapp-python-agent 26.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: secureapp-python-agent
3
+ Version: 26.5.0
4
+ Summary: Splunk SecureApp OpenTelemetry Extension for Python applications
5
+ Author-email: Splunk <support@splunk.com>
6
+ License-Expression: LicenseRef-Proprietary
7
+ Keywords: observability,opentelemetry,security,monitoring,splunk
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Topic :: System :: Monitoring
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: opentelemetry-api<1.40,>=1.27.0
23
+ Requires-Dist: opentelemetry-sdk<1.40,>=1.27.0
24
+ Requires-Dist: opentelemetry-exporter-otlp<1.40,>=1.27.0
25
+ Requires-Dist: protobuf>=5.0; python_version >= "3.14"
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8.4.1; extra == "dev"
28
+ Requires-Dist: pytest-cov>=6.2.1; extra == "dev"
29
+ Requires-Dist: pytest-testmon>=2.1.3; extra == "dev"
30
+ Requires-Dist: mypy>=1.16.1; extra == "dev"
31
+ Requires-Dist: ruff>=0.12.1; extra == "dev"
32
+ Requires-Dist: coverage[toml]>=7.0.0; extra == "dev"
33
+ Requires-Dist: tox>=4.27.0; extra == "dev"
34
+ Requires-Dist: build>=1.2.2.post1; extra == "dev"
35
+ Requires-Dist: pip-tools>=7.4.1; extra == "dev"
36
+ Requires-Dist: setuptools-scm[toml]>=8.0.0; extra == "dev"
37
+ Requires-Dist: twine>=5.0.0; extra == "dev"
38
+ Provides-Extra: benchmark
39
+ Requires-Dist: pytest-benchmark>=4.0.0; extra == "benchmark"
40
+ Requires-Dist: memory-profiler>=0.61.0; extra == "benchmark"
41
+ Requires-Dist: psutil>=5.9.0; extra == "benchmark"
42
+ Requires-Dist: memray>=1.10.0; extra == "benchmark"
43
+ Provides-Extra: all
44
+ Requires-Dist: secureapp-python-agent[benchmark,dev]; extra == "all"
45
+ Dynamic: license-file
46
+
47
+ # Splunk SecureApp OpenTelemetry Extension
48
+
49
+ OpenTelemetry Python extension for integrating Splunk SecureApp with OpenTelemetry.
50
+ This extension monitors runtime dependencies and reports them via OpenTelemetry logs.
51
+
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ # Install from PyPI
57
+ pip install secureapp-python-agent
58
+
59
+ # Install with OpenTelemetry instrumentation
60
+ pip install secureapp-python-agent splunk-opentelemetry
61
+
62
+ # Run your application with automatic instrumentation
63
+ opentelemetry-instrument python your_app.py
64
+
65
+ ## Configuration
66
+
67
+ The extension can be configured using environment variables:
68
+
69
+ | Environment Variable | Default | Description |
70
+ |---------------------------------------------|---------|-----------------------------------------------------------|
71
+ | `SPLUNK_SECUREAPP_AGENT_ENABLED` | `true` | Enable or disable the agent completely |
72
+ | `OTEL_LOGS_EXPORTER` | `otlp` | Log exporter type: `otlp`, `console`, or `none` |
73
+ | `SPLUNK_SECUREAPP_DEPENDENCY_INITIAL_DELAY` | `60.0` | Initial delay (seconds) before dependency tracking starts |
74
+ | `SPLUNK_SECUREAPP_DEPENDENCY_SCAN_INTERVAL` | `86400` | Interval (seconds) between dependency scans (24 hours) |
75
+ | `SPLUNK_SECUREAPP_LOG_LEVEL_INFO | NOTSET | Standard Python Log Levels |
76
+
77
+ ## Features
78
+
79
+ ### Runtime Dependency Monitoring
80
+
81
+ The extension monitors third-party Python packages loaded at runtime and reports
82
+ them through OpenTelemetry logs with:
83
+ - Package name and version
84
+ - Import timestamp
85
+ - Standard library exclusion for performance optimization
86
+ - Low overhead (<10MB memory, <100ms startup impact)
87
+
88
+ ### OpenTelemetry Integration
89
+
90
+ - Sends dependency data as structured logs via configurable exporters
91
+ - Compatible with the OpenTelemetry Collector and Splunk Observability backends
92
+ - Lightweight implementation with optimized performance
93
+
94
+ ## Compatibility
95
+
96
+ ### OpenTelemetry Versions
97
+
98
+ The extension is compatible with OpenTelemetry versions 1.39.x plus
99
+
100
+ ### Python Versions
101
+
102
+ Supported Python versions:
103
+ - Python 3.10
104
+ - Python 3.11
105
+ - Python 3.12
106
+ - Python 3.13
107
+ - Python 3.14
108
+
109
+ ## Performance Considerations
110
+
111
+ The SecureApp agent is designed with minimal performance impact:
112
+ - Startup overhead: <100ms
113
+ - Memory overhead: <10MB
114
+ - Optimizations:
115
+ - Lazy imports for better startup performance
116
+ - Standard library detection to avoid unnecessary scanning
117
+ - Configurable scan intervals
118
+ - Efficient batch processing for telemetry data
119
+
@@ -0,0 +1,12 @@
1
+ secureapp_python_agent-26.5.0.dist-info/licenses/LICENSE,sha256=oOJ-AemxdpKyhlaKimR1M0xZAX0c83qdhf-JEZPx_VM,114
2
+ splunk_secureapp_opentelemetry_extension/__init__.py,sha256=-f2AjRN0PY1iU-6AWQC9K3IytpXD4D6dUYcPLGfdmIQ,975
3
+ splunk_secureapp_opentelemetry_extension/agent.py,sha256=8qk7HqM6RPz_gp8T7_s3bMk6UdbQlyO6epfR161XCew,11492
4
+ splunk_secureapp_opentelemetry_extension/dependency_analyzer.py,sha256=1L7w4kNZh-dWHAM3kJcscPyXTijexRDSrBwvEBv-sl0,19853
5
+ splunk_secureapp_opentelemetry_extension/environment_variables.py,sha256=3OWPjQZvTmOWE-rqnfJxx6i0GDyr2FuRbr9wNOAlYh4,1707
6
+ splunk_secureapp_opentelemetry_extension/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ splunk_secureapp_opentelemetry_extension/utils.py,sha256=GdVF6NkjIGn4CpQLDjh6Tfmq9hvf7s8sli9gXQKSFow,2730
8
+ secureapp_python_agent-26.5.0.dist-info/METADATA,sha256=gjmSgQII1k5ULgnaVAVt2sksg_hbcHj6zGn7VzX5zuw,4609
9
+ secureapp_python_agent-26.5.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ secureapp_python_agent-26.5.0.dist-info/entry_points.txt,sha256=4jjmIjyx4CM0dFRCha8SjG8wPcPhPM-yMunARk_FAMc,215
11
+ secureapp_python_agent-26.5.0.dist-info/top_level.txt,sha256=v4_vqQoYxJHbcukkfaEXrUfcesHkGm-6G18lxXrruyI,41
12
+ secureapp_python_agent-26.5.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,5 @@
1
+ [opentelemetry_post_instrument]
2
+ splunk_secureapp = splunk_secureapp_opentelemetry_extension:post_instrument
3
+
4
+ [opentelemetry_pre_instrument]
5
+ splunk_secureapp = splunk_secureapp_opentelemetry_extension:pre_instrument
@@ -0,0 +1,2 @@
1
+
2
+ The terms governing use of the code is at this site: https://www.splunk.com/en_us/legal/splunk-general-terms.html
@@ -0,0 +1 @@
1
+ splunk_secureapp_opentelemetry_extension
@@ -0,0 +1,38 @@
1
+ # Copyright (c) 2026 Splunk Inc.
2
+ # All rights reserved.
3
+ # This software is the proprietary information of Splunk Inc.
4
+
5
+
6
+ """Splunk SecureApp OpenTelemetry Extension for Python."""
7
+
8
+ from splunk_secureapp_opentelemetry_extension.agent import (
9
+ start_monitoring,
10
+ stop_monitoring,
11
+ )
12
+
13
+
14
+ def pre_instrument() -> None:
15
+ """OpenTelemetry pre-instrumentation hook.
16
+
17
+ Called by OpenTelemetry auto-instrumentation before instrumenting libraries.
18
+ This initializes the SecureApp agent early in the process lifecycle.
19
+ """
20
+ start_monitoring()
21
+
22
+
23
+ def post_instrument() -> None:
24
+ """OpenTelemetry post-instrumentation hook.
25
+
26
+ Called by OpenTelemetry auto-instrumentation after instrumenting libraries.
27
+ Currently a no-op since dependency monitoring is started in pre_instrument.
28
+ """
29
+ # No additional actions needed after instrumentation
30
+ pass
31
+
32
+
33
+ __all__ = [
34
+ "start_monitoring",
35
+ "stop_monitoring",
36
+ "pre_instrument",
37
+ "post_instrument",
38
+ ]
@@ -0,0 +1,304 @@
1
+ # Copyright (c) 2026 Splunk Inc.
2
+ # All rights reserved.
3
+ # This software is the proprietary information of Splunk Inc.
4
+
5
+
6
+ """Main agent module for Splunk SecureApp OpenTelemetry Extension."""
7
+
8
+ import os
9
+ import threading
10
+ from typing import TYPE_CHECKING
11
+
12
+ from opentelemetry.environment_variables import OTEL_LOGS_EXPORTER
13
+
14
+ from splunk_secureapp_opentelemetry_extension.utils import log_package_info
15
+
16
+ # Lazy imports for better startup performance - only import when needed
17
+ if TYPE_CHECKING:
18
+ from opentelemetry.sdk._logs import LoggerProvider
19
+
20
+ from splunk_secureapp_opentelemetry_extension.dependency_analyzer import (
21
+ RuntimeDependencyAnalyzer,
22
+ )
23
+
24
+ from splunk_secureapp_opentelemetry_extension import environment_variables, utils
25
+
26
+ # Performance constants for startup optimization
27
+ _SHUTDOWN_TIMEOUT_SECONDS = 5.0
28
+ _DEFAULT_EXPORTER_TYPE = "otlp"
29
+
30
+ _PROJECT_NAME = "secureapp-python-agent"
31
+
32
+ logger = utils.set_logging_for_module(__name__)
33
+
34
+
35
+ class SecureAppAgent:
36
+ """Main agent class for Splunk SecureApp OpenTelemetry Extension."""
37
+
38
+ def __init__(self) -> None:
39
+ """Initialize the SecureApp agent."""
40
+ self._initialized = False
41
+ self._dependency_thread: threading.Thread | None = None
42
+ self._stop_event: threading.Event = threading.Event()
43
+ self._logger_provider: LoggerProvider | None = None
44
+ self._dependency_analyzer: RuntimeDependencyAnalyzer | None = None
45
+
46
+ def _create_logger_provider(self) -> "LoggerProvider":
47
+ """Create and configure the OpenTelemetry logger provider.
48
+
49
+ Returns:
50
+ Configured LoggerProvider with appropriate exporter.
51
+
52
+ Raises:
53
+ ImportError: If required OpenTelemetry components are not available.
54
+ RuntimeError: If logger provider configuration fails.
55
+ """
56
+ # Lazy imports for better startup performance - only import when actually starting
57
+ from opentelemetry.sdk._logs import LoggerProvider
58
+ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, LogExporter
59
+
60
+ # Initialize SecureApp's logger provider
61
+ logger_provider = LoggerProvider()
62
+
63
+ exporter_type = os.getenv(OTEL_LOGS_EXPORTER, _DEFAULT_EXPORTER_TYPE).lower()
64
+ log_exporter: LogExporter
65
+ # Create appropriate exporter based on configuration
66
+ if exporter_type == "otlp":
67
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import (
68
+ OTLPLogExporter,
69
+ )
70
+
71
+ log_exporter = OTLPLogExporter() # type: ignore[assignment]
72
+ elif exporter_type == "console":
73
+ from opentelemetry.sdk._logs.export import ConsoleLogExporter
74
+
75
+ log_exporter = ConsoleLogExporter() # type: ignore[assignment]
76
+ else:
77
+ logger.warning(
78
+ f"Unknown {OTEL_LOGS_EXPORTER} value: {exporter_type}, defaulting to OTLP"
79
+ )
80
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import (
81
+ OTLPLogExporter,
82
+ )
83
+
84
+ log_exporter = OTLPLogExporter() # type: ignore[assignment]
85
+
86
+ logger_provider.add_log_record_processor(BatchLogRecordProcessor(log_exporter))
87
+
88
+ return logger_provider
89
+
90
+ def start(self) -> None:
91
+ """Start the SecureApp agent.
92
+
93
+ Uses the following environment variables for configuration:
94
+ - OTEL_LOGS_EXPORTER to specify the log exporter type (otlp, console, none)
95
+ - SPLUNK_SECUREAPP_DEPENDENCY_INITIAL_DELAY for initial delay before dependency tracking starts
96
+ - SPLUNK_SECUREAPP_DEPENDENCY_SCAN_INTERVAL for how often to scan dependencies
97
+
98
+ Note: SPLUNK_SECUREAPP_AGENT_ENABLED is checked at the start_monitoring() level.
99
+ """
100
+ if self._initialized:
101
+ logger.warning("Agent already initialized, skipping configuration")
102
+ return
103
+
104
+ logger.info("Starting SecureApp agent")
105
+
106
+ log_package_info(_PROJECT_NAME, logger)
107
+
108
+ try:
109
+ from splunk_secureapp_opentelemetry_extension.dependency_analyzer import (
110
+ RuntimeDependencyAnalyzer,
111
+ )
112
+
113
+ # Create and configure logger provider
114
+ self._logger_provider = self._create_logger_provider()
115
+
116
+ # Initialize dependency analyzer with our logger provider
117
+ self._dependency_analyzer = RuntimeDependencyAnalyzer(
118
+ logger_provider=self._logger_provider
119
+ )
120
+
121
+ # Start dependency tracking (always enabled when agent is enabled)
122
+ self._begin_monitoring()
123
+
124
+ self._initialized = True
125
+ logger.info("SecureApp agent started successfully")
126
+
127
+ except Exception as e:
128
+ logger.error(f"Failed to start SecureApp agent: {e}")
129
+ raise
130
+
131
+ def _begin_monitoring(self) -> None:
132
+ """Begin background dependency monitoring."""
133
+ if self._dependency_thread and self._dependency_thread.is_alive():
134
+ logger.debug("Dependency monitoring already running")
135
+ return
136
+
137
+ logger.info("Beginning dependency monitoring")
138
+ self._dependency_thread = threading.Thread(
139
+ target=self._dependency_tracking_loop,
140
+ daemon=True,
141
+ name="SecureApp-DependencyTracker",
142
+ )
143
+ self._dependency_thread.start()
144
+
145
+ def _dependency_tracking_loop(self) -> None:
146
+ """Background loop for dependency tracking with initial delay."""
147
+ if self._dependency_analyzer is None:
148
+ logger.error("Dependency analyzer not initialized")
149
+ return
150
+
151
+ initial_delay = environment_variables.SPLUNK_SECUREAPP_DEPENDENCY_INITIAL_DELAY
152
+ scan_interval = environment_variables.SPLUNK_SECUREAPP_DEPENDENCY_SCAN_INTERVAL
153
+
154
+ # Wait for initial delay to allow application initialization
155
+ logger.info(
156
+ f"Dependency tracking starting after {initial_delay}s initial delay"
157
+ )
158
+ if self._stop_event.wait(initial_delay):
159
+ # Stop event was set during initial delay
160
+ logger.info("Dependency tracking stopped during initial delay")
161
+ return
162
+
163
+ logger.info("Starting regular dependency scans")
164
+
165
+ while not self._stop_event.is_set():
166
+ try:
167
+ # Emit runtime package events using our specific analyzer
168
+ self._dependency_analyzer.emit_package_events()
169
+
170
+ except Exception as e:
171
+ logger.error(f"Error during dependency tracking: {e}")
172
+ # Add exponential backoff on errors to prevent rapid retry loops
173
+ error_backoff = min(
174
+ 60.0, scan_interval * 0.1
175
+ ) # Max 1 minute, min 10% of interval
176
+ if self._stop_event.wait(error_backoff):
177
+ break
178
+ continue
179
+
180
+ # Wait for next scan or stop event
181
+ if self._stop_event.wait(scan_interval):
182
+ # Stop event was set, exit the loop
183
+ break
184
+
185
+ logger.info("Dependency tracking stopped")
186
+
187
+ def shutdown(self) -> None:
188
+ """Shutdown the SecureApp agent and all monitoring activities.
189
+
190
+ This method follows OpenTelemetry shutdown conventions and:
191
+ - Signals the dependency tracking thread to stop
192
+ - Waits for the thread to finish gracefully
193
+ - Shuts down the logger provider and processors
194
+ - Marks the agent as uninitialized for potential restart
195
+
196
+ This method is idempotent - calling it multiple times is safe.
197
+
198
+ Raises:
199
+ Exception: Re-raises any critical errors during shutdown that prevent cleanup.
200
+ """
201
+ if not self._initialized:
202
+ logger.debug("Agent not initialized, nothing to shutdown")
203
+ return
204
+
205
+ logger.info("Shutting down SecureApp agent")
206
+
207
+ try:
208
+ # Signal stop to dependency tracking thread
209
+ self._stop_event.set()
210
+
211
+ # Wait for dependency thread to finish gracefully
212
+ if (
213
+ self._dependency_thread is not None
214
+ and self._dependency_thread.is_alive()
215
+ ):
216
+ logger.debug("Waiting for dependency tracking thread to stop")
217
+ self._dependency_thread.join(
218
+ timeout=_SHUTDOWN_TIMEOUT_SECONDS
219
+ ) # 5 second timeout
220
+ if (
221
+ self._dependency_thread is not None
222
+ and self._dependency_thread.is_alive()
223
+ ):
224
+ logger.warning("Dependency tracking thread did not stop gracefully")
225
+ # Thread is still alive, but we'll continue cleanup anyway
226
+
227
+ # Shutdown logger provider and processors
228
+ if self._logger_provider is not None:
229
+ try:
230
+ # Type check: Ensure shutdown method exists and is callable
231
+ shutdown_method = getattr(self._logger_provider, "shutdown", None)
232
+ if shutdown_method is not None and callable(shutdown_method):
233
+ shutdown_method()
234
+ else:
235
+ logger.warning("LoggerProvider does not have a shutdown method")
236
+ except Exception as e:
237
+ logger.warning(f"Error shutting down logger provider: {e}")
238
+
239
+ logger.info("SecureApp agent shutdown complete")
240
+
241
+ finally:
242
+ # Always reset state for potential restart, even if shutdown had errors
243
+ # Type safety: Explicitly set each attribute to None with correct types
244
+ self._initialized = False
245
+ self._dependency_thread = None
246
+ self._logger_provider = None
247
+ self._dependency_analyzer = None
248
+ self._stop_event.clear() # CRITICAL: Reset for potential restart
249
+
250
+
251
+ # Global agent instance
252
+ _agent: SecureAppAgent | None = None
253
+
254
+
255
+ def start_monitoring() -> None:
256
+ """Start the SecureApp agent.
257
+
258
+ This is the main public API function for starting dependency monitoring.
259
+ Can be called multiple times safely (subsequent calls are ignored).
260
+
261
+ Respects the SPLUNK_SECUREAPP_AGENT_ENABLED environment variable.
262
+ If disabled, this function returns early without creating an agent.
263
+ """
264
+ global _agent
265
+
266
+ # Check if agent should be enabled before creating it
267
+ if not environment_variables.SPLUNK_SECUREAPP_AGENT_ENABLED:
268
+ logger.info("SecureApp agent is disabled via SPLUNK_SECUREAPP_AGENT_ENABLED")
269
+ return
270
+
271
+ # Check exporter configuration first before creating any resources
272
+ exporter_type = os.getenv(OTEL_LOGS_EXPORTER, _DEFAULT_EXPORTER_TYPE).lower()
273
+ if exporter_type == "none":
274
+ # No exporter - agent will be essentially disabled for logs
275
+ logger.info(
276
+ f"SecureApp agent is disabled because Logs exporter disabled via {OTEL_LOGS_EXPORTER}=none"
277
+ )
278
+ return
279
+
280
+ if _agent is None:
281
+ _agent = SecureAppAgent()
282
+
283
+ _agent.start()
284
+
285
+
286
+ def get_agent() -> SecureAppAgent | None:
287
+ """Get the global agent instance.
288
+
289
+ Returns:
290
+ SecureApp agent instance or None if not configured or disabled.
291
+ """
292
+ return _agent
293
+
294
+
295
+ def stop_monitoring() -> None:
296
+ """Stop the SecureApp monitoring agent.
297
+
298
+ This gracefully shuts down all monitoring activities and cleans up resources.
299
+ Can be called multiple times safely (subsequent calls are ignored).
300
+ """
301
+ global _agent
302
+ if _agent is not None:
303
+ _agent.shutdown()
304
+ _agent = None
@@ -0,0 +1,528 @@
1
+ # Copyright (c) 2026 Splunk Inc.
2
+ # All rights reserved.
3
+ # This software is the proprietary information of Splunk Inc.
4
+
5
+
6
+ """Dependency analysis module for tracking Python package dependencies."""
7
+
8
+ import base64
9
+ import gzip
10
+ import json
11
+ import logging
12
+ import sys
13
+ import time
14
+ import uuid
15
+ from dataclasses import dataclass
16
+ from importlib.metadata import (
17
+ Distribution,
18
+ PackageNotFoundError,
19
+ distribution,
20
+ packages_distributions,
21
+ )
22
+ from typing import TYPE_CHECKING, Any, Optional
23
+
24
+ from opentelemetry._logs.severity import SeverityNumber
25
+ from opentelemetry.sdk._logs._internal import LogRecord # type: ignore[attr-defined]
26
+ from opentelemetry.trace.span import TraceFlags
27
+
28
+ from splunk_secureapp_opentelemetry_extension import environment_variables, utils
29
+
30
+ if TYPE_CHECKING:
31
+ from opentelemetry.sdk._logs import LoggerProvider
32
+
33
+ logger = utils.set_logging_for_module(__name__)
34
+
35
+ # SecureApp event name constants
36
+ SECUREAPP_DEPENDENCY_REPORT_EVENT_NAME = "com.cisco.secureapp.report.v1"
37
+
38
+ # Configuration constants
39
+ MAX_PACKAGES_PER_FRAGMENT = 1000
40
+ PKG_DISTRIBUTIONS_CACHE_TTL = 300.0 # 5 minutes TTL
41
+
42
+
43
+ @dataclass
44
+ class PackageInfo:
45
+ """Package information for dependency reports and runtime analysis.
46
+
47
+ Represents a single package entry with all required fields for
48
+ security analysis and internal processing.
49
+ """
50
+
51
+ name: str
52
+ version: str
53
+ ecosystem: str = "python"
54
+ path: str = ""
55
+
56
+
57
+ @dataclass
58
+ class SecureAppEventBody:
59
+ """Event body structure for SecureApp dependency reports.
60
+
61
+ Represents the complete structure of a dependency report event,
62
+ including fragmentation support for large package lists.
63
+ """
64
+
65
+ id: str
66
+ fragment_index: int
67
+ max_fragments: int
68
+ is_done: bool
69
+ packages: list[PackageInfo]
70
+
71
+ def to_dict(self) -> dict[str, Any]:
72
+ """Convert event body to dictionary format for JSON serialization."""
73
+ # Pre-allocate dict with known size for better performance
74
+ result = {
75
+ "id": self.id,
76
+ "fragment_index": self.fragment_index,
77
+ "max_fragments": self.max_fragments,
78
+ "is_done": self.is_done,
79
+ "packages": [],
80
+ }
81
+
82
+ # Use list comprehension for better performance
83
+ result["packages"] = [
84
+ {
85
+ "name": pkg.name,
86
+ "version": pkg.version,
87
+ "ecosystem": pkg.ecosystem,
88
+ "path": pkg.path,
89
+ }
90
+ for pkg in self.packages
91
+ ]
92
+
93
+ return result
94
+
95
+
96
+ class RuntimeDependencyAnalyzer:
97
+ """Analyzes Python packages loaded into runtime.
98
+
99
+ This analyzer identifies third-party packages while ignoring stdlib modules.
100
+ It uses importlib.metadata for accurate package resolution and supports
101
+ namespace packages correctly.
102
+ """
103
+
104
+ def __init__(self, logger_provider: Optional["LoggerProvider"] = None) -> None:
105
+ """Initialize the runtime dependency analyzer.
106
+
107
+ Args:
108
+ logger_provider: OpenTelemetry LoggerProvider to use for event emission.
109
+ If None, events will not be emitted.
110
+ """
111
+ self._runtime_packages: dict[str, PackageInfo] = {}
112
+ self._last_scan_time: float = 0.0
113
+ self._scan_interval = (
114
+ environment_variables.SPLUNK_SECUREAPP_DEPENDENCY_SCAN_INTERVAL
115
+ )
116
+ self._logger_provider = logger_provider
117
+ # Cache packages_distributions() result - expensive call with TTL
118
+ self._pkg_distributions_cache: dict[str, list[str]] | None = None
119
+ self._pkg_distributions_cache_time: float = 0.0
120
+
121
+ # Cache for stdlib module detection
122
+ self._stdlib_module_cache: dict[str, bool] = {}
123
+
124
+ # Pre-cache stdlib module names for faster lookups (Python 3.10+)
125
+ self._stdlib_module_names: frozenset[str] = getattr(
126
+ sys, "stdlib_module_names", frozenset()
127
+ )
128
+
129
+ def _should_ignore_module(self, module_name: str, module_path: str | None) -> bool:
130
+ """Determine if a module should be ignored during dependency scanning.
131
+
132
+ Uses Python 3.10+ native detection with optimized lookups.
133
+ Pre-caches stdlib_module_names to avoid repeated hasattr() calls.
134
+
135
+ Args:
136
+ module_name: The module name
137
+ module_path: The __file__ attribute of the module, or None
138
+
139
+ Returns:
140
+ True if module should be ignored (stdlib/system), False if it's a third-party package
141
+ """
142
+ if not module_path:
143
+ return True
144
+
145
+ # Use cache for repeated lookups
146
+ if module_name in self._stdlib_module_cache:
147
+ return self._stdlib_module_cache[module_name]
148
+
149
+ # Extract top-level module name once
150
+ top_level_name = module_name.split(".", 1)[0]
151
+
152
+ # Fast path: Check pre-cached stdlib module names
153
+ if top_level_name in self._stdlib_module_names:
154
+ self._stdlib_module_cache[module_name] = True
155
+ return True
156
+
157
+ # Check built-in modules (C extensions in stdlib)
158
+ if top_level_name in sys.builtin_module_names:
159
+ self._stdlib_module_cache[module_name] = True
160
+ return True
161
+
162
+ # Cache and return False (third-party)
163
+ self._stdlib_module_cache[module_name] = False
164
+ return False
165
+
166
+ def _get_packages_distributions(self) -> dict[str, list[str]]:
167
+ """Get cached packages_distributions() result.
168
+
169
+ Returns:
170
+ Dictionary mapping top-level package names to distribution names.
171
+ """
172
+ current_time = time.time()
173
+
174
+ if (
175
+ self._pkg_distributions_cache is None
176
+ or current_time - self._pkg_distributions_cache_time
177
+ > PKG_DISTRIBUTIONS_CACHE_TTL
178
+ ):
179
+ # Convert Mapping to dict to match type annotation
180
+ self._pkg_distributions_cache = dict(packages_distributions())
181
+ self._pkg_distributions_cache_time = current_time
182
+
183
+ return self._pkg_distributions_cache
184
+
185
+ def _find_distribution_for_module(
186
+ self, module_name: str, module_path: str
187
+ ) -> PackageInfo | None:
188
+ """Find the correct distribution for a module.
189
+
190
+ Args:
191
+ module_name: Full module name (e.g., "google.protobuf")
192
+ module_path: Path to the module file
193
+
194
+ Returns:
195
+ PackageInfo with correct distribution name and version, or None if not found
196
+ """
197
+ # Extract top-level package name
198
+ top_level_name = module_name.split(".", 1)[0]
199
+
200
+ # Only log debug messages if debug logging is enabled
201
+ debug_enabled = logger.isEnabledFor(logging.DEBUG)
202
+ if debug_enabled:
203
+ logger.debug(
204
+ f"Finding distribution for module: {module_name} (top-level: {top_level_name}) at {module_path}"
205
+ )
206
+
207
+ try:
208
+ # Use cached packages_distributions to get the mapping
209
+ pkg_to_dists = self._get_packages_distributions()
210
+ distributions_for_module = pkg_to_dists.get(top_level_name, [])
211
+
212
+ if debug_enabled:
213
+ logger.debug(
214
+ f"packages_distributions() found: {distributions_for_module} for {top_level_name}"
215
+ )
216
+
217
+ if not distributions_for_module:
218
+ # Try direct lookup if not in packages_distributions
219
+ if debug_enabled:
220
+ logger.debug(
221
+ f"No distributions found in packages_distributions, trying direct lookup for {top_level_name}"
222
+ )
223
+ try:
224
+ dist = distribution(top_level_name)
225
+ result = PackageInfo(
226
+ name=dist.metadata["Name"],
227
+ version=dist.version,
228
+ path=module_path,
229
+ )
230
+ if debug_enabled:
231
+ logger.debug(
232
+ f"Direct lookup successful: {result.name} v{result.version}"
233
+ )
234
+ return result
235
+ except PackageNotFoundError:
236
+ if debug_enabled:
237
+ logger.debug(f"Direct lookup failed for {top_level_name}")
238
+ return None
239
+
240
+ # If only one distribution provides this module, use it
241
+ if len(distributions_for_module) == 1:
242
+ dist_name = distributions_for_module[0]
243
+ if debug_enabled:
244
+ logger.debug(f"Single distribution found: {dist_name}")
245
+ dist = distribution(dist_name)
246
+ result = PackageInfo(
247
+ name=dist.metadata["Name"],
248
+ version=dist.version,
249
+ path=module_path,
250
+ )
251
+ if debug_enabled:
252
+ logger.debug(
253
+ f"Using single distribution: {result.name} v{result.version}"
254
+ )
255
+ return result
256
+
257
+ # Multiple distributions provide this module - determine which one
258
+ # based on which distribution actually contains the specific module file
259
+ if debug_enabled:
260
+ logger.debug(
261
+ f"Multiple distributions found: {distributions_for_module}"
262
+ )
263
+ logger.debug(
264
+ "Checking which distribution contains the specific module file..."
265
+ )
266
+
267
+ for dist_name in distributions_for_module:
268
+ try:
269
+ dist = distribution(dist_name)
270
+ if debug_enabled:
271
+ logger.debug(f"Checking distribution: {dist_name}")
272
+
273
+ # Check if this distribution contains the specific module file
274
+ if self._distribution_contains_module_file(dist, module_path):
275
+ result = PackageInfo(
276
+ name=dist.metadata["Name"],
277
+ version=dist.version,
278
+ path=module_path,
279
+ )
280
+ if debug_enabled:
281
+ logger.debug(
282
+ f"File match found! Using distribution: {result.name} v{result.version}"
283
+ )
284
+ return result
285
+ if debug_enabled:
286
+ logger.debug(
287
+ f"Distribution {dist_name} does not contain the module file"
288
+ )
289
+
290
+ except Exception as e:
291
+ if debug_enabled:
292
+ logger.debug(f"Error checking distribution {dist_name}: {e}")
293
+ continue
294
+
295
+ # If no distribution matched by file, use first as fallback
296
+ dist_name = distributions_for_module[0]
297
+ if debug_enabled:
298
+ logger.debug(
299
+ f"No file match found, using first distribution as fallback: {dist_name}"
300
+ )
301
+ dist = distribution(dist_name)
302
+ result = PackageInfo(
303
+ name=dist.metadata["Name"],
304
+ version=dist.version,
305
+ path=module_path,
306
+ )
307
+ if debug_enabled:
308
+ logger.debug(f"Fallback distribution: {result.name} v{result.version}")
309
+ return result
310
+
311
+ except Exception as e:
312
+ if debug_enabled:
313
+ logger.debug(f"Error finding distribution for {module_name}: {e}")
314
+ return None
315
+
316
+ def _distribution_contains_module_file(
317
+ self, dist: Distribution, module_path: str
318
+ ) -> bool:
319
+ """Check if a distribution contains the specific module file.
320
+
321
+ Args:
322
+ dist: Distribution object from importlib.metadata
323
+ module_path: Full path to the module file
324
+
325
+ Returns:
326
+ True if this distribution contains the module file, False otherwise
327
+ """
328
+ try:
329
+ # Fast path: Check if this distribution has files
330
+ if not hasattr(dist, "files") or not dist.files:
331
+ return False
332
+
333
+ # Extract relative path once with optimized logic
334
+ if "site-packages/" in module_path:
335
+ relative_path = module_path.split("site-packages/", 1)[1]
336
+ elif "dist-packages/" in module_path:
337
+ relative_path = module_path.split("dist-packages/", 1)[1]
338
+ else:
339
+ return False
340
+
341
+ # Use any() with generator for memory efficiency
342
+ return any(str(file_obj) == relative_path for file_obj in dist.files)
343
+
344
+ except Exception as e:
345
+ logger.debug(f"Error checking if distribution contains module file: {e}")
346
+ return False
347
+
348
+ def get_runtime_packages(self) -> dict[str, PackageInfo]:
349
+ """Get information about packages currently loaded in Python runtime.
350
+
351
+ Returns:
352
+ Dictionary mapping distribution names to PackageInfo objects.
353
+ Only includes third-party packages from site-packages/dist-packages.
354
+ """
355
+ current_time = time.time()
356
+
357
+ # Use cached data if within scan interval and cache exists
358
+ if (
359
+ current_time - self._last_scan_time < self._scan_interval
360
+ and self._runtime_packages
361
+ ):
362
+ if logger.isEnabledFor(logging.DEBUG):
363
+ logger.debug(
364
+ f"Using cached runtime packages ({len(self._runtime_packages)} packages)"
365
+ )
366
+ return self._runtime_packages.copy()
367
+
368
+ if logger.isEnabledFor(logging.DEBUG):
369
+ logger.debug("Performing fresh scan of runtime packages...")
370
+
371
+ # Create a snapshot of sys.modules to avoid "dictionary changed size during iteration"
372
+ modules_snapshot = dict(sys.modules)
373
+ found_packages: dict[str, PackageInfo] = {}
374
+ processed_count = 0
375
+ skipped_count = 0
376
+
377
+ for module_name, module_obj in modules_snapshot.items():
378
+ try:
379
+ # Skip modules without __file__ or with None __file__
380
+ module_path = getattr(module_obj, "__file__", None)
381
+ if not module_path:
382
+ skipped_count += 1
383
+ continue
384
+
385
+ # Skip stdlib and system modules
386
+ if self._should_ignore_module(module_name, module_path):
387
+ skipped_count += 1
388
+ continue
389
+
390
+ # Find the distribution for this module
391
+ package_info = self._find_distribution_for_module(
392
+ module_name, module_path
393
+ )
394
+ if package_info:
395
+ # Use distribution name as key to avoid duplicates
396
+ found_packages[package_info.name] = package_info
397
+ processed_count += 1
398
+
399
+ except Exception as e:
400
+ if logger.isEnabledFor(logging.DEBUG):
401
+ logger.debug(f"Error processing module {module_name}: {e}")
402
+ continue
403
+
404
+ # Update cache
405
+ self._runtime_packages = found_packages
406
+ self._last_scan_time = current_time
407
+
408
+ if logger.isEnabledFor(logging.DEBUG):
409
+ logger.debug(
410
+ f"Scan complete - found {len(found_packages)} packages, "
411
+ f"processed {processed_count}, skipped {skipped_count}"
412
+ )
413
+ return found_packages.copy()
414
+
415
+ def create_runtime_package_events(self) -> list[LogRecord]:
416
+ """Create OpenTelemetry log events for runtime package dependencies.
417
+
418
+ Returns:
419
+ List of LogRecord objects containing package dependency data.
420
+ """
421
+ packages = self.get_runtime_packages()
422
+
423
+ # No conversion needed - packages are already PackageInfo objects
424
+ package_infos = list(packages.values())
425
+
426
+ # Create event fragments
427
+ events = []
428
+ total_packages = len(package_infos)
429
+ max_fragments = max(
430
+ 1,
431
+ (total_packages + MAX_PACKAGES_PER_FRAGMENT - 1)
432
+ // MAX_PACKAGES_PER_FRAGMENT,
433
+ )
434
+ event_id = str(uuid.uuid4())
435
+
436
+ for fragment_index in range(max_fragments):
437
+ start_idx = fragment_index * MAX_PACKAGES_PER_FRAGMENT
438
+ end_idx = min(start_idx + MAX_PACKAGES_PER_FRAGMENT, total_packages)
439
+ fragment_packages = package_infos[start_idx:end_idx]
440
+
441
+ event_body = SecureAppEventBody(
442
+ id=event_id,
443
+ fragment_index=fragment_index,
444
+ max_fragments=max_fragments,
445
+ is_done=(fragment_index == max_fragments - 1),
446
+ packages=fragment_packages,
447
+ )
448
+
449
+ # Encode the event body
450
+ encoded_body = self._encode_event_body(event_body)
451
+ if encoded_body is None:
452
+ logger.error("Failed to encode event body")
453
+ continue
454
+
455
+ # Create log record with hardcoded trace/span IDs for OTLP compatibility
456
+ # Note: resource parameter removed in OpenTelemetry 1.39.0+
457
+ log_record_kwargs = {
458
+ "timestamp": int(time.time() * 1_000_000_000), # nanoseconds
459
+ "trace_id": 0, # Use simple hardcoded trace/span IDs for OpenTelemetry compatibility
460
+ "span_id": 0, # Use simple hardcoded trace/span IDs for OpenTelemetry compatibility
461
+ "trace_flags": TraceFlags.get_default(),
462
+ "severity_text": "",
463
+ "severity_number": SeverityNumber.UNSPECIFIED,
464
+ "body": encoded_body,
465
+ "attributes": {
466
+ "event.name": SECUREAPP_DEPENDENCY_REPORT_EVENT_NAME,
467
+ },
468
+ }
469
+
470
+ # Try with resource parameter for OTel <1.39.0 compatibility
471
+ try:
472
+ log_record = LogRecord(**log_record_kwargs, resource=None) # type: ignore[call-overload]
473
+ except TypeError:
474
+ # OTel 1.39.0+ removed resource parameter
475
+ log_record = LogRecord(**log_record_kwargs) # type: ignore[call-overload]
476
+
477
+ events.append(log_record)
478
+
479
+ logger.debug(
480
+ f"Created {len(events)} package events with {total_packages} total packages"
481
+ )
482
+ return events
483
+
484
+ def emit_package_events(self) -> None:
485
+ """Emit package dependency events using the configured logger provider."""
486
+ if not self._logger_provider:
487
+ logger.debug("No logger provider configured - skipping event emission")
488
+ return
489
+
490
+ try:
491
+ events = self.create_runtime_package_events()
492
+ if not events:
493
+ logger.debug("No events to emit")
494
+ return
495
+
496
+ otel_logger = self._logger_provider.get_logger("secureapp")
497
+
498
+ for event in events:
499
+ otel_logger.emit(event)
500
+
501
+ logger.debug(f"Successfully emitted {len(events)} package events")
502
+
503
+ except Exception as e:
504
+ logger.error(f"Failed to emit package events: {e}")
505
+
506
+ def _encode_event_body(self, event_body: SecureAppEventBody) -> str | None:
507
+ """Encode event body as compressed base64 JSON.
508
+
509
+ Args:
510
+ event_body: SecureAppEventBody object to encode
511
+
512
+ Returns:
513
+ Base64-encoded compressed JSON string, or None if encoding fails
514
+ """
515
+ try:
516
+ # Convert to JSON
517
+ json_data = json.dumps(event_body.to_dict(), separators=(",", ":"))
518
+
519
+ # Compress with gzip
520
+ compressed_data: bytes = gzip.compress(json_data.encode("utf-8"))
521
+
522
+ # Encode as base64 (bytes is valid input for b64encode)
523
+ encoded_data = base64.b64encode(compressed_data)
524
+ return encoded_data.decode("ascii")
525
+
526
+ except Exception as e:
527
+ logger.error(f"Failed to encode event body: {e}")
528
+ return None
@@ -0,0 +1,59 @@
1
+ # Copyright (c) 2026 Splunk Inc.
2
+ # All rights reserved.
3
+ # This software is the proprietary information of Splunk Inc.
4
+
5
+
6
+ """Environment variable definitions for Splunk SecureApp OpenTelemetry Extension."""
7
+
8
+ import os
9
+
10
+
11
+ def get_env_bool(name: str, default: bool = False) -> bool:
12
+ """Get boolean environment variable value.
13
+
14
+ Args:
15
+ name: Environment variable name.
16
+ default: Default value if variable is not set.
17
+
18
+ Returns:
19
+ Boolean value of the environment variable.
20
+ """
21
+ value = os.getenv(name, "").lower()
22
+ return value in ("true", "1", "yes", "on") if value else default
23
+
24
+
25
+ def get_env_str(name: str, default: str = "") -> str:
26
+ """Get string environment variable value.
27
+
28
+ Args:
29
+ name: Environment variable name.
30
+ default: Default value if variable is not set.
31
+
32
+ Returns:
33
+ String value of the environment variable.
34
+ """
35
+ return os.getenv(name, default)
36
+
37
+
38
+ # OTLP configuration
39
+ # Use standard OpenTelemetry environment variables instead:
40
+ # - OTEL_EXPORTER_OTLP_ENDPOINT
41
+ # - OTEL_EXPORTER_OTLP_LOGS_ENDPOINT
42
+
43
+ # Feature toggles
44
+ SPLUNK_SECUREAPP_AGENT_ENABLED = get_env_bool("SPLUNK_SECUREAPP_AGENT_ENABLED", True)
45
+
46
+ # Dependency analysis configuration
47
+ SPLUNK_SECUREAPP_DEPENDENCY_SCAN_INTERVAL = int(
48
+ get_env_str(
49
+ "SPLUNK_SECUREAPP_DEPENDENCY_SCAN_INTERVAL", "86400"
50
+ ) # 1 day (24 * 60 * 60)
51
+ )
52
+ SPLUNK_SECUREAPP_DEPENDENCY_INITIAL_DELAY = int(
53
+ get_env_str("SPLUNK_SECUREAPP_DEPENDENCY_INITIAL_DELAY", "60") # 1 minute
54
+ )
55
+
56
+ # Logging
57
+ SPLUNK_SECUREAPP_LOG_LEVEL = get_env_str("SPLUNK_SECUREAPP_LOG_LEVEL")
58
+ if SPLUNK_SECUREAPP_LOG_LEVEL is not None:
59
+ SPLUNK_SECUREAPP_LOG_LEVEL = SPLUNK_SECUREAPP_LOG_LEVEL.upper()
File without changes
@@ -0,0 +1,86 @@
1
+ # Copyright (c) 2026 Splunk Inc.
2
+ # All rights reserved.
3
+ # This software is the proprietary information of Splunk Inc.
4
+
5
+
6
+ """Utilities for Splunk SecureApp OpenTelemetry Extension."""
7
+
8
+ import logging
9
+ from importlib.metadata import (
10
+ PackageNotFoundError,
11
+ distribution,
12
+ metadata,
13
+ requires,
14
+ )
15
+
16
+ from splunk_secureapp_opentelemetry_extension import environment_variables
17
+
18
+
19
+ def set_logging_for_module(name: str) -> logging.Logger:
20
+ """Configures and returns a logging.Logger instance for a specific module.
21
+
22
+ Args:
23
+ name (str): The name of the module, typically passed as __name__.
24
+
25
+ Returns:
26
+ logging.Logger: A configured logger instance.
27
+ """
28
+ logger = logging.getLogger(name)
29
+
30
+ # If Log Level not set for SecureApp - just use whatever root logger has set
31
+ if not environment_variables.SPLUNK_SECUREAPP_LOG_LEVEL:
32
+ return logger
33
+
34
+ # Set the logger level - if error - print it and return logger with root level
35
+ try:
36
+ logger.setLevel(environment_variables.SPLUNK_SECUREAPP_LOG_LEVEL)
37
+ except ValueError as e:
38
+ print(f"Error: {e}")
39
+ return logger
40
+
41
+ # Prevent duplicate handlers if the function is called multiple times
42
+ if not logger.handlers:
43
+ console_handler = logging.StreamHandler()
44
+ console_handler.setLevel(environment_variables.SPLUNK_SECUREAPP_LOG_LEVEL)
45
+
46
+ formatter = logging.Formatter(
47
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
48
+ )
49
+ console_handler.setFormatter(formatter)
50
+ logger.addHandler(console_handler)
51
+
52
+ logger.propagate = False
53
+
54
+ # Explicitly return the logger
55
+ return logger
56
+
57
+
58
+ def log_package_info(package_name: str, logger: logging.Logger) -> None:
59
+ """Logs the name, version, and installation location of a specified Python package.
60
+
61
+ Args:
62
+ package_name (str): The name of the package to retrieve information for.
63
+ logger (logging.Logger): The logger instance used to output the package information.
64
+
65
+ Returns:
66
+ None
67
+ """
68
+ try:
69
+ # Get the distribution information for the package
70
+ dist = distribution(package_name)
71
+
72
+ # Get all metadata
73
+ pkg_metadata = metadata(package_name)
74
+
75
+ # Get dependencies
76
+ pkg_dependencies = requires(package_name)
77
+
78
+ # Log the package details
79
+ logger.info(f"Package: {package_name}")
80
+ logger.info(f"Version: {dist.version}")
81
+ logger.info(f"Location: {dist.locate_file('')}")
82
+ logger.info(f"Summary: {pkg_metadata['Summary']}")
83
+ logger.info(f"Dependencies: {pkg_dependencies}")
84
+
85
+ except PackageNotFoundError:
86
+ logger.info(f"Package '{package_name}' is not installed in this environment.")