Remove plugin-only option from secrets (#2213)

This commit is contained in:
Anbraten 2023-10-24 20:38:47 +02:00 committed by GitHub
parent 703983419a
commit f44aa8a6fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 112 additions and 104 deletions

View file

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

View file

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

View file

@ -4212,7 +4212,7 @@ const docTemplate = `{
"Secret": {
"type": "object",
"properties": {
"event": {
"events": {
"type": "array",
"items": {
"$ref": "#/definitions/WebhookEvent"
@ -4221,7 +4221,7 @@ const docTemplate = `{
"id": {
"type": "integer"
},
"image": {
"images": {
"type": "array",
"items": {
"type": "string"
@ -4230,9 +4230,6 @@ const docTemplate = `{
"name": {
"type": "string"
},
"plugins_only": {
"type": "boolean"
},
"value": {
"type": "string"
}

View file

@ -89,12 +89,7 @@ Please be careful when exposing secrets to pull requests. If your repository is
## 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.
:::
To prevent abusing your secrets from malicious usage, you can limit a secret to a list of images. If enabled they are not available to any other plugin (steps without user-defined commands). If you or an attacker defines explicit commands, the secrets will not be available to the container to prevent leaking them.
## CLI Examples

View file

@ -8,6 +8,8 @@ Some versions need some changes to the server configuration or the pipeline conf
- Dropped deprecated `pipeline:` keyword in favor of `steps:` in pipeline config
- Dropped deprecated `branches:` filter in favor of global [`when.branch`](./20-usage/20-workflow-syntax.md#branch-1) filter
- Deprecated `platform:` filter in favor of `labels:`, [read more](./20-usage/20-workflow-syntax.md#filter-by-platform)
- Secrets `event` property was renamed to `events` and `image` to `images` as both are lists. The new property `events` / `images` has to be used in the api and as cli argument. The old properties `event` and `image` were removed.
- The secrets `plugin_only` option was removed. Secrets with images are now always only available for plugins using listed by the `images` property. Existing secrets with a list of `images` will now only be available to the listed images if they are used as a plugin.
- Removed `build` alias for `pipeline` command in CLI
- Removed `ssh` backend. Use an agent directly on the SSH machine using the `local` backend.
- Removed `/hook` and `/stream` API paths in favor of `/api/(hook|stream)`. You may need to use the "Repair repository" button in the repo settings or "Repair all" in the admin settings to recreate the forge hook.

View file

@ -41,14 +41,13 @@ type Registry struct {
}
type Secret struct {
Name string
Value string
Match []string
PluginOnly bool
Name string
Value string
AllowedPlugins []string
}
func (s *Secret) Available(container *yaml_types.Container) bool {
return (len(s.Match) == 0 || utils.MatchImage(container.Image, s.Match...)) && (!s.PluginOnly || container.IsPlugin())
return (len(s.AllowedPlugins) == 0 || utils.MatchImage(container.Image, s.AllowedPlugins...)) && (len(s.AllowedPlugins) == 0 || container.IsPlugin())
}
type secretMap map[string]Secret

View file

@ -28,33 +28,28 @@ import (
func TestSecretAvailable(t *testing.T) {
secret := Secret{
Match: []string{"golang"},
PluginOnly: false,
AllowedPlugins: []string{},
}
assert.True(t, secret.Available(&yaml_types.Container{
Image: "golang",
Commands: yaml_base_types.StringOrSlice{"echo 'this is not a plugin'"},
}))
assert.False(t, secret.Available(&yaml_types.Container{
Image: "not-golang",
Commands: yaml_base_types.StringOrSlice{"echo 'this is not a plugin'"},
}))
// secret only available for "golang" plugin
secret = Secret{
Match: []string{"golang"},
PluginOnly: true,
AllowedPlugins: []string{"golang"},
}
assert.True(t, secret.Available(&yaml_types.Container{
Image: "golang",
Commands: yaml_base_types.StringOrSlice{},
}))
assert.False(t, secret.Available(&yaml_types.Container{
Image: "not-golang",
Commands: yaml_base_types.StringOrSlice{},
Image: "golang",
Commands: yaml_base_types.StringOrSlice{"echo 'this is not a plugin'"},
}))
assert.False(t, secret.Available(&yaml_types.Container{
Image: "not-golang",
Commands: yaml_base_types.StringOrSlice{"echo 'this is not a plugin'"},
Commands: yaml_base_types.StringOrSlice{},
}))
}

View file

@ -235,10 +235,9 @@ func (b *StepBuilder) toInternalRepresentation(parsed *yaml_types.Workflow, envi
continue
}
secrets = append(secrets, compiler.Secret{
Name: sec.Name,
Value: sec.Value,
Match: sec.Images,
PluginOnly: sec.PluginsOnly,
Name: sec.Name,
Value: sec.Value,
AllowedPlugins: sec.Images,
})
}

View file

@ -84,11 +84,10 @@ func PostGlobalSecret(c *gin.Context) {
return
}
secret := &model.Secret{
Name: in.Name,
Value: in.Value,
Events: in.Events,
Images: in.Images,
PluginsOnly: in.PluginsOnly,
Name: in.Name,
Value: in.Value,
Events: in.Events,
Images: in.Images,
}
if err := secret.Validate(); err != nil {
c.String(http.StatusBadRequest, "Error inserting global secret. %s", err)
@ -135,7 +134,6 @@ 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(http.StatusBadRequest, "Error updating global secret. %s", err)

View file

@ -107,12 +107,11 @@ func PostOrgSecret(c *gin.Context) {
return
}
secret := &model.Secret{
OrgID: orgID,
Name: in.Name,
Value: in.Value,
Events: in.Events,
Images: in.Images,
PluginsOnly: in.PluginsOnly,
OrgID: orgID,
Name: in.Name,
Value: in.Value,
Events: in.Events,
Images: in.Images,
}
if err := secret.Validate(); err != nil {
c.String(http.StatusUnprocessableEntity, "Error inserting org %q secret. %s", orgID, err)
@ -165,7 +164,6 @@ 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(http.StatusUnprocessableEntity, "Error updating org %q secret. %s", orgID, err)

View file

@ -67,12 +67,11 @@ 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,
PluginsOnly: in.PluginsOnly,
RepoID: repo.ID,
Name: strings.ToLower(in.Name),
Value: in.Value,
Events: in.Events,
Images: in.Images,
}
if err := secret.Validate(); err != nil {
c.String(http.StatusUnprocessableEntity, "Error inserting secret. %s", err)
@ -123,7 +122,6 @@ 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(http.StatusUnprocessableEntity, "Error updating secret. %s", err)

View file

@ -69,16 +69,13 @@ type SecretStore interface {
// Secret represents a secret variable, such as a password or token.
type Secret struct {
ID int64 `json:"id" xorm:"pk autoincr 'secret_id'"`
OrgID int64 `json:"-" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_org_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'"`
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"`
ID int64 `json:"id" xorm:"pk autoincr 'secret_id'"`
OrgID int64 `json:"-" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_org_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'"`
Value string `json:"value,omitempty" xorm:"TEXT 'secret_value'"`
Images []string `json:"images" xorm:"json 'secret_images'"`
Events []WebhookEvent `json:"events" xorm:"json 'secret_events'"`
} // @name Secret
// TableName return database table name for xorm
@ -154,13 +151,12 @@ 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,
OrgID: s.OrgID,
RepoID: s.RepoID,
Name: s.Name,
Images: s.Images,
PluginsOnly: s.PluginsOnly,
Events: sortEvents(s.Events),
ID: s.ID,
OrgID: s.OrgID,
RepoID: s.RepoID,
Name: s.Name,
Images: s.Images,
Events: sortEvents(s.Events),
}
}

View file

@ -0,0 +1,43 @@
// Copyright 2023 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package migration
import (
"xorm.io/xorm"
)
type oldSecret025 struct {
ID int64 `json:"id" xorm:"pk autoincr 'secret_id'"`
PluginsOnly bool `json:"plugins_only" xorm:"secret_plugins_only"`
SkipVerify bool `json:"-" xorm:"secret_skip_verify"`
Conceal bool `json:"-" xorm:"secret_conceal"`
Images []string `json:"images" xorm:"json 'secret_images'"`
}
func (oldSecret025) TableName() string {
return "secrets"
}
var removePluginOnlyOptionFromSecretsTable = task{
name: "remove-plugin-only-option-from-secrets-table",
fn: func(sess *xorm.Session) (err error) {
// make sure plugin_only column exists
if err := sess.Sync(new(oldSecret025)); err != nil {
return err
}
return dropTableColumns(sess, "secrets", "secret_plugins_only", "secret_skip_verify", "secret_conceal")
},
}

View file

@ -57,6 +57,7 @@ var migrationTasks = []*task{
&addOrgID,
&alterTableTasksUpdateColumnTaskDataType,
&alterTableConfigUpdateColumnConfigDataType,
&removePluginOnlyOptionFromSecretsTable,
}
var allBeans = []interface{}{

View file

@ -16,12 +16,10 @@
<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')">
<CheckboxesField v-model="innerValue.event" :options="secretEventsOptions" />
<CheckboxesField v-model="innerValue.events" :options="secretEventsOptions" />
</InputField>
<div class="flex gap-2">
@ -71,11 +69,11 @@ const innerValue = computed({
});
const images = computed<string>({
get() {
return innerValue.value?.image?.join(',') || '';
return innerValue.value?.images?.join(',') || '';
},
set(value) {
if (innerValue.value) {
innerValue.value.image = value
innerValue.value.images = value
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');

View file

@ -7,7 +7,7 @@
>
<span>{{ secret.name }}</span>
<div class="ml-auto space-x-2 <md:hidden">
<Badge v-for="event in secret.event" :key="event" :label="event" />
<Badge v-for="event in secret.events" :key="event" :label="event" />
</div>
<IconButton
icon="edit"

View file

@ -4,7 +4,7 @@ export type Secret = {
id: string;
name: string;
value: string;
event: WebhookEvents[];
image: string[];
events: WebhookEvents[];
images: string[];
plugins_only: string;
};

View file

@ -132,12 +132,11 @@ 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"`
PluginsOnly bool `json:"plugins_only"`
Events []string `json:"event"`
ID int64 `json:"id"`
Name string `json:"name"`
Value string `json:"value,omitempty"`
Images []string `json:"images"`
Events []string `json:"events"`
}
// Activity represents an item in the user's feed or timeline.