Go中的自定义静态分析教程-第一部分

656 阅读7分钟

静态分析是在代码运行前以自动化的方式检查源代码的做法,通常是为了在它们表现出来之前发现错误。作为一种用于关键任务应用的强大编程语言,Go也为其开发人员编写安全代码增加了很多责任。零指针恐慌、变量影子以及其他忽视重要错误的风险,会使一个看起来不错的程序成为攻击或你从未想象过的故障的轻松目标。

这与使用临界点有什么不同

从定义上讲,linters已经是一种静态分析器,旨在从一组公认的标准中找到你的代码中的不良做法。虽然铸币师提供了一种根据一系列规则来规范你的代码的方法,但有一些高级的边缘情况,即使铸币师也无法执行,例如针对你的应用程序的bug。对于这种边缘情况,编写你自己的静态分析是你最好的选择。

通过编写静态分析工具,你可以以编程方式查看自己的源代码,并执行任何你想执行的规则。事实上,由于Go团队编写的golang.org/x/tools/go/analysis,Go使静态分析变得非常容易,在我们能做的事情方面给了我们很大的灵活性。我们将使用标准库的分析工具来研究防止我们的代码以不安全的方式编译的重要方法。这篇博文的代码可以在Github github.com/rauljordan/…上找到。

让我们先看看一些从静态分析中受益的代码的主要例子,这些例子已经被大多数主要linters和工具(如go vet )捕获。

检查我们代码中没有处理返回错误的地方

有时我们会忘记正确处理或传播Go代码中的错误:

users, _ := db.GetUsers()
for _, user := range users {
	fmt.Println(user.Name)
}

我们没有处理上面的错误,如果users 由于调用db.GetUsers() 的错误而变成了nil,那么我们在运行时就会在上面的代码中出现nil指针的恐慌。这是静态分析的一个常见候选项,实际上已经是go vet 工具中包含的一个流行项,称为errcheck

意外的变量阴影

package main

import (
	"fmt"
)

func test () (x int64, y int64) {
	x = 3
	y = 6
	return
}

func main() {
	var x int64 = 2
	fmt.Printf("x %v \n", x)
	x, y := test()
 	fmt.Printf("x %v y %v \n", x, y)
}

输出:

x 2 
x 3 y 6 

尽管我们初始化了一个新的变量y ,但并没有声明新的x ,而是将原来的值进行了影射,这是一个很大的错误风险,也是Go代码库中许多问题的来源。检查意外的变量阴影是静态分析的另一个常见用例。

我们的用例:在我们的应用程序中执行文件权限的最佳实践

在我的公司,我们维护着一个用Go编写的名为Prysm的开源项目,该项目是为处理数以亿计的美元而建立的,这使得它成为攻击者从不太懂技术的用户或可能有不安全配置的用户那里偷窃的首要目标。在我们的特定应用程序中,我们正在将敏感文件写入用户拥有的目录中。为此,我们想利用unix的文件权限来确保任何文件只有来自用户的读/写权限,而不是来自组或同一系统上的其他用户。

我们想为我们的应用程序完成的是确保我们对当前用户的文件有读/写权限。作为一种理智的检查,我们可以使用下面这个有用的工具permissions-calculator

UNIX权限使用八进制符号,你可以在这里阅读更多的信息。根据计算器的结果,权限0600将完成我们所期望的最终目标。

主要问题:标准库做出了危险的假设

Go的标准库非常棒,而且有很多功能。然而,它的一些有用的功能,如os.MkdirAllioutil.WriteFile ,如果被误用就很危险。假设我们想创建一个目录,如myapplication/secrets/ ,我们可以写下面的代码来帮助我们:

package main

import (
	"os"
	"log"
)

func main() {
	// Write with user only read/write/execute permissions.
	if err := os.MkdirAll("myapplication/secrets", 0700); err != nil {
		log.Fatalf("Could not write directory: %v", err)
	}
}

然而,事实证明,如果该目录已经存在,即使它有不同的权限,上面的代码也会无误完成。让我们写一个测试:

package main

import (
	"os"
	"testing"
)

