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.
- secureapp_python_agent-26.5.0.dist-info/METADATA +119 -0
- secureapp_python_agent-26.5.0.dist-info/RECORD +12 -0
- secureapp_python_agent-26.5.0.dist-info/WHEEL +5 -0
- secureapp_python_agent-26.5.0.dist-info/entry_points.txt +5 -0
- secureapp_python_agent-26.5.0.dist-info/licenses/LICENSE +2 -0
- secureapp_python_agent-26.5.0.dist-info/top_level.txt +1 -0
- splunk_secureapp_opentelemetry_extension/__init__.py +38 -0
- splunk_secureapp_opentelemetry_extension/agent.py +304 -0
- splunk_secureapp_opentelemetry_extension/dependency_analyzer.py +528 -0
- splunk_secureapp_opentelemetry_extension/environment_variables.py +59 -0
- splunk_secureapp_opentelemetry_extension/py.typed +0 -0
- splunk_secureapp_opentelemetry_extension/utils.py +86 -0
|
@@ -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 @@
|
|
|
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.")
|