1
0
mirror of https://github.com/SAP/jenkins-library.git synced 2025-02-19 19:44:27 +02:00

feat(malwareExecuteScan): refactoring and docker support (#3421)

* feat(malwareExecuteScan): add support for scanning docker images

* refactoring

* print out finding if available

* generate toolrecord for malware scan

* persist scan report

* docs

* fix

* fix

* rollback cmd/init_unix.go

* auhenticated pull

* fix

* fix: report shall be consistent with the api model

* gcs upload

* fix linter
This commit is contained in:
Christian Volk 2022-01-24 09:48:01 +01:00 committed by GitHub
parent 6520115950
commit b0e4599d4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 984 additions and 316 deletions

View File

@ -19,21 +19,21 @@ func containerSaveImage(config containerSaveImageOptions, telemetryData *telemet
dClient := &piperDocker.Client{}
dClient.SetOptions(dClientOptions)
err := runContainerSaveImage(&config, telemetryData, cachePath, "", dClient)
_, err := runContainerSaveImage(&config, telemetryData, cachePath, "", dClient)
if err != nil {
log.Entry().WithError(err).Fatal("step execution failed")
}
}
func runContainerSaveImage(config *containerSaveImageOptions, telemetryData *telemetry.CustomData, cachePath, rootPath string, dClient piperDocker.Download) error {
func runContainerSaveImage(config *containerSaveImageOptions, telemetryData *telemetry.CustomData, cachePath, rootPath string, dClient piperDocker.Download) (string, error) {
err := os.RemoveAll(cachePath)
if err != nil {
return errors.Wrap(err, "failed to prepare cache")
return "", errors.Wrap(err, "failed to prepare cache")
}
err = os.Mkdir(cachePath, 0755)
if err != nil {
return errors.Wrap(err, "failed to create cache")
return "", errors.Wrap(err, "failed to create cache")
}
// ensure that download cache is cleaned up at the end
@ -41,11 +41,11 @@ func runContainerSaveImage(config *containerSaveImageOptions, telemetryData *tel
imageSource, err := dClient.GetImageSource()
if err != nil {
return errors.Wrap(err, "failed to get docker image source")
return "", errors.Wrap(err, "failed to get docker image source")
}
image, err := dClient.DownloadImageToPath(imageSource, cachePath)
if err != nil {
return errors.Wrap(err, "failed to download docker image")
return "", errors.Wrap(err, "failed to download docker image")
}
tarfilePath := config.FilePath
@ -57,20 +57,20 @@ func runContainerSaveImage(config *containerSaveImageOptions, telemetryData *tel
tarFile, err := os.Create(tarfilePath)
if err != nil {
return errors.Wrapf(err, "failed to create %v for docker image", tarfilePath)
return "", errors.Wrapf(err, "failed to create %v for docker image", tarfilePath)
}
defer tarFile.Close()
if err := os.Chmod(tarfilePath, 0644); err != nil {
return errors.Wrapf(err, "failed to adapt permissions on %v", tarfilePath)
return "", errors.Wrapf(err, "failed to adapt permissions on %v", tarfilePath)
}
err = dClient.TarImage(tarFile, image)
if err != nil {
return errors.Wrap(err, "failed to tar container image")
return "", errors.Wrap(err, "failed to tar container image")
}
return nil
return tarfilePath, nil
}
func filenameFromContainer(rootPath, containerImage string) string {

View File

@ -65,7 +65,7 @@ func TestRunContainerSaveImage(t *testing.T) {
dClient := containerMock{}
err = runContainerSaveImage(&config, &telemetryData, cacheFolder, tmpFolder, &dClient)
filePath, err := runContainerSaveImage(&config, &telemetryData, cacheFolder, tmpFolder, &dClient)
assert.NoError(t, err)
assert.Equal(t, cacheFolder, dClient.filePath)
@ -74,12 +74,14 @@ func TestRunContainerSaveImage(t *testing.T) {
content, err := ioutil.ReadFile(filepath.Join(tmpFolder, "testfile.tar"))
assert.NoError(t, err)
assert.Equal(t, "This is a test", string(content))
assert.Contains(t, filePath, "testfile.tar")
})
t.Run("failure - cache creation", func(t *testing.T) {
config := containerSaveImageOptions{}
dClient := containerMock{}
err := runContainerSaveImage(&config, &telemetryData, "", "", &dClient)
_, err := runContainerSaveImage(&config, &telemetryData, "", "", &dClient)
assert.Contains(t, fmt.Sprint(err), "failed to create cache: mkdir :")
})
@ -92,7 +94,7 @@ func TestRunContainerSaveImage(t *testing.T) {
defer os.RemoveAll(tmpFolder)
dClient := containerMock{imageSourceErr: "image source error"}
err = runContainerSaveImage(&config, &telemetryData, filepath.Join(tmpFolder, "cache"), tmpFolder, &dClient)
_, err = runContainerSaveImage(&config, &telemetryData, filepath.Join(tmpFolder, "cache"), tmpFolder, &dClient)
assert.EqualError(t, err, "failed to get docker image source: image source error")
})
@ -105,7 +107,7 @@ func TestRunContainerSaveImage(t *testing.T) {
defer os.RemoveAll(tmpFolder)
dClient := containerMock{downloadImageErr: "download error"}
err = runContainerSaveImage(&config, &telemetryData, filepath.Join(tmpFolder, "cache"), tmpFolder, &dClient)
_, err = runContainerSaveImage(&config, &telemetryData, filepath.Join(tmpFolder, "cache"), tmpFolder, &dClient)
assert.EqualError(t, err, "failed to download docker image: download error")
})
@ -118,7 +120,7 @@ func TestRunContainerSaveImage(t *testing.T) {
defer os.RemoveAll(tmpFolder)
dClient := containerMock{tarImageErr: "tar error"}
err = runContainerSaveImage(&config, &telemetryData, filepath.Join(tmpFolder, "cache"), tmpFolder, &dClient)
_, err = runContainerSaveImage(&config, &telemetryData, filepath.Join(tmpFolder, "cache"), tmpFolder, &dClient)
assert.EqualError(t, err, "failed to tar container image: tar error")
})
}

View File

@ -1,171 +1,177 @@
package cmd
import (
"crypto/sha256"
"encoding/json"
"fmt"
"github.com/SAP/jenkins-library/pkg/command"
piperDocker "github.com/SAP/jenkins-library/pkg/docker"
piperhttp "github.com/SAP/jenkins-library/pkg/http"
"github.com/SAP/jenkins-library/pkg/log"
"github.com/SAP/jenkins-library/pkg/malwarescan"
"github.com/SAP/jenkins-library/pkg/piperutils"
"github.com/SAP/jenkins-library/pkg/telemetry"
"github.com/SAP/jenkins-library/pkg/toolrecord"
"github.com/pkg/errors"
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
var open = _open
var getSHA256 = _getSHA256
type malwareScanUtils interface {
OpenFile(name string, flag int, perm os.FileMode) (io.ReadCloser, error)
SHA256(path string) (string, error)
func _open(path string) (io.ReadCloser, error) {
return os.Open(path)
newDockerClient(piperDocker.ClientOptions) piperDocker.Download
malwarescan.Client
piperutils.FileUtils
}
type malwareExecuteScanResponse struct {
MalwareDetected bool
EncryptedContentDetected bool
ScanSize int
MimeType string
SHA256 string
type malwareScanUtilsBundle struct {
malwarescan.Client
*piperutils.Files
}
func malwareExecuteScan(config malwareExecuteScanOptions, telemetryData *telemetry.CustomData) {
// for command execution use Command
c := command.Command{}
// reroute command output to logging framework
c.Stdout(log.Writer())
c.Stderr(log.Writer())
// for http calls import piperhttp "github.com/SAP/jenkins-library/pkg/http"
// and use a &piperhttp.Client{} in a custom system
// Example: step checkmarxExecuteScan.go
httpClient := &piperhttp.Client{}
// error situations should stop execution through log.Entry().Fatal() call which leads to an os.Exit(1) in the end
err := runMalwareScan(&config, telemetryData, &c, httpClient)
if err != nil {
log.Entry().WithError(err).Fatal("step execution failed")
}
func (utils *malwareScanUtilsBundle) OpenFile(name string, flag int, perm os.FileMode) (io.ReadCloser, error) {
return utils.Files.FileOpen(name, flag, perm)
}
func runMalwareScan(config *malwareExecuteScanOptions, telemetryData *telemetry.CustomData, command command.ExecRunner,
httpClient piperhttp.Sender) error {
log.Entry().Infof("Scanning file \"%s\" for malware using service \"%s\"", config.File, config.Host)
candidate, err := open(config.File)
if err != nil {
return err
}
defer candidate.Close()
func (utils *malwareScanUtilsBundle) newDockerClient(options piperDocker.ClientOptions) piperDocker.Download {
dClient := piperDocker.Client{}
dClient.SetOptions(options)
return &dClient
}
func newMalwareScanUtilsBundle(config malwareExecuteScanOptions) *malwareScanUtilsBundle {
timeout, err := time.ParseDuration(fmt.Sprintf("%ss", config.Timeout))
if err != nil {
return errors.Wrapf(err, "Invalid timeout: %v", config.Timeout)
timeout = 60
log.Entry().Warnf("Unable to parse timeout for malwareScan: '%v'. Falling back to %ds", err, timeout)
}
opts := piperhttp.ClientOptions{
httpClientOptions := piperhttp.ClientOptions{
Username: config.Username,
Password: config.Password,
MaxRequestDuration: timeout,
TransportTimeout: timeout,
}
httpClient.SetOptions(opts)
var scanResponse *malwareExecuteScanResponse
scanResponse, err = sendMalwareScanRequest(httpClient, "POST", config.Host+"/scan", candidate)
httpClient := &piperhttp.Client{}
httpClient.SetOptions(httpClientOptions)
return &malwareScanUtilsBundle{
Client: &malwarescan.ClientImpl{
HTTPClient: httpClient,
Host: config.Host,
},
Files: &piperutils.Files{},
}
}
func malwareExecuteScan(config malwareExecuteScanOptions, telemetryData *telemetry.CustomData) {
utils := newMalwareScanUtilsBundle(config)
err := runMalwareScan(&config, telemetryData, utils)
if err != nil {
log.Entry().WithError(err).Fatal("step execution failed")
}
}
func runMalwareScan(config *malwareExecuteScanOptions, telemetryData *telemetry.CustomData, utils malwareScanUtils) error {
file, err := selectAndPrepareFileForMalwareScan(config, utils)
if err != nil {
return err
}
log.Entry().Infof("Scanning file \"%s\" for malware using service \"%s\"", file, config.Host)
candidate, err := utils.OpenFile(file, os.O_RDONLY, 0666)
if err != nil {
return err
}
defer candidate.Close()
scannerInfo, err := utils.Info()
log.Entry().Infof("***************************************")
log.Entry().Infof("* Engine: %s", scannerInfo.EngineVersion)
log.Entry().Infof("* Signatures: %s", scannerInfo.SignatureTimestamp)
log.Entry().Infof("***************************************")
if _, err = createToolRecordMalwareScan("./", config, scannerInfo); err != nil {
return err
}
scanResponse, err := utils.Scan(candidate)
if err != nil {
return err
}
if err = createMalwareScanReport(config, scanResponse, utils); err != nil {
return err
}
log.Entry().Debugf(
"File '%s' has been scanned. MalwareDetected: %t, EncryptedContentDetected: %t, ScanSize: %d, MimeType: '%s', SHA256: '%s'",
config.File,
"File '%s' has been scanned. MalwareDetected: %t, EncryptedContentDetected: %t, ScanSize: %d, MimeType: '%s', SHA256: '%s', Finding: '%s'",
file,
scanResponse.MalwareDetected,
scanResponse.EncryptedContentDetected,
scanResponse.ScanSize,
scanResponse.MimeType,
scanResponse.SHA256)
scanResponse.SHA256,
scanResponse.Finding)
err = validateHash(scanResponse.SHA256, config.File)
if err != nil {
if err = validateHash(scanResponse.SHA256, file, utils); err != nil {
return err
}
if scanResponse.MalwareDetected || scanResponse.EncryptedContentDetected {
return fmt.Errorf("Malware scan failed for file '%s'. Malware detected: %t, encrypted content detected: %t",
config.File, scanResponse.MalwareDetected, scanResponse.EncryptedContentDetected)
return fmt.Errorf("Malware scan failed for file '%s'. Malware detected: %t, encrypted content detected: %t, finding: %v",
file, scanResponse.MalwareDetected, scanResponse.EncryptedContentDetected, scanResponse.Finding)
}
log.Entry().Infof("Malware scan succeeded for file '%s'. Malware detected: %t, encrypted content detected: %t",
config.File, scanResponse.MalwareDetected, scanResponse.EncryptedContentDetected)
file, scanResponse.MalwareDetected, scanResponse.EncryptedContentDetected)
return nil
}
func sendMalwareScanRequest(client piperhttp.Sender, method, url string, candidate io.Reader) (*malwareExecuteScanResponse, error) {
// piper http utils mashall some http response codes into errors. We wan't to check the status code
// ourselves hence we wait with returning that error (maybe also related to errors others than http status codes)
// sendRequest results in any combination of nil and non-nil response and error.
// a response body could even be already closed.
response, err := client.SendRequest(method, url, candidate, prepareHeaders(), nil)
if response != nil && response.Body != nil {
defer response.Body.Close()
func selectAndPrepareFileForMalwareScan(config *malwareExecuteScanOptions, utils malwareScanUtils) (string, error) {
if len(config.ScanFile) > 0 {
return config.ScanFile, nil
}
return validateResponse(response, err)
// automatically detect the file to be scanned depending on the buildtool
if config.BuildTool == "docker" && len(config.ScanImage) > 0 {
correctMalwareDockerConfigEnvVar(config, utils)
saveImageOptions := containerSaveImageOptions{
ContainerImage: config.ScanImage,
ContainerRegistryURL: config.ScanImageRegistryURL,
IncludeLayers: config.ScanImageIncludeLayers,
FilePath: config.ScanImage,
}
dClientOptions := piperDocker.ClientOptions{ImageName: saveImageOptions.ContainerImage, RegistryURL: saveImageOptions.ContainerRegistryURL, LocalPath: "", IncludeLayers: saveImageOptions.IncludeLayers}
dClient := utils.newDockerClient(dClientOptions)
tarFile, err := runContainerSaveImage(&saveImageOptions, &telemetry.CustomData{}, "./cache", "", dClient)
if err != nil {
if strings.Contains(fmt.Sprint(err), "no image found") {
log.SetErrorCategory(log.ErrorConfiguration)
}
return "", errors.Wrapf(err, "failed to download Docker image %v", config.ScanImage)
}
return tarFile, nil
}
return "", fmt.Errorf("Please specify a file to be scanned")
}
func validateResponse(response *http.Response, err error) (*malwareExecuteScanResponse, error) {
var body []byte
var errRead error
if response != nil && response.Body != nil {
body, errRead = ioutil.ReadAll(response.Body)
}
if err != nil {
return nil, fmt.Errorf("HTTP request failed with error: %v. Details: \"%s\"", err, body)
}
if response == nil {
return nil, fmt.Errorf("No response available")
}
if response.StatusCode != 200 {
return nil, fmt.Errorf("Unexpected response code (%d). %d expected. Details: \"%s\"", response.StatusCode, 200, body)
}
if errRead != nil {
return nil, errRead
}
return marshalResponse(body)
}
func marshalResponse(body []byte) (*malwareExecuteScanResponse, error) {
var scanResponse malwareExecuteScanResponse
err := json.Unmarshal(body, &scanResponse)
if err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("Unmarshalling of response body failed. Body: '%s'", body))
}
return &scanResponse, nil
}
func validateHash(remoteHash, fileName string) error {
hash, err := getSHA256(fileName)
func validateHash(remoteHash, fileName string, utils malwareScanUtils) error {
hash, err := utils.SHA256(fileName)
if err != nil {
return err
}
@ -180,25 +186,52 @@ func validateHash(remoteHash, fileName string) error {
return nil
}
func _getSHA256(fileName string) (string, error) {
// create toolrecord file for malwarescan
func createToolRecordMalwareScan(workspace string, config *malwareExecuteScanOptions, scanner *malwarescan.Info) (string, error) {
record := toolrecord.New(workspace, "malwarescan", config.Host)
record.SetOverallDisplayData("Malware Scanner", "")
f, err := open(fileName)
if err != nil {
return "", err
}
defer f.Close()
hash := sha256.New()
_, err = io.Copy(hash, f)
if err != nil {
if err := record.AddKeyData("engineVersion", scanner.EngineVersion, "Engine Version", ""); err != nil {
return "", err
}
return fmt.Sprintf("%x", string(hash.Sum(nil))), nil
if err := record.AddKeyData("signatureTimestamp", scanner.SignatureTimestamp, "Signature Timestamp", ""); err != nil {
return "", err
}
if err := record.Persist(); err != nil {
return "", err
}
return record.GetFileName(), nil
}
func prepareHeaders() http.Header {
headers := http.Header{}
headers.Add("Content-Type", "application/octet-stream")
return headers
func createMalwareScanReport(config *malwareExecuteScanOptions, scanResult *malwarescan.ScanResult, utils malwareScanUtils) error {
scanResultJSON, err := json.Marshal(scanResult)
if err != nil {
return err
}
return utils.FileWrite(config.ReportFileName, scanResultJSON, 0666)
}
func correctMalwareDockerConfigEnvVar(config *malwareExecuteScanOptions, utils malwareScanUtils) {
path := config.DockerConfigJSON
if len(path) > 0 {
log.Entry().Infof("Docker credentials configuration: %v", path)
if len(config.ScanImageRegistryURL) > 0 && len(config.ContainerRegistryUser) > 0 && len(config.ContainerRegistryPassword) > 0 {
var err error
path, err = piperDocker.CreateDockerConfigJSON(config.ScanImageRegistryURL, config.ContainerRegistryUser, config.ContainerRegistryPassword, "", config.DockerConfigJSON, utils)
if err != nil {
log.Entry().Warningf("failed to update Docker config.json: %v", err)
}
}
path, _ := utils.Abs(path)
// use parent directory
path = filepath.Dir(path)
os.Setenv("DOCKER_CONFIG", path)
} else {
log.Entry().Info("Docker credentials configuration: NONE")
}
}

View File

@ -5,39 +5,88 @@ package cmd
import (
"fmt"
"os"
"reflect"
"strings"
"time"
"github.com/SAP/jenkins-library/pkg/config"
"github.com/SAP/jenkins-library/pkg/gcs"
"github.com/SAP/jenkins-library/pkg/log"
"github.com/SAP/jenkins-library/pkg/splunk"
"github.com/SAP/jenkins-library/pkg/telemetry"
"github.com/SAP/jenkins-library/pkg/validation"
"github.com/bmatcuk/doublestar"
"github.com/spf13/cobra"
)
type malwareExecuteScanOptions struct {
Host string `json:"host,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
File string `json:"file,omitempty"`
Timeout string `json:"timeout,omitempty"`
BuildTool string `json:"buildTool,omitempty"`
DockerConfigJSON string `json:"dockerConfigJSON,omitempty"`
ContainerRegistryPassword string `json:"containerRegistryPassword,omitempty"`
ContainerRegistryUser string `json:"containerRegistryUser,omitempty"`
Host string `json:"host,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
ScanImage string `json:"scanImage,omitempty"`
ScanImageIncludeLayers bool `json:"scanImageIncludeLayers,omitempty"`
ScanImageRegistryURL string `json:"scanImageRegistryUrl,omitempty"`
ScanFile string `json:"scanFile,omitempty"`
Timeout string `json:"timeout,omitempty"`
ReportFileName string `json:"reportFileName,omitempty"`
}
// MalwareExecuteScanCommand Performs a malware scan
type malwareExecuteScanReports struct {
}
func (p *malwareExecuteScanReports) persist(stepConfig malwareExecuteScanOptions, gcpJsonKeyFilePath string, gcsBucketId string, gcsFolderPath string, gcsSubFolder string) {
if gcsBucketId == "" {
log.Entry().Info("persisting reports to GCS is disabled, because gcsBucketId is empty")
return
}
log.Entry().Info("Uploading reports to Google Cloud Storage...")
content := []gcs.ReportOutputParam{
{FilePattern: "**/toolrun_malwarescan_*.json", ParamRef: "", StepResultType: "malwarescan"},
{FilePattern: "", ParamRef: "reportFileName", StepResultType: "malwarescan"},
}
envVars := []gcs.EnvVar{
{Name: "GOOGLE_APPLICATION_CREDENTIALS", Value: gcpJsonKeyFilePath, Modified: false},
}
gcsClient, err := gcs.NewClient(gcs.WithEnvVars(envVars))
if err != nil {
log.Entry().Errorf("creation of GCS client failed: %v", err)
}
defer gcsClient.Close()
structVal := reflect.ValueOf(&stepConfig).Elem()
inputParameters := map[string]string{}
for i := 0; i < structVal.NumField(); i++ {
field := structVal.Type().Field(i)
if field.Type.String() == "string" {
paramName := strings.Split(field.Tag.Get("json"), ",")
paramValue, _ := structVal.Field(i).Interface().(string)
inputParameters[paramName[0]] = paramValue
}
}
if err := gcs.PersistReportsToGCS(gcsClient, content, inputParameters, gcsFolderPath, gcsBucketId, gcsSubFolder, doublestar.Glob, os.Stat); err != nil {
log.Entry().Errorf("failed to persist reports: %v", err)
}
}
// MalwareExecuteScanCommand Performs a malware scan using the [SAP Malware Scanning Service](https://help.sap.com/viewer/b416237f818c4e2e827f6118640079f8/LATEST/en-US/b7c9b86fe724458086a502df3160f380.html).
func MalwareExecuteScanCommand() *cobra.Command {
const STEP_NAME = "malwareExecuteScan"
metadata := malwareExecuteScanMetadata()
var stepConfig malwareExecuteScanOptions
var startTime time.Time
var reports malwareExecuteScanReports
var logCollector *log.CollectorHook
var splunkClient *splunk.Splunk
telemetryClient := &telemetry.Telemetry{}
var createMalwareExecuteScanCmd = &cobra.Command{
Use: STEP_NAME,
Short: "Performs a malware scan",
Long: `Performs a malware scan`,
Short: "Performs a malware scan using the [SAP Malware Scanning Service](https://help.sap.com/viewer/b416237f818c4e2e827f6118640079f8/LATEST/en-US/b7c9b86fe724458086a502df3160f380.html).",
Long: `Performs a malware scan using the [SAP Malware Scanning Service](https://help.sap.com/viewer/b416237f818c4e2e827f6118640079f8/LATEST/en-US/b7c9b86fe724458086a502df3160f380.html).`,
PreRunE: func(cmd *cobra.Command, _ []string) error {
startTime = time.Now()
log.SetStepName(STEP_NAME)
@ -54,6 +103,9 @@ func MalwareExecuteScanCommand() *cobra.Command {
log.SetErrorCategory(log.ErrorConfiguration)
return err
}
log.RegisterSecret(stepConfig.DockerConfigJSON)
log.RegisterSecret(stepConfig.ContainerRegistryPassword)
log.RegisterSecret(stepConfig.ContainerRegistryUser)
log.RegisterSecret(stepConfig.Username)
log.RegisterSecret(stepConfig.Password)
@ -83,6 +135,7 @@ func MalwareExecuteScanCommand() *cobra.Command {
stepTelemetryData := telemetry.CustomData{}
stepTelemetryData.ErrorCode = "1"
handler := func() {
reports.persist(stepConfig, GeneralConfig.GCPJsonKeyFilePath, GeneralConfig.GCSBucketId, GeneralConfig.GCSFolderPath, GeneralConfig.GCSSubFolder)
config.RemoveVaultSecretFiles()
stepTelemetryData.Duration = fmt.Sprintf("%v", time.Since(startTime).Milliseconds())
stepTelemetryData.ErrorCategory = log.GetErrorCategory().String()
@ -114,16 +167,24 @@ func MalwareExecuteScanCommand() *cobra.Command {
}
func addMalwareExecuteScanFlags(cmd *cobra.Command, stepConfig *malwareExecuteScanOptions) {
cmd.Flags().StringVar(&stepConfig.BuildTool, "buildTool", os.Getenv("PIPER_buildTool"), "Defines the tool which is used for building the artifact.")
cmd.Flags().StringVar(&stepConfig.DockerConfigJSON, "dockerConfigJSON", os.Getenv("PIPER_dockerConfigJSON"), "Path to the file `.docker/config.json` - this is typically provided by your CI/CD system. You can find more details about the Docker credentials in the [Docker documentation](https://docs.docker.com/engine/reference/commandline/login/).")
cmd.Flags().StringVar(&stepConfig.ContainerRegistryPassword, "containerRegistryPassword", os.Getenv("PIPER_containerRegistryPassword"), "For `buildTool: docker`: Password for container registry access - typically provided by the CI/CD environment.")
cmd.Flags().StringVar(&stepConfig.ContainerRegistryUser, "containerRegistryUser", os.Getenv("PIPER_containerRegistryUser"), "For `buildTool: docker`: Username for container registry access - typically provided by the CI/CD environment.")
cmd.Flags().StringVar(&stepConfig.Host, "host", os.Getenv("PIPER_host"), "malware scanning host.")
cmd.Flags().StringVar(&stepConfig.Username, "username", os.Getenv("PIPER_username"), "User")
cmd.Flags().StringVar(&stepConfig.Password, "password", os.Getenv("PIPER_password"), "Password")
cmd.Flags().StringVar(&stepConfig.File, "file", os.Getenv("PIPER_file"), "The file which is scanned for malware")
cmd.Flags().StringVar(&stepConfig.ScanImage, "scanImage", os.Getenv("PIPER_scanImage"), "For `buildTool: docker`: Defines the docker image which should be scanned.")
cmd.Flags().BoolVar(&stepConfig.ScanImageIncludeLayers, "scanImageIncludeLayers", true, "For `buildTool: docker`: Defines if layers should be included.")
cmd.Flags().StringVar(&stepConfig.ScanImageRegistryURL, "scanImageRegistryUrl", os.Getenv("PIPER_scanImageRegistryUrl"), "For `buildTool: docker`: Defines the registry where the scanImage is located.")
cmd.Flags().StringVar(&stepConfig.ScanFile, "scanFile", os.Getenv("PIPER_scanFile"), "The file which is scanned for malware")
cmd.Flags().StringVar(&stepConfig.Timeout, "timeout", `600`, "timeout for http layer in seconds")
cmd.Flags().StringVar(&stepConfig.ReportFileName, "reportFileName", `malwarescan_report.json`, "The file name of the report to be created")
cmd.MarkFlagRequired("buildTool")
cmd.MarkFlagRequired("host")
cmd.MarkFlagRequired("username")
cmd.MarkFlagRequired("password")
cmd.MarkFlagRequired("file")
}
// retrieve step metadata
@ -132,7 +193,7 @@ func malwareExecuteScanMetadata() config.StepData {
Metadata: config.StepMetadata{
Name: "malwareExecuteScan",
Aliases: []config.Alias{},
Description: "Performs a malware scan",
Description: "Performs a malware scan using the [SAP Malware Scanning Service](https://help.sap.com/viewer/b416237f818c4e2e827f6118640079f8/LATEST/en-US/b7c9b86fe724458086a502df3160f380.html).",
},
Spec: config.StepSpec{
Inputs: config.StepInputs{
@ -140,6 +201,73 @@ func malwareExecuteScanMetadata() config.StepData {
{Name: "malwareScanCredentialsId", Description: "Jenkins 'Username with password' credentials ID containing the technical user/password credential used to communicate with the malwarescanning service.", Type: "jenkins"},
},
Parameters: []config.StepParameters{
{
Name: "buildTool",
ResourceRef: []config.ResourceReference{
{
Name: "commonPipelineEnvironment",
Param: "buildTool",
},
},
Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: true,
Aliases: []config.Alias{},
Default: os.Getenv("PIPER_buildTool"),
},
{
Name: "dockerConfigJSON",
ResourceRef: []config.ResourceReference{
{
Name: "commonPipelineEnvironment",
Param: "custom/dockerConfigJSON",
},
{
Name: "dockerConfigJsonCredentialsId",
Type: "secret",
},
{
Name: "dockerConfigFileVaultSecretName",
Type: "vaultSecretFile",
Default: "docker-config",
},
},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: false,
Aliases: []config.Alias{},
Default: os.Getenv("PIPER_dockerConfigJSON"),
},
{
Name: "containerRegistryPassword",
ResourceRef: []config.ResourceReference{
{
Name: "commonPipelineEnvironment",
Param: "custom/repositoryPassword",
},
},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: false,
Aliases: []config.Alias{},
Default: os.Getenv("PIPER_containerRegistryPassword"),
},
{
Name: "containerRegistryUser",
ResourceRef: []config.ResourceReference{
{
Name: "commonPipelineEnvironment",
Param: "custom/repositoryUsername",
},
},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: false,
Aliases: []config.Alias{},
Default: os.Getenv("PIPER_containerRegistryUser"),
},
{
Name: "host",
ResourceRef: []config.ResourceReference{},
@ -192,13 +320,50 @@ func malwareExecuteScanMetadata() config.StepData {
Default: os.Getenv("PIPER_password"),
},
{
Name: "file",
Name: "scanImage",
ResourceRef: []config.ResourceReference{
{
Name: "commonPipelineEnvironment",
Param: "container/imageNameTag",
},
},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: false,
Aliases: []config.Alias{},
Default: os.Getenv("PIPER_scanImage"),
},
{
Name: "scanImageIncludeLayers",
ResourceRef: []config.ResourceReference{},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "bool",
Mandatory: false,
Aliases: []config.Alias{},
Default: true,
},
{
Name: "scanImageRegistryUrl",
ResourceRef: []config.ResourceReference{
{
Name: "commonPipelineEnvironment",
Param: "container/registryUrl",
},
},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: false,
Aliases: []config.Alias{},
Default: os.Getenv("PIPER_scanImageRegistryUrl"),
},
{
Name: "scanFile",
ResourceRef: []config.ResourceReference{},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: true,
Aliases: []config.Alias{},
Default: os.Getenv("PIPER_file"),
Mandatory: false,
Aliases: []config.Alias{{Name: "file", Deprecated: true}},
Default: os.Getenv("PIPER_scanFile"),
},
{
Name: "timeout",
@ -209,6 +374,27 @@ func malwareExecuteScanMetadata() config.StepData {
Aliases: []config.Alias{},
Default: `600`,
},
{
Name: "reportFileName",
ResourceRef: []config.ResourceReference{},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: false,
Aliases: []config.Alias{},
Default: `malwarescan_report.json`,
},
},
},
Outputs: config.StepOutputs{
Resources: []config.StepResources{
{
Name: "reports",
Type: "reports",
Parameters: []map[string]interface{}{
{"filePattern": "**/toolrun_malwarescan_*.json", "type": "malwarescan"},
{"type": "malwarescan"},
},
},
},
},
},

View File

@ -2,11 +2,16 @@ package cmd
import (
"fmt"
pkgutil "github.com/GoogleContainerTools/container-diff/pkg/util"
piperDocker "github.com/SAP/jenkins-library/pkg/docker"
piperhttp "github.com/SAP/jenkins-library/pkg/http"
"github.com/SAP/jenkins-library/pkg/malwarescan"
"github.com/SAP/jenkins-library/pkg/mock"
"github.com/stretchr/testify/assert"
"io"
"io/ioutil"
"net/http"
"os"
"strings"
"testing"
)
@ -15,186 +20,178 @@ var malwareScanConfig = malwareExecuteScanOptions{
Host: "https://example.org/malwarescanner",
Username: "me",
Password: "********",
File: "target/myFile",
ScanFile: "target/myFile",
Timeout: "60",
}
func TestMalwareScanTests(t *testing.T) {
type malwareScanUtilsMockBundle struct {
*mock.FilesMock
open = openFileMock()
returnScanResult *malwarescan.ScanResult
returnSHA256 string
}
getSHA256 = func(path string) (string, error) {
return "96ca802fbd54d31903f1115a1d95590c685160637d9262bd340ab30d0f817e85", nil
func (utils *malwareScanUtilsMockBundle) SHA256(filePath string) (string, error) {
if utils.returnSHA256 == "" {
return utils.returnScanResult.SHA256, nil
}
defer func() {
open = _open
getSHA256 = _getSHA256
}()
t.Run("No malware, no encrypted content", func(t *testing.T) {
httpClient := httpMock{StatusCode: 200, ResponseBody: "{\"malwareDetected\":false,\"encryptedContentDetected\":false,\"scanSize\":298782,\"mimeType\":\"application/octet-stream\",\"SHA256\":\"96ca802fbd54d31903f1115a1d95590c685160637d9262bd340ab30d0f817e85\"}"}
return utils.returnSHA256, nil
}
error := runMalwareScan(&malwareScanConfig, nil, nil, &httpClient)
func (utils *malwareScanUtilsMockBundle) OpenFile(path string, flag int, perm os.FileMode) (io.ReadCloser, error) {
return utils.FilesMock.Open(path, flag, perm)
}
if assert.NoError(t, error) {
func (utils *malwareScanUtilsMockBundle) FileWrite(path string, content []byte, perm os.FileMode) error {
return utils.FilesMock.FileWrite(path, content, perm)
}
t.Run("check url", func(t *testing.T) {
assert.Equal(t, "https://example.org/malwarescanner/scan", httpClient.URL)
})
func (utils *malwareScanUtilsMockBundle) Info() (*malwarescan.Info, error) {
return &malwarescan.Info{EngineVersion: "Mock Malware Scanner", SignatureTimestamp: "n/a"}, nil
}
t.Run("check method", func(t *testing.T) {
assert.Equal(t, "POST", httpClient.Method)
})
func (utils *malwareScanUtilsMockBundle) Scan(candidate io.Reader) (*malwarescan.ScanResult, error) {
return utils.returnScanResult, nil
}
t.Run("check user", func(t *testing.T) {
assert.Equal(t, "me", httpClient.Options.Username)
})
func (utils *malwareScanUtilsMockBundle) newDockerClient(options piperDocker.ClientOptions) piperDocker.Download {
return &dockerClientMock{imageName: options.ImageName, registryURL: options.RegistryURL, localPath: options.LocalPath, includeLayers: options.IncludeLayers}
}
t.Run("check password", func(t *testing.T) {
assert.Equal(t, "********", httpClient.Options.Password)
})
func TestMalwareScanWithNoBuildtool(t *testing.T) {
files := &mock.FilesMock{}
files.AddFile("target/myFile", []byte(`HELLO`))
utils := malwareScanUtilsMockBundle{
FilesMock: files,
}
t.Run("No malware, no encrypted content in file", func(t *testing.T) {
utils.returnScanResult = &malwarescan.ScanResult{
MalwareDetected: false,
EncryptedContentDetected: false,
ScanSize: 298782,
MimeType: "application/octet-stream",
SHA256: "96ca802fbd54d31903f1115a1d95590c685160637d9262bd340ab30d0f817e85",
}
error := runMalwareScan(&malwareScanConfig, nil, &utils)
assert.NoError(t, error)
})
t.Run("Malware detected", func(t *testing.T) {
httpClient := httpMock{StatusCode: 200, ResponseBody: "{\"malwareDetected\":true,\"encryptedContentDetected\":false,\"scanSize\":298782,\"mimeType\":\"application/octet-stream\",\"SHA256\":\"96ca802fbd54d31903f1115a1d95590c685160637d9262bd340ab30d0f817e85\"}"}
error := runMalwareScan(&malwareScanConfig, nil, nil, &httpClient)
assert.EqualError(t, error, "Malware scan failed for file 'target/myFile'. Malware detected: true, encrypted content detected: false")
})
t.Run("Encrypted content detected", func(t *testing.T) {
httpClient := httpMock{StatusCode: 200, ResponseBody: "{\"malwareDetected\": false, \"encryptedContentDetected\": true, \"scanSize\":298782,\"mimeType\":\"application/octet-stream\",\"SHA256\":\"96ca802fbd54d31903f1115a1d95590c685160637d9262bd340ab30d0f817e85\"}"}
error := runMalwareScan(&malwareScanConfig, nil, nil, &httpClient)
assert.EqualError(t, error, "Malware scan failed for file 'target/myFile'. Malware detected: false, encrypted content detected: true")
})
t.Run("Malware and encrypted content detected", func(t *testing.T) {
httpClient := httpMock{StatusCode: 200, ResponseBody: "{\"malwareDetected\": true, \"encryptedContentDetected\": true, \"scanSize\":298782,\"mimeType\":\"application/octet-stream\",\"SHA256\":\"96ca802fbd54d31903f1115a1d95590c685160637d9262bd340ab30d0f817e85\"}"}
error := runMalwareScan(&malwareScanConfig, nil, nil, &httpClient)
assert.EqualError(t, error, "Malware scan failed for file 'target/myFile'. Malware detected: true, encrypted content detected: true")
})
}
func TestMalwareScanRequest(t *testing.T) {
httpClient := httpMock{StatusCode: 200, ResponseBody: "{\"malwareDetected\":false,\"encryptedContentDetected\":false,\"scanSize\":298782,\"mimeType\":\"application/octet-stream\",\"SHA256\":\"96ca802fbd54d31903f1115a1d95590c685160637d9262bd340ab30d0f817e85\"}"}
candidate := readCloserMock{Content: "HELLO"}
malWareScanResponse, err := sendMalwareScanRequest(&httpClient, "POST", "https://example.org/malwarescanner", candidate)
t.Run("Malware - scanRequest - check response body closed", func(t *testing.T) {
if assert.NoError(t, err) {
assert.True(t, httpClient.Body.Closed)
t.Run("Malware detected in file", func(t *testing.T) {
utils.returnScanResult = &malwarescan.ScanResult{
MalwareDetected: true,
EncryptedContentDetected: false,
ScanSize: 298782,
MimeType: "application/octet-stream",
SHA256: "96ca802fbd54d31903f1115a1d95590c685160637d9262bd340ab30d0f817e85",
Finding: "Win.Test.EICAR_HDB-1",
}
error := runMalwareScan(&malwareScanConfig, nil, &utils)
assert.EqualError(t, error, "Malware scan failed for file 'target/myFile'. Malware detected: true, encrypted content detected: false, finding: Win.Test.EICAR_HDB-1")
})
t.Run("Malware - scanRequest - check scan response", func(t *testing.T) {
if assert.NoError(t, err) {
assert.False(t, malWareScanResponse.MalwareDetected)
assert.False(t, malWareScanResponse.EncryptedContentDetected)
assert.Equal(t, 298782, malWareScanResponse.ScanSize)
assert.Equal(t, "96ca802fbd54d31903f1115a1d95590c685160637d9262bd340ab30d0f817e85", malWareScanResponse.SHA256)
t.Run("Encrypted content detected in file", func(t *testing.T) {
utils.returnScanResult = &malwarescan.ScanResult{
MalwareDetected: false,
EncryptedContentDetected: true,
ScanSize: 298782,
MimeType: "application/octet-stream",
SHA256: "96ca802fbd54d31903f1115a1d95590c685160637d9262bd340ab30d0f817e85",
}
})
}
func TestMalwareMarshalBody(t *testing.T) {
t.Run("Malware - marshalResponse - body uninitialized", func(t *testing.T) {
var body []byte
r, error := marshalResponse(body)
assert.Nil(t, r)
assert.EqualError(t, error, "Unmarshalling of response body failed. Body: '': unexpected end of JSON input")
})
}
func TestMalwareValidateResponse(t *testing.T) {
t.Run("Malware - validateResponse - HTTP error", func(t *testing.T) {
r, e := validateResponse(nil, fmt.Errorf("request error"))
assert.Nil(t, r)
assert.EqualError(t, e, "HTTP request failed with error: request error. Details: \"\"")
})
t.Run("Malware - validateResponse - empty response", func(t *testing.T) {
r, e := validateResponse(&http.Response{}, fmt.Errorf("request error"))
assert.Nil(t, r)
assert.EqualError(t, e, "HTTP request failed with error: request error. Details: \"\"")
error := runMalwareScan(&malwareScanConfig, nil, &utils)
assert.EqualError(t, error, "Malware scan failed for file 'target/myFile'. Malware detected: false, encrypted content detected: true, finding: ")
})
t.Run("Malware - validateResponse - status code 500", func(t *testing.T) {
mock := responseMock(500, true)
r, e := validateResponse(mock, nil)
assert.Nil(t, r)
assert.True(t, strings.HasPrefix(e.Error(), "Unexpected response code (500)"))
})
t.Run("Malware - validateResponse - malwareDetected", func(t *testing.T) {
mock := responseMock(200, true)
r, e := validateResponse(mock, nil)
assert.NotNil(t, r)
assert.Nil(t, e)
})
}
func responseMock(code int, malwareDetected bool) *http.Response {
if malwareDetected {
return &http.Response{StatusCode: code, Body: &readCloserMock{Content: "{\"malwareDetected\":true,\"encryptedContentDetected\":false,\"scanSize\":298782,\"mimeType\":\"application/octet-stream\",\"SHA256\":\"96ca802fbd54d31903f1115a1d95590c685160637d9262bd340ab30d0f817e85\"}"}}
}
return &http.Response{StatusCode: code, Body: &readCloserMock{Content: "{\"malwareDetected\":false,\"encryptedContentDetected\":false,\"scanSize\":298782,\"mimeType\":\"application/octet-stream\",\"SHA256\":\"96ca802fbd54d31903f1115a1d95590c685160637d9262bd340ab30d0f817e85\"}"}}
}
func TestMalwareFileNotFound(t *testing.T) {
httpClient := httpMock{StatusCode: 200}
open = func(path string) (io.ReadCloser, error) {
return nil, fmt.Errorf("open %s: no such file or directory", path)
}
defer func() { open = _open }()
error := runMalwareScan(&malwareScanConfig, nil, nil, &httpClient)
assert.Error(t, error, "open target/myFile: no such file or directory")
}
func TestMalwareInternalServerError(t *testing.T) {
httpClient := httpMock{StatusCode: 500, ResponseBody: "internal server error"}
open = openFileMock()
defer func() { open = _open }()
err := runMalwareScan(&malwareScanConfig, nil, nil, &httpClient)
assert.Error(t, err, "Unexpected response code (500). 200 expected. Details: \"internal server error\"")
}
func TestMalwareRetrieveHash(t *testing.T) {
open = openFileMock()
defer func() {
open = _open
}()
hash, err := _getSHA256("target/myFile")
if assert.NoError(t, err) {
assert.Equal(t, "3733cd977ff8eb18b987357e22ced99f46097f31ecb239e878ae63760e83e4d5", hash)
}
}
func openFileMock() func(path string) (io.ReadCloser, error) {
return func(path string) (io.ReadCloser, error) {
if path == "target/myFile" {
return &readCloserMock{Content: "HELLO"}, nil // the content does not matter
t.Run("Malware and encrypted content detected in file", func(t *testing.T) {
utils.returnScanResult = &malwarescan.ScanResult{
MalwareDetected: true,
EncryptedContentDetected: true,
ScanSize: 298782,
MimeType: "application/octet-stream",
SHA256: "96ca802fbd54d31903f1115a1d95590c685160637d9262bd340ab30d0f817e85",
Finding: "Win.Test.EICAR_HDB-1",
}
return nil, fmt.Errorf("open %s: no such file or directory", path)
error := runMalwareScan(&malwareScanConfig, nil, &utils)
assert.EqualError(t, error, "Malware scan failed for file 'target/myFile'. Malware detected: true, encrypted content detected: true, finding: Win.Test.EICAR_HDB-1")
})
t.Run("No file and no buildtool specified", func(t *testing.T) {
malwareScanConfig := malwareExecuteScanOptions{
Host: "https://example.org/malwarescanner",
Username: "me",
Password: "********",
Timeout: "60",
}
error := runMalwareScan(&malwareScanConfig, nil, nil)
assert.EqualError(t, error, "Please specify a file to be scanned")
})
t.Run("File to be scanned, can't be found", func(t *testing.T) {
utils.returnScanResult = nil
malwareScanConfig := malwareExecuteScanOptions{
Host: "https://example.org/malwarescanner",
Username: "me",
Password: "********",
Timeout: "60",
ScanFile: "target/fileWhichDoesntExist",
}
error := runMalwareScan(&malwareScanConfig, nil, &utils)
assert.EqualError(t, error, "the file 'target/fileWhichDoesntExist' does not exist: file does not exist")
})
}
func TestMalwareScanWithDockerAsBuildtoolTests(t *testing.T) {
files := &mock.FilesMock{}
files.AddFile("dockerimagename_latest.tar", []byte(``))
utils := malwareScanUtilsMockBundle{
FilesMock: files,
}
t.Run("No malware detected in docker image", func(t *testing.T) {
utils.returnScanResult = &malwarescan.ScanResult{
MalwareDetected: false,
EncryptedContentDetected: false,
ScanSize: 298782,
MimeType: "application/octet-stream",
SHA256: "96ca802fbd54d31903f1115a1d95590c685160637d9262bd340ab30d0f817e85",
}
malwareScanConfig := malwareExecuteScanOptions{
Host: "https://example.org/malwarescanner",
Username: "me",
Password: "********",
Timeout: "60",
BuildTool: "docker",
ScanImage: "dockerimagename:latest",
}
error := runMalwareScan(&malwareScanConfig, nil, &utils)
assert.NoError(t, error)
})
t.Run("No file and no buildtool specified", func(t *testing.T) {
malwareScanConfig := malwareExecuteScanOptions{
Host: "https://example.org/malwarescanner",
Username: "me",
Password: "********",
Timeout: "60",
}
error := runMalwareScan(&malwareScanConfig, nil, &utils)
assert.EqualError(t, error, "Please specify a file to be scanned")
})
}
type httpMock struct {
@ -211,6 +208,9 @@ func (c *httpMock) SetOptions(options piperhttp.ClientOptions) {
}
func (c *httpMock) SendRequest(method string, url string, r io.Reader, header http.Header, cookies []*http.Cookie) (*http.Response, error) {
if strings.HasSuffix(url, "/info") {
return &http.Response{StatusCode: 200, Body: &readCloserMock{Content: "{\"engineVersion\": \"Malware Service Mock\", \"signatureTimestamp\": \"2022-01-12T09:26:28.000Z\"}"}}, nil
}
c.Method = method
c.URL = url
@ -246,3 +246,24 @@ func (rc *readCloserMock) Close() error {
rc.Closed = true
return nil
}
type dockerClientMock struct {
imageName string
registryURL string
localPath string
includeLayers bool
}
func (c *dockerClientMock) GetImageSource() (string, error) {
return c.imageName, nil
}
//DownloadImageToPath download the image to the specified path
func (c *dockerClientMock) DownloadImageToPath(imageSource, filePath string) (pkgutil.Image, error) {
return pkgutil.Image{}, nil // fmt.Errorf("%s", filePath)
}
//TarImage write a tar from the given image
func (c *dockerClientMock) TarImage(writer io.Writer, image pkgutil.Image) error {
return nil
}

View File

@ -176,7 +176,7 @@ func runWhitesourceScan(config *ScanOptions, scan *ws.Scan, utils whitesourceUti
dClientOptions := piperDocker.ClientOptions{ImageName: saveImageOptions.ContainerImage, RegistryURL: saveImageOptions.ContainerRegistryURL, LocalPath: "", IncludeLayers: saveImageOptions.IncludeLayers}
dClient := &piperDocker.Client{}
dClient.SetOptions(dClientOptions)
if err := runContainerSaveImage(&saveImageOptions, &telemetry.CustomData{}, "./cache", "", dClient); err != nil {
if _, err := runContainerSaveImage(&saveImageOptions, &telemetry.CustomData{}, "./cache", "", dClient); err != nil {
if strings.Contains(fmt.Sprint(err), "no image found") {
log.SetErrorCategory(log.ErrorConfiguration)
}

View File

@ -0,0 +1,123 @@
package malwarescan
import (
"encoding/json"
"fmt"
piperhttp "github.com/SAP/jenkins-library/pkg/http"
"github.com/pkg/errors"
"io"
"io/ioutil"
"net/http"
)
// ScanResult : Returned by the scan endpoint of the malwarescan api of SAP CP
type ScanResult struct {
MalwareDetected bool `json:"malwareDetected"`
EncryptedContentDetected bool `json:"encryptedContentDetected"`
ScanSize int `json:"scanSize"`
Finding string `json:"finding,omitempty"`
MimeType string `json:"mimeType"`
SHA256 string `json:"SHA256"`
}
// Info : Returned by the info endpoint of the malwarescan api of SAP CP
type Info struct {
MaxScanSize int
SignatureTimestamp string
EngineVersion string
}
// ScanError : Returned by the malwarescan api of SAP CP in case of an error
type ScanError struct {
Message string
}
// Client : Interface for the malwarescan api provided by SAP CP (see https://api.sap.com/api/MalwareScanAPI/overview)
type Client interface {
Scan(candidate io.Reader) (*ScanResult, error)
Info() (*Info, error)
}
// ClientImpl : Client implementation of the malwarescan api provided by SAP CP (see https://api.sap.com/api/MalwareScanAPI/overview)
type ClientImpl struct {
HTTPClient piperhttp.Sender
Host string
}
// Scan : Triggers a malwarescan in SAP CP for the given content.
func (c *ClientImpl) Scan(candidate io.Reader) (*ScanResult, error) {
var scanResult ScanResult
headers := http.Header{}
headers.Add("Content-Type", "application/octet-stream")
err := c.sendAPIRequest("POST", "/scan", candidate, headers, &scanResult)
if err != nil {
return nil, err
}
return &scanResult, nil
}
// Info : Returns some information about the scanengine used by the malwarescan service.
func (c *ClientImpl) Info() (*Info, error) {
var info Info
err := c.sendAPIRequest("GET", "/info", nil, nil, &info)
if err != nil {
return nil, err
}
return &info, nil
}
func (c *ClientImpl) sendAPIRequest(method, endpoint string, body io.Reader, header http.Header, obj interface{}) error {
// piper http utils mashall some http response codes into errors. We wan't to check the status code
// ourselves hence we wait with returning that error (maybe also related to errors others than http status codes)
// sendRequest results in any combination of nil and non-nil response and error.
// a response body could even be already closed.
response, err := c.HTTPClient.SendRequest(method, c.Host+endpoint, body, header, nil)
if response.StatusCode != 200 {
var scanError ScanError
err = c.unmarshalResponse(response, &scanError)
if err != nil {
return fmt.Errorf("MalwareService returned with status code %d, no further information available", response.StatusCode)
}
return fmt.Errorf("MalwareService returned with status code %d: %s", response.StatusCode, scanError.Message)
}
return c.unmarshalResponse(response, obj)
}
func (c *ClientImpl) readBody(response *http.Response) ([]byte, error) {
if response != nil && response.Body != nil {
defer response.Body.Close()
return ioutil.ReadAll(response.Body)
}
return nil, fmt.Errorf("No response body available")
}
func (c *ClientImpl) unmarshalResponse(response *http.Response, obj interface{}) error {
body, err := c.readBody(response)
if err != nil {
return err
}
err = json.Unmarshal(body, obj)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("Unmarshalling of response body failed. Body: '%s'", body))
}
return err
}

View File

@ -0,0 +1,179 @@
package malwarescan
import (
"fmt"
piperhttp "github.com/SAP/jenkins-library/pkg/http"
"github.com/stretchr/testify/assert"
"io"
"io/ioutil"
"net/http"
"testing"
)
func TestMalwareServiceScan(t *testing.T) {
t.Run("Scan without finding", func(t *testing.T) {
httpClient := &httpMock{StatusCode: 200, ResponseBody: "{\"malwareDetected\":false,\"encryptedContentDetected\":false,\"scanSize\":298782,\"mimeType\":\"application/octet-stream\",\"SHA256\":\"96ca802fbd54d31903f1115a1d95590c685160637d9262bd340ab30d0f817e85\"}"}
malwareService := ClientImpl{
HTTPClient: httpClient,
Host: "https://example.org/malwarescanner",
}
candidate := readCloserMock{Content: "HELLO"}
scanResult, err := malwareService.Scan(candidate)
if assert.NoError(t, err) {
assert.True(t, httpClient.Body.Closed)
assert.Equal(t, "https://example.org/malwarescanner/scan", httpClient.URL)
assert.Equal(t, "POST", httpClient.Method)
if assert.NotNil(t, httpClient.Header) {
assert.Equal(t, "application/octet-stream", httpClient.Header.Get("Content-Type"))
}
assert.Equal(t, "application/octet-stream", scanResult.MimeType)
assert.Equal(t, 298782, scanResult.ScanSize)
assert.Equal(t, "96ca802fbd54d31903f1115a1d95590c685160637d9262bd340ab30d0f817e85", scanResult.SHA256)
assert.Equal(t, "", scanResult.Finding)
assert.False(t, scanResult.MalwareDetected)
assert.False(t, scanResult.EncryptedContentDetected)
}
})
t.Run("Scan without finding", func(t *testing.T) {
httpClient := &httpMock{StatusCode: 200, ResponseBody: "{\"malwareDetected\":true,\"encryptedContentDetected\":true,\"scanSize\":298782,\"mimeType\":\"application/octet-stream\",\"SHA256\":\"96ca802fbd54d31903f1115a1d95590c685160637d9262bd340ab30d0f817e85\", \"finding\": \"Description of the finding\"}"}
malwareService := ClientImpl{
HTTPClient: httpClient,
Host: "https://example.org/malwarescanner",
}
candidate := readCloserMock{Content: "HELLO"}
scanResult, err := malwareService.Scan(candidate)
if assert.NoError(t, err) {
assert.True(t, httpClient.Body.Closed)
assert.Equal(t, "https://example.org/malwarescanner/scan", httpClient.URL)
assert.Equal(t, "POST", httpClient.Method)
if assert.NotNil(t, httpClient.Header) {
assert.Equal(t, "application/octet-stream", httpClient.Header.Get("Content-Type"))
}
assert.Equal(t, "application/octet-stream", scanResult.MimeType)
assert.Equal(t, 298782, scanResult.ScanSize)
assert.Equal(t, "96ca802fbd54d31903f1115a1d95590c685160637d9262bd340ab30d0f817e85", scanResult.SHA256)
assert.Equal(t, "Description of the finding", scanResult.Finding)
assert.True(t, scanResult.MalwareDetected)
assert.True(t, scanResult.EncryptedContentDetected)
}
})
t.Run("Scan results in error - file to large", func(t *testing.T) {
httpClient := &httpMock{StatusCode: 413, ResponseBody: "{\"message\":\"Payload too large - The file is too large and cannot be scanned or the archive structure is too complex.\"}"}
malwareService := ClientImpl{
HTTPClient: httpClient,
Host: "https://example.org/malwarescanner",
}
candidate := readCloserMock{Content: "HELLO"}
scanResult, err := malwareService.Scan(candidate)
assert.Nil(t, scanResult)
assert.EqualError(t, err, "MalwareService returned with status code 413: Payload too large - The file is too large and cannot be scanned or the archive structure is too complex.")
})
t.Run("Scan results in error - unexpected error", func(t *testing.T) {
httpClient := &httpMock{StatusCode: 500, ResponseBody: ""}
malwareService := ClientImpl{
HTTPClient: httpClient,
Host: "https://example.org/malwarescanner",
}
candidate := readCloserMock{Content: "HELLO"}
scanResult, err := malwareService.Scan(candidate)
assert.Nil(t, scanResult)
assert.EqualError(t, err, "MalwareService returned with status code 500, no further information available")
})
}
func TestMalwareServiceInfo(t *testing.T) {
t.Run("Receives engine info", func(t *testing.T) {
httpClient := &httpMock{StatusCode: 200, ResponseBody: "{\"engineVersion\": \"Malware Service Mock\", \"signatureTimestamp\": \"2022-01-12T09:26:28.000Z\", \"maxScanSize\": 666}"}
malwareService := ClientImpl{
HTTPClient: httpClient,
Host: "https://example.org/malwarescanner",
}
info, err := malwareService.Info()
if assert.NoError(t, err) {
assert.True(t, httpClient.Body.Closed)
assert.Equal(t, "https://example.org/malwarescanner/info", httpClient.URL)
assert.Equal(t, "GET", httpClient.Method)
assert.Equal(t, "Malware Service Mock", info.EngineVersion)
assert.Equal(t, "2022-01-12T09:26:28.000Z", info.SignatureTimestamp)
assert.Equal(t, 666, info.MaxScanSize)
}
})
}
type httpMock struct {
Method string // is set during test execution
URL string // is set before test execution
ResponseBody string // is set before test execution
Options piperhttp.ClientOptions // is set during test
StatusCode int // is set during test
Body readCloserMock // is set during test
Header http.Header // is set during test
}
func (c *httpMock) SetOptions(options piperhttp.ClientOptions) {
c.Options = options
}
func (c *httpMock) SendRequest(method string, url string, r io.Reader, header http.Header, cookies []*http.Cookie) (*http.Response, error) {
c.Method = method
c.URL = url
c.Header = header
if r != nil {
_, err := ioutil.ReadAll(r)
if err != nil {
return nil, err
}
}
c.Body = readCloserMock{Content: c.ResponseBody}
res := http.Response{StatusCode: c.StatusCode, Body: &c.Body}
return &res, nil
}
type readCloserMock struct {
Content string
Closed bool
}
func (rc readCloserMock) Read(b []byte) (n int, err error) {
if len(b) < len(rc.Content) {
// in real life we would fill the buffer according to buffer size ...
return 0, fmt.Errorf("Buffer size (%d) not sufficient, need: %d", len(b), len(rc.Content))
}
copy(b, rc.Content)
return len(rc.Content), io.EOF
}
func (rc *readCloserMock) Close() error {
rc.Closed = true
return nil
}

View File

@ -505,6 +505,15 @@ type FileMock struct {
content []byte
}
// Reads the content of the mock
func (f *FileMock) Read(b []byte) (n int, err error) {
for i, p := range f.content {
b[i] = p
}
return len(f.content), nil
}
// Close mocks freeing the associated OS resources.
func (f *FileMock) Close() error {
f.files = nil
@ -542,7 +551,7 @@ func (f *FileMock) Write(p []byte) (n int, err error) {
// Instead, it returns a pointer to a FileMock instance, which implements a number of the same methods as os.File.
// The flag parameter is checked for os.O_CREATE and os.O_APPEND and behaves accordingly.
func (f *FilesMock) Open(path string, flag int, perm os.FileMode) (*FileMock, error) {
if f.files == nil && flag&os.O_CREATE == 0 {
if (f.files == nil || !f.HasFile(path)) && flag&os.O_CREATE == 0 {
return nil, fmt.Errorf("the file '%s' does not exist: %w", path, os.ErrNotExist)
}
f.init()

View File

@ -4,6 +4,7 @@ import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/sha256"
"errors"
"fmt"
"io"
@ -390,3 +391,20 @@ func (f Files) Abs(path string) (string, error) {
func (f Files) Symlink(oldname, newname string) error {
return os.Symlink(oldname, newname)
}
// Computes a SHA256 for a given file
func (f Files) SHA256(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
_, err = io.Copy(hash, file)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", string(hash.Sum(nil))), nil
}

View File

@ -1,8 +1,8 @@
metadata:
name: malwareExecuteScan
description: Performs a malware scan
description: Performs a malware scan using the [SAP Malware Scanning Service](https://help.sap.com/viewer/b416237f818c4e2e827f6118640079f8/LATEST/en-US/b7c9b86fe724458086a502df3160f380.html).
longDescription: |
Performs a malware scan
Performs a malware scan using the [SAP Malware Scanning Service](https://help.sap.com/viewer/b416237f818c4e2e827f6118640079f8/LATEST/en-US/b7c9b86fe724458086a502df3160f380.html).
spec:
inputs:
secrets:
@ -10,6 +10,56 @@ spec:
description: Jenkins 'Username with password' credentials ID containing the technical user/password credential used to communicate with the malwarescanning service.
type: jenkins
params:
- name: buildTool
type: string
description: "Defines the tool which is used for building the artifact."
mandatory: true
scope:
- GENERAL
- PARAMETERS
- STAGES
- STEPS
resourceRef:
- name: commonPipelineEnvironment
param: buildTool
- name: dockerConfigJSON
type: string
description: Path to the file `.docker/config.json` - this is typically provided by your CI/CD system. You can find more details about the Docker credentials in the [Docker documentation](https://docs.docker.com/engine/reference/commandline/login/).
scope:
- PARAMETERS
- STAGES
- STEPS
secret: true
resourceRef:
- name: commonPipelineEnvironment
param: custom/dockerConfigJSON
- name: dockerConfigJsonCredentialsId
type: secret
- type: vaultSecretFile
name: dockerConfigFileVaultSecretName
default: docker-config
- name: containerRegistryPassword
description: "For `buildTool: docker`: Password for container registry access - typically provided by the CI/CD environment."
type: string
scope:
- PARAMETERS
- STAGES
- STEPS
secret: true
resourceRef:
- name: commonPipelineEnvironment
param: custom/repositoryPassword
- name: containerRegistryUser
description: "For `buildTool: docker`: Username for container registry access - typically provided by the CI/CD environment."
type: string
scope:
- PARAMETERS
- STAGES
- STEPS
secret: true
resourceRef:
- name: commonPipelineEnvironment
param: custom/repositoryUsername
- name: host
type: string
description: "malware scanning host."
@ -50,14 +100,44 @@ spec:
- name: malwareScanPasswordVaultSecretName
type: vaultSecret
default: malware-scan
- name: file
- name: scanImage
type: string
description: "For `buildTool: docker`: Defines the docker image which should be scanned."
resourceRef:
- name: commonPipelineEnvironment
param: container/imageNameTag
scope:
- PARAMETERS
- STAGES
- STEPS
- name: scanImageIncludeLayers
type: bool
description: "For `buildTool: docker`: Defines if layers should be included."
scope:
- PARAMETERS
- STAGES
- STEPS
default: true
- name: scanImageRegistryUrl
type: string
description: "For `buildTool: docker`: Defines the registry where the scanImage is located."
resourceRef:
- name: commonPipelineEnvironment
param: container/registryUrl
scope:
- PARAMETERS
- STAGES
- STEPS
- name: scanFile
aliases:
- name: file
deprecated: true
type: string
description: "The file which is scanned for malware"
scope:
- PARAMETERS
- STAGES
- STEPS
mandatory: true
- name: timeout
type: string
description: "timeout for http layer in seconds"
@ -67,3 +147,20 @@ spec:
- STEPS
mandatory: false
default: 600
- name: reportFileName
type: string
description: The file name of the report to be created
scope:
- PARAMETERS
- STAGES
- STEPS
default: malwarescan_report.json
outputs:
resources:
- name: reports
type: reports
params:
- filePattern: "**/toolrun_malwarescan_*.json"
type: malwarescan
- paramRef: reportFileName
type: malwarescan