vibescan-cli 0.1.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.
- vibescan/__init__.py +3 -0
- vibescan/__main__.py +5 -0
- vibescan/aggregator/__init__.py +0 -0
- vibescan/cli.py +85 -0
- vibescan/collector/__init__.py +4 -0
- vibescan/collector/context.py +19 -0
- vibescan/collector/file_collector.py +90 -0
- vibescan/collector/gitignore_parser.py +24 -0
- vibescan/models/__init__.py +4 -0
- vibescan/models/issue.py +45 -0
- vibescan/models/scan_result.py +27 -0
- vibescan/reporters/__init__.py +0 -0
- vibescan/reporters/console.py +89 -0
- vibescan/rules/__init__.py +4 -0
- vibescan/rules/base.py +14 -0
- vibescan/rules/dangerous_patterns.py +296 -0
- vibescan/rules/git_hygiene.py +175 -0
- vibescan/rules/registry.py +44 -0
- vibescan/rules/secret/__init__.py +0 -0
- vibescan/rules/secret/cicd_pipeline.py +67 -0
- vibescan/rules/secret/cloud_credentials.py +69 -0
- vibescan/rules/secret/config_hardcode.py +65 -0
- vibescan/rules/secret/data_files.py +84 -0
- vibescan/rules/secret/doc_secrets.py +69 -0
- vibescan/rules/secret/docker_infra.py +75 -0
- vibescan/rules/secret/editor_remnants.py +91 -0
- vibescan/rules/secret/env_exposure.py +57 -0
- vibescan/rules/secret/frontend_env.py +75 -0
- vibescan/rules/secret/hardcoded_patterns.py +108 -0
- vibescan/rules/secret/ide_settings.py +82 -0
- vibescan/rules/secret/mobile_files.py +57 -0
- vibescan/rules/secret/private_keys.py +67 -0
- vibescan/rules/secret/system_configs.py +71 -0
- vibescan/rules/structure.py +168 -0
- vibescan/templates/.gitkeep +0 -0
- vibescan_cli-0.1.0.dist-info/METADATA +220 -0
- vibescan_cli-0.1.0.dist-info/RECORD +39 -0
- vibescan_cli-0.1.0.dist-info/WHEEL +4 -0
- vibescan_cli-0.1.0.dist-info/entry_points.txt +2 -0
vibescan/__init__.py
ADDED
vibescan/__main__.py
ADDED
|
File without changes
|
vibescan/cli.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""CLI entry point using typer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from vibescan import __version__
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
name="vibescan",
|
|
13
|
+
help="Scan your project for leaked secrets and security issues.",
|
|
14
|
+
add_completion=False,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _version_callback(value: bool) -> None:
|
|
19
|
+
if value:
|
|
20
|
+
typer.echo(f"vibescan {__version__}")
|
|
21
|
+
raise typer.Exit()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command()
|
|
25
|
+
def scan(
|
|
26
|
+
path: Path = typer.Argument(
|
|
27
|
+
".",
|
|
28
|
+
help="Project directory to scan.",
|
|
29
|
+
exists=True,
|
|
30
|
+
file_okay=False,
|
|
31
|
+
resolve_path=True,
|
|
32
|
+
),
|
|
33
|
+
min_severity: str = typer.Option(
|
|
34
|
+
"info",
|
|
35
|
+
"--min-severity", "-s",
|
|
36
|
+
help="Minimum severity to report: critical, high, medium, low, info.",
|
|
37
|
+
),
|
|
38
|
+
version: bool = typer.Option(
|
|
39
|
+
False,
|
|
40
|
+
"--version", "-v",
|
|
41
|
+
help="Show version and exit.",
|
|
42
|
+
callback=_version_callback,
|
|
43
|
+
is_eager=True,
|
|
44
|
+
),
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Scan a project directory for security issues."""
|
|
47
|
+
from vibescan.collector import collect
|
|
48
|
+
from vibescan.models import ScanResult, Severity
|
|
49
|
+
from vibescan.reporters.console import print_report
|
|
50
|
+
from vibescan.rules import get_all_rules
|
|
51
|
+
|
|
52
|
+
# Validate min_severity
|
|
53
|
+
try:
|
|
54
|
+
threshold = Severity(min_severity.lower())
|
|
55
|
+
except ValueError:
|
|
56
|
+
typer.echo(f"Error: Invalid severity '{min_severity}'. "
|
|
57
|
+
f"Choose from: critical, high, medium, low, info.")
|
|
58
|
+
raise typer.Exit(code=2)
|
|
59
|
+
|
|
60
|
+
# Collect project context
|
|
61
|
+
ctx = collect(path)
|
|
62
|
+
|
|
63
|
+
# Run rules
|
|
64
|
+
all_issues = []
|
|
65
|
+
for rule in get_all_rules():
|
|
66
|
+
all_issues.extend(rule.run(ctx))
|
|
67
|
+
|
|
68
|
+
# Filter by severity
|
|
69
|
+
filtered = [i for i in all_issues if i.severity >= threshold]
|
|
70
|
+
|
|
71
|
+
# Sort: critical first
|
|
72
|
+
filtered.sort(key=lambda i: -i.severity.rank)
|
|
73
|
+
|
|
74
|
+
# Build result
|
|
75
|
+
result = ScanResult(
|
|
76
|
+
issues=filtered,
|
|
77
|
+
project_root=str(ctx.project_root),
|
|
78
|
+
files_scanned=len(ctx.text_files),
|
|
79
|
+
files_skipped=len(ctx.skipped_files),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Report
|
|
83
|
+
print_report(result)
|
|
84
|
+
|
|
85
|
+
raise typer.Exit(code=result.exit_code)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class TextFile:
|
|
9
|
+
path: str
|
|
10
|
+
content: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ProjectContext:
|
|
15
|
+
project_root: Path
|
|
16
|
+
text_files: list[TextFile] = field(default_factory=list)
|
|
17
|
+
all_files: list[str] = field(default_factory=list)
|
|
18
|
+
gitignore_patterns: list[str] = field(default_factory=list)
|
|
19
|
+
skipped_files: list[str] = field(default_factory=list)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""File Collector - collects text files, all file paths, and .gitignore patterns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from vibescan.collector.context import ProjectContext, TextFile
|
|
8
|
+
from vibescan.collector.gitignore_parser import parse_gitignore_files
|
|
9
|
+
|
|
10
|
+
EXCLUDED_DIRS: set[str] = {
|
|
11
|
+
"node_modules", ".git", ".venv", "venv",
|
|
12
|
+
"build", "dist", "coverage", "__pycache__",
|
|
13
|
+
".next", ".nuxt", ".output",
|
|
14
|
+
"vendor", "target", ".gradle",
|
|
15
|
+
".tox", "eggs", ".mypy_cache", ".pytest_cache",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
TEXT_EXTENSIONS: set[str] = {
|
|
19
|
+
".py", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs",
|
|
20
|
+
".java", ".go", ".rs", ".rb", ".php", ".c", ".cpp", ".h",
|
|
21
|
+
".cs", ".swift", ".kt", ".kts", ".scala",
|
|
22
|
+
".html", ".htm", ".css", ".scss", ".less",
|
|
23
|
+
".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf",
|
|
24
|
+
".xml", ".csv", ".sql", ".sh", ".bash", ".zsh",
|
|
25
|
+
".md", ".txt", ".rst", ".env", ".properties",
|
|
26
|
+
".gradle", ".sbt", ".pom",
|
|
27
|
+
".tf", ".hcl", ".vue", ".svelte",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
TEXT_FILENAMES: set[str] = {
|
|
31
|
+
"Dockerfile", "Makefile", "Jenkinsfile", "Procfile",
|
|
32
|
+
"Caddyfile", "Gemfile", "Rakefile", "Fastfile",
|
|
33
|
+
".gitignore", ".dockerignore", ".editorconfig",
|
|
34
|
+
".htaccess", ".npmrc", ".pypirc", ".netrc",
|
|
35
|
+
".pgpass", ".my.cnf", ".boto",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
MAX_FILE_SIZE: int = 5 * 1024 * 1024 # 5MB
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_text_file(path: Path) -> bool:
|
|
42
|
+
return path.suffix.lower() in TEXT_EXTENSIONS or path.name in TEXT_FILENAMES
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _should_skip_dir(name: str) -> bool:
|
|
46
|
+
return name in EXCLUDED_DIRS or name.endswith(".egg-info")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def collect(root: Path) -> ProjectContext:
|
|
50
|
+
root = root.resolve()
|
|
51
|
+
ctx = ProjectContext(project_root=root)
|
|
52
|
+
|
|
53
|
+
for item in _walk(root):
|
|
54
|
+
rel = str(item.relative_to(root))
|
|
55
|
+
ctx.all_files.append(rel)
|
|
56
|
+
|
|
57
|
+
if not _is_text_file(item):
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
if item.stat().st_size > MAX_FILE_SIZE:
|
|
61
|
+
ctx.skipped_files.append(rel)
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
content = item.read_text(encoding="utf-8")
|
|
66
|
+
except (UnicodeDecodeError, PermissionError, OSError):
|
|
67
|
+
ctx.skipped_files.append(rel)
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
ctx.text_files.append(TextFile(path=rel, content=content))
|
|
71
|
+
|
|
72
|
+
ctx.gitignore_patterns = parse_gitignore_files(root)
|
|
73
|
+
return ctx
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _walk(root: Path):
|
|
77
|
+
"""Recursively yield files, skipping excluded dirs and symlinks."""
|
|
78
|
+
try:
|
|
79
|
+
entries = sorted(root.iterdir())
|
|
80
|
+
except PermissionError:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
for entry in entries:
|
|
84
|
+
if entry.is_symlink():
|
|
85
|
+
continue
|
|
86
|
+
if entry.is_dir():
|
|
87
|
+
if not _should_skip_dir(entry.name):
|
|
88
|
+
yield from _walk(entry)
|
|
89
|
+
elif entry.is_file():
|
|
90
|
+
yield entry
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Parse .gitignore files and extract patterns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_gitignore_files(root: Path) -> list[str]:
|
|
9
|
+
patterns: list[str] = []
|
|
10
|
+
|
|
11
|
+
for gitignore in root.rglob(".gitignore"):
|
|
12
|
+
if gitignore.is_symlink():
|
|
13
|
+
continue
|
|
14
|
+
try:
|
|
15
|
+
text = gitignore.read_text(encoding="utf-8")
|
|
16
|
+
except (UnicodeDecodeError, PermissionError, OSError):
|
|
17
|
+
continue
|
|
18
|
+
|
|
19
|
+
for line in text.splitlines():
|
|
20
|
+
stripped = line.strip()
|
|
21
|
+
if stripped and not stripped.startswith("#"):
|
|
22
|
+
patterns.append(stripped)
|
|
23
|
+
|
|
24
|
+
return patterns
|
vibescan/models/issue.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Severity(str, Enum):
|
|
8
|
+
CRITICAL = "critical"
|
|
9
|
+
HIGH = "high"
|
|
10
|
+
MEDIUM = "medium"
|
|
11
|
+
LOW = "low"
|
|
12
|
+
INFO = "info"
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def rank(self) -> int:
|
|
16
|
+
return {
|
|
17
|
+
Severity.CRITICAL: 4,
|
|
18
|
+
Severity.HIGH: 3,
|
|
19
|
+
Severity.MEDIUM: 2,
|
|
20
|
+
Severity.LOW: 1,
|
|
21
|
+
Severity.INFO: 0,
|
|
22
|
+
}[self]
|
|
23
|
+
|
|
24
|
+
def __ge__(self, other: Severity) -> bool:
|
|
25
|
+
return self.rank >= other.rank
|
|
26
|
+
|
|
27
|
+
def __gt__(self, other: Severity) -> bool:
|
|
28
|
+
return self.rank > other.rank
|
|
29
|
+
|
|
30
|
+
def __le__(self, other: Severity) -> bool:
|
|
31
|
+
return self.rank <= other.rank
|
|
32
|
+
|
|
33
|
+
def __lt__(self, other: Severity) -> bool:
|
|
34
|
+
return self.rank < other.rank
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Issue:
|
|
39
|
+
rule_id: str
|
|
40
|
+
severity: Severity
|
|
41
|
+
file: str
|
|
42
|
+
line: int | None
|
|
43
|
+
message: str
|
|
44
|
+
why: str
|
|
45
|
+
fix: str
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from vibescan.models.issue import Issue, Severity
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ScanResult:
|
|
10
|
+
issues: list[Issue] = field(default_factory=list)
|
|
11
|
+
project_root: str = ""
|
|
12
|
+
files_scanned: int = 0
|
|
13
|
+
files_skipped: int = 0
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def summary(self) -> dict[str, int]:
|
|
17
|
+
counts: dict[str, int] = {}
|
|
18
|
+
for sev in Severity:
|
|
19
|
+
counts[sev.value] = sum(1 for i in self.issues if i.severity == sev)
|
|
20
|
+
return counts
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def exit_code(self) -> int:
|
|
24
|
+
for issue in self.issues:
|
|
25
|
+
if issue.severity in (Severity.CRITICAL, Severity.HIGH):
|
|
26
|
+
return 1
|
|
27
|
+
return 0
|
|
File without changes
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Console Reporter - rich-based colored terminal output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from vibescan.models.issue import Severity
|
|
11
|
+
from vibescan.models.scan_result import ScanResult
|
|
12
|
+
|
|
13
|
+
SEVERITY_COLORS: dict[Severity, str] = {
|
|
14
|
+
Severity.CRITICAL: "bold red",
|
|
15
|
+
Severity.HIGH: "red",
|
|
16
|
+
Severity.MEDIUM: "yellow",
|
|
17
|
+
Severity.LOW: "cyan",
|
|
18
|
+
Severity.INFO: "dim",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
SEVERITY_ICONS: dict[Severity, str] = {
|
|
22
|
+
Severity.CRITICAL: "[!]",
|
|
23
|
+
Severity.HIGH: "[H]",
|
|
24
|
+
Severity.MEDIUM: "[M]",
|
|
25
|
+
Severity.LOW: "[L]",
|
|
26
|
+
Severity.INFO: "[i]",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def print_report(result: ScanResult, console: Console | None = None) -> None:
|
|
31
|
+
console = console or Console()
|
|
32
|
+
|
|
33
|
+
# Header
|
|
34
|
+
console.print()
|
|
35
|
+
console.print(
|
|
36
|
+
Panel(
|
|
37
|
+
f"[bold]VibeScan[/bold] scanned [cyan]{result.files_scanned}[/cyan] files "
|
|
38
|
+
f"in [cyan]{result.project_root}[/cyan]",
|
|
39
|
+
title="Scan Complete",
|
|
40
|
+
border_style="blue",
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if not result.issues:
|
|
45
|
+
console.print("\n[bold green]No issues found. Your project looks clean![/bold green]\n")
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
# Summary table
|
|
49
|
+
summary = result.summary
|
|
50
|
+
summary_table = Table(title="Summary", show_header=False, box=None, padding=(0, 2))
|
|
51
|
+
summary_table.add_column("Severity", style="bold")
|
|
52
|
+
summary_table.add_column("Count", justify="right")
|
|
53
|
+
for sev in Severity:
|
|
54
|
+
count = summary[sev.value]
|
|
55
|
+
if count > 0:
|
|
56
|
+
style = SEVERITY_COLORS[sev]
|
|
57
|
+
summary_table.add_row(
|
|
58
|
+
Text(sev.value.upper(), style=style),
|
|
59
|
+
Text(str(count), style=style),
|
|
60
|
+
)
|
|
61
|
+
console.print(summary_table)
|
|
62
|
+
console.print()
|
|
63
|
+
|
|
64
|
+
# Issues grouped by file
|
|
65
|
+
issues_by_file: dict[str, list] = {}
|
|
66
|
+
for issue in result.issues:
|
|
67
|
+
issues_by_file.setdefault(issue.file, []).append(issue)
|
|
68
|
+
|
|
69
|
+
for file_path, issues in sorted(issues_by_file.items()):
|
|
70
|
+
console.print(f"[bold underline]{file_path}[/bold underline]")
|
|
71
|
+
for issue in issues:
|
|
72
|
+
sev = issue.severity
|
|
73
|
+
icon = SEVERITY_ICONS[sev]
|
|
74
|
+
color = SEVERITY_COLORS[sev]
|
|
75
|
+
loc = f":{issue.line}" if issue.line else ""
|
|
76
|
+
|
|
77
|
+
console.print(f" [{color}]{icon}[/{color}] {issue.message}")
|
|
78
|
+
if issue.line:
|
|
79
|
+
console.print(f" Line {issue.line}")
|
|
80
|
+
console.print(f" [dim]Why:[/dim] {issue.why}")
|
|
81
|
+
console.print(f" [dim]Fix:[/dim] {issue.fix}")
|
|
82
|
+
console.print()
|
|
83
|
+
|
|
84
|
+
# Exit code hint
|
|
85
|
+
if result.exit_code != 0:
|
|
86
|
+
console.print("[bold red]Exit code 1: CRITICAL or HIGH issues found.[/bold red]")
|
|
87
|
+
else:
|
|
88
|
+
console.print("[bold green]Exit code 0: No critical issues.[/bold green]")
|
|
89
|
+
console.print()
|
vibescan/rules/base.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Base class for all rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from vibescan.collector.context import ProjectContext
|
|
8
|
+
from vibescan.models.issue import Issue
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseRule(ABC):
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def run(self, ctx: ProjectContext) -> list[Issue]:
|
|
14
|
+
...
|