This commit is contained in:
Iliyan Angelov
2025-12-01 06:50:10 +02:00
parent 91f51bc6fe
commit 62c1fe5951
4682 changed files with 544807 additions and 31208 deletions

View File

@@ -0,0 +1,5 @@
from .main import Npm
__all__ = [
"Npm",
]

View File

@@ -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)

View File

@@ -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")

View File

@@ -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