README
¶
Semantic Firewall
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.sfw auditsends a diff's high-risk changes and the commit message to an LLM, which 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.
Contents
- Install
- Commands
- sfw check
- sfw diff
- Risk scoring
- Detection scope
- sfw audit
- sfw index and sfw scan
- Signature store
- Fingerprint pipeline
- Semantic Zipper
- Function topology
- Entropy and obfuscation
- Sandbox
- GitHub Action
- Library usage
Install
go install github.com/BlackVectorOps/semantic_firewall/v5/cmd/sfw@v5.2.0
go.mod declares go 1.26.3. sfw version prints the build:
Semantic Firewall CLI
Build: v5.2.0 (Pebble/Gob+PackedIdx)
Commands
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) |
<old.go> <new.go> |
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 |
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.
sfw check
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.
sfw diff
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
| 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. |
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.
Files
The diff reports the functions declared in the given file, closures 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 file that the host build excludes is loaded under a configuration that includes it:
_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,go1.*andgoexperiment.*do not count as custom tags. The first match is loaded, with cgo disabled.- A file that no combination matches is an error.
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.
Function matching
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.
- 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" │
└────────────────────────────────────────────────────────────────────────────┘
Risk scoring
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 present in the old version is gone (RemovedCheck) |
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.
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 and branches are labelled (Calls-1, Loops-1, Branches-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~.
Capability classes
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:
| Class | Members |
|---|---|
exec |
every function in os/exec; 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) |
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 and Transport 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, reflect.Value.CallSlice |
filesystem |
os.WriteFile, Create, OpenFile, Remove, RemoveAll, Rename, Chmod, Chown, Lchown, Truncate, Symlink and Link, as package-level functions or os.Root methods; os.CopyFS |
Verification calls (topology.IsVerificationCall), whose removal is a RemovedCheck:
crypto/subtle.ConstantTime*,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.
Detection scope
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.CallandCallSlice, which are scored as thereflectioncapability.- Removal of a verification call, or deletion of a function that made one.
- New goroutines, entropy class increases and obfuscation class increases.
Not scored (no concrete target):
- Interface method calls (
invoke:). - Calls through function values (
dynamic:).
These appear in CallSignatures but not in CallTargets, so they never reach the capability classifier.
Not scored (other limits):
- Plain-conditional guards: removing one (
if user != admin, a bounds check, a TLS config field) shows up asBranches-Nand changed literals inremoved_ops, and scores 0. - Filesystem calls outside the list:
os.Mkdir,os.MkdirAll, methods on*os.Fileandioutil.WriteFileare not in thefilesystemclass. - 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 audit
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, sending its name, score,topology_deltaand at most 10added_ops. -
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
| 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 and sfw scan
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/app
index
sfw 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.
scan
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.
Dependency scanning
--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). dependencies_scanned counts dependency functions, which are also included in total_functions_scanned.
Signature store
A --db path ending in .json selects the JSON backend for check --scan, index, scan and stats. Any other path selects PebbleDB.
Database path resolution
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.
PebbleDB layout
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 |
"3" |
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 3 (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.
stats and migrate
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.
API
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. |
Fingerprint pipeline
┌────────────────────────────────────────────────────────────────┐
│ golang.org/x/tools/go/packages.Load, then go/ssa build │
│ loader env: CGO_ENABLED=0 GOPROXY=off GOTOOLCHAIN=local │
│ GOFLAGS=-mod=readonly GOWORK=off GONOSUMDB=* │
└────────────────────────────────┬───────────────────────────────┘
│ *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 │
└────────────────────────────────────────────────────────────────┘
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.
Example
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.
Scalar evolution (pkg/analysis/loop)
- 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.
Canonical IR rules (pkg/analysis/ir)
- 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.
Semantic Zipper
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.
Function topology
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-call targets |
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 signatures
| Call | CallSignatures key |
Example |
|---|---|---|
| Static function | <package name>.<func> |
net.Dial, exec.Command |
| Concrete method | <package name>.<method> (receiver dropped) |
bytes.WriteString, http.Do |
| 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.
Fuzzy hash
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.
Similarity
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.
Entropy and obfuscation
Entropy
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.
Obfuscation profile
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.
Sandbox
check, 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, and each input's go.mod/.git root |
| 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.
GitHub Action
on: [pull_request]
permissions:
contents: read
jobs:
sfw:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # diff and audit read HEAD~1
- id: sfw
uses: BlackVectorOps/semantic_firewall@v5.2.0
with:
mode: audit
api-key: ${{ secrets.ANTHROPIC_API_KEY }}
The Action does the following before running sfw:
- installs
sfwv5.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. In diff mode the job fails if sfw diff fails on any changed file.
| Input | Default | Purpose |
|---|---|---|
path |
. |
target for check and scan |
mode |
check |
check, diff, scan or audit |
go-version |
1.26 |
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 |
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,PASSorFAIL
Library usage
Packages live under pkg/; pkg/api is the entry point the CLI itself calls. Each example below is a complete main package.
Semantic diff
package main
import (
"fmt"
"log"
"github.com/BlackVectorOps/semantic_firewall/v5/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)
}
}
Fingerprints and canonical IR
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/v5/pkg/analysis/ir"
"github.com/BlackVectorOps/semantic_firewall/v5/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)
}
}
Topology and signature scan
package main
import (
"fmt"
"log"
"os"
"github.com/BlackVectorOps/semantic_firewall/v5/pkg/analysis/ir"
"github.com/BlackVectorOps/semantic_firewall/v5/pkg/analysis/topology"
"github.com/BlackVectorOps/semantic_firewall/v5/pkg/diff"
"github.com/BlackVectorOps/semantic_firewall/v5/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)
}
}
}
License
MIT. See LICENSE.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
sfw
command
|
|
|
internal
|
|
|
cli
-- internal/cli/audit.go --
|
-- internal/cli/audit.go -- |
|
sandbox
-- internal/sandbox/manager.go --
|
-- internal/sandbox/manager.go -- |
|
pkg
|
|
|
api
Package api is the public, library-shaped surface of Semantic Firewall.
|
Package api is the public, library-shaped surface of Semantic Firewall. |
|
storage/pathguard
Package pathguard refuses database writes under operating-system directories.
|
Package pathguard refuses database writes under operating-system directories. |