diff --git a/producers/mobsf/BUILD b/producers/mobsf/BUILD new file mode 100644 index 0000000..0f2035c --- /dev/null +++ b/producers/mobsf/BUILD @@ -0,0 +1,28 @@ +subinclude("@third_party/subrepos/pleasings//docker") + +go_binary( + name = "entrypoint", + srcs = [ + "cli.go", + "main.go", + "project.go", + ], + out = "entrypoint", + deps = [ + "//api/proto:v1", + "//pkg/template:template", + "//producers:producers", + "//producers/mobsf/report:report", + "//producers/mobsf/report/android:android", + "//producers/mobsf/report/ios:ios", + ], +) + +docker_image( + name = "mobsf", + srcs = [ + ":entrypoint", + ], + dockerfile = "Dockerfile-producer-mobsf", + image = "dracon-producer-mobsf", +) diff --git a/producers/mobsf/Dockerfile-producer-mobsf b/producers/mobsf/Dockerfile-producer-mobsf new file mode 100644 index 0000000..eabb0a5 --- /dev/null +++ b/producers/mobsf/Dockerfile-producer-mobsf @@ -0,0 +1,6 @@ +FROM opensecurity/mobile-security-framework-mobsf:v3.1.1 as mobsf + +COPY /entrypoint / + +WORKDIR / +ENTRYPOINT ["/entrypoint"] diff --git a/producers/mobsf/cli.go b/producers/mobsf/cli.go new file mode 100644 index 0000000..3e6675c --- /dev/null +++ b/producers/mobsf/cli.go @@ -0,0 +1,76 @@ +package main + +import ( + "fmt" + "regexp" + "strings" +) + +// Exclusions represents a list of MobSF static analysis scan rules whose +// findings should be ignored when scan reports are being processed by the tool. +// A rule is given by its ID (the value of the "id" key in the YAML files in the +// directories below), and must be prefixed with either "android." or "ios." as +// appropriate. +// - Android: https://github.com/MobSF/Mobile-Security-Framework-MobSF/tree/master/StaticAnalyzer/views/android/rules +// - iOS: https://github.com/MobSF/Mobile-Security-Framework-MobSF/tree/master/StaticAnalyzer/views/ios/rules +type Exclusions struct { + All []string + PerPlatform map[string]map[string]bool +} + +// String returns the Exclusions in its canonical string form (a comma-delimited +// list of values in the order in which they were added). +func (e *Exclusions) String() string { + if e.All == nil { + return "" + } + return strings.Join(e.All, ",") +} + +// Set defines a value for the Exclusions, given a comma-delimited list of +// values as a string. +func (e *Exclusions) Set(value string) error { + for _, id := range strings.Split(value, ",") { + if found, _ := regexp.MatchString(`^(android|ios)\.`, id); !found { + return fmt.Errorf("rule ID must begin with either 'android.' or 'ios.'") + } + + e.All = append(e.All, id) + + split := strings.SplitN(id, ".", 2) + platform := split[0] + mobSFID := split[1] + if _, found := e.PerPlatform[platform]; !found { + e.PerPlatform[platform] = make(map[string]bool) + } + e.PerPlatform[platform][mobSFID] = true + } + + return nil +} + +// SetFor returns a map whose keys represent rule IDs that should be excluded +// when scanning projects for the given platform. +func (e *Exclusions) SetFor(platform string) map[string]bool { + if _, found := e.PerPlatform[platform]; found { + return e.PerPlatform[platform] + } else { + return map[string]bool{} + } +} + +// CLI represents the command line options supported by this tool. +type CLI struct { + InPath string + OutPath string + CodeAnalysisExclusions Exclusions +} + +// NewCLI creates and initialises a new CLI struct. +func NewCLI() *CLI { + cli := new(CLI) + + cli.CodeAnalysisExclusions.PerPlatform = make(map[string]map[string]bool) + + return cli +} diff --git a/producers/mobsf/main.go b/producers/mobsf/main.go new file mode 100644 index 0000000..36f4d35 --- /dev/null +++ b/producers/mobsf/main.go @@ -0,0 +1,534 @@ +// Package main implements a Dracon producer for MobSF, a mobile security +// framework (https://github.com/MobSF/Mobile-Security-Framework-MobSF). The +// producer acts as a wrapper around MobSF, handling the initialisation of the +// MobSF web server, the identification of individual MobSF-compatible mobile +// app projects within the target code base, the compression and uploading of +// these projects to MobSF, the retrieval of Android and iOS scan reports, and +// the conversion of scan reports into a Dracon Issues protobuf. +package main + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/json" + "flag" + "fmt" + "io" + "io/ioutil" + "log" + "math/rand" + "mime/multipart" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/thought-machine/dracon/api/proto/v1" + dtemplate "github.com/thought-machine/dracon/pkg/template" + "github.com/thought-machine/dracon/producers" + mreport "github.com/thought-machine/dracon/producers/mobsf/report" + "github.com/thought-machine/dracon/producers/mobsf/report/android" + "github.com/thought-machine/dracon/producers/mobsf/report/ios" +) + +const ( + MobSFBindHost = "127.0.0.1" + MobSFBindPort = 8080 +) + +var MobSFAPIKey = generateAPIKey() + +// parseCLIOptions returns a CLI struct representing the command line options +// that were passed to this tool. +func parseCLIOptions() *CLI { + cli := NewCLI() + + flag.StringVar( + &cli.InPath, + "in", + dtemplate.TemplateVars.ProducerSourcePath, + "Path to directory containing source code to scan", + ) + + flag.StringVar( + &cli.OutPath, + "out", + dtemplate.TemplateVars.ProducerOutPath, + "Path to which Dracon Issues protobuf should be written", + ) + + flag.Var( + &cli.CodeAnalysisExclusions, + "exclude", + "Comma-delimited list of static analysis rule IDs to ignore", + ) + + flag.Parse() + + // So producers.WriteDraconOut knows where to write to: + producers.OutFile = cli.OutPath + + return cli +} + +// generateAPIKey generates an API key for the MobSF REST API. The generated key +// has low entropy, but MobSF listens locally and only runs for the duration of +// the code base scan, so this shouldn't be a problem. +func generateAPIKey() string { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + hash := sha256.Sum256([]byte(strconv.Itoa(r.Int()))) + return fmt.Sprintf("%x", hash) +} + +// startMobSF spawns MobSF in a child process and returns the PID of that +// process. +func startMobSF() (int, error) { + bindArg := fmt.Sprintf("--bind=%s:%d", MobSFBindHost, MobSFBindPort) + + mobSF := exec.Command( + "/usr/local/bin/gunicorn", + "--daemon", bindArg, "--workers=1", "--threads=10", "--timeout=1800", + "MobSF.wsgi:application", + ) + mobSF.Env = append(os.Environ(), "MOBSF_API_KEY=" + MobSFAPIKey) + mobSF.Dir = "/root/Mobile-Security-Framework-MobSF" + + if err := mobSF.Run(); err != nil { + return 0, err + } + + return mobSF.Process.Pid, nil +} + +// isAPIResponsive returns true if the MobSF REST API is responding to requests, +// or false if it is not. +func isAPIResponsive() bool { + client := http.Client{Timeout: 1 * time.Second} + + resp, err := client.Get(fmt.Sprintf("http://%s:%d/api/v1", MobSFBindHost, MobSFBindPort)) + if err != nil { + return false + } + + return strings.Contains(resp.Header.Get("Access-Control-Allow-Headers"), "Authorization") +} + +// findProjects searches a directory tree for Android and iOS projects, and +// returns a list of directories that represent the root directories for the +// discovered projects. +func findProjects(path string) ([]*Project, error) { + if dir, err := os.Stat(path); err != nil || !dir.IsDir() { + return nil, fmt.Errorf("%s is not a directory", path) + } + + projects := make([]*Project, 0) + err := filepath.Walk(path, func(f string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + + if !fi.IsDir() { + return nil + } + + var projectType *ProjectType + + if isAndroidEclipseProject(f) { + projectType = new(ProjectType) + *projectType = AndroidEclipse + } else if isAndroidStudioProject(f) { + projectType = new(ProjectType) + *projectType = AndroidStudio + } else if isXcodeiOSProject(f) { + projectType = new(ProjectType) + *projectType = XcodeIos + } + + if projectType != nil { + log.Printf("Found %s project at %s\n", *projectType, f) + projects = append(projects, &Project{ + RootDir: f, + Type: *projectType, + }) + + // If this directory is a supported project type, there's no need to + // walk its contents + return filepath.SkipDir + } + + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to walk directory %s: %w", path, err) + } + + return projects, nil +} + +// isAndroidEclipseProject returns true if the given path is the root directory +// of an Android Eclipse project, or false if not. +// +// The following conditions must hold for a directory structure to be considered +// an Android Eclipse project by MobSF: +// - the file AndroidManifest.xml must exist; +// - the directory src/ must exist. +func isAndroidEclipseProject(path string) bool { + if f, err := os.Stat(path); err != nil || !f.IsDir() { + return false + } + + f := filepath.Join(path, "AndroidManifest.xml") + fi, err := os.Stat(f) + if err != nil { + if !os.IsNotExist(err) { + log.Printf("Failed to stat %s: %v\n", f, err) + } + return false + } + if !fi.Mode().IsRegular() { + return false + } + + f = filepath.Join(path, "src") + fi, err = os.Stat(f) + if err != nil { + if !os.IsNotExist(err) { + log.Printf("Failed to stat %s: %v\n", f, err) + } + return false + } + if !fi.IsDir() { + return false + } + + return true +} + +// isAndroidStudioProject returns true if the given path is the root directory +// of an Android Studio project, or false if not. +// +// The following conditions must hold for a directory structure to be considered +// an Android Studio project by MobSF: +// - the file app/src/main/AndroidManifest.xml must exist; +// - the directory app/src/main/java/ must exist. +func isAndroidStudioProject(path string) bool { + if f, err := os.Stat(path); err != nil || !f.IsDir() { + return false + } + + f := filepath.Join(path, "app", "src", "main", "AndroidManifest.xml") + fi, err := os.Stat(f) + if err != nil { + if !os.IsNotExist(err) { + log.Printf("Failed to stat %s: %v\n", f, err) + } + return false + } + if !fi.Mode().IsRegular() { + return false + } + + f = filepath.Join(path, "app", "src", "main", "java") + fi, err = os.Stat(f) + if err != nil { + if !os.IsNotExist(err) { + log.Printf("Failed to stat %s: %v\n", f, err) + } + return false + } + if !fi.IsDir() { + return false + } + + return true +} + +// isXcodeiOSProject returns true if the given path is the root directory of an +// Xcode iOS project, or false if not. +// +// The following conditions must hold for a directory structure to be considered +// an Xcode iOS project by MobSF: +// - a directory with the suffix .xcodeproj must exist; +// - a file named Info.plist must exist somewhere in the directory structure. +func isXcodeiOSProject(path string) bool { + if f, err := os.Stat(path); err != nil || !f.IsDir() { + return false + } + + files, err := ioutil.ReadDir(path) + if err != nil { + log.Printf("Failed to read directory %s: %v\n", path, err) + return false + } + hasXcodeproj := false + for _, file := range files { + if strings.HasSuffix(file.Name(), ".xcodeproj") { + hasXcodeproj = true + break + } + } + if !hasXcodeproj { + return false + } + + hasinfoPlist := false + err = filepath.Walk(path, func(f string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + + if _, fc := filepath.Split(f); fc == "Info.plist" && fi.Mode().IsRegular() { + hasinfoPlist = true + return io.EOF // Target file found - stop walking + } + + return nil + }) + if err != nil && err != io.EOF { + log.Printf("Failed to walk directory %s: %v\n", path, err) + return false + } + if !hasinfoPlist { + return false + } + + return true +} + +// generateZipFile generates a zip file containing the files in the directory at +// the given path and writes the resulting zip file to the given io.Writer. +func generateZipFile(path string, dest io.Writer) error { + if dir, err := os.Stat(path); err != nil || !dir.IsDir() { + return fmt.Errorf("%s is not a directory", path) + } + + zipWriter := zip.NewWriter(dest) + err := filepath.Walk(path, func(f string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + + if fi.IsDir() { + return nil + } + + relPath, err := filepath.Rel(path, f) + if err != nil { + return err + } + + log.Printf("Adding: %s\n", relPath) + + zipMember, err := zipWriter.Create(relPath) + if err != nil { + return err + } + + fh, err := os.Open(f) + if err != nil { + return err + } + + if _, err = io.Copy(zipMember, fh); err != nil { + return err + } + + return nil + }) + if err != nil { + return err + } + + zipWriter.Close() + if err != nil { + return err + } + + return nil +} + +// uploadProjects uploads the project code bases in the given directories to +// MobSF, and returns a mapping from each project directory path to its +// corresponding MobSFFile representing the zip file containing that project +// code base in MobSF. +func uploadProjects(projects []*Project) error { + for _, p := range projects { + // Generate zip file from project code base + log.Printf("Generating zip file for project in %s\n", p.RootDir) + body := new(bytes.Buffer) + multipartWriter := multipart.NewWriter(body) + zipName := fmt.Sprintf("%x.zip", sha256.Sum256([]byte(p.RootDir))) + part, err := multipartWriter.CreateFormFile("file", zipName) + if err != nil { + return fmt.Errorf("failed to open multipart writer: %w", err) + } + if err := generateZipFile(p.RootDir, part); err != nil { + return fmt.Errorf("failed to generate zip file: %w", err) + } + if err := multipartWriter.Close(); err != nil { + return fmt.Errorf("failed to close multipart writer: %w", err) + } + + // Build upload request + url := fmt.Sprintf("http://%s:%d/api/v1/upload", MobSFBindHost, MobSFBindPort) + req, err := http.NewRequest("POST", url, body) + if err != nil { + return fmt.Errorf("failed to create API request: %w", err) + } + req.Header.Set("Content-Type", multipartWriter.FormDataContentType()) + req.Header.Set("Authorization", MobSFAPIKey) + + // Send upload request + log.Println("Uploading zip file to MobSF") + client := &http.Client{Timeout: 5 * time.Minute} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("MobSF API request failed: %w", err) + } + if resp.StatusCode != 200 { + return fmt.Errorf("MobSF API request failed: %s", resp.Status) + } + if !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") { + return fmt.Errorf("MobSF API did not respond with JSON content") + } + + // Parse response + var file *MobSFFile + defer resp.Body.Close() + if err := json.NewDecoder(resp.Body).Decode(&file); err != nil { + return fmt.Errorf("unable to parse JSON response from MobSF API: %w", err) + } + + p.Upload = file + } + + return nil +} + +// scanProject orders MobSF to scan the given project code base, ignoring the +// rule IDs given by exclusions, and returns a (partial) scan report. +func scanProject(project *Project, exclusions Exclusions) (mreport.Report, error) { + // Build scan request + url := fmt.Sprintf("http://%s:%d/api/v1/scan", MobSFBindHost, MobSFBindPort) + req, err := http.NewRequest("POST", url, strings.NewReader(project.Upload.AsScanQuery())) + if err != nil { + return nil, fmt.Errorf("failed to create API scan request: %w", err) + } + req.Header.Set("Authorization", MobSFAPIKey) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // Send scan request + client := &http.Client{Timeout: 10 * time.Minute} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("MobSF API scan request failed: %w", err) + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("MobSF API scan request failed: %s", resp.Status) + } + if !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") { + return nil, fmt.Errorf("MobSF API did not respond with JSON content on scan endpoint") + } + + // Build JSON report request + url = fmt.Sprintf("http://%s:%d/api/v1/report_json", MobSFBindHost, MobSFBindPort) + req, err = http.NewRequest("POST", url, strings.NewReader(project.Upload.AsReportQuery())) + if err != nil { + return nil, fmt.Errorf("failed to create API report request: %w", err) + } + req.Header.Set("Authorization", MobSFAPIKey) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // Send JSON report request + client = &http.Client{Timeout: 30 * time.Second} + resp, err = client.Do(req) + if err != nil { + return nil, fmt.Errorf("MobSF API report request failed: %w", err) + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("MobSF API report request failed: %s", resp.Status) + } + if !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") { + return nil, fmt.Errorf("MobSF API did not respond with JSON content on report endpoint") + } + + // Parse response body as scan report + reportBytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read MobSF API report response body: %w", err) + } + resp.Body.Close() + var report mreport.Report + switch project.Type { + case AndroidEclipse, AndroidStudio: + report, err = android.NewReport(reportBytes, exclusions.SetFor("android")) + case XcodeIos: + report, err = ios.NewReport(reportBytes, exclusions.SetFor("ios")) + default: + return nil, fmt.Errorf("no report parser for this project type") + } + if err != nil { + return nil, fmt.Errorf("error while parsing report: %w", err) + } + report.SetRootDir(project.RootDir) + + return report, nil +} + +func main() { + cli := parseCLIOptions() + + mobSFPid, err := startMobSF() + if err == nil { + log.Printf("Started MobSF (PID: %d)\n", mobSFPid) + } else { + log.Fatalf("Failed to start MobSF: %v\n", err) + } + + log.Println("Waiting for MobSF REST API to become responsive") + apiResponsive := false + for range [30]int{} { + if isAPIResponsive() { + apiResponsive = true + break + } else { + time.Sleep(1 * time.Second) + } + } + if !apiResponsive { + log.Fatalln("MobSF REST API did not become responsive within 30 seconds; exiting") + } + + log.Printf("Searching for project directories in %s\n", cli.InPath) + projects, err := findProjects(cli.InPath) + if err != nil { + log.Fatalf("Failed while searching for project directories: %v\n", err) + } + + log.Println("Uploading project code bases to MobSF") + err = uploadProjects(projects) + if err != nil { + log.Fatalf("Failed to upload project code bases to MobSF: %v\n", err) + } + + issues := make([]*v1.Issue, 0) + for _, p := range projects { + log.Printf("Scanning project in %s\n", p.RootDir) + + report, err := scanProject(p, cli.CodeAnalysisExclusions) + if err != nil { + log.Fatalf("Failed to scan project: %v\n", err) + } + + reportIssues := report.AsIssues() + log.Printf("Issues reported: %d\n", len(reportIssues)) + issues = append(issues, reportIssues...) + } + + log.Printf("Writing Dracon Issues protobuf to %s\n", cli.OutPath) + producers.WriteDraconOut("mobsf", issues) +} diff --git a/producers/mobsf/project.go b/producers/mobsf/project.go new file mode 100644 index 0000000..c21f065 --- /dev/null +++ b/producers/mobsf/project.go @@ -0,0 +1,54 @@ +package main + +import ( + "net/url" +) + +// ProjectType represents a particular type of mobile app project supported by +// MobSF. +type ProjectType string + +const ( + AndroidEclipse ProjectType = "Android Eclipse" + AndroidStudio = "Android Studio" + XcodeIos = "Xcode iOS" +) + +// Project represents a particular project somewhere in the target code base. +type Project struct { + RootDir string + Type ProjectType + Upload *MobSFFile +} + +// MobSFFile represents a file stored in MobSF. This is typically a project code +// base that has been uploaded to MobSF via the REST API or web interface. +type MobSFFile struct { + FileName string `json:"file_name"` + Hash string `json:"hash"` + ScanType string `json:"scan_type"` +} + +// AsScanQuery returns a string representation of the MobSFFile that identifies +// the corresponding server-side file as part of a request to MobSF's scan +// endpoint. +func (m *MobSFFile) AsScanQuery() string { + v := url.Values{} + + v.Add("file_name", m.FileName) + v.Add("hash", m.Hash) + v.Add("scan_type", m.ScanType) + + return v.Encode() +} + +// AsReportQuery returns a string representation of the MobSFFile that +// identifies the corresponding server-side file as part of a request to any of +// MobSF's report generation endpoints. +func (m *MobSFFile) AsReportQuery() string { + v := url.Values{} + + v.Add("hash", m.Hash) + + return v.Encode() +} diff --git a/producers/mobsf/report/BUILD b/producers/mobsf/report/BUILD new file mode 100644 index 0000000..d366bea --- /dev/null +++ b/producers/mobsf/report/BUILD @@ -0,0 +1,10 @@ +go_library( + name = "report", + srcs = [ + "report.go", + ], + deps = [ + "//api/proto:v1", + ], + visibility = ["//producers/mobsf/..."], +) diff --git a/producers/mobsf/report/android/BUILD b/producers/mobsf/report/android/BUILD new file mode 100644 index 0000000..55c695d --- /dev/null +++ b/producers/mobsf/report/android/BUILD @@ -0,0 +1,24 @@ +go_library( + name = "android", + srcs = [ + "android.go", + ], + deps = [ + "//api/proto:v1", + "//producers/mobsf/report:report", + ], + visibility = ["//producers/mobsf/..."] +) + +go_test( + name = "android_test", + srcs = [ + "android_test.go", + ], + deps = [ + ":android", + "//api/proto:v1", + "//producers:producers", + "//third_party/go:stretchr_testify", + ], +) diff --git a/producers/mobsf/report/android/android.go b/producers/mobsf/report/android/android.go new file mode 100644 index 0000000..8c69181 --- /dev/null +++ b/producers/mobsf/report/android/android.go @@ -0,0 +1,61 @@ +// Package android provides types and functions for working with Android project +// scan reports from MobSF. +package android + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "github.com/thought-machine/dracon/api/proto/v1" + mreport "github.com/thought-machine/dracon/producers/mobsf/report" +) + +// Report represents a (partial) Android project scan report. +type Report struct { + RootDir string `json:"-"` + CodeAnalysis map[string]mreport.CodeAnalysisFinding `json:"code_analysis"` + CodeAnalysisExclusions map[string]bool `json:"-"` +} + +func NewReport(report []byte, exclusions map[string]bool) (mreport.Report, error) { + var r *Report + if err := json.Unmarshal(report, &r); err != nil { + return nil, err + } + + r.CodeAnalysisExclusions = exclusions + + return r, nil +} + +func (r *Report) SetRootDir(path string) { + r.RootDir = path +} + +func (r *Report) AsIssues() []*v1.Issue { + issues := make([]*v1.Issue, 0) + + for id, finding := range r.CodeAnalysis { + if _, exists := r.CodeAnalysisExclusions[id]; exists { + continue + } + + for filename, linesList := range finding.Files { + for _, line := range strings.Split(linesList, ",") { + issues = append(issues, &v1.Issue{ + Target: fmt.Sprintf("%s:%s", filepath.Join(r.RootDir, filename), line), + Type: id, + Title: finding.Metadata.CWE, + Severity: v1.Severity(v1.Severity_value[fmt.Sprintf("SEVERITY_%s", strings.ToUpper(finding.Metadata.Severity))]), + Cvss: finding.Metadata.CVSS, + Confidence: v1.Confidence_CONFIDENCE_INFO, + Description: finding.Metadata.Description, + }) + } + } + } + + return issues +} diff --git a/producers/mobsf/report/android/android_test.go b/producers/mobsf/report/android/android_test.go new file mode 100644 index 0000000..456ed78 --- /dev/null +++ b/producers/mobsf/report/android/android_test.go @@ -0,0 +1,104 @@ +package android + +import ( + v1 "github.com/thought-machine/dracon/api/proto/v1" + + "testing" + + "github.com/stretchr/testify/assert" +) + +var invalidJSON = `Not a valid JSON object` + +func TestParseInvalidJSON(t *testing.T) { + report, err := NewReport([]byte(invalidJSON), map[string]bool{}) + assert.Nil(t, report) + assert.Error(t, err) +} + +var androidReport = `{ + "code_analysis": { + "android_ip_disclosure": { + "files": { "test/MainApplication.java": "58" }, + "metadata": { + "id": "android_ip_disclosure", + "description": "IP Address disclosure", + "type": "Regex", + "pattern": "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}", + "severity": "warning", + "input_case": "exact", + "cvss": 4.3, + "cwe": "CWE-200 Information Exposure", + "owasp-mobile": "", + "masvs": "MSTG-CODE-2" + } + }, + "android_insecure_random": { + "files": { "test/MainApplication.java": "26" }, + "metadata": { + "id": "android_insecure_random", + "description": "The App uses an insecure Random Number Generator.", + "type": "Regex", + "pattern": "java\\.util\\.Random;", + "severity": "high", + "input_case": "exact", + "cvss": 7.5, + "cwe": "CWE-330 Use of Insufficiently Random Values", + "owasp-mobile": "M5: Insufficient Cryptography", + "masvs": "MSTG-CRYPTO-6" + } + } + } +} +` + +func TestParseValidIosReportNoExclusions(t *testing.T) { + report, err := NewReport([]byte(androidReport), map[string]bool{}) + report.SetRootDir("android_project") + assert.NoError(t, err) + + issues := report.AsIssues() + assert.Len(t, issues, 2) + + expectedIssues := []*v1.Issue{ + &v1.Issue{ + Target: "android_project/test/MainApplication.java:58", + Type: "android_ip_disclosure", + Title: "CWE-200 Information Exposure", + Cvss: 4.3, + Description: "IP Address disclosure", + }, + &v1.Issue{ + Target: "android_project/test/MainApplication.java:26", + Type: "android_insecure_random", + Title: "CWE-330 Use of Insufficiently Random Values", + Severity: v1.Severity_SEVERITY_HIGH, + Cvss: 7.5, + Description: "The App uses an insecure Random Number Generator.", + }, + } + + assert.Equal(t, issues, expectedIssues) +} + +func TestParseValidIosReportExclusions(t *testing.T) { + report, err := NewReport([]byte(androidReport), map[string]bool{"android_ip_disclosure": true}) + report.SetRootDir("android_project") + assert.NoError(t, err) + + issues := report.AsIssues() + assert.Len(t, issues, 1) + + expectedIssues := []*v1.Issue{ + &v1.Issue{ + Target: "android_project/test/MainApplication.java:26", + Type: "android_insecure_random", + Title: "CWE-330 Use of Insufficiently Random Values", + Severity: v1.Severity_SEVERITY_HIGH, + Cvss: 7.5, + Description: "The App uses an insecure Random Number Generator.", + }, + } + + assert.Equal(t, issues, expectedIssues) +} diff --git a/producers/mobsf/report/ios/BUILD b/producers/mobsf/report/ios/BUILD new file mode 100644 index 0000000..d30bbb5 --- /dev/null +++ b/producers/mobsf/report/ios/BUILD @@ -0,0 +1,24 @@ +go_library( + name = "ios", + srcs = [ + "ios.go", + ], + deps = [ + "//api/proto:v1", + "//producers/mobsf/report:report", + ], + visibility = ["//producers/mobsf/..."] +) + +go_test( + name = "ios_test", + srcs = [ + "ios_test.go", + ], + deps = [ + ":ios", + "//api/proto:v1", + "//producers:producers", + "//third_party/go:stretchr_testify", + ], +) diff --git a/producers/mobsf/report/ios/ios.go b/producers/mobsf/report/ios/ios.go new file mode 100644 index 0000000..28bf57d --- /dev/null +++ b/producers/mobsf/report/ios/ios.go @@ -0,0 +1,110 @@ +// Package ios provides types and functions for working with iOS project scan +// reports from MobSF. +package ios + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "github.com/thought-machine/dracon/api/proto/v1" + mreport "github.com/thought-machine/dracon/producers/mobsf/report" +) + +// Report represents a (partial) iOS project scan report. +type Report struct { + RootDir string `json:"-"` + CodeAnalysis map[string]mreport.CodeAnalysisFinding `json:"code_analysis"` + CodeAnalysisExclusions map[string]bool `json:"-"` + ATSAnalysis []ATSAnalysisFinding `json:"ats_analysis"` +} + +// ATSAnalysisFinding represents the App Transport Security (ATS) findings in an +// iOS project scan report. +type ATSAnalysisFinding struct { + Issue string `json:"issue"` + Status string `json:"status"` + Description string `json:"description"` +} + +func NewReport(report []byte, exclusions map[string]bool) (mreport.Report, error) { + var r *Report + if err := json.Unmarshal(report, &r); err != nil { + return nil, err + } + + r.CodeAnalysisExclusions = exclusions + + return r, nil +} + +func (r *Report) SetRootDir(path string) { + r.RootDir = path +} + +func (r *Report) AsIssues() []*v1.Issue { + issues := make([]*v1.Issue, 0) + + for id, finding := range r.CodeAnalysis { + if _, exists := r.CodeAnalysisExclusions[id]; exists { + continue + } + + for filename, linesList := range finding.Files { + for _, line := range strings.Split(linesList, ",") { + issues = append(issues, &v1.Issue{ + Target: fmt.Sprintf("%s:%s", filepath.Join(r.RootDir, filename), line), + Type: id, + Title: finding.Metadata.CWE, + Severity: v1.Severity(v1.Severity_value[fmt.Sprintf("SEVERITY_%s", strings.ToUpper(finding.Metadata.Severity))]), + Cvss: finding.Metadata.CVSS, + Confidence: v1.Confidence_CONFIDENCE_INFO, + Description: finding.Metadata.Description, + }) + } + } + } + + // ATS analysis findings are per plist file, but projects could contain more + // than one plist file and MobSF doesn't specify which plist file each + // finding is for, so they could be duplicated - remove any duplicates + // before returning them as Dracon Issues + seen := make(map[string]bool) + for _, finding := range r.ATSAnalysis { + if _, exists := seen[finding.Issue]; !exists { + seen[finding.Issue] = true + + issue := &v1.Issue{ + // MobSF doesn't report the precise source of the issue so we + // can't be more specific about its location than this: + Target: r.RootDir, + Type: "Insecure App Transport Security policy", + Title: finding.Issue, + Confidence: v1.Confidence_CONFIDENCE_MEDIUM, + Description: fmt.Sprintf( + "An insecure App Transport Security policy is defined in a plist file in the iOS app project directory %s.\n\nDetails:\n\n%s\n%s", + r.RootDir, + finding.Issue, + finding.Description, + ), + } + + switch finding.Status { + case "info": + issue.Severity = v1.Severity_SEVERITY_INFO + case "warning": + issue.Severity = v1.Severity_SEVERITY_LOW + case "insecure": + issue.Severity = v1.Severity_SEVERITY_MEDIUM + case "secure": + // We don't need to report this as an issue + continue + } + + issues = append(issues, issue) + } + } + + return issues +} diff --git a/producers/mobsf/report/ios/ios_test.go b/producers/mobsf/report/ios/ios_test.go new file mode 100644 index 0000000..e03b434 --- /dev/null +++ b/producers/mobsf/report/ios/ios_test.go @@ -0,0 +1,168 @@ +package ios + +import ( + v1 "github.com/thought-machine/dracon/api/proto/v1" + + "testing" + + "github.com/stretchr/testify/assert" +) + +var invalidJSON = `Not a valid JSON object` + +func TestParseInvalidJSON(t *testing.T) { + report, err := NewReport([]byte(invalidJSON), map[string]bool{}) + assert.Nil(t, report) + assert.Error(t, err) +} + +var iOSReport = `{ + "ats_analysis": [ + { + "issue": "App Transport Security AllowsArbitraryLoads is allowed", + "status": "insecure", + "description": "App Transport Security restrictions are disabled for all network connections. Disabling ATS means that unsecured HTTP connections are allowed. HTTPS connections are also allowed, and are still subject to default server trust evaluation. However, extended security checks like requiring a minimum Transport Layer Security (TLS) protocol version\u2014are disabled. This setting is not applicable to domains listed in NSExceptionDomains." + }, + { + "issue": "NSExceptionRequiresForwardSecrecy set to NO for localhost", + "status": "insecure", + "description": "NSExceptionRequiresForwardSecrecy limits the accepted ciphers to those that support perfect forward secrecy (PFS) through the Elliptic Curve Diffie-Hellman Ephemeral (ECDHE) key exchange. Set the value for this key to NO to override the requirement that a server must support PFS for the given domain. This key is optional. The default value is YES, which limits the accepted ciphers to those that support PFS through Elliptic Curve Diffie-Hellman Ephemeral (ECDHE) key exchange." + }, + { + "issue": "App Transport Security AllowsArbitraryLoads is allowed", + "status": "insecure", + "description": "App Transport Security restrictions are disabled for all network connections. Disabling ATS means that unsecured HTTP connections are allowed. HTTPS connections are also allowed, and are still subject to default server trust evaluation. However, extended security checks like requiring a minimum Transport Layer Security (TLS) protocol version\u2014are disabled. This setting is not applicable to domains listed in NSExceptionDomains." + } + ], + "code_analysis": { + "ios_app_logging": { + "files": { "file.m": "31" }, + "metadata": { + "id": "ios_app_logging", + "cvss": 7.5, + "cwe": "CWE-532 Insertion of Sensitive Information into Log File", + "description": "The App logs information. Sensitive information should never be logged.", + "input_case": "exact", + "masvs": "MSTG-STORAGE-3", + "owasp-mobile": "", + "pattern": "NSLog|NSAssert|fprintf|fprintf|Logging", + "severity": "info", + "type": "Regex" + } + }, + "ios_swift_log": { + "files": { + "file1.swift": "62", + "file2.swift": "37,16" + }, + "metadata": { + "id": "ios_swift_log", + "cvss": 7.5, + "cwe": "CWE-532", + "description": "The App logs information. Sensitive information should never be logged.", + "input_case": "exact", + "masvs": "MSTG-STORAGE-3", + "owasp-mobile": "", + "pattern": "(print|NSLog|os_log|OSLog|os_signpost)\\(.*\\)", + "severity": "info", + "type": "Regex" + } + } + } +} +` + +func TestParseValidIosReportNoExclusions(t *testing.T) { + report, err := NewReport([]byte(iOSReport), map[string]bool{}) + report.SetRootDir("ios_project") + assert.NoError(t, err) + + issues := report.AsIssues() + assert.Len(t, issues, 6) + + expectedIssues := []*v1.Issue{ + &v1.Issue{ + Target: "ios_project/file.m:31", + Type: "ios_app_logging", + Title: "CWE-532 Insertion of Sensitive Information into Log File", + Cvss: 7.5, + Description: "The App logs information. Sensitive information should never be logged.", + }, + &v1.Issue{ + Target: "ios_project/file1.swift:62", + Type: "ios_swift_log", + Title: "CWE-532", + Cvss: 7.5, + Description: "The App logs information. Sensitive information should never be logged.", + }, + &v1.Issue{ + Target: "ios_project/file2.swift:37", + Type: "ios_swift_log", + Title: "CWE-532", + Cvss: 7.5, + Description: "The App logs information. Sensitive information should never be logged.", + }, + &v1.Issue{ + Target: "ios_project/file2.swift:16", + Type: "ios_swift_log", + Title: "CWE-532", + Cvss: 7.5, + Description: "The App logs information. Sensitive information should never be logged.", + }, + &v1.Issue{ + Target: "ios_project", + Type: "Insecure App Transport Security policy", + Title: "App Transport Security AllowsArbitraryLoads is allowed", + Severity: v1.Severity_SEVERITY_MEDIUM, + Confidence: v1.Confidence_CONFIDENCE_MEDIUM, + Description: "An insecure App Transport Security policy is defined in a plist file in the iOS app project directory ios_project.\n\nDetails:\n\nApp Transport Security AllowsArbitraryLoads is allowed\nApp Transport Security restrictions are disabled for all network connections. Disabling ATS means that unsecured HTTP connections are allowed. HTTPS connections are also allowed, and are still subject to default server trust evaluation. However, extended security checks like requiring a minimum Transport Layer Security (TLS) protocol version\342\200\224are disabled. This setting is not applicable to domains listed in NSExceptionDomains.", + }, + &v1.Issue{ + Target: "ios_project", + Type: "Insecure App Transport Security policy", + Title: "NSExceptionRequiresForwardSecrecy set to NO for localhost", + Severity: v1.Severity_SEVERITY_MEDIUM, + Confidence: v1.Confidence_CONFIDENCE_MEDIUM, + Description: "An insecure App Transport Security policy is defined in a plist file in the iOS app project directory ios_project.\n\nDetails:\n\nNSExceptionRequiresForwardSecrecy set to NO for localhost\nNSExceptionRequiresForwardSecrecy limits the accepted ciphers to those that support perfect forward secrecy (PFS) through the Elliptic Curve Diffie-Hellman Ephemeral (ECDHE) key exchange. Set the value for this key to NO to override the requirement that a server must support PFS for the given domain. This key is optional. The default value is YES, which limits the accepted ciphers to those that support PFS through Elliptic Curve Diffie-Hellman Ephemeral (ECDHE) key exchange.", + }, + } + + assert.Equal(t, issues, expectedIssues) +} + +func TestParseValidIosReportExclusions(t *testing.T) { + report, err := NewReport([]byte(iOSReport), map[string]bool{"ios_swift_log": true}) + report.SetRootDir("ios_project") + assert.NoError(t, err) + + issues := report.AsIssues() + assert.Len(t, issues, 3) + + expectedIssues := []*v1.Issue{ + &v1.Issue{ + Target: "ios_project/file.m:31", + Type: "ios_app_logging", + Title: "CWE-532 Insertion of Sensitive Information into Log File", + Cvss: 7.5, + Description: "The App logs information. Sensitive information should never be logged.", + }, + &v1.Issue{ + Target: "ios_project", + Type: "Insecure App Transport Security policy", + Title: "App Transport Security AllowsArbitraryLoads is allowed", + Severity: v1.Severity_SEVERITY_MEDIUM, + Confidence: v1.Confidence_CONFIDENCE_MEDIUM, + Description: "An insecure App Transport Security policy is defined in a plist file in the iOS app project directory ios_project.\n\nDetails:\n\nApp Transport Security AllowsArbitraryLoads is allowed\nApp Transport Security restrictions are disabled for all network connections. Disabling ATS means that unsecured HTTP connections are allowed. HTTPS connections are also allowed, and are still subject to default server trust evaluation. However, extended security checks like requiring a minimum Transport Layer Security (TLS) protocol version\342\200\224are disabled. This setting is not applicable to domains listed in NSExceptionDomains.", + }, + &v1.Issue{ + Target: "ios_project", + Type: "Insecure App Transport Security policy", + Title: "NSExceptionRequiresForwardSecrecy set to NO for localhost", + Severity: v1.Severity_SEVERITY_MEDIUM, + Confidence: v1.Confidence_CONFIDENCE_MEDIUM, + Description: "An insecure App Transport Security policy is defined in a plist file in the iOS app project directory ios_project.\n\nDetails:\n\nNSExceptionRequiresForwardSecrecy set to NO for localhost\nNSExceptionRequiresForwardSecrecy limits the accepted ciphers to those that support perfect forward secrecy (PFS) through the Elliptic Curve Diffie-Hellman Ephemeral (ECDHE) key exchange. Set the value for this key to NO to override the requirement that a server must support PFS for the given domain. This key is optional. The default value is YES, which limits the accepted ciphers to those that support PFS through Elliptic Curve Diffie-Hellman Ephemeral (ECDHE) key exchange.", + }, + } + + assert.Equal(t, issues, expectedIssues) +} diff --git a/producers/mobsf/report/report.go b/producers/mobsf/report/report.go new file mode 100644 index 0000000..01a0982 --- /dev/null +++ b/producers/mobsf/report/report.go @@ -0,0 +1,28 @@ +// Package report provides common types for scan report formats. +package report + +import ( + v1 "github.com/thought-machine/dracon/api/proto/v1" +) + +// Report is an interface for scan report formats. +type Report interface { + // SetRootDir sets the path to this project's root directory. + SetRootDir(string) + + // AsIssues transforms this Report into a slice of Dracon Issues that can be + // processed by the Dracon enricher. + AsIssues() []*v1.Issue +} + +type CodeAnalysisFinding struct { + Files map[string]string `json:"files"` + Metadata CodeAnalysisMetadata `json:"metadata"` +} + +type CodeAnalysisMetadata struct { + CVSS float64 `json:"cvss"` + CWE string `json:"cwe"` + Description string `json:"description"` + Severity string `json:"severity"` +} diff --git a/producers/producer.go b/producers/producer.go index c1133e1..86bb41e 100644 --- a/producers/producer.go +++ b/producers/producer.go @@ -34,14 +34,12 @@ const ( EnvDraconScanID = "DRACON_SCAN_ID" ) -func init() { +// ParseFlags will parse the input flags for the producer and perform simple validation +func ParseFlags() error { flag.StringVar(&InResults, "in", "", "") flag.StringVar(&OutFile, "out", "", "") flag.BoolVar(&Append, "append", false, "Append to output file instead of overwriting it") -} -// ParseFlags will parse the input flags for the producer and perform simple validation -func ParseFlags() error { flag.Parse() if len(InResults) < 0 { return fmt.Errorf("in is undefined")