381 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Go
		
	
	
	
			
		
		
	
	
			381 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Go
		
	
	
	
| /*
 | |
| Copyright 2021.
 | |
| 
 | |
| Licensed under the Apache License, Version 2.0 (the "License");
 | |
| you may not use this file except in compliance with the License.
 | |
| You may obtain a copy of the License at
 | |
| 
 | |
|     http://www.apache.org/licenses/LICENSE-2.0
 | |
| 
 | |
| Unless required by applicable law or agreed to in writing, software
 | |
| distributed under the License is distributed on an "AS IS" BASIS,
 | |
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | |
| See the License for the specific language governing permissions and
 | |
| limitations under the License.
 | |
| */
 | |
| 
 | |
| package v1alpha2
 | |
| 
 | |
| import (
 | |
| 	"compress/gzip"
 | |
| 	"encoding/json"
 | |
| 	"errors"
 | |
| 	"io"
 | |
| 	"net/http"
 | |
| 	"os"
 | |
| 	"time"
 | |
| 
 | |
| 	"github.com/jenkinsci/kubernetes-operator/pkg/log"
 | |
| 	"github.com/jenkinsci/kubernetes-operator/pkg/plugins"
 | |
| 
 | |
| 	"golang.org/x/mod/semver"
 | |
| 	"k8s.io/apimachinery/pkg/runtime"
 | |
| 	ctrl "sigs.k8s.io/controller-runtime"
 | |
| 	logf "sigs.k8s.io/controller-runtime/pkg/log"
 | |
| 	"sigs.k8s.io/controller-runtime/pkg/webhook"
 | |
| 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
 | |
| )
 | |
| 
 | |
| var (
 | |
| 	jenkinslog                                                = logf.Log.WithName("jenkins-resource") // log is for logging in this package.
 | |
| 	SecValidator                                              = *NewSecurityValidator()
 | |
| 	_                                       webhook.Validator = &Jenkins{}
 | |
| 	initialSecurityWarningsDownloadSucceded                   = false
 | |
| )
 | |
| 
 | |
| const (
 | |
| 	Hosturl                      = "https://ci.jenkins.io/job/Infra/job/plugin-site-api/job/generate-data/lastSuccessfulBuild/artifact/plugins.json.gzip"
 | |
| 	PluginDataFileCompressedPath = "/tmp/plugins.json.gzip"
 | |
| 	PluginDataFile               = "/tmp/plugins.json"
 | |
| 	shortenedCheckingPeriod      = 1 * time.Hour
 | |
| 	defaultCheckingPeriod        = 12 * time.Minute
 | |
| )
 | |
| 
 | |
| func (in *Jenkins) SetupWebhookWithManager(mgr ctrl.Manager) error {
 | |
| 	return ctrl.NewWebhookManagedBy(mgr).
 | |
| 		For(in).
 | |
| 		Complete()
 | |
| }
 | |
| 
 | |
| // TODO(user): change verbs to "verbs=create;update;delete" if you want to enable deletion validation.
 | |
| // +kubebuilder:webhook:path=/validate-jenkins-io-jenkins-io-v1alpha2-jenkins,mutating=false,failurePolicy=fail,sideEffects=None,groups=jenkins.io.jenkins.io,resources=jenkins,verbs=create;update,versions=v1alpha2,name=vjenkins.kb.io,admissionReviewVersions={v1}
 | |
| 
 | |
| // ValidateCreate implements webhook.Validator so a webhook will be registered for the type
 | |
