mirror of
https://github.com/securego/gosec.git
synced 2024-12-25 20:15:54 +00:00
0ae0174c25
The existing code assumed imports to be either imported, or imported with an alias. Badly formatted files may have duplicate imports for a package, using different aliases. This patch refactors the code, and; Introduces a new `GetImportedNames` function, which returns all name(s) and aliase(s) for a package, which effectively combines `GetAliasedName` and `GetImportedName`, but adding support for duplicate imports. The old `GetAliasedName` and `GetImportedName` functions have been rewritten to use the new function and marked deprecated, but could be removed if there are no external consumers. With this patch, the linter is able to detect issues in files such as; package main import ( crand "crypto/rand" "math/big" "math/rand" rand2 "math/rand" rand3 "math/rand" ) func main() { _, _ = crand.Int(crand.Reader, big.NewInt(int64(2))) // good _ = rand.Intn(2) // bad _ = rand2.Intn(2) // bad _ = rand3.Intn(2) // bad } Before this patch, only a single issue would be detected: gosec --quiet . [main.go:14] - G404 (CWE-338): Use of weak random number generator (math/rand instead of crypto/rand) (Confidence: MEDIUM, Severity: HIGH) 13: > 14: _ = rand.Intn(2) // bad 15: _ = rand2.Intn(2) // bad With this patch, all issues are identified: gosec --quiet . [main.go:16] - G404 (CWE-338): Use of weak random number generator (math/rand instead of crypto/rand) (Confidence: MEDIUM, Severity: HIGH) 15: _ = rand2.Intn(2) // bad > 16: _ = rand3.Intn(2) // bad 17: } [main.go:15] - G404 (CWE-338): Use of weak random number generator (math/rand instead of crypto/rand) (Confidence: MEDIUM, Severity: HIGH) 14: _ = rand.Intn(2) // bad > 15: _ = rand2.Intn(2) // bad 16: _ = rand3.Intn(2) // bad [main.go:14] - G404 (CWE-338): Use of weak random number generator (math/rand instead of crypto/rand) (Confidence: MEDIUM, Severity: HIGH) 13: > 14: _ = rand.Intn(2) // bad 15: _ = rand2.Intn(2) // bad While working on this change, I noticed that ImportTracker.TrackFile() was not able to find import aliases; Analyser.Check() called both ImportTracker.TrackFile() and ast.Walk(), which (with the updated ImportTracker) resulted in importes to be in- correctly included multiple times (once with the correct alias, once with the default). I updated ImportTracker.TrackFile() to fix this, but with the updated ImportTracker, Analyser.Check() no longer has to call ImportTracker.TrackFile() separately, as ast.Walk() already handles the file, and will find all imports. Signed-off-by: Sebastiaan van Stijn <github@gone.nl> Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
514 lines
15 KiB
Go
514 lines
15 KiB
Go
// (c) Copyright 2016 Hewlett Packard Enterprise Development LP
|
|
//
|
|
// 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 gosec holds the central scanning logic used by gosec security scanner
|
|
package gosec
|
|
|
|
import (
|
|
"fmt"
|
|
"go/ast"
|
|
"go/build"
|
|
"go/token"
|
|
"go/types"
|
|
"log"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"golang.org/x/tools/go/packages"
|
|
)
|
|
|
|
// LoadMode controls the amount of details to return when loading the packages
|
|
const LoadMode = packages.NeedName |
|
|
packages.NeedFiles |
|
|
packages.NeedCompiledGoFiles |
|
|
packages.NeedImports |
|
|
packages.NeedTypes |
|
|
packages.NeedTypesSizes |
|
|
packages.NeedTypesInfo |
|
|
packages.NeedSyntax
|
|
|
|
const externalSuppressionJustification = "Globally suppressed."
|
|
|
|
const aliasOfAllRules = "*"
|
|
|
|
var generatedCodePattern = regexp.MustCompile(`^// Code generated .* DO NOT EDIT\.$`)
|
|
|
|
// The Context is populated with data parsed from the source code as it is scanned.
|
|
// It is passed through to all rule functions as they are called. Rules may use
|
|
// this data in conjunction withe the encountered AST node.
|
|
type Context struct {
|
|
FileSet *token.FileSet
|
|
Comments ast.CommentMap
|
|
Info *types.Info
|
|
Pkg *types.Package
|
|
PkgFiles []*ast.File
|
|
Root *ast.File
|
|
Config Config
|
|
Imports *ImportTracker
|
|
Ignores []map[string][]SuppressionInfo
|
|
PassedValues map[string]interface{}
|
|
}
|
|
|
|
// Metrics used when reporting information about a scanning run.
|
|
type Metrics struct {
|
|
NumFiles int `json:"files"`
|
|
NumLines int `json:"lines"`
|
|
NumNosec int `json:"nosec"`
|
|
NumFound int `json:"found"`
|
|
}
|
|
|
|
// Analyzer object is the main object of gosec. It has methods traverse an AST
|
|
// and invoke the correct checking rules as on each node as required.
|
|
type Analyzer struct {
|
|
ignoreNosec bool
|
|
ruleset RuleSet
|
|
context *Context
|
|
config Config
|
|
logger *log.Logger
|
|
issues []*Issue
|
|
stats *Metrics
|
|
errors map[string][]Error // keys are file paths; values are the golang errors in those files
|
|
tests bool
|
|
excludeGenerated bool
|
|
showIgnored bool
|
|
trackSuppressions bool
|
|
concurrency int
|
|
}
|
|
|
|
// SuppressionInfo object is to record the kind and the justification that used
|
|
// to suppress violations.
|
|
type SuppressionInfo struct {
|
|
Kind string `json:"kind"`
|
|
Justification string `json:"justification"`
|
|
}
|
|
|
|
// NewAnalyzer builds a new analyzer.
|
|
func NewAnalyzer(conf Config, tests bool, excludeGenerated bool, trackSuppressions bool, concurrency int, logger *log.Logger) *Analyzer {
|
|
ignoreNoSec := false
|
|
if enabled, err := conf.IsGlobalEnabled(Nosec); err == nil {
|
|
ignoreNoSec = enabled
|
|
}
|
|
showIgnored := false
|
|
if enabled, err := conf.IsGlobalEnabled(ShowIgnored); err == nil {
|
|
showIgnored = enabled
|
|
}
|
|
if logger == nil {
|
|
logger = log.New(os.Stderr, "[gosec]", log.LstdFlags)
|
|
}
|
|
return &Analyzer{
|
|
ignoreNosec: ignoreNoSec,
|
|
showIgnored: showIgnored,
|
|
ruleset: NewRuleSet(),
|
|
context: &Context{},
|
|
config: conf,
|
|
logger: logger,
|
|
issues: make([]*Issue, 0, 16),
|
|
stats: &Metrics{},
|
|
errors: make(map[string][]Error),
|
|
tests: tests,
|
|
concurrency: concurrency,
|
|
excludeGenerated: excludeGenerated,
|
|
trackSuppressions: trackSuppressions,
|
|
}
|
|
}
|
|
|
|
// SetConfig updates the analyzer configuration
|
|
func (gosec *Analyzer) SetConfig(conf Config) {
|
|
gosec.config = conf
|
|
}
|
|
|
|
// Config returns the current configuration
|
|
func (gosec *Analyzer) Config() Config {
|
|
return gosec.config
|
|
}
|
|
|
|
// LoadRules instantiates all the rules to be used when analyzing source
|
|
// packages
|
|
func (gosec *Analyzer) LoadRules(ruleDefinitions map[string]RuleBuilder, ruleSuppressed map[string]bool) {
|
|
for id, def := range ruleDefinitions {
|
|
r, nodes := def(id, gosec.config)
|
|
gosec.ruleset.Register(r, ruleSuppressed[id], nodes...)
|
|
}
|
|
}
|
|
|
|
// Process kicks off the analysis process for a given package
|
|
func (gosec *Analyzer) Process(buildTags []string, packagePaths ...string) error {
|
|
config := &packages.Config{
|
|
Mode: LoadMode,
|
|
BuildFlags: buildTags,
|
|
Tests: gosec.tests,
|
|
}
|
|
|
|
type result struct {
|
|
pkgPath string
|
|
pkgs []*packages.Package
|
|
err error
|
|
}
|
|
|
|
results := make(chan result)
|
|
jobs := make(chan string, len(packagePaths))
|
|
quit := make(chan struct{})
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
worker := func(j chan string, r chan result, quit chan struct{}) {
|
|
for {
|
|
select {
|
|
case s := <-j:
|
|
pkgs, err := gosec.load(s, config)
|
|
select {
|
|
case r <- result{pkgPath: s, pkgs: pkgs, err: err}:
|
|
case <-quit:
|
|
// we've been told to stop, probably an error while
|
|
// processing a previous result.
|
|
wg.Done()
|
|
return
|
|
}
|
|
default:
|
|
// j is empty and there are no jobs left
|
|
wg.Done()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// fill the buffer
|
|
for _, pkgPath := range packagePaths {
|
|
jobs <- pkgPath
|
|
}
|
|
|
|
for i := 0; i < gosec.concurrency; i++ {
|
|
wg.Add(1)
|
|
go worker(jobs, results, quit)
|
|
}
|
|
|
|
go func() {
|
|
wg.Wait()
|
|
close(results)
|
|
}()
|
|
|
|
for r := range results {
|
|
if r.err != nil {
|
|
gosec.AppendError(r.pkgPath, r.err)
|
|
}
|
|
for _, pkg := range r.pkgs {
|
|
if pkg.Name != "" {
|
|
err := gosec.ParseErrors(pkg)
|
|
if err != nil {
|
|
close(quit)
|
|
wg.Wait() // wait for the goroutines to stop
|
|
return fmt.Errorf("parsing errors in pkg %q: %w", pkg.Name, err)
|
|
}
|
|
gosec.Check(pkg)
|
|
}
|
|
}
|
|
}
|
|
sortErrors(gosec.errors)
|
|
return nil
|
|
}
|
|
|
|
func (gosec *Analyzer) load(pkgPath string, conf *packages.Config) ([]*packages.Package, error) {
|
|
abspath, err := GetPkgAbsPath(pkgPath)
|
|
if err != nil {
|
|
gosec.logger.Printf("Skipping: %s. Path doesn't exist.", abspath)
|
|
return []*packages.Package{}, nil
|
|
}
|
|
|
|
gosec.logger.Println("Import directory:", abspath)
|
|
// step 1/3 create build context.
|
|
buildD := build.Default
|
|
// step 2/3: add build tags to get env dependent files into basePackage.
|
|
buildD.BuildTags = conf.BuildFlags
|
|
basePackage, err := buildD.ImportDir(pkgPath, build.ImportComment)
|
|
if err != nil {
|
|
return []*packages.Package{}, fmt.Errorf("importing dir %q: %w", pkgPath, err)
|
|
}
|
|
|
|
var packageFiles []string
|
|
for _, filename := range basePackage.GoFiles {
|
|
packageFiles = append(packageFiles, path.Join(pkgPath, filename))
|
|
}
|
|
for _, filename := range basePackage.CgoFiles {
|
|
packageFiles = append(packageFiles, path.Join(pkgPath, filename))
|
|
}
|
|
|
|
if gosec.tests {
|
|
testsFiles := make([]string, 0)
|
|
testsFiles = append(testsFiles, basePackage.TestGoFiles...)
|
|
testsFiles = append(testsFiles, basePackage.XTestGoFiles...)
|
|
for _, filename := range testsFiles {
|
|
packageFiles = append(packageFiles, path.Join(pkgPath, filename))
|
|
}
|
|
}
|
|
|
|
// step 3/3 remove build tags from conf to proceed build correctly.
|
|
conf.BuildFlags = nil
|
|
pkgs, err := packages.Load(conf, packageFiles...)
|
|
if err != nil {
|
|
return []*packages.Package{}, fmt.Errorf("loading files from package %q: %w", pkgPath, err)
|
|
}
|
|
return pkgs, nil
|
|
}
|
|
|
|
// Check runs analysis on the given package
|
|
func (gosec *Analyzer) Check(pkg *packages.Package) {
|
|
gosec.logger.Println("Checking package:", pkg.Name)
|
|
for _, file := range pkg.Syntax {
|
|
fp := pkg.Fset.File(file.Pos())
|
|
if fp == nil {
|
|
// skip files which cannot be located
|
|
continue
|
|
}
|
|
checkedFile := fp.Name()
|
|
// Skip the no-Go file from analysis (e.g. a Cgo files is expanded in 3 different files
|
|
// stored in the cache which do not need to by analyzed)
|
|
if filepath.Ext(checkedFile) != ".go" {
|
|
continue
|
|
}
|
|
if gosec.excludeGenerated && isGeneratedFile(file) {
|
|
gosec.logger.Println("Ignoring generated file:", checkedFile)
|
|
continue
|
|
}
|
|
|
|
gosec.logger.Println("Checking file:", checkedFile)
|
|
gosec.context.FileSet = pkg.Fset
|
|
gosec.context.Config = gosec.config
|
|
gosec.context.Comments = ast.NewCommentMap(gosec.context.FileSet, file, file.Comments)
|
|
gosec.context.Root = file
|
|
gosec.context.Info = pkg.TypesInfo
|
|
gosec.context.Pkg = pkg.Types
|
|
gosec.context.PkgFiles = pkg.Syntax
|
|
gosec.context.Imports = NewImportTracker()
|
|
gosec.context.PassedValues = make(map[string]interface{})
|
|
ast.Walk(gosec, file)
|
|
gosec.stats.NumFiles++
|
|
gosec.stats.NumLines += pkg.Fset.File(file.Pos()).LineCount()
|
|
}
|
|
}
|
|
|
|
func isGeneratedFile(file *ast.File) bool {
|
|
for _, comment := range file.Comments {
|
|
for _, row := range comment.List {
|
|
if generatedCodePattern.MatchString(row.Text) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ParseErrors parses the errors from given package
|
|
func (gosec *Analyzer) ParseErrors(pkg *packages.Package) error {
|
|
if len(pkg.Errors) == 0 {
|
|
return nil
|
|
}
|
|
for _, pkgErr := range pkg.Errors {
|
|
parts := strings.Split(pkgErr.Pos, ":")
|
|
file := parts[0]
|
|
var err error
|
|
var line int
|
|
if len(parts) > 1 {
|
|
if line, err = strconv.Atoi(parts[1]); err != nil {
|
|
return fmt.Errorf("parsing line: %w", err)
|
|
}
|
|
}
|
|
var column int
|
|
if len(parts) > 2 {
|
|
if column, err = strconv.Atoi(parts[2]); err != nil {
|
|
return fmt.Errorf("parsing column: %w", err)
|
|
}
|
|
}
|
|
msg := strings.TrimSpace(pkgErr.Msg)
|
|
newErr := NewError(line, column, msg)
|
|
if errSlice, ok := gosec.errors[file]; ok {
|
|
gosec.errors[file] = append(errSlice, *newErr)
|
|
} else {
|
|
errSlice = []Error{}
|
|
gosec.errors[file] = append(errSlice, *newErr)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AppendError appends an error to the file errors
|
|
func (gosec *Analyzer) AppendError(file string, err error) {
|
|
// Do not report the error for empty packages (e.g. files excluded from build with a tag)
|
|
r := regexp.MustCompile(`no buildable Go source files in`)
|
|
if r.MatchString(err.Error()) {
|
|
return
|
|
}
|
|
errors := make([]Error, 0)
|
|
if ferrs, ok := gosec.errors[file]; ok {
|
|
errors = ferrs
|
|
}
|
|
ferr := NewError(0, 0, err.Error())
|
|
errors = append(errors, *ferr)
|
|
gosec.errors[file] = errors
|
|
}
|
|
|
|
// ignore a node (and sub-tree) if it is tagged with a nosec tag comment
|
|
func (gosec *Analyzer) ignore(n ast.Node) map[string]SuppressionInfo {
|
|
if groups, ok := gosec.context.Comments[n]; ok && !gosec.ignoreNosec {
|
|
|
|
// Checks if an alternative for #nosec is set and, if not, uses the default.
|
|
noSecDefaultTag := "#nosec"
|
|
noSecAlternativeTag, err := gosec.config.GetGlobal(NoSecAlternative)
|
|
if err != nil {
|
|
noSecAlternativeTag = noSecDefaultTag
|
|
}
|
|
|
|
for _, group := range groups {
|
|
comment := strings.TrimSpace(group.Text())
|
|
foundDefaultTag := strings.HasPrefix(comment, noSecDefaultTag) || regexp.MustCompile("\n *"+noSecDefaultTag).Match([]byte(comment))
|
|
foundAlternativeTag := strings.HasPrefix(comment, noSecAlternativeTag) || regexp.MustCompile("\n *"+noSecAlternativeTag).Match([]byte(comment))
|
|
|
|
if foundDefaultTag || foundAlternativeTag {
|
|
gosec.stats.NumNosec++
|
|
|
|
// Discard what's in front of the nosec tag.
|
|
if foundDefaultTag {
|
|
comment = strings.SplitN(comment, noSecDefaultTag, 2)[1]
|
|
} else {
|
|
comment = strings.SplitN(comment, noSecAlternativeTag, 2)[1]
|
|
}
|
|
|
|
// Extract the directive and the justification.
|
|
justification := ""
|
|
commentParts := regexp.MustCompile(`-{2,}`).Split(comment, 2)
|
|
directive := commentParts[0]
|
|
if len(commentParts) > 1 {
|
|
justification = strings.TrimSpace(strings.TrimRight(commentParts[1], "\n"))
|
|
}
|
|
|
|
// Pull out the specific rules that are listed to be ignored.
|
|
re := regexp.MustCompile(`(G\d{3})`)
|
|
matches := re.FindAllStringSubmatch(directive, -1)
|
|
|
|
suppression := SuppressionInfo{
|
|
Kind: "inSource",
|
|
Justification: justification,
|
|
}
|
|
|
|
// Find the rule IDs to ignore.
|
|
ignores := make(map[string]SuppressionInfo)
|
|
for _, v := range matches {
|
|
ignores[v[1]] = suppression
|
|
}
|
|
|
|
// If no specific rules were given, ignore everything.
|
|
if len(matches) == 0 {
|
|
ignores[aliasOfAllRules] = suppression
|
|
}
|
|
return ignores
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Visit runs the gosec visitor logic over an AST created by parsing go code.
|
|
// Rule methods added with AddRule will be invoked as necessary.
|
|
func (gosec *Analyzer) Visit(n ast.Node) ast.Visitor {
|
|
// If we've reached the end of this branch, pop off the ignores stack.
|
|
if n == nil {
|
|
if len(gosec.context.Ignores) > 0 {
|
|
gosec.context.Ignores = gosec.context.Ignores[1:]
|
|
}
|
|
return gosec
|
|
}
|
|
switch i := n.(type) {
|
|
case *ast.File:
|
|
// Using ast.File instead of ast.ImportSpec, so that we can track
|
|
// all imports at once.
|
|
gosec.context.Imports.TrackFile(i)
|
|
}
|
|
|
|
// Get any new rule exclusions.
|
|
ignoredRules := gosec.ignore(n)
|
|
|
|
// Now create the union of exclusions.
|
|
ignores := map[string][]SuppressionInfo{}
|
|
if len(gosec.context.Ignores) > 0 {
|
|
for k, v := range gosec.context.Ignores[0] {
|
|
ignores[k] = v
|
|
}
|
|
}
|
|
|
|
for ruleID, suppression := range ignoredRules {
|
|
ignores[ruleID] = append(ignores[ruleID], suppression)
|
|
}
|
|
|
|
// Push the new set onto the stack.
|
|
gosec.context.Ignores = append([]map[string][]SuppressionInfo{ignores}, gosec.context.Ignores...)
|
|
|
|
for _, rule := range gosec.ruleset.RegisteredFor(n) {
|
|
// Check if all rules are ignored.
|
|
generalSuppressions, generalIgnored := ignores[aliasOfAllRules]
|
|
// Check if the specific rule is ignored
|
|
ruleSuppressions, ruleIgnored := ignores[rule.ID()]
|
|
|
|
ignored := generalIgnored || ruleIgnored
|
|
suppressions := append(generalSuppressions, ruleSuppressions...)
|
|
|
|
// Track external suppressions.
|
|
if gosec.ruleset.IsRuleSuppressed(rule.ID()) {
|
|
ignored = true
|
|
suppressions = append(suppressions, SuppressionInfo{
|
|
Kind: "external",
|
|
Justification: externalSuppressionJustification,
|
|
})
|
|
}
|
|
|
|
issue, err := rule.Match(n, gosec.context)
|
|
if err != nil {
|
|
file, line := GetLocation(n, gosec.context)
|
|
file = path.Base(file)
|
|
gosec.logger.Printf("Rule error: %v => %s (%s:%d)\n", reflect.TypeOf(rule), err, file, line)
|
|
}
|
|
if issue != nil {
|
|
if gosec.showIgnored {
|
|
issue.NoSec = ignored
|
|
}
|
|
if !ignored || !gosec.showIgnored {
|
|
gosec.stats.NumFound++
|
|
}
|
|
if ignored && gosec.trackSuppressions {
|
|
issue.WithSuppressions(suppressions)
|
|
gosec.issues = append(gosec.issues, issue)
|
|
} else if !ignored || gosec.showIgnored || gosec.ignoreNosec {
|
|
gosec.issues = append(gosec.issues, issue)
|
|
}
|
|
}
|
|
}
|
|
return gosec
|
|
}
|
|
|
|
// Report returns the current issues discovered and the metrics about the scan
|
|
func (gosec *Analyzer) Report() ([]*Issue, *Metrics, map[string][]Error) {
|
|
return gosec.issues, gosec.stats, gosec.errors
|
|
}
|
|
|
|
// Reset clears state such as context, issues and metrics from the configured analyzer
|
|
func (gosec *Analyzer) Reset() {
|
|
gosec.context = &Context{}
|
|
gosec.issues = make([]*Issue, 0, 16)
|
|
gosec.stats = &Metrics{}
|
|
gosec.ruleset = NewRuleSet()
|
|
}
|