diff --git a/docs/cmd/wolfictl_advisory_move.md b/docs/cmd/wolfictl_advisory_move.md new file mode 100644 index 000000000..d57c3e87e --- /dev/null +++ b/docs/cmd/wolfictl_advisory_move.md @@ -0,0 +1,47 @@ +## wolfictl advisory move + +Move a package's advisories into a new package. + +***Aliases**: mv* + +### Usage + +``` +wolfictl advisory move +``` + +### Synopsis + +Move a package's advisories into a new package. + +This command will move most advisories for the given package into a new package. And rename the +package to the new package name. (i.e., from foo.advisories.yaml to foo-X.Y.advisories.yaml) If the +target file already exists, the command will try to merge the advisories. To ensure the advisories +are up-to-date, the command will start a scan for the new package. + +This command is also useful to start version streaming for an existing package that has not been +version streamed before. Especially that requires manual intervention to move the advisories. + +The command will move the latest event for each advisory, and will update the timestamp +of the event to now. The command will not copy events of type "detection", "fixed", +"analysis_not_planned", or "fix_not_planned". + + +### Options + +``` + -d, --dir string directory containing the advisories to copy (default ".") + -h, --help help for move +``` + +### Options inherited from parent commands + +``` + --log-level string log level (e.g. debug, info, warn, error) (default "info") + --log-policy strings log policy (e.g. builtin:stderr, /tmp/log/foo) (default [builtin:stderr]) +``` + +### SEE ALSO + +* [wolfictl advisory](wolfictl_advisory.md) - Commands for consuming and maintaining security advisory data + diff --git a/docs/man/man1/wolfictl-advisory-move.1 b/docs/man/man1/wolfictl-advisory-move.1 new file mode 100644 index 000000000..47f484227 --- /dev/null +++ b/docs/man/man1/wolfictl-advisory-move.1 @@ -0,0 +1,58 @@ +.TH "WOLFICTL\-ADVISORY\-MOVE" "1" "" "Auto generated by spf13/cobra" "" +.nh +.ad l + + +.SH NAME +.PP +wolfictl\-advisory\-move \- Move a package's advisories into a new package. + + +.SH SYNOPSIS +.PP +\fBwolfictl advisory move \fP + + +.SH DESCRIPTION +.PP +Move a package's advisories into a new package. + +.PP +This command will move most advisories for the given package into a new package. And rename the +package to the new package name. (i.e., from foo.advisories.yaml to foo\-X.Y.advisories.yaml) If the +target file already exists, the command will try to merge the advisories. To ensure the advisories +are up\-to\-date, the command will start a scan for the new package. + +.PP +This command is also useful to start version streaming for an existing package that has not been +version streamed before. Especially that requires manual intervention to move the advisories. + +.PP +The command will move the latest event for each advisory, and will update the timestamp +of the event to now. The command will not copy events of type "detection", "fixed", +"analysis\_not\_planned", or "fix\_not\_planned". + + +.SH OPTIONS +.PP +\fB\-d\fP, \fB\-\-dir\fP="." + directory containing the advisories to copy + +.PP +\fB\-h\fP, \fB\-\-help\fP[=false] + help for move + + +.SH OPTIONS INHERITED FROM PARENT COMMANDS +.PP +\fB\-\-log\-level\fP="info" + log level (e.g. debug, info, warn, error) + +.PP +\fB\-\-log\-policy\fP=[builtin:stderr] + log policy (e.g. builtin:stderr, /tmp/log/foo) + + +.SH SEE ALSO +.PP +\fBwolfictl\-advisory(1)\fP diff --git a/pkg/cli/advisory.go b/pkg/cli/advisory.go index 27dc6be0f..d55eb04b0 100644 --- a/pkg/cli/advisory.go +++ b/pkg/cli/advisory.go @@ -48,6 +48,7 @@ func cmdAdvisory() *cobra.Command { cmdAdvisorySecDB(), cmdAdvisoryUpdate(), cmdAdvisoryValidate(), + cmdAdvisoryMove(), ) return cmd diff --git a/pkg/cli/advisory_copy.go b/pkg/cli/advisory_copy.go index c6e8e3b77..dbc3e5e67 100644 --- a/pkg/cli/advisory_copy.go +++ b/pkg/cli/advisory_copy.go @@ -49,39 +49,9 @@ of the event to now. The command will not copy events of type "detection", "fixe out.Advisories = nil for _, adv := range hdoc.Advisories { - evts := make([]v2.Event, 0, len(adv.Events)) - - for _, evt := range adv.Events { - switch evt.Type { - case v2.EventTypeDetection, v2.EventTypeFixed, v2.EventTypeAnalysisNotPlanned, v2.EventTypeFixNotPlanned: - // Don't carry these over. - continue - - case v2.EventTypePendingUpstreamFix, v2.EventTypeFalsePositiveDetermination, v2.EventTypeTruePositiveDetermination: - // Carry these over as-is. - evts = append(evts, evt) - - default: - // A new type was added and we don't know how to handle it. Default to not carrying it over. - } - } - - if len(evts) == 0 { - // No events to carry over. - continue + if carried, ok := carryAdvisory(adv); ok { + out.Advisories = append(out.Advisories, carried) } - - // Sort events by timestamp and only take the latest event. - sort.Slice(evts, func(i, j int) bool { - return evts[i].Timestamp.Before(evts[j].Timestamp) - }) - evts = []v2.Event{evts[len(evts)-1]} - - // Update the timestamp to now. - evts[0].Timestamp = v2.Now() - - adv.Events = evts - out.Advisories = append(out.Advisories, adv) } return advisoryCfgs.Create(ctx, want+".advisories.yaml", out) @@ -91,3 +61,42 @@ of the event to now. The command will not copy events of type "detection", "fixe return cmd } + +// carryAdvisory decides whether to carry over an advisory and its events. +// Returns true with the updated advisory if it should be carried over. Otherwise, returns false +// and the current advisory. +func carryAdvisory(advisory v2.Advisory) (v2.Advisory, bool) { + evts := make([]v2.Event, 0, len(advisory.Events)) + + for _, evt := range advisory.Events { + switch evt.Type { + case v2.EventTypeDetection, v2.EventTypeFixed, v2.EventTypeAnalysisNotPlanned, v2.EventTypeFixNotPlanned: + // Don't carry these over. + continue + + case v2.EventTypePendingUpstreamFix, v2.EventTypeFalsePositiveDetermination, v2.EventTypeTruePositiveDetermination: + // Carry these over as-is. + evts = append(evts, evt) + + default: + // A new type was added and we don't know how to handle it. Default to not carrying it over. + } + } + + if len(evts) == 0 { + // No events to carry over. + return advisory, false + } + + // Sort events by timestamp and only take the latest event. + sort.Slice(evts, func(i, j int) bool { + return evts[i].Timestamp.Before(evts[j].Timestamp) + }) + evts = []v2.Event{evts[len(evts)-1]} + + // Update the timestamp to now. + evts[0].Timestamp = v2.Now() + + advisory.Events = evts + return advisory, true +} diff --git a/pkg/cli/advisory_move.go b/pkg/cli/advisory_move.go new file mode 100644 index 000000000..fe004c819 --- /dev/null +++ b/pkg/cli/advisory_move.go @@ -0,0 +1,117 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + v2 "github.com/wolfi-dev/wolfictl/pkg/configs/advisory/v2" + rwos "github.com/wolfi-dev/wolfictl/pkg/configs/rwfs/os" +) + +func cmdAdvisoryMove() *cobra.Command { + var dir string + cmd := &cobra.Command{ + Use: "move ", + Aliases: []string{"mv"}, + Short: "Move a package's advisories into a new package.", + Long: `Move a package's advisories into a new package. + +This command will move most advisories for the given package into a new package. And rename the +package to the new package name. (i.e., from foo.advisories.yaml to foo-X.Y.advisories.yaml) If the +target file already exists, the command will try to merge the advisories. To ensure the advisories +are up-to-date, the command will start a scan for the new package. + +This command is also useful to start version streaming for an existing package that has not been +version streamed before. Especially that requires manual intervention to move the advisories. + +The command will move the latest event for each advisory, and will update the timestamp +of the event to now. The command will not copy events of type "detection", "fixed", +"analysis_not_planned", or "fix_not_planned". +`, + SilenceErrors: true, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + have, want := args[0], args[1] + + have = strings.TrimSuffix(have, ".advisories.yaml") + want = strings.TrimSuffix(want, ".advisories.yaml") + + advisoryFsys := rwos.DirFS(dir) + advisoryCfgs, err := v2.NewIndex(ctx, advisoryFsys) + if err != nil { + return err + } + + oldEntry, err := advisoryCfgs.Select().WhereName(have).First() + if err != nil { + return fmt.Errorf("unable to find advisory for package %q: %w", have, err) + } + oldDoc := oldEntry.Configuration() + + shouldMergeExistings := false + newEntry, err := advisoryCfgs.Select().WhereName(want).First() + if err == nil && len(newEntry.Configuration().Advisories) > 0 { + shouldMergeExistings = true + } + + out := *oldDoc + out.Package.Name = want + out.Advisories = nil + + for _, adv := range oldDoc.Advisories { + if carried, ok := carryAdvisory(adv); ok { + out.Advisories = append(out.Advisories, carried) + } + } + + havePath := have + ".advisories.yaml" + wantPath := want + ".advisories.yaml" + + // If the new file already exists, merge the old advisories to it and re-create. + if shouldMergeExistings { + newDoc := newEntry.Configuration() + + updater := v2.NewAdvisoriesSectionUpdater(func(_ v2.Document) (v2.Advisories, error) { + return mergeExistingAdvisories(out.Advisories, newDoc.Advisories), nil + }) + + if err := newEntry.Update(ctx, updater); err != nil { + return fmt.Errorf("unable to update %q: %w", wantPath, err) + } + + // Remove the existing file to re-create it since it's already existed. + if err := advisoryCfgs.Remove(wantPath); err != nil { + return fmt.Errorf("unable to remove old file %q: %w", wantPath, err) + } + } + + if err := advisoryCfgs.Remove(havePath); err != nil { + return fmt.Errorf("unable to remove old file %q: %w", havePath, err) + } + + return advisoryCfgs.Create(ctx, wantPath, out) + }, + } + cmd.PersistentFlags().StringVarP(&dir, "dir", "d", ".", "directory containing the advisories to copy") + + return cmd +} + +// mergeExistingAdvisories merges the current advisories with the existing advisories. +func mergeExistingAdvisories(current, existing v2.Advisories) v2.Advisories { + res := make(v2.Advisories, 0, len(current)+len(existing)) + + // Add current advisories to the result and mark their IDs as seen + res = append(res, current...) + + // Add existing advisories to the result if they are not already present + for _, adv := range existing { + if _, found := res.Get(adv.ID); !found { + res = append(res, adv) + } + } + + return res +} diff --git a/pkg/configs/index.go b/pkg/configs/index.go index e6abc33f1..803d6ee82 100644 --- a/pkg/configs/index.go +++ b/pkg/configs/index.go @@ -157,6 +157,28 @@ func (i *Index[T]) Create(ctx context.Context, filepath string, cfg T) error { return nil } +// Delete deletes the configuration file at the given path. The configuration is +// also removed from the Index. +func (i *Index[T]) Remove(filepath string) error { + idx, ok := i.byPath[filepath] + if !ok { + return fmt.Errorf("unable to remove configuration: no configuration found at %q", filepath) + } + + if err := i.fsys.Remove(filepath); err != nil { + return err + } + + delete(i.byPath, filepath) + delete(i.byName, i.cfgs[idx].Name()) + + i.paths = append(i.paths[:idx], i.paths[idx+1:]...) + i.yamlRoots = append(i.yamlRoots[:idx], i.yamlRoots[idx+1:]...) + i.cfgs = append(i.cfgs[:idx], i.cfgs[idx+1:]...) + + return nil +} + // Path returns the path to the configuration file for the given name. func (i *Index[T]) Path(name string) string { idx, ok := i.byName[name] diff --git a/pkg/configs/index_test.go b/pkg/configs/index_test.go index 43f085dc8..995466c77 100644 --- a/pkg/configs/index_test.go +++ b/pkg/configs/index_test.go @@ -32,3 +32,39 @@ func TestNewIndex(t *testing.T) { assert.NotContains(t, index.paths, ".not-a-config.yaml") }) } + +func TestRemove(t *testing.T) { + ctx := context.Background() + fsys := rwos.DirFS("testdata/index-1") + + index, err := NewIndex[config.Configuration](ctx, fsys, func(ctx context.Context, path string) (*config.Configuration, error) { + return config.ParseConfiguration(ctx, path, config.WithFS(fsys)) + }) + require.NoError(t, err) + + name := "config-new" + filename := name + ".advisories.yaml" + + err = index.Create(ctx, filename, config.Configuration{ + Package: config.Package{ + Name: name, + Version: "1.0.0", + }, + }) + require.NoError(t, err) + + _, err = index.Select().WhereName(name).First() + require.NoError(t, err) + + t.Run("removes a config", func(t *testing.T) { + err := index.Remove(filename) + require.NoError(t, err) + + assert.NotContains(t, index.paths, filename) + }) + + t.Run("ensure the config is removed", func(t *testing.T) { + _, err := index.Select().WhereName(name).First() + require.Error(t, err) + }) +} diff --git a/pkg/configs/rwfs/fs.go b/pkg/configs/rwfs/fs.go index d01c8d7e1..77bc242b4 100644 --- a/pkg/configs/rwfs/fs.go +++ b/pkg/configs/rwfs/fs.go @@ -10,6 +10,7 @@ type FS interface { OpenAsWritable(name string) (File, error) Truncate(name string, size int64) error Create(name string) (File, error) + Remove(name string) error } type File interface { diff --git a/pkg/configs/rwfs/os/memfs/memfs.go b/pkg/configs/rwfs/os/memfs/memfs.go index 5ec6f632c..e984f0279 100644 --- a/pkg/configs/rwfs/os/memfs/memfs.go +++ b/pkg/configs/rwfs/os/memfs/memfs.go @@ -124,6 +124,14 @@ func (m *memWriteFS) Truncate(name string, size int64) error { return nil } +func (m *memWriteFS) Remove(name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + delete(m.data, name) + return nil +} + func (m *memWriteFS) Create(name string) (rwfs.File, error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/pkg/configs/rwfs/os/os.go b/pkg/configs/rwfs/os/os.go index 5f5194f8e..9af7c8a84 100644 --- a/pkg/configs/rwfs/os/os.go +++ b/pkg/configs/rwfs/os/os.go @@ -36,6 +36,11 @@ func (fsys FS) Truncate(name string, size int64) error { return os.Truncate(p, size) } +func (fsys FS) Remove(name string) error { + p := fsys.fullPath(name) + return os.Remove(p) +} + func (fsys FS) fullPath(name string) string { return filepath.Join(fsys.rootDir, name) } diff --git a/pkg/configs/rwfs/os/tester/tester.go b/pkg/configs/rwfs/os/tester/tester.go index 79a589b25..1c733406a 100644 --- a/pkg/configs/rwfs/os/tester/tester.go +++ b/pkg/configs/rwfs/os/tester/tester.go @@ -131,6 +131,15 @@ func (fsys *FS) Truncate(string, int64) error { return nil } +func (fsys *FS) Remove(name string) error { + if _, ok := fsys.fixtures[name]; ok { + delete(fsys.fixtures, name) + return nil + } + + return os.ErrNotExist +} + func (fsys *FS) Diff(name string) string { if tf, ok := fsys.fixtures[name]; ok { want := tf.expectedRead