ndaal-binsec 3.2.1

Binary (in)security scanner for ELF/PE/Mach-O with native, strictly-validated SARIF 2.1.0 and Markdown output (ndaal fork of binsec)
Documentation
//! Native SARIF 2.1.0 generation, strict validation, and Markdown rendering.
//!
//! Follows the house standard in `skills/rust-sarif.md`: SARIF documents are
//! built with the `sarif_rust` fluent builders, validated with
//! `SarifValidator::strict()` (the production level), and converted to
//! GitHub-Flavored Markdown with `sarif-to-md-core`. Both the SARIF and the
//! Markdown are validated before they leave the process, so binsec never
//! emits a non-conformant report.

use sarif_rust::{
    Level, LocationBuilder, ResultBuilder, RunBuilder, SarifLogBuilder, SarifValidator,
};

use serde_json::Value;

use crate::check::GenericMap;
use crate::{BinError, BinResult};

/// Build a SARIF 2.1.0 JSON string for one analysed binary.
///
/// `sections` pairs a category label (e.g. `"mitigations"`) with the matching
/// result map. Each entry becomes one `note`-level result located at
/// `binary_uri`, constructed through the `sarif_rust` typed builders.
pub fn build(
    binary_uri: &str,
    sections: &[(&str, &GenericMap)],
    tool_version: &str,
) -> BinResult<String> {
    let mut run = RunBuilder::with_tool("binsec", Some(tool_version));
    for (category, map) in sections {
        for (name, value) in map.iter() {
            let location = LocationBuilder::new().with_file_location(binary_uri).build();
            let result = ResultBuilder::with_text_message(format!("{name}: {}", render(value)))
                .with_rule_id(rule_id(category, name))
                .with_level(Level::Note)
                .add_location(location)
                .build();
            run = run.add_result(result);
        }
    }

    let log = SarifLogBuilder::with_standard_schema()
        .add_run(run.build())
        .build()
        .map_err(|err| BinError::Internal(format!("SARIF build failed: {err}")))?;

    sarif_rust::to_string_pretty(&log)
        .map_err(|err| BinError::Internal(format!("SARIF serialization failed: {err}")))
}

/// Strictly validate a SARIF JSON string against the SARIF 2.1.0 model.
///
/// Parses with `sarif_rust::from_str` and runs `SarifValidator::strict()` —
/// the production validation level mandated by `skills/rust-sarif.md`.
///
/// # Errors
/// Returns an error if the input is not valid SARIF 2.1.0.
pub fn validate_sarif(json: &str) -> BinResult<()> {
    let log = sarif_rust::from_str(json)
        .map_err(|err| BinError::Internal(format!("SARIF parse error: {err}")))?;
    SarifValidator::strict()
        .validate_sarif_log(&log)
        .map_err(|err| BinError::Internal(format!("SARIF validation error: {err}")))
}

/// Render a SARIF JSON string to GitHub-Flavored Markdown via
/// `sarif-to-md-core`. The SARIF is strict-validated first and the produced
/// Markdown is structurally validated before it is returned.
///
/// # Errors
/// Returns an error if the SARIF is invalid or Markdown generation fails.
pub fn to_markdown(json: &str) -> BinResult<String> {
    validate_sarif(json)?;
    let processor = sarif_to_md_core::ReportProcessorBuilder::new()
        .generator(sarif_to_md_core::generators::SarifMarkdownGenerator::new(
            sarif_to_md_core::markdown::MarkdownFormat::GitHubFlavored,
            true,
        ))
        .content(json.to_owned())
        .build()
        .map_err(|err| BinError::Internal(format!("Markdown processor build failed: {err}")))?;
    let markdown = processor
        .generate()
        .map_err(|err| BinError::Internal(format!("Markdown generation failed: {err}")))?;
    validate_markdown(&markdown)?;
    Ok(markdown)
}

/// Validate generated Markdown: it must be non-empty and contain at least one
/// structural marker (heading, table, or horizontal rule).
///
/// # Errors
/// Returns an error if the Markdown is empty or structurally implausible.
pub fn validate_markdown(markdown: &str) -> BinResult<()> {
    if markdown.trim().is_empty() {
        return Err(BinError::Internal("Markdown output is empty".to_string()));
    }
    if !markdown.contains('#') && !markdown.contains('|') && !markdown.contains("---") {
        return Err(BinError::Internal(
            "Markdown output missing expected structure (no headings, tables, or rules)"
                .to_string(),
        ));
    }
    Ok(())
}

/// Build a stable, slug-like rule id such as
/// `binsec.mitigations.stack-canary`. Non-alphanumeric runs collapse to a
/// single `-`, which is trimmed from both ends.
fn rule_id(category: &str, name: &str) -> String {
    let mut slug = String::with_capacity(name.len());
    let mut prev_dash = false;
    for ch in name.chars() {
        if ch.is_ascii_alphanumeric() {
            slug.push(ch.to_ascii_lowercase());
            prev_dash = false;
        } else if !prev_dash {
            slug.push('-');
            prev_dash = true;
        }
    }
    format!("binsec.{category}.{}", slug.trim_matches('-'))
}

/// Render a JSON value as a short human string for the result message.
fn render(value: &Value) -> String {
    match value {
        Value::String(text) => text.clone(),
        other => other.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::{build, rule_id, validate_markdown, validate_sarif};
    use crate::check::GenericMap;
    use serde_json::{json, Value};

    fn sample_sarif() -> String {
        let mut mitigations = GenericMap::new();
        mitigations.insert("Stack Canary".to_string(), json!(true));
        mitigations.insert("Read-Only Relocatable (RELRO)".to_string(), json!("Full"));
        let sections = [("mitigations", &mitigations)];
        build("/bin/true", &sections, "3.2.0").expect("build SARIF")
    }

    #[test]
    fn rule_id_slugs_names() {
        assert_eq!(
            rule_id("mitigations", "Stack Canary"),
            "binsec.mitigations.stack-canary"
        );
        assert_eq!(
            rule_id("mitigations", "Position Independent Executable (PIE)"),
            "binsec.mitigations.position-independent-executable-pie"
        );
    }

    #[test]
    fn built_sarif_passes_strict_validation() {
        let sarif = sample_sarif();
        validate_sarif(&sarif).expect("a built report must pass SarifValidator::strict()");

        let doc: Value = serde_json::from_str(&sarif).expect("valid JSON");
        assert_eq!(doc["version"], "2.1.0");
        assert_eq!(doc["runs"][0]["tool"]["driver"]["name"], "binsec");
        assert_eq!(doc["runs"][0]["tool"]["driver"]["version"], "3.2.0");
        assert_eq!(doc["runs"][0]["results"].as_array().unwrap().len(), 2);
    }

    #[test]
    fn validate_sarif_rejects_non_sarif() {
        assert!(validate_sarif(r#"{"not":"a sarif document"}"#).is_err());
    }

    #[test]
    fn validate_markdown_checks_structure() {
        assert!(validate_markdown("").is_err());
        assert!(validate_markdown("plain text, no markers").is_err());
        assert!(validate_markdown("# Heading\n").is_ok());
    }

    #[test]
    fn markdown_renders_from_built_report() {
        let sarif = sample_sarif();
        let markdown = super::to_markdown(&sarif).expect("markdown render");
        assert!(markdown.contains('#'));
    }
}