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", Name: "image",
Usage: "secret limited to these images", 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{ secret := &woodpecker.Secret{
Name: strings.ToLower(c.String("name")), Name: strings.ToLower(c.String("name")),
Value: c.String("value"), Value: c.String("value"),
Images: c.StringSlice("image"), Images: c.StringSlice("image"),
Events: c.StringSlice("event"), PluginsOnly: c.Bool("plugins-only"),
Events: c.StringSlice("event"),
} }
if len(secret.Events) == 0 { if len(secret.Events) == 0 {
secret.Events = defaultSecretEvents secret.Events = defaultSecretEvents

View file

@ -42,6 +42,10 @@ var secretUpdateCmd = &cli.Command{
Name: "image", Name: "image",
Usage: "secret limited to these images", 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{ secret := &woodpecker.Secret{
Name: strings.ToLower(c.String("name")), Name: strings.ToLower(c.String("name")),
Value: c.String("value"), Value: c.String("value"),
Images: c.StringSlice("image"), Images: c.StringSlice("image"),
Events: c.StringSlice("event"), PluginsOnly: c.Bool("plugins-only"),
Events: c.StringSlice("event"),
} }
if strings.HasPrefix(secret.Value, "@") { if strings.HasPrefix(secret.Value, "@") {
path := strings.TrimPrefix(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. 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 ## 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). 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 { type Secret struct {
Name string Name string
Value string Value string
Match []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 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 !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") 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 { for _, requested := range container.Secrets.Secrets {
secret, ok := c.secrets[strings.ToLower(requested.Source)] 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 environment[strings.ToUpper(requested.Target)] = secret.Value
} }
} }

View file

@ -111,3 +111,7 @@ func (c *Containers) UnmarshalYAML(value *yaml.Node) error {
return nil 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 ( import (
"testing" "testing"
"github.com/docker/docker/api/types/strslice"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@ -301,3 +302,13 @@ func stringsToInterface(val ...string) []interface{} {
} }
return res 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 return
} }
secret := &model.Secret{ secret := &model.Secret{
Name: in.Name, Name: in.Name,
Value: in.Value, Value: in.Value,
Events: in.Events, Events: in.Events,
Images: in.Images, Images: in.Images,
PluginsOnly: in.PluginsOnly,
} }
if err := secret.Validate(); err != nil { if err := secret.Validate(); err != nil {
c.String(400, "Error inserting global secret. %s", err) c.String(400, "Error inserting global secret. %s", err)
@ -100,6 +101,7 @@ func PatchGlobalSecret(c *gin.Context) {
if in.Images != nil { if in.Images != nil {
secret.Images = in.Images secret.Images = in.Images
} }
secret.PluginsOnly = in.PluginsOnly
if err := secret.Validate(); err != nil { if err := secret.Validate(); err != nil {
c.String(400, "Error updating global secret. %s", err) c.String(400, "Error updating global secret. %s", err)

View file

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

View file

@ -50,11 +50,12 @@ func PostSecret(c *gin.Context) {
return return
} }
secret := &model.Secret{ secret := &model.Secret{
RepoID: repo.ID, RepoID: repo.ID,
Name: strings.ToLower(in.Name), Name: strings.ToLower(in.Name),
Value: in.Value, Value: in.Value,
Events: in.Events, Events: in.Events,
Images: in.Images, Images: in.Images,
PluginsOnly: in.PluginsOnly,
} }
if err := secret.Validate(); err != nil { if err := secret.Validate(); err != nil {
c.String(400, "Error inserting secret. %s", err) c.String(400, "Error inserting secret. %s", err)
@ -95,6 +96,7 @@ func PatchSecret(c *gin.Context) {
if in.Images != nil { if in.Images != nil {
secret.Images = in.Images secret.Images = in.Images
} }
secret.PluginsOnly = in.PluginsOnly
if err := secret.Validate(); err != nil { if err := secret.Validate(); err != nil {
c.String(400, "Error updating secret. %s", err) 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. // Secret represents a secret variable, such as a password or token.
// swagger:model registry // swagger:model registry
type Secret struct { type Secret struct {
ID int64 `json:"id" xorm:"pk autoincr 'secret_id'"` ID int64 `json:"id" xorm:"pk autoincr 'secret_id'"`
Owner string `json:"-" xorm:"NOT NULL DEFAULT '' UNIQUE(s) INDEX 'secret_owner'"` 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'"` 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'"` Name string `json:"name" xorm:"NOT NULL UNIQUE(s) INDEX 'secret_name'"`
Value string `json:"value,omitempty" xorm:"TEXT 'secret_value'"` Value string `json:"value,omitempty" xorm:"TEXT 'secret_value'"`
Images []string `json:"image" xorm:"json 'secret_images'"` Images []string `json:"image" xorm:"json 'secret_images'"`
Events []WebhookEvent `json:"event" xorm:"json 'secret_events'"` PluginsOnly bool `json:"plugins_only" xorm:"secret_plugins_only"`
SkipVerify bool `json:"-" xorm:"secret_skip_verify"` Events []WebhookEvent `json:"event" xorm:"json 'secret_events'"`
Conceal bool `json:"-" xorm:"secret_conceal"` SkipVerify bool `json:"-" xorm:"secret_skip_verify"`
Conceal bool `json:"-" xorm:"secret_conceal"`
} }
// TableName return database table name for xorm // 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. // Copy makes a copy of the secret without the value.
func (s *Secret) Copy() *Secret { func (s *Secret) Copy() *Secret {
return &Secret{ return &Secret{
ID: s.ID, ID: s.ID,
Owner: s.Owner, Owner: s.Owner,
RepoID: s.RepoID, RepoID: s.RepoID,
Name: s.Name, Name: s.Name,
Images: s.Images, Images: s.Images,
Events: sortEvents(s.Events), PluginsOnly: s.PluginsOnly,
Events: sortEvents(s.Events),
} }
} }

View file

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

View file

@ -121,6 +121,7 @@
"events": "Available at following 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." "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", "edit": "Edit secret",
"delete":"Delete secret" "delete":"Delete secret"
}, },
@ -258,6 +259,7 @@
"images": "Available for following images", "images": "Available for following images",
"desc": "Comma separated list of images where this secret is available, leave empty to allow all 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": {
"events": "Available at following 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." "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", "images": "Available for following images",
"desc": "Comma separated list of images where this secret is available, leave empty to allow all 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": {
"events": "Available at following 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." "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')"> <InputField :label="$t(i18nPrefix + 'images.images')">
<TextField v-model="images" :placeholder="$t(i18nPrefix + 'images.desc')" /> <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>
<InputField :label="$t(i18nPrefix + 'events.events')"> <InputField :label="$t(i18nPrefix + 'events.events')">

View file

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

View file

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