Skip to content

Commit

Permalink
[SECRES-2569] Add a subcommand to configure the firewall environment (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
ikretz authored Nov 26, 2024
1 parent 6891e87 commit 7c20a80
Show file tree
Hide file tree
Showing 12 changed files with 448 additions and 165 deletions.
72 changes: 24 additions & 48 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Supply-chain firewall
# Supply-Chain Firewall

![Test](https://github.com/DataDog/supply-chain-firewall/actions/workflows/test.yaml/badge.svg)
![Code quality](https://github.com/DataDog/supply-chain-firewall/actions/workflows/code_quality.yaml/badge.svg)
Expand Down Expand Up @@ -30,45 +30,41 @@ cd supply-chain-firewall
make install
```

To check whether the installation succeeded, run the following command and verify that you see the help message output below.
To check whether the installation succeeded, run the following command and verify that you see output similar to the following.
```bash
$ scfw -h
usage: scfw [options] COMMAND
$ scfw --version
0.2.0
```

### Post-installation steps

A tool to prevent the installation of vulnerable or malicious pip and npm packages
To get the most out of the supply-chain firewall, it is recommended to run the `scfw configure` command after installation. This script will walk you through configuring your environment so that all `pip` or `npm` commands are passively run through the firewall as well as enabling Datadog logging, described in more detail below.

options:
-h, --help show this help message and exit
--dry-run Verify any installation targets but do not run the package manager command
--log-level LEVEL Desired logging level (default: WARNING, options: DEBUG, INFO, WARNING, ERROR)
--executable PATH Python or npm executable to use for running commands (default: environmentally determined)
```bash
$ scfw configure
...
```

### Compatibility

The supply-chain firewall is compatible with `pip >= 22.2` and `npm >= 7.0`. In keeping with its goal of blocking 100% of known-malicious package installations, the firewall will refuse to run with an incompatible version of `pip` or `npm`. Please upgrade to or verify that you are running a compatible version of `pip` or `npm` before using this tool.

Currently, the supply-chain firewall is only fully supported on macOS systems, though it should run as intended on most common Linux distributions. It is currently not supported on Windows.

## Usage

To use the supply-chain firewall, just prepend `scfw` to the `pip install` or `npm install` command you want to run.
To use the supply-chain firewall, just prepend `scfw run` to the `pip install` or `npm install` command you want to run.

```
$ scfw npm install react
$ scfw pip install -r requirements.txt
$ scfw run npm install react
$ scfw run pip install -r requirements.txt
```

For `pip install` commands, the firewall will install packages in the same environment (virtual or global) in which the command was run.

If desired, the following aliases can be added to one's `.bashrc`/`.zshrc` file to passively run all `pip` and `npm` commands through the firewall.

```
alias pip="scfw pip"
alias npm="scfw npm"
```

## Limitations

Unlike `pip`, a variety of `npm` operations beyond `npm install` can end up installing new packages. For now, only `npm install` commands are in scope for analysis with the supply chain firewall. We are hoping to extend the firewall's purview to other "installish" `npm` commands over time.
Unlike `pip`, a variety of `npm` operations beyond `npm install` can end up installing new packages. For now, only `npm install` commands are in scope for analysis with the supply-chain firewall. We are hoping to extend the firewall's purview to other "installish" `npm` commands over time.

## Datadog Logs integration

Expand All @@ -78,35 +74,15 @@ The supply-chain firewall can optionally send logs of blocked and successful ins

To opt in, set the environment variable `DD_API_KEY` to your Datadog API key, either directly in your shell environment or in a `.env` file in the current working directory. A logging level may also be selected by setting the environment variable `SCFW_DD_LOG_LEVEL` to one of `ALLOW`, `ABORT` or `BLOCK`. The `BLOCK` level only logs blocked installations, `ABORT` logs blocked and aborted installations, and `ALLOW` logs these as well as successful installations. The `BLOCK` level is set by default, i.e., when `SCFW_DD_LOG_LEVEL` is either not set or does not contain a valid log level.

Users may also implement custom loggers for use with the firewall. A template for implementating custom loggers may be found in `examples/logger.py`. Details may also be found in the API documentation.

## Development

To set up for testing and development, create a fresh `virtualenv`, activate it and run `make install-dev`. This will install `scfw` and the development dependencies.
You can also use the `scfw configure` command to walk through the steps of configuring your environment for Datadog logging.

### Testing
The firewall can integrate with user-supplied loggers. A template for implementating a custom logger may be found in `examples/logger.py`. Refer to the API documentation for details.

The test suite may be executed in the development environment by running `make test`. To additionally view code coverage, run `make coverage`.

To facilitate testing "in the wild", `scfw` provides a `--dry-run` option that will verify any installation targets and exit without executing the given package manager command:

```
$ scfw --dry-run npm install axios
Dry-run: no issues found, exiting without running command.
```

Of course, one can always test inside a container or VM for an added layer of protection, if desired.

### Code quality

The supply-chain firewall code may be typechecked with `mypy` and linted with `flake8`. Run `make typecheck` or `make lint`, respectively, in the environment where the development dependencies have been installed.

Run `make checks` to run the full suite of code quality checks, including tests. These are the same checks that run in the repository's CI, the only difference being that the CI jobs matrix test against a range of `pip` and `npm` versions. There is also a pre-commit hook that runs the checks in case one wishes to run them on each commit.

### Documentation
## Development

API documentation may be built via `pdoc` by running `make docs` from your development environment. This will automatically open the documentation in your system's default browser.
We welcome community contributions to the supply-chain firewall. Refer to the [CONTRIBUTING](./CONTRIBUTING.md) guide for instructions on building the API documentation and setting up for developing the supply-chain firewall.

## Feedback
## Maintainers

All constructive feedback is welcome and greatly appreciated. Please feel free to open an issue in this repository or reach out to Ian Kretz ([email protected]) directly via Slack or email.
- [Ian Kretz](https://github.com/ikretz)
- [Sebastian Obregoso](https://www.linkedin.com/in/sebastianobregoso/)
Binary file modified images/demo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 13 additions & 9 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

[tool.setuptools]
packages = ["scfw", "scfw.commands", "scfw.loggers", "scfw.verifiers"]

[project]
name = "scfw"
version = "0.1.0"
dynamic = ["version"]
dependencies = [
"datadog-api-client",
"inquirer",
"packaging",
"python-dotenv",
"requests",
Expand All @@ -25,4 +19,14 @@ description = "A tool for preventing the installation of malicious open-source p
readme = "README.md"

[project.scripts]
scfw = "scfw.firewall:run_firewall"
scfw = "scfw.main:main"

[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

[tool.setuptools]
packages = ["scfw", "scfw.commands", "scfw.loggers", "scfw.verifiers"]

[tool.setuptools.dynamic]
version = {attr = "scfw.__version__"}
21 changes: 21 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
blessed==1.20.0 ; python_version >= "3.10" and python_version < "4" \
--hash=sha256:0c542922586a265e699188e52d5f5ac5ec0dd517e5a1041d90d2bbf23f906058 \
--hash=sha256:2cdd67f8746e048f00df47a2880f4d6acbcdb399031b604e34ba8f71d5787680
certifi==2024.8.30 ; python_version >= "3.10" and python_version < "4" \
--hash=sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8 \
--hash=sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9
Expand Down Expand Up @@ -110,9 +113,15 @@ charset-normalizer==3.4.0 ; python_version >= "3.10" and python_version < "4" \
datadog-api-client==2.30.0 ; python_version >= "3.10" and python_version < "4" \
--hash=sha256:05b1fa4a6b08d474149a7d2fffc7b43a68e686a912b8db4e23aecea2b5a5ba0a \
--hash=sha256:a98c0cbe357e14b2288faa7192f8f802d34bc95ea23e82bff3b24cc590d63233
editor==1.6.6 ; python_version >= "3.10" and python_version < "4" \
--hash=sha256:bb6989e872638cd119db9a4fce284cd8e13c553886a1c044c6b8d8a160c871f8 \
--hash=sha256:e818e6913f26c2a81eadef503a2741d7cca7f235d20e217274a009ecd5a74abf
idna==3.10 ; python_version >= "3.10" and python_version < "4" \
--hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \
--hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3
inquirer==3.4.0 ; python_version >= "3.10" and python_version < "4" \
--hash=sha256:8edc99c076386ee2d2204e5e3653c2488244e82cb197b2d498b3c1b5ffb25d0b \
--hash=sha256:bb0ec93c833e4ce7b51b98b1644b0a4d2bb39755c39787f6a504e4fee7a11b60
packaging==24.2 ; python_version >= "3.10" and python_version < "4" \
--hash=sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759 \
--hash=sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f
Expand All @@ -122,9 +131,15 @@ python-dateutil==2.9.0.post0 ; python_version >= "3.10" and python_version < "4"
python-dotenv==1.0.1 ; python_version >= "3.10" and python_version < "4" \
--hash=sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca \
--hash=sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a
readchar==4.2.1 ; python_version >= "3.10" and python_version < "4" \
--hash=sha256:91ce3faf07688de14d800592951e5575e9c7a3213738ed01d394dcc949b79adb \
--hash=sha256:a769305cd3994bb5fa2764aa4073452dc105a4ec39068ffe6efd3c20c60acc77
requests==2.32.3 ; python_version >= "3.10" and python_version < "4" \
--hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \
--hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6
runs==1.2.2 ; python_version >= "3.10" and python_version < "4" \
--hash=sha256:0980dcbc25aba1505f307ac4f0e9e92cbd0be2a15a1e983ee86c24c87b839dfd \
--hash=sha256:9dc1815e2895cfb3a48317b173b9f1eac9ba5549b36a847b5cc60c3bf82ecef1
six==1.16.0 ; python_version >= "3.10" and python_version < "4" \
--hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \
--hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254
Expand All @@ -134,3 +149,9 @@ typing-extensions==4.12.2 ; python_version >= "3.10" and python_version < "4" \
urllib3==2.2.3 ; python_version >= "3.10" and python_version < "4" \
--hash=sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac \
--hash=sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9
wcwidth==0.2.13 ; python_version >= "3.10" and python_version < "4" \
--hash=sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859 \
--hash=sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5
xmod==1.8.1 ; python_version >= "3.10" and python_version < "4" \
--hash=sha256:38c76486b9d672c546d57d8035df0beb7f4a9b088bc3fb2de5431ae821444377 \
--hash=sha256:a24e9458a4853489042522bdca9e50ee2eac5ab75c809a91150a8a7f40670d48
2 changes: 2 additions & 0 deletions scfw/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""
A supply-chain "firewall" for preventing the installation of vulnerable or malicious `pip` and `npm` packages.
"""

__version__ = "0.2.0"
4 changes: 2 additions & 2 deletions scfw/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

import sys

from scfw.firewall import run_firewall
import scfw.main as main


if __name__ == "__main__":
sys.exit(run_firewall())
sys.exit(main.main())
130 changes: 110 additions & 20 deletions scfw/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
"""

from argparse import Namespace
from enum import Enum
import logging
import sys
from typing import Optional
from typing import Callable, Optional

import scfw
from scfw.ecosystem import ECOSYSTEM
from scfw.parser import ArgumentError, ArgumentParser

Expand All @@ -19,27 +21,104 @@
_DEFAULT_LOG_LEVEL = logging.getLevelName(logging.WARNING)


def _add_configure_cli(parser: ArgumentParser) -> None:
"""
Defines the command-line interface for the firewall's `configure` subcommand.
Args:
parser: The `ArgumentParser` to which the `configure` command line will be added.
"""
return


def _add_run_cli(parser: ArgumentParser) -> None:
"""
Defines the command-line interface for the firewall's `run` subcommand.
Args:
parser: The `ArgumentParser` to which the `run` command line will be added.
"""
parser.add_argument(
"--dry-run",
action="store_true",
help="Verify any installation targets but do not run the package manager command"
)

parser.add_argument(
"--executable",
type=str,
default=None,
metavar="PATH",
help="Python or npm executable to use for running commands (default: environmentally determined)"
)


class Subcommand(Enum):
"""
The set of subcommands that comprise the supply-chain firewall's command line.
"""
Configure = "configure"
Run = "run"

def _parser_spec(self) -> dict:
"""
Return the `ArgumentParser` configuration for the given subcommand's parser.
Returns:
A `dict` of `kwargs` to pass to the `argparse.SubParsersAction.add_parser()`
method for configuring the subparser corresponding to the subcommand.
"""
match self:
case Subcommand.Configure:
return {
"exit_on_error": False,
"description": "Configure the environment for using the supply-chain firewall."
}
case Subcommand.Run:
return {
"usage": "%(prog)s [options] COMMAND",
"exit_on_error": False,
"description": "Run a package manager command through the supply-chain firewall."
}

def _cli_spec(self) -> Callable[[ArgumentParser], None]:
"""
Return a function for adding the given subcommand's command-line options
to a given `ArgumentParser`.
Returns:
A `Callable[[ArgumentParser], None]` that adds the command-line options
for the subcommand to the `ArgumentParser` it is given, in the intended
case via a sequence of calls to `ArgumentParser.add_argument()`.
"""
match self:
case Subcommand.Configure:
return _add_configure_cli
case Subcommand.Run:
return _add_run_cli


def _cli() -> ArgumentParser:
"""
Defines the command-line interface for the supply-chain firewall.
Returns:
A parser for the supply-chain firewall's command line.
This parser only handles the firewall's optional arguments, not the package
manager command being run through the firewall.
This parser only handles the firewall's own optional arguments and subcommands.
It does not parse the package manager commands being run through the firewall.
"""
parser = ArgumentParser(
prog="scfw",
usage="%(prog)s [options] COMMAND",
exit_on_error=False,
description="A tool to prevent the installation of vulnerable or malicious pip and npm packages"
description="A tool for preventing the installation of malicious PyPI and npm packages."
)

parser.add_argument(
"--dry-run",
action="store_true",
help="Verify any installation targets but do not run the package manager command"
"-v",
"--version",
action="version",
version=scfw.__version__
)

parser.add_argument(
Expand All @@ -51,13 +130,11 @@ def _cli() -> ArgumentParser:
help="Desired logging level (default: %(default)s, options: %(choices)s)"
)

parser.add_argument(
"--executable",
type=str,
default=None,
metavar="PATH",
help="Python or npm executable to use for running commands (default: environmentally determined)"
)
subparsers = parser.add_subparsers(dest="subcommand")

for subcommand in Subcommand:
subparser = subparsers.add_parser(subcommand.value, **subcommand._parser_spec())
subcommand._cli_spec()(subparser)

return parser

Expand All @@ -74,9 +151,9 @@ def _parse_command_line(argv: list[str]) -> tuple[Optional[Namespace], str]:
argument vector and a `str` help message for the caller's use in early exits.
In the case of a parsing failure, `None` is returned instead of a `Namespace`.
On success, the returned `Namespace` contains the package manager command
present in the given argument vector as a (possibly empty) `list[str]` under
the `command` attribute.
On success, and only for the `run` subcommand, the returned `Namespace` contains
the package manager command present in the given argument vector as a `list[str]`
under the `command` attribute.
"""
hinge = len(argv)
for ecosystem in ECOSYSTEM:
Expand All @@ -90,8 +167,21 @@ def _parse_command_line(argv: list[str]) -> tuple[Optional[Namespace], str]:

try:
args = parser.parse_args(argv[1:hinge])
args_dict = vars(args)
args_dict["command"] = argv[hinge:]

# TODO(ikretz): Use `Subcommand` here instead of strings
# Only allow a package manager `command` argument when
# the user selected the `run` subcommand
match args.subcommand == "run", hinge == len(argv):
case True, False:
# `run` subcommand with `command` argument
args_dict = vars(args)
args_dict["command"] = argv[hinge:]
case False, True:
# Non-`run` subcommand, no `command` argument
pass
case _:
raise ArgumentError

return args, help_msg

except ArgumentError:
Expand Down
Loading

0 comments on commit 7c20a80

Please sign in to comment.