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
29 changes: 26 additions & 3 deletions crates/cargo-capsec/bench/audit_wild.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,22 @@ def parse_crates_toml(path: Path) -> list[dict]:

def get_capsec_version() -> str:
"""Get the installed cargo-capsec version."""
# Try --version first (most reliable)
result = subprocess.run(
["cargo", "capsec", "--version"],
capture_output=True,
text=True,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
# Fall back to parsing help output
result = subprocess.run(
["cargo", "capsec", "audit", "--help"],
capture_output=True,
text=True,
)
# Try to extract from help output or just return unknown
for line in result.stdout.splitlines():
if "version" in line.lower():
if "version" in line.lower() or "capsec" in line.lower():
return line.strip()
return "unknown"

Expand Down Expand Up @@ -97,6 +105,20 @@ def clone_repo(repo_url: str, dest: Path) -> bool:
return True


def strip_temp_paths(audit_data: dict | None, crate_dir: Path) -> dict | None:
"""Strip absolute temp directory prefix from file paths in audit output."""
if not audit_data:
return audit_data
prefix = str(crate_dir)
if not prefix.endswith("/"):
prefix += "/"
for crate_entry in audit_data.get("crates", []):
for finding in crate_entry.get("findings", []):
if "file" in finding and finding["file"].startswith(prefix):
finding["file"] = finding["file"][len(prefix):]
return audit_data


def run_audit(crate_dir: Path) -> dict:
"""Run cargo capsec audit on a directory. Returns parsed result."""
start = time.monotonic()
Expand All @@ -117,7 +139,8 @@ def run_audit(crate_dir: Path) -> dict:

if result.stdout.strip():
try:
output["audit"] = json.loads(result.stdout)
audit = json.loads(result.stdout)
output["audit"] = strip_temp_paths(audit, crate_dir)
except json.JSONDecodeError:
output["audit"] = None
output["parse_error"] = "Failed to parse JSON output"
Expand Down
43 changes: 42 additions & 1 deletion crates/cargo-capsec/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ use std::path::PathBuf;
cargo capsec audit --format sarif SARIF for GitHub Code Scanning\n \
cargo capsec audit --baseline Save results as baseline\n \
cargo capsec audit --diff Show changes since last baseline\n \
cargo capsec audit --min-risk high Only show high and critical findings"
cargo capsec audit --min-risk high Only show high and critical findings\n \
cargo capsec check-deny Verify #[capsec::deny] annotations\n \
cargo capsec badge Generate shields.io badge\n \
cargo capsec badge --json Output shields.io endpoint JSON"
)]
pub struct Cli {
/// When invoked as `cargo capsec`, cargo passes "capsec" as the first arg.
Expand All @@ -36,6 +39,10 @@ pub enum CargoSubcommand {
pub enum Commands {
/// Scan for ambient authority usage
Audit(AuditArgs),
/// Verify #[capsec::deny] annotations are respected
CheckDeny(CheckDenyArgs),
/// Generate a shields.io badge from audit results
Badge(BadgeArgs),
}

#[derive(clap::Args)]
Expand Down Expand Up @@ -80,3 +87,37 @@ pub struct AuditArgs {
#[arg(short, long)]
pub quiet: bool,
}

#[derive(clap::Args)]
pub struct CheckDenyArgs {
/// Path to workspace root
#[arg(short, long, default_value = ".")]
pub path: PathBuf,

/// Output format
#[arg(short, long, default_value = "text", value_parser = ["text", "json", "sarif"])]
pub format: String,

/// Only scan these crates (comma-separated)
#[arg(long)]
pub only: Option<String>,

/// Skip these crates (comma-separated)
#[arg(long)]
pub skip: Option<String>,
}

#[derive(clap::Args)]
pub struct BadgeArgs {
/// Path to workspace root
#[arg(short, long, default_value = ".")]
pub path: PathBuf,

/// Output shields.io endpoint JSON instead of markdown
#[arg(long)]
pub json: bool,

/// Risk threshold for badge color (default: high)
#[arg(long, default_value = "high", value_parser = ["low", "medium", "high", "critical"])]
pub fail_on: String,
}
20 changes: 18 additions & 2 deletions crates/cargo-capsec/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub struct CrateInfo {
#[derive(Deserialize)]
struct CargoMetadata {
packages: Vec<Package>,
workspace_root: String,
}

#[derive(Deserialize)]
Expand All @@ -38,17 +39,27 @@ struct Package {
source: Option<String>,
}

/// Result of workspace discovery: crates and the resolved workspace root.
pub struct DiscoveryResult {
/// All discovered crates.
pub crates: Vec<CrateInfo>,
/// The Cargo workspace root (from `cargo metadata`).
pub workspace_root: PathBuf,
}

/// Discovers all crates in a Cargo workspace by running `cargo metadata`.
///
/// When `include_deps` is `false` (default), passes `--no-deps` for speed — only
/// workspace members and path dependencies appear. When `true`, all transitive
/// dependencies with cached source are included.
///
/// Returns both the discovered crates and the resolved workspace root path.
pub fn discover_crates(
workspace_root: &Path,
include_deps: bool,
spawn_cap: &impl capsec_core::has::Has<capsec_core::permission::Spawn>,
_fs_cap: &impl capsec_core::has::Has<capsec_core::permission::FsRead>,
) -> Result<Vec<CrateInfo>, String> {
) -> Result<DiscoveryResult, String> {
// Use --no-deps by default for speed (avoids resolving 300+ transitive deps).
// Drop it when --include-deps is set so path dependencies and registry crates appear.
let mut args = vec!["metadata", "--format-version=1"];
Expand All @@ -70,6 +81,8 @@ pub fn discover_crates(
let metadata: CargoMetadata = serde_json::from_slice(&output.stdout)
.map_err(|e| format!("Failed to parse cargo metadata: {e}"))?;

let resolved_root = PathBuf::from(&metadata.workspace_root);

let mut crates = Vec::new();

for package in &metadata.packages {
Expand All @@ -90,7 +103,10 @@ pub fn discover_crates(
}
}

Ok(crates)
Ok(DiscoveryResult {
crates,
workspace_root: resolved_root,
})
}

/// Recursively discovers all `.rs` source files in a directory.
Expand Down
Loading
Loading