From 270b3766a068233c87dcbc9c0fe76256eec5d64f Mon Sep 17 00:00:00 2001 From: Caleb Brown Date: Fri, 5 Jun 2026 08:40:25 +1000 Subject: [PATCH] Add initial malosv command for managing the repo. Includes "withdraw" and "unwithdraw" subcommands, and a library for parsing report-based arguments. Signed-off-by: Caleb Brown --- cmd/malosv/internal/cmd/cmd.go | 99 +++++ cmd/malosv/internal/cmd/unwithdraw.go | 79 ++++ cmd/malosv/internal/cmd/withdraw.go | 88 ++++ cmd/malosv/internal/reportargs/args.go | 147 +++++++ cmd/malosv/internal/reportargs/args_test.go | 216 ++++++++++ cmd/malosv/internal/reportargs/cmd.go | 27 ++ cmd/malosv/internal/reportargs/cmd_test.go | 83 ++++ cmd/malosv/internal/reportargs/helper_test.go | 20 + cmd/malosv/internal/reportargs/resolvers.go | 292 +++++++++++++ .../internal/reportargs/resolvers_test.go | 401 ++++++++++++++++++ cmd/malosv/internal/reportargs/shared_test.go | 61 +++ cmd/malosv/main.go | 29 ++ go.mod | 1 + go.sum | 2 + internal/config/config.go | 16 + internal/config/config_test.go | 47 ++ internal/report/report.go | 18 + internal/report/report_test.go | 53 +++ internal/reportio/reportio.go | 44 ++ internal/reportio/reportio_test.go | 94 ++++ 20 files changed, 1817 insertions(+) create mode 100644 cmd/malosv/internal/cmd/cmd.go create mode 100644 cmd/malosv/internal/cmd/unwithdraw.go create mode 100644 cmd/malosv/internal/cmd/withdraw.go create mode 100644 cmd/malosv/internal/reportargs/args.go create mode 100644 cmd/malosv/internal/reportargs/args_test.go create mode 100644 cmd/malosv/internal/reportargs/cmd.go create mode 100644 cmd/malosv/internal/reportargs/cmd_test.go create mode 100644 cmd/malosv/internal/reportargs/helper_test.go create mode 100644 cmd/malosv/internal/reportargs/resolvers.go create mode 100644 cmd/malosv/internal/reportargs/resolvers_test.go create mode 100644 cmd/malosv/internal/reportargs/shared_test.go create mode 100644 cmd/malosv/main.go diff --git a/cmd/malosv/internal/cmd/cmd.go b/cmd/malosv/internal/cmd/cmd.go new file mode 100644 index 00000000000..6a0b031a817 --- /dev/null +++ b/cmd/malosv/internal/cmd/cmd.go @@ -0,0 +1,99 @@ +// Copyright 2026 Malicious Packages 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 +// +// https://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 cmd + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/urfave/cli/v3" + + "github.com/ossf/malicious-packages/cmd/malosv/internal/reportargs" + "github.com/ossf/malicious-packages/internal/config" +) + +var Command *cli.Command + +func init() { + var cfg config.Config + + Command = &cli.Command{ + Name: "malosv", + Usage: "Manage the malicious packages repository", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Aliases: []string{"c"}, + Usage: "Load configuration from `FILE`", + Value: "config/config.yaml", + }, + }, + Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { + configPath := cmd.String("config") + configFile, err := os.Open(configPath) + if err != nil { + return ctx, fmt.Errorf("failed to open config file %s: %w", configPath, err) + } + defer configFile.Close() + + c, err := config.ReadYAML(configFile) + if err != nil { + return ctx, fmt.Errorf("failed reading config: %w", err) + } + log.Printf("Loaded config from %s", configPath) + + cfg = *c + ctx = cfg.NewContext(ctx) + return ctx, nil + }, + Commands: []*cli.Command{ + { + Name: "withdraw", + Usage: "Withdraw one or more reports", + Action: Withdraw, + Arguments: []cli.Argument{ + &reportargs.ReportArguments{ + Config: &cfg, + Resolvers: reportargs.AllResolvers, + BasesFn: func(cfg *config.Config) []string { return []string{cfg.MaliciousPath} }, + }, + }, + Flags: []cli.Flag{ + &cli.TimestampFlag{ + Name: "withdraw-at", + Config: cli.TimestampConfig{ + Layouts: []string{time.RFC3339}, + }, + }, + }, + }, + { + Name: "unwithdraw", + Usage: "Unwithdraw one or more reports", + Action: Unwithdraw, + Arguments: []cli.Argument{ + &reportargs.ReportArguments{ + Config: &cfg, + Resolvers: reportargs.AllResolvers, + BasesFn: func(cfg *config.Config) []string { return []string{cfg.FalsePositivePath} }, + }, + }, + }, + }, + } +} diff --git a/cmd/malosv/internal/cmd/unwithdraw.go b/cmd/malosv/internal/cmd/unwithdraw.go new file mode 100644 index 00000000000..535cda4fd5b --- /dev/null +++ b/cmd/malosv/internal/cmd/unwithdraw.go @@ -0,0 +1,79 @@ +// Copyright 2026 Malicious Packages 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 +// +// https://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 cmd + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/urfave/cli/v3" + + "github.com/ossf/malicious-packages/cmd/malosv/internal/reportargs" + "github.com/ossf/malicious-packages/internal/config" + "github.com/ossf/malicious-packages/internal/report" + "github.com/ossf/malicious-packages/internal/reportio" +) + +// Unwithdraw will safely unwithdraw the reports specified on the command line. +func Unwithdraw(ctx context.Context, cmd *cli.Command) error { + c := config.FromContext(ctx) + reports := reportargs.FromCommand(cmd) + + // Ensure there is only one report per path + var targets []string + for path, pkgReports := range reports { + if len(pkgReports) != 1 { + return fmt.Errorf("too many reports for %q (%d)", path, len(pkgReports)) + } + targets = append(targets, pkgReports...) + } + + log.Printf("Found %d target(s) for unwithdrawal", len(targets)) + + // Create a temp directory for atomic writes. We use the system temp + // directory to avoid accidentally leaving temp junk in the repository. + tempDir, err := os.MkdirTemp("", "osv-unwithdraw-*") + if err != nil { + log.Fatalf("Failed creating temp dir: %v", err) + } + defer func() { + // Clean up temp directory + if err := os.RemoveAll(tempDir); err != nil { + log.Fatalf("Failed cleaning up temp dir: %v", err) + } + }() + + for _, target := range targets { + log.Printf("Unwithdrawing %s", target) + + // Change the report to not have the withdrawn timestamp. + if err := reportio.MutateReport(target, func(r *report.Report) (bool, error) { + r.Unwithdraw() + return true, nil + }, tempDir); err != nil { + return fmt.Errorf("failed to update report %s: %w", target, err) + } + + // Move the report to the correct location for real reports. + if err := reportio.MoveReport(target, c.FalsePositivePath, c.MaliciousPath); err != nil { + return fmt.Errorf("failed to move report %s: %w", target, err) + } + } + + log.Printf("Successfully unwithdrew %d report(s)", len(targets)) + return nil +} diff --git a/cmd/malosv/internal/cmd/withdraw.go b/cmd/malosv/internal/cmd/withdraw.go new file mode 100644 index 00000000000..9cffb555e94 --- /dev/null +++ b/cmd/malosv/internal/cmd/withdraw.go @@ -0,0 +1,88 @@ +// Copyright 2026 Malicious Packages 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 +// +// https://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 cmd + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/urfave/cli/v3" + + "github.com/ossf/malicious-packages/cmd/malosv/internal/reportargs" + "github.com/ossf/malicious-packages/internal/config" + "github.com/ossf/malicious-packages/internal/report" + "github.com/ossf/malicious-packages/internal/reportio" +) + +// Withdraw will safely withdraw the reports specified on the command line. +func Withdraw(ctx context.Context, cmd *cli.Command) error { + c := config.FromContext(ctx) + reports := reportargs.FromCommand(cmd) + + // Ensure there is only one report per path + var targets []string + for path, pkgReports := range reports { + if len(pkgReports) != 1 { + return fmt.Errorf("too many reports for %q (%d)", path, len(pkgReports)) + } + targets = append(targets, pkgReports...) + } + + log.Printf("Found %d target(s) for withdrawal", len(targets)) + + // Determine the withdrawal time. + withdrawnTime := time.Now().UTC() + if cmd.IsSet("withdraw-at") { + withdrawnTime = cmd.Timestamp("withdraw-at").UTC() + } + + log.Printf("Withdrawn time: %s", withdrawnTime.Format(time.RFC3339)) + + // Create a temp directory for atomic writes. We use the system temp + // directory to avoid accidentally leaving temp junk in the repository. + tempDir, err := os.MkdirTemp("", "osv-withdraw-*") + if err != nil { + log.Fatalf("Failed creating temp dir: %v", err) + } + defer func() { + // Clean up temp directory + if err := os.RemoveAll(tempDir); err != nil { + log.Fatalf("Failed cleaning up temp dir: %v", err) + } + }() + + for _, target := range targets { + log.Printf("Withdrawing %s", target) + + // Change the report to include the withdrawn timestamp. + if err := reportio.MutateReport(target, func(r *report.Report) (bool, error) { + r.Withdraw(withdrawnTime) + return true, nil + }, tempDir); err != nil { + return fmt.Errorf("failed to update report %s: %w", target, err) + } + + // Move the report to the correct location for withdrawn reports. + if err := reportio.MoveReport(target, c.MaliciousPath, c.FalsePositivePath); err != nil { + return fmt.Errorf("failed to move report %s: %w", target, err) + } + } + + log.Printf("Successfully withdrew %d report(s)", len(targets)) + return nil +} diff --git a/cmd/malosv/internal/reportargs/args.go b/cmd/malosv/internal/reportargs/args.go new file mode 100644 index 00000000000..cdf21061479 --- /dev/null +++ b/cmd/malosv/internal/reportargs/args.go @@ -0,0 +1,147 @@ +// Copyright 2026 Malicious Packages 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 +// +// https://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 reportargs + +import ( + "fmt" + "log" + "slices" + "strings" + + "github.com/ossf/malicious-packages/internal/config" +) + +// ReportArguments is an implementation of cli.Arguments used for parsing +// report arguments from the command line. +// +// Example: +// +// var cfg config.Config +// +// cli.Command{ +// Arguments: []cli.Argument{ +// &reportargs.ReportArguments{ +// Config: &cfg, +// Resolvers: reportargs.AllResolvers, +// BasesFn: func(cfg *config.Config) []string { return []string{cfg.MaliciousPath} }, +// }, +// }, +// } +type ReportArguments struct { + // Config is the processed config. When this struct is created the Config has + // not been processed, so this should point to where the parsed config will be + // stored after it has been loaded. + Config *config.Config + + // BasesFn returns the base paths for where the reports are located. This is + // a function because at the time this struct is created the Config has not + // been processed. + BasesFn func(*config.Config) []string + + // Resolvers are the set of enabled resolvers. This is a bit-wise set of + // ResolverFlags. + // + // Example: + // + // ResolveArguments{ + // Resolvers: ResolveByFile | ResolveByDirectory + // } + // + // Use AllResolvers if all the resolvers are desired. + Resolvers ResolverFlags + + // IgnoreUnparsed will cause Parse to return any unprocessed arguments rather + // than returning an error. + IgnoreUnparsed bool + + // Reports is a map of the report path, and a slice of the + // filenames of the OSV reports in that path. The report path is the + // "ecosystem/package_name" fragment, and may include OSV filenames in + // different base directories. + reports map[string][]string +} + +// Implements the cli.Argument interface. +// +// Always returns false. +func (a *ReportArguments) HasName(name string) bool { + // We don't need a name. + return false +} + +// Implements the cli.Argument interface. +func (a *ReportArguments) Parse(args []string) ([]string, error) { + bases := a.BasesFn(a.Config) + + a.reports = make(map[string][]string) + + // This loop steps through the resolver flags in r and checks if each is set. + // The index i corresponds to each flag's bit position. + for _, r := range resolvers { + if !r.IsEnabled(a.Resolvers) { + // Resolver is not enabled. So skip. + continue + } + log.Printf("Resolving by %s...", r.name) + + reports, unused, err := r.Resolve(args, bases) + if err != nil { + return nil, fmt.Errorf("resolver %v failed: %w", r.name, err) + } + for key, paths := range reports { + for _, path := range paths { + if slices.Contains(a.reports[key], path) { + return nil, fmt.Errorf("duplicate report specified: %q", path) + } + } + a.reports[key] = append(a.reports[key], paths...) + } + args = unused + if len(args) == 0 { + break + } + } + + if !a.IgnoreUnparsed && len(args) > 0 { + return nil, fmt.Errorf("failed to find reports: %v", args) + } + + return args, nil +} + +// Implements the cli.Argument interface. +func (a *ReportArguments) Usage() string { + var parts []string + for _, r := range resolvers { + if !r.IsEnabled(a.Resolvers) { + continue + } + parts = append(parts, r.argName) + } + argName := strings.Join(parts, "|") + return fmt.Sprintf("<%s> [<%s> ...]", argName, argName) +} + +func (a *ReportArguments) Reports() map[string][]string { + if a == nil { + return nil + } + return a.reports +} + +// Implements the cli.Argument interface. +func (a *ReportArguments) Get() any { + return a.Reports() +} diff --git a/cmd/malosv/internal/reportargs/args_test.go b/cmd/malosv/internal/reportargs/args_test.go new file mode 100644 index 00000000000..5d9bb849610 --- /dev/null +++ b/cmd/malosv/internal/reportargs/args_test.go @@ -0,0 +1,216 @@ +// Copyright 2026 Malicious Packages 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 +// +// https://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 reportargs_test + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/ossf/malicious-packages/cmd/malosv/internal/reportargs" + "github.com/ossf/malicious-packages/internal/config" +) + +func TestReportArguments_HasName(t *testing.T) { + a := &reportargs.ReportArguments{} + if got := a.HasName("anything"); got { + t.Errorf("HasName(%q) = %t; want false", "anything", got) + } +} + +func TestReportArguments_Usage(t *testing.T) { + tests := []struct { + name string + resolvers reportargs.ResolverFlags + want string + }{ + { + name: "only file resolver", + resolvers: reportargs.ResolveByFile, + want: " [ ...]", + }, + { + name: "file and directory resolvers", + resolvers: reportargs.ResolveByFile | reportargs.ResolveByDirectory, + want: " [ ...]", + }, + { + name: "all resolvers", + resolvers: reportargs.AllResolvers, + want: " [ ...]", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := &reportargs.ReportArguments{Resolvers: tt.resolvers} + if got := a.Usage(); got != tt.want { + t.Errorf("Usage() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestReportArguments_GetAndReports(t *testing.T) { + var a *reportargs.ReportArguments + if got := a.Reports(); got != nil { + t.Errorf("Reports() on nil receiver = %v; want nil", got) + } + gotNil := a.Get() + if gotNil == nil { + t.Error("Get() on nil receiver = nil; want typed nil map") + } + m, ok := gotNil.(map[string][]string) + if !ok { + t.Errorf("Get() on nil receiver returned type %T; want map[string][]string", gotNil) + } + if m != nil { + t.Errorf("Get() on nil receiver wrapped map = %v; want nil map", m) + } + + a = &reportargs.ReportArguments{} + reportsMap := map[string][]string{ + "npm/package": {"/path/to/report.json"}, + } + a.SetReportsForTesting(reportsMap) + + if got := a.Reports(); !reflect.DeepEqual(got, reportsMap) { + t.Errorf("Reports() = %v, want %v", got, reportsMap) + } + if got := a.Get(); !reflect.DeepEqual(got, reportsMap) { + t.Errorf("Get() = %v, want %v", got, reportsMap) + } +} + +func TestReportArguments_Parse(t *testing.T) { + _, bases := setupTestDir(t) + base1 := bases[0] + fileAbs := filepath.Join(base1, "npm/package/MAL-1.json") + + cfg := &config.Config{} + basesFn := func(_ *config.Config) []string { + return []string{base1} + } + + tests := []struct { + name string + resolvers reportargs.ResolverFlags + ignoreUnparsed bool + args []string + wantReports map[string][]string + wantUnused []string + wantErr bool + }{ + { + name: "happy path - resolve by file", + resolvers: reportargs.ResolveByFile, + ignoreUnparsed: false, + args: []string{fileAbs}, + wantReports: map[string][]string{ + "npm/package": {fileAbs}, + }, + wantUnused: nil, + wantErr: false, + }, + { + name: "unparsed arguments with error", + resolvers: reportargs.ResolveByFile, + ignoreUnparsed: false, + args: []string{fileAbs, "unparsed-arg"}, + wantReports: nil, + wantUnused: nil, + wantErr: true, + }, + { + name: "unparsed arguments ignored", + resolvers: reportargs.ResolveByFile, + ignoreUnparsed: true, + args: []string{fileAbs, "unparsed-arg"}, + wantReports: map[string][]string{ + "npm/package": {fileAbs}, + }, + wantUnused: []string{"unparsed-arg"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := &reportargs.ReportArguments{ + Config: cfg, + BasesFn: basesFn, + Resolvers: tt.resolvers, + IgnoreUnparsed: tt.ignoreUnparsed, + } + unused, err := a.Parse(tt.args) + if (err != nil) != tt.wantErr { + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err != nil { + return + } + if !reflect.DeepEqual(unused, tt.wantUnused) { + t.Errorf("Parse() unused = %v, want %v", unused, tt.wantUnused) + } + if !reflect.DeepEqual(a.Reports(), tt.wantReports) { + t.Errorf("Parse() reports = %v, want %v", a.Reports(), tt.wantReports) + } + }) + } +} + +func TestReportArguments_Parse_DuplicateReport(t *testing.T) { + _, bases := setupTestDir(t) + base1 := bases[0] + fileAbs := filepath.Join(base1, "npm/package/MAL-1.json") + + cfg := &config.Config{} + basesFn := func(_ *config.Config) []string { + return []string{base1} + } + + a := &reportargs.ReportArguments{ + Config: cfg, + BasesFn: basesFn, + Resolvers: reportargs.ResolveByFile | reportargs.ResolveByEcosystemAndName, + } + + // We pass the file as both a filename candidate and an ecosystem/package candidate. + _, err := a.Parse([]string{fileAbs, "npm/package"}) + if err == nil { + t.Error("Parse() expected duplicate report error, got nil") + } +} + +func TestReportArguments_Parse_ResolverError(t *testing.T) { + cfg := &config.Config{} + basesFn := func(_ *config.Config) []string { + return []string{"/non-existent-directory-to-cause-error"} + } + a := &reportargs.ReportArguments{ + Config: cfg, + BasesFn: basesFn, + Resolvers: reportargs.ResolveByID, + } + _, err := a.Parse([]string{"MAL-1"}) + if err == nil { + t.Error("Parse() expected error when walking non-existent directory, got nil") + } + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("Parse() expected os.ErrNotExist, got %v", err) + } +} diff --git a/cmd/malosv/internal/reportargs/cmd.go b/cmd/malosv/internal/reportargs/cmd.go new file mode 100644 index 00000000000..c7a5637c34f --- /dev/null +++ b/cmd/malosv/internal/reportargs/cmd.go @@ -0,0 +1,27 @@ +// Copyright 2026 Malicious Packages 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 +// +// https://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 reportargs + +import "github.com/urfave/cli/v3" + +// FromCommand will return report arguments from cmd. +func FromCommand(cmd *cli.Command) map[string][]string { + for _, arg := range cmd.Arguments { + if reports, ok := arg.(*ReportArguments); ok { + return reports.Reports() + } + } + return nil +} diff --git a/cmd/malosv/internal/reportargs/cmd_test.go b/cmd/malosv/internal/reportargs/cmd_test.go new file mode 100644 index 00000000000..25141b625b5 --- /dev/null +++ b/cmd/malosv/internal/reportargs/cmd_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 Malicious Packages 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 +// +// https://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 reportargs_test + +import ( + "reflect" + "testing" + + "github.com/urfave/cli/v3" + + "github.com/ossf/malicious-packages/cmd/malosv/internal/reportargs" +) + +// A dummy argument that implements cli.Argument to test mixed argument lists. +type dummyArgument struct{} + +func (d *dummyArgument) HasName(name string) bool { return false } +func (d *dummyArgument) Parse(args []string) ([]string, error) { return args, nil } +func (d *dummyArgument) Usage() string { return "" } +func (d *dummyArgument) Get() any { return nil } + +func TestFromCommand(t *testing.T) { + reportsMap := map[string][]string{ + "npm/package": {"/path/to/report1.json"}, + } + + a := &reportargs.ReportArguments{} + a.SetReportsForTesting(reportsMap) + + tests := []struct { + name string + cmd *cli.Command + want map[string][]string + }{ + { + name: "command with ReportArguments", + cmd: &cli.Command{ + Arguments: []cli.Argument{ + &dummyArgument{}, + a, + }, + }, + want: reportsMap, + }, + { + name: "command without ReportArguments", + cmd: &cli.Command{ + Arguments: []cli.Argument{ + &dummyArgument{}, + }, + }, + want: nil, + }, + { + name: "command with empty arguments list", + cmd: &cli.Command{ + Arguments: []cli.Argument{}, + }, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := reportargs.FromCommand(tt.cmd) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("FromCommand() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/cmd/malosv/internal/reportargs/helper_test.go b/cmd/malosv/internal/reportargs/helper_test.go new file mode 100644 index 00000000000..532f86f6ba0 --- /dev/null +++ b/cmd/malosv/internal/reportargs/helper_test.go @@ -0,0 +1,20 @@ +// Copyright 2026 Malicious Packages 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 +// +// https://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 reportargs + +// SetReportsForTesting allows external tests to set the unexported reports field. +func (a *ReportArguments) SetReportsForTesting(reports map[string][]string) { + a.reports = reports +} diff --git a/cmd/malosv/internal/reportargs/resolvers.go b/cmd/malosv/internal/reportargs/resolvers.go new file mode 100644 index 00000000000..011d67ef0e6 --- /dev/null +++ b/cmd/malosv/internal/reportargs/resolvers.go @@ -0,0 +1,292 @@ +// Copyright 2026 Malicious Packages 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 +// +// https://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 reportargs + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/ossf/malicious-packages/internal/reportio" +) + +type ResolverFlags int + +const ( + ResolveByFile ResolverFlags = 1 << iota + ResolveByDirectory + ResolveByEcosystemAndName + ResolveByID +) + +const ( + AllResolvers = ResolveByFile | ResolveByDirectory | ResolveByEcosystemAndName | ResolveByID +) + +type reportResolveFunc func(paths, bases []string) (map[string][]string, []string, error) + +// resolvers are the valid set of report resolver funcs. They must correspond to +// the ResolverFlags above exactly, and appear in the same order. +// +// The resolvers are configured using flags, rather than function refs because +// the order the resolvers are run in is important. They are ordered from the +// least expensive to the most expensive to run - `byReportFile` only considers +// a single file, and `byID` walks each specified base. +var resolvers = []resolver{ + { + flag: ResolveByFile, + fn: byReportFile, + name: "File", + argName: "filename", + }, + { + flag: ResolveByDirectory, + fn: byDirectoryPath, + name: "Directory", + argName: "dirname", + }, + { + flag: ResolveByEcosystemAndName, + fn: byEcosystemAndPackage, + name: "Ecosystem and Package", + argName: "ecosystem/package", + }, + { + flag: ResolveByID, + fn: byID, + name: "Report ID", + argName: "report_id", + }, +} + +type resolver struct { + flag ResolverFlags + fn reportResolveFunc + name string + argName string +} + +func (r resolver) IsEnabled(flag ResolverFlags) bool { + return r.flag&flag != 0 +} + +func (r resolver) Resolve(paths, bases []string) (map[string][]string, []string, error) { + return r.fn(paths, bases) +} + +func byID(candidates, bases []string) (map[string][]string, []string, error) { + var ids []string + var unused []string + for _, c := range candidates { + if strings.ContainsRune(c, filepath.Separator) { + unused = append(unused, c) + } else { + ids = append(ids, c) + } + } + + if len(ids) == 0 { + // This function is expensive, so abort if there is no work to do. + return nil, unused, nil + } + + var found []string + reports := make(map[string][]string) + for _, base := range bases { + err := filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if len(ids) == len(found) { + // If we have found all the reports stop immediately. + return filepath.SkipAll + } + if d.IsDir() { + // Skip if d is a directory. + return nil + } + if !reportio.IsPossibleReport(d.Name(), d.Type()) { + return nil + } + + dir, filename := filepath.Split(path) + // Get the name minus the extension, which should be the ID. + name := strings.TrimSuffix(filename, filepath.Ext(filename)) + if slices.Contains(ids, name) { + rel, err := filepath.Rel(base, dir) + if err != nil { + return fmt.Errorf("failed to get relative path for %q: %w", dir, err) + } + found = append(found, name) + reports[rel] = append(reports[rel], path) + } + return nil + }) + if err != nil { + return nil, nil, err + } + } + // Stick all the unfound ids into the unused bucket. + if len(found) != len(ids) { + for _, id := range ids { + if !slices.Contains(found, id) { + unused = append(unused, id) + } + } + } + return reports, unused, nil +} + +func byEcosystemAndPackage(candidates, bases []string) (map[string][]string, []string, error) { + var unused []string + reports := make(map[string][]string) + + for _, c := range candidates { + if !strings.ContainsRune(c, filepath.Separator) { + // Expect at least one slash in the candidate. + unused = append(unused, c) + continue + } + if err := reportio.ValidatePath(c); err != nil { + // Do more validation on the path, and skip if it failed. + unused = append(unused, c) + continue + } + paths, err := reportio.ReportsInPaths(c, bases) + if err != nil { + return nil, nil, fmt.Errorf("failed getting reports for %q: %w", c, err) + } + if len(paths) == 0 { + // No reports in this path. + unused = append(unused, c) + continue + } + reports[c] = append(reports[c], paths...) + } + return reports, unused, nil +} + +func byDirectoryPath(candidates, bases []string) (map[string][]string, []string, error) { + var unused []string + reports := make(map[string][]string) + + for _, c := range candidates { + if !strings.ContainsRune(c, filepath.Separator) { + // Expect at least one slash in the candidate. + unused = append(unused, c) + continue + } + info, err := os.Stat(c) + if os.IsNotExist(err) { + // Expect the directory to exist. + unused = append(unused, c) + continue + } else if err != nil { + // Some other error occurred. + return nil, nil, fmt.Errorf("failed to stat %q: %w", c, err) + } + if !info.IsDir() { + // Expect the directory to actually be a directory. + unused = append(unused, c) + continue + } + dir, err := filepath.Abs(c) + if err != nil { + return nil, nil, fmt.Errorf("failed to get absolute path for %q: %w", c, err) + } + + relPath := "" + for _, base := range bases { + rel, err := filepath.Rel(base, dir) + if err == nil && !strings.HasPrefix(filepath.ToSlash(rel), "../") { + relPath = rel + break + } + } + if relPath == "" { + // Expect a relative path to a base. + unused = append(unused, c) + continue + } + + paths, err := reportio.ReportsInPaths(dir, nil) + if err != nil { + return nil, nil, fmt.Errorf("failed getting reports for %q: %w", c, err) + } + if len(paths) == 0 { + // No reports in this path. + unused = append(unused, c) + continue + } + reports[relPath] = append(reports[relPath], paths...) + } + return reports, unused, nil +} + +func byReportFile(candidates, bases []string) (map[string][]string, []string, error) { + var unused []string + reports := make(map[string][]string) + + for _, c := range candidates { + if !strings.ContainsRune(c, filepath.Separator) { + // Expect at least one slash in the candidate. + unused = append(unused, c) + continue + } + info, err := os.Stat(c) + if os.IsNotExist(err) { + // Expect the directory to exist. + unused = append(unused, c) + continue + } else if err != nil { + // Some other error occurred. + return nil, nil, fmt.Errorf("failed to stat %q: %w", c, err) + } + if !info.Mode().IsRegular() { + // Expect the file to actually be a file. + unused = append(unused, c) + continue + } + if !reportio.IsPossibleReport(info.Name(), info.Mode()) { + // Expect the file to be a possible report. + unused = append(unused, c) + continue + } + filename, err := filepath.Abs(c) + if err != nil { + return nil, nil, fmt.Errorf("failed to get absolute path for %q: %w", c, err) + } + + relFilename := "" + for _, base := range bases { + rel, err := filepath.Rel(base, filename) + if err == nil && !strings.HasPrefix(filepath.ToSlash(rel), "../") { + relFilename = rel + break + } + } + if relFilename == "" { + // Expect a relative path to a base. + unused = append(unused, c) + continue + } + relPath := filepath.Dir(relFilename) + reports[relPath] = append(reports[relPath], filename) + } + return reports, unused, nil +} diff --git a/cmd/malosv/internal/reportargs/resolvers_test.go b/cmd/malosv/internal/reportargs/resolvers_test.go new file mode 100644 index 00000000000..bfe84e4b1fd --- /dev/null +++ b/cmd/malosv/internal/reportargs/resolvers_test.go @@ -0,0 +1,401 @@ +// Copyright 2026 Malicious Packages 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 +// +// https://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 reportargs_test + +import ( + "os" + "path/filepath" + "reflect" + "slices" + "testing" + + "github.com/ossf/malicious-packages/cmd/malosv/internal/reportargs" + "github.com/ossf/malicious-packages/internal/config" +) + +func parseWithResolver(resolver reportargs.ResolverFlags, candidates, bases []string) (map[string][]string, []string, error) { + a := &reportargs.ReportArguments{ + BasesFn: func(*config.Config) []string { return bases }, + Resolvers: resolver, + IgnoreUnparsed: true, + } + unused, err := a.Parse(candidates) + return a.Reports(), unused, err +} + +func TestByReportFile(t *testing.T) { + tmpDir, bases := setupTestDir(t) + base1 := bases[0] + base2 := bases[1] + + mal1Abs := filepath.Join(base1, "npm/package/MAL-1.json") + mal2Abs := filepath.Join(base2, "pypi/package/MAL-2.json") + readmeAbs := filepath.Join(base1, "npm/package/README.md") + dotAbs := filepath.Join(base1, "npm/package/.dotfile") + subDirAbs := filepath.Join(base1, "npm/package/subdir") + nonExistentAbs := filepath.Join(base1, "npm/package/MAL-999.json") + outsideAbs := filepath.Join(tmpDir, "outside.json") + if err := os.WriteFile(outsideAbs, []byte("{}"), 0o600); err != nil { + t.Fatalf("failed to create outside file: %v", err) + } + + tests := []struct { + name string + candidates []string + want map[string][]string + wantUnused []string + wantErr bool + }{ + { + name: "happy path - single file", + candidates: []string{mal1Abs}, + want: map[string][]string{ + "npm/package": {mal1Abs}, + }, + wantUnused: nil, + }, + { + name: "happy path - multiple files in different bases", + candidates: []string{mal1Abs, mal2Abs}, + want: map[string][]string{ + "npm/package": {mal1Abs}, + "pypi/package": {mal2Abs}, + }, + wantUnused: nil, + }, + { + name: "no separator in candidate", + candidates: []string{"MAL-1.json"}, + want: map[string][]string{}, + wantUnused: []string{"MAL-1.json"}, + }, + { + name: "non-existent file", + candidates: []string{nonExistentAbs}, + want: map[string][]string{}, + wantUnused: []string{nonExistentAbs}, + }, + { + name: "not regular file (directory)", + candidates: []string{subDirAbs}, + want: map[string][]string{}, + wantUnused: []string{subDirAbs}, + }, + { + name: "invalid name (README)", + candidates: []string{readmeAbs}, + want: map[string][]string{}, + wantUnused: []string{readmeAbs}, + }, + { + name: "invalid name (dotfile)", + candidates: []string{dotAbs}, + want: map[string][]string{}, + wantUnused: []string{dotAbs}, + }, + { + name: "outside bases", + candidates: []string{outsideAbs}, + want: map[string][]string{}, + wantUnused: []string{outsideAbs}, + }, + { + name: "mixed candidates", + candidates: []string{mal1Abs, nonExistentAbs, mal2Abs, "no-sep"}, + want: map[string][]string{ + "npm/package": {mal1Abs}, + "pypi/package": {mal2Abs}, + }, + wantUnused: []string{nonExistentAbs, "no-sep"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, unused, err := parseWithResolver(reportargs.ResolveByFile, tt.candidates, bases) + if (err != nil) != tt.wantErr { + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Parse() reports = %v, want %v", got, tt.want) + } + if !reflect.DeepEqual(unused, tt.wantUnused) { + t.Errorf("Parse() unused = %v, want %v", unused, tt.wantUnused) + } + }) + } +} + +func TestByDirectoryPath(t *testing.T) { + tmpDir, bases := setupTestDir(t) + base1 := bases[0] + base2 := bases[1] + + dir1Abs := filepath.Join(base1, "npm/package") + dir2Abs := filepath.Join(base2, "pypi/package") + nonExistentAbs := filepath.Join(base1, "npm/not-exist") + outsideAbs := filepath.Join(tmpDir, "outside") + if err := os.MkdirAll(outsideAbs, 0o755); err != nil { + t.Fatalf("failed to create outside dir: %v", err) + } + + mal1Abs := filepath.Join(base1, "npm/package/MAL-1.json") + mal2Abs := filepath.Join(base2, "pypi/package/MAL-2.json") + + tests := []struct { + name string + candidates []string + want map[string][]string + wantUnused []string + wantErr bool + }{ + { + name: "happy path - single directory", + candidates: []string{dir1Abs}, + want: map[string][]string{ + "npm/package": {mal1Abs}, + }, + wantUnused: nil, + }, + { + name: "happy path - multiple directories", + candidates: []string{dir1Abs, dir2Abs}, + want: map[string][]string{ + "npm/package": {mal1Abs}, + "pypi/package": {mal2Abs}, + }, + wantUnused: nil, + }, + { + name: "no separator in candidate", + candidates: []string{"package"}, + want: map[string][]string{}, + wantUnused: []string{"package"}, + }, + { + name: "non-existent directory", + candidates: []string{nonExistentAbs}, + want: map[string][]string{}, + wantUnused: []string{nonExistentAbs}, + }, + { + name: "is not a directory (file instead)", + candidates: []string{mal1Abs}, + want: map[string][]string{}, + wantUnused: []string{mal1Abs}, + }, + { + name: "outside bases", + candidates: []string{outsideAbs}, + want: map[string][]string{}, + wantUnused: []string{outsideAbs}, + }, + { + name: "mixed candidates", + candidates: []string{dir1Abs, nonExistentAbs, dir2Abs}, + want: map[string][]string{ + "npm/package": {mal1Abs}, + "pypi/package": {mal2Abs}, + }, + wantUnused: []string{nonExistentAbs}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, unused, err := parseWithResolver(reportargs.ResolveByDirectory, tt.candidates, bases) + if (err != nil) != tt.wantErr { + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Parse() reports = %v, want %v", got, tt.want) + } + if !reflect.DeepEqual(unused, tt.wantUnused) { + t.Errorf("Parse() unused = %v, want %v", unused, tt.wantUnused) + } + }) + } +} + +func TestByEcosystemAndPackage(t *testing.T) { + _, bases := setupTestDir(t) + base1 := bases[0] + base2 := bases[1] + + mal1Abs := filepath.Join(base1, "npm/package/MAL-1.json") + mal2Abs := filepath.Join(base2, "pypi/package/MAL-2.json") + + tests := []struct { + name string + candidates []string + want map[string][]string + wantUnused []string + wantErr bool + }{ + { + name: "happy path - single ecosystem/package", + candidates: []string{"npm/package"}, + want: map[string][]string{ + "npm/package": {mal1Abs}, + }, + wantUnused: nil, + }, + { + name: "happy path - multiple ecosystem/package", + candidates: []string{"npm/package", "pypi/package"}, + want: map[string][]string{ + "npm/package": {mal1Abs}, + "pypi/package": {mal2Abs}, + }, + wantUnused: nil, + }, + { + name: "no separator in candidate", + candidates: []string{"package"}, + want: map[string][]string{}, + wantUnused: []string{"package"}, + }, + { + name: "invalid path structure", + candidates: []string{"/absolute/path"}, + want: map[string][]string{}, + wantUnused: []string{"/absolute/path"}, + }, + { + name: "invalid path structure (normalizes to dot)", + candidates: []string{"a/../b"}, + want: map[string][]string{}, + wantUnused: []string{"a/../b"}, + }, + { + name: "no reports in path", + candidates: []string{"npm/empty-package"}, + want: map[string][]string{}, + wantUnused: []string{"npm/empty-package"}, + }, + { + name: "mixed candidates", + candidates: []string{"npm/package", "npm/empty-package", "pypi/package"}, + want: map[string][]string{ + "npm/package": {mal1Abs}, + "pypi/package": {mal2Abs}, + }, + wantUnused: []string{"npm/empty-package"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, unused, err := parseWithResolver(reportargs.ResolveByEcosystemAndName, tt.candidates, bases) + if (err != nil) != tt.wantErr { + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Parse() reports = %v, want %v", got, tt.want) + } + if !reflect.DeepEqual(unused, tt.wantUnused) { + t.Errorf("Parse() unused = %v, want %v", unused, tt.wantUnused) + } + }) + } +} + +func TestByID(t *testing.T) { + _, bases := setupTestDir(t) + base1 := bases[0] + base2 := bases[1] + + mal1Abs := filepath.Join(base1, "npm/package/MAL-1.json") + mal2Abs := filepath.Join(base2, "pypi/package/MAL-2.json") + + tests := []struct { + name string + candidates []string + want map[string][]string + wantUnused []string + wantErr bool + }{ + { + name: "happy path - single ID", + candidates: []string{"MAL-1"}, + want: map[string][]string{ + "npm/package": {mal1Abs}, + }, + wantUnused: nil, + }, + { + name: "happy path - multiple IDs in different bases", + candidates: []string{"MAL-1", "MAL-2"}, + want: map[string][]string{ + "npm/package": {mal1Abs}, + "pypi/package": {mal2Abs}, + }, + wantUnused: nil, + }, + { + name: "candidate with separator is skipped", + candidates: []string{"npm/MAL-1"}, + want: map[string][]string{}, + wantUnused: []string{"npm/MAL-1"}, + }, + { + name: "ID not found", + candidates: []string{"MAL-999"}, + want: map[string][]string{}, + wantUnused: []string{"MAL-999"}, + }, + { + name: "mixed candidates", + candidates: []string{"MAL-1", "MAL-999", "MAL-2", "npm/MAL-1"}, + want: map[string][]string{ + "npm/package": {mal1Abs}, + "pypi/package": {mal2Abs}, + }, + wantUnused: []string{"npm/MAL-1", "MAL-999"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, unused, err := parseWithResolver(reportargs.ResolveByID, tt.candidates, bases) + if (err != nil) != tt.wantErr { + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // Sort slices in got maps because filepath.WalkDir order is not guaranteed. + for k := range got { + slices.Sort(got[k]) + } + for k := range tt.want { + slices.Sort(tt.want[k]) + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Parse() reports = %v, want %v", got, tt.want) + } + + // Sort unused as well, just in case. + slices.Sort(unused) + slices.Sort(tt.wantUnused) + if !reflect.DeepEqual(unused, tt.wantUnused) { + t.Errorf("Parse() unused = %v, want %v", unused, tt.wantUnused) + } + }) + } +} diff --git a/cmd/malosv/internal/reportargs/shared_test.go b/cmd/malosv/internal/reportargs/shared_test.go new file mode 100644 index 00000000000..354e1e12859 --- /dev/null +++ b/cmd/malosv/internal/reportargs/shared_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 Malicious Packages 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 +// +// https://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 reportargs_test + +import ( + "os" + "path/filepath" + "testing" +) + +func setupTestDir(t *testing.T) (string, []string) { + t.Helper() + dir := t.TempDir() + + base1 := filepath.Join(dir, "base1") + base2 := filepath.Join(dir, "base2") + + for _, b := range []string{base1, base2} { + if err := os.MkdirAll(b, 0o755); err != nil { + t.Fatalf("failed to create base dir %s: %v", b, err) + } + } + + // Create test files + files := []string{ + "base1/npm/package/MAL-1.json", + "base1/npm/package/README.md", + "base1/npm/package/.dotfile", + "base2/pypi/package/MAL-2.json", + } + + for _, f := range files { + fullPath := filepath.Join(dir, f) + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + t.Fatalf("failed to create dir for %s: %v", f, err) + } + if err := os.WriteFile(fullPath, []byte("{}"), 0o600); err != nil { + t.Fatalf("failed to write file %s: %v", f, err) + } + } + + // Create a subdirectory inside base1/npm/package (non-regular file test) + subDir := filepath.Join(base1, "npm/package/subdir") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatalf("failed to create subdir: %v", err) + } + + return dir, []string{base1, base2} +} diff --git a/cmd/malosv/main.go b/cmd/malosv/main.go new file mode 100644 index 00000000000..b5d9b877aa4 --- /dev/null +++ b/cmd/malosv/main.go @@ -0,0 +1,29 @@ +// Copyright 2026 Malicious Packages 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 +// +// https://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 main + +import ( + "context" + "log" + "os" + + "github.com/ossf/malicious-packages/cmd/malosv/internal/cmd" +) + +func main() { + if err := cmd.Command.Run(context.Background(), os.Args); err != nil { + log.Fatal(err) + } +} diff --git a/go.mod b/go.mod index 1e11f924d30..88c08da7d6a 100644 --- a/go.mod +++ b/go.mod @@ -73,6 +73,7 @@ require ( github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/urfave/cli/v3 v3.9.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect diff --git a/go.sum b/go.sum index d3711c2a148..d5960f829d2 100644 --- a/go.sum +++ b/go.sum @@ -188,6 +188,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/urfave/cli/v3 v3.9.0 h1:AV9lIiPv3ukYnxunaCUsHnEozptYmDN2F0+yWqLMn/c= +github.com/urfave/cli/v3 v3.9.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/internal/config/config.go b/internal/config/config.go index 6c077364337..65c084bacad 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,7 @@ package config import ( + "context" "errors" "fmt" "io" @@ -113,3 +114,18 @@ func ReadYAML(r io.Reader) (*Config, error) { } return c, nil } + +type contextKeyType struct{} + +var contextKey contextKeyType + +func (c *Config) NewContext(ctx context.Context) context.Context { + return context.WithValue(ctx, contextKey, c) +} + +func FromContext(ctx context.Context) *Config { + if c, ok := ctx.Value(contextKey).(*Config); ok { + return c + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ae22dfd87eb..5780d6f7ca0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -143,3 +143,50 @@ func getTestConfig() *config.Config { }, } } + +func TestInit_NoUnmergablePath(t *testing.T) { + c := getTestConfig() + c.UnmergablePath = "" + if err := c.Init(); err == nil { + t.Error("Init() = nil; want an error") + } +} + +func TestGetSource(t *testing.T) { + c := getTestConfig() + s1 := c.GetSource("source1") + if s1 == nil || s1.ID != "source1" { + t.Errorf("GetSource(\"source1\") = %v; want source with ID \"source1\"", s1) + } +} + +func TestGetSource_MissingID(t *testing.T) { + c := getTestConfig() + sMissing := c.GetSource("nonexistent") + if sMissing != nil { + t.Errorf("GetSource(\"nonexistent\") = %v; want nil", sMissing) + } +} + +func TestContextRoundTrip(t *testing.T) { + c := getTestConfig() + ctx := t.Context() + + // Set config + ctx = c.NewContext(ctx) + + // After setting, FromContext should return the config + got := config.FromContext(ctx) + if got != c { + t.Errorf("FromContext(ctx) = %v; want %v", got, c) + } +} + +func TestFromContext_Unset(t *testing.T) { + ctx := t.Context() + + // Before setting, FromContext should return nil + if got := config.FromContext(ctx); got != nil { + t.Errorf("FromContext(empty) = %v; want nil", got) + } +} diff --git a/internal/report/report.go b/internal/report/report.go index 223fbd7317e..5cb0b3cd163 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -245,6 +245,24 @@ func (r *Report) IsWithdrawn() bool { return !r.raw.Withdrawn.IsZero() } +// Withdraw marks the report as withdrawn at the given time. +// It also updates the modified time to the same time. +func (r *Report) Withdraw(t time.Time) { + r.raw.Withdrawn = t + r.raw.Modified = t +} + +// Unwithdraw marks the report as unwithdrawn. +func (r *Report) Unwithdraw() { + r.raw.Withdrawn = time.Time{} + r.UpdateModified() +} + +// UpdateModified sets the modified time to the current time. +func (r *Report) UpdateModified() { + r.raw.Modified = time.Now().UTC() +} + // Published returns the published time for the report. func (r *Report) Published() time.Time { return r.raw.Published diff --git a/internal/report/report_test.go b/internal/report/report_test.go index 79125ebf583..8b0111ea010 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -503,3 +503,56 @@ func TestReport_Split_WithTooManyOrigins(t *testing.T) { t.Fatalf("Split() = %v; want %v", err, report.ErrSplitting) } } + +func TestWithdrawal(t *testing.T) { + r := testReport(osvschema.EcosystemNPM, "example") + + // Initially should not be withdrawn + if r.IsWithdrawn() { + t.Error("IsWithdrawn() = true; want false") + } + + now := time.Now().Truncate(time.Second).UTC() + r.Withdraw(now) + + if !r.IsWithdrawn() { + t.Error("IsWithdrawn() = false; want true") + } + if got := r.Vuln().Withdrawn; !got.Equal(now) { + t.Errorf("Withdrawn time = %v; want %v", got, now) + } + if got := r.Vuln().Modified; !got.Equal(now) { + t.Errorf("Modified time = %v; want %v", got, now) + } +} + +func TestUnwithdrawal(t *testing.T) { + r := testReport(osvschema.EcosystemNPM, "example") + + // Initially should be withdrawn. + r.Withdraw(time.Now().Truncate(time.Second).UTC()) + if !r.IsWithdrawn() { + t.Error("IsWithdrawn() = false; want true") + } + + r.Unwithdraw() + if r.IsWithdrawn() { + t.Error("After Unwithdraw: IsWithdrawn() = true; want false") + } + if got := r.Vuln().Withdrawn; !got.IsZero() { + t.Errorf("Withdrawn time = %v; want zero time", got) + } +} + +func TestUpdateModified(t *testing.T) { + r := testReport(osvschema.EcosystemNPM, "example") + oldModified := r.Vuln().Modified + + time.Sleep(10 * time.Millisecond) + r.UpdateModified() + + newModified := r.Vuln().Modified + if !newModified.After(oldModified) { + t.Errorf("UpdateModified didn't advance modified time: %v <= %v", newModified, oldModified) + } +} diff --git a/internal/reportio/reportio.go b/internal/reportio/reportio.go index 2ffa251f44a..63427ddc8ba 100644 --- a/internal/reportio/reportio.go +++ b/internal/reportio/reportio.go @@ -21,6 +21,8 @@ import ( "os" "path/filepath" + "github.com/google/renameio" + "github.com/ossf/malicious-packages/internal/report" ) @@ -84,6 +86,11 @@ func OriginExistsInPaths(path string, bases []string, sourceID, shasum string) ( // // An error will be returned if there is an error reading the the filesystem. func ReportsInPaths(path string, bases []string) ([]string, error) { + if len(bases) == 0 { + // If bases is empty assume path is a full path to a report directory. + return reportsInPath(filepath.Clean(path)) + } + var reports []string for _, base := range bases { fp := filepath.Clean(filepath.Join(base, path)) @@ -154,6 +161,8 @@ func PreparePath(path, base string) (string, error) { // MoveReport will relocate the report at the supplied path r, from baseSrc // to baseDest. // +// r, baseSrc and baseDest are expected to be absolute paths. +// // An error will be returned if the move fails, if the destination directory // can not be created, or the report does not live under baseSrc. func MoveReport(r, baseSrc, baseDest string) error { @@ -185,3 +194,38 @@ func MoveReport(r, baseSrc, baseDest string) error { } return nil } + +// MutateReport allows for the atomic mutation of the report file using the +// mutateFn. The tempDir is used internally by renameio. +// +// mutateFn returns a bool indicating whether or not the report was changed. If +// the bool is true the report will be written to disk and atomically moved +// to replace the original file. +func MutateReport(file string, mutateFn func(*report.Report) (bool, error), tempDir string) error { + r, err := report.FromFile(file) + if err != nil { + return fmt.Errorf("failed to read report %s: %w", file, err) + } + + if changed, err := mutateFn(r); err != nil { + return fmt.Errorf("failed to mutate report %s: %w", file, err) + } else if !changed { + return nil + } + + t, err := renameio.TempFile(tempDir, file) + if err != nil { + return fmt.Errorf("failed to open temp file for %s: %w", file, err) + } + defer t.Cleanup() + + err = r.WriteJSON(t) + if err != nil { + return fmt.Errorf("failed to write %s: %w", file, err) + } + + if err := t.CloseAtomicallyReplace(); err != nil { + return fmt.Errorf("atomic save failed for %s: %w", file, err) + } + return nil +} diff --git a/internal/reportio/reportio_test.go b/internal/reportio/reportio_test.go index 5a0198e8bf2..838ce7f91d1 100644 --- a/internal/reportio/reportio_test.go +++ b/internal/reportio/reportio_test.go @@ -15,11 +15,14 @@ package reportio_test import ( + "errors" "io/fs" "os" "path/filepath" "testing" + "time" + "github.com/ossf/malicious-packages/internal/report" "github.com/ossf/malicious-packages/internal/reportio" ) @@ -241,3 +244,94 @@ func TestMoveReport_Errors(t *testing.T) { }) } } + +func TestMutateReport_Success(t *testing.T) { + dir := t.TempDir() + reportFile := filepath.Join(dir, "report.json") + tempDir := filepath.Join(dir, "temp") + if err := os.Mkdir(tempDir, 0o777); err != nil { + t.Fatalf("Mkdir() = %v; want no error", err) + } + + rJSON := `{ "schema_version": "1.5.0", "summary": "original summary", "affected": [{"package":{"ecosystem": "npm", "name": "example"}, "versions": ["0"]}]}` + if err := os.WriteFile(reportFile, []byte(rJSON), 0o600); err != nil { + t.Fatalf("WriteFile() = %v; want no error", err) + } + + now := time.Now().Truncate(time.Second).UTC() + err := reportio.MutateReport(reportFile, func(r *report.Report) (bool, error) { + r.Withdraw(now) + return true, nil + }, tempDir) + if err != nil { + t.Fatalf("MutateReport() = %v; want no error", err) + } + + mutated, err := report.FromFile(reportFile) + if err != nil { + t.Fatalf("FromFile() = %v; want no error", err) + } + if !mutated.IsWithdrawn() { + t.Error("IsWithdrawn() = false; want true") + } +} + +func TestMutateReport_NoChange(t *testing.T) { + dir := t.TempDir() + reportFile := filepath.Join(dir, "report.json") + tempDir := filepath.Join(dir, "temp") + if err := os.Mkdir(tempDir, 0o777); err != nil { + t.Fatalf("Mkdir() = %v; want no error", err) + } + + rJSON := `{ "schema_version": "1.5.0", "summary": "original summary", "affected": [{"package":{"ecosystem": "npm", "name": "example"}, "versions": ["0"]}]}` + if err := os.WriteFile(reportFile, []byte(rJSON), 0o600); err != nil { + t.Fatalf("WriteFile() = %v; want no error", err) + } + + err := reportio.MutateReport(reportFile, func(r *report.Report) (bool, error) { + return false, nil + }, tempDir) + if err != nil { + t.Fatalf("MutateReport() = %v; want no error", err) + } + + mutated, err := report.FromFile(reportFile) + if err != nil { + t.Fatalf("FromFile() = %v; want no error", err) + } + if mutated.IsWithdrawn() { + t.Error("IsWithdrawn() = true; want false") + } +} + +func TestMutateReport_FnError(t *testing.T) { + dir := t.TempDir() + reportFile := filepath.Join(dir, "report.json") + tempDir := filepath.Join(dir, "temp") + if err := os.Mkdir(tempDir, 0o777); err != nil { + t.Fatalf("Mkdir() = %v; want no error", err) + } + + rJSON := `{ "schema_version": "1.5.0", "summary": "original summary", "affected": [{"package":{"ecosystem": "npm", "name": "example"}, "versions": ["0"]}]}` + if err := os.WriteFile(reportFile, []byte(rJSON), 0o600); err != nil { + t.Fatalf("WriteFile() = %v; want no error", err) + } + + testErr := errors.New("test mutation error") + err := reportio.MutateReport(reportFile, func(r *report.Report) (bool, error) { + r.Withdraw(time.Now()) + return false, testErr + }, tempDir) + if err == nil || !errors.Is(err, testErr) { + t.Fatalf("MutateReport() = %v; want error wrapping %v", err, testErr) + } + + mutated, err := report.FromFile(reportFile) + if err != nil { + t.Fatalf("FromFile() = %v; want no error", err) + } + if mutated.IsWithdrawn() { + t.Error("IsWithdrawn() = true; want false") + } +}