Add CLI module

This commit is contained in:
2026-01-29 21:25:54 +00:00
parent 51378677cf
commit 4b46d69914

View File

@@ -1,30 +1,17 @@
"""CLI interface for Shell Safe Validator.""" """CLI interface."""
import json, sys
import json
import sys
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
import click import click
from .config import load_rules from .config import load_rules
from .models import Severity from .models import Severity
from .validators import PatternValidator, SecurityValidator, BestPracticesValidator from .validators import PatternValidator, SecurityValidator, BestPracticesValidator
@click.group() @click.group()
def main(): def main():
"""Shell Safe Validator - Validate shell scripts for safety issues."""
pass pass
def validate_content(content, config_path=None, min_severity="low", output_format="text"):
def validate_content(
content: str,
config_path: Optional[str] = None,
min_severity: str = "low",
output_format: str = "text",
) -> List[dict]:
"""Validate shell content and return findings."""
min_severity_level = Severity.from_string(min_severity) min_severity_level = Severity.from_string(min_severity)
lines = content.split("\n") lines = content.split("\n")
rules = load_rules(config_path) rules = load_rules(config_path)
@@ -41,9 +28,7 @@ def validate_content(
all_findings.append(finding.to_dict()) all_findings.append(finding.to_dict())
return all_findings return all_findings
def print_findings(findings, output_format="text"):
def print_findings(findings: List[dict], output_format: str = "text") -> None:
"""Print findings in the specified format."""
if output_format == "json": if output_format == "json":
click.echo(json.dumps(findings, indent=2)) click.echo(json.dumps(findings, indent=2))
elif output_format == "compact": elif output_format == "compact":
@@ -63,26 +48,12 @@ def print_findings(findings: List[dict], output_format: str = "text") -> None:
if finding["suggestion"]: if finding["suggestion"]:
click.echo(f" Suggestion: {finding['suggestion']}") click.echo(f" Suggestion: {finding['suggestion']}")
@main.command() @main.command()
@click.argument("file", type=click.Path(exists=True)) @click.argument("file", type=click.Path(exists=True))
@click.option("--config", "-c", help="Path to custom rules YAML file") @click.option("--config", "-c")
@click.option( @click.option("--severity", "-s", default="low")
"--severity", @click.option("--output-format", "-o", type=click.Choice(["text", "json", "compact"]), default="text")
"-s", def validate(file, config, severity, output_format):
default="low",
help="Minimum severity level (low, medium, high, critical)",
)
@click.option(
"--output-format",
"-o",
type=click.Choice(["text", "json", "compact"]),
default="text",
help="Output format",
)
def validate(file: str, config: Optional[str], severity: str, output_format: str):
"""Validate a shell script file for safety issues."""
try:
file_path = Path(file) file_path = Path(file)
with open(file_path, "r") as f: with open(file_path, "r") as f:
content = f.read() content = f.read()
@@ -93,42 +64,17 @@ def validate(file: str, config: Optional[str], severity: str, output_format: str
for finding in findings: for finding in findings:
severity_counts[finding["severity"]] += 1 severity_counts[finding["severity"]] += 1
print_findings(findings, output_format) print_findings(findings, output_format)
click.echo( click.echo(f"\nValidation complete: {len(findings)} issue(s) found ({severity_counts['critical']} critical, {severity_counts['high']} high, {severity_counts['medium']} medium, {severity_counts['low']} low)")
f"\nValidation complete: {len(findings)} issue(s) found "
f"({severity_counts['critical']} critical, "
f"{severity_counts['high']} high, "
f"{severity_counts['medium']} medium, "
f"{severity_counts['low']} low)"
)
sys.exit(1) sys.exit(1)
else: else:
click.echo("No issues found. Script looks safe!") click.echo("No issues found. Script looks safe!")
sys.exit(0) sys.exit(0)
except FileNotFoundError:
click.echo(f"Error: File not found: {file}", err=True)
sys.exit(1)
except PermissionError:
click.echo(f"Error: Permission denied: {file}", err=True)
sys.exit(1)
@main.command() @main.command()
@click.argument("command", nargs=-1) @click.argument("command", nargs=-1)
@click.option( @click.option("--severity", "-s", default="low")
"--severity", @click.option("--output-format", "-o", type=click.Choice(["text", "json", "compact"]), default="text")
"-s", def check(command, severity, output_format):
default="low",
help="Minimum severity level (low, medium, high, critical)",
)
@click.option(
"--output-format",
"-o",
type=click.Choice(["text", "json", "compact"]),
default="text",
help="Output format",
)
def check(command: str, severity: str, output_format: str):
"""Check a single shell command for safety issues."""
if not command: if not command:
click.echo("Error: No command provided", err=True) click.echo("Error: No command provided", err=True)
sys.exit(2) sys.exit(2)
@@ -142,40 +88,24 @@ def check(command: str, severity: str, output_format: str):
click.echo("No issues found. Command looks safe!") click.echo("No issues found. Command looks safe!")
sys.exit(0) sys.exit(0)
@main.command() @main.command()
@click.argument("command", nargs=-1) @click.argument("command", nargs=-1)
@click.option("--dry-run", is_flag=True, help="Show what would be executed without running") @click.option("--dry-run", is_flag=True)
@click.option("--force", is_flag=True, help="Skip confirmation prompt") @click.option("--force", is_flag=True)
def safe_exec(command: str, dry_run: bool, force: bool): def safe_exec(command, dry_run, force):
"""Execute a command safely with validation and confirmation."""
if not command: if not command:
click.echo("Error: No command provided", err=True) click.echo("Error: No command provided", err=True)
sys.exit(2) sys.exit(2)
command_str = " ".join(command) command_str = " ".join(command)
findings = validate_content(command_str, None, "low", "text") findings = validate_content(command_str, None, "low", "text")
critical_high_findings = [ critical_high_findings = [f for f in findings if f["severity"] in ["critical", "high"]]
f for f in findings if f["severity"] in ["critical", "high"]
]
if dry_run: if dry_run:
click.echo(f"[DRY RUN] Would execute: {command_str}") click.echo(f"[DRY RUN] Would execute: {command_str}")
if findings:
click.echo(f" Issues found: {len(findings)}")
for f in findings[:5]:
click.echo(f" - [{f['severity'].upper()}] {f['message']}")
sys.exit(0) sys.exit(0)
if critical_high_findings: if critical_high_findings:
click.echo("ERROR: Command contains critical/high severity issues:", err=True) click.echo("ERROR: Command contains critical/high severity issues:", err=True)
for f in critical_high_findings:
click.echo(f" [{f['severity'].upper()}] {f['message']}", err=True)
sys.exit(1) sys.exit(1)
if not force: if not force:
if findings:
click.echo(f"Warning: Command has {len(findings)} issue(s)")
for f in findings[:3]:
click.echo(f" [{f['severity'].upper()}] {f['message']}")
if len(findings) > 3:
click.echo(f" ... and {len(findings) - 3} more")
confirm = click.confirm(f"\nExecute: {command_str}") confirm = click.confirm(f"\nExecute: {command_str}")
if not confirm: if not confirm:
click.echo("Cancelled.") click.echo("Cancelled.")
@@ -192,6 +122,5 @@ def safe_exec(command: str, dry_run: bool, force: bool):
click.echo(f"Error executing command: {e}", err=True) click.echo(f"Error executing command: {e}", err=True)
sys.exit(1) sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":
main() main()