Lesson 15 / 16
Static Analysis and Security Scanning
Of the same two sources, one is clean, the other flawed; the compiler accepts both the same way, but an analyzer reports the difference between them through an exit code. Of three patterns, the compiler catches none, the analyzer catches two. The limiting measurement shows, by running it, that coming out clean from analysis does not mean flawless — the third, uncaught pattern genuinely crashes when run. A final measurement compares a dependency's declared version against a modeled threshold list.
Published
In this lesson
- Distinguishes the difference between two sources that the compiler cannot see but an analyzer reports through an exit code, with a single measurement
- Counts how many of three patterns the compiler rejects none of are caught by the analyzer
- Bounds analysis coming out clean as not meaning flawless, by running an uncaught pattern
Contents
The tool measured in the previous lesson read the source only at the level of names and types; it never looked at what was inside the bodies at all. This lesson descends into exactly that gap: a tool that reads the body itself, searching for specific patterns. But this tool has its own limit too — reporting what it found does not mean that what it did not search for does not exist.
Two Sources, One Analyzer
Static analysis is a process that searches a source, without ever running it, only by reading its text, for specific fault patterns. The language’s own distribution’s analysis tool does this job with a single command and reports only an exit code — in this lesson we never read the text of the lines it found, only whether the result is clean or flawed.
The measurement below sets up two sources: one prints an integer with the correct format verb, the other gives a string to the same format verb — a mismatch between type and format.
// helpers.go — the helpers this lesson shares: setting up a temporary package and calling the tool
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
// show prints a fact, who states it, and its evidence on a single line.
func show(info, speaker, evidence string) {
fmt.Printf("%-40s %-7s %s\n", info, speaker, evidence)
}
// setupModule writes the given files into a new temporary module; the path is never printed.
func setupModule(files map[string]string) string {
dir, err := os.MkdirTemp("", "analysis")
if err != nil {
return ""
}
os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module analysistrial\n\ngo 1.24\n"), 0o644)
for name, content := range files {
os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)
}
return dir
}
// runTool runs the language's own command, returning the output and whether it succeeded; output is never printed.
func runTool(dir string, arg ...string) (string, bool) {
cmd := exec.Command("go", arg...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
output, err := cmd.CombinedOutput()
return string(output), err == nil
}
// compiles attempts to build the package in the given directory.
func compiles(dir string) bool {
_, ok := runTool(dir, "build", "-o", os.DevNull, ".")
return ok
}
// analysisClean says whether the package in the given directory passes static analysis clean.
func analysisClean(dir string) bool {
_, ok := runTool(dir, "vet", "./...")
return ok
}
// measurement1.go — same two sources, one clean one flawed, both passed through a single analyzer
package main
import "fmt"
func main() {
clean := setupModule(map[string]string{
"prog.go": "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Printf(\"value=%d\\n\", 7)\n}\n",
})
flawed := setupModule(map[string]string{
"prog.go": "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Printf(\"value=%d\\n\", \"text\")\n}\n",
})
fmt.Println("-- two sources, one analyzer --")
show("clean source compiled", "tool", fmt.Sprint(compiles(clean)))
show("flawed source compiled", "tool", fmt.Sprint(compiles(flawed)))
show("clean source analysis", "tool", fmt.Sprint("clean=", analysisClean(clean)))
show("flawed source analysis", "tool", fmt.Sprint("clean=", analysisClean(flawed)))
}
-- two sources, one analyzer -- clean source compiled tool true flawed source compiled tool true clean source analysis tool clean=true flawed source analysis tool clean=false
BD15. Both sources compile — the type mismatch is not a compiler-level error, because the relationship between a format string and its arguments is not the subject of the compiler’s type checking. The analyzer, by contrast, does distinguish these two lines: in one the format verb and the argument type match, in the other they do not. This is another form of the “tool” knowledge seen in previous lessons — the analyzer searches for a pattern without running the source, only by reading it, and reports what it found through a single exit code.
The Pattern the Compiler Does Not Catch
The single pattern above is only a beginning. The measurement below tries three separate patterns together: a format mismatch, a copied lock value, and an ignored error. All three compile; the question asked is how many are caught by the analyzer.
// measurement2.go — three patterns the compiler rejects none of, splitting apart as they pass through the analyzer
package main
import "fmt"
const patternFormat = "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Printf(\"value=%d\\n\", \"text\")\n}\n"
const patternLock = "package main\n\nimport \"sync\"\n\ntype Counter struct {\n\tlock sync.Mutex\n\tvalue int\n}\n\nfunc copyIt(c Counter) Counter {\n\treturn c\n}\n\nfunc main() {\n\tcopyIt(Counter{})\n}\n"
const patternIgnoredError = "package main\n\nimport \"fmt\"\n\ntype Record struct{ Name string }\n\nfunc find(key string) (*Record, error) {\n\tif key == \"\" {\n\t\treturn nil, fmt.Errorf(\"empty key\")\n\t}\n\treturn &Record{Name: key}, nil\n}\n\nfunc main() {\n\tr, _ := find(\"\")\n\tfmt.Println(r.Name)\n}\n"
func main() {
patterns := []struct{ name, source string }{
{"format mismatch", patternFormat},
{"lock copy", patternLock},
{"ignored error", patternIgnoredError},
}
fmt.Println("-- three patterns the compiler does not catch --")
compiledCount, cleanCount := 0, 0
for _, p := range patterns {
dir := setupModule(map[string]string{"prog.go": p.source})
c := compiles(dir)
cl := analysisClean(dir)
if c {
compiledCount++
}
if cl {
cleanCount++
}
show(p.name+" compiled", "tool", fmt.Sprint(c))
show(p.name+" analysis clean", "tool", fmt.Sprint(cl))
}
show("compiled count of three patterns", "tool", fmt.Sprint(compiledCount))
show("analysis-passed count of three patterns", "tool", fmt.Sprint(cleanCount))
show("pattern count caught by analyzer", "tool", fmt.Sprint(len(patterns)-cleanCount))
}
-- three patterns the compiler does not catch -- format mismatch compiled tool true format mismatch analysis clean tool false lock copy compiled tool true lock copy analysis clean tool false ignored error compiled tool true ignored error analysis clean tool true compiled count of three patterns tool 3 analysis-passed count of three patterns tool 1 pattern count caught by analyzer tool 2
BD16. All three patterns compile — the number of patterns caught by the compiler is zero. These three patterns sit entirely outside the layer the compiler works in, because all three are type-valid code: a wrongly formatted print call, a struct copied by value with a lock field inside, an ignored error value — none of these is a type mismatch or a syntax error. The analyzer catches two of the three patterns: the format mismatch and the lock copy. The third — the ignored error — comes out clean from analysis. This third pattern is the subject of the next section.
The two caught patterns share a common property: both fit a pattern that sits outside the
language’s own type system, but is still mechanically recognizable. In the format mismatch, the
pattern is whether every % mark in a format string matches the type of the argument supplied;
in the lock copy, the pattern is whether a type documented by the sync package (any struct
containing a lock) is carried by pointer rather than by value. The analyzer enforces both these
rules by parsing the source and searching for a specific shape in the syntax tree — much like the
signature comparator of the previous lesson, except what is searched here is not a signature but
a usage pattern. The third pattern’s failure to be caught is not because the analyzer fails to
match either of these two rules — it is because a rule searching for an ignored error is not
in the list at all.
Limiting Measurement: Coming Out Clean Does Not Mean Flawless
The third pattern above is a call that ignores a function’s returned error and uses the result
directly. The analyzer found this clean; the compiler did not reject it either. The measurement
below genuinely runs this pattern — this time not by calling a subprocess, but by running the
same logic inside this very measurement, because the panic here will be caught with recover and
read only as a boolean.
// measurement3.go — limiting measurement: coming out clean does not mean flawless
package main
import "fmt"
const patternIgnoredError = "package main\n\nimport \"fmt\"\n\ntype Record struct{ Name string }\n\nfunc find(key string) (*Record, error) {\n\tif key == \"\" {\n\t\treturn nil, fmt.Errorf(\"empty key\")\n\t}\n\treturn &Record{Name: key}, nil\n}\n\nfunc main() {\n\tr, _ := find(\"\")\n\tfmt.Println(r.Name)\n}\n"
// Record and find are a genuine, runnable counterpart of the source above: they carry the exact
// same logic that the analyzer read as text.
type Record struct{ Name string }
func find(key string) (*Record, error) {
if key == "" {
return nil, fmt.Errorf("empty key")
}
return &Record{Name: key}, nil
}
// attempt runs the call that ignores the error and catches the panic; the stack trace is never printed.
func attempt() (panicked bool) {
defer func() {
if r := recover(); r != nil {
panicked = true
}
}()
r, _ := find("")
_ = r.Name
return false
}
func main() {
dir := setupModule(map[string]string{"prog.go": patternIgnoredError})
fmt.Println("-- limiting measurement: coming out clean does not mean flawless --")
show("analysis result", "tool", fmt.Sprint("clean=", analysisClean(dir)))
show("panicked when run", "run", fmt.Sprint(attempt()))
}
-- limiting measurement: coming out clean does not mean flawless -- analysis result tool clean=true panicked when run run true
BD17. The analyzer’s answer (clean=true) and the run’s answer (panicked=true) say two
separate things about the same source, and both are correct. The analyzer says “I found none of
the patterns I search for” — this does not include a rule searching for an ignored error. The run
says “this source, run with this input, crashes.” These two answers do not contradict each other;
both are bounded by the tool’s scope. The clean=true line therefore cannot be read as “this
source has no flaw” — it can only be read as “none of the patterns the analyzer knows about are
present in this source.” This is the same distinction the course already established for coverage
ratio: a tool’s silence does not mean the thing it does not search for does not exist.
The practical counterpart of this limit is that an analyzer’s rule list is not fixed, but it is always finite. As new patterns are noticed, new rules can be added to the list — the two rules used specifically in this lesson (format mismatch, lock copy) were added this way too, entering the list only after a real fault was seen repeatedly in real programs. But no matter how large the list grows, there is always a pattern not yet added; this lesson chose a single example to make this gap concrete, but the chosen example itself is not what matters — what matters is that an analyzer’s clean answer always means “clean according to the rules known so far,” carrying no claim of absolute correctness.
Known Vulnerability Scan: A Modeled List
The patterns the analyzer searches for are bounded by the language’s own rules. A dependency’s known vulnerability is a separate kind of information — it comes not from the code’s own shape, but from which version the dependency was fixed in. A known vulnerability scan compares a dependency’s declared version against the version the fix arrived in. The measurement below never connects to a real vulnerability list at all; the comparison is modeled in the source with a small threshold table.
// measurement4.go — known vulnerability scan, a tool built with a modeled threshold list
package main
import "fmt"
// Version carries a three-part version number (same pattern as M09/K01 go-packages/02).
type Version struct{ Major, Minor, Patch int }
func (v Version) text() string { return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) }
func (v Version) lessThan(other Version) bool {
if v.Major != other.Major {
return v.Major < other.Major
}
if v.Minor != other.Minor {
return v.Minor < other.Minor
}
return v.Patch < other.Patch
}
// safeThreshold is a MODELED table holding the known lowest safe version for each module; not a
// real vulnerability database, only a small list this measurement itself sets up.
var safeThreshold = map[string]Version{
"libraryX": {1, 4, 0},
"libraryY": {2, 0, 0},
}
type Dependency struct {
Module string
Declared Version
}
// hasVulnerability checks whether the declared version falls below the modeled threshold.
func hasVulnerability(d Dependency) bool {
threshold, exists := safeThreshold[d.Module]
if !exists {
return false
}
return d.Declared.lessThan(threshold)
}
func main() {
dependencies := []Dependency{
{Module: "libraryX", Declared: Version{1, 2, 0}},
{Module: "libraryY", Declared: Version{2, 3, 0}},
}
fmt.Println("-- known vulnerability scan: a modeled list --")
vulnerableCount := 0
for _, d := range dependencies {
v := hasVulnerability(d)
if v {
vulnerableCount++
}
show(d.Module+" declared="+d.Declared.text(), "tool", fmt.Sprint("vulnerable=", v))
}
show("vulnerable count of two dependencies", "tool", fmt.Sprint(vulnerableCount))
}
-- known vulnerability scan: a modeled list -- libraryX declared=1.2.0 tool vulnerable=true libraryY declared=2.3.0 tool vulnerable=false vulnerable count of two dependencies tool 1
BD18. The declared version for libraryX (1.2.0) falls below the modeled threshold
(1.4.0) — vulnerable. The declared version for libraryY (2.3.0) exceeds its threshold
(2.0.0) — not vulnerable. This comparison carries the same limit as the previous two
measurements: the threshold table itself is a model this measurement built, it does not come
from a real record, and if a module is not in the table at all, hasVulnerability silently
returns false — a missing table means the scan finds nothing, exactly like the analyzer never
seeing a pattern it does not search for. The Go Fundamentals course’s minimal version
selection measurement established which version gets selected; this measurement asks, through a
separate, modeled comparison, whether that selected version falls below a known threshold.
Summary
- Static analysis works by searching for specific patterns without ever running the source; it reports the difference between two sources the compiler accepts only through an exit code.
- Of the three patterns the compiler rejects none of, two were caught by the analyzer; one — the ignored error — came out clean.
- Limiting measurement: the pattern that came out clean crashed when genuinely run; the analyzer’s silence means not flawlessness, only that the pattern searched for was not found.
- A known vulnerability scan compares a dependency’s declared version against a modeled threshold; if the table is missing an entry, the scan finds nothing at all.
- The measure used throughout this lesson stayed the same: a tool’s silence shows that what it searched for was not found, not that the thing itself does not exist.
Next Step
The analyzer measured in this lesson stayed within the language’s own boundaries — the patterns it searched for always belonged to Go’s own syntax and types. The course’s closing lesson crosses this boundary: when a Go program calls another language’s library, one more boundary opens, one the analyzer cannot see through either. The next question is which tool, with which number, the cost of crossing the language’s own boundary becomes visible through.
To keep your progress and take notes, Log in
My notes
Log in to take notes.