lucidscan 0.5.12__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.
Files changed (91) hide show
  1. lucidscan/__init__.py +12 -0
  2. lucidscan/bootstrap/__init__.py +26 -0
  3. lucidscan/bootstrap/paths.py +160 -0
  4. lucidscan/bootstrap/platform.py +111 -0
  5. lucidscan/bootstrap/validation.py +76 -0
  6. lucidscan/bootstrap/versions.py +119 -0
  7. lucidscan/cli/__init__.py +50 -0
  8. lucidscan/cli/__main__.py +8 -0
  9. lucidscan/cli/arguments.py +405 -0
  10. lucidscan/cli/commands/__init__.py +64 -0
  11. lucidscan/cli/commands/autoconfigure.py +294 -0
  12. lucidscan/cli/commands/help.py +69 -0
  13. lucidscan/cli/commands/init.py +656 -0
  14. lucidscan/cli/commands/list_scanners.py +59 -0
  15. lucidscan/cli/commands/scan.py +307 -0
  16. lucidscan/cli/commands/serve.py +142 -0
  17. lucidscan/cli/commands/status.py +84 -0
  18. lucidscan/cli/commands/validate.py +105 -0
  19. lucidscan/cli/config_bridge.py +152 -0
  20. lucidscan/cli/exit_codes.py +17 -0
  21. lucidscan/cli/runner.py +284 -0
  22. lucidscan/config/__init__.py +29 -0
  23. lucidscan/config/ignore.py +178 -0
  24. lucidscan/config/loader.py +431 -0
  25. lucidscan/config/models.py +316 -0
  26. lucidscan/config/validation.py +645 -0
  27. lucidscan/core/__init__.py +3 -0
  28. lucidscan/core/domain_runner.py +463 -0
  29. lucidscan/core/git.py +174 -0
  30. lucidscan/core/logging.py +34 -0
  31. lucidscan/core/models.py +207 -0
  32. lucidscan/core/streaming.py +340 -0
  33. lucidscan/core/subprocess_runner.py +164 -0
  34. lucidscan/detection/__init__.py +21 -0
  35. lucidscan/detection/detector.py +154 -0
  36. lucidscan/detection/frameworks.py +270 -0
  37. lucidscan/detection/languages.py +328 -0
  38. lucidscan/detection/tools.py +229 -0
  39. lucidscan/generation/__init__.py +15 -0
  40. lucidscan/generation/config_generator.py +275 -0
  41. lucidscan/generation/package_installer.py +330 -0
  42. lucidscan/mcp/__init__.py +20 -0
  43. lucidscan/mcp/formatter.py +510 -0
  44. lucidscan/mcp/server.py +297 -0
  45. lucidscan/mcp/tools.py +1049 -0
  46. lucidscan/mcp/watcher.py +237 -0
  47. lucidscan/pipeline/__init__.py +17 -0
  48. lucidscan/pipeline/executor.py +187 -0
  49. lucidscan/pipeline/parallel.py +181 -0
  50. lucidscan/plugins/__init__.py +40 -0
  51. lucidscan/plugins/coverage/__init__.py +28 -0
  52. lucidscan/plugins/coverage/base.py +160 -0
  53. lucidscan/plugins/coverage/coverage_py.py +454 -0
  54. lucidscan/plugins/coverage/istanbul.py +411 -0
  55. lucidscan/plugins/discovery.py +107 -0
  56. lucidscan/plugins/enrichers/__init__.py +61 -0
  57. lucidscan/plugins/enrichers/base.py +63 -0
  58. lucidscan/plugins/linters/__init__.py +26 -0
  59. lucidscan/plugins/linters/base.py +125 -0
  60. lucidscan/plugins/linters/biome.py +448 -0
  61. lucidscan/plugins/linters/checkstyle.py +393 -0
  62. lucidscan/plugins/linters/eslint.py +368 -0
  63. lucidscan/plugins/linters/ruff.py +498 -0
  64. lucidscan/plugins/reporters/__init__.py +45 -0
  65. lucidscan/plugins/reporters/base.py +30 -0
  66. lucidscan/plugins/reporters/json_reporter.py +79 -0
  67. lucidscan/plugins/reporters/sarif_reporter.py +303 -0
  68. lucidscan/plugins/reporters/summary_reporter.py +61 -0
  69. lucidscan/plugins/reporters/table_reporter.py +81 -0
  70. lucidscan/plugins/scanners/__init__.py +57 -0
  71. lucidscan/plugins/scanners/base.py +60 -0
  72. lucidscan/plugins/scanners/checkov.py +484 -0
  73. lucidscan/plugins/scanners/opengrep.py +464 -0
  74. lucidscan/plugins/scanners/trivy.py +492 -0
  75. lucidscan/plugins/test_runners/__init__.py +27 -0
  76. lucidscan/plugins/test_runners/base.py +111 -0
  77. lucidscan/plugins/test_runners/jest.py +381 -0
  78. lucidscan/plugins/test_runners/karma.py +481 -0
  79. lucidscan/plugins/test_runners/playwright.py +434 -0
  80. lucidscan/plugins/test_runners/pytest.py +598 -0
  81. lucidscan/plugins/type_checkers/__init__.py +27 -0
  82. lucidscan/plugins/type_checkers/base.py +106 -0
  83. lucidscan/plugins/type_checkers/mypy.py +355 -0
  84. lucidscan/plugins/type_checkers/pyright.py +313 -0
  85. lucidscan/plugins/type_checkers/typescript.py +280 -0
  86. lucidscan-0.5.12.dist-info/METADATA +242 -0
  87. lucidscan-0.5.12.dist-info/RECORD +91 -0
  88. lucidscan-0.5.12.dist-info/WHEEL +5 -0
  89. lucidscan-0.5.12.dist-info/entry_points.txt +34 -0
  90. lucidscan-0.5.12.dist-info/licenses/LICENSE +201 -0
  91. lucidscan-0.5.12.dist-info/top_level.txt +1 -0
