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

nordisk/holger

server/lib/tests/pushed_images_go_through_the_intake_chain.rs

//! **A push to the `oci` registry goes through the intake chain** (lane HOL-OCI-EVIDENCE
//! item 2, owner 2026-10-31). HOL-REFERRERS left it open: an image pushed to the plain `oci`
//! registry was served the moment its manifest landed, unscanned, with no Holger evidence.
//!
//! On a Holger with an intake chain every `oci` repository is a SCANNED REGISTRY unless the
//! RON opts it out by name (`promotion.intake.oci_push: [(repository: …, scan: off)]`):
//!
//! * a push by tag takes the image into the chain as the pusher: SCANNED (the image SBOM,
//!   the secrets scan of every layer, the CVE match), POLICY_CHECKED, then the approvals
//!   (never the pusher's); nothing of it is served before it is AVAILABLE, and then its
//!   manifest, blobs and tags are, with Holger's evidence as referrers;
//! * a push the chain refuses fails the push itself (`403 DENIED` with the reason) and is
//!   never served;
//! * a write needs the Importer duty; a DELETE is refused (`holger-oci-3`);
//! * a signature pushed before the tag rides in the image's layout; an artifact attached
//!   after the image is AVAILABLE is served beside it;
//! * an opt-out serves as pushed, and is refused at boot where the policy requires a scan
//!   (`holger-oci-1`).
#![cfg(all(feature = "supply-chain", feature = "jobs", feature = "sign"))]

mod oci_evidence;

use holger_server_lib::promote::intake::{ArtifactState, TransitionRequest};
use oci_evidence::*;

fn chained() -> Running {
    start(
        &Boot {
            oci_repo: true,
            approvals: 2,
            yara: true,
            ..Default::default()
        },
        None,
    )
}

fn get(r: &Running, path: &str) -> (u16, String, Vec<u8>) {
    request(r.http, "GET", path, &[("Accept", MANIFEST)], b"")
}

fn text(b: &[u8]) -> String {
    String::from_utf8_lossy(b).to_string()
}

