cve-sentinel 0.1.2__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.
cve_sentinel/config.py ADDED
@@ -0,0 +1,347 @@
1
+ """Configuration management for CVE Sentinel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ import yaml
11
+
12
+
13
+ class ConfigError(Exception):
14
+ """Configuration error."""
15
+
16
+ pass
17
+
18
+
19
+ class ConfigValidationError(ConfigError):
20
+ """Configuration validation error."""
21
+
22
+ pass
23
+
24
+
25
+ @dataclass
26
+ class Config:
27
+ """CVE Sentinel configuration.
28
+
29
+ Attributes:
30
+ target_path: Directory to scan for dependencies.
31
+ exclude: List of path patterns to exclude from scanning.
32
+ analysis_level: Depth of analysis (1-3).
33
+ 1: Direct dependencies from manifest files
34
+ 2: Transitive dependencies from lock files
35
+ 3: Import statement scanning in source code
36
+ auto_scan_on_startup: Whether to automatically scan on startup.
37
+ cache_ttl_hours: Cache time-to-live in hours.
38
+ nvd_api_key: NVD API key for vulnerability data.
39
+ """
40
+
41
+ target_path: Path = field(default_factory=lambda: Path("."))
42
+ exclude: list[str] = field(
43
+ default_factory=lambda: ["node_modules/", "vendor/", ".git/", "__pycache__/", "venv/"]
44
+ )
45
+ analysis_level: int = 2
46
+ auto_scan_on_startup: bool = True
47
+ cache_ttl_hours: int = 24
48
+ nvd_api_key: Optional[str] = None
49
+ custom_patterns: Optional[dict[str, dict[str, list[str]]]] = None
50
+
51
+ def __post_init__(self) -> None:
52
+ """Convert target_path to Path if string."""
53
+ if isinstance(self.target_path, str):
54
+ self.target_path = Path(self.target_path)
55
+
56
+
57
+ def _find_config_file(base_path: Path) -> Optional[Path]:
58
+ """Find configuration file in the given directory.
59
+
60
+ Searches for .cve-sentinel.yaml first, then .cve-sentinel.yml.
61
+
62
+ Args:
63
+ base_path: Directory to search in.
64
+
65
+ Returns:
66
+ Path to config file if found, None otherwise.
67
+ """
68
+ yaml_path = base_path / ".cve-sentinel.yaml"
69
+ if yaml_path.exists():
70
+ return yaml_path
71
+
72
+ yml_path = base_path / ".cve-sentinel.yml"
73
+ if yml_path.exists():
74
+ return yml_path
75
+
76
+ return None
77
+
78
+
79
+ def _load_yaml_config(config_path: Path) -> dict[str, Any]:
80
+ """Load configuration from YAML file.
81
+
82
+ Args:
83
+ config_path: Path to the YAML config file.
84
+
85
+ Returns:
86
+ Dictionary with configuration values.
87
+
88
+ Raises:
89
+ ConfigError: If file cannot be read or parsed.
90
+ """
91
+ try:
92
+ with open(config_path, encoding="utf-8") as f:
93
+ data = yaml.safe_load(f)
94
+ return data if data is not None else {}
95
+ except yaml.YAMLError as e:
96
+ raise ConfigError(f"Failed to parse YAML config file: {e}") from e
97
+ except OSError as e:
98
+ raise ConfigError(f"Failed to read config file: {e}") from e
99
+
100
+
101
+ def _load_env_vars() -> dict[str, Any]:
102
+ """Load configuration from environment variables.
103
+
104
+ Supported environment variables:
105
+ - CVE_SENTINEL_NVD_API_KEY: NVD API key
106
+ - CVE_SENTINEL_CONFIG_PATH: Custom config file path
107
+
108
+ Returns:
109
+ Dictionary with configuration values from environment.
110
+ """
111
+ env_config: dict[str, Any] = {}
112
+
113
+ nvd_api_key = os.environ.get("CVE_SENTINEL_NVD_API_KEY")
114
+ if nvd_api_key:
115
+ env_config["nvd_api_key"] = nvd_api_key
116
+
117
+ return env_config
118
+
119
+
120
+ def _get_config_path_from_env() -> Optional[Path]:
121
+ """Get custom config file path from environment variable.
122
+
123
+ Returns:
124
+ Path to config file if CVE_SENTINEL_CONFIG_PATH is set, None otherwise.
125
+ """
126
+ config_path_str = os.environ.get("CVE_SENTINEL_CONFIG_PATH")
127
+ if config_path_str:
128
+ return Path(config_path_str)
129
+ return None
130
+
131
+
132
+ def _validate_custom_patterns(
133
+ custom_patterns: Optional[dict[str, dict[str, list[str]]]],
134
+ ) -> list[str]:
135
+ """Validate custom_patterns configuration.
136
+
137
+ Args:
138
+ custom_patterns: The custom_patterns config value to validate.
139
+
140
+ Returns:
141
+ List of validation error messages (empty if valid).
142
+ """
143
+ errors: list[str] = []
144
+
145
+ if custom_patterns is None:
146
+ return errors
147
+
148
+ if not isinstance(custom_patterns, dict):
149
+ errors.append("custom_patterns must be a dictionary")
150
+ return errors
151
+
152
+ # Valid ecosystem names (including common aliases)
153
+ valid_ecosystems = {
154
+ "javascript",
155
+ "npm",
156
+ "python",
157
+ "pypi",
158
+ "go",
159
+ "java",
160
+ "maven",
161
+ "gradle",
162
+ "ruby",
163
+ "rubygems",
164
+ "rust",
165
+ "crates.io",
166
+ "php",
167
+ "packagist",
168
+ }
169
+
170
+ valid_pattern_types = {"manifests", "locks"}
171
+
172
+ for ecosystem, patterns_dict in custom_patterns.items():
173
+ if ecosystem not in valid_ecosystems:
174
+ errors.append(
175
+ f"custom_patterns: unknown ecosystem '{ecosystem}'. "
176
+ f"Valid ecosystems: {', '.join(sorted(valid_ecosystems))}"
177
+ )
178
+ continue
179
+
180
+ if not isinstance(patterns_dict, dict):
181
+ errors.append(
182
+ f"custom_patterns.{ecosystem}: must be a dictionary "
183
+ "with 'manifests' and/or 'locks' keys"
184
+ )
185
+ continue
186
+
187
+ for pattern_type, patterns in patterns_dict.items():
188
+ if pattern_type not in valid_pattern_types:
189
+ errors.append(
190
+ f"custom_patterns.{ecosystem}: unknown pattern type '{pattern_type}'. "
191
+ "Valid types: manifests, locks"
192
+ )
193
+ continue
194
+
195
+ if not isinstance(patterns, list):
196
+ errors.append(
197
+ f"custom_patterns.{ecosystem}.{pattern_type}: must be a list of strings"
198
+ )
199
+ continue
200
+
201
+ for i, pattern in enumerate(patterns):
202
+ if not isinstance(pattern, str):
203
+ errors.append(
204
+ f"custom_patterns.{ecosystem}.{pattern_type}[{i}]: must be a string"
205
+ )
206
+ elif not pattern:
207
+ errors.append(
208
+ f"custom_patterns.{ecosystem}.{pattern_type}[{i}]: pattern cannot be empty"
209
+ )
210
+
211
+ return errors
212
+
213
+
214
+ def _validate_config(config: Config) -> None:
215
+ """Validate configuration values.
216
+
217
+ Args:
218
+ config: Configuration to validate.
219
+
220
+ Raises:
221
+ ConfigValidationError: If validation fails.
222
+ """
223
+ errors: list[str] = []
224
+
225
+ # Validate analysis_level (1-3)
226
+ if not 1 <= config.analysis_level <= 3:
227
+ errors.append(f"analysis_level must be between 1 and 3, got {config.analysis_level}")
228
+
229
+ # Validate cache_ttl_hours (positive integer)
230
+ if config.cache_ttl_hours <= 0:
231
+ errors.append(f"cache_ttl_hours must be positive, got {config.cache_ttl_hours}")
232
+
233
+ # Validate target_path exists
234
+ if not config.target_path.exists():
235
+ errors.append(f"target_path does not exist: {config.target_path}")
236
+
237
+ # NVD API key is required for full functionality
238
+ if not config.nvd_api_key:
239
+ errors.append(
240
+ "nvd_api_key is required. Set CVE_SENTINEL_NVD_API_KEY environment variable "
241
+ "or add nvd_api_key to .cve-sentinel.yaml"
242
+ )
243
+
244
+ # Validate custom_patterns
245
+ errors.extend(_validate_custom_patterns(config.custom_patterns))
246
+
247
+ if errors:
248
+ raise ConfigValidationError("\n".join(errors))
249
+
250
+
251
+ def load_config(
252
+ base_path: Optional[Path] = None,
253
+ validate: bool = True,
254
+ require_api_key: bool = True,
255
+ cli_overrides: Optional[dict[str, Any]] = None,
256
+ ) -> Config:
257
+ """Load configuration from file, environment variables, and CLI arguments.
258
+
259
+ Configuration is loaded in the following order (later values override earlier):
260
+ 1. Default values
261
+ 2. YAML config file (.cve-sentinel.yaml or .cve-sentinel.yml)
262
+ 3. Environment variables
263
+ 4. CLI arguments (highest priority)
264
+
265
+ Args:
266
+ base_path: Base directory to search for config file.
267
+ Defaults to current working directory.
268
+ validate: Whether to validate the configuration.
269
+ require_api_key: Whether to require NVD API key in validation.
270
+ cli_overrides: Dictionary of CLI argument overrides (highest priority).
271
+
272
+ Returns:
273
+ Loaded and optionally validated Config object.
274
+
275
+ Raises:
276
+ ConfigError: If config file cannot be read or parsed.
277
+ ConfigValidationError: If validation is enabled and fails.
278
+ """
279
+ if base_path is None:
280
+ base_path = Path.cwd()
281
+
282
+ # Start with defaults
283
+ config_data: dict[str, Any] = {}
284
+
285
+ # Check for custom config path from environment
286
+ env_config_path = _get_config_path_from_env()
287
+ if env_config_path:
288
+ if env_config_path.exists():
289
+ config_data.update(_load_yaml_config(env_config_path))
290
+ else:
291
+ raise ConfigError(
292
+ f"Config file specified by CVE_SENTINEL_CONFIG_PATH does not exist: "
293
+ f"{env_config_path}"
294
+ )
295
+ else:
296
+ # Find config file in base_path
297
+ config_path = _find_config_file(base_path)
298
+ if config_path:
299
+ config_data.update(_load_yaml_config(config_path))
300
+
301
+ # Override with environment variables
302
+ config_data.update(_load_env_vars())
303
+
304
+ # Override with CLI arguments (highest priority)
305
+ if cli_overrides:
306
+ config_data.update(cli_overrides)
307
+
308
+ # Convert target_path to absolute path relative to base_path
309
+ if "target_path" in config_data:
310
+ target = Path(config_data["target_path"])
311
+ if not target.is_absolute():
312
+ config_data["target_path"] = base_path / target
313
+ else:
314
+ config_data["target_path"] = base_path
315
+
316
+ # Create Config object
317
+ config = Config(**config_data)
318
+
319
+ # Validate if requested
320
+ if validate:
321
+ if not require_api_key and not config.nvd_api_key:
322
+ # Skip API key validation but validate other fields
323
+ errors: list[str] = []
324
+ if not 1 <= config.analysis_level <= 3:
325
+ errors.append(
326
+ f"analysis_level must be between 1 and 3, got {config.analysis_level}"
327
+ )
328
+ if config.cache_ttl_hours <= 0:
329
+ errors.append(f"cache_ttl_hours must be positive, got {config.cache_ttl_hours}")
330
+ if not config.target_path.exists():
331
+ errors.append(f"target_path does not exist: {config.target_path}")
332
+ errors.extend(_validate_custom_patterns(config.custom_patterns))
333
+ if errors:
334
+ raise ConfigValidationError("\n".join(errors))
335
+ else:
336
+ _validate_config(config)
337
+
338
+ return config
339
+
340
+
341
+ def get_default_config() -> Config:
342
+ """Get default configuration without loading from file.
343
+
344
+ Returns:
345
+ Config object with default values.
346
+ """
347
+ return Config()
@@ -0,0 +1,22 @@
1
+ """CVE data fetchers for NVD and OSV APIs."""
2
+
3
+ from cve_sentinel.fetchers.nvd import CVEData, NVDAPIError, NVDClient, NVDRateLimitError
4
+ from cve_sentinel.fetchers.osv import (
5
+ MergedVulnerability,
6
+ OSVAPIError,
7
+ OSVClient,
8
+ OSVVulnerability,
9
+ merge_nvd_osv_data,
10
+ )
11
+
12
+ __all__ = [
13
+ "CVEData",
14
+ "MergedVulnerability",
15
+ "NVDAPIError",
16
+ "NVDClient",
17
+ "NVDRateLimitError",
18
+ "OSVAPIError",
19
+ "OSVClient",
20
+ "OSVVulnerability",
21
+ "merge_nvd_osv_data",
22
+ ]