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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,16 @@ crs-toolchain generate php-function-names
crs-toolchain generate php-function-names --rules 933161 --php-repo /path/to/php-src --frequency-list ./php-frequency-cache.txt
```

### Plugin commands

```shell
# Install a plugin's newest release into <CRS_ROOT>/plugins
crs-toolchain -d /path/to/coreruleset plugin install fake-bot

# Pin a version and install into a custom directory
crs-toolchain plugin install fake-bot --version v1.1.0 --plugins-dir /etc/crs/plugins
Comment thread
fzipi marked this conversation as resolved.
```

### Chore commands

```shell
Expand Down
93 changes: 93 additions & 0 deletions cmd/plugin/install/install.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright 2026 OWASP Core Rule Set Project
// SPDX-License-Identifier: Apache-2.0

package install

import (
"fmt"

"github.com/rs/zerolog/log"
"github.com/spf13/cobra"

"github.com/coreruleset/crs-toolchain/v2/cmd/internal"
"github.com/coreruleset/crs-toolchain/v2/plugin"
)

var logger = log.With().Str("component", "cmd.plugin.install").Logger()

var (
pluginsDir string
version string
force bool
requireSignature bool
)

func New(cmdContext *internal.CommandContext) *cobra.Command {
cmd := &cobra.Command{
Use: "install NAME",
Short: "Install a CRS plugin from the plugin registry",
Long: `Resolve NAME against the published plugin registry, resolve a release tag,
download that release, and copy the plugin's files into the plugins directory.

Existing files are never overwritten unless --force is given.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
targetDir := pluginsDir
if targetDir == "" {
targetDir = cmdContext.RootContext().PluginsDir()
}

result, err := plugin.Install(cmd.Context(), plugin.Options{
Name: args[0],
Version: version,
PluginsDir: targetDir,
Force: force,
RequireSignature: requireSignature,
})
if err != nil {
return err
}

report(cmd, cmdContext, targetDir, result)
return nil
},
}

buildFlags(cmd)
return cmd
}

func buildFlags(cmd *cobra.Command) {
cmd.Flags().StringVar(&pluginsDir, "plugins-dir", "",
"Target directory to install into. Defaults to '<CRS_ROOT>/plugins'.")
cmd.Flags().StringVar(&version, "version", "",
"Release tag to install. Defaults to the newest release.")
cmd.Flags().BoolVar(&force, "force", false,
"Overwrite files already present in the target directory.")
cmd.Flags().BoolVar(&requireSignature, "require-signature", false,
"Fail unless the release is signed. No registered plugin publishes signed releases yet, "+
"so this always fails today.")
}

func report(cmd *cobra.Command, cmdContext *internal.CommandContext, targetDir string, result *plugin.Result) {
out := cmd.OutOrStdout()
fmt.Fprintf(out, "Installed %s %s (%s) into %s\n", result.Name, result.Tag, result.Repository, targetDir)
fmt.Fprintf(out, " type: %s, status: %s, rule IDs: %d-%d\n",
result.Type, result.Status, result.RuleIDRange.Start, result.RuleIDRange.End)
fmt.Fprintf(out, " digest: %s\n", result.Digest)

if result.LuaWarning {
warn(cmd, cmdContext, fmt.Sprintf("%s ships Lua scripts; it requires a Lua-enabled ModSecurity", result.Name))
}
for _, file := range result.OverlapWarnings {
warn(cmd, cmdContext, fmt.Sprintf("%s's rule ID range overlaps existing file %s", result.Name, file))
}
}

func warn(cmd *cobra.Command, cmdContext *internal.CommandContext, message string) {
if cmdContext.Output == internal.GitHub {
fmt.Fprintf(cmd.OutOrStdout(), "::warning ::%s\n", message)
return
}
logger.Warn().Msg(message)
}
23 changes: 23 additions & 0 deletions cmd/plugin/plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright 2026 OWASP Core Rule Set Project
// SPDX-License-Identifier: Apache-2.0

package plugin

import (
"github.com/spf13/cobra"

"github.com/coreruleset/crs-toolchain/v2/cmd/internal"
"github.com/coreruleset/crs-toolchain/v2/cmd/plugin/install"
)

func New(cmdContext *internal.CommandContext) *cobra.Command {
cmd := &cobra.Command{
Use: "plugin",
Short: "Manage CRS plugins",
Args: cobra.ExactArgs(1),
}

cmd.AddCommand(install.New(cmdContext))

return cmd
}
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/coreruleset/crs-toolchain/v2/cmd/completion"
"github.com/coreruleset/crs-toolchain/v2/cmd/generate"
"github.com/coreruleset/crs-toolchain/v2/cmd/internal"
"github.com/coreruleset/crs-toolchain/v2/cmd/plugin"
"github.com/coreruleset/crs-toolchain/v2/cmd/regex"
"github.com/coreruleset/crs-toolchain/v2/cmd/util"
)
Expand Down Expand Up @@ -55,6 +56,7 @@ func New() *cobra.Command {
chore.New(cmdContext),
completion.New(),
generate.New(cmdContext),
plugin.New(cmdContext),
regex.New(cmdContext),
util.New(cmdContext),
)
Expand Down
7 changes: 7 additions & 0 deletions context/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type Context struct {
includeFilesDirectory string
excludeFilesDirectory string
regressionTestFilesDirectory string
pluginsDirectory string
configuration *configuration.Configuration
}

Expand All @@ -30,6 +31,7 @@ func NewWithConfiguration(rootDir string, configuration *configuration.Configura
includeFilesDirectory: rootDir + "/regex-assembly/include",
excludeFilesDirectory: rootDir + "/regex-assembly/exclude",
regressionTestFilesDirectory: rootDir + "/tests/regression/tests",
pluginsDirectory: rootDir + "/plugins",
configuration: configuration,
}
}
Expand Down Expand Up @@ -64,6 +66,11 @@ func (ctx *Context) RegressionTestsDir() string {
return ctx.regressionTestFilesDirectory
}

// PluginsDir returns the 'plugins' directory.
func (ctx *Context) PluginsDir() string {
return ctx.pluginsDirectory
}

func (ctx *Context) Configuration() *configuration.Configuration {
return ctx.configuration
}
116 changes: 116 additions & 0 deletions plugin/github.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright 2026 OWASP Core Rule Set Project
// SPDX-License-Identifier: Apache-2.0

package plugin

import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"regexp"
"time"

"github.com/cli/go-gh/v2/pkg/api"
)

// githubAPIBaseURL is a var so tests can point it at an httptest server. It
// is passed as an absolute URL to the go-gh REST client, which uses it
// as-is instead of resolving it against the client's configured host.
var githubAPIBaseURL = "https://api.github.com"

// githubTimeout bounds a single GitHub API or download request.
var githubTimeout = 60 * time.Second

// maxTarballBytes bounds the size of a downloaded plugin source tarball.
// Plugin repositories are a handful of rule/config/lua files; anything
// approaching this size indicates a misbehaving or malicious release rather
// than a legitimate plugin.
const maxTarballBytes = 100 << 20 // 100 MiB

var repositoryURLPattern = regexp.MustCompile(`^https://github\.com/([^/]+)/([^/]+?)/?$`)

// parseRepository extracts the owner and repo name from a GitHub repository
// URL, as published in the registry's `repository` field.
func parseRepository(repositoryURL string) (owner, repo string, err error) {
matches := repositoryURLPattern.FindStringSubmatch(repositoryURL)
if matches == nil {
return "", "", fmt.Errorf("%q is not a GitHub repository URL", repositoryURL)
}
return matches[1], matches[2], nil
}

// newGitHubClient builds a go-gh REST client. If a GH_TOKEN or GITHUB_TOKEN
// is set, or the gh CLI is configured, requests are authenticated, which is
// what allows installing from a private plugin repository.
func newGitHubClient() (*api.RESTClient, error) {
client, err := api.NewRESTClient(api.ClientOptions{
Headers: map[string]string{
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
Timeout: githubTimeout,
})
if err != nil {
return nil, fmt.Errorf("creating GitHub client: %w", err)
}
return client, nil
}

// resolveTag resolves version to a concrete GitHub release tag. An empty
// version resolves to the newest release. A repository with no releases, or
// a pinned version that isn't a published release, is a clear error rather
// than falling back to a branch.
func resolveTag(ctx context.Context, client *api.RESTClient, owner, repo, version string) (string, error) {
path := fmt.Sprintf("%s/repos/%s/%s/releases/latest", githubAPIBaseURL, owner, repo)
if version != "" {
path = fmt.Sprintf("%s/repos/%s/%s/releases/tags/%s", githubAPIBaseURL, owner, repo, version)
}

var release struct {
TagName string `json:"tag_name"`
}
if err := client.DoWithContext(ctx, http.MethodGet, path, nil, &release); err != nil {
var httpErr *api.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
if version != "" {
return "", fmt.Errorf("no release tagged %q found for %s/%s", version, owner, repo)
}
return "", fmt.Errorf("no releases found for %s/%s (repository may be private or have no releases)", owner, repo)
}
return "", fmt.Errorf("resolving release for %s/%s: %w", owner, repo, err)
}
return release.TagName, nil
}

// downloadTarball downloads the source archive for owner/repo at tag into
// destFile. GitHub plugin releases publish no release assets, so the source
// tarball is the only artifact that can be fetched at a given tag.
func downloadTarball(ctx context.Context, client *api.RESTClient, owner, repo, tag, destFile string) error {
ctx, cancel := context.WithTimeout(ctx, githubTimeout)
defer cancel()

path := fmt.Sprintf("%s/repos/%s/%s/tarball/%s", githubAPIBaseURL, owner, repo, tag)
resp, err := client.RequestWithContext(ctx, http.MethodGet, path, nil)
if err != nil {
return fmt.Errorf("downloading %s/%s@%s: %w", owner, repo, tag, err)
}
defer resp.Body.Close()

out, err := os.Create(destFile)
if err != nil {
return fmt.Errorf("creating %s for %s/%s@%s: %w", destFile, owner, repo, tag, err)
}

written, copyErr := io.Copy(out, io.LimitReader(resp.Body, maxTarballBytes+1))
closeErr := out.Close()
if err := errors.Join(copyErr, closeErr); err != nil {
return fmt.Errorf("writing %s/%s@%s: %w", owner, repo, tag, err)
}
if written > maxTarballBytes {
return fmt.Errorf("downloading %s/%s@%s: archive exceeds maximum size of %d bytes", owner, repo, tag, maxTarballBytes)
}
return nil
}
Loading
Loading