bonsai-ninja-security 0.2.1

Security rulepack loader, matcher, and source/sink/sanitizer wrapper for bonsai-ninja.
Documentation
use super::*;

fn edge(
    trace_id: u64,
    parent_trace_id: Option<u64>,
    caller: u32,
    callee: u32,
    start: u64,
) -> TaintedCallEdge {
    TaintedCallEdge {
        trace_id,
        parent_trace_id,
        caller: FuncId::new(caller),
        callee: FuncId::new(callee),
        call_span: Span::new(bonsai_common::FileId::new(0), start, start + 1),
        tainted_args: Vec::new(),
        precision: Precision::Exact,
        edge_kind: bonsai_callgraph::EdgeKind::Direct,
    }
}

fn real_edge(trace_id: u64, caller: u32, callee: u32) -> TaintedCallEdge {
    let mut record = edge(trace_id, None, caller, callee, trace_id * 10);
    record.tainted_args.push(bonsai_taint::TaintedArg {
        index: 0,
        value_text: format!("v{caller}"),
        param_name: format!("p{callee}"),
        place: Some(format!("v{caller}")),
        source_names: vec![format!("v{caller}")],
    });
    record
}

#[test]
fn source_lineage_reports_truncated_and_omitted_paths() {
    let records = vec![
        edge(1, None, 1, 2, 10),
        edge(2, Some(1), 2, 3, 20),
        edge(3, Some(2), 3, 4, 30),
        edge(4, Some(3), 4, 5, 40),
        edge(5, Some(1), 2, 6, 50),
        edge(6, Some(1), 2, 7, 60),
    ];

    let (lineages, stats) = collect_tainted_source_lineages(&records, FuncId::new(1), 2, 2);

    assert_eq!(stats.emitted_paths, 2);
    assert_eq!(stats.omitted_paths, 1);
    assert_eq!(stats.truncated_paths, 1);
    assert_eq!(lineages.len(), 2);
    assert!(lineages[0].truncated_hops);
    assert_eq!(lineages[0].records.len(), 2);
    assert!(!lineages[1].truncated_hops);

    let summary = SourceLineageSummary::from_statuses(
        lineages
            .iter()
            .enumerate()
            .map(|(idx, emission)| SourceLineageStatus::from_lineage(emission, stats, idx)),
    );
    assert_eq!(summary.emitted_paths, 2);
    assert_eq!(summary.omitted_paths, 1);
    assert_eq!(summary.truncated_hop_flows, 1);
}

#[test]
fn source_lineage_unbounded_limits_emit_every_path() {
    let records = (1..=30)
        .map(|trace_id| edge(trace_id, None, 1, 100 + trace_id as u32, trace_id * 10))
        .collect::<Vec<_>>();

    let (bounded, bounded_stats) = collect_tainted_source_lineages(
        &records,
        FuncId::new(1),
        SourceLineageLimits::bounded_default().max_hops,
        SourceLineageLimits::bounded_default().max_paths,
    );
    assert_eq!(bounded.len(), SOURCE_ANALYSIS_LINEAGE_RENDER_PATHS);
    assert_eq!(bounded_stats.omitted_paths, 6);
    let bounded_summary = SourceLineageSummary::from_statuses(
        bounded
            .iter()
            .enumerate()
            .map(|(idx, emission)| SourceLineageStatus::from_lineage(emission, bounded_stats, idx)),
    );
    assert_eq!(
        bounded_summary.emitted_paths,
        SOURCE_ANALYSIS_LINEAGE_RENDER_PATHS
    );
    assert_eq!(bounded_summary.omitted_paths, 6);

    let (unbounded, unbounded_stats) = collect_tainted_source_lineages(
        &records,
        FuncId::new(1),
        SourceLineageLimits::unbounded().max_hops,
        SourceLineageLimits::unbounded().max_paths,
    );
    assert_eq!(unbounded.len(), records.len());
    assert_eq!(unbounded_stats.omitted_paths, 0);
    assert_eq!(unbounded_stats.truncated_paths, 0);
}

