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/cli.py ADDED
@@ -0,0 +1,517 @@
1
+ """CVE Sentinel CLI with subcommand support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import logging
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import List, Optional
13
+
14
+ from cve_sentinel.config import ConfigError, load_config
15
+ from cve_sentinel.scanner import CVESentinelScanner, __version__, setup_logging
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Default configuration template
20
+ CONFIG_TEMPLATE = """\
21
+ # CVE Sentinel Configuration
22
+ # See: https://github.com/xxx/cve-sentinel#configuration
23
+
24
+ # Target directory to scan (relative to this config file)
25
+ target_path: "."
26
+
27
+ # Paths to exclude from scanning (glob patterns)
28
+ exclude:
29
+ - "node_modules/"
30
+ - "vendor/"
31
+ - ".git/"
32
+ - "__pycache__/"
33
+ - "venv/"
34
+ - ".venv/"
35
+ - "dist/"
36
+ - "build/"
37
+
38
+ # Analysis level:
39
+ # 1: Direct dependencies from manifest files only
40
+ # 2: Include transitive dependencies from lock files
41
+ # 3: Include import statement scanning in source code
42
+ analysis_level: 2
43
+
44
+ # Automatically scan when Claude Code session starts
45
+ auto_scan_on_startup: true
46
+
47
+ # Cache time-to-live in hours (CVE data cache)
48
+ cache_ttl_hours: 24
49
+
50
+ # NVD API key (recommended for better rate limits)
51
+ # Get your key at: https://nvd.nist.gov/developers/request-an-api-key
52
+ # Best practice: Set via environment variable CVE_SENTINEL_NVD_API_KEY
53
+ # nvd_api_key: "your-api-key-here"
54
+
55
+ # Custom file patterns for dependency detection (optional)
56
+ # Use this to scan non-standard dependency files
57
+ # Valid ecosystems: javascript, python, go, java, ruby, rust, php
58
+ # custom_patterns:
59
+ # python:
60
+ # manifests:
61
+ # - "deps/*.txt"
62
+ # - "custom-requirements.txt"
63
+ # locks:
64
+ # - "custom.lock"
65
+ # javascript:
66
+ # manifests:
67
+ # - "dependencies.json"
68
+ """
69
+
70
+ # CLAUDE.md addition template
71
+ CLAUDE_MD_ADDITION = """\
72
+
73
+ ## CVE Sentinel Integration
74
+
75
+ This project uses CVE Sentinel for automatic vulnerability detection.
76
+
77
+ ### Automatic Scanning
78
+ CVE Sentinel automatically scans dependencies when a Claude Code session starts.
79
+ Check `.cve-sentinel/status.json` for scan status and `.cve-sentinel/results.json` for results.
80
+
81
+ ### Manual Commands
82
+ ```bash
83
+ # Scan current directory (no config file needed)
84
+ cve-sentinel scan
85
+
86
+ # Scan specific path
87
+ cve-sentinel scan /path/to/project
88
+
89
+ # With options
90
+ cve-sentinel scan --level 2 --exclude "test/*" --verbose
91
+ ```
92
+
93
+ ### Configuration (Optional)
94
+ For persistent settings, create `.cve-sentinel.yaml`. CLI options override config file settings.
95
+ """
96
+
97
+
98
+ def cmd_scan(args: argparse.Namespace) -> int:
99
+ """Execute the scan command."""
100
+ setup_logging(verbose=args.verbose)
101
+
102
+ target_path = args.path.resolve()
103
+
104
+ try:
105
+ # Build CLI overrides
106
+ cli_overrides: dict = {}
107
+ if args.level is not None:
108
+ cli_overrides["analysis_level"] = args.level
109
+ if args.exclude:
110
+ cli_overrides["exclude"] = args.exclude
111
+
112
+ config = load_config(
113
+ base_path=target_path,
114
+ validate=True,
115
+ require_api_key=False,
116
+ cli_overrides=cli_overrides,
117
+ )
118
+
119
+ scanner = CVESentinelScanner(config)
120
+ result = scanner.scan(target_path)
121
+
122
+ if not result.success:
123
+ logger.error("Scan failed with errors")
124
+ for error in result.errors:
125
+ logger.error(f" - {error}")
126
+ return 2
127
+
128
+ # Determine exit code based on vulnerabilities
129
+ if result.has_vulnerabilities:
130
+ severity_order = ["CRITICAL", "HIGH", "MEDIUM", "LOW"]
131
+ threshold_index = severity_order.index(args.fail_on)
132
+
133
+ for vuln in result.vulnerabilities:
134
+ severity = (vuln.severity or "UNKNOWN").upper()
135
+ if severity in severity_order:
136
+ if severity_order.index(severity) <= threshold_index:
137
+ return 1
138
+
139
+ return 0
140
+
141
+ except ConfigError as e:
142
+ logger.error(f"Configuration error: {e}")
143
+ return 2
144
+ except KeyboardInterrupt:
145
+ logger.info("Scan cancelled by user")
146
+ return 2
147
+ except Exception as e:
148
+ logger.exception(f"Unexpected error: {e}")
149
+ return 2
150
+
151
+
152
+ def cmd_init(args: argparse.Namespace) -> int:
153
+ """Execute the init command."""
154
+ setup_logging(verbose=args.verbose)
155
+
156
+ target_path = args.path.resolve()
157
+
158
+ print(f"Initializing CVE Sentinel in: {target_path}")
159
+
160
+ # Create .cve-sentinel.yaml
161
+ config_file = target_path / ".cve-sentinel.yaml"
162
+ if config_file.exists() and not args.force:
163
+ print(f"Configuration file already exists: {config_file}")
164
+ print("Use --force to overwrite.")
165
+ else:
166
+ config_file.write_text(CONFIG_TEMPLATE)
167
+ print(f"Created: {config_file}")
168
+
169
+ # Create .cve-sentinel directory
170
+ sentinel_dir = target_path / ".cve-sentinel"
171
+ sentinel_dir.mkdir(parents=True, exist_ok=True)
172
+ print(f"Created: {sentinel_dir}/")
173
+
174
+ # Add to .gitignore if it exists
175
+ gitignore = target_path / ".gitignore"
176
+ gitignore_entries = [".cve-sentinel/"]
177
+
178
+ if gitignore.exists():
179
+ content = gitignore.read_text()
180
+ lines_to_add = [entry for entry in gitignore_entries if entry not in content]
181
+ if lines_to_add:
182
+ with open(gitignore, "a") as f:
183
+ f.write("\n# CVE Sentinel\n")
184
+ for entry in lines_to_add:
185
+ f.write(f"{entry}\n")
186
+ print(f"Updated: {gitignore}")
187
+ else:
188
+ with open(gitignore, "w") as f:
189
+ f.write("# CVE Sentinel\n")
190
+ for entry in gitignore_entries:
191
+ f.write(f"{entry}\n")
192
+ print(f"Created: {gitignore}")
193
+
194
+ # Show CLAUDE.md addition suggestion
195
+ print("\n" + "=" * 50)
196
+ print("Consider adding the following to your CLAUDE.md:")
197
+ print("=" * 50)
198
+ print(CLAUDE_MD_ADDITION)
199
+ print("=" * 50)
200
+
201
+ print("\nCVE Sentinel initialized successfully!")
202
+ print("\nNext steps:")
203
+ print(" 1. Run: cve-sentinel scan")
204
+ print(" 2. (Optional) Customize .cve-sentinel.yaml for persistent settings")
205
+
206
+ return 0
207
+
208
+
209
+ def cmd_uninstall(args: argparse.Namespace) -> int:
210
+ """Execute the uninstall command."""
211
+ setup_logging(verbose=args.verbose)
212
+
213
+ print("Uninstalling CVE Sentinel...")
214
+
215
+ # Confirm uninstall
216
+ if not args.yes:
217
+ response = input("Are you sure you want to uninstall CVE Sentinel? [y/N] ")
218
+ if response.lower() not in ("y", "yes"):
219
+ print("Uninstall cancelled.")
220
+ return 0
221
+
222
+ errors = []
223
+
224
+ # Remove Claude Code hook settings
225
+ settings_file = Path.home() / ".claude" / "settings.json"
226
+ if settings_file.exists():
227
+ try:
228
+ with open(settings_file) as f:
229
+ settings = json.load(f)
230
+
231
+ # Remove CVE Sentinel hook
232
+ if "hooks" in settings and "sessionStart" in settings["hooks"]:
233
+ settings["hooks"]["sessionStart"] = [
234
+ h
235
+ for h in settings["hooks"]["sessionStart"]
236
+ if not (isinstance(h, dict) and h.get("name") == "cve-sentinel")
237
+ ]
238
+
239
+ with open(settings_file, "w") as f:
240
+ json.dump(settings, f, indent=2)
241
+ print("Removed Claude Code hook settings")
242
+
243
+ except Exception as e:
244
+ errors.append(f"Failed to update Claude Code settings: {e}")
245
+
246
+ # Remove hook script
247
+ hook_script = Path.home() / ".claude" / "hooks" / "cve-sentinel-scan.sh"
248
+ if hook_script.exists():
249
+ try:
250
+ hook_script.unlink()
251
+ print(f"Removed: {hook_script}")
252
+ except Exception as e:
253
+ errors.append(f"Failed to remove hook script: {e}")
254
+
255
+ # Remove cache (optional)
256
+ if args.remove_cache:
257
+ cache_dir = Path.home() / ".cve-sentinel"
258
+ if cache_dir.exists():
259
+ try:
260
+ shutil.rmtree(cache_dir)
261
+ print(f"Removed cache: {cache_dir}")
262
+ except Exception as e:
263
+ errors.append(f"Failed to remove cache: {e}")
264
+
265
+ # Uninstall pip package
266
+ try:
267
+ result = subprocess.run(
268
+ [sys.executable, "-m", "pip", "uninstall", "-y", "cve-sentinel"],
269
+ capture_output=True,
270
+ text=True,
271
+ )
272
+ if result.returncode == 0:
273
+ print("Uninstalled CVE Sentinel package")
274
+ else:
275
+ errors.append(f"pip uninstall failed: {result.stderr}")
276
+ except Exception as e:
277
+ errors.append(f"Failed to uninstall package: {e}")
278
+
279
+ if errors:
280
+ print("\nCompleted with errors:")
281
+ for error in errors:
282
+ print(f" - {error}")
283
+ return 1
284
+
285
+ print("\nCVE Sentinel has been uninstalled.")
286
+ print("\nNote: Project-level .cve-sentinel.yaml and .cve-sentinel/ directories")
287
+ print("have not been removed. Delete them manually if desired.")
288
+
289
+ return 0
290
+
291
+
292
+ def cmd_update(args: argparse.Namespace) -> int:
293
+ """Execute the update command."""
294
+ setup_logging(verbose=args.verbose)
295
+
296
+ print("Updating CVE Sentinel...")
297
+
298
+ # Update pip package
299
+ try:
300
+ cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "cve-sentinel"]
301
+ if args.verbose:
302
+ result = subprocess.run(cmd, text=True)
303
+ else:
304
+ result = subprocess.run(cmd, capture_output=True, text=True)
305
+
306
+ if result.returncode == 0:
307
+ print("CVE Sentinel package updated successfully")
308
+ else:
309
+ if result.stderr:
310
+ print(f"Update failed: {result.stderr}")
311
+ return 1
312
+
313
+ except Exception as e:
314
+ print(f"Failed to update package: {e}")
315
+ return 1
316
+
317
+ # Update hook script
318
+ hooks_dir = Path.home() / ".claude" / "hooks"
319
+ hook_script = hooks_dir / "cve-sentinel-scan.sh"
320
+
321
+ if hooks_dir.exists():
322
+ hook_content = """\
323
+ #!/bin/bash
324
+ # CVE Sentinel SessionStart Hook
325
+ # This script is called when a Claude Code session starts
326
+
327
+ # Get the project directory from argument or current directory
328
+ PROJECT_DIR="${1:-.}"
329
+
330
+ # Check if .cve-sentinel.yaml exists or if there are dependency files
331
+ should_scan() {
332
+ if [ -f "$PROJECT_DIR/.cve-sentinel.yaml" ]; then
333
+ return 0
334
+ fi
335
+
336
+ # Check for common dependency files
337
+ for file in package.json requirements.txt pyproject.toml Gemfile Cargo.toml go.mod composer.json pom.xml build.gradle; do
338
+ if [ -f "$PROJECT_DIR/$file" ]; then
339
+ return 0
340
+ fi
341
+ done
342
+
343
+ return 1
344
+ }
345
+
346
+ # Run scan if applicable
347
+ if should_scan; then
348
+ # Run in background to not block Claude Code startup
349
+ nohup cve-sentinel scan --path "$PROJECT_DIR" > /dev/null 2>&1 &
350
+ fi
351
+ """
352
+ try:
353
+ hook_script.write_text(hook_content)
354
+ hook_script.chmod(0o755)
355
+ print(f"Updated hook script: {hook_script}")
356
+ except Exception as e:
357
+ print(f"Warning: Failed to update hook script: {e}")
358
+
359
+ print("\nCVE Sentinel has been updated to the latest version.")
360
+
361
+ return 0
362
+
363
+
364
+ def create_parser() -> argparse.ArgumentParser:
365
+ """Create the main argument parser with subcommands."""
366
+ parser = argparse.ArgumentParser(
367
+ prog="cve-sentinel",
368
+ description="CVE auto-detection and remediation for project dependencies",
369
+ )
370
+
371
+ parser.add_argument(
372
+ "--version",
373
+ "-V",
374
+ action="version",
375
+ version=f"%(prog)s {__version__}",
376
+ )
377
+
378
+ subparsers = parser.add_subparsers(
379
+ title="commands",
380
+ dest="command",
381
+ description="Available commands",
382
+ )
383
+
384
+ # Scan command (default behavior)
385
+ scan_parser = subparsers.add_parser(
386
+ "scan",
387
+ help="Scan project for CVE vulnerabilities",
388
+ description="Scan project dependencies for known CVE vulnerabilities",
389
+ )
390
+ scan_parser.add_argument(
391
+ "path",
392
+ type=Path,
393
+ nargs="?",
394
+ default=Path("."),
395
+ help="Path to the project directory (default: current directory)",
396
+ )
397
+ scan_parser.add_argument(
398
+ "--level",
399
+ "-l",
400
+ type=int,
401
+ choices=[1, 2, 3],
402
+ default=None,
403
+ help="Analysis level: 1=manifest only, 2=include lock files, 3=include import scanning",
404
+ )
405
+ scan_parser.add_argument(
406
+ "--exclude",
407
+ "-e",
408
+ action="append",
409
+ help="Path patterns to exclude (can be specified multiple times)",
410
+ )
411
+ scan_parser.add_argument(
412
+ "--verbose",
413
+ "-v",
414
+ action="store_true",
415
+ help="Enable verbose output",
416
+ )
417
+ scan_parser.add_argument(
418
+ "--fail-on",
419
+ choices=["CRITICAL", "HIGH", "MEDIUM", "LOW"],
420
+ default="HIGH",
421
+ help="Exit with error if vulnerabilities at or above this severity (default: HIGH)",
422
+ )
423
+ scan_parser.set_defaults(func=cmd_scan)
424
+
425
+ # Init command
426
+ init_parser = subparsers.add_parser(
427
+ "init",
428
+ help="Initialize CVE Sentinel in a project",
429
+ description="Create configuration files for CVE Sentinel in the project",
430
+ )
431
+ init_parser.add_argument(
432
+ "--path",
433
+ "-p",
434
+ type=Path,
435
+ default=Path("."),
436
+ help="Path to the project directory (default: current directory)",
437
+ )
438
+ init_parser.add_argument(
439
+ "--force",
440
+ "-f",
441
+ action="store_true",
442
+ help="Overwrite existing configuration",
443
+ )
444
+ init_parser.add_argument(
445
+ "--verbose",
446
+ "-v",
447
+ action="store_true",
448
+ help="Enable verbose output",
449
+ )
450
+ init_parser.set_defaults(func=cmd_init)
451
+
452
+ # Uninstall command
453
+ uninstall_parser = subparsers.add_parser(
454
+ "uninstall",
455
+ help="Uninstall CVE Sentinel",
456
+ description="Remove CVE Sentinel and its configuration",
457
+ )
458
+ uninstall_parser.add_argument(
459
+ "--yes",
460
+ "-y",
461
+ action="store_true",
462
+ help="Skip confirmation prompt",
463
+ )
464
+ uninstall_parser.add_argument(
465
+ "--remove-cache",
466
+ action="store_true",
467
+ help="Also remove cached CVE data",
468
+ )
469
+ uninstall_parser.add_argument(
470
+ "--verbose",
471
+ "-v",
472
+ action="store_true",
473
+ help="Enable verbose output",
474
+ )
475
+ uninstall_parser.set_defaults(func=cmd_uninstall)
476
+
477
+ # Update command
478
+ update_parser = subparsers.add_parser(
479
+ "update",
480
+ help="Update CVE Sentinel to the latest version",
481
+ description="Update CVE Sentinel package and hook scripts",
482
+ )
483
+ update_parser.add_argument(
484
+ "--verbose",
485
+ "-v",
486
+ action="store_true",
487
+ help="Enable verbose output",
488
+ )
489
+ update_parser.set_defaults(func=cmd_update)
490
+
491
+ return parser
492
+
493
+
494
+ def main(args: Optional[List[str]] = None) -> int:
495
+ """Main entry point for CVE Sentinel CLI."""
496
+ parser = create_parser()
497
+
498
+ # Handle shorthand: if first arg is not a known command, treat as path for scan
499
+ known_commands = {"scan", "init", "uninstall", "update"}
500
+ if args and args[0] not in known_commands and not args[0].startswith("-"):
501
+ # First arg looks like a path, prepend "scan"
502
+ args = ["scan", *args]
503
+
504
+ parsed_args = parser.parse_args(args)
505
+
506
+ # Default to scan if no command specified
507
+ if parsed_args.command is None:
508
+ # No arguments at all - run scan on current directory
509
+ scan_args = ["scan"]
510
+ parsed_args = parser.parse_args(scan_args)
511
+
512
+ # Execute command
513
+ if hasattr(parsed_args, "func"):
514
+ return parsed_args.func(parsed_args)
515
+
516
+ parser.print_help()
517
+ return 0