updates
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
from .main import Npm
|
||||
|
||||
__all__ = [
|
||||
"Npm",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,173 @@
|
||||
from typing import TYPE_CHECKING, List, Optional, Dict, Any, Literal
|
||||
from typing import Tuple
|
||||
|
||||
import logging
|
||||
import typer
|
||||
|
||||
from safety.models import ToolResult
|
||||
from .parser import NpmParser
|
||||
|
||||
from ..base import BaseCommand
|
||||
from safety_schemas.models.events.types import ToolType
|
||||
from ..environment_diff import EnvironmentDiffTracker, NpmEnvironmentDiffTracker
|
||||
from ..mixins import InstallationAuditMixin
|
||||
from ..constants import TOP_NPMJS_PACKAGES
|
||||
from ..auth import build_index_url
|
||||
import json
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..environment_diff import EnvironmentDiffTracker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NpmCommand(BaseCommand):
|
||||
"""
|
||||
Main class for hooks into npm commands.
|
||||
"""
|
||||
|
||||
def get_tool_type(self) -> ToolType:
|
||||
return ToolType.NPM
|
||||
|
||||
def get_command_name(self) -> List[str]:
|
||||
return ["npm"]
|
||||
|
||||
def get_ecosystem(self) -> Literal["pypi", "npmjs"]:
|
||||
return "npmjs"
|
||||
|
||||
def get_package_list_command(self) -> List[str]:
|
||||
return [*self.get_command_name(), "list", "--json", "--all", "-l"]
|
||||
|
||||
def _flatten_packages(self, dependencies: Dict[str, Any]) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Flatten npm list --json --all -l output into a list of package dictionaries with file paths.
|
||||
|
||||
Args:
|
||||
dependencies: The root dependencies dictionary from JSON output from npm list --json --all -l
|
||||
|
||||
Returns:
|
||||
List of dictionaries with name, version, and location keys
|
||||
"""
|
||||
result = []
|
||||
|
||||
def traverse(dependencies: Dict[str, Any]):
|
||||
if not dependencies:
|
||||
return
|
||||
|
||||
for name, info in dependencies.items():
|
||||
result.append(
|
||||
{
|
||||
"name": name,
|
||||
"version": info.get("version", ""),
|
||||
"location": info.get("path", ""),
|
||||
}
|
||||
)
|
||||
|
||||
# Recursively process nested dependencies
|
||||
if "dependencies" in info:
|
||||
traverse(info["dependencies"])
|
||||
|
||||
traverse(dependencies)
|
||||
|
||||
return result
|
||||
|
||||
def parse_package_list_output(self, output: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Handle the output of the npm list command.
|
||||
|
||||
Args:
|
||||
output: Command output
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: List of package dictionaries
|
||||
"""
|
||||
try:
|
||||
result = json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
# Log error and return empty list
|
||||
logger.exception(f"Error parsing package list output: {output[:100]}...")
|
||||
return []
|
||||
|
||||
return self._flatten_packages(result.get("dependencies", {}))
|
||||
|
||||
def get_diff_tracker(self) -> "EnvironmentDiffTracker":
|
||||
return NpmEnvironmentDiffTracker()
|
||||
|
||||
def _get_typosquatting_reference_packages(self) -> Tuple[str]:
|
||||
return TOP_NPMJS_PACKAGES
|
||||
|
||||
@classmethod
|
||||
def from_args(cls, args: List[str], **kwargs):
|
||||
parser = NpmParser()
|
||||
|
||||
if intention := parser.parse(args):
|
||||
kwargs["intention"] = intention
|
||||
|
||||
if intention.modifies_packages():
|
||||
return AuditableNpmCommand(args, **kwargs)
|
||||
|
||||
if intention.queries_packages():
|
||||
return SearchCommand(args, **kwargs)
|
||||
|
||||
return NpmCommand(args, **kwargs)
|
||||
|
||||
|
||||
class NpmIndexEnvMixin:
|
||||
"""
|
||||
Mixin to inject Safety's default index URL into npm's environment.
|
||||
Expects implementers to define `self._index_url` (Optional[str]).
|
||||
"""
|
||||
|
||||
def env(self, ctx: typer.Context) -> dict:
|
||||
env = super().env(ctx) # pyright: ignore[reportAttributeAccessIssue]
|
||||
default_index_url = build_index_url(
|
||||
ctx, getattr(self, "_index_url", None), "npm"
|
||||
)
|
||||
env["NPM_CONFIG_REGISTRY"] = default_index_url
|
||||
return env
|
||||
|
||||
|
||||
class SearchCommand(NpmIndexEnvMixin, NpmCommand):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._index_url = None
|
||||
|
||||
|
||||
class AuditableNpmCommand(NpmIndexEnvMixin, NpmCommand, InstallationAuditMixin):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._index_url = None
|
||||
|
||||
def before(self, ctx: typer.Context):
|
||||
super().before(ctx)
|
||||
args: List[Optional[str]] = self._args.copy() # type: ignore
|
||||
|
||||
if self._intention:
|
||||
if registry_opt := self._intention.options.get(
|
||||
"registry"
|
||||
) or self._intention.options.get("r"):
|
||||
registry_value = registry_opt["value"]
|
||||
|
||||
if registry_value and registry_value.startswith(
|
||||
"https://pkgs.safetycli.com"
|
||||
):
|
||||
self._index_url = registry_value
|
||||
|
||||
arg_index = registry_opt["arg_index"]
|
||||
value_index = registry_opt["value_index"]
|
||||
|
||||
if (
|
||||
arg_index
|
||||
and value_index
|
||||
and arg_index < len(args)
|
||||
and value_index < len(args)
|
||||
):
|
||||
args[arg_index] = None
|
||||
args[value_index] = None
|
||||
|
||||
self._args = [arg for arg in args if arg is not None]
|
||||
|
||||
def after(self, ctx: typer.Context, result: ToolResult):
|
||||
super().after(ctx, result)
|
||||
self.handle_installation_audit(ctx, result)
|
||||
@@ -0,0 +1,171 @@
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from safety.tool.constants import (
|
||||
NPMJS_PUBLIC_REPOSITORY_URL,
|
||||
NPMJS_ORGANIZATION_REPOSITORY_URL,
|
||||
NPMJS_PROJECT_REPOSITORY_URL,
|
||||
)
|
||||
from safety.tool.resolver import get_unwrapped_command
|
||||
from safety.utils.pyapp_utils import get_path, get_env
|
||||
|
||||
from safety.console import main_console
|
||||
from safety.tool.auth import build_index_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Npm:
|
||||
@classmethod
|
||||
def is_installed(cls) -> bool:
|
||||
"""
|
||||
Checks if the NPM program is installed
|
||||
|
||||
Returns:
|
||||
True if NPM is installed on system, or false otherwise
|
||||
"""
|
||||
return shutil.which("npm", path=get_path()) is not None
|
||||
|
||||
@classmethod
|
||||
def configure_project(
|
||||
cls,
|
||||
project_path: Path,
|
||||
org_slug: Optional[str],
|
||||
project_id: Optional[str],
|
||||
console: Console = main_console,
|
||||
) -> Optional[Path]:
|
||||
"""
|
||||
Configures Safety index url for specified npmrc file.
|
||||
|
||||
Args:
|
||||
file (Path): Path to npmrc file.
|
||||
org_slug (str): Organization slug.
|
||||
project_id (str): Project identifier.
|
||||
console (Console): Console instance.
|
||||
"""
|
||||
if not cls.is_installed():
|
||||
logger.error("NPM is not installed.")
|
||||
return None
|
||||
|
||||
repository_url = (
|
||||
NPMJS_PROJECT_REPOSITORY_URL.format(org_slug, project_id)
|
||||
if project_id and org_slug
|
||||
else (
|
||||
NPMJS_ORGANIZATION_REPOSITORY_URL.format(org_slug)
|
||||
if org_slug
|
||||
else NPMJS_PUBLIC_REPOSITORY_URL
|
||||
)
|
||||
)
|
||||
|
||||
project_root = project_path.resolve()
|
||||
if not project_root.is_dir():
|
||||
project_root = project_path.parent
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
get_unwrapped_command(name="npm"),
|
||||
"config",
|
||||
"set",
|
||||
"registry",
|
||||
repository_url,
|
||||
"--location",
|
||||
"project",
|
||||
],
|
||||
capture_output=True,
|
||||
cwd=project_root,
|
||||
env=get_env(),
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(
|
||||
f"Failed to configure NPM project settings: {result.stderr.decode('utf-8')}"
|
||||
)
|
||||
return None
|
||||
|
||||
return project_root
|
||||
|
||||
@classmethod
|
||||
def configure_system(
|
||||
cls, org_slug: Optional[str], console: Console = main_console
|
||||
) -> Optional[Path]:
|
||||
"""
|
||||
Configures NPM system to use to Safety index url.
|
||||
"""
|
||||
if not cls.is_installed():
|
||||
logger.error("NPM is not installed.")
|
||||
return None
|
||||
|
||||
try:
|
||||
repository_url = (
|
||||
NPMJS_ORGANIZATION_REPOSITORY_URL.format(org_slug)
|
||||
if org_slug
|
||||
else NPMJS_PUBLIC_REPOSITORY_URL
|
||||
)
|
||||
result = subprocess.run(
|
||||
[
|
||||
get_unwrapped_command(name="npm"),
|
||||
"config",
|
||||
"set",
|
||||
"-g",
|
||||
"registry",
|
||||
repository_url,
|
||||
],
|
||||
capture_output=True,
|
||||
env=get_env(),
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(
|
||||
f"Failed to configure NPM global settings: {result.stderr.decode('utf-8')}"
|
||||
)
|
||||
return None
|
||||
|
||||
query_config_result = subprocess.run(
|
||||
[
|
||||
get_unwrapped_command(name="npm"),
|
||||
"config",
|
||||
"get",
|
||||
"globalconfig",
|
||||
],
|
||||
capture_output=True,
|
||||
env=get_env(),
|
||||
)
|
||||
config_file_path = query_config_result.stdout.decode("utf-8").strip()
|
||||
|
||||
if config_file_path:
|
||||
return Path(config_file_path)
|
||||
|
||||
logger.error("Failed to match the config file path written by NPM.")
|
||||
return Path()
|
||||
except Exception:
|
||||
logger.exception("Failed to configure NPM global settings.")
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def reset_system(cls, console: Console = main_console):
|
||||
# TODO: Move this logic and implement it in a more robust way
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
get_unwrapped_command(name="npm"),
|
||||
"config",
|
||||
"set",
|
||||
"-g",
|
||||
"registry",
|
||||
],
|
||||
capture_output=True,
|
||||
env=get_env(),
|
||||
)
|
||||
except Exception:
|
||||
console.print("Failed to reset NPM global settings.")
|
||||
|
||||
@classmethod
|
||||
def build_index_url(cls, ctx: typer.Context, index_url: Optional[str]) -> str:
|
||||
return build_index_url(ctx, index_url, "npm")
|
||||
@@ -0,0 +1,195 @@
|
||||
from typing import Dict
|
||||
|
||||
from ..base import ToolCommandLineParser
|
||||
from ..intents import ToolIntentionType
|
||||
|
||||
from typing import Union, Set, Optional, Mapping
|
||||
from ..intents import Dependency
|
||||
|
||||
ADD_PACKAGE_ALIASES = [
|
||||
"install",
|
||||
"add",
|
||||
"i",
|
||||
"in",
|
||||
"ins",
|
||||
"inst",
|
||||
"insta",
|
||||
"instal",
|
||||
"isnt",
|
||||
"isnta",
|
||||
"isntal",
|
||||
"isntall",
|
||||
]
|
||||
|
||||
REMOVE_PACKAGE_ALIASES = [
|
||||
"uninstall",
|
||||
"unlink",
|
||||
"remove",
|
||||
"rm",
|
||||
"r",
|
||||
"un",
|
||||
]
|
||||
|
||||
UPDATE_PACKAGE_ALIASES = [
|
||||
"update",
|
||||
"up",
|
||||
"upgrade",
|
||||
"udpate",
|
||||
]
|
||||
|
||||
SYNC_PACKAGES_ALIASES = [
|
||||
"ci",
|
||||
"clean-install",
|
||||
"ic",
|
||||
"install-clean",
|
||||
"isntall-clean",
|
||||
]
|
||||
|
||||
LIST_PACKAGES_ALIASES = [
|
||||
"list",
|
||||
"ls",
|
||||
"ll",
|
||||
"la",
|
||||
]
|
||||
|
||||
SEARCH_PACKAGES_ALIASES = [
|
||||
# Via view
|
||||
"view",
|
||||
"info",
|
||||
"show",
|
||||
"v",
|
||||
# Via search
|
||||
"search",
|
||||
"find",
|
||||
"s",
|
||||
"se",
|
||||
]
|
||||
|
||||
INIT_PROJECT_ALIASES = [
|
||||
"init",
|
||||
"create",
|
||||
]
|
||||
|
||||
|
||||
class NpmParser(ToolCommandLineParser):
|
||||
def get_tool_name(self) -> str:
|
||||
return "npm"
|
||||
|
||||
def get_command_hierarchy(self) -> Mapping[str, Union[ToolIntentionType, Mapping]]:
|
||||
"""
|
||||
Context for command hierarchy parsing
|
||||
"""
|
||||
|
||||
alias_map = {
|
||||
ToolIntentionType.ADD_PACKAGE: ADD_PACKAGE_ALIASES,
|
||||
ToolIntentionType.REMOVE_PACKAGE: REMOVE_PACKAGE_ALIASES,
|
||||
ToolIntentionType.UPDATE_PACKAGE: UPDATE_PACKAGE_ALIASES,
|
||||
ToolIntentionType.SYNC_PACKAGES: SYNC_PACKAGES_ALIASES,
|
||||
ToolIntentionType.SEARCH_PACKAGES: SEARCH_PACKAGES_ALIASES,
|
||||
ToolIntentionType.LIST_PACKAGES: LIST_PACKAGES_ALIASES,
|
||||
ToolIntentionType.INIT_PROJECT: INIT_PROJECT_ALIASES,
|
||||
}
|
||||
|
||||
hierarchy = {
|
||||
alias.lower().strip(): intention
|
||||
for intention, aliases in alias_map.items()
|
||||
for alias in aliases
|
||||
}
|
||||
|
||||
return hierarchy
|
||||
|
||||
def get_known_flags(self) -> Dict[str, Set[str]]:
|
||||
"""
|
||||
Define flags that DON'T take values to avoid consuming packages
|
||||
"""
|
||||
GLOBAL_FLAGS = {
|
||||
"S",
|
||||
"save",
|
||||
"no-save",
|
||||
"save-prod",
|
||||
"save-dev",
|
||||
"save-optional",
|
||||
"save-peer",
|
||||
"save-bundle",
|
||||
"g",
|
||||
"global",
|
||||
"workspaces",
|
||||
"include-workspace-root",
|
||||
"install-links",
|
||||
"json",
|
||||
"no-color",
|
||||
"parseable",
|
||||
"p",
|
||||
"no-description",
|
||||
"prefer-offline",
|
||||
"offline",
|
||||
}
|
||||
|
||||
OTHER_FLAGS = {
|
||||
"E",
|
||||
"save-exact",
|
||||
"legacy-bundling",
|
||||
"global-style",
|
||||
"strict-peer-deps",
|
||||
"prefer-dedupe",
|
||||
"no-package-lock",
|
||||
"package-lock-only",
|
||||
"foreground-scripts",
|
||||
"ignore-scripts",
|
||||
"no-audit",
|
||||
"no-bin-links",
|
||||
"no-fund",
|
||||
"dry-run",
|
||||
}
|
||||
|
||||
return {
|
||||
# We don't need to differentiate between flags for different commands
|
||||
"global": GLOBAL_FLAGS | OTHER_FLAGS,
|
||||
}
|
||||
|
||||
def _parse_package_spec(
|
||||
self, spec_str: str, arg_index: int
|
||||
) -> Optional[Dependency]:
|
||||
"""
|
||||
Parse npm registry specs like "react", "@types/node@^20",
|
||||
and aliases like "alias@npm:@sentry/node@7".
|
||||
Skips non-registry (git/url/path).
|
||||
"""
|
||||
import re
|
||||
|
||||
s = spec_str.strip()
|
||||
|
||||
REGISTRY_RE = re.compile(
|
||||
r"""^(?P<name>@[^/\s]+/[^@\s]+|[A-Za-z0-9._-]+)(?:@(?P<constraint>.+))?$"""
|
||||
)
|
||||
ALIAS_RE = re.compile(
|
||||
r"""^(?P<alias>@?[^@\s/]+(?:/[^@\s/]+)?)@npm:(?P<target>.+)$"""
|
||||
)
|
||||
|
||||
def mk(name: str, constraint: Optional[str]) -> Dependency:
|
||||
dep = Dependency(
|
||||
name=name.lower(),
|
||||
version_constraint=(constraint or None),
|
||||
arg_index=arg_index,
|
||||
original_text=spec_str,
|
||||
)
|
||||
|
||||
return dep
|
||||
|
||||
# alias form
|
||||
m = ALIAS_RE.match(s)
|
||||
if m:
|
||||
alias = m.group("alias")
|
||||
target = m.group("target").strip()
|
||||
rm = REGISTRY_RE.match(target)
|
||||
if rm:
|
||||
return mk(alias, rm.group("constraint"))
|
||||
# out-of-scope target
|
||||
return None
|
||||
|
||||
# plain registry form
|
||||
m = REGISTRY_RE.match(s)
|
||||
if m:
|
||||
return mk(m.group("name"), m.group("constraint"))
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user