diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..75b14fd --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/visma-prodsec/confused + +go 1.18 diff --git a/main.go b/main.go index 4a39acc..91c2e26 100644 --- a/main.go +++ b/main.go @@ -21,7 +21,7 @@ func main() { verbose := false filename := "" safespaces := "" - flag.StringVar(&lang, "l", "npm", "Package repository system. Possible values: \"pip\", \"npm\", \"composer\", \"mvn\"") + flag.StringVar(&lang, "l", "npm", "Package repository system. Possible values: \"pip\", \"npm\", \"composer\", \"mvn\", \"rubygems\"") flag.StringVar(&safespaces, "s", "", "Comma-separated list of known-secure namespaces. Supports wildcards") flag.BoolVar(&verbose, "v", false, "Verbose output") flag.Parse() @@ -34,18 +34,23 @@ func main() { } filename = flag.Args()[0] - if lang == "pip" { + + switch lang { + case "pip": resolver = NewPythonLookup(verbose) - } else if lang == "npm" { + case "npm": resolver = NewNPMLookup(verbose) - } else if lang == "composer" { + case "composer": resolver = NewComposerLookup(verbose) - } else if lang == "mvn" { + case "mvn": resolver = NewMVNLookup(verbose) - } else { + case "rubygems": + resolver = NewRubyGemsLookup(verbose) + default: fmt.Printf("Unknown package repository system: %s\n", lang) os.Exit(1) } + err := resolver.ReadPackagesFromFile(filename) if err != nil { fmt.Printf("Encountered an error while trying to read packages from file: %s\n", err) diff --git a/mvn.go b/mvn.go index b6c74bc..6b67813 100644 --- a/mvn.go +++ b/mvn.go @@ -18,9 +18,9 @@ type MVNLookup struct { } type MVNPackage struct { - Group string + Group string Artifact string - Version string + Version string } // NewNPMLookup constructs an `MVNLookup` struct and returns it. @@ -68,7 +68,7 @@ func (n *MVNLookup) PackagesNotInPublic() []string { notavail := []string{} for _, pkg := range n.Packages { if !n.isAvailableInPublic(pkg, 0) { - notavail = append(notavail, pkg.Group + "/" + pkg.Artifact) + notavail = append(notavail, pkg.Group+"/"+pkg.Artifact) } } return notavail @@ -86,11 +86,11 @@ func (n *MVNLookup) isAvailableInPublic(pkg MVNPackage, retry int) bool { return true } - group := strings.Replace(pkg.Group, ".", "/",-1) + group := strings.Replace(pkg.Group, ".", "/", -1) if n.Verbose { - fmt.Print("Checking: https://repo1.maven.org/maven2/"+group+"/ ") + fmt.Print("Checking: https://repo1.maven.org/maven2/" + group + "/ ") } - resp, err := http.Get("https://repo1.maven.org/maven2/"+group+"/") + resp, err := http.Get("https://repo1.maven.org/maven2/" + group + "/") if err != nil { fmt.Printf(" [W] Error when trying to request https://repo1.maven.org/maven2/"+group+"/ : %s\n", err) return false @@ -114,7 +114,7 @@ func (n *MVNLookup) isAvailableInPublic(pkg MVNPackage, retry int) bool { fmt.Printf(" [!] Server responded with 429 (Too many requests), throttling and retrying...\n") time.Sleep(10 * time.Second) retry = retry + 1 - n.isAvailableInPublic(pkg, retry) + return n.isAvailableInPublic(pkg, retry) } return false -} \ No newline at end of file +} diff --git a/npm.go b/npm.go index 2e931b1..6243204 100644 --- a/npm.go +++ b/npm.go @@ -28,16 +28,16 @@ type NpmResponse struct { } type NpmResponseUnpublished struct { - Maintainers []struct { - Email string `json:"email"` - Name string `json:"name"` - } `json:"maintainers"` - Name string `json:"name"` - Tags struct { - Latest string `json:"latest"` - } `json:"tags"` - Time time.Time `json:"time"` - Versions []string `json:"versions"` + Maintainers []struct { + Email string `json:"email"` + Name string `json:"name"` + } `json:"maintainers"` + Name string `json:"name"` + Tags struct { + Latest string `json:"latest"` + } `json:"tags"` + Time time.Time `json:"time"` + Versions []string `json:"versions"` } // NotAvailable returns true if the package has its all versions unpublished making it susceptible for takeover @@ -53,7 +53,7 @@ type NPMLookup struct { } type NPMPackage struct { - Name string + Name string Version string } @@ -155,7 +155,7 @@ func (n *NPMLookup) isAvailableInPublic(pkgname string, retry int) bool { fmt.Printf(" [!] Server responded with 429 (Too many requests), throttling and retrying...\n") time.Sleep(10 * time.Second) retry = retry + 1 - n.isAvailableInPublic(pkgname, retry) + return n.isAvailableInPublic(pkgname, retry) } return false } diff --git a/rubygems.go b/rubygems.go new file mode 100644 index 0000000..93c4531 --- /dev/null +++ b/rubygems.go @@ -0,0 +1,149 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "strings" + "time" +) + +type Gem struct { + Remote string + IsLocal bool + IsRubyGems bool + IsTransitive bool + Name string + Version string +} + +type RubyGemsResponse struct { + Name string `json:"name"` + Downloads int64 `json:"downloads"` + Version string `json:"version"` +} + +// RubyGemsLookup represents a collection of rubygems packages to be tested for dependency confusion. +type RubyGemsLookup struct { + Packages []Gem + Verbose bool +} + +// NewRubyGemsLookup constructs an `RubyGemsLookup` struct and returns it. +func NewRubyGemsLookup(verbose bool) PackageResolver { + return &RubyGemsLookup{Packages: []Gem{}, Verbose: verbose} +} + +// ReadPackagesFromFile reads package information from a Gemfile.lock file +// +// Returns any errors encountered +func (r *RubyGemsLookup) ReadPackagesFromFile(filename string) error { + file, err := os.Open(filename) + if err != nil { + return err + } + defer file.Close() + scanner := bufio.NewScanner(file) + var remote string + for scanner.Scan() { + line := scanner.Text() + trimmedLine := strings.TrimSpace(line) + if strings.HasPrefix(trimmedLine, "remote:") { + remote = strings.TrimSpace(strings.SplitN(trimmedLine, ":", 2)[1]) + } else if trimmedLine == "revision:" { + continue + } else if trimmedLine == "branch:" { + continue + } else if trimmedLine == "GIT" { + continue + } else if trimmedLine == "GEM" { + continue + } else if trimmedLine == "PATH" { + continue + } else if trimmedLine == "PLATFORMS" { + break + } else if trimmedLine == "specs:" { + continue + } else if len(trimmedLine) > 0 { + parts := strings.SplitN(trimmedLine, " ", 2) + name := strings.TrimSpace(parts[0]) + var version string + if len(parts) > 1 { + version = strings.TrimRight(strings.TrimLeft(parts[1], "("), ")") + } else { + version = "" + } + r.Packages = append(r.Packages, Gem{ + Remote: remote, + IsLocal: !strings.HasPrefix(remote, "http"), + IsRubyGems: strings.HasPrefix(remote, "https://rubygems.org"), + IsTransitive: countLeadingSpaces(line) == 6, + Name: name, + Version: version, + }) + } else { + continue + } + } + return nil +} + +// PackagesNotInPublic determines if a rubygems package does not exist in the public rubygems package repository. +// +// Returns a slice of strings with any rubygem packages not in the public rubygems package repository +func (r *RubyGemsLookup) PackagesNotInPublic() []string { + notavail := []string{} + for _, pkg := range r.Packages { + if pkg.IsLocal || !pkg.IsRubyGems { + continue + } + if !r.isAvailableInPublic(pkg.Name, 0) { + notavail = append(notavail, pkg.Name) + } + } + return notavail +} + +// isAvailableInPublic determines if a rubygems package exists in the public rubygems.org package repository. +// +// Returns true if the package exists in the public rubygems package repository. +func (r *RubyGemsLookup) isAvailableInPublic(pkgname string, retry int) bool { + if retry > 3 { + fmt.Printf(" [W] Maximum number of retries exhausted for package: %s\n", pkgname) + return false + } + url := fmt.Sprintf("https://rubygems.org/api/v1/gems/%s.json", pkgname) + if r.Verbose { + fmt.Printf("Checking: %s : \n", url) + } + resp, err := http.Get(url) + if err != nil { + fmt.Printf(" [W] Error when trying to request %s: %s\n", url, err) + return false + } + defer resp.Body.Close() + if r.Verbose { + fmt.Printf("%s\n", resp.Status) + } + if resp.StatusCode == http.StatusOK { + rubygemsResp := RubyGemsResponse{} + body, _ := ioutil.ReadAll(resp.Body) + err = json.Unmarshal(body, &rubygemsResp) + if err != nil { + // This shouldn't ever happen because if it doesn't return JSON, it likely has returned + // a non-200 status code. + fmt.Printf(" [W] Error when trying to unmarshal response from %s: %s\n", url, err) + return false + } + return true + } else if resp.StatusCode == 429 { + fmt.Printf(" [!] Server responded with 429 (Too many requests), throttling and retrying...\n") + time.Sleep(10 * time.Second) + retry = retry + 1 + return r.isAvailableInPublic(pkgname, retry) + } + return false +} diff --git a/util.go b/util.go index 9abecde..fa45963 100644 --- a/util.go +++ b/util.go @@ -1,5 +1,7 @@ package main +import "strings" + func inSlice(what rune, where []rune) bool { for _, r := range where { if r == what { @@ -9,3 +11,6 @@ func inSlice(what rune, where []rune) bool { return false } +func countLeadingSpaces(line string) int { + return len(line) - len(strings.TrimLeft(line, " ")) +}