@@ -0,0 +1,431 @@
1
+ """Configuration file loading and merging.
2
+
3
+ Handles loading configuration from YAML files with:
4
+ - Project-level config (.lucidscan.yml)
5
+ - Global config (~/.lucidscan/config/config.yml)
6
+ - Environment variable expansion (${VAR})
7
+ - Config merging with proper precedence
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ import yaml
18
+
19
+ from lucidscan.config.models import (
20
+ CoveragePipelineConfig,
21
+ DomainPipelineConfig,
22
+ FailOnConfig,
23
+ LucidScanConfig,
24
+ OutputConfig,
25
+ PipelineConfig,
26
+ ProjectConfig,
27
+ ScannerDomainConfig,
28
+ ToolConfig,
29
+ )
30
+ from lucidscan.config.validation import validate_config
31
+ from lucidscan.core.logging import get_logger
32
+ from lucidscan.bootstrap.paths import get_lucidscan_home
33
+
34
+ LOGGER = get_logger(__name__)
35
+
36
+ # Config file names
37
+ PROJECT_CONFIG_NAMES = [".lucidscan.yml", ".lucidscan.yaml", "lucidscan.yml", "lucidscan.yaml"]
38
+ GLOBAL_CONFIG_NAME = "config.yml"
39
+
40
+ # Environment variable pattern: ${VAR} or ${VAR:-default}
41
+ ENV_VAR_PATTERN = re.compile(r"\$\{([^}:]+)(?::-([^}]*))?\}")
42
+
43
+
44
+ class ConfigError(Exception):
45
+ """Configuration loading or parsing error."""
46
+
47
+ pass
48
+
49
+
50
+ def load_config(
51
+ project_root: Path,
52
+ cli_config_path: Optional[Path] = None,
53
+ cli_overrides: Optional[Dict[str, Any]] = None,
54
+ ) -> LucidScanConfig:
55
+ """Load configuration with proper precedence.
56
+
57
+ Precedence (highest to lowest):
58
+ 1. CLI flags (cli_overrides)
59
+ 2. Custom config file (cli_config_path) OR project config (.lucidscan.yml)
60
+ 3. Global config (~/.lucidscan/config/config.yml)
61
+ 4. Built-in defaults
62
+
63
+ Args:
64
+ project_root: Project root directory for finding .lucidscan.yml.
65
+ cli_config_path: Optional path to custom config file (--config flag).
66
+ cli_overrides: Dict of CLI flag overrides.
67
+
68
+ Returns:
69
+ Merged LucidScanConfig instance.
70
+
71
+ Raises:
72
+ ConfigError: If specified config file doesn't exist or has parse errors.
73
+ """
74
+ sources: List[str] = []
75
+ merged: Dict[str, Any] = {}
76
+
77
+ # Layer 1: Global config
78
+ global_path = find_global_config()
79
+ if global_path and global_path.exists():
80
+ try:
81
+ global_dict = load_yaml_file(global_path)
82
+ validate_config(global_dict, source=str(global_path))
83
+ merged = merge_configs(merged, global_dict)
84
+ sources.append(f"global:{global_path}")
85
+ LOGGER.debug(f"Loaded global config from {global_path}")
86
+ except Exception as e:
87
+ LOGGER.warning(f"Failed to load global config: {e}")
88
+
89
+ # Layer 2: Project or custom config
90
+ if cli_config_path:
91
+ if not cli_config_path.exists():
92
+ raise ConfigError(f"Config file not found: {cli_config_path}")
93
+ try:
94
+ project_dict = load_yaml_file(cli_config_path)
95
+ validate_config(project_dict, source=str(cli_config_path))
96
+ merged = merge_configs(merged, project_dict)
97
+ sources.append(f"custom:{cli_config_path}")
98
+ LOGGER.debug(f"Loaded custom config from {cli_config_path}")
99
+ except yaml.YAMLError as e:
100
+ raise ConfigError(f"Invalid YAML in {cli_config_path}: {e}") from e
101
+ else:
102
+ project_path = find_project_config(project_root)
103
+ if project_path and project_path.exists():
104
+ try:
105
+ project_dict = load_yaml_file(project_path)
106
+ validate_config(project_dict, source=str(project_path))
107
+ merged = merge_configs(merged, project_dict)
108
+ sources.append(f"project:{project_path}")
109
+ LOGGER.debug(f"Loaded project config from {project_path}")
110
+ except yaml.YAMLError as e:
111
+ raise ConfigError(f"Invalid YAML in {project_path}: {e}") from e
112
+
113
+ # Layer 3: CLI overrides
114
+ if cli_overrides:
115
+ merged = merge_configs(merged, cli_overrides)
116
+ sources.append("cli")
117
+ LOGGER.debug("Applied CLI overrides")
118
+
119
+ # Convert to typed config
120
+ config = dict_to_config(merged)
121
+ config._config_sources = sources
122
+
123
+ LOGGER.debug(f"Config loaded from sources: {sources}")
124
+ return config
125
+
126
+
127
+ def find_project_config(project_root: Path) -> Optional[Path]:
128
+ """Find config file in project root.
129
+
130
+ Searches for .lucidscan.yml, .lucidscan.yaml, lucidscan.yml, lucidscan.yaml
131
+ in the project root directory.
132
+
133
+ Args:
134
+ project_root: Directory to search in.
135
+
136
+ Returns:
137
+ Path to config file if found, None otherwise.
138
+ """
139
+ for name in PROJECT_CONFIG_NAMES:
140
+ config_path = project_root / name
141
+ if config_path.exists():
142
+ return config_path
143
+ return None
144
+
145
+
146
+ def find_global_config() -> Optional[Path]:
147
+ """Find global config at ~/.lucidscan/config/config.yml.
148
+
149
+ Returns:
150
+ Path to global config if it exists, None otherwise.
151
+ """
152
+ home = get_lucidscan_home()
153
+ config_path = home / "config" / GLOBAL_CONFIG_NAME
154
+ if config_path.exists():
155
+ return config_path
156
+ return None
157
+
158
+
159
+ def load_yaml_file(path: Path) -> Dict[str, Any]:
160
+ """Load and parse a YAML config file.
161
+
162
+ Performs environment variable expansion on string values.
163
+
164
+ Args:
165
+ path: Path to YAML file.
166
+
167
+ Returns:
168
+ Parsed dictionary.
169
+
170
+ Raises:
171
+ yaml.YAMLError: If YAML parsing fails.
172
+ FileNotFoundError: If file doesn't exist.
173
+ """
174
+ with open(path, "r", encoding="utf-8") as f:
175
+ content = f.read()
176
+
177
+ data = yaml.safe_load(content)
178
+
179
+ if data is None:
180
+ return {}
181
+
182
+ if not isinstance(data, dict):
183
+ raise ConfigError(f"Config file must be a YAML mapping, got {type(data).__name__}")
184
+
185
+ # Expand environment variables
186
+ return expand_env_vars(data)
187
+
188
+
189
+ def expand_env_vars(data: Any) -> Any:
190
+ """Recursively expand environment variables in config values.
191
+
192
+ Supports ${VAR} and ${VAR:-default} syntax.
193
+
194
+ Args:
195
+ data: Config data (dict, list, or scalar).
196
+
197
+ Returns:
198
+ Data with environment variables expanded.
199
+ """
200
+ if isinstance(data, dict):
201
+ return {k: expand_env_vars(v) for k, v in data.items()}
202
+ elif isinstance(data, list):
203
+ return [expand_env_vars(item) for item in data]
204
+ elif isinstance(data, str):
205
+ return ENV_VAR_PATTERN.sub(_env_var_replacer, data)
206
+ else:
207
+ return data
208
+
209
+
210
+ def _env_var_replacer(match: re.Match[str]) -> str:
211
+ """Replace environment variable reference with its value."""
212
+ var_name = match.group(1)
213
+ default_value = match.group(2)
214
+
215
+ value = os.environ.get(var_name)
216
+ if value is not None:
217
+ return value
218
+ if default_value is not None:
219
+ return default_value
220
+
221
+ LOGGER.warning(f"Environment variable ${var_name} is not set and has no default")
222
+ return ""
223
+
224
+
225
+ def merge_configs(base: Dict[str, Any], overlay: Dict[str, Any]) -> Dict[str, Any]:
226
+ """Deep merge two config dicts, with overlay taking precedence.
227
+
228
+ Rules:
229
+ - Scalar values: overlay replaces base
230
+ - Lists: overlay replaces base (no merging)
231
+ - Dicts: recursive merge
232
+
233
+ Args:
234
+ base: Base configuration dictionary.
235
+ overlay: Overlay configuration to merge on top.
236
+
237
+ Returns:
238
+ Merged configuration dictionary.
239
+ """
240
+ result = base.copy()
241
+
242
+ for key, overlay_value in overlay.items():
243
+ if key in result and isinstance(result[key], dict) and isinstance(overlay_value, dict):
244
+ result[key] = merge_configs(result[key], overlay_value)
245
+ else:
246
+ result[key] = overlay_value
247
+
248
+ return result
249
+
250
+
251
+ def _parse_tool_config(tool_data: Dict[str, Any]) -> ToolConfig:
252
+ """Parse a single tool configuration.
253
+
254
+ Args:
255
+ tool_data: Tool configuration dictionary.
256
+
257
+ Returns:
258
+ ToolConfig instance.
259
+ """
260
+ name = tool_data.get("name", "")
261
+ config_path = tool_data.get("config")
262
+ strict = tool_data.get("strict", False)
263
+ domains = tool_data.get("domains", [])
264
+
265
+ # Everything else is tool-specific options
266
+ options = {
267
+ k: v for k, v in tool_data.items()
268
+ if k not in ("name", "config", "strict", "domains")
269
+ }
270
+
271
+ return ToolConfig(
272
+ name=name,
273
+ config=config_path,
274
+ strict=strict,
275
+ domains=domains,
276
+ options=options,
277
+ )
278
+
279
+
280
+ def _parse_domain_pipeline_config(
281
+ domain_data: Optional[Dict[str, Any]]
282
+ ) -> Optional[DomainPipelineConfig]:
283
+ """Parse a domain pipeline configuration (linting, type_checking, etc.).
284
+
285
+ Args:
286
+ domain_data: Domain configuration dictionary or None.
287
+
288
+ Returns:
289
+ DomainPipelineConfig instance or None if not configured.
290
+ """
291
+ if domain_data is None:
292
+ return None
293
+
294
+ enabled = domain_data.get("enabled", True)
295
+ tools_data = domain_data.get("tools", [])
296
+
297
+ tools = []
298
+ for tool_data in tools_data:
299
+ if isinstance(tool_data, dict):
300
+ tools.append(_parse_tool_config(tool_data))
301
+ elif isinstance(tool_data, str):
302
+ # Simple string format: just the tool name
303
+ tools.append(ToolConfig(name=tool_data))
304
+
305
+ return DomainPipelineConfig(enabled=enabled, tools=tools)
306
+
307
+
308
+ def _parse_coverage_pipeline_config(
309
+ coverage_data: Optional[Dict[str, Any]]
310
+ ) -> Optional[CoveragePipelineConfig]:
311
+ """Parse coverage pipeline configuration.
312
+
313
+ Args:
314
+ coverage_data: Coverage configuration dictionary or None.
315
+
316
+ Returns:
317
+ CoveragePipelineConfig instance or None if not configured.
318
+ """
319
+ if coverage_data is None:
320
+ return None
321
+
322
+ # Parse tools the same way as _parse_domain_pipeline_config
323
+ tools_data = coverage_data.get("tools", [])
324
+ tools = []
325
+ for tool_data in tools_data:
326
+ if isinstance(tool_data, dict):
327
+ tools.append(_parse_tool_config(tool_data))
328
+ elif isinstance(tool_data, str):
329
+ # Simple string format: just the tool name
330
+ tools.append(ToolConfig(name=tool_data))
331
+
332
+ return CoveragePipelineConfig(
333
+ enabled=coverage_data.get("enabled", False),
334
+ threshold=coverage_data.get("threshold", 80),
335
+ tools=tools,
336
+ )
337
+
338
+
339
+ def dict_to_config(data: Dict[str, Any]) -> LucidScanConfig:
340
+ """Convert validated dict to typed LucidScanConfig.
341
+
342
+ Args:
343
+ data: Configuration dictionary.
344
+
345
+ Returns:
346
+ Typed LucidScanConfig instance.
347
+ """
348
+ # Parse output config
349
+ output_data = data.get("output", {})
350
+ output = OutputConfig(
351
+ format=output_data.get("format", "json"),
352
+ )
353
+
354
+ # Parse scanner configs
355
+ scanners: Dict[str, ScannerDomainConfig] = {}
356
+ scanners_data = data.get("scanners", {})
357
+
358
+ for domain, domain_data in scanners_data.items():
359
+ if not isinstance(domain_data, dict):
360
+ continue
361
+
362
+ # Extract framework-level keys
363
+ enabled = domain_data.get("enabled", True)
364
+ plugin = domain_data.get("plugin", "")
365
+
366
+ # Everything else is plugin-specific options
367
+ options = {k: v for k, v in domain_data.items() if k not in ("enabled", "plugin")}
368
+
369
+ scanners[domain] = ScannerDomainConfig(
370
+ enabled=enabled,
371
+ plugin=plugin,
372
+ options=options,
373
+ )
374
+
375
+ # Parse enrichers (passthrough for now)
376
+ enrichers = data.get("enrichers", {})
377
+
378
+ # Parse pipeline config
379
+ pipeline_data = data.get("pipeline", {})
380
+ pipeline = PipelineConfig(
381
+ enrichers=pipeline_data.get("enrichers", []),
382
+ max_workers=pipeline_data.get("max_workers", 4),
383
+ linting=_parse_domain_pipeline_config(pipeline_data.get("linting")),
384
+ type_checking=_parse_domain_pipeline_config(pipeline_data.get("type_checking")),
385
+ testing=_parse_domain_pipeline_config(pipeline_data.get("testing")),
386
+ coverage=_parse_coverage_pipeline_config(pipeline_data.get("coverage")),
387
+ security=_parse_domain_pipeline_config(pipeline_data.get("security")),
388
+ )
389
+
390
+ # Parse project config
391
+ project_data = data.get("project", {})
392
+ project = ProjectConfig(
393
+ name=project_data.get("name", ""),
394
+ languages=project_data.get("languages", []),
395
+ )
396
+
397
+ # Parse fail_on (string or dict format)
398
+ fail_on_data = data.get("fail_on")
399
+ fail_on: str | FailOnConfig | None = None
400
+ if fail_on_data is not None:
401
+ if isinstance(fail_on_data, str):
402
+ # Legacy string format - keep as string
403
+ fail_on = fail_on_data
404
+ elif isinstance(fail_on_data, dict):
405
+ # Dict format - convert to FailOnConfig
406
+ fail_on = FailOnConfig(
407
+ linting=fail_on_data.get("linting"),
408
+ type_checking=fail_on_data.get("type_checking"),
409
+ security=fail_on_data.get("security"),
410
+ testing=fail_on_data.get("testing"),
411
+ coverage=fail_on_data.get("coverage"),
412
+ )
413
+
414
+ return LucidScanConfig(
415
+ project=project,
416
+ fail_on=fail_on,
417
+ ignore=data.get("ignore", []),
418
+ output=output,
419
+ scanners=scanners,
420
+ enrichers=enrichers,
421
+ pipeline=pipeline,
422
+ )
423
+
424
+
425
+ def get_default_config() -> LucidScanConfig:
426
+ """Get default configuration with no scanners enabled.
427
+
428
+ Returns:
429
+ Default LucidScanConfig instance.
430
+ """
431
+ return LucidScanConfig()