mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2024-11-22 18:01:02 +00:00
parent
9ece7a1c49
commit
e568c42e84
17 changed files with 154 additions and 50 deletions
|
@ -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
|
||||||
|
|
|
@ -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, "@")
|
||||||
|
|
|
@ -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).
|
||||||
|
|
|
@ -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
|
||||||
|
|
42
pipeline/frontend/yaml/compiler/compiler_test.go
Normal file
42
pipeline/frontend/yaml/compiler/compiler_test.go
Normal 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'"}),
|
||||||
|
}))
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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."
|
||||||
|
|
|
@ -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')">
|
||||||
|
|
|
@ -6,4 +6,5 @@ export type Secret = {
|
||||||
value: string;
|
value: string;
|
||||||
event: WebhookEvents[];
|
event: WebhookEvents[];
|
||||||
image: string[];
|
image: string[];
|
||||||
|
plugins_only: string;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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.
|
||||||
|
|
Loading…
Reference in a new issue