bonsai-ninja-security 0.1.0

Security rulepack loader, matcher, and source/sink/sanitizer wrapper for bonsai-ninja.
Documentation
use super::*;
use crate::loader::{load_rulepack, LanguagePack, Rulepack, RulepackMetadata};
use crate::rule::{MatchKind, MatchSpec, Rule, RuleConstraint, RuleKind, RuleTarget, Severity};

fn temp_root(tag: &str) -> std::path::PathBuf {
    use std::time::{SystemTime, UNIX_EPOCH};
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("time")
        .as_nanos();
    let path = std::env::temp_dir().join(format!("{tag}-{}-{nanos}", std::process::id()));
    std::fs::create_dir(&path).expect("temp dir");
    path
}

fn bundled_metadata() -> RulepackMetadata {
    load_rulepack(
        &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("../..")
            .join("security-patterns"),
    )
    .expect("bundled rulepack")
    .metadata
}

fn pack_with_bundled_metadata() -> Rulepack {
    let mut pack = Rulepack::default();
    pack.metadata = bundled_metadata();
    pack
}

fn package_rule(package: &str) -> Rule {
    Rule {
        id: "java.test.package_gate".to_string(),
        aliases: Vec::new(),
        enabled: true,
        disabled_reason: None,
        title: None,
        tag: Some("test".to_string()),
        severity: Some(Severity::Critical),
        trust: None,
        category: None,
        cwe: vec![],
        owasp: vec![],
        frameworks: vec![],
        packages: vec![package.to_string()],
        imports: vec![],
        modules: vec![],
        manifests: vec![],
        lockfiles: vec![],
        package_matching: Default::default(),
        payload_types: vec![],
        match_spec: MatchSpec {
            kind: MatchKind::Call,
            callee: Some(RuleTarget {
                name: Some("lookup".to_string()),
                ..Default::default()
            }),
            target: None,
            search_depth: 0,
        },
        analysis_semantics: None,
        taint_semantics: None,
        lifecycle_transition: None,
        returns_type: None,
        callback_param_types: Vec::new(),
        callback_arg_index: None,
        constraints: RuleConstraint::default(),
        match_examples: Vec::new(),
        description: "test".to_string(),
        kind: RuleKind::Sink,
        language: "java".to_string(),
        source_path: "test.yml".to_string(),
    }
}

fn python_package_rule(package: &str) -> Rule {
    let mut rule = package_rule(package);
    rule.id = "python.test.package_gate".to_string();
    rule.language = "python".to_string();
    rule
}

#[test]
fn manifest_recognition_reuses_the_language_mapping() {
    let metadata = bundled_metadata();
    for basename in [
        "Pipfile",
        "mix.exs",
        "go.work",
        "packages.config",
        "project.csproj",
        "plugin.gemspec",
        "Package.resolved",
    ] {
        assert!(
            is_dependency_manifest_basename(basename, &metadata),
            "{basename} has a language mapping and must be scanned"
        );
    }
    assert!(!is_dependency_manifest_basename("notes.yaml", &metadata));
}

#[cfg(unix)]
#[test]
fn manifest_scan_does_not_follow_directory_symlinks_outside_the_workspace() {
    let root = temp_root("bonsai-deps-symlink-root");
    let outside = temp_root("bonsai-deps-symlink-outside");
    std::fs::write(outside.join("Pipfile"), "requests = \"*\"").expect("outside manifest");
    std::os::unix::fs::symlink(&outside, root.join("linked")).expect("directory symlink");

    let paths = scan_manifest_files(&root, &pack_with_bundled_metadata());
    assert!(
        paths.is_empty(),
        "dependency inventory must not follow workspace symlinks: {paths:?}"
    );
}

#[test]
fn dependency_inventory_treats_manifest_package_name_as_evidence() {
    let root = temp_root("bonsai-deps-package");
    std::fs::write(
        root.join("pom.xml"),
        r"<project><artifactId>log4j-core</artifactId></project>",
    )
    .expect("pom");
    let ws = Workspace::new(std::sync::Arc::new(bonsai_lang_api::LanguageRegistry::new()));

    let mut pack = pack_with_bundled_metadata();
    pack.packs.insert(
        "java".to_string(),
        LanguagePack {
            language: "java".to_string(),
            sources: Vec::new(),
            sinks: vec![package_rule("log4j-core")],
            sanitizers: Vec::new(),
            typing: Vec::new(),
        },
    );

    let inventory = build_inventory(&pack, &ws, &root);
    assert!(
        inventory.rows.iter().any(|row| {
            row.key == "log4j-core"
                && row.signals.iter().any(|signal| signal == "packages:log4j-core")
                && row.evidence_files.iter().any(|file| file.ends_with("pom.xml"))
        }),
        "expected log4j-core manifest evidence, got {:?}",
        inventory.rows
    );
}

#[test]
fn dependency_inventory_does_not_project_one_package_signal_onto_siblings() {
    let root = temp_root("bonsai-deps-package-siblings");
    std::fs::write(
        root.join("pom.xml"),
        r"<project><artifactId>log4j-core</artifactId></project>",
    )
    .expect("pom");
    let ws = Workspace::new(std::sync::Arc::new(bonsai_lang_api::LanguageRegistry::new()));

    let mut rule = package_rule("log4j-core");
    rule.packages.push("commons-io".to_string());

    let mut pack = pack_with_bundled_metadata();
    pack.packs.insert(
        "java".to_string(),
        LanguagePack {
            language: "java".to_string(),
            sources: Vec::new(),
            sinks: vec![rule],
            sanitizers: Vec::new(),
            typing: Vec::new(),
        },
    );

    let inventory = build_inventory(&pack, &ws, &root);
    assert!(
        inventory.rows.iter().any(|row| row.key == "log4j-core"),
        "expected log4j-core evidence, got {:?}",
        inventory.rows
    );
    assert!(
        inventory.rows.iter().all(|row| row.key != "commons-io"),
        "did not expect commons-io evidence from a log4j-core manifest, got {:?}",
        inventory.rows
    );
}

