Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 34 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,15 @@ Same engine under all of them (it cross-compiles to WASM for the browser path),
```bash
# 1. install
cargo install sanctifier-cli
# (skip Z3 with: cargo install sanctifier-cli --no-default-features)

# 2. scan
sanctifier analyze ./contracts/my-token
sanctifier analyze ./contracts

# 3. ship a badge for your README
# 3. integrate into CI — exit 1 on high/critical findings
sanctifier analyze ./contracts --exit-code --format sarif > sanctifier.sarif

# 4. ship a security badge for your README
sanctifier analyze . --format json > report.json
sanctifier badge --report report.json --svg-output sanctifier.svg
```
Expand Down Expand Up @@ -188,21 +192,32 @@ Skip Z3 entirely with `cargo install sanctifier-cli --no-default-features` — e
## CLI reference

```bash
sanctifier analyze [PATH] [--format text|json] [--limit BYTES] [--webhook-url URL]...
sanctifier diff [PATH] --baseline <report.json>
# Full analysis (most flags shown; all have defaults)
sanctifier analyze [PATH]
--format text|json|sarif|ndjson # output format (default: text)
--limit BYTES # ledger entry size cap (default: 64000)
--timeout SECS # per-file timeout, 0 = none (default: 30)
--exit-code # exit 1 when findings meet threshold
--min-severity critical|high|medium|low # threshold for --exit-code (default: high)
--profile strict|lenient|ci|audit # preset overrides --exit-code/--min-severity
--webhook-url URL # POST results here on completion (repeatable)
--no-cache # skip incremental analysis cache

# Other commands
sanctifier diff [PATH] --baseline <report.json> # new/resolved findings vs baseline
sanctifier watch [PATH] # re-runs on file change
sanctifier workspace [PATH] # cargo-workspaceaware scan
sanctifier workspace [PATH] # cargo-workspace-aware scan
sanctifier callgraph [PATH] --output callgraph.dot
sanctifier badge --report report.json --svg-output sanctifier.svg
sanctifier fix [PATH] --rule S003 # apply patcher fixes
sanctifier verify [PATH] # Z3-only invariant pass
sanctifier deploy ... # ship the runtime guard
sanctifier deploy [PATH] --network testnet|futurenet|mainnet
sanctifier doctor # environment diagnostics
sanctifier init # generate .sanctify.toml
sanctifier init [PATH] # scaffold project + .sanctify.toml
sanctifier update # self-update with checksum check
```

Every subcommand respects `--format json` for machine consumption.
Every subcommand accepts `--format json` for machine consumption. Use `--format ndjson` with `analyze` for streaming line-delimited output (one JSON object per finding, final `{"event":"done"}`).

---

Expand All @@ -212,14 +227,20 @@ Every subcommand respects `--format json` for machine consumption.

```jsonc
{
"metadata": { "version": "0.1.0", "format": "sanctifier-ci-v1", "timestamp": "…" },
"summary": { "critical": 0, "high": 0, "medium": 2, "low": 0 },
"findings": { "auth_gaps": [...], "arithmetic_issues": [...], "storage_collisions": [...] },
"vuln_db_matches": [{ "id": "SOL-2024-002", "severity": "CRITICAL", "matched_at": "…" }],
"schema_version": "1.0.0"
"metadata": { "version": "0.1.0", "format": "sanctifier-ci-v1", "timestamp": "…" },
"summary": { "critical": 0, "high": 1, "medium": 2, "low": 0 },
"error_codes": ["S001", "S003"],
"auth_gaps": [{ "location": "src/lib.rs:42", "message": "missing require_auth" }],
"arithmetic_issues": [{ "location": "src/lib.rs:30", "operator": "-" }],
"rule_violations": [{ "rule_name": "require_auth_for_args", "severity": "Error",
"location": "src/lib.rs:set_admin", "message": "…" }],
"vuln_db_matches": [{ "id": "SOL-2024-002", "severity": "CRITICAL", "matched_at": "src/lib.rs:55" }],
"schema_version": "1.0.0"
}
```

