Skip to content

Commit

Permalink
[SECRES-2471] Fix logging to respect configured log level (#37)
Browse files Browse the repository at this point in the history
  • Loading branch information
ikretz authored Nov 28, 2024
1 parent 8140a3b commit a444455
Show file tree
Hide file tree
Showing 11 changed files with 54 additions and 47 deletions.
39 changes: 24 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
![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)

The supply-chain firewall is a command-line tool for preventing the installation of malicious PyPI and npm packages. It is intended primarily for use by engineers to protect their development workstations from compromise in a supply-chain attack.
<p align="center">
<img src="./images/logo.png" alt="Supply-Chain Firewall" width="300" />
</p>

![scfw demo usage](images/demo.png)
Supply-Chain Firewall is a command-line tool for preventing the installation of malicious PyPI and npm packages. It is intended primarily for use by engineers to protect their development workstations from compromise in a supply-chain attack.

The firewall collects all targets that would be installed by a given `pip` or `npm` command and checks them against reputable sources of data on open source malware and vulnerabilities. The command is automatically blocked when any data source finds that any target is malicious. In cases where a data source reports other findings for a target, the findings are presented to the user along with a prompt confirming intent to proceed with the installation.
![scfw demo usage](images/demo.gif)

Supply-Chain Firewall collects all targets that would be installed by a given `pip` or `npm` command and checks them against reputable sources of data on open-source malware and vulnerabilities. The command is automatically blocked when any data source finds that any target is malicious. In cases where a data source reports other findings for a target, they are presented to the user along with a prompt confirming intent to proceed with the installation.

Default data sources include:

Expand All @@ -16,7 +20,7 @@ Default data sources include:

Users may also implement verifiers for alternative data sources. A template for implementating custom verifiers may be found in `examples/verifier.py`. Details may also be found in the API documentation.

The principal goal of the supply-chain firewall is to block 100% of installations of known-malicious packages within the purview of its data sources.
The principal goal of Supply-Chain Firewall is to block 100% of installations of known-malicious packages within the purview of its data sources.

## Getting started

Expand All @@ -33,12 +37,12 @@ make install
To check whether the installation succeeded, run the following command and verify that you see output similar to the following.
```bash
$ scfw --version
1.0.0
1.0.1
```

### Post-installation steps

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.
To get the most out of 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 `scfw` as well as enabling Datadog logging, described in more detail below.

```bash
$ scfw configure
Expand All @@ -47,40 +51,45 @@ $ 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.
| Package manager | Compatible versions |
| :---------------: | :-------------------: |
| npm | >= 7.0 |
| pip | >= 22.2 |

In keeping with its goal of blocking 100% of known-malicious package installations, `scfw` will refuse to run with an incompatible version of a supported package manager. Please upgrade to or verify that you are running a compatible version 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.
Currently, 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 run` to the `pip install` or `npm install` command you want to run.
To use Supply-Chain Firewall, prepend `scfw run` to the `pip install` or `npm install` command you want to run.

```
$ 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.
For `pip install` commands, packages will be installed in the same environment (virtual or global) in which the command was run.

## Limitations
### 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 Supply-Chain Firewall's scope. We are hoping to extend the tool's purview to other "installish" `npm` commands over time.

## Datadog Logs integration

The supply-chain firewall can optionally send logs of blocked and successful installations to Datadog.
Supply-Chain Firewall can optionally send logs of blocked and successful installations to Datadog.

![scfw datadog log](images/datadog_log.png)

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.

You can also use the `scfw configure` command to walk through the steps of configuring your environment for Datadog logging.

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.
Supply-Chain 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.

## Development

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 development.
We welcome community contributions to Supply-Chain Firewall. Refer to the [CONTRIBUTING](./CONTRIBUTING.md) guide for instructions on building the API documentation and setting up for development.

## Maintainers

Expand Down
Binary file modified images/datadog_log.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed images/demo.png
Binary file not shown.
Binary file added images/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion scfw/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
A supply-chain "firewall" for preventing the installation of vulnerable or malicious `pip` and `npm` packages.
"""

__version__ = "1.0.0"
__version__ = "1.0.1"
13 changes: 6 additions & 7 deletions scfw/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ def _cli() -> ArgumentParser:
help="Desired logging level (default: %(default)s, options: %(choices)s)"
)

subparsers = parser.add_subparsers(dest="subcommand")
subparsers = parser.add_subparsers(dest="subcommand", required=True)

for subcommand in Subcommand:
subparser = subparsers.add_parser(subcommand.value, **subcommand._parser_spec())
Expand Down Expand Up @@ -168,16 +168,15 @@ def _parse_command_line(argv: list[str]) -> tuple[Optional[Namespace], str]:
try:
args = parser.parse_args(argv[1: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
match Subcommand(args.subcommand), argv[hinge:]:
case Subcommand.Run, []:
raise ArgumentError
case Subcommand.Run, _:
args_dict = vars(args)
args_dict["command"] = argv[hinge:]
case False, True:
# Non-`run` subcommand, no `command` argument
case _, []:
pass
case _:
raise ArgumentError
Expand Down
11 changes: 7 additions & 4 deletions scfw/configure.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,8 @@ def run_configure(args: Namespace) -> int:
print(_GREETING)

answers = inquirer.prompt(_get_questions())
if (config := _format_answers(answers)):
for file in [Path.home() / file for file in _CONFIG_FILES]:
_update_config_file(file, config)
for file in [Path.home() / file for file in _CONFIG_FILES]:
_update_config_file(file, _format_answers(answers))

print(_EPILOGUE)

Expand Down Expand Up @@ -141,7 +140,11 @@ def enclose(config: str) -> str:
with open(config_file) as f:
contents = f.read()

updated = re.sub(f"{_BLOCK_START}(.*?){_BLOCK_END}", enclose(config), contents, flags=re.DOTALL)
pattern = f"{_BLOCK_START}(.*?){_BLOCK_END}"
if not config:
pattern = f"\n{pattern}\n"

updated = re.sub(pattern, enclose(config) if config else '', contents, flags=re.DOTALL)
if updated == contents and config not in contents:
updated = f"{contents}\n{enclose(config)}\n"

Expand Down
7 changes: 3 additions & 4 deletions scfw/loggers/dd_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import os
import socket

import scfw
from scfw.configure import DD_API_KEY_VAR, DD_LOG_LEVEL_VAR
from scfw.ecosystem import ECOSYSTEM
from scfw.logger import FirewallAction, FirewallLogger
Expand All @@ -32,7 +33,7 @@ class _DDLogHandler(logging.Handler):
"""
DD_SOURCE = "scfw"
DD_ENV = "dev"
DD_VERSION = "0.1.0"
DD_VERSION = scfw.__version__

def __init__(self):
super().__init__()
Expand All @@ -48,10 +49,8 @@ def emit(self, record):
env = self.DD_ENV
if not (service := os.getenv("DD_SERVICE")):
service = record.__dict__.get("ecosystem", self.DD_SOURCE)
if not (version := os.getenv("DD_VERSION")):
version = self.DD_VERSION

usm_tags = {f"env:{env}", f"version:{version}"}
usm_tags = {f"env:{env}", f"version:{self.DD_VERSION}"}

targets = record.__dict__.get("targets", {})
target_tags = set(map(lambda e: f"target:{e}", targets))
Expand Down
23 changes: 12 additions & 11 deletions scfw/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import scfw.configure as configure
import scfw.firewall as firewall

_log = logging.getLogger(__name__)


def main() -> int:
"""
Expand All @@ -21,14 +23,13 @@ def main() -> int:
args, help = cli.parse_command_line()

if not args:
print(help)
print(help, end='')
return 0

log = _root_logger()
log.setLevel(args.log_level)
_configure_logging(args.log_level)

log.info(f"Starting supply-chain firewall on {time.asctime(time.localtime())}")
log.debug(f"Command line: {vars(args)}")
_log.info(f"Starting Supply-Chain Firewall on {time.asctime(time.localtime())}")
_log.debug(f"Command line: {vars(args)}")

match Subcommand(args.subcommand):
case Subcommand.Configure:
Expand All @@ -39,17 +40,17 @@ def main() -> int:
return 0


def _root_logger() -> logging.Logger:
def _configure_logging(level: int) -> None:
"""
Configure the root logger and return a handle to it.
Configure the root logger.
Returns:
A handle to the configured root logger.
Args:
level: The log level selected by the user.
"""
handler = logging.StreamHandler()
handler.addFilter(logging.Filter(name="scfw"))
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))

log = logging.getLogger()
log.addHandler(handler)

return log
log.setLevel(level)
6 changes: 1 addition & 5 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,7 @@ def test_cli_no_options_no_command():
"""
argv = ["scfw"]
args, _ = _parse_command_line(argv)
assert args.subcommand == None
assert "command" not in args
assert "dry_run" not in args
assert "executable" not in args
assert args.log_level == _DEFAULT_LOG_LEVEL
assert args == None


def test_cli_all_options_no_command():
Expand Down

0 comments on commit a444455

Please sign in to comment.