| func (in *Jenkins) ValidateCreate() (admission.Warnings, error) {
 | |
| 	if in.Spec.ValidateSecurityWarnings {
 | |
| 		jenkinslog.Info("validate create", "name", in.Name)
 | |
| 		err := Validate(*in)
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	return nil, nil
 | |
| }
 | |
| 
 | |
| // ValidateUpdate implements webhook.Validator so a webhook will be registered for the type
 | |
| func (in *Jenkins) ValidateUpdate(old runtime.Object) (admission.Warnings, error) {
 | |
| 	if in.Spec.ValidateSecurityWarnings {
 | |
| 		jenkinslog.Info("validate update", "name", in.Name)
 | |
| 		return nil, Validate(*in)
 | |
| 	}
 | |
| 
 | |
| 	return nil, nil
 | |
| }
 | |
| 
 | |
| func (in *Jenkins) ValidateDelete() (admission.Warnings, error) {
 | |
| 	return nil, nil
 | |
| }
 | |
| 
 | |
| type SecurityValidator struct {
 | |
| 	PluginDataCache PluginsInfo
 | |
| 	isCached        bool
 | |
| 	Attempts        int
 | |
| 	checkingPeriod  time.Duration
 | |
| }
 | |
| 
 | |
| type PluginsInfo struct {
 | |
| 	Plugins []PluginInfo `json:"plugins"`
 | |
| }
 | |
| 
 | |
| type PluginInfo struct {
 | |
| 	Name             string    `json:"name"`
 | |
| 	SecurityWarnings []Warning `json:"securityWarnings"`
 | |
| }
 | |
| 
 | |
| type Warning struct {
 | |
| 	Versions []Version `json:"versions"`
 | |
| 	ID       string    `json:"id"`
 | |
| 	Message  string    `json:"message"`
 | |
| 	URL      string    `json:"url"`
 | |
| 	Active   bool      `json:"active"`
 | |
| }
 | |
| 
 | |
| type Version struct {
 | |
| 	FirstVersion string `json:"firstVersion"`
 | |
| 	LastVersion  string `json:"lastVersion"`
 | |
| }
 | |
| 
 | |
| type PluginData struct {
 | |
| 	Version string
 | |
| 	Kind    string
 | |
| }
 | |
| 
 | |
| // Validates security warnings for both updating and creating a Jenkins CR
 | |
| func Validate(r Jenkins) error {
 | |
| 	if !SecValidator.isCached {
 | |
| 		return errors.New("plugins data has not been fetched")
 | |
| 	}
 | |
| 
 | |
| 	pluginSet := make(map[string]PluginData)
 | |
| 	var faultyBasePlugins string
 | |
| 	var faultyUserPlugins string
 | |
| 	basePlugins := plugins.BasePlugins()
 | |
| 
 | |
| 	for _, plugin := range basePlugins {
 | |
| 		// Only Update the map if the plugin is not present or a lower version is being used
 | |
| 		if pluginData, ispresent := pluginSet[plugin.Name]; !ispresent || semver.Compare(makeSemanticVersion(plugin.Version), pluginData.Version) == 1 {
 | |
| 			pluginSet[plugin.Name] = PluginData{Version: plugin.Version, Kind: "base"}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	for _, plugin := range r.Spec.Master.Plugins {
 | |
| 		if pluginData, ispresent := pluginSet[plugin.Name]; !ispresent || semver.Compare(makeSemanticVersion(plugin.Version), pluginData.Version) == 1 {
 | |
| 			pluginSet[plugin.Name] = PluginData{Version: plugin.Version, Kind: "user-defined"}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	for _, plugin := range SecValidator.PluginDataCache.Plugins {
 | |
| 		if pluginData, ispresent := pluginSet[plugin.Name]; ispresent {
 | |
| 			var hasVulnerabilities bool
 | |
| 			for _, warning := range plugin.SecurityWarnings {
 | |
| 				for _, version := range warning.Versions {
 | |
| 					firstVersion := version.FirstVersion
 | |
| 					lastVersion := version.LastVersion
 | |
| 					if len(firstVersion) == 0 {
 | |
| 						firstVersion = "0" // setting default value in case of empty string
 | |
| 					}
 | |
| 					if len(lastVersion) == 0 {
 | |
| 						lastVersion = pluginData.Version // setting default value in case of empty string
 | |
| 					}
 | |
| 					// checking if this warning applies to our version as well
 | |
| 					if compareVersions(firstVersion, lastVersion, pluginData.Version) {
 | |
| 						jenkinslog.Info("Security Vulnerability detected in "+pluginData.Kind+" "+plugin.Name+":"+pluginData.Version, "Warning message", warning.Message, "For more details,check security advisory", warning.URL)
 | |
| 						hasVulnerabilities = true
 | |
| 					}
 | |
| 				}
 | |
| 			}
 | |
| 
 | |
| 			if hasVulnerabilities {
 | |
| 				if pluginData.Kind == "base" {
 | |
| 					faultyBasePlugins += "\n" + plugin.Name + ":" + pluginData.Version
 | |
| 				} else {
 | |
| 					faultyUserPlugins += "\n" + plugin.Name + ":" + pluginData.Version
 | |
| 				}
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 	if len(faultyBasePlugins) > 0 || len(faultyUserPlugins) > 0 {
 | |
| 		var errormsg string
 | |
| 		if len(faultyBasePlugins) > 0 {
 | |
| 			errormsg += "security vulnerabilities detected in the following base plugins: " + faultyBasePlugins
 | |
| 		}
 | |
| 		if len(faultyUserPlugins) > 0 {
 | |
| 			errormsg += "security vulnerabilities detected in the following user-defined plugins: " + faultyUserPlugins
 | |
| 		}
 | |
| 		return errors.New(errormsg)
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // NewMonitor creates a new worker and instantiates all the data structures required
 | |
| func NewSecurityValidator() *SecurityValidator {
 | |
| 	return &SecurityValidator{
 | |
| 		isCached:       false,
 | |
| 		Attempts:       0,
 | |
| 		checkingPeriod: shortenedCheckingPeriod,
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (in *SecurityValidator) MonitorSecurityWarnings(securityWarningsFetched chan bool) {
 | |
| 	jenkinslog.Info("Security warnings check: enabled\n")
 | |
| 	for {
 | |
| 		in.checkForSecurityVulnerabilities(securityWarningsFetched)
 | |
| 		<-time.After(in.checkingPeriod)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (in *SecurityValidator) checkForSecurityVulnerabilities(securityWarningsFetched chan bool) {
 | |
| 	err := in.fetchPluginData()
 | |
| 	if err != nil {
 | |
| 		jenkinslog.Info("Cache plugin data", "failed to fetch plugin data", err)
 | |
| 		in.checkingPeriod = shortenedCheckingPeriod
 | |
| 		return
 | |
| 	}
 | |
| 	in.isCached = true
 | |
| 	in.checkingPeriod = defaultCheckingPeriod
 | |
| 
 | |
| 	// should only be executed once when the operator starts
 | |
| 	if !initialSecurityWarningsDownloadSucceded {
 | |
| 		securityWarningsFetched <- in.isCached
 | |
| 		initialSecurityWarningsDownloadSucceded = true
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // Downloads extracts and reads the JSON data in every 12 hours
 | |
| func (in *SecurityValidator) fetchPluginData() error {
 | |
| 	jenkinslog.Info("Initializing/Updating the plugin data cache")
 | |
| 	var err error
 | |
| 	for in.Attempts = 0; in.Attempts < 5; in.Attempts++ {
 | |
| 		err = in.download()
 | |
| 		if err != nil {
 | |
| 			jenkinslog.V(log.VDebug).Info("Cache Plugin Data", "failed to download file", err)
 | |
| 			continue
 | |
| 		}
 | |
| 		break
 | |
| 	}
 | |
| 
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	for in.Attempts = 0; in.Attempts < 5; in.Attempts++ {
 | |
| 		err = in.extract()
 | |
| 		if err != nil {
 | |
| 			jenkinslog.V(log.VDebug).Info("Cache Plugin Data", "failed to extract file", err)
 | |
| 			continue
 | |
| 		}
 | |
| 		break
 | |
| 	}
 | |
| 
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	for in.Attempts = 0; in.Attempts < 5; in.Attempts++ {
 | |
| 		err = in.cache()
 | |
| 		if err != nil {
 | |
| 			jenkinslog.V(log.VDebug).Info("Cache Plugin Data", "failed to read plugin data file", err)
 | |
| 			continue
 | |
| 		}
 | |
| 		break
 | |
| 	}
 | |
| 
 | |
| 	return err
 | |
| }
 | |
| 
 | |
| func (in *SecurityValidator) download() error {
 | |
| 	pluginDataFileCompressed, err := os.Create(PluginDataFileCompressedPath)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	// ensure pluginDataFileCompressed is closed
 | |
| 	defer func() {
 | |
| 		if err := pluginDataFileCompressed.Close(); err != nil {
 | |
| 			jenkinslog.V(log.VDebug).Info("Failed to close SecurityValidator.download io", "error", err)
 | |
| 		}
 | |
| 	}()
 | |
| 
 | |
| 	req, err := http.NewRequest(http.MethodGet, Hosturl, nil)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	req.Header.Set("Content-Type", "application/json")
 | |
| 
 | |
| 	Client := http.Client{
 | |
| 		Timeout: 1 * time.Minute,
 | |
| 	}
 | |
| 
 | |
| 	response, err := Client.Do(req)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	defer httpResponseCloser(response)
 | |
| 
 | |
| 	_, err = io.Copy(pluginDataFileCompressed, response.Body)
 | |
| 
 | |
| 	return err
 | |
| }
 | |
| 
 | |
| func (in *SecurityValidator) extract() error {
 | |
| 	reader, err := os.Open(PluginDataFileCompressedPath)
 | |
| 
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	defer func() {
 | |
| 		if err := reader.Close(); err != nil {
 | |
| 			log.Log.Error(err, "failed to close SecurityValidator.extract.reader ")
 | |
| 		}
 | |
| 	}()
 | |
| 
 | |
| 	archive, err := gzip.NewReader(reader)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	defer func() {
 | |
| 		if err := archive.Close(); err != nil {
 | |
| 			log.Log.Error(err, "failed to close SecurityValidator.extract.archive ")
 | |
| 		}
 | |
| 	}()
 | |
| 	writer, err := os.Create(PluginDataFile)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	defer func() {
 | |
| 		if err := writer.Close(); err != nil {
 | |
| 			log.Log.Error(err, "failed to close SecurityValidator.extract.writer")
 | |
| 		}
 | |
| 	}()
 | |
| 
 | |
| 	_, err = io.Copy(writer, archive)
 | |
| 	return err
 | |
| }
 | |
| 
 | |
| // Loads the JSON data into memory and stores it
 | |
| func (in *SecurityValidator) cache() error {
 | |
| 	jsonFile, err := os.Open(PluginDataFile)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	defer func() {
 | |
| 		if err := jsonFile.Close(); err != nil {
 | |
| 			log.Log.Error(err, "failed to close SecurityValidator.cache.jsonFile")
 | |
| 		}
 | |
| 	}()
 | |
| 	byteValue, err := io.ReadAll(jsonFile)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	err = json.Unmarshal(byteValue, &in.PluginDataCache)
 | |
| 	return err
 | |
| }
 | |
| 
 | |
| // returns a semantic version that can be used for comparison, allowed versioning format vMAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH
 | |
| func makeSemanticVersion(version string) string {
 | |
| 	if version[0] != 'v' {
 | |
| 		version = "v" + version
 | |
| 	}
 | |
| 	return semver.Canonical(version)
 | |
| }
 | |
| 
 | |
| // Compare if the current version lies between first version and last version
 | |
| func compareVersions(firstVersion string, lastVersion string, pluginVersion string) bool {
 | |
| 	firstSemVer := makeSemanticVersion(firstVersion)
 | |
| 	lastSemVer := makeSemanticVersion(lastVersion)
 | |
| 	pluginSemVer := makeSemanticVersion(pluginVersion)
 | |
| 	if semver.Compare(pluginSemVer, firstSemVer) == -1 || semver.Compare(pluginSemVer, lastSemVer) == 1 {
 | |
| 		return false
 | |
| 	}
 | |
| 	return true
 | |
| }
 | |
| 
 | |
| func httpResponseCloser(response *http.Response) {
 | |
| 	if err := response.Body.Close(); err != nil {
 | |
| 		log.Log.Error(err, "failed to close http response body")
 | |
| 	}
 | |
| }
 |