func TestMkdirAll_SilentFailure(t *testing.T) {
	dirPath := "myapplication/secrets"
	t.Cleanup(func() {
		if err := os.RemoveAll(dirPath); err != nil {
			t.Error("Could not remove directory")
		}
	})
	// Evil attacker creates the directory ahead of time
	// with full 777 permissions.
	if err := os.MkdirAll(dirPath, 0777); err != nil {
		t.Fatalf("Could not write directory: %v", err)
	}
	// Now our application attempts to write to the directory
	// with 700 permissions to only allow current user read/write/exec.
	if err := os.MkdirAll(dirPath, 0700); err != nil {
		t.Fatalf("Could not write directory: %v", err)
	}
	info, err := os.Stat(dirPath)
	if err != nil {
		t.Fatal(err)
	}
	// Check if other users have read permission.
	if info.Mode()&(1<<2) != 0 {
		t.Error("Expected permissions only for user")
	}
}

让我们运行这个测试:

$ go test .
--- FAIL: TestMkdirAll_SilentFailure (0.00s)
    main_test.go:61: Expected permissions only for user
FAIL

呀!在第二次调用os.MkdirAll ,这里没有错误,更糟糕的是,攻击者能够以最开放的权限创建一个目录,损害了我们应用程序的安全假设。什么原因呢?首先,标准库需要对所需的默认行为做出尽可能少的假设,而事实证明这个假设是他们能做出的最简单的假设。其次,同样的行为也出现在流行的ioutil 包的WriteFile 函数中:

package main

import (
	"io/ioutil"
	"os"
	"path/filepath"
	"testing"
)

func TestWriteFile_SilentFailure(t *testing.T) {
	dirPath := "myapplication/secrets"
	t.Cleanup(func() {
		if err := os.RemoveAll(dirPath); err != nil {
			t.Error("Could not remove directory")
		}
	})
	// We create a directory with 777 permissions.
	if err := os.MkdirAll(dirPath, 0777); err != nil {
		t.Fatalf("Could not write directory: %v", err)
	}
	secretFile := filepath.Join(dirPath, "credentials.txt")
	if err := ioutil.WriteFile(secretFile, []byte("password"), 0777); err != nil {
		t.Fatalf("Could not write file: %v", err)
	}
	if err := ioutil.WriteFile(secretFile, []byte("password"), 0600); err != nil {
		t.Fatalf("Could not write file: %v", err)
	}
	info, err := os.Stat(secretFile)
	if err != nil {
		t.Fatal(err)
	}
	// Check if other users have read permission.
	if info.Mode()&(1<<2) != 0 {
		t.Error("Expected permissions only for user")
	}
}

让我们运行测试:

$ go test .
--- FAIL: TestWriteFile_SilentFailure (0.00s)
    main_test.go:61: Expected permissions only for user
FAIL

同样的事情!如果我们试图写一个已经被攻击者用不同权限破坏的文件,我们甚至不会得到一个错误。很明显,如果你正在编写一个重要的应用程序,这些来自标准库的文件写入工具是有风险的,我们应该使用我们自己的特定函数,正确地检查这个问题。例如,我们可以在我们的应用程序中创建我们的小包,称为fileutil ,在这里我们创建我们自己的WriteFileMkdirAll

package fileutil

func WriteFile(filename string, data []byte) error {
	// Make sure the file does not already exist with different permissions.
	...
	// Write a file with strict, 600 permissions (read/write for user only).
	...
}

func MkdirAll(dir string) error {
	// Make sure the directory does not already exist with different permissions.
	...
	// Write a directory with strict, 700 permissions (read/write/execute for user only).
	...
}

很好,现在我们可以直接告诉公司里的每个开发人员或我们开源项目的贡献者,不要使用os ,也不要使用ioutil ,而是使用我们自己的fileutil 包,对吗?在一个快速移动的代码库中,尤其是在开放源代码中,这几乎是不可能的。我们应该把它添加到现有的静态检查工具中,使其成为我们持续集成套件的一部分,例如,如果使用标准库的文件编写工具,项目根本无法构建。这就是静态分析发生的地方,确保我们的程序在运行前就出错。

方法:静态分析以确保安全的文件和目录编写