#[test]
fn a_pushed_image_is_scanned_approved_and_only_then_served() {
    let r = chained();
    let img = demo_image("pushed");
    let (s, h, b) = push(&r, "ida", "demo/app", "1", &img);
    assert_eq!(s, 201, "the push is taken: {}", text(&b));
    assert_eq!(
        header(&h, "x-holger-intake-state").as_deref(),
        Some("policy_checked"),
        "{h}"
    );

    // Nothing of it is served before it is AVAILABLE.
    let (s, _, b) = get(&r, "/v2/oci/demo/app/manifests/1");
    assert_eq!(s, 404, "{}", text(&b));
    assert!(
        text(&b).contains("not yet AVAILABLE") && text(&b).contains("POLICY_CHECKED"),
        "{}",
        text(&b)
    );
    assert_eq!(
        get(&r, &format!("/v2/oci/demo/app/manifests/{}", img.digest)).0,
        404
    );
    assert_eq!(
        get(
            &r,
            &format!("/v2/oci/demo/app/blobs/{}", digest(&img.layers[0]))
        )
        .0,
        404,
        "a staged blob is never served"
    );
    let (_, _, tags) = get(&r, "/v2/oci/demo/app/tags/list");
    assert!(!text(&tags).contains("\"1\""), "{}", text(&tags));

    // In the chain as the pusher, under the registry's name; the pusher never approves it.
    let chain = r.chain();
    let id = layout_in(&chain, "oci/demo/app/", ArtifactState::PolicyChecked);
    assert_eq!(
        id.name,
        format!("oci/demo/app/{}.oci.tar", img.digest.replace(':', "-"))
    );
    let st = chain.status(&id).unwrap();
    let first = chain.history(&id).into_iter().next().unwrap();
    assert_eq!(first.actor, "ida", "INGESTED as the pusher");
    assert_eq!(first.origin, "oci", "its origin is the registry");
    assert!(
        chain
            .advance(
                TransitionRequest::new(id.clone(), ArtifactState::Approved, "ida")
                    .with_reason("mine")
            )
            .is_err(),
        "the importer never approves"
    );
    let _ = st;
    approve(&chain, &id, &["bob", "carol"]);

    // Now it is served (the first read seals it, as the pull-through does): by tag, by
    // digest, its blobs and its tag.
    let (s, h, b) = get(&r, "/v2/oci/demo/app/manifests/1");
    assert_eq!(s, 200, "{}", text(&b));
    assert_eq!(chain.state(&id), ArtifactState::Available);
    assert_eq!(b, img.manifest);
    assert_eq!(
        header(&h, "docker-content-digest").as_deref(),
        Some(img.digest.as_str())
    );
    assert_eq!(
        header(&h, "x-holger-intake-state").as_deref(),
        Some("available")
    );
    assert_eq!(
        get(&r, &format!("/v2/oci/demo/app/manifests/{}", img.digest)).2,
        img.manifest
    );
    for blob in img.blobs() {
        let (s, _, got) = get(&r, &format!("/v2/oci/demo/app/blobs/{}", digest(&blob)));
        assert_eq!(s, 200);
        assert_eq!(got, blob);
    }
    let (_, _, tags) = get(&r, "/v2/oci/demo/app/tags/list");
    assert!(text(&tags).contains("\"1\""), "{}", text(&tags));

    // And Holger's evidence rides beside it: the scan verdict and the image SBOM.
    let ev = holger_evidence(&r, "oci/demo/app", &img.digest);
    let kinds: Vec<&str> = ev.iter().map(|(t, _)| t.as_str()).collect();
    assert!(
        kinds.contains(&SCAN_EVIDENCE) && kinds.contains(&CYCLONEDX),
        "{kinds:?}"
    );
    let (_, sbom) = ev.iter().find(|(t, _)| t == CYCLONEDX).unwrap();
    let purls: Vec<&str> = sbom["predicate"]["components"]
        .as_array()
        .unwrap()
        .iter()
        .filter_map(|c| c["purl"].as_str())
        .collect();
    assert!(
        purls.contains(&"pkg:apk/wolfi/openssl@3.0.13-r2?distro=wolfi"),
        "{purls:?}"
    );
    let att = format!("sha256-{}.att", img.digest.trim_start_matches("sha256:"));
    assert_eq!(
        get(&r, &format!("/v2/oci/demo/app/manifests/{att}")).0,
        200,
        "the .att tag carries Holger's envelopes"
    );
}

/// **A client that pushes the manifest by digest and then tags it gets its push through**
/// (lane HOL-FIELD-2): `helm push` 3.18+ (oras-go v2) PUTs the manifest by digest, reads it
/// back by digest and PUTs it again under the tag. The scanned registry answered the read
/// with 404 (staged, not AVAILABLE), so helm 3.19 failed with `failed to perform "Tag" on
/// destination: sha256:…: not found` and no chart could be pushed. The pusher (an Importer
/// with write on the registry) reads its own staged manifest back by digest; nobody else
/// does, and no staged blob is ever served.
#[test]
fn a_digest_push_then_tag_goes_through_and_only_a_pusher_reads_the_staged_manifest() {
    let r = chained();
    let img = demo_image("digest-first");
    push_blobs(&r, "ida", "demo/chart", &img.blobs());
    let (s, _, b) = push_manifest(&r, "ida", "demo/chart", &img.digest, &img.manifest);
    assert_eq!(s, 201, "the digest push is staged: {}", text(&b));

    let path = format!("/v2/oci/demo/chart/manifests/{}", img.digest);
    let ida = bearer("ida");
    let (s, h, b) = request(
        r.http,
        "GET",
        &path,
        &[("Accept", MANIFEST), ("Authorization", &ida)],
        b"",
    );
    assert_eq!(
        s,
        200,
        "the pusher reads its staged manifest back by digest: {}",
        text(&b)
    );
    assert_eq!(b, img.manifest);
    assert_eq!(
        header(&h, "docker-content-digest").as_deref(),
        Some(img.digest.as_str())
    );
    let (s, _, _) = request(
        r.http,
        "HEAD",
        &path,
        &[("Accept", MANIFEST), ("Authorization", &ida)],
        b"",
    );
    assert_eq!(s, 200, "and HEADs it");

    // Nobody else reads it, and its blobs stay unserved.
    assert_eq!(
        get(&r, &path).0,
        404,
        "an anonymous read of a staged manifest"
    );
    let bob = bearer("bob");
    let (s, _, _) = request(
        r.http,
        "GET",
        &path,
        &[("Accept", MANIFEST), ("Authorization", &bob)],
        b"",
    );
    assert_eq!(s, 404, "an approver without the Importer duty");
    let blob = format!("/v2/oci/demo/chart/blobs/{}", digest(&img.layers[0]));
    let (s, _, _) = request(r.http, "GET", &blob, &[("Authorization", &ida)], b"");
    assert_eq!(
        s, 404,
        "a staged blob is never served, not even to its pusher"
    );

    // The tag push takes the image into the chain, as `docker push` does.
    let (s, h, b) = push_manifest(&r, "ida", "demo/chart", "0.1.0", &img.manifest);
    assert_eq!(s, 201, "the tag is taken: {}", text(&b));
    assert_eq!(
        header(&h, "x-holger-intake-state").as_deref(),
        Some("policy_checked"),
        "{h}"
    );
}

