From 26faf8f3f04b1c5f9f81c03ffc6b2008732207e2 Mon Sep 17 00:00:00 2001 From: Teppei Fukuda Date: Tue, 14 May 2024 12:29:20 +0400 Subject: [PATCH] feat: add support for plugin index (#6674) Signed-off-by: knqyf263 Co-authored-by: DmitriyLewen <91113035+DmitriyLewen@users.noreply.github.com> --- .github/workflows/semantic-pr.yaml | 1 + cmd/trivy/main.go | 5 +- docs/community/contribute/pr.md | 1 + docs/docs/advanced/plugins.md | 236 ------------ docs/docs/configuration/reporting.md | 2 +- docs/docs/plugin/developer-guide.md | 203 ++++++++++ docs/docs/plugin/index.md | 70 ++++ docs/docs/plugin/user-guide.md | 207 ++++++++++ .../configuration/cli/trivy_plugin.md | 4 +- .../configuration/cli/trivy_plugin_install.md | 2 +- .../configuration/cli/trivy_plugin_run.md | 2 +- .../configuration/cli/trivy_plugin_search.md | 31 ++ .../configuration/cli/trivy_plugin_update.md | 4 +- .../configuration/cli/trivy_plugin_upgrade.md | 31 ++ mkdocs.yml | 29 +- pkg/clock/clock.go | 7 +- pkg/commands/app.go | 91 +++-- pkg/flag/options.go | 2 +- pkg/log/handler.go | 7 + pkg/plugin/index.go | 117 ++++++ pkg/plugin/index_test.go | 87 +++++ pkg/plugin/manager.go | 355 ++++++++++++++++++ .../{plugin_test.go => manager_test.go} | 315 +++++++++------- pkg/plugin/plugin.go | 306 +++------------ pkg/plugin/testdata/plugin/index.yaml | 15 + pkg/plugin/testdata/test_plugin/plugin.yaml | 2 +- pkg/utils/fsutils/fs.go | 9 + 27 files changed, 1444 insertions(+), 697 deletions(-) delete mode 100644 docs/docs/advanced/plugins.md create mode 100644 docs/docs/plugin/developer-guide.md create mode 100644 docs/docs/plugin/index.md create mode 100644 docs/docs/plugin/user-guide.md create mode 100644 docs/docs/references/configuration/cli/trivy_plugin_search.md create mode 100644 docs/docs/references/configuration/cli/trivy_plugin_upgrade.md create mode 100644 pkg/plugin/index.go create mode 100644 pkg/plugin/index_test.go create mode 100644 pkg/plugin/manager.go rename pkg/plugin/{plugin_test.go => manager_test.go} (56%) create mode 100644 pkg/plugin/testdata/plugin/index.yaml diff --git a/.github/workflows/semantic-pr.yaml b/.github/workflows/semantic-pr.yaml index f02ef758ae9..ead9c4cccdd 100644 --- a/.github/workflows/semantic-pr.yaml +++ b/.github/workflows/semantic-pr.yaml @@ -44,6 +44,7 @@ jobs: k8s aws vm + plugin alpine wolfi diff --git a/cmd/trivy/main.go b/cmd/trivy/main.go index dbff5fa54ab..9e4fe4f5d63 100644 --- a/cmd/trivy/main.go +++ b/cmd/trivy/main.go @@ -28,10 +28,7 @@ func main() { func run() error { // Trivy behaves as the specified plugin. if runAsPlugin := os.Getenv("TRIVY_RUN_AS_PLUGIN"); runAsPlugin != "" { - if !plugin.IsPredefined(runAsPlugin) { - return xerrors.Errorf("unknown plugin: %s", runAsPlugin) - } - if err := plugin.RunWithURL(context.Background(), runAsPlugin, plugin.RunOptions{Args: os.Args[1:]}); err != nil { + if err := plugin.RunWithURL(context.Background(), runAsPlugin, plugin.Options{Args: os.Args[1:]}); err != nil { return xerrors.Errorf("plugin error: %w", err) } return nil diff --git a/docs/community/contribute/pr.md b/docs/community/contribute/pr.md index 2538cce3327..072d7358b8c 100644 --- a/docs/community/contribute/pr.md +++ b/docs/community/contribute/pr.md @@ -114,6 +114,7 @@ mode: - server - aws - vm +- plugin os: diff --git a/docs/docs/advanced/plugins.md b/docs/docs/advanced/plugins.md deleted file mode 100644 index dfdfb31d8c0..00000000000 --- a/docs/docs/advanced/plugins.md +++ /dev/null @@ -1,236 +0,0 @@ -# Plugins -Trivy provides a plugin feature to allow others to extend the Trivy CLI without the need to change the Trivycode base. -This plugin system was inspired by the plugin system used in [kubectl][kubectl], [Helm][helm], and [Conftest][conftest]. - -## Overview -Trivy plugins are add-on tools that integrate seamlessly with Trivy. -They provide a way to extend the core feature set of Trivy, but without requiring every new feature to be written in Go and added to the core tool. - -- They can be added and removed from a Trivy installation without impacting the core Trivy tool. -- They can be written in any programming language. -- They integrate with Trivy, and will show up in Trivy help and subcommands. - -!!! warning - Trivy plugins available in public are not audited for security. - You should install and run third-party plugins at your own risk, since they are arbitrary programs running on your machine. - - -## Installing a Plugin -A plugin can be installed using the `trivy plugin install` command. -This command takes a url and will download the plugin and install it in the plugin cache. - -Trivy adheres to the XDG specification, so the location depends on whether XDG_DATA_HOME is set. -Trivy will now search XDG_DATA_HOME for the location of the Trivy plugins cache. -The preference order is as follows: - -- XDG_DATA_HOME if set and .trivy/plugins exists within the XDG_DATA_HOME dir -- ~/.trivy/plugins - -Under the hood Trivy leverages [go-getter][go-getter] to download plugins. -This means the following protocols are supported for downloading plugins: - -- OCI Registries -- Local Files -- Git -- HTTP/HTTPS -- Mercurial -- Amazon S3 -- Google Cloud Storage - -For example, to download the Kubernetes Trivy plugin you can execute the following command: - -```bash -$ trivy plugin install github.com/aquasecurity/trivy-plugin-kubectl -``` -Also, Trivy plugin can be installed from a local archive: -```bash -$ trivy plugin install myplugin.tar.gz -``` - -## Using Plugins -Once the plugin is installed, Trivy will load all available plugins in the cache on the start of the next Trivy execution. -A plugin will be made in the Trivy CLI based on the plugin name. -To display all plugins, you can list them by `trivy --help` - -```bash -$ trivy --help -NAME: - trivy - A simple and comprehensive vulnerability scanner for containers - -USAGE: - trivy [global options] command [command options] target - -VERSION: - dev - -COMMANDS: - image, i scan an image - filesystem, fs scan local filesystem - repository, repo scan remote repository - client, c client mode - server, s server mode - plugin, p manage plugins - kubectl scan kubectl resources - help, h Shows a list of commands or help for one command -``` - -As shown above, `kubectl` subcommand exists in the `COMMANDS` section. -To call the kubectl plugin and scan existing Kubernetes deployments, you can execute the following command: - -``` -$ trivy kubectl deployment -- --ignore-unfixed --severity CRITICAL -``` - -Internally the kubectl plugin calls the kubectl binary to fetch information about that deployment and passes the using images to Trivy. -You can see the detail [here][trivy-plugin-kubectl]. - -If you want to omit even the subcommand, you can use `TRIVY_RUN_AS_PLUGIN` environment variable. - -```bash -$ TRIVY_RUN_AS_PLUGIN=kubectl trivy job your-job -- --format json -``` - -## Installing and Running Plugins on the fly -`trivy plugin run` installs a plugin and runs it on the fly. -If the plugin is already present in the cache, the installation is skipped. - -```bash -trivy plugin run github.com/aquasecurity/trivy-plugin-kubectl pod your-pod -- --exit-code 1 -``` - -## Uninstalling Plugins -Specify a plugin name with `trivy plugin uninstall` command. - -```bash -$ trivy plugin uninstall kubectl -``` - -## Building Plugins -Each plugin has a top-level directory, and then a plugin.yaml file. - -```bash -your-plugin/ - | - |- plugin.yaml - |- your-plugin.sh -``` - -In the example above, the plugin is contained inside of a directory named `your-plugin`. -It has two files: plugin.yaml (required) and an executable script, your-plugin.sh (optional). - -The core of a plugin is a simple YAML file named plugin.yaml. -Here is an example YAML of trivy-plugin-kubectl plugin that adds support for Kubernetes scanning. - -```yaml -name: "kubectl" -repository: github.com/aquasecurity/trivy-plugin-kubectl -version: "0.1.0" -usage: scan kubectl resources -description: |- - A Trivy plugin that scans the images of a kubernetes resource. - Usage: trivy kubectl TYPE[.VERSION][.GROUP] NAME -platforms: - - selector: # optional - os: darwin - arch: amd64 - uri: ./trivy-kubectl # where the execution file is (local file, http, git, etc.) - bin: ./trivy-kubectl # path to the execution file - - selector: # optional - os: linux - arch: amd64 - uri: https://github.com/aquasecurity/trivy-plugin-kubectl/releases/download/v0.1.0/trivy-kubectl.tar.gz - bin: ./trivy-kubectl -``` - -The `plugin.yaml` field should contain the following information: - -- name: The name of the plugin. This also determines how the plugin will be made available in the Trivy CLI. For example, if the plugin is named kubectl, you can call the plugin with `trivy kubectl`. (required) -- version: The version of the plugin. (required) -- usage: A short usage description. (required) -- description: A long description of the plugin. This is where you could provide a helpful documentation of your plugin. (required) -- platforms: (required) - - selector: The OS/Architecture specific variations of a execution file. (optional) - - os: OS information based on GOOS (linux, darwin, etc.) (optional) - - arch: The architecture information based on GOARCH (amd64, arm64, etc.) (optional) - - uri: Where the executable file is. Relative path from the root directory of the plugin or remote URL such as HTTP and S3. (required) - - bin: Which file to call when the plugin is executed. Relative path from the root directory of the plugin. (required) - -The following rules will apply in deciding which platform to select: - -- If both `os` and `arch` under `selector` match the current platform, search will stop and the platform will be used. -- If `selector` is not present, the platform will be used. -- If `os` matches and there is no more specific `arch` match, the platform will be used. -- If no `platform` match is found, Trivy will exit with an error. - -After determining platform, Trivy will download the execution file from `uri` and store it in the plugin cache. -When the plugin is called via Trivy CLI, `bin` command will be executed. - -The plugin is responsible for handling flags and arguments. Any arguments are passed to the plugin from the `trivy` command. - -A plugin should be archived `*.tar.gz`. - -```bash -$ tar -czvf myplugin.tar.gz plugin.yaml script.py -plugin.yaml -script.py - -$ trivy plugin install myplugin.tar.gz -2023-03-03T19:04:42.026+0600 INFO Installing the plugin from myplugin.tar.gz... -2023-03-03T19:04:42.026+0600 INFO Loading the plugin metadata... - -$ trivy myplugin -Hello from Trivy demo plugin! -``` - -## Plugin Types -Plugins are typically intended to be used as subcommands of Trivy, -but some plugins can be invoked as part of Trivy's built-in commands. -Currently, the following type of plugin is experimentally supported: - -- Output plugins - -### Output Plugins - -!!! warning "EXPERIMENTAL" - This feature might change without preserving backwards compatibility. - -Trivy supports "output plugins" which process Trivy's output, -such as by transforming the output format or sending it elsewhere. -For instance, in the case of image scanning, the output plugin can be called as follows: - -```shell -$ trivy image --format json --output plugin= [--output-plugin-arg ] -``` - -Since scan results are passed to the plugin via standard input, plugins must be capable of handling standard input. - -!!! warning - To avoid Trivy hanging, you need to read all data from `Stdin` before the plugin exits successfully or stops with an error. - -While the example passes JSON to the plugin, other formats like SBOM can also be passed (e.g., `--format cyclonedx`). - -If a plugin requires flags or other arguments, they can be passed using `--output-plugin-arg`. -This is directly forwarded as arguments to the plugin. -For example, `--output plugin=myplugin --output-plugin-arg "--foo --bar=baz"` translates to `myplugin --foo --bar=baz` in execution. - -An example of the output plugin is available [here](https://github.com/aquasecurity/trivy-output-plugin-count). -It can be used as below: - -```shell -# Install the plugin first -$ trivy plugin install github.com/aquasecurity/trivy-output-plugin-count - -# Call the output plugin in image scanning -$ trivy image --format json --output plugin=count --output-plugin-arg "--published-after 2023-10-01" debian:12 -``` - -## Example -- https://github.com/aquasecurity/trivy-plugin-kubectl -- https://github.com/aquasecurity/trivy-output-plugin-count - -[kubectl]: https://kubernetes.io/docs/tasks/extend-kubectl/kubectl-plugins/ -[helm]: https://helm.sh/docs/topics/plugins/ -[conftest]: https://www.conftest.dev/plugins/ -[go-getter]: https://github.com/hashicorp/go-getter -[trivy-plugin-kubectl]: https://github.com/aquasecurity/trivy-plugin-kubectl - diff --git a/docs/docs/configuration/reporting.md b/docs/docs/configuration/reporting.md index 117db88de86..8671501ad88 100644 --- a/docs/docs/configuration/reporting.md +++ b/docs/docs/configuration/reporting.md @@ -399,7 +399,7 @@ $ trivy [--format ] --output plugin= [--output-plu ``` This is useful for cases where you want to convert the output into a custom format, or when you want to send the output somewhere. -For more details, please check [here](../advanced/plugins.md#output-plugins). +For more details, please check [here](../plugin/plugins.md#output-plugins). ## Converting To generate multiple reports, you can generate the JSON report first and convert it to other formats with the `convert` subcommand. diff --git a/docs/docs/plugin/developer-guide.md b/docs/docs/plugin/developer-guide.md new file mode 100644 index 00000000000..5080bab9ede --- /dev/null +++ b/docs/docs/plugin/developer-guide.md @@ -0,0 +1,203 @@ +# Developer Guide + +## Developing Trivy plugins +This section will guide you through the process of developing Trivy plugins. +To help you get started quickly, we have published a [plugin template repository][plugin-template]. +You can use this template as a starting point for your plugin development. + +### Introduction +If you are looking to start developing plugins for Trivy, read [the user guide](./user-guide.md) first. + +The development process involves the following steps: + +- Create a repository for your plugin, named `trivy-plugin-`. +- Create an executable binary that can be invoked as `trivy `. +- Place the executable binary in a repository. +- Create a `plugin.yaml` file that describes the plugin. +- (Submit your plugin to the [Trivy plugin index][trivy-plugin-index].) + +After you develop a plugin with a good name following the best practices and publish it, you can submit your plugin to the [Trivy plugin index][trivy-plugin-index]. + +### Naming +This section describes guidelines for naming your plugins. + +#### Use `trivy-plugin-` prefix +The name of the plugin repository should be prefixed with `trivy-plugin-`. + +#### Use lowercase and hyphens +Plugin names must be all lowercase and separate words with hyphens. +Don’t use camelCase, PascalCase, or snake_case; use kebab-case. + +- NO: `trivy OpenSvc` +- YES: `trivy open-svc` + +#### Be specific +Plugin names should not be verbs or nouns that are generic, already overloaded, or likely to be used for broader purposes by another plugin. + +- NO: trivy sast (Too broad) +- YES: trivy govulncheck + + +#### Be unique +Find a unique name for your plugin that differentiates it from other plugins that perform a similar function. + +- NO: `trivy images` (Unclear how it is different from the builtin “image" command) +- YES: `trivy registry-images` (Unique name). + +#### Prefix Vendor Identifiers +Use vendor-specific strings as prefix, separated with a dash. +This makes it easier to search/group plugins that are about a specific vendor. + +- NO: `trivy security-hub-aws (Makes it harder to search or locate in a plugin list) +- YES: `trivy aws-security-hub (Will show up together with other aws-* plugins) + +### Choosing a language +Since Trivy plugins are standalone executables, you can write them in any programming language. + +If you are planning to write a plugin with Go, check out [the Report struct](https://github.com/aquasecurity/trivy/blob/787b466e069e2d04e73b3eddbda621e5eec8543b/pkg/types/report.go#L13-L24), +which is the output of Trivy scan. + + +### Writing your plugin +Each plugin has a top-level directory, and then a `plugin.yaml` file. + +```bash +your-plugin/ + | + |- plugin.yaml + |- your-plugin.sh +``` + +In the example above, the plugin is contained inside a directory named `your-plugin`. +It has two files: `plugin.yaml` (required) and an executable script, `your-plugin.sh` (optional). + +#### Writing a plugin manifest +The plugin manifest is a simple YAML file named `plugin.yaml`. +Here is an example YAML of [trivy-plugin-kubectl][trivy-plugin-kubectl] plugin that adds support for Kubernetes scanning. + +```yaml +name: "kubectl" +version: "0.1.0" +repository: github.com/aquasecurity/trivy-plugin-kubectl +maintainer: aquasecurity +output: false +summary: Scan kubectl resources +description: |- + A Trivy plugin that scans the images of a kubernetes resource. + Usage: trivy kubectl TYPE[.VERSION][.GROUP] NAME +platforms: + - selector: # optional + os: darwin + arch: amd64 + uri: ./trivy-kubectl # where the execution file is (local file, http, git, etc.) + bin: ./trivy-kubectl # path to the execution file + - selector: # optional + os: linux + arch: amd64 + uri: https://github.com/aquasecurity/trivy-plugin-kubectl/releases/download/v0.1.0/trivy-kubectl.tar.gz + bin: ./trivy-kubectl +``` + +We encourage you to copy and adapt plugin manifests of existing plugins. + +- [count][trivy-plugin-count] +- [referrer][trivy-plugin-referrer] + +The `plugin.yaml` field should contain the following information: + +- name: The name of the plugin. This also determines how the plugin will be made available in the Trivy CLI. For example, if the plugin is named kubectl, you can call the plugin with `trivy kubectl`. (required) +- version: The version of the plugin. [Semantic Versioning][semver] should be used. (required) +- repository: The repository name where the plugin is hosted. (required) +- maintainer: The name of the maintainer of the plugin. (required) +- output: Whether the plugin supports [the output mode](./user-guide.md#output-mode-support). (optional) +- usage: Deprecated: use summary instead. (optional) +- summary: A short usage description. (required) +- description: A long description of the plugin. This is where you could provide a helpful documentation of your plugin. (required) +- platforms: (required) + - selector: The OS/Architecture specific variations of a execution file. (optional) + - os: OS information based on GOOS (linux, darwin, etc.) (optional) + - arch: The architecture information based on GOARCH (amd64, arm64, etc.) (optional) + - uri: Where the executable file is. Relative path from the root directory of the plugin or remote URL such as HTTP and S3. (required) + - bin: Which file to call when the plugin is executed. Relative path from the root directory of the plugin. (required) + +The following rules will apply in deciding which platform to select: + +- If both `os` and `arch` under `selector` match the current platform, search will stop and the platform will be used. +- If `selector` is not present, the platform will be used. +- If `os` matches and there is no more specific `arch` match, the platform will be used. +- If no `platform` match is found, Trivy will exit with an error. + +After determining platform, Trivy will download the execution file from `uri` and store it in the plugin cache. +When the plugin is called via Trivy CLI, `bin` command will be executed. + +#### Plugin arguments/flags +The plugin is responsible for handling flags and arguments. +Any arguments are passed to the plugin from the `trivy` command. + +#### Testing plugin installation locally +A plugin should be archived `*.tar.gz`. +After you have archived your plugin into a `.tar.gz` file, you can verify that your plugin installs correctly with Trivy. + +```bash +$ tar -czvf myplugin.tar.gz plugin.yaml script.py +plugin.yaml +script.py + +$ trivy plugin install myplugin.tar.gz +2023-03-03T19:04:42.026+0600 INFO Installing the plugin from myplugin.tar.gz... +2023-03-03T19:04:42.026+0600 INFO Loading the plugin metadata... + +$ trivy myplugin +Hello from Trivy demo plugin! +``` + +## Publishing plugins +The [plugin.yaml](#writing-a-plugin-manifest) file is the core of your plugin, so as long as it is published somewhere, your plugin can be installed. +If you choose to publish your plugin on GitHub, you can make it installable by placing the plugin.yaml file in the root directory of your repository. +Users can then install your plugin with the command, `trivy plugin install github.com/org/repo`. + +While the `uri` specified in the plugin.yaml file doesn't necessarily need to point to the same repository, it's a good practice to host the executable file within the same repository when using GitHub. +You can utilize GitHub Releases to distribute the executable file. +For an example of how to structure your plugin repository, refer to [the plugin template repository][plugin-template]. + +## Distributing plugins via the Trivy plugin index +Trivy can install plugins directly by specifying a repository, like `trivy plugin install github.com/aquasecurity/trivy-plugin-referrer`, +so you don't necessarily need to register your plugin in the Trivy plugin index. +However, we would recommend distributing your plugin via the Trivy plugin index +since it makes it easier for other users to find (`trivy plugin search`) and install your plugin (e.g. `trivy plugin install kubectl`). + +### Pre-submit checklist +- Review [the plugin naming guide](#naming). +- Ensure the `plugin.yaml` file has all the required fields. +- Tag a git release with a semantic version (e.g. v1.0.0). +- [Test your plugin installation locally](#testing-plugin-installation-locally). + +### Submitting plugins +Submitting your plugin to the plugin index is a straightforward process. +All you need to do is create a YAML file for your plugin and place it in the [plugins/](https://github.com/aquasecurity/trivy-plugin-index/tree/main/plugins) directory of [the index repository][trivy-plugin-index]. + +Once you've done that, create a pull request (PR) and have it reviewed by the maintainers. +Once your PR is merged, the index will be updated, and your plugin will be available for installation. +[The plugin index page][plugin-list] will also be automatically updated to list your newly added plugin. + +The content of the YAML file is very simple. +You only need to specify the name of your plugin and the repository where it is distributed. + +```yaml +name: referrer +repository: github.com/aquasecurity/trivy-plugin-referrer +``` + +After your PR is merged, the CI system will automatically retrieve the `plugin.yaml` file from your repository and update [the index.yaml file][index]. +If any required fields are missing from your `plugin.yaml`, the CI will fail, so make sure your `plugin.yaml` has all the required fields before creating a PR. +Once [the index.yaml][index] has been updated, running `trivy plugin update` will download the updated index to your local machine. + + +[plugin-template]: https://github.com/aquasecurity/trivy-plugin-template +[plugin-list]: https://aquasecurity.github.io/trivy-plugin-index/ +[index]: https://aquasecurity.github.io/trivy-plugin-index/v1/index.yaml +[semver]: https://semver.org/ +[trivy-plugin-index]: https://github.com/aquasecurity/trivy-plugin-index +[trivy-plugin-kubectl]: https://github.com/aquasecurity/trivy-plugin-kubectl +[trivy-plugin-count]: https://github.com/aquasecurity/trivy-plugin-count/blob/main/plugin.yaml +[trivy-plugin-referrer]: https://github.com/aquasecurity/trivy-plugin-referrer/blob/main/plugin.yaml diff --git a/docs/docs/plugin/index.md b/docs/docs/plugin/index.md new file mode 100644 index 00000000000..b640dce47b9 --- /dev/null +++ b/docs/docs/plugin/index.md @@ -0,0 +1,70 @@ +# Plugins +Trivy provides a plugin feature to allow others to extend the Trivy CLI without the need to change the Trivy code base. +This plugin system was inspired by the plugin system used in [kubectl][kubectl], [Helm][helm], and [Conftest][conftest]. + +## Overview +Trivy plugins are add-on tools that integrate seamlessly with Trivy. +They provide a way to extend the core feature set of Trivy, but without requiring every new feature to be written in Go and added to the core tool. + +- They can be added and removed from a Trivy installation without impacting the core Trivy tool. +- They can be written in any programming language. +- They integrate with Trivy, and will show up in Trivy help and subcommands. + +!!! warning + Trivy plugins available in public are not audited for security. + You should install and run third-party plugins at your own risk, since they are arbitrary programs running on your machine. + +## Quickstart +Trivy helps you discover and install plugins on your machine. + +You can install and use a wide variety of Trivy plugins to enhance your experience. + +Let’s get started: + +1. Download the plugin list: + + ```bash + $ trivy plugin update + ``` + +2. Discover Trivy plugins available on the plugin index: + + ```bash + $ trivy plugin search + NAME DESCRIPTION MAINTAINER OUTPUT + aqua A plugin for integration with Aqua Security SaaS platform aquasecurity + kubectl A plugin scanning the images of a kubernetes resource aquasecurity + referrer A plugin for OCI referrers aquasecurity ✓ + [...] + ``` + +3. Choose a plugin from the list and install it: + + ```bash + $ trivy plugin install referrer + ``` + +4. Use the installed plugin: + + ```bash + $ trivy referrer --help + ``` + +5. Keep your plugins up-to-date: + + ```bash + $ trivy plugin upgrade + ``` + +6. Uninstall a plugin you no longer use: + + ```bash + trivy plugin uninstall referrer + ``` + +This is practically all you need to know to start using Trivy plugins. + + +[kubectl]: https://kubernetes.io/docs/tasks/extend-kubectl/kubectl-plugins/ +[helm]: https://helm.sh/docs/topics/plugins/ +[conftest]: https://www.conftest.dev/plugins/ diff --git a/docs/docs/plugin/user-guide.md b/docs/docs/plugin/user-guide.md new file mode 100644 index 00000000000..b216f3d63c8 --- /dev/null +++ b/docs/docs/plugin/user-guide.md @@ -0,0 +1,207 @@ +# User Guide + +## Discovering Plugins +You can find a list of Trivy plugins distributed via trivy-plugin-index [here][trivy-plugin-index]. +However, you can find plugins using the command line as well. + +First, refresh your local copy of the plugin index: + +```bash +$ trivy plugin update +``` + +To list all plugins available, run: + +```bash +$ trivy plugin search +NAME DESCRIPTION MAINTAINER OUTPUT +aqua A plugin for integration with Aqua Security SaaS platform aquasecurity +kubectl A plugin scanning the images of a kubernetes resource aquasecurity +referrer A plugin for OCI referrers aquasecurity ✓ +``` + +You can specify search keywords as arguments: + +```bash +$ trivy plugin search referrer + +NAME DESCRIPTION MAINTAINER OUTPUT +referrer A plugin for OCI referrers aquasecurity ✓ +``` + +It lists plugins with the keyword in the name or description. + +## Installing Plugins +Plugins can be installed with the `trivy plugin install` command: + +```bash +$ trivy plugin install referrer +``` + +This command will download the plugin and install it in the plugin cache. + +Trivy adheres to the XDG specification, so the location depends on whether XDG_DATA_HOME is set. +Trivy will now search XDG_DATA_HOME for the location of the Trivy plugins cache. +The preference order is as follows: + +- XDG_DATA_HOME if set and .trivy/plugins exists within the XDG_DATA_HOME dir +- ~/.trivy/plugins + +Furthermore, it is possible to download plugins that are not registered in the index by specifying the URL directly or by specifying the file path. + +```bash +$ trivy plugin install github.com/aquasecurity/trivy-plugin-kubectl +``` +```bash +$ trivy plugin install myplugin.tar.gz +``` + +Under the hood Trivy leverages [go-getter][go-getter] to download plugins. +This means the following protocols are supported for downloading plugins: + +- OCI Registries +- Local Files +- Git +- HTTP/HTTPS +- Mercurial +- Amazon S3 +- Google Cloud Storage + +## Listing Installed Plugins +To list all plugins installed, run: + +```bash +$ trivy plugin list +``` + +## Using Plugins +Once the plugin is installed, Trivy will load all available plugins in the cache on the start of the next Trivy execution. +A plugin will be made in the Trivy CLI based on the plugin name. +To display all plugins, you can list them by `trivy --help` + +```bash +$ trivy --help +NAME: + trivy - A simple and comprehensive vulnerability scanner for containers + +USAGE: + trivy [global options] command [command options] target + +VERSION: + dev + +Scanning Commands + aws [EXPERIMENTAL] Scan AWS account + config Scan config files for misconfigurations + filesystem Scan local filesystem + image Scan a container image + +... + +Plugin Commands + kubectl scan kubectl resources + referrer Put referrers to OCI registry +``` + +As shown above, `kubectl` subcommand exists in the `Plugin Commands` section. +To call the kubectl plugin and scan existing Kubernetes deployments, you can execute the following command: + +``` +$ trivy kubectl deployment -- --ignore-unfixed --severity CRITICAL +``` + +Internally the kubectl plugin calls the kubectl binary to fetch information about that deployment and passes the using images to Trivy. +You can see the detail [here][trivy-plugin-kubectl]. + +If you want to omit even the subcommand, you can use `TRIVY_RUN_AS_PLUGIN` environment variable. + +```bash +$ TRIVY_RUN_AS_PLUGIN=kubectl trivy job your-job -- --format json +``` + +## Installing and Running Plugins on the fly +`trivy plugin run` installs a plugin and runs it on the fly. +If the plugin is already present in the cache, the installation is skipped. + +```bash +trivy plugin run kubectl pod your-pod -- --exit-code 1 +``` + +## Upgrading Plugins +To upgrade all plugins that you have installed to their latest versions, run: + +```bash +$ trivy plugin upgrade +``` + +To upgrade only certain plugins, you can explicitly specify their names: + +```bash +$ trivy plugin upgrade +``` + +## Uninstalling Plugins +Specify a plugin name with `trivy plugin uninstall` command. + +```bash +$ trivy plugin uninstall kubectl +``` + +Here's the revised English documentation based on your requested changes: + +## Output Mode Support +While plugins are typically intended to be used as subcommands of Trivy, plugins supporting the output mode can be invoked as part of Trivy's built-in commands. + +!!! warning "EXPERIMENTAL" + This feature might change without preserving backwards compatibility. + +Trivy supports plugins that are compatible with the output mode, which process Trivy's output, such as by transforming the output format or sending it elsewhere. +You can determine whether a plugin supports the output mode by checking the `OUTPUT` column in the output of `trivy plugin search` or `trivy plugin list`. + +```bash +$ trivy plugin search +NAME DESCRIPTION MAINTAINER OUTPUT +aqua A plugin for integration with Aqua Security SaaS platform aquasecurity +kubectl A plugin scanning the images of a kubernetes resource aquasecurity +referrer A plugin for OCI referrers aquasecurity ✓ +``` + +In this case, the `referrer` plugin supports the output mode. + +For instance, in the case of image scanning, a plugin supporting the output mode can be called as follows: + +```bash +$ trivy image --format json --output plugin= [--output-plugin-arg ] +``` + +Since scan results are passed to the plugin via standard input, plugins must be capable of handling standard input. + +!!! warning + To avoid Trivy hanging, you need to read all data from `Stdin` before the plugin exits successfully or stops with an error. + +While the example passes JSON to the plugin, other formats like SBOM can also be passed (e.g., `--format cyclonedx`). + +If a plugin requires flags or other arguments, they can be passed using `--output-plugin-arg`. +This is directly forwarded as arguments to the plugin. +For example, `--output plugin=myplugin --output-plugin-arg "--foo --bar=baz"` translates to `myplugin --foo --bar=baz` in execution. + +An example of a plugin supporting the output mode is available [here][trivy-plugin-count]. +It can be used as below: + +```bash +# Install the plugin first +$ trivy plugin install count + +# Call the plugin supporting the output mode in image scanning +$ trivy image --format json --output plugin=count --output-plugin-arg "--published-after 2023-10-01" debian:12 +``` + +## Example + +- [kubectl][trivy-plugin-kubectl] +- [count][trivy-plugin-count] + +[trivy-plugin-index]: https://aquasecurity.github.io/trivy-plugin-index/ +[go-getter]: https://github.com/hashicorp/go-getter +[trivy-plugin-kubectl]: https://github.com/aquasecurity/trivy-plugin-kubectl +[trivy-plugin-count]: https://github.com/aquasecurity/trivy-plugin-count diff --git a/docs/docs/references/configuration/cli/trivy_plugin.md b/docs/docs/references/configuration/cli/trivy_plugin.md index 9f47212d17f..a3d105d2cd3 100644 --- a/docs/docs/references/configuration/cli/trivy_plugin.md +++ b/docs/docs/references/configuration/cli/trivy_plugin.md @@ -28,6 +28,8 @@ Manage plugins * [trivy plugin install](trivy_plugin_install.md) - Install a plugin * [trivy plugin list](trivy_plugin_list.md) - List installed plugin * [trivy plugin run](trivy_plugin_run.md) - Run a plugin on the fly +* [trivy plugin search](trivy_plugin_search.md) - List Trivy plugins available on the plugin index and search among them * [trivy plugin uninstall](trivy_plugin_uninstall.md) - Uninstall a plugin -* [trivy plugin update](trivy_plugin_update.md) - Update an existing plugin +* [trivy plugin update](trivy_plugin_update.md) - Update the local copy of the plugin index +* [trivy plugin upgrade](trivy_plugin_upgrade.md) - Upgrade installed plugins to newer versions diff --git a/docs/docs/references/configuration/cli/trivy_plugin_install.md b/docs/docs/references/configuration/cli/trivy_plugin_install.md index f92da959832..dbd5f21797b 100644 --- a/docs/docs/references/configuration/cli/trivy_plugin_install.md +++ b/docs/docs/references/configuration/cli/trivy_plugin_install.md @@ -3,7 +3,7 @@ Install a plugin ``` -trivy plugin install URL | FILE_PATH +trivy plugin install NAME | URL | FILE_PATH ``` ### Options diff --git a/docs/docs/references/configuration/cli/trivy_plugin_run.md b/docs/docs/references/configuration/cli/trivy_plugin_run.md index 0dc7087a19d..5befb58f90e 100644 --- a/docs/docs/references/configuration/cli/trivy_plugin_run.md +++ b/docs/docs/references/configuration/cli/trivy_plugin_run.md @@ -3,7 +3,7 @@ Run a plugin on the fly ``` -trivy plugin run URL | FILE_PATH +trivy plugin run NAME | URL | FILE_PATH ``` ### Options diff --git a/docs/docs/references/configuration/cli/trivy_plugin_search.md b/docs/docs/references/configuration/cli/trivy_plugin_search.md new file mode 100644 index 00000000000..931babfd59b --- /dev/null +++ b/docs/docs/references/configuration/cli/trivy_plugin_search.md @@ -0,0 +1,31 @@ +## trivy plugin search + +List Trivy plugins available on the plugin index and search among them + +``` +trivy plugin search [KEYWORD] +``` + +### Options + +``` + -h, --help help for search +``` + +### Options inherited from parent commands + +``` + --cache-dir string cache directory (default "/path/to/cache") + -c, --config string config path (default "trivy.yaml") + -d, --debug debug mode + --generate-default-config write the default config to trivy-default.yaml + --insecure allow insecure server connections + -q, --quiet suppress progress bar and log output + --timeout duration timeout (default 5m0s) + -v, --version show version +``` + +### SEE ALSO + +* [trivy plugin](trivy_plugin.md) - Manage plugins + diff --git a/docs/docs/references/configuration/cli/trivy_plugin_update.md b/docs/docs/references/configuration/cli/trivy_plugin_update.md index 532add27cfa..da26290882b 100644 --- a/docs/docs/references/configuration/cli/trivy_plugin_update.md +++ b/docs/docs/references/configuration/cli/trivy_plugin_update.md @@ -1,9 +1,9 @@ ## trivy plugin update -Update an existing plugin +Update the local copy of the plugin index ``` -trivy plugin update PLUGIN_NAME +trivy plugin update ``` ### Options diff --git a/docs/docs/references/configuration/cli/trivy_plugin_upgrade.md b/docs/docs/references/configuration/cli/trivy_plugin_upgrade.md new file mode 100644 index 00000000000..a3d363d5643 --- /dev/null +++ b/docs/docs/references/configuration/cli/trivy_plugin_upgrade.md @@ -0,0 +1,31 @@ +## trivy plugin upgrade + +Upgrade installed plugins to newer versions + +``` +trivy plugin upgrade [PLUGIN_NAMES] +``` + +### Options + +``` + -h, --help help for upgrade +``` + +### Options inherited from parent commands + +``` + --cache-dir string cache directory (default "/path/to/cache") + -c, --config string config path (default "trivy.yaml") + -d, --debug debug mode + --generate-default-config write the default config to trivy-default.yaml + --insecure allow insecure server connections + -q, --quiet suppress progress bar and log output + --timeout duration timeout (default 5m0s) + -v, --version show version +``` + +### SEE ALSO + +* [trivy plugin](trivy_plugin.md) - Manage plugins + diff --git a/mkdocs.yml b/mkdocs.yml index c3437fecf41..9fb769a7c92 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -128,9 +128,12 @@ nav: - VEX: docs/supply-chain/vex.md - Compliance: - Reports: docs/compliance/compliance.md + - Plugin: + - Overview: docs/plugin/index.md + - User Guide: docs/plugin/user-guide.md + - Developer Guide: docs/plugin/developer-guide.md - Advanced: - Modules: docs/advanced/modules.md - - Plugins: docs/advanced/plugins.md - Air-Gapped Environment: docs/advanced/air-gap.md - Container Image: - Embed in Dockerfile: docs/advanced/container/embed-in-dockerfile.md @@ -152,16 +155,20 @@ nav: - Filesystem: docs/references/configuration/cli/trivy_filesystem.md - Image: docs/references/configuration/cli/trivy_image.md - Kubernetes: docs/references/configuration/cli/trivy_kubernetes.md - - Module: docs/references/configuration/cli/trivy_module.md - - Module Install: docs/references/configuration/cli/trivy_module_install.md - - Module Uninstall: docs/references/configuration/cli/trivy_module_uninstall.md - - Plugin: docs/references/configuration/cli/trivy_plugin.md - - Plugin Info: docs/references/configuration/cli/trivy_plugin_info.md - - Plugin Install: docs/references/configuration/cli/trivy_plugin_install.md - - Plugin List: docs/references/configuration/cli/trivy_plugin_list.md - - Plugin Run: docs/references/configuration/cli/trivy_plugin_run.md - - Plugin Uninstall: docs/references/configuration/cli/trivy_plugin_uninstall.md - - Plugin Update: docs/references/configuration/cli/trivy_plugin_update.md + - Module: + - Module: docs/references/configuration/cli/trivy_module.md + - Module Install: docs/references/configuration/cli/trivy_module_install.md + - Module Uninstall: docs/references/configuration/cli/trivy_module_uninstall.md + - Plugin: + - Plugin: docs/references/configuration/cli/trivy_plugin.md + - Plugin Info: docs/references/configuration/cli/trivy_plugin_info.md + - Plugin Install: docs/references/configuration/cli/trivy_plugin_install.md + - Plugin List: docs/references/configuration/cli/trivy_plugin_list.md + - Plugin Run: docs/references/configuration/cli/trivy_plugin_run.md + - Plugin Uninstall: docs/references/configuration/cli/trivy_plugin_uninstall.md + - Plugin Update: docs/references/configuration/cli/trivy_plugin_update.md + - Plugin Upgrade: docs/references/configuration/cli/trivy_plugin_upgrade.md + - Plugin Search: docs/references/configuration/cli/trivy_plugin_search.md - Repository: docs/references/configuration/cli/trivy_repository.md - Rootfs: docs/references/configuration/cli/trivy_rootfs.md - SBOM: docs/references/configuration/cli/trivy_sbom.md diff --git a/pkg/clock/clock.go b/pkg/clock/clock.go index 91f6a5212bd..38df49259e5 100644 --- a/pkg/clock/clock.go +++ b/pkg/clock/clock.go @@ -8,6 +8,11 @@ import ( clocktesting "k8s.io/utils/clock/testing" ) +type ( + RealClock = clock.RealClock + FakeClock = clocktesting.FakeClock +) + // clockKey is the context key for clock. It is unexported to prevent collisions with context keys defined in // other packages. type clockKey struct{} @@ -27,7 +32,7 @@ func Now(ctx context.Context) time.Time { func Clock(ctx context.Context) clock.Clock { t, ok := ctx.Value(clockKey{}).(clock.Clock) if !ok { - return clock.RealClock{} + return RealClock{} } return t } diff --git a/pkg/commands/app.go b/pkg/commands/app.go index 38ef4c38276..6ae687276f2 100644 --- a/pkg/commands/app.go +++ b/pkg/commands/app.go @@ -1,6 +1,7 @@ package commands import ( + "context" "encoding/json" "errors" "fmt" @@ -111,20 +112,24 @@ func NewApp() *cobra.Command { } func loadPluginCommands() []*cobra.Command { + ctx := context.Background() + manager := plugin.NewManager() + var commands []*cobra.Command - plugins, err := plugin.LoadAll() + plugins, err := manager.LoadAll(ctx) if err != nil { - log.Debug("No plugins loaded") + log.DebugContext(ctx, "No plugins loaded") return nil } for _, p := range plugins { p := p cmd := &cobra.Command{ Use: fmt.Sprintf("%s [flags]", p.Name), - Short: p.Usage, + Short: p.Summary, + Long: p.Description, GroupID: groupPlugin, RunE: func(cmd *cobra.Command, args []string) error { - if err = p.Run(cmd.Context(), plugin.RunOptions{Args: args}); err != nil { + if err = p.Run(cmd.Context(), plugin.Options{Args: args}); err != nil { return xerrors.Errorf("plugin error: %w", err) } return nil @@ -719,14 +724,15 @@ func NewPluginCommand() *cobra.Command { } cmd.AddCommand( &cobra.Command{ - Use: "install URL | FILE_PATH", + Use: "install NAME | URL | FILE_PATH", Aliases: []string{"i"}, Short: "Install a plugin", SilenceErrors: true, + SilenceUsage: true, DisableFlagsInUseLine: true, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if _, err := plugin.Install(cmd.Context(), args[0], true); err != nil { + if _, err := plugin.Install(cmd.Context(), args[0], plugin.Options{}); err != nil { return xerrors.Errorf("plugin install error: %w", err) } return nil @@ -735,12 +741,13 @@ func NewPluginCommand() *cobra.Command { &cobra.Command{ Use: "uninstall PLUGIN_NAME", Aliases: []string{"u"}, - SilenceErrors: true, DisableFlagsInUseLine: true, Short: "Uninstall a plugin", + SilenceErrors: true, + SilenceUsage: true, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - if err := plugin.Uninstall(args[0]); err != nil { + RunE: func(cmd *cobra.Command, args []string) error { + if err := plugin.Uninstall(cmd.Context(), args[0]); err != nil { return xerrors.Errorf("plugin uninstall error: %w", err) } return nil @@ -749,62 +756,86 @@ func NewPluginCommand() *cobra.Command { &cobra.Command{ Use: "list", Aliases: []string{"l"}, - SilenceErrors: true, DisableFlagsInUseLine: true, + SilenceErrors: true, + SilenceUsage: true, Short: "List installed plugin", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - info, err := plugin.List() - if err != nil { + if err := plugin.List(cmd.Context()); err != nil { return xerrors.Errorf("plugin list display error: %w", err) } - if _, err := fmt.Fprint(os.Stdout, info); err != nil { - return xerrors.Errorf("print error: %w", err) - } return nil }, }, &cobra.Command{ Use: "info PLUGIN_NAME", Short: "Show information about the specified plugin", - SilenceErrors: true, DisableFlagsInUseLine: true, + SilenceErrors: true, + SilenceUsage: true, Args: cobra.ExactArgs(1), RunE: func(_ *cobra.Command, args []string) error { - info, err := plugin.Information(args[0]) - if err != nil { + if err := plugin.Information(args[0]); err != nil { return xerrors.Errorf("plugin information display error: %w", err) } - if _, err := fmt.Fprint(os.Stdout, info); err != nil { - return xerrors.Errorf("print error: %w", err) - } return nil }, }, &cobra.Command{ - Use: "run URL | FILE_PATH", + Use: "run NAME | URL | FILE_PATH", Aliases: []string{"r"}, - SilenceErrors: true, DisableFlagsInUseLine: true, + SilenceErrors: true, + SilenceUsage: true, Short: "Run a plugin on the fly", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return plugin.RunWithURL(cmd.Context(), args[0], plugin.RunOptions{Args: args[1:]}) + return plugin.RunWithURL(cmd.Context(), args[0], plugin.Options{Args: args[1:]}) }, }, &cobra.Command{ - Use: "update PLUGIN_NAME", - Short: "Update an existing plugin", - SilenceErrors: true, + Use: "update", + Short: "Update the local copy of the plugin index", DisableFlagsInUseLine: true, - Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - if err := plugin.Update(args[0]); err != nil { + SilenceErrors: true, + SilenceUsage: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := plugin.Update(cmd.Context()); err != nil { return xerrors.Errorf("plugin update error: %w", err) } return nil }, }, + &cobra.Command{ + Use: "search [KEYWORD]", + DisableFlagsInUseLine: true, + SilenceErrors: true, + SilenceUsage: true, + Short: "List Trivy plugins available on the plugin index and search among them", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var keyword string + if len(args) == 1 { + keyword = args[0] + } + return plugin.Search(cmd.Context(), keyword) + }, + }, + &cobra.Command{ + Use: "upgrade [PLUGIN_NAMES]", + Short: "Upgrade installed plugins to newer versions", + DisableFlagsInUseLine: true, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if err := plugin.Upgrade(cmd.Context(), args); err != nil { + return xerrors.Errorf("plugin upgrade error: %w", err) + } + return nil + }, + }, ) cmd.SetFlagErrorFunc(flagErrorFunc) return cmd diff --git a/pkg/flag/options.go b/pkg/flag/options.go index 744abbd1dda..9f4032fb971 100644 --- a/pkg/flag/options.go +++ b/pkg/flag/options.go @@ -447,7 +447,7 @@ func (o *Options) outputPluginWriter(ctx context.Context) (io.Writer, func() err pluginName := strings.TrimPrefix(o.Output, "plugin=") pr, pw := io.Pipe() - wait, err := plugin.Start(ctx, pluginName, plugin.RunOptions{ + wait, err := plugin.Start(ctx, pluginName, plugin.Options{ Args: o.OutputPluginArgs, Stdin: pr, }) diff --git a/pkg/log/handler.go b/pkg/log/handler.go index 5e07b104715..b2474cbee3c 100644 --- a/pkg/log/handler.go +++ b/pkg/log/handler.go @@ -14,6 +14,8 @@ import ( "github.com/fatih/color" "github.com/samber/lo" "golang.org/x/xerrors" + + "github.com/aquasecurity/trivy/pkg/clock" ) const ( @@ -145,6 +147,11 @@ func (h *ColorHandler) Handle(ctx context.Context, r slog.Record) error { freeBuf(bufp) }() + // For tests, use the fake clock's time. + if c, ok := clock.Clock(ctx).(*clock.FakeClock); ok { + r.Time = c.Now() + } + buf = h.handle(ctx, buf, r) h.mu.Lock() diff --git a/pkg/plugin/index.go b/pkg/plugin/index.go new file mode 100644 index 00000000000..c825c16e67a --- /dev/null +++ b/pkg/plugin/index.go @@ -0,0 +1,117 @@ +package plugin + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/samber/lo" + "golang.org/x/xerrors" + "gopkg.in/yaml.v3" + + "github.com/aquasecurity/trivy/pkg/downloader" + "github.com/aquasecurity/trivy/pkg/log" + "github.com/aquasecurity/trivy/pkg/utils/fsutils" +) + +const indexURL = "https://aquasecurity.github.io/trivy-plugin-index/v1/index.yaml" + +type Index struct { + Version int `yaml:"version"` + Plugins []struct { + Name string `yaml:"name"` + Maintainer string `yaml:"maintainer"` + Summary string `yaml:"summary"` + Repository string `yaml:"repository"` + Output bool `yaml:"output"` + } `yaml:"plugins"` +} + +func (m *Manager) Update(ctx context.Context) error { + m.logger.InfoContext(ctx, "Updating the plugin index...", log.String("url", m.indexURL)) + if err := downloader.Download(ctx, m.indexURL, filepath.Dir(m.indexPath), ""); err != nil { + return xerrors.Errorf("unable to download the plugin index: %w", err) + } + return nil +} + +func (m *Manager) Search(ctx context.Context, keyword string) error { + index, err := m.loadIndex() + if errors.Is(err, os.ErrNotExist) { + m.logger.ErrorContext(ctx, "The plugin index is not found. Please run 'trivy plugin update' to download the index.") + return xerrors.Errorf("plugin index not found: %w", err) + } else if err != nil { + return xerrors.Errorf("unable to load the plugin index: %w", err) + } + + var buf bytes.Buffer + buf.WriteString(fmt.Sprintf("%-20s %-60s %-20s %s\n", "NAME", "DESCRIPTION", "MAINTAINER", "OUTPUT")) + for _, p := range index.Plugins { + if keyword == "" || strings.Contains(p.Name, keyword) || strings.Contains(p.Summary, keyword) { + s := fmt.Sprintf("%-20s %-60s %-20s %s\n", truncateString(p.Name, 20), + truncateString(p.Summary, 60), truncateString(p.Maintainer, 20), + lo.Ternary(p.Output, " ✓", "")) + buf.WriteString(s) + } + } + + if _, err = fmt.Fprintf(m.w, buf.String()); err != nil { + return err + } + + return nil +} + +// tryIndex returns the repository URL if the plugin name is found in the index. +// Otherwise, it returns the input name. +func (m *Manager) tryIndex(ctx context.Context, name string) string { + // If the index file does not exist, download it first. + if !fsutils.FileExists(m.indexPath) { + if err := m.Update(ctx); err != nil { + m.logger.ErrorContext(ctx, "Failed to update the plugin index", log.Err(err)) + return name + } + } + + index, err := m.loadIndex() + if errors.Is(err, os.ErrNotExist) { + m.logger.WarnContext(ctx, "The plugin index is not found. Please run 'trivy plugin update' to download the index.") + return name + } else if err != nil { + m.logger.ErrorContext(ctx, "Unable to load the plugin index: %w", err) + return name + } + + for _, p := range index.Plugins { + if p.Name == name { + return p.Repository + } + } + return name +} + +func (m *Manager) loadIndex() (*Index, error) { + f, err := os.Open(m.indexPath) + if err != nil { + return nil, xerrors.Errorf("unable to open the index file: %w", err) + } + defer f.Close() + + var index Index + if err = yaml.NewDecoder(f).Decode(&index); err != nil { + return nil, xerrors.Errorf("unable to decode the index file: %w", err) + } + + return &index, nil +} + +func truncateString(str string, num int) string { + if len(str) <= num { + return str + } + return str[:num-3] + "..." +} diff --git a/pkg/plugin/index_test.go b/pkg/plugin/index_test.go new file mode 100644 index 00000000000..d918a9dac7e --- /dev/null +++ b/pkg/plugin/index_test.go @@ -0,0 +1,87 @@ +package plugin_test + +import ( + "bytes" + "context" + "github.com/aquasecurity/trivy/pkg/plugin" + "github.com/aquasecurity/trivy/pkg/utils/fsutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestManager_Update(t *testing.T) { + tempDir := t.TempDir() + fsutils.SetCacheDir(tempDir) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte(`this is index`)) + require.NoError(t, err) + })) + t.Cleanup(ts.Close) + + manager := plugin.NewManager(plugin.WithIndexURL(ts.URL + "/index.yaml")) + err := manager.Update(context.Background()) + require.NoError(t, err) + + indexPath := filepath.Join(tempDir, "plugin", "index.yaml") + assert.FileExists(t, indexPath) + + b, err := os.ReadFile(indexPath) + require.NoError(t, err) + assert.Equal(t, "this is index", string(b)) +} + +func TestManager_Search(t *testing.T) { + tests := []struct { + name string + keyword string + dir string + want string + wantErr string + }{ + { + name: "all plugins", + keyword: "", + dir: "testdata", + want: `NAME DESCRIPTION MAINTAINER OUTPUT +foo A foo plugin aquasecurity ✓ +bar A bar plugin aquasecurity +test A test plugin aquasecurity +`, + }, + { + name: "keyword", + keyword: "bar", + dir: "testdata", + want: `NAME DESCRIPTION MAINTAINER OUTPUT +bar A bar plugin aquasecurity +`, + }, + { + name: "no index", + keyword: "", + dir: "unknown", + wantErr: "plugin index not found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fsutils.SetCacheDir(tt.dir) + + var got bytes.Buffer + m := plugin.NewManager(plugin.WithWriter(&got)) + err := m.Search(context.Background(), tt.keyword) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got.String()) + }) + } +} diff --git a/pkg/plugin/manager.go b/pkg/plugin/manager.go new file mode 100644 index 00000000000..8f79d744bfb --- /dev/null +++ b/pkg/plugin/manager.go @@ -0,0 +1,355 @@ +package plugin + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/samber/lo" + "golang.org/x/xerrors" + "gopkg.in/yaml.v3" + + "github.com/aquasecurity/trivy/pkg/downloader" + ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" + "github.com/aquasecurity/trivy/pkg/log" + "github.com/aquasecurity/trivy/pkg/utils/fsutils" +) + +const configFile = "plugin.yaml" + +var ( + pluginsRelativeDir = filepath.Join(".trivy", "plugins") + + _defaultManager *Manager +) + +type ManagerOption func(indexer *Manager) + +func WithWriter(w io.Writer) ManagerOption { + return func(indexer *Manager) { + indexer.w = w + } +} + +func WithIndexURL(indexURL string) ManagerOption { + return func(indexer *Manager) { + indexer.indexURL = indexURL + } +} + +// Manager manages the plugins +type Manager struct { + w io.Writer + indexURL string + logger *log.Logger + pluginRoot string + indexPath string +} + +func NewManager(opts ...ManagerOption) *Manager { + m := &Manager{ + w: os.Stdout, + indexURL: indexURL, + logger: log.WithPrefix("plugin"), + pluginRoot: filepath.Join(fsutils.HomeDir(), pluginsRelativeDir), + indexPath: filepath.Join(fsutils.CacheDir(), "plugin", "index.yaml"), + } + for _, opt := range opts { + opt(m) + } + return m +} + +func defaultManager() *Manager { + if _defaultManager == nil { + _defaultManager = NewManager() + } + return _defaultManager +} + +func Install(ctx context.Context, name string, opts Options) (Plugin, error) { + return defaultManager().Install(ctx, name, opts) +} +func Start(ctx context.Context, name string, opts Options) (Wait, error) { + return defaultManager().Start(ctx, name, opts) +} +func RunWithURL(ctx context.Context, name string, opts Options) error { + return defaultManager().RunWithURL(ctx, name, opts) +} +func Upgrade(ctx context.Context, names []string) error { return defaultManager().Upgrade(ctx, names) } +func Uninstall(ctx context.Context, name string) error { return defaultManager().Uninstall(ctx, name) } +func Information(name string) error { return defaultManager().Information(name) } +func List(ctx context.Context) error { return defaultManager().List(ctx) } +func Update(ctx context.Context) error { return defaultManager().Update(ctx) } +func Search(ctx context.Context, keyword string) error { return defaultManager().Search(ctx, keyword) } + +// Install installs a plugin +func (m *Manager) Install(ctx context.Context, name string, opts Options) (Plugin, error) { + src := m.tryIndex(ctx, name) + + // If the plugin is already installed, it skips installing the plugin. + if p, installed := m.isInstalled(ctx, src); installed { + m.logger.InfoContext(ctx, "The plugin is already installed", log.String("name", p.Name)) + return p, nil + } + + m.logger.InfoContext(ctx, "Installing the plugin...", log.String("src", src)) + return m.install(ctx, src, opts) +} + +func (m *Manager) install(ctx context.Context, src string, opts Options) (Plugin, error) { + tempDir, err := downloader.DownloadToTempDir(ctx, src) + if err != nil { + return Plugin{}, xerrors.Errorf("download failed: %w", err) + } + defer os.RemoveAll(tempDir) + + m.logger.DebugContext(ctx, "Loading the plugin metadata...") + plugin, err := m.loadMetadata(tempDir) + if err != nil { + return Plugin{}, xerrors.Errorf("failed to load the plugin metadata: %w", err) + } + + if err = plugin.install(ctx, plugin.Dir(), tempDir, opts); err != nil { + return Plugin{}, xerrors.Errorf("failed to install the plugin: %w", err) + } + + // Copy plugin.yaml into the plugin dir + f, err := os.Create(filepath.Join(plugin.Dir(), configFile)) + if err != nil { + return Plugin{}, xerrors.Errorf("failed to create plugin.yaml: %w", err) + } + defer f.Close() + + if err = yaml.NewEncoder(f).Encode(plugin); err != nil { + return Plugin{}, xerrors.Errorf("yaml encode error: %w", err) + } + + m.logger.InfoContext(ctx, "Plugin successfully installed", log.String("name", plugin.Name)) + + return plugin, nil +} + +// Uninstall installs the plugin +func (m *Manager) Uninstall(ctx context.Context, name string) error { + pluginDir := filepath.Join(m.pluginRoot, name) + if !fsutils.DirExists(pluginDir) { + m.logger.ErrorContext(ctx, "No such plugin") + return nil + } + if err := os.RemoveAll(pluginDir); err != nil { + return xerrors.Errorf("failed to uninstall the plugin: %w", err) + } + m.logger.InfoContext(ctx, "Plugin successfully uninstalled", log.String("name", name)) + return nil +} + +// Information gets the information about an installed plugin +func (m *Manager) Information(name string) error { + plugin, err := m.load(name) + if err != nil { + return xerrors.Errorf("plugin load error: %w", err) + } + + _, err = fmt.Fprintf(m.w, ` +Plugin: %s + Version: %s + Summary: %s + Description: %s +`, plugin.Name, plugin.Version, plugin.Summary, plugin.Description) + + return err +} + +// List gets a list of all installed plugins +func (m *Manager) List(ctx context.Context) error { + s, err := m.list(ctx) + if err != nil { + return xerrors.Errorf("unable to list plugins: %w", err) + } + _, err = fmt.Fprintf(m.w, "%s\n", s) + return err +} + +func (m *Manager) list(ctx context.Context) (string, error) { + if _, err := os.Stat(m.pluginRoot); err != nil { + if os.IsNotExist(err) { + return "No Installed Plugins", nil + } + return "", xerrors.Errorf("stat error: %w", err) + } + plugins, err := m.LoadAll(ctx) + if err != nil { + return "", xerrors.Errorf("unable to load plugins: %w", err) + } else if len(plugins) == 0 { + return "No Installed Plugins", nil + } + pluginList := []string{"Installed Plugins:"} + for _, plugin := range plugins { + pluginList = append(pluginList, fmt.Sprintf(" Name: %s\n Version: %s\n", plugin.Name, plugin.Version)) + } + + return strings.Join(pluginList, "\n"), nil +} + +// Upgrade upgrades an existing plugins +func (m *Manager) Upgrade(ctx context.Context, names []string) error { + if len(names) == 0 { + plugins, err := m.LoadAll(ctx) + if err != nil { + return xerrors.Errorf("unable to load plugins: %w", err) + } else if len(plugins) == 0 { + m.logger.InfoContext(ctx, "No installed plugins") + return nil + } + names = lo.Map(plugins, func(p Plugin, _ int) string { return p.Name }) + } + for _, name := range names { + if err := m.upgrade(ctx, name); err != nil { + return xerrors.Errorf("unable to upgrade '%s' plugin: %w", name, err) + } + } + return nil +} + +func (m *Manager) upgrade(ctx context.Context, name string) error { + plugin, err := m.load(name) + if err != nil { + return xerrors.Errorf("plugin load error: %w", err) + } + + logger := m.logger.With("name", name) + logger.InfoContext(ctx, "Upgrading plugin...") + updated, err := m.install(ctx, plugin.Repository, Options{ + // Use the current installed platform + Platform: ftypes.Platform{ + Platform: &v1.Platform{ + OS: plugin.Installed.Platform.OS, + Architecture: plugin.Installed.Platform.Arch, + }, + }, + }) + if err != nil { + return xerrors.Errorf("unable to perform an upgrade installation: %w", err) + } + + if plugin.Version == updated.Version { + logger.InfoContext(ctx, "The plugin is up-to-date", log.String("version", plugin.Version)) + } else { + logger.InfoContext(ctx, "Plugin upgraded", + log.String("from", plugin.Version), log.String("to", updated.Version)) + } + return nil +} + +// LoadAll loads all plugins +func (m *Manager) LoadAll(ctx context.Context) ([]Plugin, error) { + dirs, err := os.ReadDir(m.pluginRoot) + if err != nil { + return nil, xerrors.Errorf("failed to read %s: %w", m.pluginRoot, err) + } + + var plugins []Plugin + for _, d := range dirs { + if !d.IsDir() { + continue + } + plugin, err := m.loadMetadata(filepath.Join(m.pluginRoot, d.Name())) + if err != nil { + m.logger.WarnContext(ctx, "Plugin load error", log.Err(err)) + continue + } + plugins = append(plugins, plugin) + } + return plugins, nil +} + +// Start starts the plugin +func (m *Manager) Start(ctx context.Context, name string, opts Options) (Wait, error) { + plugin, err := m.load(name) + if err != nil { + return nil, xerrors.Errorf("plugin load error: %w", err) + } + + wait, err := plugin.Start(ctx, opts) + if err != nil { + return nil, xerrors.Errorf("unable to run %s plugin: %w", plugin.Name, err) + } + return wait, nil +} + +// RunWithURL runs the plugin +func (m *Manager) RunWithURL(ctx context.Context, name string, opts Options) error { + plugin, err := m.Install(ctx, name, opts) + if err != nil { + return xerrors.Errorf("plugin install error: %w", err) + } + + if err = plugin.Run(ctx, opts); err != nil { + return xerrors.Errorf("unable to run %s plugin: %w", plugin.Name, err) + } + return nil +} + +func (m *Manager) load(name string) (Plugin, error) { + pluginDir := filepath.Join(m.pluginRoot, name) + if _, err := os.Stat(pluginDir); err != nil { + if os.IsNotExist(err) { + return Plugin{}, xerrors.Errorf("could not find a plugin called '%s', did you install it?", name) + } + return Plugin{}, xerrors.Errorf("plugin stat error: %w", err) + } + + plugin, err := m.loadMetadata(pluginDir) + if err != nil { + return Plugin{}, xerrors.Errorf("unable to load plugin metadata: %w", err) + } + + return plugin, nil +} + +func (m *Manager) loadMetadata(dir string) (Plugin, error) { + filePath := filepath.Join(dir, configFile) + f, err := os.Open(filePath) + if err != nil { + return Plugin{}, xerrors.Errorf("file open error: %w", err) + } + defer f.Close() + + var plugin Plugin + if err = yaml.NewDecoder(f).Decode(&plugin); err != nil { + return Plugin{}, xerrors.Errorf("yaml decode error: %w", err) + } + + if plugin.Name == "" { + return Plugin{}, xerrors.Errorf("'name' is empty") + } + + // e.g. ~/.trivy/plugins/kubectl + plugin.dir = filepath.Join(m.pluginRoot, plugin.Name) + + if plugin.Summary == "" && plugin.Usage != "" { + plugin.Summary = plugin.Usage // For backward compatibility + plugin.Usage = "" + } + + return plugin, nil +} + +func (m *Manager) isInstalled(ctx context.Context, url string) (Plugin, bool) { + installedPlugins, err := m.LoadAll(ctx) + if err != nil { + return Plugin{}, false + } + + for _, plugin := range installedPlugins { + if plugin.Repository == url { + return plugin, true + } + } + return Plugin{}, false +} diff --git a/pkg/plugin/plugin_test.go b/pkg/plugin/manager_test.go similarity index 56% rename from pkg/plugin/plugin_test.go rename to pkg/plugin/manager_test.go index d3f5aa1a0fe..958a320512d 100644 --- a/pkg/plugin/plugin_test.go +++ b/pkg/plugin/manager_test.go @@ -1,11 +1,21 @@ package plugin_test import ( + "archive/zip" + "bytes" "context" + "github.com/aquasecurity/trivy/pkg/clock" + ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" + "github.com/aquasecurity/trivy/pkg/utils/fsutils" + v1 "github.com/google/go-containerregistry/pkg/v1" + "log/slog" + "net/http" + "net/http/httptest" "os" "path/filepath" "runtime" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -14,7 +24,7 @@ import ( "github.com/aquasecurity/trivy/pkg/plugin" ) -func TestPlugin_Run(t *testing.T) { +func TestManager_Run(t *testing.T) { if runtime.GOOS == "windows" { // the test.sh script can't be run on windows so skipping t.Skip("Test satisfied adequately by Linux tests") @@ -23,7 +33,7 @@ func TestPlugin_Run(t *testing.T) { Name string Repository string Version string - Usage string + Summary string Description string Platforms []plugin.Platform GOOS string @@ -32,7 +42,7 @@ func TestPlugin_Run(t *testing.T) { tests := []struct { name string fields fields - opts plugin.RunOptions + opts plugin.Options wantErr string }{ { @@ -41,7 +51,7 @@ func TestPlugin_Run(t *testing.T) { Name: "test_plugin", Repository: "github.com/aquasecurity/trivy-plugin-test", Version: "0.1.0", - Usage: "test", + Summary: "test", Description: "test", Platforms: []plugin.Platform{ { @@ -63,7 +73,7 @@ func TestPlugin_Run(t *testing.T) { Name: "test_plugin", Repository: "github.com/aquasecurity/trivy-plugin-test", Version: "0.1.0", - Usage: "test", + Summary: "test", Description: "test", Platforms: []plugin.Platform{ { @@ -79,7 +89,7 @@ func TestPlugin_Run(t *testing.T) { Name: "test_plugin", Repository: "github.com/aquasecurity/trivy-plugin-test", Version: "0.1.0", - Usage: "test", + Summary: "test", Description: "test", Platforms: []plugin.Platform{ { @@ -102,7 +112,7 @@ func TestPlugin_Run(t *testing.T) { Name: "test_plugin", Repository: "github.com/aquasecurity/trivy-plugin-test", Version: "0.1.0", - Usage: "test", + Summary: "test", Description: "test", Platforms: []plugin.Platform{ { @@ -125,7 +135,7 @@ func TestPlugin_Run(t *testing.T) { Name: "error_plugin", Repository: "github.com/aquasecurity/trivy-plugin-error", Version: "0.1.0", - Usage: "test", + Summary: "test", Description: "test", Platforms: []plugin.Platform{ { @@ -145,24 +155,27 @@ func TestPlugin_Run(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - os.Setenv("XDG_DATA_HOME", "testdata") - defer os.Unsetenv("XDG_DATA_HOME") + t.Setenv("XDG_DATA_HOME", "testdata") p := plugin.Plugin{ Name: tt.fields.Name, Repository: tt.fields.Repository, Version: tt.fields.Version, - Usage: tt.fields.Usage, + Summary: tt.fields.Summary, Description: tt.fields.Description, Platforms: tt.fields.Platforms, - GOOS: tt.fields.GOOS, - GOARCH: tt.fields.GOARCH, } - err := p.Run(context.Background(), tt.opts) + err := p.Run(context.Background(), plugin.Options{ + Platform: ftypes.Platform{ + Platform: &v1.Platform{ + OS: "linux", + Architecture: "amd64", + }, + }, + }) if tt.wantErr != "" { - require.NotNil(t, err) - assert.Contains(t, err.Error(), tt.wantErr) + require.ErrorContains(t, err, tt.wantErr) return } assert.NoError(t, err) @@ -170,142 +183,145 @@ func TestPlugin_Run(t *testing.T) { } } -func TestInstall(t *testing.T) { +func TestManager_Install(t *testing.T) { if runtime.GOOS == "windows" { // the test.sh script can't be run on windows so skipping t.Skip("Test satisfied adequately by Linux tests") } + wantPlugin := plugin.Plugin{ + Name: "test_plugin", + Repository: "github.com/aquasecurity/trivy-plugin-test", + Version: "0.1.0", + Summary: "test", + Description: "test", + Platforms: []plugin.Platform{ + { + Selector: &plugin.Selector{ + OS: "linux", + Arch: "amd64", + }, + URI: "./test.sh", + Bin: "./test.sh", + }, + }, + Installed: plugin.Installed{ + Platform: plugin.Selector{ + OS: "linux", + Arch: "amd64", + }, + }, + } + tests := []struct { - name string - url string - want plugin.Plugin - wantFile string - wantErr string + name string + pluginName string + want plugin.Plugin + wantFile string + wantErr string }{ { - name: "happy path", - url: "testdata/test_plugin", - want: plugin.Plugin{ - Name: "test_plugin", - Repository: "github.com/aquasecurity/trivy-plugin-test", - Version: "0.1.0", - Usage: "test", - Description: "test", - Platforms: []plugin.Platform{ - { - Selector: &plugin.Selector{ - OS: "linux", - Arch: "amd64", - }, - URI: "./test.sh", - Bin: "./test.sh", - }, - }, - GOOS: "linux", - GOARCH: "amd64", - }, + name: "http", + want: wantPlugin, wantFile: ".trivy/plugins/test_plugin/test.sh", }, { - name: "plugin not found", - url: "testdata/not_found", - want: plugin.Plugin{ - Name: "test_plugin", - Repository: "github.com/aquasecurity/trivy-plugin-test", - Version: "0.1.0", - Usage: "test", - Description: "test", - Platforms: []plugin.Platform{ - { - Selector: &plugin.Selector{ - OS: "linux", - Arch: "amd64", - }, - URI: "./test.sh", - Bin: "./test.sh", - }, - }, - GOOS: "linux", - GOARCH: "amd64", - }, - wantErr: "no such file or directory", + name: "local path", + pluginName: "testdata/test_plugin", + want: wantPlugin, + wantFile: ".trivy/plugins/test_plugin/test.sh", }, { - name: "no plugin.yaml", - url: "testdata/no_yaml", - want: plugin.Plugin{ - Name: "no_yaml", - Repository: "github.com/aquasecurity/trivy-plugin-test", - Version: "0.1.0", - Usage: "test", - Description: "test", - Platforms: []plugin.Platform{ - { - Selector: &plugin.Selector{ - OS: "linux", - Arch: "amd64", - }, - URI: "./test.sh", - Bin: "./test.sh", - }, - }, - GOOS: "linux", - GOARCH: "amd64", - }, - wantErr: "file open error", + name: "index", + pluginName: "test", + want: wantPlugin, + wantFile: ".trivy/plugins/test_plugin/test.sh", + }, + { + name: "plugin not found", + pluginName: "testdata/not_found", + wantErr: "no such file or directory", + }, + { + name: "no plugin.yaml", + pluginName: "testdata/no_yaml", + wantErr: "file open error", }, } - log.InitLogger(false, true) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // The test plugin will be installed here dst := t.TempDir() - os.Setenv("XDG_DATA_HOME", dst) + t.Setenv("XDG_DATA_HOME", dst) + + // For plugin index + fsutils.SetCacheDir("testdata") + + if tt.pluginName == "" { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + zr := zip.NewWriter(w) + require.NoError(t, zr.AddFS(os.DirFS("testdata/test_plugin"))) + require.NoError(t, zr.Close()) + })) + t.Cleanup(ts.Close) + tt.pluginName = ts.URL + "/test_plugin.zip" + } - got, err := plugin.Install(context.Background(), tt.url, false) + got, err := plugin.NewManager().Install(context.Background(), tt.pluginName, plugin.Options{ + Platform: ftypes.Platform{ + Platform: &v1.Platform{ + Architecture: "amd64", + OS: "linux", + }, + }, + }) if tt.wantErr != "" { - require.NotNil(t, err) - assert.Contains(t, err.Error(), tt.wantErr) + require.ErrorContains(t, err, tt.wantErr) return } assert.NoError(t, err) - assert.Equal(t, tt.want, got) + assert.EqualExportedValues(t, tt.want, got) assert.FileExists(t, filepath.Join(dst, tt.wantFile)) }) } } -func TestUninstall(t *testing.T) { - if runtime.GOOS == "windows" { - // the test.sh script can't be run on windows so skipping - t.Skip("Test satisfied adequately by Linux tests") - } +func TestManager_Uninstall(t *testing.T) { + ctx := clock.With(context.Background(), time.Date(2021, 8, 25, 12, 20, 30, 5, time.UTC)) pluginName := "test_plugin" tempDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tempDir) pluginDir := filepath.Join(tempDir, ".trivy", "plugins", pluginName) - // Create the test plugin directory - err := os.MkdirAll(pluginDir, os.ModePerm) - require.NoError(t, err) - - // Create the test file - err = os.WriteFile(filepath.Join(pluginDir, "test.sh"), []byte(`foo`), os.ModePerm) - require.NoError(t, err) - - // Uninstall the plugin - err = plugin.Uninstall(pluginName) - assert.NoError(t, err) - assert.NoFileExists(t, pluginDir) + t.Run("plugin found", func(t *testing.T) { + // Create the test plugin directory + err := os.MkdirAll(pluginDir, os.ModePerm) + require.NoError(t, err) + + // Create the test file + err = os.WriteFile(filepath.Join(pluginDir, "test.sh"), []byte(`foo`), os.ModePerm) + require.NoError(t, err) + + // Uninstall the plugin + err = plugin.NewManager().Uninstall(ctx, pluginName) + assert.NoError(t, err) + assert.NoDirExists(t, pluginDir) + }) + + t.Run("plugin not found", func(t *testing.T) { + t.Setenv("NO_COLOR", tempDir) + buf := bytes.NewBuffer(nil) + slog.SetDefault(slog.New(log.NewHandler(buf, &log.Options{Level: log.LevelInfo}))) + + err := plugin.NewManager().Uninstall(ctx, pluginName) + assert.NoError(t, err) + assert.Equal(t, "2021-08-25T12:20:30Z\tERROR\t[plugin] No such plugin\n", buf.String()) + }) } -func TestInformation(t *testing.T) { - if runtime.GOOS == "windows" { - // the test.sh script can't be run on windows so skipping - t.Skip("Test satisfied adequately by Linux tests") - } +func TestManager_Information(t *testing.T) { pluginName := "test_plugin" tempDir := t.TempDir() @@ -327,22 +343,27 @@ description: A simple test plugin` err = os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(pluginMetadata), os.ModePerm) require.NoError(t, err) + var got bytes.Buffer + manager := plugin.NewManager(plugin.WithWriter(&got)) + // Get Information for the plugin - info, err := plugin.Information(pluginName) + err = manager.Information(pluginName) require.NoError(t, err) - assert.Equal(t, "\nPlugin: test_plugin\n Description: A simple test plugin\n Version: 0.1.0\n Usage: test\n", info) + assert.Equal(t, ` +Plugin: test_plugin + Version: 0.1.0 + Summary: test + Description: A simple test plugin +`, got.String()) + got.Reset() // Get Information for unknown plugin - info, err = plugin.Information("unknown") + err = manager.Information("unknown") require.Error(t, err) assert.ErrorContains(t, err, "could not find a plugin called 'unknown', did you install it?") } -func TestLoadAll1(t *testing.T) { - if runtime.GOOS == "windows" { - // the test.sh script can't be run on windows so skipping - t.Skip("Test satisfied adequately by Linux tests") - } +func TestManager_LoadAll(t *testing.T) { tests := []struct { name string dir string @@ -357,7 +378,7 @@ func TestLoadAll1(t *testing.T) { Name: "test_plugin", Repository: "github.com/aquasecurity/trivy-plugin-test", Version: "0.1.0", - Usage: "test", + Summary: "test", Description: "test", Platforms: []plugin.Platform{ { @@ -369,35 +390,34 @@ func TestLoadAll1(t *testing.T) { Bin: "./test.sh", }, }, - GOOS: "linux", - GOARCH: "amd64", }, }, }, { name: "sad path", dir: "sad", - wantErr: "no such file or directory", + wantErr: "failed to read", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - os.Setenv("XDG_DATA_HOME", tt.dir) - defer os.Unsetenv("XDG_DATA_HOME") + t.Setenv("XDG_DATA_HOME", tt.dir) - got, err := plugin.LoadAll() + got, err := plugin.NewManager().LoadAll(context.Background()) if tt.wantErr != "" { - require.NotNil(t, err) - assert.Contains(t, err.Error(), tt.wantErr) + require.ErrorContains(t, err, tt.wantErr) return } assert.NoError(t, err) - assert.Equal(t, tt.want, got) + require.Len(t, got, len(tt.want)) + for i := range tt.want { + assert.EqualExportedValues(t, tt.want[i], got[i]) + } }) } } -func TestUpdate(t *testing.T) { +func TestManager_Upgrade(t *testing.T) { if runtime.GOOS == "windows" { // the test.sh script can't be run on windows so skipping t.Skip("Test satisfied adequately by Linux tests") @@ -418,28 +438,35 @@ func TestUpdate(t *testing.T) { repository: testdata/test_plugin version: "0.0.5" usage: test -description: A simple test plugin` +description: A simple test plugin +installed: + platform: + os: linux + arch: amd64` err = os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(pluginMetadata), os.ModePerm) require.NoError(t, err) + ctx := context.Background() + m := plugin.NewManager() + // verify initial version - verifyVersion(t, pluginName, "0.0.5") + verifyVersion(t, ctx, m, pluginName, "0.0.5") - // Update the existing plugin - err = plugin.Update(pluginName) + // Upgrade the existing plugin + err = m.Upgrade(ctx, nil) require.NoError(t, err) // verify plugin updated - verifyVersion(t, pluginName, "0.1.0") + verifyVersion(t, ctx, m, pluginName, "0.1.0") } -func verifyVersion(t *testing.T, pluginName, expectedVersion string) { - plugins, err := plugin.LoadAll() +func verifyVersion(t *testing.T, ctx context.Context, m *plugin.Manager, pluginName, expectedVersion string) { + plugins, err := m.LoadAll(ctx) require.NoError(t, err) - for _, plugin := range plugins { - if plugin.Name == pluginName { - assert.Equal(t, expectedVersion, plugin.Version) + for _, p := range plugins { + if p.Name == pluginName { + assert.Equal(t, expectedVersion, p.Version) } } } diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go index 11e46a4488a..68a50ae3178 100644 --- a/pkg/plugin/plugin.go +++ b/pkg/plugin/plugin.go @@ -3,48 +3,41 @@ package plugin import ( "context" "errors" - "fmt" "io" "os" "os/exec" "path/filepath" "runtime" - "strings" + "github.com/samber/lo" "golang.org/x/xerrors" - "gopkg.in/yaml.v3" "github.com/aquasecurity/trivy/pkg/downloader" + ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" "github.com/aquasecurity/trivy/pkg/log" "github.com/aquasecurity/trivy/pkg/types" "github.com/aquasecurity/trivy/pkg/utils/fsutils" ) -const ( - configFile = "plugin.yaml" -) - -var ( - pluginsRelativeDir = filepath.Join(".trivy", "plugins") - - officialPlugins = map[string]string{ - "kubectl": "github.com/aquasecurity/trivy-plugin-kubectl", - "aqua": "github.com/aquasecurity/trivy-plugin-aqua", - } -) - // Plugin represents a plugin. type Plugin struct { Name string `yaml:"name"` Repository string `yaml:"repository"` Version string `yaml:"version"` - Usage string `yaml:"usage"` + Summary string `yaml:"summary"` + Usage string `yaml:"usage"` // Deprecated: Use summary instead Description string `yaml:"description"` Platforms []Platform `yaml:"platforms"` - // runtime environment for testability - GOOS string `yaml:"_goos"` - GOARCH string `yaml:"_goarch"` + // Installed holds the metadata about installation + Installed Installed `yaml:"installed"` + + // dir points to the directory where the plugin is installed + dir string +} + +type Installed struct { + Platform Selector `yaml:"platform"` } // Platform represents where the execution file exists per platform. @@ -56,22 +49,23 @@ type Platform struct { // Selector represents the environment. type Selector struct { - OS string - Arch string + OS string `yaml:"os"` + Arch string `yaml:"arch"` } -type RunOptions struct { - Args []string - Stdin io.Reader +type Options struct { + Args []string + Stdin io.Reader // For output plugin + Platform ftypes.Platform } -func (p Plugin) Cmd(ctx context.Context, opts RunOptions) (*exec.Cmd, error) { - platform, err := p.selectPlatform() +func (p *Plugin) Cmd(ctx context.Context, opts Options) (*exec.Cmd, error) { + platform, err := p.selectPlatform(ctx, opts) if err != nil { return nil, xerrors.Errorf("platform selection error: %w", err) } - execFile := filepath.Join(dir(), p.Name, platform.Bin) + execFile := filepath.Join(p.Dir(), platform.Bin) cmd := exec.CommandContext(ctx, execFile, opts.Args...) cmd.Stdin = os.Stdin @@ -90,7 +84,7 @@ type Wait func() error // Start starts the plugin // // After a successful call to Start the Wait method must be called. -func (p Plugin) Start(ctx context.Context, opts RunOptions) (Wait, error) { +func (p *Plugin) Start(ctx context.Context, opts Options) (Wait, error) { cmd, err := p.Cmd(ctx, opts) if err != nil { return nil, xerrors.Errorf("cmd: %w", err) @@ -103,7 +97,7 @@ func (p Plugin) Start(ctx context.Context, opts RunOptions) (Wait, error) { } // Run runs the plugin -func (p Plugin) Run(ctx context.Context, opts RunOptions) error { +func (p *Plugin) Run(ctx context.Context, opts Options) error { cmd, err := p.Cmd(ctx, opts) if err != nil { return xerrors.Errorf("cmd: %w", err) @@ -124,13 +118,15 @@ func (p Plugin) Run(ctx context.Context, opts RunOptions) error { return nil } -func (p Plugin) selectPlatform() (Platform, error) { +func (p *Plugin) selectPlatform(ctx context.Context, opts Options) (Platform, error) { // These values are only filled in during unit tests. - if p.GOOS == "" { - p.GOOS = runtime.GOOS + goos := runtime.GOOS + if opts.Platform.Platform != nil && opts.Platform.OS != "" { + goos = opts.Platform.OS } - if p.GOARCH == "" { - p.GOARCH = runtime.GOARCH + goarch := runtime.GOARCH + if opts.Platform.Platform != nil && opts.Platform.Architecture != "" { + goarch = opts.Platform.Architecture } for _, platform := range p.Platforms { @@ -139,9 +135,9 @@ func (p Plugin) selectPlatform() (Platform, error) { } selector := platform.Selector - if (selector.OS == "" || p.GOOS == selector.OS) && - (selector.Arch == "" || p.GOARCH == selector.Arch) { - log.Debug("Platform found", + if (selector.OS == "" || goos == selector.OS) && + (selector.Arch == "" || goarch == selector.Arch) { + log.DebugContext(ctx, "Platform found", log.String("os", selector.OS), log.String("arch", selector.Arch)) return platform, nil } @@ -149,240 +145,24 @@ func (p Plugin) selectPlatform() (Platform, error) { return Platform{}, xerrors.New("platform not found") } -func (p Plugin) install(ctx context.Context, dst, pwd string) error { - log.Debug("Installing the plugin...", log.String("path", dst)) - platform, err := p.selectPlatform() +func (p *Plugin) install(ctx context.Context, dst, pwd string, opts Options) error { + log.DebugContext(ctx, "Installing the plugin...", log.String("path", dst)) + platform, err := p.selectPlatform(ctx, opts) if err != nil { return xerrors.Errorf("platform selection error: %w", err) } + p.Installed.Platform = lo.FromPtr(platform.Selector) - log.Debug("Downloading the execution file...", log.String("uri", platform.URI)) + log.DebugContext(ctx, "Downloading the execution file...", log.String("uri", platform.URI)) if err = downloader.Download(ctx, platform.URI, dst, pwd); err != nil { return xerrors.Errorf("unable to download the execution file (%s): %w", platform.URI, err) } return nil } -func (p Plugin) dir() (string, error) { - if p.Name == "" { - return "", xerrors.Errorf("'name' is empty") - } - - // e.g. ~/.trivy/plugins/kubectl - return filepath.Join(dir(), p.Name), nil -} - -// Install installs a plugin -func Install(ctx context.Context, url string, force bool) (Plugin, error) { - // Replace short names with full qualified names - // e.g. kubectl => github.com/aquasecurity/trivy-plugin-kubectl - if v, ok := officialPlugins[url]; ok { - url = v - } - - if !force { - // If the plugin is already installed, it skips installing the plugin. - if p, installed := isInstalled(url); installed { - return p, nil - } - } - - log.Info("Installing the plugin...", log.String("url", url)) - tempDir, err := downloader.DownloadToTempDir(ctx, url) - if err != nil { - return Plugin{}, xerrors.Errorf("download failed: %w", err) - } - defer os.RemoveAll(tempDir) - - log.Info("Loading the plugin metadata...") - plugin, err := loadMetadata(tempDir) - if err != nil { - return Plugin{}, xerrors.Errorf("failed to load the plugin metadata: %w", err) - } - - pluginDir, err := plugin.dir() - if err != nil { - return Plugin{}, xerrors.Errorf("failed to determine the plugin dir: %w", err) - } - - if err = plugin.install(ctx, pluginDir, tempDir); err != nil { - return Plugin{}, xerrors.Errorf("failed to install the plugin: %w", err) - } - - // Copy plugin.yaml into the plugin dir - if _, err = fsutils.CopyFile(filepath.Join(tempDir, configFile), filepath.Join(pluginDir, configFile)); err != nil { - return Plugin{}, xerrors.Errorf("failed to copy plugin.yaml: %w", err) - } - - return plugin, nil -} - -// Uninstall installs the plugin -func Uninstall(name string) error { - pluginDir := filepath.Join(dir(), name) - return os.RemoveAll(pluginDir) -} - -// Information gets the information about an installed plugin -func Information(name string) (string, error) { - plugin, err := load(name) - if err != nil { - return "", xerrors.Errorf("plugin load error: %w", err) - } - - return fmt.Sprintf(` -Plugin: %s - Description: %s - Version: %s - Usage: %s -`, plugin.Name, plugin.Description, plugin.Version, plugin.Usage), nil -} - -// List gets a list of all installed plugins -func List() (string, error) { - if _, err := os.Stat(dir()); err != nil { - if os.IsNotExist(err) { - return "No Installed Plugins\n", nil - } - return "", xerrors.Errorf("stat error: %w", err) - } - plugins, err := LoadAll() - if err != nil { - return "", xerrors.Errorf("unable to load plugins: %w", err) - } - pluginList := []string{"Installed Plugins:"} - for _, plugin := range plugins { - pluginList = append(pluginList, fmt.Sprintf(" Name: %s\n Version: %s\n", plugin.Name, plugin.Version)) - } - - return strings.Join(pluginList, "\n"), nil -} - -// Update updates an existing plugin -func Update(name string) error { - plugin, err := load(name) - if err != nil { - return xerrors.Errorf("plugin load error: %w", err) - } - - logger := log.With("name", name) - logger.Info("Updating plugin...") - updated, err := Install(nil, plugin.Repository, true) - if err != nil { - return xerrors.Errorf("unable to perform an update installation: %w", err) - } - - if plugin.Version == updated.Version { - logger.Info("The plugin is up-to-date", log.String("version", plugin.Version)) - } else { - logger.Info("Plugin updated", - log.String("from", plugin.Version), log.String("to", updated.Version)) - } - return nil -} - -// LoadAll loads all plugins -func LoadAll() ([]Plugin, error) { - pluginsDir := dir() - dirs, err := os.ReadDir(pluginsDir) - if err != nil { - return nil, xerrors.Errorf("failed to read %s: %w", pluginsDir, err) - } - - var plugins []Plugin - for _, d := range dirs { - if !d.IsDir() { - continue - } - plugin, err := loadMetadata(filepath.Join(pluginsDir, d.Name())) - if err != nil { - log.Warn("Plugin load error", log.Err(err)) - continue - } - plugins = append(plugins, plugin) - } - return plugins, nil -} - -// Start starts the plugin -func Start(ctx context.Context, name string, opts RunOptions) (Wait, error) { - plugin, err := load(name) - if err != nil { - return nil, xerrors.Errorf("plugin load error: %w", err) - } - - wait, err := plugin.Start(ctx, opts) - if err != nil { - return nil, xerrors.Errorf("unable to run %s plugin: %w", plugin.Name, err) - } - return wait, nil -} - -// RunWithURL runs the plugin with URL -func RunWithURL(ctx context.Context, url string, opts RunOptions) error { - plugin, err := Install(ctx, url, false) - if err != nil { - return xerrors.Errorf("plugin install error: %w", err) - } - - if err = plugin.Run(ctx, opts); err != nil { - return xerrors.Errorf("unable to run %s plugin: %w", plugin.Name, err) - } - return nil -} - -func IsPredefined(name string) bool { - _, ok := officialPlugins[name] - return ok -} - -func load(name string) (Plugin, error) { - pluginDir := filepath.Join(dir(), name) - if _, err := os.Stat(pluginDir); err != nil { - if os.IsNotExist(err) { - return Plugin{}, xerrors.Errorf("could not find a plugin called '%s', did you install it?", name) - } - return Plugin{}, xerrors.Errorf("plugin stat error: %w", err) - } - - plugin, err := loadMetadata(pluginDir) - if err != nil { - return Plugin{}, xerrors.Errorf("unable to load plugin metadata: %w", err) - } - - return plugin, nil -} - -func loadMetadata(dir string) (Plugin, error) { - filePath := filepath.Join(dir, configFile) - f, err := os.Open(filePath) - if err != nil { - return Plugin{}, xerrors.Errorf("file open error: %w", err) - } - defer f.Close() - - var plugin Plugin - if err = yaml.NewDecoder(f).Decode(&plugin); err != nil { - return Plugin{}, xerrors.Errorf("yaml decode error: %w", err) - } - - return plugin, nil -} - -func dir() string { - return filepath.Join(fsutils.HomeDir(), pluginsRelativeDir) -} - -func isInstalled(url string) (Plugin, bool) { - installedPlugins, err := LoadAll() - if err != nil { - return Plugin{}, false - } - - for _, plugin := range installedPlugins { - if plugin.Repository == url { - return plugin, true - } +func (p *Plugin) Dir() string { + if p.dir != "" { + return p.dir } - return Plugin{}, false + return filepath.Join(fsutils.HomeDir(), pluginsRelativeDir, p.Name) } diff --git a/pkg/plugin/testdata/plugin/index.yaml b/pkg/plugin/testdata/plugin/index.yaml new file mode 100644 index 00000000000..17fbb198793 --- /dev/null +++ b/pkg/plugin/testdata/plugin/index.yaml @@ -0,0 +1,15 @@ +version: 1 +plugins: + - name: foo + output: true + maintainer: aquasecurity + summary: A foo plugin + repository: github.com/aquasecurity/trivy-plugin-foo + - name: bar + maintainer: aquasecurity + summary: A bar plugin + repository: github.com/aquasecurity/trivy-plugin-bar + - name: test + maintainer: aquasecurity + summary: A test plugin + repository: testdata/test_plugin \ No newline at end of file diff --git a/pkg/plugin/testdata/test_plugin/plugin.yaml b/pkg/plugin/testdata/test_plugin/plugin.yaml index 7c2021b2919..272c8d5760a 100644 --- a/pkg/plugin/testdata/test_plugin/plugin.yaml +++ b/pkg/plugin/testdata/test_plugin/plugin.yaml @@ -1,7 +1,7 @@ name: "test_plugin" repository: github.com/aquasecurity/trivy-plugin-test version: "0.1.0" -usage: test +summary: test description: test platforms: - selector: diff --git a/pkg/utils/fsutils/fs.go b/pkg/utils/fsutils/fs.go index 915581f08ad..6d0502d8cc1 100644 --- a/pkg/utils/fsutils/fs.go +++ b/pkg/utils/fsutils/fs.go @@ -1,6 +1,7 @@ package fsutils import ( + "errors" "fmt" "io" "io/fs" @@ -84,6 +85,14 @@ func DirExists(path string) bool { return true } +func FileExists(filename string) bool { + _, err := os.Stat(filename) + if errors.Is(err, os.ErrNotExist) { + return false + } + return err == nil +} + type WalkDirRequiredFunc func(path string, d fs.DirEntry) bool type WalkDirFunc func(path string, d fs.DirEntry, r io.Reader) error