Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,15 @@ jobs:
WHEEL_FILE=$(ls dist/*.whl | head -1)
echo "Installing wheel: $WHEEL_FILE"
uv pip install "$WHEEL_FILE"

- name: Test twyn as a library
run: uv run python -c "import twyn; twyn.check_dependencies"

- name: Test --version flag
- name: Test twyn as a cli tool
run: |
WHEEL_FILE=$(ls dist/*.whl | head -1)
echo "Installing wheel: `$WHEEL_FILE` with `cli` extra."
uv pip install "${WHEEL_FILE}[cli]"
# Test that the CLI is available and --version works
uv run twyn --version

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
uses: astral-sh/setup-uv@557e51de59eb14aaaba2ed9621916900a91d50c6 # v6.6.1

- name: Install the project
run: uv sync --locked
run: uv sync --locked --extra cli

- name: Run Twyn against our dependencies
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
uses: astral-sh/setup-uv@557e51de59eb14aaaba2ed9621916900a91d50c6 # v6.6.1

- name: Install the dependencies
run: uv sync --locked --group dev --python ${{ matrix.python-version }}
run: uv sync --locked --group dev --all-extras --python ${{ matrix.python-version }}

- name: Run tests
run: uv run pytest tests
81 changes: 47 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,21 @@

- [Overview](#overview)
- [Quickstart](#quickstart)
- [Installation](#installation)
- [Docker](#docker)
- [Run](#run)
- [JSON Format](#json-format)
- [Using `Twyn` as a cli tool](#using-twyn-as-a-cli-tool)
- [Installation](#installation)
- [Docker](#docker)
- [Run](#run)
- [JSON Format](#json-format)
- [Using `Twyn` as a library](#using-twyn-as-a-library)
- [Logging level](#logging-level)
- [Configuration](#configuration)
- [Allowlist](#allowlist)
- [Dependency files](#dependency-files)
- [Check dependencies introduced through the CLI](#check-dependencies-introduced-through-the-cli)
- [Selector method](#selector-method)
- [Configuration file](#configuration-file)
- [Cache](#cache)
- [Using `Twyn` as a library](#using-twyn-as-a-library)
- [Logging level](#logging-level)


## Overview
`Twyn` is a security tool that compares the name of your dependencies against a set of the most popular ones,
Expand All @@ -39,15 +41,16 @@ It works as follows:

## Quickstart

### Installation
### Using twyn as a CLI tool
#### Installation

`Twyn` is available on PyPi repository, you can install it by running

```sh
pip install twyn
pip install twyn[cli]
```

### Docker
#### Docker

`Twyn` provides a Docker image, which can be found [here](https://hub.docker.com/r/elementsinteractive/twyn).

Expand All @@ -58,7 +61,7 @@ docker pull elementsinteractive/twyn:latest
docker run elementsinteractive/twyn --help
```

### Run
#### Run

To run twyn simply type:

Expand All @@ -72,7 +75,7 @@ For a list of all the available options as well as their expected arguments run:
twyn run --help
```

### JSON format
#### JSON format
If you want your output in JSON format, you can run `Twyn` with the following flag:

```python
Expand All @@ -84,12 +87,43 @@ This will output:
{"errors":[{"dependency":"reqests","similars":["requests","grequests"]}]}
```

### Using Twyn as a library


#### Installation
`Twyn` also supports being used as 3rd party library for you project. To install it, run:


```sh
pip install twyn
```

Example usage in your code:

```python
from twyn import check_dependencies

typos = check_dependencies()

for typo in typos.errors:
print(f"Dependency:{typo.dependency}")
print(f"Did you mean any of [{','.join(typo.similars)}]")

```

#### Logging level
By default, logging is disabled when running as a 3rd party library. To override this behaviour, you can:

```python
logging.basicConfig(level=logging.INFO)
logging.getLogger("twyn").setLevel(logging.INFO)
```

## Configuration

### Allowlist

It can happen that a legitimate package known by the user raises an error because is too similar to one of the most trusted ones.
You can then add this packages to the `allowlist`, so it will be skipped:
It can happen that a legitimate package known by the user raises an error because it is too similar to one of the most trusted ones. Imagine that you are using internally a package that you developed called `reqests`. You can then add this packages to the `allowlist`, so it will not be reported as a typo:

```sh
twyn allowlist add <package>
Expand Down Expand Up @@ -201,24 +235,3 @@ To clear the cache, run:
```


### Using Twyn as a library

`Twyn` also supports being used as 3rd party library for you project.

```python
from twyn import check_dependencies

typos = check_dependencies()

for typo in typos.errors:
print(f"Dependency:{typo.dependency}")
print(f"Did you mean any of [{','.join(typo.similars)}]")

```
### Logging level
To override the logging level when using `Twyn` as a 3rd party library, simply override it like:

```python
logging.basicConfig(level=logging.DEBUG)
logging.getLogger("twyn").setLevel(logging.DEBUG)
```
2 changes: 1 addition & 1 deletion dependencies/scripts/download_packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def download(ecosystem: str) -> None:
):
with attempt, httpx.Client(timeout=30) as client:
logger.info("Attempting to download %s packages. Attempt #%d.", ecosystem, attempt.num)
response = client.get(ECOSYSTEMS[ecosystem]["url"]) # type: ignore[arg-type]
response = client.get(ECOSYSTEMS[ecosystem]["url"])
response.raise_for_status()

fpath = Path("dependencies") / f"{ecosystem}.json"
Expand Down
2 changes: 1 addition & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ help:
venv:
@if ! {{ venv-exists }}; \
then \
uv sync --frozen --all-groups; \
uv sync --frozen --all-extras --all-groups; \
fi

# Cleans all artifacts generated while running this project, including the virtualenv.
Expand Down
9 changes: 7 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ maintainers = [
requires-python = "<4,>=3.9"
dependencies = [
"requests<3.0.0,>=2.32.4",
"click<9.0.0,>=8.1.8",
"rich<15.0.0,>=14.0.0",
"rapidfuzz<4.0.0,>=2.13.7",
"pyparsing<4.0.0,>=3.2.3",
"tomlkit<0.14.0,>=0.11.6",
Expand All @@ -26,6 +24,13 @@ description = "Security tool against dependency typosquatting attacks"
readme = "README.md"
dynamic = ["version"]

[project.optional-dependencies]
cli = [
"click<9.0.0,>=8.1.8",
"rich<15.0.0,>=14.0.0",
]


[tool.hatch.version]
path = "VERSION"
pattern = "v(?P<version>[^\\s]+)"
Expand Down
24 changes: 14 additions & 10 deletions src/twyn/base/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import logging
from typing import IO, Any, Optional

import click

logger = logging.getLogger("twyn")


Expand All @@ -19,14 +17,20 @@ def __init__(self, message: str = "") -> None:
super().__init__(message or self.message)


class CliError(click.ClickException):
"""Error that will populate application errors to stdout. It does not inherit from `TwynError`."""
try:
import click

message = "CLI error"
class CliError(click.ClickException):
"""Error that will populate application errors to stdout. It does not inherit from `TwynError`."""

def __init__(self, message: str = "") -> None:
super().__init__(message)
message = "CLI error"

def __init__(self, message: str = "") -> None:
super().__init__(message)

def show(self, file: Optional[IO[Any]] = None) -> None:
logger.debug(self.format_message(), exc_info=True)
logger.error(self.format_message(), exc_info=False)

def show(self, file: Optional[IO[Any]] = None) -> None:
logger.debug(self.format_message(), exc_info=True)
logger.error(self.format_message(), exc_info=False)
except ModuleNotFoundError:
pass
21 changes: 15 additions & 6 deletions src/twyn/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,38 @@
import sys
from typing import Optional

import click
from rich.console import Console
from rich.logging import RichHandler

from twyn.__version__ import __version__
from twyn.base.constants import (
DEFAULT_PROJECT_TOML_FILE,
DEPENDENCY_FILE_MAPPING,
SELECTOR_METHOD_MAPPING,
)
from twyn.base.exceptions import CliError, TwynError
from twyn.base.exceptions import TwynError
from twyn.config.config_handler import ConfigHandler
from twyn.file_handler.file_handler import FileHandler
from twyn.main import check_dependencies
from twyn.trusted_packages.cache_handler import CacheHandler
from twyn.trusted_packages.constants import CACHE_DIR

try:
import click
from rich.console import Console
from rich.logging import RichHandler

from twyn.base.exceptions import CliError
except ImportError:
print("Could not run twyn as a cli tool, some dependencies are missing! Run `pip install twyn[cli]`.")
import sys

sys.exit(1)

logger = logging.getLogger("twyn")

logging.basicConfig(
format="%(message)s",
datefmt="[%X]",
handlers=[RichHandler(rich_tracebacks=True, show_path=False, console=Console(stderr=True))],
)
logger = logging.getLogger("twyn")


@click.group()
Expand Down
23 changes: 16 additions & 7 deletions src/twyn/main.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import logging
from collections.abc import Iterable
from typing import Optional, Union

from rich.progress import track

from twyn.base.constants import (
SELECTOR_METHOD_MAPPING,
PackageEcosystems,
Expand Down Expand Up @@ -88,21 +87,31 @@ def check_dependencies(
normalized_dependencies = top_package_reference.normalize_packages(dependencies_to_check)

typos_list = TyposquatCheckResultList()
dependencies_list = (
track(normalized_dependencies, description="Processing...") if show_progress_bar else normalized_dependencies
)
for dependency in dependencies_list:

for dependency in _get_dependencies_list(normalized_dependencies, show_progress_bar):
if dependency in normalized_allowlist_packages:
logger.info("Dependency %s is in the allowlist", dependency)
continue

logger.info("Analyzing %s", dependency)
if dependency not in trusted_packages and (typosquat_results := trusted_packages.get_typosquat(dependency)):
typos_list.errors.append(typosquat_results)

return typos_list


def _get_dependencies_list(normalized_dependencies: set[str], show_progress_bar: bool) -> Iterable[str]:
try:
from rich.progress import track # noqa: PLC0415

return (
track(normalized_dependencies, description="Processing...")
if show_progress_bar
else normalized_dependencies
)
except ImportError:
return normalized_dependencies


def _get_selector_method(selector_method: str) -> SelectorMethod:
if selector_method not in SELECTOR_METHOD_MAPPING:
InvalidSelectorMethodError("Invalid selector method")
Expand Down
2 changes: 1 addition & 1 deletion tests/main/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@
@pytest.fixture(scope="module")
def disable_track() -> Generator[None, Any, None]:
"""Disables the track UI for running tests."""
with patch("twyn.main.track") as m_track:
with patch("rich.progress.track") as m_track:
m_track.side_effect = lambda iterable, description: iterable
yield
4 changes: 2 additions & 2 deletions tests/main/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ def test_track_is_disabled_by_default_when_used_as_package(
package_ecosystem=None,
)
mock_get_packages.return_value = {"requests"}
with patch("twyn.main.track") as m_track:
with patch("rich.progress.track") as m_track:
check_dependencies()
assert m_track.call_count == 0

Expand All @@ -328,7 +328,7 @@ def test_track_is_shown_when_enabled(self, mock_config: Mock, mock_get_packages:
package_ecosystem=None,
)
mock_get_packages.return_value = {"requests"}
with patch("twyn.main.track") as m_track:
with patch("rich.progress.track") as m_track:
check_dependencies(show_progress_bar=True)
assert m_track.call_count == 1

Expand Down
Loading