#[test]
fn a_refused_push_fails_the_push_itself_and_is_never_served() {
    let r = chained();
    // Two halves, so this source file is not itself the test string.
    let eicar = format!(
        "{}{}",
        "X5O!P%@AP[4\\PZX54(P^)7CC)7}$", "EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"
    )
    .into_bytes();
    let img = Img::new(&[vec![
        ("etc/os-release", b"ID=wolfi\n"),
        ("usr/bin/dropper", &eicar),
    ]]);
    let (s, _, b) = push(&r, "ida", "leaky/app", "1", &img);
    assert_eq!(
        s,
        403,
        "docker push fails with Holger's refusal: {}",
        text(&b)
    );
    let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
    assert_eq!(v["errors"][0]["code"], "DENIED", "{v}");
    assert!(
        v["errors"][0]["message"]
            .as_str()
            .unwrap()
            .contains("EICAR"),
        "the reason is named: {v}"
    );
    let chain = r.chain();
    // Refused at the door: nothing of it was INGESTED, and it is never served.
    assert!(
        chain
            .all()
            .iter()
            .all(|(id, _)| !id.name.starts_with("oci/leaky/")),
        "refused before a byte entered the chain"
    );
    let (s, _, b) = get(&r, "/v2/oci/leaky/app/manifests/1");
    assert_eq!(s, 404, "a refused image is never served: {}", text(&b));
    assert_eq!(
        get(
            &r,
            &format!("/v2/oci/leaky/app/blobs/{}", digest(&img.layers[0]))
        )
        .0,
        404
    );
}

#[test]
fn a_push_needs_the_importer_duty_and_a_delete_is_refused() {
    // ida may delete by her role; the scanned registry refuses it all the same.
    let r = start(
        &Boot {
            oci_repo: true,
            approvals: 2,
            auth_extra: r#"roles: {"ida": Admin, "adm": Writer},"#.into(),
            ..Default::default()
        },
        None,
    );
    // A repository admin configures; it does not bring artifacts in.
    let (s, _, b) = request(
        r.http,
        "POST",
        "/v2/oci/demo/app/blobs/uploads/",
        &[("Authorization", &bearer("adm"))],
        b"",
    );
    assert_eq!(s, 403, "{}", text(&b));
    assert!(
        text(&b).to_ascii_lowercase().contains("importer"),
        "the duty is named: {}",
        text(&b)
    );
    let img = demo_image("del");
    assert_eq!(push(&r, "ida", "demo/del", "1", &img).0, 201);
    let (s, _, b) = request(
        r.http,
        "DELETE",
        &format!("/v2/oci/demo/del/manifests/{}", img.digest),
        &[("Authorization", &bearer("ida"))],
        b"",
    );
    assert_eq!(s, 403, "{}", text(&b));
    assert!(text(&b).contains("[holger-oci-3]"), "{}", text(&b));
    assert!(
        r.chain()
            .all()
            .iter()
            .any(|(id, _)| id.name.starts_with("oci/demo/del/")),
        "still in the chain"
    );
}

