updates
This commit is contained in:
626
Backend/venv/lib/python3.12/site-packages/safety/tool/base.py
Normal file
626
Backend/venv/lib/python3.12/site-packages/safety/tool/base.py
Normal file
@@ -0,0 +1,626 @@
|
||||
from abc import ABC, abstractmethod
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple, Union, Literal, Mapping
|
||||
from dataclasses import dataclass
|
||||
import typer
|
||||
from safety.events.utils import emit_tool_command_executed
|
||||
from safety.models import ToolResult
|
||||
from safety.tool.constants import (
|
||||
PROJECT_CONFIG,
|
||||
MOST_FREQUENTLY_DOWNLOADED_PYPI_PACKAGES,
|
||||
)
|
||||
from safety.tool.typosquatting import TyposquattingProtection
|
||||
from safety.utils.pyapp_utils import get_env
|
||||
|
||||
from .environment_diff import EnvironmentDiffTracker
|
||||
from .intents import CommandToolIntention, ToolIntentionType, Dependency
|
||||
from .resolver import get_unwrapped_command
|
||||
|
||||
from safety_schemas.models.events.types import ToolType
|
||||
|
||||
from safety.events.utils import emit_diff_operations
|
||||
|
||||
from .utils import (
|
||||
is_os_supported,
|
||||
)
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseCommand(ABC):
|
||||
"""
|
||||
Abstract base class for tool commands.
|
||||
Requires subclasses to implement all required attributes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
args: List[str],
|
||||
capture_output: bool = False,
|
||||
intention: Optional[CommandToolIntention] = None,
|
||||
command_alias_used: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the command.
|
||||
|
||||
Args:
|
||||
args: Command arguments
|
||||
capture_output: Whether to capture command output
|
||||
"""
|
||||
self._args = args
|
||||
self._intention = intention
|
||||
self._capture_output = capture_output
|
||||
self._command_alias_used = command_alias_used
|
||||
|
||||
self._tool_type = self.get_tool_type()
|
||||
self.__typosquatting_protection = self._build_typosquatting_protection()
|
||||
|
||||
self._diff_tracker = self.get_diff_tracker()
|
||||
self._should_track_state = self.should_track_state()
|
||||
|
||||
@abstractmethod
|
||||
def get_tool_type(self) -> ToolType:
|
||||
"""
|
||||
Get the tool type for this command type.
|
||||
Must be implemented by subclasses.
|
||||
|
||||
Returns:
|
||||
ToolType: Tool type
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_command_name(self) -> List[str]:
|
||||
"""
|
||||
Get the command name for this command type.
|
||||
Must be implemented by subclasses.
|
||||
|
||||
Returns:
|
||||
List[str]: Command name as a list (e.g. ["pip"])
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_ecosystem(self) -> Literal["pypi", "npmjs"]:
|
||||
"""
|
||||
Get the ecosystem for this command type.
|
||||
Must be implemented by subclasses.
|
||||
|
||||
Returns:
|
||||
Literal["pypi", "npmjs"]: Ecosystem
|
||||
"""
|
||||
return "pypi"
|
||||
|
||||
@abstractmethod
|
||||
def get_diff_tracker(self) -> EnvironmentDiffTracker:
|
||||
"""
|
||||
Get the diff tracker instance for this command type.
|
||||
Must be implemented by subclasses.
|
||||
|
||||
Returns:
|
||||
EnvironmentDiffTracker: Diff tracker instance
|
||||
"""
|
||||
pass
|
||||
|
||||
def should_track_state(self) -> bool:
|
||||
"""
|
||||
Determine if this command should track state changes.
|
||||
Subclasses can override for more sophisticated logic.
|
||||
|
||||
Returns:
|
||||
bool: True if state changes should be tracked
|
||||
"""
|
||||
if self._intention:
|
||||
return self._intention.modifies_packages()
|
||||
|
||||
return False
|
||||
|
||||
def _get_typosquatting_reference_packages(self) -> Tuple[str]:
|
||||
"""
|
||||
Return the corpus of well-known package names used by the
|
||||
TypoSquatting protection to validate/correct package names.
|
||||
|
||||
Child classes should override this if they target a different
|
||||
package ecosystem (e.g., npm) or want a custom corpus.
|
||||
|
||||
Returns:
|
||||
Tuple[str]: Default set of popular PyPI package names.
|
||||
"""
|
||||
return MOST_FREQUENTLY_DOWNLOADED_PYPI_PACKAGES
|
||||
|
||||
def _build_typosquatting_protection(self) -> TyposquattingProtection:
|
||||
"""
|
||||
Factory method for the TypoSquatting protection instance.
|
||||
|
||||
Child classes may override this to customize the protection
|
||||
strategy entirely (not only the corpus), if needed.
|
||||
|
||||
Returns:
|
||||
TyposquattingProtection: Configured protection instance.
|
||||
"""
|
||||
return TyposquattingProtection(self._get_typosquatting_reference_packages())
|
||||
|
||||
def get_package_list_command(self) -> List[str]:
|
||||
"""
|
||||
Get the command to list installed packages.
|
||||
Subclasses must override this to provide the correct command.
|
||||
|
||||
Returns:
|
||||
List[str]: Command to list packages in JSON format
|
||||
"""
|
||||
# Default implementation, should be overridden by subclasses
|
||||
return [*self.get_command_name(), "list", "-v", "--format=json"]
|
||||
|
||||
def parse_package_list_output(self, output: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse the output of the package list command.
|
||||
Subclasses can override this for custom parsing logic.
|
||||
|
||||
Args:
|
||||
output: Command output
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: List of package dictionaries
|
||||
"""
|
||||
# Default implementation assumes JSON output
|
||||
try:
|
||||
return json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
# Log error and return empty list
|
||||
logger.exception(f"Error parsing package list output: {output[:100]}...")
|
||||
return []
|
||||
|
||||
def _initialize_diff_tracker(self, ctx: typer.Context):
|
||||
"""
|
||||
Common implementation to initialize the diff tracker.
|
||||
Can be called by child classes in their before() implementation.
|
||||
"""
|
||||
current_packages = self._get_installed_packages(ctx)
|
||||
self._diff_tracker.set_before_state(current_packages)
|
||||
|
||||
def __run_scan_if_needed(self, ctx: typer.Context, silent: bool = True):
|
||||
if not is_os_supported():
|
||||
return
|
||||
|
||||
target = Path.cwd()
|
||||
if (target / PROJECT_CONFIG).is_file():
|
||||
if silent:
|
||||
self.__run_silent_scan(ctx, target)
|
||||
else:
|
||||
from safety.init.command import init_scan_ui
|
||||
|
||||
init_scan_ui(ctx, prompt_user=True)
|
||||
|
||||
def __run_silent_scan(self, ctx: typer.Context, target: Path):
|
||||
"""
|
||||
Run a scan silently without displaying progress.
|
||||
"""
|
||||
target_arg = str(target.resolve())
|
||||
CMD = ("safety", "scan", "--target", target_arg)
|
||||
|
||||
logger.info(f"Launching silent scan: {CMD}")
|
||||
|
||||
try:
|
||||
kwargs = {
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
"stdin": subprocess.DEVNULL,
|
||||
"shell": False,
|
||||
}
|
||||
|
||||
if sys.platform == "win32":
|
||||
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
kwargs["start_new_session"] = True
|
||||
|
||||
subprocess.Popen(CMD, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start independent scan: {e}")
|
||||
|
||||
def _handle_command_result(self, ctx: typer.Context, result: ToolResult):
|
||||
"""
|
||||
Common implementation to handle command results.
|
||||
Can be called by child classes in their after() implementation.
|
||||
"""
|
||||
process = result.process
|
||||
if process:
|
||||
if process.returncode == 0 and self._should_track_state:
|
||||
self._perform_diff(ctx, result.tool_path)
|
||||
self.__run_scan_if_needed(ctx, silent=True)
|
||||
|
||||
emit_tool_command_executed(
|
||||
ctx.obj.event_bus,
|
||||
ctx, # type: ignore
|
||||
tool=self._tool_type,
|
||||
result=result,
|
||||
)
|
||||
|
||||
def is_installed(self) -> bool:
|
||||
"""
|
||||
Checks if the tool program is reachable
|
||||
|
||||
Returns:
|
||||
True if the tool is reachable on system, or false otherwise
|
||||
"""
|
||||
cmd_name = self.get_command_name()[0]
|
||||
return shutil.which(cmd_name) is not None
|
||||
|
||||
def before(self, ctx: typer.Context):
|
||||
if self._should_track_state:
|
||||
self._initialize_diff_tracker(ctx)
|
||||
|
||||
if (
|
||||
self._intention
|
||||
and self._intention.packages
|
||||
and self._intention.intention_type is not ToolIntentionType.REMOVE_PACKAGE
|
||||
):
|
||||
for dep in self._intention.packages:
|
||||
if reviewed_name := self.__typosquatting_protection.coerce(
|
||||
self._intention, dep.name
|
||||
):
|
||||
dep.corrected_text = dep.original_text.replace(
|
||||
dep.name, reviewed_name
|
||||
)
|
||||
# NOTE: Mutation here is a workaround, it should be improved in the future.
|
||||
dep.name = reviewed_name
|
||||
self._args[dep.arg_index] = dep.corrected_text
|
||||
|
||||
def after(self, ctx: typer.Context, result: ToolResult):
|
||||
self._handle_command_result(ctx, result)
|
||||
|
||||
def execute(self, ctx: typer.Context) -> ToolResult:
|
||||
self.before(ctx)
|
||||
# TODO: Safety should redirect to the proper pip/tool, if the user is
|
||||
# using pip3, it should be redirected to pip3, not pip to avoid any
|
||||
# issues.
|
||||
|
||||
cmd = self.get_command_name()
|
||||
cmd_name = cmd[0]
|
||||
logger.debug(f"Getting unwrapped command for: {cmd_name}")
|
||||
tool_path = get_unwrapped_command(name=cmd_name)
|
||||
logger.debug(f"Resolved tool_path: {tool_path}")
|
||||
|
||||
pre_args = [tool_path] + cmd[1:]
|
||||
args = pre_args + self.__remove_safety_args(self._args)
|
||||
logger.debug(f"Final command args: {args}")
|
||||
|
||||
started_at = time.monotonic()
|
||||
logger.debug(f"Running subprocess with capture_output={self._capture_output}")
|
||||
process = subprocess.run(
|
||||
args, capture_output=self._capture_output, env=self.env(ctx)
|
||||
)
|
||||
logger.debug(f"Subprocess completed with returncode: {process.returncode}")
|
||||
|
||||
duration_ms = int((time.monotonic() - started_at) * 1000)
|
||||
|
||||
result = ToolResult(
|
||||
process=process, duration_ms=duration_ms, tool_path=tool_path
|
||||
)
|
||||
|
||||
self.after(ctx, result)
|
||||
|
||||
return result
|
||||
|
||||
def env(self, ctx: typer.Context):
|
||||
"""
|
||||
Returns the environment.
|
||||
Args:
|
||||
ctx (typer.Context): The context.
|
||||
Returns:
|
||||
dict: The environment.
|
||||
"""
|
||||
return get_env()
|
||||
|
||||
def __remove_safety_args(self, args: List[str]):
|
||||
return [arg for arg in args if not arg.startswith("--safety")]
|
||||
|
||||
def _get_installed_packages(self, ctx: typer.Context) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get currently installed packages
|
||||
"""
|
||||
command = self.get_package_list_command()
|
||||
base_cmd = [get_unwrapped_command(name=command[0])]
|
||||
args = base_cmd + command[1:]
|
||||
|
||||
result = subprocess.run(args, capture_output=True, env=self.env(ctx), text=True)
|
||||
return self.parse_package_list_output(result.stdout)
|
||||
|
||||
def _perform_diff(self, ctx: typer.Context, tool_path: Optional[str] = None):
|
||||
"""
|
||||
Perform the diff operation.
|
||||
Can be called by child classes when appropriate.
|
||||
"""
|
||||
current_packages = self._get_installed_packages(ctx)
|
||||
self._diff_tracker.set_after_state(current_packages)
|
||||
added, removed, updated = self._diff_tracker.get_diff()
|
||||
|
||||
emit_diff_operations(
|
||||
ctx.obj.event_bus,
|
||||
ctx, # type: ignore
|
||||
added=added,
|
||||
removed=removed,
|
||||
updated=updated,
|
||||
tool_path=tool_path,
|
||||
by_tool=self._tool_type,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedCommand:
|
||||
"""
|
||||
Represents a parsed command with its hierarchy
|
||||
"""
|
||||
|
||||
chain: List[str] # e.g., ['pip', 'install'] or ['add']
|
||||
intention: ToolIntentionType
|
||||
remaining_args_start: int # Where options/packages start
|
||||
|
||||
|
||||
class ToolCommandLineParser(ABC):
|
||||
"""
|
||||
Base implementation of a command line parser for tools
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._tool_name = self.get_tool_name()
|
||||
|
||||
@abstractmethod
|
||||
def get_tool_name(self) -> str:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_command_hierarchy(self) -> Mapping[str, Union[ToolIntentionType, Mapping]]:
|
||||
"""
|
||||
Return command hierarchy only. No option definitions needed.
|
||||
|
||||
Example:
|
||||
{
|
||||
'add': ToolIntentionType.ADD_PACKAGE,
|
||||
'pip': {
|
||||
'install': ToolIntentionType.ADD_PACKAGE,
|
||||
'uninstall': ToolIntentionType.REMOVE_PACKAGE
|
||||
}
|
||||
}
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_known_flags(self) -> Dict[str, Set[str]]:
|
||||
"""
|
||||
Return known flags that don't take values.
|
||||
Format: {command_path: {flag_names}}
|
||||
|
||||
Example:
|
||||
{
|
||||
'global': {'verbose', 'v', 'quiet', 'q', 'help', 'h'},
|
||||
'install': {'upgrade', 'U', 'dry-run', 'no-deps', 'user'}
|
||||
}
|
||||
"""
|
||||
pass
|
||||
|
||||
def parse(
|
||||
self, args: List[str], start_from: int = 0
|
||||
) -> Optional[CommandToolIntention]:
|
||||
"""
|
||||
Main parsing method
|
||||
"""
|
||||
|
||||
parsed_command = self._parse_command_hierarchy(args, start_from)
|
||||
if not parsed_command:
|
||||
return None
|
||||
|
||||
remaining_args = args[parsed_command.remaining_args_start :]
|
||||
options, packages = self._parse_options_and_packages(
|
||||
remaining_args, parsed_command
|
||||
)
|
||||
|
||||
return CommandToolIntention(
|
||||
tool=self._tool_name,
|
||||
command=" ".join(parsed_command.chain),
|
||||
command_chain=parsed_command.chain,
|
||||
intention_type=parsed_command.intention,
|
||||
packages=packages,
|
||||
options=options,
|
||||
raw_args=args.copy(),
|
||||
)
|
||||
|
||||
def _is_known_flag(self, option_key: str, command_chain: List[str]) -> bool:
|
||||
"""
|
||||
Check if option is a known flag using command context
|
||||
"""
|
||||
known_flags = self.get_known_flags()
|
||||
|
||||
# Try command-specific flags first, then global
|
||||
candidates = []
|
||||
if command_chain:
|
||||
for i in range(len(command_chain), 0, -1):
|
||||
candidates.append(".".join(command_chain[:i]))
|
||||
candidates.append("global")
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate in known_flags and option_key in known_flags[candidate]:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _parse_command_hierarchy(
|
||||
self, args: List[str], start_from: int
|
||||
) -> Optional[ParsedCommand]:
|
||||
"""
|
||||
Parse the command hierarchy - stop at first non-command
|
||||
"""
|
||||
if not args or start_from >= len(args):
|
||||
return None
|
||||
|
||||
hierarchy = self.get_command_hierarchy()
|
||||
command_chain = []
|
||||
current_level = hierarchy
|
||||
i = start_from
|
||||
|
||||
while i < len(args):
|
||||
arg = args[i].lower()
|
||||
|
||||
# Check if this argument is a valid command at current level
|
||||
if isinstance(current_level, Mapping) and arg in current_level:
|
||||
command_chain.append(arg)
|
||||
current_level = current_level[arg]
|
||||
|
||||
# If we hit an intention type, we're done with commands
|
||||
if isinstance(current_level, ToolIntentionType):
|
||||
return ParsedCommand(
|
||||
chain=command_chain,
|
||||
intention=current_level,
|
||||
remaining_args_start=i + 1,
|
||||
)
|
||||
|
||||
i += 1
|
||||
|
||||
# Check if we ended on a valid intention
|
||||
if isinstance(current_level, ToolIntentionType):
|
||||
return ParsedCommand(
|
||||
chain=command_chain, intention=current_level, remaining_args_start=i
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _parse_options_and_packages(
|
||||
self, args: List[str], parsed_command: ParsedCommand
|
||||
) -> Tuple[Dict[str, Any], List[Dependency]]:
|
||||
"""
|
||||
Simple parsing: hyphens = options, everything else = packages/args
|
||||
"""
|
||||
|
||||
options = {}
|
||||
packages = []
|
||||
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
|
||||
if arg.startswith("-"):
|
||||
option_key, option_data, consumed = self._parse_option(
|
||||
args, i, parsed_command
|
||||
)
|
||||
options[option_key] = option_data
|
||||
i += consumed
|
||||
else:
|
||||
arg_index = parsed_command.remaining_args_start + i
|
||||
|
||||
dep = self._try_parse_package(arg, arg_index, parsed_command)
|
||||
|
||||
if dep:
|
||||
packages.append(dep)
|
||||
else:
|
||||
self._store_unknown_argument(options, arg, arg_index)
|
||||
|
||||
i += 1
|
||||
|
||||
return options, packages
|
||||
|
||||
def _parse_option(
|
||||
self, args: List[str], i: int, parsed_command: ParsedCommand
|
||||
) -> Tuple[str, Dict[str, Any], int]:
|
||||
"""
|
||||
Parse a single option, args[i] is expected to be a hyphenated option
|
||||
"""
|
||||
arg = args[i]
|
||||
arg_index = parsed_command.remaining_args_start + i
|
||||
|
||||
# Handle --option=value format
|
||||
if "=" in arg:
|
||||
option_part, value_part = arg.split("=", 1)
|
||||
option_key = option_part.lstrip("-")
|
||||
option_data = {
|
||||
"arg_index": arg_index,
|
||||
"raw_option": option_part,
|
||||
"value": value_part,
|
||||
}
|
||||
return option_key, option_data, 1
|
||||
|
||||
# Handle --option, -option formats for known flags
|
||||
option_key = arg.lstrip("-")
|
||||
|
||||
if self._is_known_flag(option_key, parsed_command.chain):
|
||||
# It's a flag - doesn't take value
|
||||
option_data = {
|
||||
"arg_index": arg_index,
|
||||
"raw_option": arg,
|
||||
"value": True,
|
||||
}
|
||||
return option_key, option_data, 1
|
||||
|
||||
# Handle --option value, -option value formats
|
||||
if i + 1 < len(args) and not args[i + 1].startswith("-"):
|
||||
option_data = {
|
||||
"arg_index": arg_index,
|
||||
"raw_option": arg,
|
||||
"value": args[i + 1],
|
||||
"value_index": arg_index + 1,
|
||||
}
|
||||
return option_key, option_data, 2
|
||||
|
||||
# Handle --option, -option formats for unknown flags
|
||||
option_data = {
|
||||
"arg_index": arg_index,
|
||||
"raw_option": arg,
|
||||
"value": True,
|
||||
}
|
||||
return option_key, option_data, 1
|
||||
|
||||
def _should_parse_as_package(self, intention: ToolIntentionType) -> bool:
|
||||
"""
|
||||
Check if arguments should be parsed as packages
|
||||
"""
|
||||
return intention in [
|
||||
ToolIntentionType.ADD_PACKAGE,
|
||||
ToolIntentionType.REMOVE_PACKAGE,
|
||||
ToolIntentionType.DOWNLOAD_PACKAGE,
|
||||
ToolIntentionType.SEARCH_PACKAGES,
|
||||
]
|
||||
|
||||
def _try_parse_package(
|
||||
self, arg: str, index: int, parsed_command: ParsedCommand
|
||||
) -> Optional[Dependency]:
|
||||
"""
|
||||
Try to parse argument as package, return None if fails
|
||||
"""
|
||||
if self._should_parse_as_package(parsed_command.intention):
|
||||
return self._parse_package_spec(arg, index)
|
||||
|
||||
return None
|
||||
|
||||
def _store_unknown_argument(self, options: Dict, arg: str, index: int):
|
||||
"""
|
||||
Store non-package arguments in options as unknown
|
||||
"""
|
||||
key = f"unknown_{len([k for k in options.keys() if k.startswith('unknown_')])}"
|
||||
options[key] = {
|
||||
"arg_index": index,
|
||||
"value": arg,
|
||||
}
|
||||
|
||||
def _parse_package_spec(
|
||||
self, spec_str: str, arg_index: int
|
||||
) -> Optional[Dependency]:
|
||||
try:
|
||||
from packaging.requirements import Requirement
|
||||
|
||||
# TODO: pip install . should be excluded
|
||||
req = Requirement(spec_str)
|
||||
|
||||
return Dependency(
|
||||
name=req.name,
|
||||
version_constraint=str(req.specifier),
|
||||
extras=req.extras,
|
||||
arg_index=arg_index,
|
||||
original_text=spec_str,
|
||||
)
|
||||
except Exception:
|
||||
# If spec parsing fails, just ignore for now
|
||||
return None
|
||||
Reference in New Issue
Block a user