axiom-scan
v3.9.0
Published
Find the invariants your codebase assumes but never tests
Maintainers
Readme
axiom-scan
Find the invariants your codebase assumes but never tests.
85% line coverage doesn't mean your code is safe. It means 85% of your lines ran during tests — not that the assumptions those lines make have ever been challenged.
Every non-trivial codebase is full of implicit invariants:
user.subscriptionis nevernullbeforegetBillingPlan()is calledinitDB()always runs beforequery()mu.Lock()is always paired with a deferredmu.Unlock()- errors from network calls are always surfaced, never silently dropped
- SQL queries are never built with string concatenation
- file paths are never constructed from user input without validation
These assumptions are true by convention, not by contract. Nobody wrote them down. Nobody tests them. When they break — due to a refactor, a new code path, a missing guard, or a security gap that's been there since day one — production breaks and the test suite stays green.
AXIOM reads your code statically, infers what it assumes to be true, diffs that against your test suite, and hands you a ranked list of the bets you're making on code that's never been verified.
Install
npm install -g axiom-scanOr run without installing:
npx axiom-scan scan ./srcDemo
$ axiom scan ./src
AXIOM v3.9.0 — static invariant analysis
target: /your/project
▸ TypeScript/JS 142 source files
▸ Go 91 source files
▸ Python 38 source files
271 source files · running inference...
invariants found:
security 5
null 47
concurrency 12
resource 9
swallowing 8
━━━ UNTESTED INVARIANTS (ranked by blast radius) ━━━
● CRITICAL src/api/files.go:34 [high confidence]
`os.Open()` with a string-concatenated path — susceptible to path traversal if a dynamic segment contains `../` (CWE-22)
├─ relied on by 3 call sites
├─ nearest public entry: GET /download (1 hop)
├─ tests covering null path: 0
└─ fix → Use `filepath.Join` + `filepath.Clean` and verify the resolved path starts with the base directory
joined := filepath.Join(baseDir, segment)
if !strings.HasPrefix(filepath.Clean(joined), filepath.Clean(baseDir)) {
return errors.New("path traversal attempt")
}
● CRITICAL src/api/payments.ts:88 [high confidence]
user.address assumed non-null
├─ relied on by 14 call sites
├─ nearest public entry: POST /checkout (2 hops)
└─ tests covering null path: 0
◐ HIGH src/workers/job.go:134 [high confidence]
`mu.Lock()` is not deferred — `Unlock` will not run on early return or panic
├─ relied on by 3 call sites
├─ nearest caller: processJob
└─ tests covering null path: 0
─────────────────────────────────────────────────────
81 invariants · scanned 312 files in 2.4s
5 critical · 18 critical · 25 high · 38 medium
security ×5 null ×47 concurrency ×12 resource ×9 swallowing ×8
axiom explain <file:line> → full remediation cardWhat it detects
| Type | Languages | What it catches |
|------|-----------|-----------------|
| security | Java, Go, Python, Ruby | Hardcoded credentials, SQL injection, command injection, path traversal, weak crypto, insecure deserialization — with CWE references and fix snippets |
| null | All 8 | Property access on values that could be null/nil/undefined with no guard |
| ordering | All 8 | Fields or variables read before they are guaranteed to be initialized |
| shape | All 8 | UUID/type format assumed at point of use without validation |
| swallowing | All 8 | Errors caught and silently discarded — empty catch, log-only catch |
| concurrency | Go, Java, Python, C#, Ruby, Rust | Mutex leaks, unjoined threads, fire-and-forget tasks |
| resource | All 8 | File handles, DB connections, sockets opened without guaranteed close — including inter-procedural: wrapper functions that return handles are tracked across call sites |
Security invariants (v3)
Security findings are ranked first — a security invariant with zero call sites still scores HIGH minimum. Every finding includes a CWE reference and a ready-to-paste fix snippet.
Hardcoded credentials (CWE-798)
Flags password, api_key, secret, access_token, private_key and similar names assigned to string literals. Placeholder strings ("change-me", "your-api-key", "xxx") are suppressed.
// Flagged
String password = "hunter2";
// Fix
String password = System.getenv("DB_PASSWORD");Supported in: Java, Go, Python, Ruby
SQL injection (CWE-89)
Flags query methods called with a string built via concatenation or interpolation instead of parameterized placeholders.
# Flagged
cursor.execute("SELECT * FROM users WHERE name = '" + name + "'")
# Fix
cursor.execute("SELECT * FROM users WHERE name = %s", (name,))Supported in: Java (createQuery, nativeQuery, prepareStatement, executeQuery, executeUpdate), Go (Query, QueryRow, Exec, Prepare, Raw, Where), Python (execute, executemany, executescript, raw), Ruby (where, having, find_by_sql, execute, order, select)
Command injection (CWE-78)
Flags shell execution where the command or its arguments are dynamically constructed.
// Flagged: shell -c with dynamic script
exec.Command("sh", "-c", userInput)
// Fix
exec.Command("/usr/bin/myexe", "--flag", sanitizedInput)# Flagged: backtick with interpolation
`rm -rf #{path}`
# Fix
require 'open3'
out, status = Open3.capture2("/usr/bin/myexe", "--flag", sanitized_input)Supported in: Java (Runtime.exec() with non-literal), Go (exec.Command with dynamic executable or shell -c pattern), Python (subprocess.run/call/Popen with shell=True + dynamic arg; os.system/os.popen with non-literal), Ruby (backtick with interpolation; system/exec/spawn with dynamic arg)
Path traversal (CWE-22)
Flags file I/O calls where the path is constructed from user-controlled input, allowing ../ sequences to escape the intended base directory.
// Flagged
File f = new File(baseDir, userFilename);
Path p = Paths.get("/uploads", userPath);
// Fix
Path resolved = Paths.get(baseDir).resolve(userFilename).normalize();
if (!resolved.startsWith(Paths.get(baseDir).normalize())) {
throw new SecurityException("path traversal attempt");
}# Flagged
with open(f"{base_dir}/{filename}") as f:
return f.read()
# Fix
import os
resolved = os.path.realpath(os.path.join(base_dir, filename))
if not resolved.startswith(os.path.realpath(base_dir) + os.sep):
raise ValueError("path traversal attempt")Supported in: Java (new File(base, dynamic), Paths.get() with non-literal segment), Go (os.Open/os.Create/os.ReadFile/os.WriteFile with + concat path), Python (open() with f-string or concat path), Ruby (File.open/File.read/File.write with interpolated path; send_file with dynamic path)
Weak cryptographic algorithm (CWE-327)
Flags use of broken hash functions and ciphers: MD5, SHA-1, DES, RC4.
// Flagged
MessageDigest md = MessageDigest.getInstance("MD5");
// Fix
MessageDigest md = MessageDigest.getInstance("SHA-256");
// For passwords: use BCryptSupported in: Java (MessageDigest.getInstance("MD5"/"SHA-1")), Go (md5.New, sha1.New, md5.Sum, sha1.Sum, des.NewCipher, rc4.NewCipher)
Insecure deserialization (CWE-502)
Flags deserialization of arbitrary objects from untrusted data.
# Flagged
data = pickle.loads(raw_bytes)
# Fix
import json
data = json.loads(raw) # safe for untrusted input# Flagged
config = yaml.load(stream)
# Fix
config = yaml.safe_load(stream)
# or: yaml.load(stream, Loader=yaml.SafeLoader)Supported in: Python (pickle.loads, pickle.load, yaml.load without SafeLoader)
axiom explain — full remediation cards
axiom explain <file:line> rescans the project, locates the invariant at that position, and renders a full remediation card:
$ axiom explain src/api/files.go:34
══════════════════════════════════════════════════════════════════
CRITICAL src/api/files.go:34
`os.Open()` with a string-concatenated path — susceptible to path
traversal if a dynamic segment contains `../` (CWE-22)
CWE-22 — Path Traversal
https://cwe.mitre.org/data/definitions/22.html
function: serveFile
type: security
score: 13
confidence: high
callers: 3
entry: GET /download (1 hop)
coverage: 0 tests
── Fix ──────────────────────────────────────────────────────────
Use `filepath.Join` + `filepath.Clean` and verify the resolved
path starts with the base directory
joined := filepath.Join(baseDir, segment)
if !strings.HasPrefix(filepath.Clean(joined), filepath.Clean(baseDir)) {
return errors.New("path traversal attempt")
}
── Call sites ───────────────────────────────────────────────────
src/middleware/download.go:89
src/handlers/static.go:112
src/admin/export.go:201
══════════════════════════════════════════════════════════════════AXIOM auto-detects the project root by walking up from the target file looking for .git, package.json, go.mod, Cargo.toml, pyproject.toml, or Gemfile. Pass --dir to override.
Fix suggestions in terminal output
Every finding that has a suggested fix shows it inline in the terminal report. The fix → line appears in green:
● CRITICAL src/db/query.go:77
SQL query built with string concatenation in `Query()` — susceptible to SQL injection
├─ relied on by 5 call sites
├─ nearest public entry: GET /search (2 hops)
├─ tests covering null path: 0
└─ fix → Use `?` or `$N` placeholders and pass values as separate arguments
rows, err := db.Query("SELECT ... WHERE id = ?", id)Fix snippets are also included in JSON output under suggested_fix.snippet.
Concurrency patterns
Go — mu.Lock() without defer mu.Unlock(); wg.Add() without wg.Wait()
Java — lock.lock() without finally { unlock() }; executor.submit() without shutdown()
Python — lock.acquire() without finally: lock.release() (prefer with lock:); Thread.start() without Thread.join()
C# — Monitor.Enter() without finally { Monitor.Exit() }; SemaphoreSlim.Wait() without finally { Release() }; Task.Run() result discarded (fire-and-forget)
Ruby — Thread.new without .join; mutex.lock without .unlock (prefer mutex.synchronize {})
Rust — let _ = mutex.lock() (guard dropped immediately, lock released at end of statement); thread::spawn handle not joined
Inter-procedural resource tracking
Resource analysis crosses function boundaries. If a function wraps os.Open and returns the handle, every call site that doesn't close the returned value is flagged:
// opener.go
func openConfig(path string) *os.File {
f, _ := os.Open(path)
return f // ← marked as resource-returning wrapper
}
// handler.go
func handleRequest(path string) {
f := openConfig(path) // ← FLAGGED: `defer f.Close()` missing
parseConfig(f)
}Wrapper functions are not flagged themselves. Two-hop chains are resolved (openRaw → openWrapped → caller). Works across files in the same scan.
Languages
| Language | Parser |
|----------|--------|
| TypeScript / JavaScript | @typescript-eslint/typescript-estree |
| Python | tree-sitter (WASM) |
| Go | tree-sitter (WASM) |
| Ruby | tree-sitter (WASM) |
| Java | tree-sitter (WASM) |
| Rust | tree-sitter (WASM) |
| C# | tree-sitter (WASM) |
| PHP | tree-sitter (WASM) |
Usage
# Scan current directory
axiom scan
# Scan a specific path
axiom scan ./src
# JSON output for CI pipelines
axiom scan --json > axiom-report.json
# SARIF output for GitHub code scanning
axiom scan --sarif > results.sarif
# Only show critical and high severity
axiom scan --min-severity high
# Exit with code 1 if any critical finding exists
axiom scan --fail-on critical
# Only scan files changed since a branch or commit
axiom scan --since main
axiom scan --since HEAD~5
# Watch mode
axiom scan --watch
# Explain a specific invariant with full remediation card
axiom explain src/billing.ts:47
# Explain against a specific project directory
axiom explain src/billing.ts:47 --dir /path/to/projectInline suppressions
Add axiom-ignore on the flagged line or the line above:
// axiom-ignore
const plan = user.subscription.plan;Works in all supported languages using that language's comment syntax.
LSP server
axiom-lsp is included. It surfaces findings as diagnostics in VS Code, Neovim, Emacs, and any LSP-compatible editor with no extension required. Validates on file open and save.
Neovim (nvim-lspconfig):
require('lspconfig').axiom.setup({
cmd = { 'axiom-lsp', '--stdio' },
filetypes = { 'typescript', 'javascript', 'python', 'go', 'ruby', 'java', 'rust', 'cs', 'php' },
})Ranking
Each invariant is scored by blast radius:
score = (call_sites × 2)
+ entrypoint_proximity // closer to HTTP handler = higher score
+ type_weight // security=10, concurrency=6, null=3, resource=3, ordering=2, swallowing=2, shape=1
- test_coverage_discountSecurity invariants start at type_weight=10, so even a finding with zero call sites and no entry point scores 11 → HIGH minimum.
Severity buckets: CRITICAL (>20) · HIGH (10–20) · MEDIUM (5–10) · LOW (<5)
Configuration
Create .axiomrc.json in your project root:
{
"ignore": ["**/*.generated.ts", "src/migrations/**"],
"minScore": 5,
"maxResults": 100,
"patterns": {
"nullMethods": ["findBySlug", "fetchLatest"],
"nullFunctions": ["my_custom_fetch"],
"lifecycleMethods": ["onBoot", "warmUp"],
"assertionFunctions": ["invariant", "ensure", "checkNotNull"]
}
}| Field | Description |
|-------|-------------|
| ignore | Glob patterns to exclude |
| minScore | Minimum blast-radius score to include |
| maxResults | Cap total results (highest score first) |
| patterns.nullMethods | Extra method names that return null/nil/undefined |
| patterns.nullFunctions | Extra function names that return null |
| patterns.lifecycleMethods | Extra methods treated as secondary constructors for ordering checks |
| patterns.assertionFunctions | Functions that guarantee non-null (invariant, assert, etc.) |
CI / GitHub Actions
- name: Run AXIOM
run: npx axiom-scan scan --sarif > axiom.sarif
- name: Upload to GitHub Code Scanning
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: axiom.sarif
# Gate on critical findings
- name: Fail on critical
run: npx axiom-scan scan --fail-on criticalDiff mode for PRs — only scan changed files:
- name: AXIOM diff scan
run: npx axiom-scan scan --since origin/main --fail-on criticalWhy not...
| Tool | Gap | |------|-----| | Istanbul / V8 coverage | Measures line execution, not behavioral assumptions | | TypeScript strict mode | Catches declared-type nulls only, not behavioral invariants | | ESLint / RuboCop / golangci-lint | Rule-based — you write the rules; AXIOM infers them | | Semgrep / CodeQL | Pattern matching — you write the patterns; AXIOM infers them | | Mutation testing | Slow, requires tests to exist, doesn't find untested assumptions | | Bandit / Brakeman | Security-only, single-language; AXIOM covers behavioral invariants across 8 languages in the same pass |
License
MIT