/// A cosign-style `.sig` manifest for `subject` (one layer, the simple-signing payload).
fn sig_for(subject: &str) -> (Vec<u8>, Vec<Vec<u8>>) {
    let payload = serde_json::to_vec(&serde_json::json!({
        "critical": {"identity": {"docker-reference": "demo"}, "image": {"docker-manifest-digest": subject}, "type": "cosign container image signature"},
        "optional": null,
    }))
    .unwrap();
    let config =
        br#"{"architecture":"","os":"","config":{},"rootfs":{"type":"layers","diff_ids":[]}}"#
            .to_vec();
    let manifest = serde_json::to_vec(&serde_json::json!({
        "schemaVersion": 2, "mediaType": MANIFEST,
        "config": {"mediaType": CONFIG, "digest": digest(&config), "size": config.len()},
        "layers": [{"mediaType": "application/vnd.dev.cosign.simplesigning.v1+json", "digest": digest(&payload), "size": payload.len(),
                    "annotations": {"dev.cosignproject.cosign/signature": "MEUCIQ=="}}],
    }))
    .unwrap();
    (manifest, vec![config, payload])
}

/// An SBOM attached the OCI 1.1 way (a manifest with a `subject`).
fn referrer_for(subject: &str, subject_len: usize) -> (Vec<u8>, Vec<Vec<u8>>) {
    let layer = br#"{"spdxVersion":"SPDX-2.3","name":"later","packages":[]}"#.to_vec();
    let empty = b"{}".to_vec();
    let manifest = serde_json::to_vec(&serde_json::json!({
        "schemaVersion": 2, "mediaType": MANIFEST, "artifactType": "application/spdx+json",
        "config": {"mediaType": "application/vnd.oci.empty.v1+json", "digest": digest(&empty), "size": 2},
        "layers": [{"mediaType": "application/spdx+json", "digest": digest(&layer), "size": layer.len()}],
        "subject": {"mediaType": MANIFEST, "digest": subject, "size": subject_len},
    }))
    .unwrap();
    (manifest, vec![empty, layer])
}

#[test]
fn a_signature_pushed_before_the_tag_rides_in_the_layout_and_an_attachment_after_is_served_once_available(
) {
    let r = chained();
    let img = demo_image("signed");
    // The image by digest first (staged), its `.sig` tag, then the tag that takes it in.
    push_blobs(&r, "ida", "demo/signed", &img.blobs());
    let (s, h, _) = push_manifest(&r, "ida", "demo/signed", &img.digest, &img.manifest);
    assert_eq!(s, 201);
    assert_eq!(
        header(&h, "x-holger-intake-state"),
        None,
        "a manifest by digest is only staged"
    );
    let sig_tag = format!("sha256-{}.sig", img.digest.trim_start_matches("sha256:"));
    let (sig, sig_blobs) = sig_for(&img.digest);
    push_blobs(&r, "ida", "demo/signed", &sig_blobs);
    assert_eq!(
        push_manifest(&r, "ida", "demo/signed", &sig_tag, &sig).0,
        201
    );
    assert_eq!(
        get(&r, &format!("/v2/oci/demo/signed/manifests/{sig_tag}")).0,
        404,
        "not served before its image is AVAILABLE"
    );
    let (s, h, _) = push_manifest(&r, "ida", "demo/signed", "v1", &img.manifest);
    assert_eq!(s, 201);
    assert_eq!(
        header(&h, "x-holger-intake-state").as_deref(),
        Some("policy_checked")
    );
    let chain = r.chain();
    let id = layout_in(&chain, "oci/demo/signed/", ArtifactState::PolicyChecked);
    // The signature rode in: the layout holds it.
    let layout =
        holger_server_lib::pull_intake::oci::Layout::read(&chain.incoming_artifact(&id).unwrap())
            .unwrap();
    assert_eq!(
        layout.attached_tags.get(&sig_tag).map(String::as_str),
        Some(digest(&sig).as_str()),
        "the .sig rides in the layout"
    );
    approve(&chain, &id, &["bob", "carol"]);
    let (s, _, b) = get(&r, &format!("/v2/oci/demo/signed/manifests/{sig_tag}"));
    assert_eq!(s, 200, "{}", text(&b));
    assert_eq!(b, sig);

    // An SBOM attached after it is AVAILABLE is served beside it.
    let (refm, ref_blobs) = referrer_for(&img.digest, img.manifest.len());
    push_blobs(&r, "ida", "demo/signed", &ref_blobs);
    assert_eq!(
        push_manifest(&r, "ida", "demo/signed", &digest(&refm), &refm).0,
        201
    );
    let (s, _, idx) = get(&r, &format!("/v2/oci/demo/signed/referrers/{}", img.digest));
    assert_eq!(s, 200);
    assert!(
        text(&idx).contains(&digest(&refm)),
        "the later attachment is a referrer: {}",
        text(&idx)
    );
    assert_eq!(
        get(
            &r,
            &format!("/v2/oci/demo/signed/manifests/{}", digest(&refm))
        )
        .2,
        refm
    );
    assert_eq!(
        get(
            &r,
            &format!("/v2/oci/demo/signed/blobs/{}", digest(&ref_blobs[1]))
        )
        .2,
        ref_blobs[1]
    );

    // An attachment to an image that is not AVAILABLE is not.
    let other = demo_image("pending");
    assert_eq!(push(&r, "ida", "demo/pending", "1", &other).0, 201);
    let (refm, ref_blobs) = referrer_for(&other.digest, other.manifest.len());
    push_blobs(&r, "ida", "demo/pending", &ref_blobs);
    assert_eq!(
        push_manifest(&r, "ida", "demo/pending", &digest(&refm), &refm).0,
        201
    );
    let (_, _, idx) = get(
        &r,
        &format!("/v2/oci/demo/pending/referrers/{}", other.digest),
    );
    assert!(!text(&idx).contains(&digest(&refm)), "{}", text(&idx));
    assert_eq!(
        get(
            &r,
            &format!("/v2/oci/demo/pending/manifests/{}", digest(&refm))
        )
        .0,
        404
    );
}

