Help us shape the future of Go! Take the 2026 Go Developer Survey

semantic_firewall

module
v5.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 13, 2026 License: MIT

README

Semantic Firewall

Go Reference Marketplace Semantic Check

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 diff compares two files function by function. It matches renamed functions by topology and scores each change by the capabilities it newly reaches.
  • sfw audit sends a diff's high-risk changes and the commit message to an LLM, which decides whether the message describes the change.
  • sfw index and sfw scan match 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

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 emitting UnhandledInstr<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 a scan_results field. 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_functions and high_risk_changes are 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.go file: loaded with the package's tests.
  • _GOOS/_GOARCH name suffix or //go:build line: go/build matches the file against GOOS/GOARCH pairs, the host's first and then a fixed list of 27 (knownPlatforms in pkg/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.* and goexperiment.* 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:

  1. Functions with the same short name are paired. No similarity threshold applies. Type parameters are compared by position, so (*List[T]).Push pairs with (*List[E]).Push.
  2. 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:
    1. equal keep-all-literals fingerprints;
    2. equal default fingerprints;
    3. TopologySimilarity of at least 0.6, best first;
    4. equal names. A pair whose names differ is reported as F$1 → F$2 and keeps its own status; it is not renamed.
  3. The remaining functions are paired only if their fuzzy hashes are identical and their TopologySimilarity is 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.Equal
  • Verify* in crypto/ed25519, crypto/ecdsa and crypto/rsa
  • x509.Certificate / CertificateRequest Verify*, CheckSignature, CheckSignatureFrom
  • (*tls.Conn).VerifyHostname
  • bcrypt.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.Dial scores 20).
  • reflect.Value.Call and CallSlice, which are scored as the reflection capability.
  • 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 as Branches-N and changed literals in removed_ops, and scores 0.
  • Filesystem calls outside the list: os.Mkdir, os.MkdirAll, methods on *os.File and ioutil.WriteFile are not in the filesystem class.
  • Package-level payloads: a package-level var initializer lives in the package init function, so its literal is attributed to init, not to the function that uses it.
  • Parse failures: in sfw diff, a file that fails to type-check is a fatal error. In sfw check, it is a per-file error entry.

sfw audit

export ANTHROPIC_API_KEY=...
sfw audit old.go new.go "minor refactor of logging"
  1. Runs sfw diff on the two files.

  2. Collects every function with risk_score >= 10, sending its name, score, topology_delta and at most 10 added_ops.

  3. If there are none, returns MATCH without calling an LLM, so no key is needed.

  4. 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.

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:

  1. 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.
  2. Rejects a candidate whose entropy distance exceeds its tolerance, using the entropy packed into the index value, before loading the signature.
  3. Loads the sig: record from the same snapshot and scores it with detection.MatchSignature.
  4. 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_scanned
  • alerts
  • summary (critical, high, medium, low, total_alerts)
  • dependencies_scanned and scanned_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:

  1. --db
  2. $SFW_DB_PATH, used without checking that it exists
  3. ./signatures.db, ~/.sfw/signatures.db, /usr/local/share/sfw/signatures.db, /var/lib/sfw/signatures.db, the first that exists
  4. ./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, AddSignatures and DeleteSignature each commit a single batch with pebble.Sync.
  • RebuildIndexes and MigrateFromJSON commit 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, /sys and /dev.
  • macOS: the same list plus /private/etc and /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 to b0.
  • 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 by b1, type int".
  • 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.CanonicalIR holds the text.
Scalar evolution (pkg/analysis/loop)
  • Loops: DetectLoops finds 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-loop SCEVCache, 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..pN and free variables fv0..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..bN in preorder DFS, true successor first. Unreachable blocks are appended in index order.
  • Commutative operands are sorted.
  • Hoisting: only len, cap, complex, real, imag, min and max. Their arguments must be loop-invariant, len/cap must 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.
    • DefaultLiteralPolicy masks every string (as <string_literal>), every integer outside [-16, 16] and every float. It keeps small integers and booleans.
    • KeepAllLiteralsPolicy keeps everything.
    • Loop start values, steps and trip counts bypass the policy, so i < 100 and i < 200 fingerprint 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.

  1. 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.
  2. Entry seed: take a longest common subsequence over at most 100 instructions of the two entry blocks.
  3. 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.
  4. Terminators: the last instruction of every block is matched by operand equivalence.
  5. 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 If or Jump where an old successor is paired with a block other than the new successor in the same position.
  6. Result: unmapped instructions are added or removed. 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 reflect type.
  • 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 BinOp operator, 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:

  1. Collect every string constant operand of the function's own SSA instructions. Each use counts, so duplicates count twice.
  2. 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.
  3. 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.

  1. Take the weighted mean over the applicable signals.
  2. Raise it to the strongest dispositive value, if that is higher.
  3. If a window's entropy is ≥ 7.2, raise it to at least 0.75.
  4. If no dispositive signal fired and fewer than two corroborating signals fired, cap it at 0.49.
  5. 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. runsc runs with --ignore-cgroups, so the limits in the spec are not applied as cgroups.
  • No seccomp profile. The spec sets none.
  • Fail-open. If runsc is not on PATH, sfw prints ::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 sfw v5.2.0 with go install, or uses an sfw already on PATH;
  • installs runsc release 20260126.0 and 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:

  • result
  • alert-count
  • deps-scanned
  • semantic-match-pct, the mean over changed files
  • audit-verdict, PASS or FAIL

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 --
llm
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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL