The sdkt-audit static analysis engine supports three modes of extension:
- Compiled-in Rules (Phase A) — Rules compiled directly into the binary.
- Native Shared Libraries (Phase B) — Dynamically loaded native plugins (
.so/.dylib/.dll). Fast but un-sandboxed. Requires thepluginsfeature flag. - WebAssembly Plugins (Phase C) — Dynamically loaded WASM plugins (
.wasm). Sandboxed, cross-platform, safe. Requires thewasm-pluginsfeature flag.
┌──────────────────────────────┐
source.rs ──▶ │ scan_all_functions() │ syn AST → Vec<FnScan>
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ RuleRegistry (global) │
│ • built-in rules (AUTH/MOVE)│
│ • linked plugin rules │
└──────────────┬───────────────┘
▼
for each rule (registration order):
rule.check(&scans, &ctx, &mut report)
▼
AuditReport (JSON / text)
AuditRule— the trait every rule implements (id,severity,description,check).RuleRegistry— ordered, de-duplicated collection;register_rule,register_builtin_rules,registered_rules,run_all.register_rule!macro — ergonomic registration into the global registry.AuditContext— optionalContractSpecfor ABI cross-checks.Finding— the unit a rule emits into the report.
- Author implements
AuditRuleand inspects&[FnScan](per-function scan: bound locals, argument usage counts,require_auth/invoke_contractcounts). - Register the rule (once) into the global registry.
- Audit execution iterates the registry in registration order, skipping any
id present in
--disable. Ordering is stable, so output is deterministic.
use sdkt_audit::{AuditContext, AuditRule, AuditReport, Finding, FnScan, Severity};
use sdkt_audit::register_rule;
pub struct NoPanicRule;
impl AuditRule for NoPanicRule {
fn id(&self) -> &'static str { "CUSTOM-001" }
fn severity(&self) -> Severity { Severity::Warning }
fn description(&self) -> &'static str { "Flags functions named 'sdkt_example_trigger'" }
fn check(&self, scans: &[FnScan], _ctx: &AuditContext, report: &mut AuditReport) {
for s in scans {
if s.fn_name.contains("sdkt_example_trigger") {
report.add(Finding {
rule_id: self.id().to_string(),
severity: self.severity(),
message: format!("Matched `{}`", s.fn_name),
location: Some(s.fn_name.clone()),
});
}
}
}
}
// Register into the global registry (call once at startup).
pub fn register() {
register_rule!(NoPanicRule);
}For Phase A, link your rule crate into the binary that calls sdkt_audit:
# in the consumer's Cargo.toml
[dependencies]
sdkt-audit = { path = "../sdkt-audit" }
my-rule-crate = { path = "../my-rule-crate" }
[features]
plugins = ["my-rule-crate"]// in the consumer, gated behind the feature:
#[cfg(feature = "plugins")]
my_rule_crate::register();The reference implementation crates/sdkt-audit-example-rule demonstrates this
end-to-end (rule EXAMPLE-001).
A dynamic plugin is a native shared library exporting a fixed C-ABI. The host
(sdkt-audit, feature plugins) loads it with libloading, checks the ABI
major version, and wraps each plugin in a PluginRule implementing AuditRule.
Only #[repr(C)] flat data crosses the FFI — no Rust trait objects or Box
cross the boundary, and plugin-owned memory is freed inside the plugin.
sdkt plugin init generates a standalone, buildable rule crate so you do not
have to copy the reference implementation by hand:
sdkt plugin init my-rule # name becomes the rule id: MY-RULE-001
cd my-rule
cargo build --release --features plugins # produces the native cdylib for your platform
cp target/release/libmy_rule.so plugin/ # .so on Linux, .dylib on macOS, my_rule.dll on Windows
sdkt plugin pack plugin/ --output my_rule.sdktplugin
sdkt plugin install plugin/libmy_rule.so
sdkt audit contracts/token/src/lib.rs --rules my_ruleThe scaffold names the artifact and plugin.toml entry for the platform you
run sdkt plugin init on (libmy_rule.so on Linux, libmy_rule.dylib on
macOS, my_rule.dll on Windows) so the pack/install commands resolve to the
file cargo build actually produced.
The scaffold derives everything from the project name: crate/lib name
(my-rule → my_rule), rule id (MY-RULE-001), and the placeholder trigger
function (sdkt_my_rule_trigger). Generated layout:
my-rule/
Cargo.toml # standalone crate ([workspace] empty), sdkt-audit from crates.io
src/lib.rs # AuditRule impl with TODO-marked check()
src/plugin_abi.rs # native C-ABI exports (feature `plugins`)
src/plugin_abi_wasm.rs # WASM ABI exports (feature `wasm-plugins`)
plugin/plugin.toml # pre-staged native metadata for pack/install
plugin-wasm/plugin.toml # pre-staged WASM metadata for pack/install
tests/rule_test.rs # integration test proving the rule wiring
README.md # build -> pack -> install -> audit walkthrough
.gitignore
tests/rule_test.rs fires the placeholder rule on a trivially matching
function name and asserts silence on a normal one, so cargo test --features plugins proves the wiring works before you write any logic. Replace the TODO
in check() with your rule; keep the C-ABI files untouched.
Use --force to overwrite an existing scaffolded directory and --format json
for machine-readable output. See docs/reference/cli.md for the full command
reference.
| Symbol | Signature | Purpose |
|---|---|---|
sdkt_plugin_abi_version |
() -> u32 |
Packed `(major<<16) |
sdkt_plugin_id |
() -> *const c_char |
Rule id, e.g. EXAMPLE-001. |
sdkt_plugin_severity |
() -> u32 |
0=critical, 1=warning, 2=info. |
sdkt_plugin_description |
() -> *const c_char |
Human-readable description. |
sdkt_plugin_init |
(*const c_char) -> c_int |
Cache the contract source; 0=ok. |
sdkt_plugin_check |
(*mut SdktAuditReportC) -> c_int |
Run the rule, write findings into the buffer. |
sdkt_plugin_free |
() -> () |
Optional cleanup (host keeps the lib alive). |
SdktAuditReportC is a fixed-capacity (MAX_FINDINGS = 64) #[repr(C)] buffer
of SdktAuditFindingC { rule_id, severity, message, location } (all C strings).
The host copies the strings into owned Strings during the call.
The reference crate sdkt-audit-example-rule (feature plugins) builds a
loadable libsdkt_audit_example_rule.so that exports these symbols. Its
sdkt_plugin_check runs only its own ExampleRule (via the in-crate
AuditRule::check) — never the global registry, to avoid re-entrant recursion.
# my-rule-crate/Cargo.toml
[dependencies]
sdkt-audit = { version = "1.0.0", path = "../sdkt-audit" }
[features]
plugins = ["sdkt-audit/plugins"]
[lib]
name = "my_rule"
crate-type = ["rlib", "cdylib"] # cdylib → loadable artifact// my-rule-crate/src/plugin_abi.rs (only with feature `plugins`)
use sdkt_audit::plugin_abi::*;
static SOURCE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
#[no_mangle]
pub unsafe extern "C" fn sdkt_plugin_abi_version() -> u32 { abi_version_pack() }
#[no_mangle]
pub unsafe extern "C" fn sdkt_plugin_id() -> *const std::os::raw::c_char { /* "MYRULE-001\0" */ }
// ... severity / description / init / check / free as above# Build the plugin (cdylib):
cargo build -p my-rule-crate --features plugins
# Load at audit time (CLI must also be built with --features plugins):
sdkt audit contracts/token/src/lib.rs --rules target/debug/libmy_rule.soSecurity: dynamic plugins run in-process. Only load plugins you trust or built yourself. A plugin whose ABI major version differs from the host is rejected with a clear error.
To distribute a rule across platforms without native compilation overhead on the host, build it as a WebAssembly (.wasm) module.
WASM plugins run in a restricted Extism sandbox:
- No filesystem access (explicitly denied by the host).
- No network access (explicitly denied by the host).
- No environment variable leaks (except minimal WASI stubs like
random_get). - Memory safety at the FFI boundary (no raw pointer ownership ambiguities).
Use the extism-pdk to export the required functions.
rustup target add wasm32-wasip1
cargo build --target wasm32-wasip1 --release
# Produces target/wasm32-wasip1/release/your_rule.wasmWASM plugins must export the following endpoints (use #[plugin_fn] from extism_pdk):
sdkt_plugin_abi_version() -> i64(Return1)sdkt_plugin_id() -> String(Return e.g. "AUTH-005")sdkt_plugin_severity() -> i64(Return0=Critical,1=Warning,2=Info)sdkt_plugin_description() -> String(Return rule description)sdkt_plugin_check(input: String) -> String(Core evaluation)
Unlike the native C-ABI which passes raw structures, the WASM ABI exchanges data via JSON over Extism memory.
Input (sdkt_plugin_check):
{
"scans": [
{
"fn_name": "transfer",
"require_auth": 1,
"invoke_contract": 0,
"bound": [],
"usage": {}
}
]
}Output (sdkt_plugin_check):
Return a JSON array of findings:
[
{
"rule_id": "AUTH-005",
"severity": 1,
"message": "Missing auth check before transfer",
"location": "transfer"
}
]Note: Findings returned are capped at 64 by the host.
Rules are pure functions over &[FnScan]. Unit-test check directly:
#[test]
fn fires_on_trigger() {
let scans = vec![FnScan {
fn_name: "sdkt_example_trigger_x".into(), ..Default::default()
}];
let ctx = AuditContext { spec: None };
let mut report = AuditReport::default();
ExampleRule.check(&scans, &ctx, &mut report);
assert_eq!(report.summary.total, 1);
}Also add a CLI integration test (as in crates/sdkt-cli/tests/audit_integration_test.rs)
that builds with your feature and asserts the rule id appears in output.
The plugin store is local and offline-first. No hosted registry, no remote sources, no crates.io plugin publishing. You package a plugin as:
my-plugin/
plugin.toml # metadata (see schema below)
my_rule.wasm # or .so / .dylib / .dll artifact
id = "author/name" # stable, namespaced plugin id
name = "My Rule"
version = "1.0.0" # semver
author = "author"
description = "What it checks."
kind = "wasm" # "wasm" | "native"
artifact = "my_rule.wasm" # filename inside the plugin directory
abi_major = 1 # must equal host SDKT_AUDIT_ABI_MAJOR
abi_minor = 0sdkt plugin init ./path/to/my-rule # scaffold a new rule project
sdkt plugin list # installed plugins
sdkt plugin show <id> # metadata
sdkt plugin install ./my-plugin/my_rule.wasm # copies + validates (local path)
sdkt plugin remove <id> # idempotent
sdkt plugin update <id> ./my-plugin/my_rule.wasm # local-only update
sdkt audit contract.rs # runs built-in rules + all installed plugins
sdkt audit contract.rs --rules <id> # explicit subset selection / ad-hoc artifact
sdkt audit contract.rs --no-plugins # skip installed plugins, run built-ins onlyWhen running sdkt audit <file>, the engine automatically discovers and loads installed plugins from the plugin store:
- Automatic loading (default): All installed plugins in the local store are loaded into the rule registry and executed alongside built-in rules without requiring
--rules. - Rules summary reporting: When plugin rules are loaded, the audit report includes a rules summary line indicating the breakdown (
Rules loaded: 5 built-in, 1 plugin). Individual findings continue to carry the plugin'srule_id. - Explicit selection (
--rules <id|path>): Passing--rulesactivates explicit subset selection. Only the specified rule IDs (resolved via the store) or raw filesystem paths are loaded; other installed plugins are not loaded. - Opt-out (
--no-plugins): Passing--no-pluginsbypasses the plugin store and runs only built-in rules. - ABI compatibility: If an installed plugin's
abi_majordoes not match the host ABI version (SDKT_AUDIT_ABI_MAJOR), the plugin is skipped with a clear warning onstderr(Warning: skipping plugin '<id>': ABI mismatch...) and the audit completes without aborting. - Zero-plugins baseline: When no plugins are installed (or
--no-pluginsis passed), audit output is identical to built-in execution with no extra lines.
plugin.tomlparses andabi_majorequals the host ABI major (else rejected).kindiswasmornative, and the artifact extension matches (.wasmforwasm;.so/.dylib/.dllfornative).- A dry-run load via the existing loader runs when the corresponding feature
(
wasm-plugins/plugins) is compiled in. - Trust model: provenance-by-path. You installed the artifact from a local
file you obtained out-of-band; no third-party trust is assumed. Signature /
checksum verification is explicitly not part of the local store design. Native plugins run
unsandboxed (unchanged Phase B behavior) —
sdkt plugin installprints a warning.
<cwd>/.sdkt/plugins<config-dir>/sdkt/plugins(XDG/config per platform)$SDKT_PLUGIN_DIR(environment override)
The existing RuleRegistry, native loader, and WASM (Extism) sandbox are reused
verbatim — the local store only adds the management layer. The remote/marketplace layer
(hosted index, signing, .sdktplugin bundles) remains unscheduled backlog.