#[test]
fn an_opt_out_serves_pushes_unscanned_and_is_refused_where_the_policy_requires_a_scan() {
    let r = start(
        &Boot {
            oci_repo: true,
            intake_extra: r#"oci_push: [(repository: "oci", scan: off)],"#.into(),
            ..Default::default()
        },
        None,
    );
    let img = demo_image("scratch");
    let (s, h, _) = push(&r, "adm", "demo/scratch", "1", &img);
    assert_eq!(s, 201);
    assert_eq!(header(&h, "x-holger-intake-state"), None, "{h}");
    assert_eq!(
        get(&r, "/v2/oci/demo/scratch/manifests/1").2,
        img.manifest,
        "served as pushed"
    );
    assert!(
        r.chain()
            .all()
            .iter()
            .all(|(id, _)| !id.name.starts_with("oci/")),
        "nothing went into the chain"
    );

    let boot = Boot {
        oci_repo: true,
        scan: Some("Some((required: [sbom]))".into()),
        intake_extra: r#"oci_push: [(repository: "oci", scan: off)],"#.into(),
        ..Default::default()
    };
    let e = try_start(
        traits::test_support::hold_port(),
        traits::test_support::hold_port(),
        &boot,
        None,
    )
    .err()
    .expect("refused at boot");
    assert!(
        e.contains("[holger-oci-1]") && e.contains("required"),
        "{e}"
    );
    let boot = Boot {
        oci_repo: true,
        intake_extra: r#"oci_push: [(repository: "nope", scan: off)],"#.into(),
        ..Default::default()
    };
    let e = try_start(
        traits::test_support::hold_port(),
        traits::test_support::hold_port(),
        &boot,
        None,
    )
    .err()
    .expect("refused at boot");
    assert!(e.contains("[holger-oci-2]"), "{e}");
}

