Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/heimdall

guests/static-scan/tests/names.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! Typosquat and dependency confusion, judged by
//! modgunn::scip::typosquat_findings on the snapshot the caller sent. Heimdall
//! is a defensive sandbox.

mod common;
use common::*;

use heimdall_scan::report::unknown;
use heimdall_scan::{Check, CheckResult, DataKind, Severity, Verdict};

const NPM_POPULAR: &[u8] =
    b"# top npm names, snapshot 2026-11-01\nlodash\nexpress\nreact\nrequest\n\n";
const PYPI_POPULAR: &[u8] = b"requests\nnumpy\npython-dateutil\nurllib3\n";

fn typo(purl: &str, data: Option<&[u8]>) -> CheckResult {
    let d: Vec<(DataKind, &[u8])> = data
        .map(|d| vec![(DataKind::PopularNames, d)])
        .unwrap_or_default();
    check(
        &envelope(b"", &d),
        &[("purl", purl), ("checks", "typosquat")],
        Check::Typosquat,
    )
}

fn confusion(purl: &str, data: Option<&[u8]>, origin: Option<&str>) -> CheckResult {
    let d: Vec<(DataKind, &[u8])> = data
        .map(|d| vec![(DataKind::InternalNamespaces, d)])
        .unwrap_or_default();
    let mut p = vec![("purl", purl), ("checks", "dependency-confusion")];
    if let Some(o) = origin {
        p.push(("origin", o));
    }
    check(&envelope(b"", &d), &p, Check::DependencyConfusion)
}

#[test]
fn a_name_one_edit_from_a_popular_npm_package_is_a_high_typosquat_finding() {
    for (purl, pop) in [
        ("pkg:npm/lodashs@1.0.0", "lodash"),
        ("pkg:npm/expres@4.0.0", "express"),
        ("pkg:npm/reat@1.0.0", "react"),
        ("pkg:npm/reqiest@2.0.0", "request"),
    ] {
        let r = typo(purl, Some(NPM_POPULAR));
        assert_eq!(r.verdict, Verdict::Findings, "{purl}");
        assert_eq!(r.findings[0].severity, Severity::High);
        assert!(
            r.findings[0].summary.contains(pop),
            "{purl}: {:?}",
            r.findings
        );
        assert!(r.findings[0].id.starts_with("MODGUNN-TYPOSQUAT:"));
    }
}

#[test]
fn the_popular_package_itself_is_not_a_typosquat_of_itself() {
    assert_clean(&typo("pkg:npm/lodash@4.17.21", Some(NPM_POPULAR)));
}

#[test]
fn a_name_far_from_every_popular_name_is_clean() {
    assert_clean(&typo("pkg:npm/my-internal-tool@1.0.0", Some(NPM_POPULAR)));
}

#[test]
fn pypi_names_compare_after_pep_503_normalization() {
    // The same project spelled another way is not a look-alike.
    assert_clean(&typo("pkg:pypi/Python_Dateutil@2.9.0", Some(PYPI_POPULAR)));
    assert_clean(&typo("pkg:pypi/Requests@2.32.0", Some(b"REQUESTS\n")));
    // A real one-letter look-alike is.
    let r = typo("pkg:pypi/reqests@1.0", Some(PYPI_POPULAR));
    assert!(has(&r, "MODGUNN-TYPOSQUAT"), "{r:?}");
}

#[test]
fn a_missing_popular_names_snapshot_is_unknown_never_clean() {
    assert_unknown(&typo("pkg:npm/lodashs@1.0.0", None), unknown::DATA_MISSING);
}

#[test]
fn an_empty_popular_names_snapshot_is_unknown() {
    assert_unknown(
        &typo("pkg:npm/lodashs@1.0.0", Some(b"# nothing\n\n")),
        unknown::DATA_INVALID,
    );
}

#[test]
fn a_popular_names_snapshot_that_is_not_utf8_is_unknown() {
    assert_unknown(
        &typo("pkg:npm/lodashs@1.0.0", Some(b"lodash\n\xff\n")),
        unknown::DATA_INVALID,
    );
}

#[test]
fn an_internal_namespace_name_resolved_publicly_is_a_critical_confusion_finding() {
    let r = confusion(
        "pkg:npm/%40acme/billing@9.9.9",
        Some(b"@acme/\nacme-\n"),
        Some("public"),
    );
    assert_eq!(r.verdict, Verdict::Findings);
    assert_eq!(r.findings[0].severity, Severity::Critical);
    assert!(r.findings[0].id.starts_with("MODGUNN-DEP-CONFUSION:"));
    let r = confusion(
        "pkg:pypi/acme-internal@1.0",
        Some(b"acme-\n"),
        Some("public"),
    );
    assert!(has(&r, "MODGUNN-DEP-CONFUSION"));
}

#[test]
fn an_internal_name_resolved_from_the_internal_registry_is_clean() {
    assert_clean(&confusion(
        "pkg:npm/%40acme/billing@1.0.0",
        Some(b"@acme/\n"),
        Some("internal"),
    ));
}

#[test]
fn a_public_name_outside_every_internal_prefix_is_clean() {
    assert_clean(&confusion(
        "pkg:npm/lodash@4.17.21",
        Some(b"@acme/\n"),
        Some("public"),
    ));
}

#[test]
fn an_explicitly_empty_namespace_list_means_none_and_is_clean() {
    assert_clean(&confusion(
        "pkg:npm/lodash@4.17.21",
        Some(b""),
        Some("public"),
    ));
}

#[test]
fn dependency_confusion_without_its_snapshot_is_unknown() {
    assert_unknown(
        &confusion("pkg:npm/%40acme/x@1.0.0", None, Some("public")),
        unknown::DATA_MISSING,
    );
}

#[test]
fn dependency_confusion_without_the_origin_parameter_is_unknown() {
    assert_unknown(
        &confusion("pkg:npm/%40acme/x@1.0.0", Some(b"@acme/\n"), None),
        unknown::ORIGIN_MISSING,
    );
}

#[test]
fn a_large_popular_list_is_judged() {
    let mut list = String::new();
    for i in 0..20_000 {
        list.push_str(&format!("package-number-{i}\n"));
    }
    list.push_str("express\n");
    let r = typo("pkg:npm/expresss@1.0.0", Some(list.as_bytes()));
    assert!(has(&r, "MODGUNN-TYPOSQUAT"));
}