#[test]
fn source_lineage_summary_reports_incomplete_flows() {
    let summary = SourceLineageSummary::from_statuses([
        SourceLineageStatus::complete(),
        SourceLineageStatus {
            complete: false,
            truncated_hops: true,
            omitted_paths: 0,
            emitted_paths: 2,
            max_hops: 3,
            max_paths: 24,
        },
        SourceLineageStatus {
            complete: false,
            truncated_hops: false,
            omitted_paths: 5,
            emitted_paths: 4,
            max_hops: 6,
            max_paths: 24,
        },
    ]);

    assert!(!summary.is_complete());
    assert_eq!(summary.incomplete_flows, 2);
    assert_eq!(summary.truncated_hop_flows, 1);
    assert_eq!(summary.omitted_paths, 5);
    assert_eq!(summary.emitted_paths, 6);
    assert_eq!(summary.max_hops, SOURCE_ANALYSIS_LINEAGE_RENDER_HOPS);
    assert_eq!(summary.max_paths, SOURCE_ANALYSIS_LINEAGE_RENDER_PATHS);
}

#[test]
fn source_lineage_merge_preserves_additive_path_counts() {
    let mut merged = SourceLineageStatus {
        complete: true,
        truncated_hops: false,
        omitted_paths: 1,
        emitted_paths: 2,
        max_hops: 4,
        max_paths: 8,
    };
    merge_source_lineage_status(
        &mut merged,
        SourceLineageStatus {
            complete: false,
            truncated_hops: true,
            omitted_paths: 3,
            emitted_paths: 5,
            max_hops: 6,
            max_paths: 10,
        },
    );

    assert!(!merged.complete);
    assert!(merged.truncated_hops);
    assert_eq!(merged.omitted_paths, 4);
    assert_eq!(merged.emitted_paths, 7);
    assert_eq!(merged.max_hops, 6);
    assert_eq!(merged.max_paths, 10);
}

#[test]
fn source_lineage_omissions_attach_to_first_emitted_status() {
    let records = vec![edge(1, None, 1, 2, 10), edge(2, None, 1, 3, 20)];
    let (lineages, mut stats) = collect_tainted_source_lineages(&records, FuncId::new(1), 6, 2);
    stats.omitted_paths = 3;

    let first_rendered = SourceLineageStatus::from_lineage(&lineages[1], stats, 0);
    let later_rendered = SourceLineageStatus::from_lineage(&lineages[0], stats, 1);

    assert_eq!(first_rendered.omitted_paths, 3);
    assert_eq!(later_rendered.omitted_paths, 0);
}

#[test]
fn strict_source_text_matching_keeps_framework_get_receivers_distinct() {
    assert!(security_text_matches_source_strict("getenv", "os.getenv"));
    assert!(security_text_matches_source_strict(
        "request.headers.get",
        "request.headers.get"
    ));
    assert!(!security_text_matches_source_strict(
        "request.args.get",
        "request.headers.get"
    ));
    assert!(!security_text_matches_source_strict(
        "request.values.get",
        "request.args.get"
    ));
}

#[test]
fn canonical_chain_search_has_no_fixed_hop_limit() {
    let records: Vec<_> = (1..=24)
        .map(|caller| real_edge(u64::from(caller), caller, caller + 1))
        .collect();
    let call_graph = bonsai_callgraph::ResolvedCallGraph::from_call_graph(bonsai_callgraph::CallGraph::new());
    let index = CanonicalChainIndex::new(&records, &call_graph);

    let path =
        best_chain_through_real_edges(&index, FuncId::new(1), FuncId::new(25)).expect("long canonical path");
    assert_eq!(path.len(), 25);
    assert_eq!(path.first(), Some(&FuncId::new(1)));
    assert_eq!(path.last(), Some(&FuncId::new(25)));
}

#[test]
fn canonical_chain_reuses_one_complete_tree_for_multiple_terminals() {
    let records = vec![real_edge(1, 1, 2), real_edge(2, 2, 3), real_edge(3, 2, 4)];
    let call_graph = bonsai_callgraph::ResolvedCallGraph::from_call_graph(bonsai_callgraph::CallGraph::new());
    let index = CanonicalChainIndex::new(&records, &call_graph);

    assert_eq!(
        index.best_chain(FuncId::new(1), FuncId::new(3)),
        Some(vec![FuncId::new(1), FuncId::new(2), FuncId::new(3)])
    );
    assert_eq!(
        index.best_chain(FuncId::new(1), FuncId::new(4)),
        Some(vec![FuncId::new(1), FuncId::new(2), FuncId::new(4)])
    );
    assert_eq!(index.best_chain_trees.borrow().len(), 1);
}