/// **A path that is not canonical never reaches past the scanned registry** (SEC-REVIEW
/// 2026-11-01 P1). The router dropped empty segments and the scanned view did not: `/v2//oci/…`
/// keyed the `oci` repository, the view's `strip_prefix("/v2/oci/")` missed, and the request
/// fell through to the raw backend. A manifest PUT so stored a tag without the intake chain,
/// and a GET served staged (or BLOCKED) bytes. Every path with an empty, `.` or `..` segment
/// is refused with 400 before any door sees it.
#[test]
fn a_double_slash_or_dot_segment_never_reaches_past_the_scanned_registry() {
    let r = chained();
    let img = demo_image("double-slash");
    push_blobs(&r, "ida", "demo/ds", &img.blobs());
    let ida = bearer("ida");

    // The write: a tag stored around the chain.
    let (s, _, b) = request(
        r.http,
        "PUT",
        "/v2//oci/demo/ds/manifests/evil",
        &[("Authorization", &ida), ("Content-Type", MANIFEST)],
        &img.manifest,
    );
    assert_eq!(
        s,
        400,
        "a double-slash manifest PUT is refused: {}",
        text(&b)
    );
    assert!(text(&b).contains("holger-server-14"), "{}", text(&b));

    // The image goes in by the front door and is staged, not AVAILABLE.
    let (s, _, b) = push_manifest(&r, "ida", "demo/ds", "1", &img.manifest);
    assert_eq!(s, 201, "{}", text(&b));

    let blob = digest(&img.layers[0]);
    for path in [
        "/v2//oci/demo/ds/manifests/1".to_string(),
        "/v2//oci/demo/ds/manifests/evil".to_string(),
        format!("/v2//oci/demo/ds/blobs/{blob}"),
        format!("/v2//oci/demo/ds/manifests/{}", img.digest),
        "/v2/./oci/demo/ds/manifests/1".to_string(),
        "/v2/oci/demo/../demo/ds/manifests/1".to_string(),
        format!("/v2/oci//demo/ds/blobs/{blob}"),
        "/v2/%2e/oci/demo/ds/manifests/1".to_string(),
    ] {
        let (s, _, b) = get(&r, &path);
        assert_eq!(s, 400, "{path} is refused, never served: {}", text(&b));
    }
    // The canonical path still holds it back, and the base endpoint still answers.
    assert_eq!(get(&r, "/v2/oci/demo/ds/manifests/1").0, 404);
    assert_eq!(get(&r, "/v2/oci/demo/ds/manifests/evil").0, 404);
    assert_ne!(get(&r, "/v2/").0, 400, "a trailing slash is canonical");
}

/// **A runnable image that names a `subject` is no attachment** (SEC-REVIEW 2026-11-01 P7).
/// Any manifest with `subject` was an "attachment": staged unscanned and served beside its
/// AVAILABLE subject, so an Importer pushed a whole image with `subject = <a clean digest>`
/// and `docker pull` got it unscanned. An attachment is only an inert artifact (no image
/// layer, bounded size); anything else walks the chain like every image.
#[test]
fn a_runnable_image_naming_a_subject_is_scanned_not_attached() {
    let r = chained();
    let clean = demo_image("clean-subject");
    assert_eq!(push(&r, "ida", "demo/subj", "1", &clean).0, 201);
    let chain = r.chain();
    let id = layout_in(&chain, "oci/demo/subj/", ArtifactState::PolicyChecked);
    approve(&chain, &id, &["bob", "carol"]);
    assert_eq!(get(&r, "/v2/oci/demo/subj/manifests/1").0, 200);

    let evil = demo_image("evil-subject");
    let mut v: serde_json::Value = serde_json::from_slice(&evil.manifest).unwrap();
    v["subject"] = serde_json::json!({
        "mediaType": MANIFEST, "digest": clean.digest, "size": clean.manifest.len()
    });
    let m = serde_json::to_vec(&v).unwrap();
    push_blobs(&r, "ida", "demo/subj", &evil.blobs());
    assert_eq!(
        push_manifest(&r, "ida", "demo/subj", &digest(&m), &m).0,
        201
    );
    assert_ne!(
        get(&r, &format!("/v2/oci/demo/subj/manifests/{}", digest(&m))).0,
        200,
        "a runnable image is never served as an attachment"
    );
    // The layers the clean image does not share (the first layer is the same bytes in both).
    for layer in evil.layers.iter().filter(|l| !clean.layers.contains(l)) {
        assert_ne!(
            get(&r, &format!("/v2/oci/demo/subj/blobs/{}", digest(layer))).0,
            200,
            "its layers are never served unscanned"
        );
    }
    let (s, h, b) = push_manifest(&r, "ida", "demo/subj", "evil", &m);
    assert_eq!(s, 201, "{}", text(&b));
    assert!(
        header(&h, "x-holger-intake-state").is_some(),
        "a tag takes it into the chain like any image: {h}"
    );
    assert_ne!(get(&r, "/v2/oci/demo/subj/manifests/evil").0, 200);
}