Skip to content

Commit caf93d0

Browse files
authored
Improve taint analyzer performance with shared SSA cache, parallel analyzer execution, and CI regression guard (#1530)
* Improve taint analyzer performance with shared SSA cache, parallel analyzer execution, and CI regression guard * Added a shared per-package SSA analysis cache with lazy, concurrency-safe call graph reuse across analyzers. * Updated taint analyzers to consume the shared cache instead of recomputing expensive artifacts per rule run. * Parallelized analyzer execution at package level while preserving deterministic issue aggregation. * Added a package-level taint benchmark to measure real end-to-end taint analyzer pass performance. * Introduced a CI benchmark regression guard with configurable thresholds for ns/op, B/op, and allocs/op. * Documented the performance guard workflow, local run command, and baseline update process in the README. Signed-off-by: Cosmin Cojocar <cosmin@cojocar.ch> * Fix script Signed-off-by: Cosmin Cojocar <cosmin@cojocar.ch> --------- Signed-off-by: Cosmin Cojocar <cosmin@cojocar.ch>
1 parent bd11fbe commit caf93d0

10 files changed

Lines changed: 447 additions & 44 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Baseline metrics for BenchmarkTaintPackageAnalyzers_SharedCache
2+
# Update with: BENCH_COUNT=10 tools/check_taint_benchmark.sh --update-baseline
3+
BASE_NS_OP=33593865
4+
BASE_B_PER_OP=8641204
5+
BASE_ALLOCS_PER_OP=51374
6+
7+
# Allowed regressions (%) relative to baseline
8+
NS_OP_REGRESSION_PCT=15
9+
B_PER_OP_REGRESSION_PCT=10
10+
ALLOCS_PER_OP_REGRESSION_PCT=10

.github/workflows/ci.yml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,28 @@ jobs:
4343
run: make test
4444
- name: Perf Diff
4545
run: make perf-diff
46+
taint-perf-guard:
47+
runs-on: ubuntu-latest
48+
env:
49+
GO111MODULE: on
50+
BENCH_COUNT: "5"
51+
steps:
52+
- name: Setup go
53+
uses: actions/setup-go@v6
54+
with:
55+
go-version: "1.26.0"
56+
- name: Checkout Source
57+
uses: actions/checkout@v6
58+
- uses: actions/cache@v5
59+
with:
60+
path: ~/go/pkg/mod
61+
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
62+
restore-keys: |
63+
${{ runner.os }}-go-
64+
- name: Check taint benchmark regression
65+
run: bash tools/check_taint_benchmark.sh
4666
coverage:
47-
needs: [test]
67+
needs: [test, taint-perf-guard]
4868
runs-on: ubuntu-latest
4969
env:
5070
GO111MODULE: on

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,27 @@ nogo(
171171
go install github.com/securego/gosec/v2/cmd/gosec@latest
172172
```
173173

174+
## Performance regression guard
175+
176+
CI includes a taint-analysis benchmark guard based on `BenchmarkTaintPackageAnalyzers_SharedCache`.
177+
178+
- Baseline values and allowed regression thresholds are stored in [.github/benchmarks/taint_benchmark_baseline.env](.github/benchmarks/taint_benchmark_baseline.env).
179+
- CI runs the guard script [tools/check_taint_benchmark.sh](tools/check_taint_benchmark.sh), averages several benchmark samples, and fails if `ns/op`, `B/op`, or `allocs/op` exceed configured thresholds.
180+
181+
Run the guard locally:
182+
183+
```bash
184+
bash tools/check_taint_benchmark.sh
185+
```
186+
187+
Update the baseline after intentional performance changes:
188+
189+
```bash
190+
BENCH_COUNT=10 bash tools/check_taint_benchmark.sh --update-baseline
191+
```
192+
193+
When baseline updates are intentional, commit both the benchmark-related code changes and the updated baseline file.
194+
174195
## Usage
175196

176197
Gosec can be configured to only run a subset of rules, to exclude certain file

analyzer.go

Lines changed: 61 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -559,55 +559,77 @@ func (gosec *Analyzer) CheckAnalyzersWithSSA(pkg *packages.Package, ssaResult *b
559559

560560
// checkAnalyzersWithSSA runs analyzers on a given package using an existing SSA result (Stateless API).
561561
func (gosec *Analyzer) checkAnalyzersWithSSA(pkg *packages.Package, ssaResult *buildssa.SSA, allIgnores ignores) ([]*issue.Issue, *Metrics) {
562-
resultMap := map[*analysis.Analyzer]any{
563-
buildssa.Analyzer: &ssautil.SSAAnalyzerResult{
564-
Config: gosec.Config(),
565-
Logger: gosec.logger,
566-
SSA: ssaResult,
567-
},
562+
sharedCache := ssautil.NewPackageAnalysisCache(ssaResult)
563+
ssaAnalyzerResult := &ssautil.SSAAnalyzerResult{
564+
Config: gosec.Config(),
565+
Logger: gosec.logger,
566+
SSA: ssaResult,
567+
Shared: sharedCache,
568568
}
569569

570570
generatedFiles := gosec.generatedFiles(pkg)
571571
issues := make([]*issue.Issue, 0)
572572
stats := &Metrics{}
573+
analyzerRuns := make([][]*issue.Issue, len(gosec.analyzerSet.Analyzers))
574+
575+
runner := errgroup.Group{}
576+
runner.SetLimit(max(gosec.concurrency, 1))
577+
578+
for index, analyzer := range gosec.analyzerSet.Analyzers {
579+
runner.Go(func() error {
580+
pass := &analysis.Pass{
581+
Analyzer: analyzer,
582+
Fset: pkg.Fset,
583+
Files: pkg.Syntax,
584+
OtherFiles: pkg.OtherFiles,
585+
IgnoredFiles: pkg.IgnoredFiles,
586+
Pkg: pkg.Types,
587+
TypesInfo: pkg.TypesInfo,
588+
TypesSizes: pkg.TypesSizes,
589+
ResultOf: map[*analysis.Analyzer]any{
590+
buildssa.Analyzer: ssaAnalyzerResult,
591+
},
592+
Report: func(d analysis.Diagnostic) {},
593+
ImportObjectFact: nil,
594+
ExportObjectFact: nil,
595+
ImportPackageFact: nil,
596+
ExportPackageFact: nil,
597+
AllObjectFacts: nil,
598+
AllPackageFacts: nil,
599+
}
600+
601+
result, err := pass.Analyzer.Run(pass)
602+
if err != nil {
603+
gosec.logger.Printf("Error running analyzer %s: %s\n", analyzer.Name, err)
604+
return nil
605+
}
606+
607+
if result == nil {
608+
return nil
609+
}
573610

574-
for _, analyzer := range gosec.analyzerSet.Analyzers {
575-
pass := &analysis.Pass{
576-
Analyzer: analyzer,
577-
Fset: pkg.Fset,
578-
Files: pkg.Syntax,
579-
OtherFiles: pkg.OtherFiles,
580-
IgnoredFiles: pkg.IgnoredFiles,
581-
Pkg: pkg.Types,
582-
TypesInfo: pkg.TypesInfo,
583-
TypesSizes: pkg.TypesSizes,
584-
ResultOf: resultMap,
585-
Report: func(d analysis.Diagnostic) {},
586-
ImportObjectFact: nil,
587-
ExportObjectFact: nil,
588-
ImportPackageFact: nil,
589-
ExportPackageFact: nil,
590-
AllObjectFacts: nil,
591-
AllPackageFacts: nil,
592-
}
593-
result, err := pass.Analyzer.Run(pass)
594-
if err != nil {
595-
gosec.logger.Printf("Error running analyzer %s: %s\n", analyzer.Name, err)
596-
continue
597-
}
598-
if result != nil {
599611
if passIssues, ok := result.([]*issue.Issue); ok {
600-
for _, iss := range passIssues {
601-
if gosec.excludeGenerated {
602-
if _, ok := generatedFiles[iss.File]; ok {
603-
continue
604-
}
605-
}
612+
analyzerRuns[index] = passIssues
613+
}
614+
615+
return nil
616+
})
617+
}
618+
619+
if err := runner.Wait(); err != nil {
620+
gosec.logger.Printf("Error waiting for analyzers: %s\n", err)
621+
}
606622

607-
// issue filtering logic
608-
issues = gosec.updateIssues(iss, issues, stats, allIgnores)
623+
for _, passIssues := range analyzerRuns {
624+
for _, iss := range passIssues {
625+
if gosec.excludeGenerated {
626+
if _, ok := generatedFiles[iss.File]; ok {
627+
continue
609628
}
610629
}
630+
631+
// issue filtering logic
632+
issues = gosec.updateIssues(iss, issues, stats, allIgnores)
611633
}
612634
}
613635
return issues, stats

analyzer_bench_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package gosec
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"log"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"testing"
11+
12+
"golang.org/x/tools/go/packages"
13+
14+
"github.com/securego/gosec/v2/analyzers"
15+
)
16+
17+
func BenchmarkTaintPackageAnalyzers_SharedCache(b *testing.B) {
18+
pkg := createTaintBenchmarkPackage(b, generateTaintStressProgram(180))
19+
20+
logger := log.New(io.Discard, "", 0)
21+
analyzer := NewAnalyzer(NewConfig(), false, false, false, 6, logger)
22+
analyzer.LoadAnalyzers(analyzers.Generate(false,
23+
analyzers.NewAnalyzerFilter(false, "G701", "G702", "G703", "G704", "G705", "G706"),
24+
).AnalyzersInfo())
25+
26+
ssaResult, err := analyzer.buildSSA(pkg)
27+
if err != nil {
28+
b.Fatalf("failed to build SSA: %v", err)
29+
}
30+
31+
b.ResetTimer()
32+
for range b.N {
33+
issues, stats := analyzer.checkAnalyzersWithSSA(pkg, ssaResult, nil)
34+
if stats == nil {
35+
b.Fatal("stats is nil")
36+
}
37+
if issues == nil {
38+
b.Fatal("issues slice is nil")
39+
}
40+
}
41+
}
42+
43+
func createTaintBenchmarkPackage(b *testing.B, source string) *packages.Package {
44+
b.Helper()
45+
46+
tmpDir, err := os.MkdirTemp("", "gosec_taint_bench")
47+
if err != nil {
48+
b.Fatalf("failed to create temp dir: %v", err)
49+
}
50+
b.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
51+
52+
mainGo := filepath.Join(tmpDir, "main.go")
53+
if err := os.WriteFile(mainGo, []byte(source), 0o600); err != nil {
54+
b.Fatalf("failed to write source file: %v", err)
55+
}
56+
57+
goMod := filepath.Join(tmpDir, "go.mod")
58+
if err := os.WriteFile(goMod, []byte("module bench\n\ngo 1.25\n"), 0o600); err != nil {
59+
b.Fatalf("failed to write go.mod: %v", err)
60+
}
61+
62+
conf := &packages.Config{
63+
Mode: LoadMode,
64+
Dir: tmpDir,
65+
}
66+
67+
pkgs, err := packages.Load(conf, ".")
68+
if err != nil {
69+
b.Fatalf("failed to load package: %v", err)
70+
}
71+
if len(pkgs) == 0 {
72+
b.Fatal("no packages loaded")
73+
}
74+
if len(pkgs[0].Errors) > 0 {
75+
b.Fatalf("errors loading package: %v", pkgs[0].Errors)
76+
}
77+
78+
return pkgs[0]
79+
}
80+
81+
func generateTaintStressProgram(functionCount int) string {
82+
var sb strings.Builder
83+
84+
sb.WriteString("package main\n")
85+
sb.WriteString("\nimport (\n")
86+
sb.WriteString("\t\"database/sql\"\n")
87+
sb.WriteString("\t\"fmt\"\n")
88+
sb.WriteString("\t\"log\"\n")
89+
sb.WriteString("\t\"net/http\"\n")
90+
sb.WriteString("\t\"os\"\n")
91+
sb.WriteString("\t\"os/exec\"\n")
92+
sb.WriteString(")\n\n")
93+
94+
sb.WriteString("var globalDB *sql.DB\n\n")
95+
96+
for i := range functionCount {
97+
fmt.Fprintf(&sb, "func sinkFanout%d(w http.ResponseWriter, r *http.Request) {\n", i)
98+
sb.WriteString("\tq := r.URL.Query().Get(\"q\")\n")
99+
sb.WriteString("\tenv := os.Getenv(\"TAINT_ENV\")\n")
100+
sb.WriteString("\tjoined := q + env\n")
101+
sb.WriteString("\t_, _ = globalDB.Query(joined)\n")
102+
sb.WriteString("\t_ = exec.Command(\"sh\", \"-c\", joined)\n")
103+
sb.WriteString("\t_, _ = os.Open(joined)\n")
104+
sb.WriteString("\t_, _ = http.Get(joined)\n")
105+
sb.WriteString("\t_, _ = fmt.Fprintf(w, \"%s\", joined)\n")
106+
sb.WriteString("\t_, _ = w.Write([]byte(joined))\n")
107+
sb.WriteString("\tlog.Print(joined)\n")
108+
sb.WriteString("}\n\n")
109+
}
110+
111+
sb.WriteString("func main() {\n")
112+
sb.WriteString("\thttp.HandleFunc(\"/\", func(w http.ResponseWriter, r *http.Request) {\n")
113+
for i := range functionCount {
114+
fmt.Fprintf(&sb, "\t\tsinkFanout%d(w, r)\n", i)
115+
}
116+
sb.WriteString("\t})\n")
117+
sb.WriteString("}\n")
118+
119+
return sb.String()
120+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package ssautil
2+
3+
import (
4+
"sync"
5+
6+
"golang.org/x/tools/go/analysis/passes/buildssa"
7+
"golang.org/x/tools/go/callgraph"
8+
"golang.org/x/tools/go/callgraph/cha"
9+
)
10+
11+
// PackageAnalysisCache stores expensive SSA-derived artifacts that can be
12+
// shared by multiple analyzers running on the same package.
13+
type PackageAnalysisCache struct {
14+
ssa *buildssa.SSA
15+
16+
callGraphOnce sync.Once
17+
callGraph *callgraph.Graph
18+
}
19+
20+
// NewPackageAnalysisCache builds a cache object for a package-level SSA result.
21+
func NewPackageAnalysisCache(ssaResult *buildssa.SSA) *PackageAnalysisCache {
22+
return &PackageAnalysisCache{ssa: ssaResult}
23+
}
24+
25+
// CallGraph returns a lazily initialized CHA call graph for the package.
26+
// It is safe for concurrent use by multiple analyzers.
27+
func (c *PackageAnalysisCache) CallGraph() *callgraph.Graph {
28+
if c == nil {
29+
return nil
30+
}
31+
32+
c.callGraphOnce.Do(func() {
33+
if c.ssa == nil || len(c.ssa.SrcFuncs) == 0 || c.ssa.SrcFuncs[0] == nil {
34+
return
35+
}
36+
c.callGraph = cha.CallGraph(c.ssa.SrcFuncs[0].Prog)
37+
})
38+
39+
return c.callGraph
40+
}

internal/ssautil/ssa_result.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type SSAAnalyzerResult struct {
2020
Config map[string]any
2121
Logger *log.Logger
2222
SSA *buildssa.SSA
23+
Shared *PackageAnalysisCache
2324
}
2425

2526
// GetSSAResult retrieves the SSA result from analysis pass

taint/analyzer.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ func makeAnalyzerRunner(rule *RuleInfo, config *Config) func(*analysis.Pass) (in
5656

5757
// Run taint analysis
5858
analyzer := New(config)
59+
if ssaResult.Shared != nil {
60+
analyzer.SetCallGraph(ssaResult.Shared.CallGraph())
61+
}
5962
results := analyzer.Analyze(srcFuncs[0].Prog, srcFuncs)
6063

6164
// Convert results to gosec issues

taint/taint.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ type Analyzer struct {
102102
callGraph *callgraph.Graph
103103
}
104104

105+
// SetCallGraph injects a precomputed call graph.
106+
func (a *Analyzer) SetCallGraph(cg *callgraph.Graph) {
107+
a.callGraph = cg
108+
}
109+
105110
// New creates a new taint analyzer with the given configuration.
106111
func New(config *Config) *Analyzer {
107112
a := &Analyzer{
@@ -176,10 +181,12 @@ func (a *Analyzer) Analyze(prog *ssa.Program, srcFuncs []*ssa.Function) []Result
176181
return nil
177182
}
178183

179-
// Build call graph using Class Hierarchy Analysis (CHA).
180-
// CHA is fast and sound (no false negatives) but may have false positives.
181-
// For more precision, use VTA (Variable Type Analysis) instead.
182-
a.callGraph = cha.CallGraph(prog)
184+
if a.callGraph == nil {
185+
// Build call graph using Class Hierarchy Analysis (CHA).
186+
// CHA is fast and sound (no false negatives) but may have false positives.
187+
// For more precision, use VTA (Variable Type Analysis) instead.
188+
a.callGraph = cha.CallGraph(prog)
189+
}
183190

184191
var results []Result
185192

0 commit comments

Comments
 (0)