`--format sarif` produces a SARIF 2.1.0 document compatible with GitHub code-scanning. `--format ndjson` streams one object per finding so large scans can be processed incrementally.

SARIF 2.1.0 output is canonical for GitHub code-scanning and any SAST aggregator.

---
Expand Down
35 changes: 19 additions & 16 deletions tooling/sanctifier-cli/src/commands/deploy.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
#![allow(dead_code)]

use crate::commands::color as c;
use crate::errors::SanctifierError;
use clap::Args;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

const VALID_NETWORKS: &[&str] = &["testnet", "futurenet", "mainnet"];

#[derive(Args, Debug)]
pub struct DeployArgs {
/// Path to the contract to deploy
Expand Down Expand Up @@ -43,26 +46,26 @@ pub struct DeploymentConfig {
pub fn exec(args: DeployArgs) -> anyhow::Result<()> {
let is_json = args.output_format == "json";

// Validate network name before doing any I/O.
if !VALID_NETWORKS.contains(&args.network.as_str()) {
return Err(SanctifierError::invalid_network(&args.network).into());
}

// Validate contract path
if !args.contract_path.exists() {
eprintln!(
"{} Error: Contract path not found: {}",
c::red("❌"),
args.contract_path.display()
);
std::process::exit(1);
return Err(SanctifierError::path_not_found(&args.contract_path).into());
}

// Get secret key from argument or environment
let secret_key = match args.secret_key {
Some(key) => key,
None => std::env::var("SOROBAN_SECRET_KEY").unwrap_or_default(),
};

if secret_key.is_empty() {
eprintln!(" Set via --secret-key or SOROBAN_SECRET_KEY environment variable");
std::process::exit(1);
}
// Resolve secret key: explicit flag wins, then env var.
let secret_key = args
.secret_key
.filter(|k| !k.is_empty())
.or_else(|| {
std::env::var("SOROBAN_SECRET_KEY")
.ok()
.filter(|k| !k.is_empty())
})
.ok_or_else(SanctifierError::missing_credentials)?;

let _ = is_json;

Expand Down
65 changes: 65 additions & 0 deletions tooling/sanctifier-cli/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ pub enum ErrorCode {
E008,
/// Dry-run mode: no changes were made (informational, not a failure).
E009,
/// Deploy target network is not a recognised Soroban network handle.
E010,
/// Soroban credentials are missing or cannot be sourced from the environment.
E011,
}

impl ErrorCode {
Expand All @@ -42,6 +46,8 @@ impl ErrorCode {
ErrorCode::E007 => "E007",
ErrorCode::E008 => "E008",
ErrorCode::E009 => "E009",
ErrorCode::E010 => "E010",
ErrorCode::E011 => "E011",
}
}
}
Expand Down Expand Up @@ -189,6 +195,28 @@ impl SanctifierError {
)
}

pub fn invalid_network(network: &str) -> Self {
Self::new(
ErrorCode::E010,
format!("'{}' is not a recognised Soroban network", network),
"Use one of: testnet, futurenet, mainnet. \
See https://developers.stellar.org/network/soroban-rpc for RPC endpoints."
.to_string(),
)
}

pub fn missing_credentials() -> Self {
Self::new(
ErrorCode::E011,
"Soroban secret key not provided",
"Pass --secret-key <SK…> or set the SOROBAN_SECRET_KEY environment variable. \
Generate a funded testnet key with: \
`stellar keys generate --global my-key --network testnet && \
stellar keys fund my-key --network testnet`."
.to_string(),
)
}

pub fn dry_run_no_changes(command: &str) -> Self {
Self::new(
ErrorCode::E009,
Expand Down Expand Up @@ -306,9 +334,46 @@ mod tests {
(ErrorCode::E007, "E007"),
(ErrorCode::E008, "E008"),
(ErrorCode::E009, "E009"),
(ErrorCode::E010, "E010"),
(ErrorCode::E011, "E011"),
];
for (code, expected) in codes {
assert_eq!(code.as_str(), expected);
}
}

#[test]
fn invalid_network_hint_lists_valid_options() {
let err = SanctifierError::invalid_network("devnet");
assert!(err.message.contains("devnet"), "message should echo the bad value");
assert!(err.hint.contains("testnet"), "hint should list valid networks");
assert!(err.hint.contains("futurenet"), "hint should list valid networks");
assert!(err.hint.contains("mainnet"), "hint should list valid networks");
assert_eq!(err.code, ErrorCode::E010);
}

#[test]
fn missing_credentials_hint_mentions_env_var_and_generate_command() {
let err = SanctifierError::missing_credentials();
assert!(err.hint.contains("SOROBAN_SECRET_KEY"));
assert!(err.hint.contains("--secret-key"));
assert!(err.hint.contains("generate"));
assert_eq!(err.code, ErrorCode::E011);
}

#[test]
fn e010_display_includes_code() {
let err = SanctifierError::invalid_network("staging");
let s = err.to_string();
assert!(s.contains("[E010]"));
assert!(s.contains("hint:"));
}

#[test]
fn e011_display_includes_code() {
let err = SanctifierError::missing_credentials();
let s = err.to_string();
assert!(s.contains("[E011]"));
assert!(s.contains("hint:"));
}
}
69 changes: 69 additions & 0 deletions tooling/sanctifier-cli/src/logging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ pub enum LogOutput {
Json,
}

/// Build an `EnvFilter` from a verbosity string without touching global state.
///
/// Accepts standard `tracing` directive syntax: `"warn"`, `"debug"`,
/// `"sanctifier=trace,warn"`, etc. Useful for measuring filter-build overhead
/// in benchmarks without side-effects.
pub fn make_filter(verbosity: &str) -> anyhow::Result<EnvFilter> {
EnvFilter::try_new(verbosity)
.map_err(|e| anyhow::anyhow!("invalid log filter '{}': {}", verbosity, e))
}

pub fn init(output: LogOutput) -> anyhow::Result<()> {
let env_filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new("warn"))
Expand Down Expand Up @@ -33,3 +43,62 @@ pub fn init(output: LogOutput) -> anyhow::Result<()> {

Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use std::time::Instant;

/// Budget for building a single `EnvFilter` — should be well under 50 ms
/// even on a heavily loaded CI runner.
const FILTER_BUILD_BUDGET_MS: u128 = 50;

#[test]
fn make_filter_accepts_standard_levels() {
for level in &["error", "warn", "info", "debug", "trace"] {
assert!(
make_filter(level).is_ok(),
"make_filter('{}') should succeed",
level
);
}
}

#[test]
fn make_filter_accepts_crate_qualified_directives() {
assert!(make_filter("sanctifier=debug,warn").is_ok());
assert!(make_filter("sanctifier_cli=trace").is_ok());
}

#[test]
fn make_filter_builds_within_budget_for_all_modes() {
let cases = [
("error", "quiet/error mode"),
("warn", "default mode"),
("info", "info mode"),
("debug", "verbose mode"),
("sanctifier=debug,warn", "crate-scoped verbose"),
];
for (directive, label) in cases {
let start = Instant::now();
let _ = make_filter(directive).expect("filter must build");
let elapsed = start.elapsed().as_millis();
assert!(
elapsed < FILTER_BUILD_BUDGET_MS,
"make_filter for {label} took {elapsed}ms, budget {FILTER_BUILD_BUDGET_MS}ms"
);
}
}

#[test]
fn make_filter_json_log_mode_builds_within_budget() {
// JSON log mode uses the same EnvFilter; verify it meets the same budget.
let start = Instant::now();
let _ = make_filter("debug").expect("filter must build");
let elapsed = start.elapsed().as_millis();
assert!(
elapsed < FILTER_BUILD_BUDGET_MS,
"JSON-mode filter build took {elapsed}ms, budget {FILTER_BUILD_BUDGET_MS}ms"
);
}
}
Loading
Loading