#[test]
fn workspace_dependency_packages_alias_python_distribution_names_to_imports() {
    let root = temp_root("bonsai-deps-python-aliases");
    std::fs::write(
        root.join("requirements.txt"),
        "psycopg2-binary==2.9.9\ndjangorestframework==3.15.1\nmysql-connector-python==9.1.0\n",
    )
    .expect("requirements");

    let context = build_workspace_dependency_package_context(&root, &bundled_metadata());
    let packages = workspace_dependency_packages_from_context(&context, "python").packages;
    assert!(
        packages.contains("psycopg2"),
        "expected psycopg2 alias from psycopg2-binary, got {:?}",
        packages
    );
    assert!(
        packages.contains("rest_framework"),
        "expected rest_framework alias from djangorestframework, got {:?}",
        packages
    );
    assert!(
        packages.contains("mysql.connector"),
        "expected mysql.connector alias from mysql-connector-python, got {:?}",
        packages
    );
}

#[test]
fn workspace_dependency_packages_alias_rust_hyphenated_crates_to_imports() {
    let root = temp_root("bonsai-deps-rust-aliases");
    std::fs::write(
        root.join("Cargo.toml"),
        r#"[dependencies]
percent-encoding = "2"
"#,
    )
    .expect("cargo");

    let context = build_workspace_dependency_package_context(&root, &bundled_metadata());
    let packages = workspace_dependency_packages_from_context(&context, "rust").packages;
    assert!(
        packages.contains("percent_encoding"),
        "expected percent_encoding alias from percent-encoding, got {:?}",
        packages
    );
}

#[test]
fn broad_manifest_refresh_replaces_stale_package_context() {
    let root = temp_root("bonsai-deps-manifest-refresh");
    let manifest = root.join("requirements.txt");
    std::fs::write(&manifest, "psycopg2-binary==2.9.9\n").expect("initial requirements");

    let metadata = bundled_metadata();
    let initial_context = build_workspace_dependency_package_context(&root, &metadata);
    let initial = workspace_dependency_packages_from_context(&initial_context, "python");
    assert!(initial.packages.contains("psycopg2"));
    assert!(!initial.packages.contains("requests"));

    std::fs::write(&manifest, "requests==2.32.3\n").expect("updated requirements");
    let refreshed_context = build_workspace_dependency_package_context(&root, &metadata);
    let refreshed = workspace_dependency_packages_from_context(&refreshed_context, "python");
    assert_ne!(initial.fingerprint, refreshed.fingerprint);
    assert!(!refreshed.packages.contains("psycopg2"));
    assert!(refreshed.packages.contains("requests"));
}

#[test]
fn analysis_manifest_snapshot_remains_immutable_until_the_run_finishes() {
    let root = temp_root("bonsai-deps-analysis-snapshot");
    let manifest = root.join("requirements.txt");
    std::fs::write(&manifest, "psycopg2-binary==2.9.9\n").expect("initial requirements");
    let workspace_id = 9_001;
    let pack = pack_with_bundled_metadata();
    let snapshot = super::begin_workspace_dependency_package_snapshot(&root, workspace_id, &pack);

    let initial =
        super::workspace_dependency_packages_for_language_in_workspace(&root, "python", workspace_id);
    assert!(initial.packages.contains("psycopg2"));
    std::fs::write(&manifest, "requests==2.32.3\n").expect("updated requirements");

    let during_run =
        super::workspace_dependency_packages_for_language_in_workspace(&root, "python", workspace_id);
    assert_eq!(initial.fingerprint, during_run.fingerprint);
    assert!(during_run.packages.contains("psycopg2"));
    assert!(!during_run.packages.contains("requests"));

    drop(snapshot);
    let _refreshed_snapshot = super::begin_workspace_dependency_package_snapshot(&root, workspace_id, &pack);
    let after_run =
        super::workspace_dependency_packages_for_language_in_workspace(&root, "python", workspace_id);
    assert_ne!(after_run.fingerprint, initial.fingerprint);
    assert!(!after_run.packages.contains("psycopg2"));
    assert!(after_run.packages.contains("requests"));
}

#[test]
fn dependency_inventory_reports_python_distribution_alias_as_package_evidence() {
    let root = temp_root("bonsai-deps-python-inventory-alias");
    std::fs::write(root.join("requirements.txt"), "psycopg2-binary==2.9.9\n").expect("requirements");
    let ws = Workspace::new(std::sync::Arc::new(bonsai_lang_api::LanguageRegistry::new()));

    let mut pack = pack_with_bundled_metadata();
    pack.packs.insert(
        "python".to_string(),
        LanguagePack {
            language: "python".to_string(),
            sources: Vec::new(),
            sinks: vec![python_package_rule("psycopg2")],
            sanitizers: Vec::new(),
            typing: Vec::new(),
        },
    );

    let inventory = build_inventory(&pack, &ws, &root);
    assert!(
        inventory.rows.iter().any(|row| {
            row.key == "psycopg2"
                && row.signals.iter().any(|signal| signal == "packages:psycopg2")
                && row
                    .evidence_files
                    .iter()
                    .any(|file| file.ends_with("requirements.txt"))
        }),
        "expected psycopg2 evidence from psycopg2-binary, got {:?}",
        inventory.rows
    );
}