feat: add support for plugin index (#6674)

Signed-off-by: knqyf263 <knqyf263@gmail.com>
Co-authored-by: DmitriyLewen <91113035+DmitriyLewen@users.noreply.github.com>
This commit is contained in:
Teppei Fukuda
2024-05-14 12:29:20 +04:00
committed by GitHub
parent 150a77313e
commit 26faf8f3f0
27 changed files with 1447 additions and 700 deletions

View File

@@ -44,6 +44,7 @@ jobs:
k8s
aws
vm
plugin
alpine
wolfi

View File

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

View File

@@ -114,6 +114,7 @@ mode:
- server
- aws
- vm
- plugin
os:

View File

@@ -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 <deployment-id> -- --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=<plugin_name> [--output-plugin-arg <plugin_flags>] <image_name>
```
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

View File

@@ -399,7 +399,7 @@ $ trivy <target> [--format <format>] --output plugin=<plugin_name> [--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.

View File

@@ -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-<name>`.
- Create an executable binary that can be invoked as `trivy <name>`.
- 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.
Dont 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

70
docs/docs/plugin/index.md Normal file
View File

@@ -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.
Lets 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/

View File

@@ -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 <deployment-id> -- --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 <PLUGIN1> <PLUGIN2>
```
## 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=<plugin_name> [--output-plugin-arg <plugin_flags>] <image_name>
```
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

View File

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

View File

@@ -3,7 +3,7 @@
Install a plugin
```
trivy plugin install URL | FILE_PATH
trivy plugin install NAME | URL | FILE_PATH
```
### Options

View File

@@ -3,7 +3,7 @@
Run a plugin on the fly
```
trivy plugin run URL | FILE_PATH
trivy plugin run NAME | URL | FILE_PATH
```
### Options

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

117
pkg/plugin/index.go Normal file
View File

@@ -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] + "..."
}

87
pkg/plugin/index_test.go Normal file
View File

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

355
pkg/plugin/manager.go Normal file
View File

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

View File

@@ -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")
}
tests := []struct {
name string
url 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",
},
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",
},
GOOS: "linux",
GOARCH: "amd64",
URI: "./test.sh",
Bin: "./test.sh",
},
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",
Installed: plugin.Installed{
Platform: plugin.Selector{
OS: "linux",
Arch: "amd64",
},
wantErr: "no such file or directory",
},
{
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",
},
}
log.InitLogger(false, true)
tests := []struct {
name string
pluginName string
want plugin.Plugin
wantFile string
wantErr string
}{
{
name: "http",
want: wantPlugin,
wantFile: ".trivy/plugins/test_plugin/test.sh",
},
{
name: "local path",
pluginName: "testdata/test_plugin",
want: wantPlugin,
wantFile: ".trivy/plugins/test_plugin/test.sh",
},
{
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",
},
}
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)
got, err := plugin.Install(context.Background(), tt.url, false)
// 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.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)
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)
// 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)
// 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)
// verify initial version
verifyVersion(t, pluginName, "0.0.5")
ctx := context.Background()
m := plugin.NewManager()
// Update the existing plugin
err = plugin.Update(pluginName)
// verify initial version
verifyVersion(t, ctx, m, pluginName, "0.0.5")
// 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)
}
}
}

View File

@@ -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")
func (p *Plugin) Dir() string {
if p.dir != "" {
return p.dir
}
// 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
}
}
return Plugin{}, false
return filepath.Join(fsutils.HomeDir(), pluginsRelativeDir, p.Name)
}

15
pkg/plugin/testdata/plugin/index.yaml vendored Normal file
View File

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

View File

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

View File

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