我们的静态分析器的目标将是如下:

  1. 检查我们是否正常导入了osio/ioutil 包,或作为别名导入。
  2. 检查我们程序中的函数调用是否使用了ioutil.WriteFileos.MkdirAll ,并提出问题。
// Package writefile implements a static analyzer to ensure that our project does not
// use ioutil.MkdirAll or os.WriteFile as they are unsafe when it comes to guaranteeing
// file permissions and not overriding existing permissions.
package writefile

import (
	"errors"
	"fmt"
	"go/ast"

	"golang.org/x/tools/go/analysis"
	"golang.org/x/tools/go/analysis/passes/inspect"
	"golang.org/x/tools/go/ast/inspector"
)

// Doc explaining the tool.
const Doc = "Tool to enforce usage of our own internal file-writing utils instead of os.MkdirAll or ioutil.WriteFile"

var errUnsafePackage = errors.New(
	"os and ioutil dir and file writing functions are not permissions-safe, use shared/fileutil",
)

// Analyzer runs static analysis.
var Analyzer = &analysis.Analyzer{
	Name:     "writefile",
	Doc:      Doc,
	Requires: []*analysis.Analyzer{inspect.Analyzer},
	Run:      run,
}

func run(pass *analysis.Pass) (interface{}, error) {

以上,我们定义了我们的导入,使用了由语言作者创建的golang.org/x/tools/go/analysis 包,并定义了一些重要的globals,如关于分析器的信息和我们希望在发现分析器搜索到的模式时打印出来的错误信息。为了运行分析器,我们需要为我们的包制定一个特殊的语法,它将告诉go vet等工具我们应该如何运行这个包。我们的包需要暴露出一个Analyzer 结构,和一个run(pass *analysis.Pass) (interface{}, error) 函数。现在让我们用它来解析我们程序的AST(抽象语法树):

func run(pass *analysis.Pass) (interface{}, error) {
	inspect, ok := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
	if !ok {
		return nil, errors.New("analyzer is not type *inspector.Inspector")
	}

	nodeFilter := []ast.Node{
		(*ast.File)(nil),
		(*ast.ImportSpec)(nil),
		(*ast.CallExpr)(nil),
	}

	aliases := make(map[string]string)
	disallowedFns := []string{"MkdirAll", "WriteFile"}

使用"go/ast" 包,我们可以告诉我们的检查器过滤掉某些Go关键字、函数调用或导入。在这种情况下,我们想检索新的go文件定义、go导入和调用表达式(函数调用的花哨名称)。此外,我们还记录了导入的别名地图,以及我们的linter正在检查的不允许的函数MkdirAllWriteFile

func run(pass *analysis.Pass) (interface{}, error) {
	...
	inspect.Preorder(nodeFilter, func(node ast.Node) {
		switch stmt := node.(type) {
		case *ast.ImportSpec:
			...
		case *ast.CallExpr:
			...
		case *ast.File:
			...
		}
	})
	return nil, nil
}

接下来,我们实际检查我们的程序,并通过inspect.Preorder 函数使用我们定义的过滤器过滤出AST中的节点,这使我们能够根据我们定义的过滤器来切换节点类型。

首先,如果我们看到一个Go导入,我们要检查它是 "os "还是 "io/ioutil",并通过其定义的名称来跟踪它,如果别名为我们定义的aliases map,则只通过其常规导入名称来跟踪。

// Collect aliases.
pkg := stmt.Path.Value
if pkg == "\"os\"" {
	if stmt.Name != nil {
		aliases[stmt.Name.Name] = stmt.Path.Value
	} else {
		aliases["os"] = stmt.Path.Value
	}
}
if pkg == "\"io/ioutil\"" {
	if stmt.Name != nil {
		aliases[stmt.Name.Name] = stmt.Path.Value
	} else {
		aliases["ioutil"] = stmt.Path.Value
	}
}

接下来,如果我们看到一个调用表达式,也就是一个函数调用,我们会检查它是否是别名映射的一部分,以及它是否是我们不允许的函数之一:

for pkg, path := range aliases {
	for _, fn := range disallowedFns {
		// Check if it is a dot imported package.
		if isPkgDot(stmt.Fun, pkg, fn) {
			pass.Reportf(
				node.Pos(),
				fmt.Sprintf(
					"%v: %s.%s() (from %s)",
					errUnsafePackage,
					pkg,
					fn,
					path,
				),
			)
		}
	}
}

如果是这种情况,那么我们就用我们的包定义的错误变量来报告分析结果,让用户知道他们不应该使用这些函数,而应该使用我们自己的fileutil 包。这就是最终的结果:

// Package writefile implements a static analyzer to ensure our project does not
// use ioutil.MkdirAll or os.WriteFile as they are unsafe when it comes to guaranteeing
// file permissions and not overriding existing permissions.
package writefile

import (
	"errors"
	"fmt"
	"go/ast"

	"golang.org/x/tools/go/analysis"
	"golang.org/x/tools/go/analysis/passes/inspect"
	"golang.org/x/tools/go/ast/inspector"
)

// Doc explaining the tool.
const Doc = "Tool to enforce usage of our own file-writing utils instead of os.MkdirAll or ioutil.WriteFile"

var errUnsafePackage = errors.New(
	"os and ioutil dir and file writing functions are not permissions-safe, use shared/fileutil",
)

// Analyzer runs static analysis.
var Analyzer = &analysis.Analyzer{
	Name:     "writefile",
	Doc:      Doc,
	Requires: []*analysis.Analyzer{inspect.Analyzer},
	Run:      run,
}

func run(pass *analysis.Pass) (interface{}, error) {
	inspect, ok := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
	if !ok {
		return nil, errors.New("analyzer is not type *inspector.Inspector")
	}

	nodeFilter := []ast.Node{
		(*ast.File)(nil),
		(*ast.ImportSpec)(nil),
		(*ast.CallExpr)(nil),
	}

	aliases := make(map[string]string)
	disallowedFns := []string{"MkdirAll", "WriteFile"}

	inspect.Preorder(nodeFilter, func(node ast.Node) {
		switch stmt := node.(type) {
		case *ast.File:
			// Reset aliases (per file).
			aliases = make(map[string]string)
		case *ast.ImportSpec:
			// Collect aliases.
			pkg := stmt.Path.Value
			if pkg == "\"os\"" {
				if stmt.Name != nil {
					aliases[stmt.Name.Name] = stmt.Path.Value
				} else {
					aliases["os"] = stmt.Path.Value
				}
			}
			if pkg == "\"io/ioutil\"" {
				if stmt.Name != nil {
					aliases[stmt.Name.Name] = stmt.Path.Value
				} else {
					aliases["ioutil"] = stmt.Path.Value
				}
			}
		case *ast.CallExpr:
			// Check if any of disallowed functions have been used.
			for pkg, path := range aliases {
				for _, fn := range disallowedFns {
					if isPkgDot(stmt.Fun, pkg, fn) {
						pass.Reportf(
							node.Pos(),
							fmt.Sprintf(
								"%v: %s.%s() (from %s)",
								errUnsafePackage,
								pkg,
								fn,
								path,
							),
						)
					}
				}
			}
		}
	})

	return nil, nil
}

func isPkgDot(expr ast.Expr, pkg, name string) bool {
	sel, ok := expr.(*ast.SelectorExpr)
	res := ok && isIdent(sel.X, pkg) && isIdent(sel.Sel, name)
	return res
}

func isIdent(expr ast.Expr, ident string) bool {
	id, ok := expr.(*ast.Ident)
	return ok && id.Name == ident
}

我们的分析器的测试

幸运的是,标准库包含了一个非常简单的方法来测试你的分析器,但它也有一些怪癖和一个特定的语法,需要让它工作。首先,让我们在我们的分析器包内定义一个testdata/ 目录。然后,创建一个analyzer_test.go 文件:

package writefile

import (
	"testing"

	"golang.org/x/tools/go/analysis/analysistest"
)

func TestAnalyzer(t *testing.T) {
	analysistest.Run(t, analysistest.TestData(), Analyzer)
}

我们可以使用analysistest 包,在分析器的testdata/ 目录中运行一堆案例,我们接下来会研究这个问题。在这一点上,我们的文件夹结构看起来如下。

static-analysis/
	main.go
	writefile/
		analyzer.go
		analyzer_test.go
		testdata/
			imports.go

在测试数据方面,这些并不是典型的Go测试文件,而是遵循一种特定的语法,你可以在这里阅读:

诊断的期望值是由一个包含正则表达式的字符串字面来指定的,必须与诊断信息相匹配。比如说。

fmt.Printf("%s", 1) // want `cannot provide int 1 to %s`

所以你的测试必须由函数调用或表达式组成,然后在它们旁边加上一个注释,期望分析器报告什么错误。在我们的案例中,我们可以写几个例子:

package testdata

import (
	"crypto/rand"
	"fmt"
	"io/ioutil"
	"math/big"
	"os"
	"path/filepath"
)

func UseOsMkdirAllAndWriteFile() {
	randPath, _ := rand.Int(rand.Reader, big.NewInt(1000000))
	// Create a random file path in our tmp dir.
	p := filepath.Join(os.TempDir(), fmt.Sprintf("/%d", randPath))
	_ = os.MkdirAll(p, os.ModePerm) // want "os and ioutil dir and file writing functions are not permissions-safe, use shared/fileutil"
	someFile := filepath.Join(p, "some.txt")
	_ = ioutil.WriteFile(someFile, []byte("hello"), os.ModePerm) // want "os and ioutil dir and file writing functions are not permissions-safe, use shared/fileutil"
}

接下来,我们可以运行go测试来检查我们的分析器是否确实报告了那些不该使用的函数。

$ go test ./writefile
ok  	github.com/rauljordan/static-analysis/writefile	0.845s

使用Go vet来应用分析器

为了把我们的分析器作为一个独立的命令来运行,标准库也提供了一些工具。我们所要做的就是定义以下main.go文件:

package main

import (
	"github.com/rauljordan/static-analysis/writefile"
	"golang.org/x/tools/go/analysis/singlechecker"
)

func main() {
	singlechecker.Main(writefile.Analyzer)
}

然后,你可以把它安装到你的系统$GOBIN与:

go install github.com/rauljordan/static-analysis

接下来,你可以把它作为一个独立的Go二进制文件运行,传入一个你想分析的Go包的路径:

static-analysis ./mybadpackage

并查看分析器的运行情况。你也可以用go vet -vettool=$(which static-analysis) ./mybadpackage ,把它作为一个自定义的分析器集成到go vet中。这篇博文的代码可以在Githubgithub.com/rauljordan/…上找到。

下一篇文章: Go频道的高级静态分析

虽然解析程序的基本AST以及将标识符与一些字符串进行比较是很容易的操作,但高级分析包并没有提供很多工具来深入到实际理解程序中。比方说,我们想防止我们的Go代码在没有接收器的情况下通过一个无缓冲的通道发送。对于那些不熟悉的人来说,下面的Go程序将阻塞我们所关心的函数的主线程,这不是运行时的预期行为。

package main

type Email struct {
	Subject string
}

func main() {
	ch := make(chan *Email)
	defer close(ch)
	handleEmailSignup(ch)
}

func handleEmailSignup(ch chan *Email) {
	// Some logic regarding handling a signup event.
	...

	// We send over the channel, which we deliberately did not prepare
	// a receiver for in another function, thereby blocking the thread.
	ch <- &Email{
		Subject: "New user signup",
	}
	// Warning: we'll never hit this line if there is no channel receiver!
	log.Println("New user has just signed up!")
}

因为没有设置通道接收器,所以我们永远不会到达日志New user has just signed up ,而且我们实际上会阻塞主线程。这在生产应用中是非常危险的,因为在我们试图通过通道发送之前,他们可能没有及时准备好一个通道接收器。

通过静态分析来执行这个不变性似乎很艰巨,因为我们不仅需要了解通道操作被调用的地方,还需要了解(a)通道是否是无缓冲的,(b)我们在程序的不同部分,也许在不同的包中向同一个通道指针写东西在未来的一篇博文中,我们将研究Go标准库中的一些高级工具,以便进行更深入的静态分析。谢谢你的阅读!