github.com/google/osv-scalibr@v0.4.1/plugin/plugin.go (about)

     1  // Copyright 2025 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package plugin collects the common code used by extractor and detector plugins.
    16  package plugin
    17  
    18  import (
    19  	"fmt"
    20  	"slices"
    21  	"strings"
    22  
    23  	cpb "github.com/google/osv-scalibr/binary/proto/config_go_proto"
    24  )
    25  
    26  // OS is the OS the scanner is running on, or a specific OS type a Plugin needs to be run on.
    27  type OS int
    28  
    29  // OS values
    30  const (
    31  	// OSAny is used only when specifying Plugin requirements.
    32  	// Specifies that the plugin expects to be compatible with any OS,
    33  	// and so should be fine to run even if OS is unknown.
    34  	OSAny     OS = iota
    35  	OSLinux   OS = iota
    36  	OSWindows OS = iota
    37  	OSMac     OS = iota
    38  	// OSUnix is used only when specifying Plugin requirements.
    39  	// Specifies that the plugin needs to be run either on Linux or Mac.
    40  	OSUnix OS = iota
    41  )
    42  
    43  // OSUnknown is only used when specifying Capabilities.
    44  // Specifies that the OS is not known and so only
    45  // plugins that require OSAny should be run.
    46  const OSUnknown = OSAny
    47  
    48  // Network is the network access of the scanner or the network
    49  // requirements of a plugin.
    50  type Network int
    51  
    52  // Network values
    53  const (
    54  	// NetworkAny is used only when specifying Plugin requirements. Specifies
    55  	// that the plugin doesn't care whether the scanner has network access or not.
    56  	NetworkAny     Network = iota
    57  	NetworkOffline Network = iota
    58  	NetworkOnline  Network = iota
    59  )
    60  
    61  // Capabilities lists capabilities that the scanning environment provides for the plugins.
    62  // A plugin can't be enabled if it has more requirements than what the scanning environment provides.
    63  type Capabilities struct {
    64  	// A specific OS type a Plugin needs to be run on.
    65  	OS OS
    66  	// Whether network access is provided.
    67  	Network Network
    68  	// Whether the scanned artifacts can be access through direct filesystem calls.
    69  	// True on hosts where the scan target is mounted onto the host's filesystem directly.
    70  	// In these cases the plugin can open direct file paths with e.g. os.Open(path).
    71  	// False if the artifact is not on the host but accessed through an abstract FS interface
    72  	// (e.g. scanning a remote container image). In these cases the plugin must use the FS interface
    73  	// to access the filesystem.
    74  	DirectFS bool
    75  	// Whether the scanner is scanning the real running system it's on. Examples where this is not the case:
    76  	// * We're scanning a virtual filesystem unrelated to the host where SCALIBR is running.
    77  	// * We're scanning a real filesystem of e.g. a container image that's mounted somewhere on disk.
    78  	RunningSystem bool
    79  	// Whether the filesystem extractor plugin requires scanning directories in addition to files.
    80  	// TODO(b/400910349): This doesn't quite fit into Capabilities so this should be moved into a
    81  	// separate Filesystem Extractor specific function.
    82  	ExtractFromDirs bool
    83  }
    84  
    85  // Plugin is the part of the plugin interface that's shared between extractors and detectors.
    86  type Plugin interface {
    87  	// A unique name used to identify this plugin.
    88  	Name() string
    89  	// Plugin version, should get bumped whenever major changes are made.
    90  	Version() int
    91  	// Requirements about the scanning environment, e.g. "needs to have network access".
    92  	Requirements() *Capabilities
    93  }
    94  
    95  // LINT.IfChange
    96  
    97  // Status contains the status and version of the plugins that ran.
    98  type Status struct {
    99  	Name    string
   100  	Version int
   101  	Status  *ScanStatus
   102  }
   103  
   104  // ScanStatus is the status of a scan run. In case the scan fails, FailureReason contains details.
   105  type ScanStatus struct {
   106  	Status        ScanStatusEnum
   107  	FailureReason string
   108  	FileErrors    []*FileError
   109  }
   110  
   111  // FileError contains the errors that occurred while scanning a specific file.
   112  type FileError struct {
   113  	FilePath     string
   114  	ErrorMessage string
   115  }
   116  
   117  // ScanStatusEnum is the enum for the scan status.
   118  type ScanStatusEnum int
   119  
   120  // ScanStatusEnum values.
   121  const (
   122  	ScanStatusUnspecified ScanStatusEnum = iota
   123  	ScanStatusSucceeded
   124  	ScanStatusPartiallySucceeded
   125  	ScanStatusFailed
   126  )
   127  
   128  // LINT.ThenChange(/binary/proto/scan_result.proto)
   129  
   130  // ValidateRequirements checks that the specified  scanning capabilities satisfy
   131  // the requirements of a given plugin.
   132  func ValidateRequirements(p Plugin, capabs *Capabilities) error {
   133  	if capabs == nil {
   134  		return nil
   135  	}
   136  	errs := []string{}
   137  	if p.Requirements().OS == OSUnix {
   138  		if capabs.OS != OSLinux && capabs.OS != OSMac {
   139  			errs = append(errs, "needs to run on Unix system but scan environment is non-Unix")
   140  		}
   141  	} else if p.Requirements().OS != OSAny && p.Requirements().OS != capabs.OS {
   142  		errs = append(errs, "needs to run on a different OS than that of the scan environment")
   143  	}
   144  	if p.Requirements().Network != NetworkAny && p.Requirements().Network != capabs.Network {
   145  		if capabs.Network == NetworkOffline {
   146  			errs = append(errs, "needs network access but scan environment doesn't provide it")
   147  		} else {
   148  			errs = append(errs, "should only run offline but the scan environment provides network access")
   149  		}
   150  	}
   151  	if p.Requirements().DirectFS && !capabs.DirectFS {
   152  		errs = append(errs, "needs direct filesystem access but scan environment doesn't provide it")
   153  	}
   154  	if p.Requirements().RunningSystem && !capabs.RunningSystem {
   155  		errs = append(errs, "scanner isn't scanning the host it's run from directly")
   156  	}
   157  	if len(errs) == 0 {
   158  		return nil
   159  	}
   160  	return fmt.Errorf("plugin %s can't be enabled: %s", p.Name(), strings.Join(errs, ", "))
   161  }
   162  
   163  // FilterByCapabilities returns all plugins from the given list that can run
   164  // under the specified capabilities (OS, direct filesystem access, network
   165  // access, etc.) of the scanning environment.
   166  func FilterByCapabilities(pls []Plugin, capabs *Capabilities) []Plugin {
   167  	result := []Plugin{}
   168  	for _, pl := range pls {
   169  		if err := ValidateRequirements(pl, capabs); err == nil {
   170  			result = append(result, pl)
   171  		}
   172  	}
   173  	return result
   174  }
   175  
   176  // FindConfig finds a plugin-specific config in the oveall config proto
   177  // using the specified getter function.
   178  func FindConfig[T any](cfg *cpb.PluginConfig, getter func(c *cpb.PluginSpecificConfig) *T) *T {
   179  	for _, specific := range cfg.PluginSpecific {
   180  		got := getter(specific)
   181  		if got != nil {
   182  			return got
   183  		}
   184  	}
   185  	return nil
   186  }
   187  
   188  // StatusFromErr returns a successful or failed plugin scan status for a given plugin based on an error.
   189  func StatusFromErr(p Plugin, partial bool, overallErr error, fileErrors []*FileError) *Status {
   190  	status := &ScanStatus{}
   191  	if overallErr == nil {
   192  		status.Status = ScanStatusSucceeded
   193  	} else {
   194  		if partial {
   195  			status.Status = ScanStatusPartiallySucceeded
   196  		} else {
   197  			status.Status = ScanStatusFailed
   198  		}
   199  		status.FileErrors = fileErrors
   200  		status.FailureReason = overallErr.Error()
   201  	}
   202  	return &Status{
   203  		Name:    p.Name(),
   204  		Version: p.Version(),
   205  		Status:  status,
   206  	}
   207  }
   208  
   209  // OverallErrFromFileErrs returns an error to set as the scan status overall failure
   210  // reason based on the plugin's per-file errors.
   211  func OverallErrFromFileErrs(fileErrors []*FileError) error {
   212  	if len(fileErrors) == 0 {
   213  		return nil
   214  	}
   215  	return fmt.Errorf("encountered %d error(s) while running plugin; check file-specific errors for details", len(fileErrors))
   216  }
   217  
   218  // DedupeStatuses combines the status of multiple instances of the same plugins
   219  // in a list, making sure there's only one entry per plugin.
   220  func DedupeStatuses(statuses []*Status) []*Status {
   221  	// Plugin name to status map
   222  	resultMap := map[string]*Status{}
   223  
   224  	for _, s := range statuses {
   225  		if old, ok := resultMap[s.Name]; ok {
   226  			resultMap[s.Name] = mergeStatus(old, s)
   227  		} else {
   228  			resultMap[s.Name] = s
   229  		}
   230  	}
   231  
   232  	result := make([]*Status, 0, len(resultMap))
   233  	for _, v := range resultMap {
   234  		result = append(result, v)
   235  	}
   236  	return result
   237  }
   238  
   239  func mergeStatus(s1 *Status, s2 *Status) *Status {
   240  	result := &Status{
   241  		Name:    s1.Name,
   242  		Version: s1.Version,
   243  		Status: &ScanStatus{
   244  			Status: mergeScanStatus(s1.Status.Status, s2.Status.Status),
   245  		},
   246  	}
   247  
   248  	if len(s1.Status.FailureReason) > 0 && len(s2.Status.FailureReason) > 0 {
   249  		result.Status.FailureReason = s1.Status.FailureReason + "\n" + s2.Status.FailureReason
   250  	} else if len(s1.Status.FailureReason) > 0 {
   251  		result.Status.FailureReason = s1.Status.FailureReason
   252  	} else {
   253  		result.Status.FailureReason = s2.Status.FailureReason
   254  	}
   255  
   256  	result.Status.FileErrors = slices.Concat(s1.Status.FileErrors, s2.Status.FileErrors)
   257  	if len(result.Status.FileErrors) > 0 {
   258  		// Instead of concating two generic "check file errors" message we create a new one.
   259  		result.Status.FailureReason = OverallErrFromFileErrs(result.Status.FileErrors).Error()
   260  	}
   261  
   262  	return result
   263  }
   264  
   265  func mergeScanStatus(e1 ScanStatusEnum, e2 ScanStatusEnum) ScanStatusEnum {
   266  	// Failures take precedence over successes.
   267  	if e1 == ScanStatusFailed || e2 == ScanStatusFailed {
   268  		return ScanStatusFailed
   269  	}
   270  	if e1 == ScanStatusPartiallySucceeded || e2 == ScanStatusPartiallySucceeded {
   271  		return ScanStatusPartiallySucceeded
   272  	}
   273  	if e1 == ScanStatusSucceeded || e2 == ScanStatusSucceeded {
   274  		return ScanStatusSucceeded
   275  	}
   276  	return ScanStatusUnspecified
   277  }
   278  
   279  // String returns a string representation of the scan status.
   280  func (s *ScanStatus) String() string {
   281  	switch s.Status {
   282  	case ScanStatusSucceeded:
   283  		return "SUCCEEDED"
   284  	case ScanStatusPartiallySucceeded:
   285  		return "PARTIALLY_SUCCEEDED"
   286  	case ScanStatusFailed:
   287  		return "FAILED: " + s.FailureReason
   288  	case ScanStatusUnspecified:
   289  		fallthrough
   290  	default:
   291  		return "UNSPECIFIED"
   292  	}
   293  }