Skip to content

Latest commit

 

History

History
373 lines (297 loc) · 14.9 KB

File metadata and controls

373 lines (297 loc) · 14.9 KB

Plugin Authoring — sdkt-audit Rules

The sdkt-audit static analysis engine supports three modes of extension:

  1. Compiled-in Rules (Phase A) — Rules compiled directly into the binary.
  2. Native Shared Libraries (Phase B) — Dynamically loaded native plugins (.so / .dylib / .dll). Fast but un-sandboxed. Requires the plugins feature flag.
  3. WebAssembly Plugins (Phase C) — Dynamically loaded WASM plugins (.wasm). Sandboxed, cross-platform, safe. Requires the wasm-plugins feature flag.

Architecture

                ┌──────────────────────────────┐
   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 — optional ContractSpec for ABI cross-checks.
  • Finding — the unit a rule emits into the report.

Compiled-in Rules (Phase A)

  1. Author implements AuditRule and inspects &[FnScan] (per-function scan: bound locals, argument usage counts, require_auth/invoke_contract counts).
  2. Register the rule (once) into the global registry.
  3. Audit execution iterates the registry in registration order, skipping any id present in --disable. Ordering is stable, so output is deterministic.

Creating a rule

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);
}

Registering it

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

Dynamic plugins (Phase B)

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.

Scaffolding a plugin project

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_rule

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

C-ABI contract (stable)

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.

Authoring a dynamic plugin

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

Loading it

# 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.so

Security: 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.

WebAssembly Plugins (Phase C)

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

Building a WASM plugin

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

WASM ABI Exports

WASM plugins must export the following endpoints (use #[plugin_fn] from extism_pdk):

  1. sdkt_plugin_abi_version() -> i64 (Return 1)
  2. sdkt_plugin_id() -> String (Return e.g. "AUTH-005")
  3. sdkt_plugin_severity() -> i64 (Return 0=Critical, 1=Warning, 2=Info)
  4. sdkt_plugin_description() -> String (Return rule description)
  5. sdkt_plugin_check(input: String) -> String (Core evaluation)

JSON Schema

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.


Testing a rule

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.


Publishing & Installing (Local Plugin Ecosystem)

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

plugin.toml schema

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 = 0

CLI

sdkt 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 only

Audit Execution with Installed Plugins

When 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's rule_id.
  • Explicit selection (--rules <id|path>): Passing --rules activates 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-plugins bypasses the plugin store and runs only built-in rules.
  • ABI compatibility: If an installed plugin's abi_major does not match the host ABI version (SDKT_AUDIT_ABI_MAJOR), the plugin is skipped with a clear warning on stderr (Warning: skipping plugin '<id>': ABI mismatch...) and the audit completes without aborting.
  • Zero-plugins baseline: When no plugins are installed (or --no-plugins is passed), audit output is identical to built-in execution with no extra lines.

Install validation (applied before the plugin is committed to the store)

  • plugin.toml parses and abi_major equals the host ABI major (else rejected).
  • kind is wasm or native, and the artifact extension matches (.wasm for wasm; .so/.dylib/.dll for native).
  • 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 install prints a warning.

Store location (precedence, lowest → highest)

  1. <cwd>/.sdkt/plugins
  2. <config-dir>/sdkt/plugins (XDG/config per platform)
  3. $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.