Support plugin-only secrets (#1344)

Closes #1071
This commit is contained in:
qwerty287 2022-10-27 04:21:07 +02:00 committed by GitHub
parent 9ece7a1c49
commit e568c42e84
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 154 additions and 50 deletions

View file

@ -42,6 +42,10 @@ var secretCreateCmd = &cli.Command{
Name: "image",
Usage: "secret limited to these images",
},
&cli.BoolFlag{
Name: "plugins-only",
Usage: "secret limited to plugins",
},
),
}
@ -52,10 +56,11 @@ func secretCreate(c *cli.Context) error {
}
secret := &woodpecker.Secret{
Name: strings.ToLower(c.String("name")),
Value: c.String("value"),
Images: c.StringSlice("image"),
Events: c.StringSlice("event"),
Name: strings.ToLower(c.String("name")),
Value: c.String("value"),
Images: c.StringSlice("image"),
PluginsOnly: c.Bool("plugins-only"),
Events: c.StringSlice("event"),
}
if len(secret.Events) == 0 {
secret.Events = defaultSecretEvents

View file

@ -42,6 +42,10 @@ var secretUpdateCmd = &cli.Command{
Name: "image",
Usage: "secret limited to these images",
},
&cli.BoolFlag{
Name: "plugins-only",
Usage: "secret limited to plugins",
},
),
}
@ -52,10 +56,11 @@ func secretUpdate(c *cli.Context) error {
}
secret := &woodpecker.Secret{
Name: strings.ToLower(c.String("name")),
Value: c.String("value"),
Images: c.StringSlice("image"),
Events: c.StringSlice("event"),
Name: strings.ToLower(c.String("name")),
Value: c.String("value"),
Images: c.StringSlice("image"),
PluginsOnly: c.Bool("plugins-only"),
Events: c.StringSlice("event"),
}
if strings.HasPrefix(secret.Value, "@") {
path := strings.TrimPrefix(secret.Value, "@")

View file

@ -79,6 +79,15 @@ woodpecker-cli secret add \
Please be careful when exposing secrets to pull requests. If your repository is open source and accepts pull requests your secrets are not safe. A bad actor can submit a malicious pull request that exposes your secrets.
## Image filter
To prevent abusing your secrets with malicious pull requests, you can limit a secret to a list of images. They are not available to any other container. In addition, you can make the secret available only for plugins (steps without user-defined commands).
:::warning
If you enable the option "Only available for plugins", always set an image filter too. Otherwise, the secret can be accessed by a very simple self-developed plugin and is thus *not* safe.
If you only set an image filter, you could still access the secret using the same image and by specifying a command that prints it.
:::
## Examples
Create the secret using default settings. The secret will be available to all images in your pipeline, and will be available to all push, tag, and deployment events (not pull request events).

View file

@ -35,9 +35,14 @@ type Registry struct {
}
type Secret struct {
Name string
Value string
Match []string
Name string
Value string
Match []string
PluginOnly bool
}
func (s *Secret) Available(container *yaml.Container) bool {
return (len(s.Match) == 0 || matchImage(container.Image, s.Match...)) && (!s.PluginOnly || container.IsPlugin())
}
type secretMap map[string]Secret

View file

@ -0,0 +1,42 @@
package compiler
import (
"testing"
"github.com/docker/docker/api/types/strslice"
"github.com/stretchr/testify/assert"
"github.com/woodpecker-ci/woodpecker/pipeline/frontend/yaml"
"github.com/woodpecker-ci/woodpecker/pipeline/frontend/yaml/types"
)
func TestSecretAvailable(t *testing.T) {
secret := Secret{
Match: []string{"golang"},
PluginOnly: false,
}
assert.True(t, secret.Available(&yaml.Container{
Image: "golang",
Commands: types.Stringorslice(strslice.StrSlice{"echo 'this is not a plugin'"}),
}))
assert.False(t, secret.Available(&yaml.Container{
Image: "not-golang",
Commands: types.Stringorslice(strslice.StrSlice{"echo 'this is not a plugin'"}),
}))
// secret only available for "golang" plugin
secret = Secret{
Match: []string{"golang"},
PluginOnly: true,
}
assert.True(t, secret.Available(&yaml.Container{
Image: "golang",
Commands: types.Stringorslice(strslice.StrSlice{}),
}))
assert.False(t, secret.Available(&yaml.Container{
Image: "not-golang",
Commands: types.Stringorslice(strslice.StrSlice{}),
}))
assert.False(t, secret.Available(&yaml.Container{
Image: "not-golang",
Commands: types.Stringorslice(strslice.StrSlice{"echo 'this is not a plugin'"}),
}))
}

View file

@ -73,7 +73,14 @@ func (c *Compiler) createProcess(name string, container *yaml.Container, section
}
if !detached {
if err := settings.ParamsToEnv(container.Settings, environment, c.secrets.toStringMap()); err != nil {
pluginSecrets := secretMap{}
for name, secret := range c.secrets {
if secret.Available(container) {
pluginSecrets[name] = secret
}
}
if err := settings.ParamsToEnv(container.Settings, environment, pluginSecrets.toStringMap()); err != nil {
log.Error().Err(err).Msg("paramsToEnv")
}
}
@ -116,7 +123,7 @@ func (c *Compiler) createProcess(name string, container *yaml.Container, section
for _, requested := range container.Secrets.Secrets {
secret, ok := c.secrets[strings.ToLower(requested.Source)]
if ok && (len(secret.Match) == 0 || matchImage(container.Image, secret.Match...)) {
if ok && secret.Available(container) {
environment[strings.ToUpper(requested.Target)] = secret.Value
}
}

View file

@ -111,3 +111,7 @@ func (c *Containers) UnmarshalYAML(value *yaml.Node) error {
return nil
}
func (c *Container) IsPlugin() bool {
return len(c.Commands) == 0 && len(c.Command) == 0
}

View file

@ -3,6 +3,7 @@ package yaml
import (
"testing"
"github.com/docker/docker/api/types/strslice"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v3"
@ -301,3 +302,13 @@ func stringsToInterface(val ...string) []interface{} {
}
return res
}
func TestIsPlugin(t *testing.T) {
assert.True(t, (&Container{}).IsPlugin())
assert.True(t, (&Container{
Commands: types.Stringorslice(strslice.StrSlice{}),
}).IsPlugin())
assert.False(t, (&Container{
Commands: types.Stringorslice(strslice.StrSlice{"echo 'this is not a plugin'"}),
}).IsPlugin())
}

View file

@ -59,10 +59,11 @@ func PostGlobalSecret(c *gin.Context) {
return
}
secret := &model.Secret{
Name: in.Name,
Value: in.Value,
Events: in.Events,
Images: in.Images,
Name: in.Name,
Value: in.Value,
Events: in.Events,
Images: in.Images,
PluginsOnly: in.PluginsOnly,
}
if err := secret.Validate(); err != nil {
c.String(400, "Error inserting global secret. %s", err)
@ -100,6 +101,7 @@ func PatchGlobalSecret(c *gin.Context) {
if in.Images != nil {
secret.Images = in.Images
}
secret.PluginsOnly = in.PluginsOnly
if err := secret.Validate(); err != nil {
c.String(400, "Error updating global secret. %s", err)

View file

@ -65,11 +65,12 @@ func PostOrgSecret(c *gin.Context) {
return
}
secret := &model.Secret{
Owner: owner,
Name: in.Name,
Value: in.Value,
Events: in.Events,
Images: in.Images,
Owner: owner,
Name: in.Name,
Value: in.Value,
Events: in.Events,
Images: in.Images,
PluginsOnly: in.PluginsOnly,
}
if err := secret.Validate(); err != nil {
c.String(400, "Error inserting org %q secret. %s", owner, err)
@ -110,6 +111,7 @@ func PatchOrgSecret(c *gin.Context) {
if in.Images != nil {
secret.Images = in.Images
}
secret.PluginsOnly = in.PluginsOnly
if err := secret.Validate(); err != nil {
c.String(400, "Error updating org %q secret. %s", owner, err)

View file

@ -50,11 +50,12 @@ func PostSecret(c *gin.Context) {
return
}
secret := &model.Secret{
RepoID: repo.ID,
Name: strings.ToLower(in.Name),
Value: in.Value,
Events: in.Events,
Images: in.Images,
RepoID: repo.ID,
Name: strings.ToLower(in.Name),
Value: in.Value,
Events: in.Events,
Images: in.Images,
PluginsOnly: in.PluginsOnly,
}
if err := secret.Validate(); err != nil {
c.String(400, "Error inserting secret. %s", err)
@ -95,6 +96,7 @@ func PatchSecret(c *gin.Context) {
if in.Images != nil {
secret.Images = in.Images
}
secret.PluginsOnly = in.PluginsOnly
if err := secret.Validate(); err != nil {
c.String(400, "Error updating secret. %s", err)

View file

@ -68,15 +68,16 @@ type SecretStore interface {
// Secret represents a secret variable, such as a password or token.
// swagger:model registry
type Secret struct {
ID int64 `json:"id" xorm:"pk autoincr 'secret_id'"`
Owner string `json:"-" xorm:"NOT NULL DEFAULT '' UNIQUE(s) INDEX 'secret_owner'"`
RepoID int64 `json:"-" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_repo_id'"`
Name string `json:"name" xorm:"NOT NULL UNIQUE(s) INDEX 'secret_name'"`
Value string `json:"value,omitempty" xorm:"TEXT 'secret_value'"`
Images []string `json:"image" xorm:"json 'secret_images'"`
Events []WebhookEvent `json:"event" xorm:"json 'secret_events'"`
SkipVerify bool `json:"-" xorm:"secret_skip_verify"`
Conceal bool `json:"-" xorm:"secret_conceal"`
ID int64 `json:"id" xorm:"pk autoincr 'secret_id'"`
Owner string `json:"-" xorm:"NOT NULL DEFAULT '' UNIQUE(s) INDEX 'secret_owner'"`
RepoID int64 `json:"-" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_repo_id'"`
Name string `json:"name" xorm:"NOT NULL UNIQUE(s) INDEX 'secret_name'"`
Value string `json:"value,omitempty" xorm:"TEXT 'secret_value'"`
Images []string `json:"image" xorm:"json 'secret_images'"`
PluginsOnly bool `json:"plugins_only" xorm:"secret_plugins_only"`
Events []WebhookEvent `json:"event" xorm:"json 'secret_events'"`
SkipVerify bool `json:"-" xorm:"secret_skip_verify"`
Conceal bool `json:"-" xorm:"secret_conceal"`
}
// TableName return database table name for xorm
@ -152,12 +153,13 @@ func (s *Secret) Validate() error {
// Copy makes a copy of the secret without the value.
func (s *Secret) Copy() *Secret {
return &Secret{
ID: s.ID,
Owner: s.Owner,
RepoID: s.RepoID,
Name: s.Name,
Images: s.Images,
Events: sortEvents(s.Events),
ID: s.ID,
Owner: s.Owner,
RepoID: s.RepoID,
Name: s.Name,
Images: s.Images,
PluginsOnly: s.PluginsOnly,
Events: sortEvents(s.Events),
}
}

View file

@ -244,9 +244,10 @@ func (b *ProcBuilder) toInternalRepresentation(parsed *yaml.Config, environ map[
continue
}
secrets = append(secrets, compiler.Secret{
Name: sec.Name,
Value: sec.Value,
Match: sec.Images,
Name: sec.Name,
Value: sec.Value,
Match: sec.Images,
PluginOnly: sec.PluginsOnly,
})
}

View file

@ -121,6 +121,7 @@
"events": "Available at following events",
"pr_warning": "Please be careful with this option as a bad actor can submit a malicious pull request that exposes your secrets."
},
"plugins_only": "Only available for plugins",
"edit": "Edit secret",
"delete":"Delete secret"
},
@ -258,6 +259,7 @@
"images": "Available for following images",
"desc": "Comma separated list of images where this secret is available, leave empty to allow all images"
},
"plugins_only": "Only available for plugins",
"events": {
"events": "Available at following events",
"pr_warning": "Please be careful with this option as a bad actor can submit a malicious pull request that exposes your secrets."
@ -286,6 +288,7 @@
"images": "Available for following images",
"desc": "Comma separated list of images where this secret is available, leave empty to allow all images"
},
"plugins_only": "Only available for plugins",
"events": {
"events": "Available at following events",
"pr_warning": "Please be careful with this option as a bad actor can submit a malicious pull request that exposes your secrets."

View file

@ -16,6 +16,8 @@
<InputField :label="$t(i18nPrefix + 'images.images')">
<TextField v-model="images" :placeholder="$t(i18nPrefix + 'images.desc')" />
<Checkbox v-model="innerValue.plugins_only" class="mt-4" :label="$t(i18nPrefix + 'plugins_only')" />
</InputField>
<InputField :label="$t(i18nPrefix + 'events.events')">

View file

@ -6,4 +6,5 @@ export type Secret = {
value: string;
event: WebhookEvents[];
image: string[];
plugins_only: string;
};

View file

@ -118,11 +118,12 @@ type (
// Secret represents a secret variable, such as a password or token.
Secret struct {
ID int64 `json:"id"`
Name string `json:"name"`
Value string `json:"value,omitempty"`
Images []string `json:"image"`
Events []string `json:"event"`
ID int64 `json:"id"`
Name string `json:"name"`
Value string `json:"value,omitempty"`
Images []string `json:"image"`
PluginsOnly bool `json:"plugins_only"`
Events []string `json:"event"`
}
// Activity represents an item in the user's feed or timeline.