Skip to content

feat(trust): Support GitLab ID tokens for attestation#169

Draft
erran wants to merge 1 commit intoalways-further:mainfrom
erran:erran/support-gitlab-oidc-signing-identity
Draft

feat(trust): Support GitLab ID tokens for attestation#169
erran wants to merge 1 commit intoalways-further:mainfrom
erran:erran/support-gitlab-oidc-signing-identity

Conversation

@erran
Copy link

@erran erran commented Feb 26, 2026

What does this PR change?

Adds support for:

  1. nono trust sign inside of GitLab CI/CD.
  2. nono trust verification with GitLab as a publisher and a CI/CD pipeline as a “workflow”

Why?

Currently GitHub is the assumed publisher despite being able to lookup ambient credentials for keyless signing. It’d be a great to leverage nono for agent instruction attestation in GitLab managed environments too.

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @erran, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces comprehensive support for GitLab CI/CD environments within the nono trust system. It enables keyless signing and verification of artifacts using GitLab ID tokens, allowing users to leverage nono for attestation in both standard and self-managed GitLab instances.

Highlights

  • GitLab CI/CD Keyless Signing: Implemented the ability to perform keyless signing directly within GitLab CI/CD pipelines by discovering OIDC tokens from the ambient environment.
  • GitLab Publisher Verification: Added logic to verify nono trust with GitLab as a publisher, supporting both gitlab.com and self-managed instances.
  • Dynamic Predicate Generation: Introduced a new function to dynamically build keyless signer predicates tailored for GitLab CI environments, prioritizing them over existing GitHub predicates when GITLAB_CI is detected.
  • Enhanced Identity Formatting: Updated the display format for keyless signer identities to correctly represent GitLab CI workflow paths, improving readability and clarity.
  • Comprehensive Test Coverage: Expanded unit tests to cover various scenarios for GitLab keyless predicate generation, identity formatting, and policy evaluation, ensuring robust functionality.
Changelog
  • crates/nono-cli/src/trust_cmd.rs
    • Updated OIDC token discovery comment to include GitLab CI.
    • Added gitlab_keyless_predicate function to construct GitLab-specific signer predicates based on environment variables.
    • Modified build_keyless_predicate to check for GitLab CI environment and use the GitLab predicate if present.
    • Adjusted format_identity to specifically handle and display GitLab CI workflow paths.
    • Included new unit tests for format_identity with GitLab workflows and build_keyless_predicate for GitLab scenarios, including custom ports and precedence.
  • crates/nono-cli/src/trust_scan.rs
    • Modified format_identity to correctly display GitLab CI workflow paths.
  • crates/nono/src/trust/policy.rs
    • Added new test cases (evaluate_trusted_gitlab_keyless, evaluate_trusted_gitlab_self_managed_keyless, evaluate_untrusted_gitlab_wrong_project) to validate policy evaluation for trusted and untrusted GitLab keyless identities.
  • crates/nono/src/trust/types.rs
    • Updated the documentation for the workflow field in SignerIdentity to explicitly mention GitLab CI config path references.
    • Added new test cases (publisher_matches_gitlab_keyless, publisher_matches_gitlab_self_managed_keyless, publisher_no_match_gitlab_wrong_issuer, publisher_matches_gitlab_tag_ref) to ensure correct matching of GitLab keyless identities against defined publishers.
Activity
  • No activity has occurred on this pull request yet.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds valuable support for GitLab CI/CD environments for keyless signing and verification, which is a great enhancement. My review focuses on improving the robustness and maintainability of this new functionality. I've identified a critical issue where missing environment variables could lead to silently generating incorrect attestations, a high-severity race condition in the new tests, and a medium-severity code duplication that should be refactored. Addressing these points will make the GitLab integration more reliable and secure.

Comment on lines +421 to +457
fn gitlab_keyless_predicate() -> Option<serde_json::Value> {
if std::env::var("GITLAB_CI").as_deref() != Ok("true") {
return None;
}

let host = std::env::var("CI_SERVER_HOST").unwrap_or_else(|_| "gitlab.com".to_string());
let port = std::env::var("CI_SERVER_PORT").unwrap_or_else(|_| "443".to_string());
let project_path = std::env::var("CI_PROJECT_PATH").unwrap_or_default();

let host_authority = if port == "443" {
host.clone()
} else {
format!("{host}:{port}")
};

let git_ref = match std::env::var("CI_COMMIT_TAG") {
Ok(tag) if !tag.is_empty() => format!("refs/tags/{tag}"),
_ => format!(
"refs/heads/{}",
std::env::var("CI_COMMIT_REF_NAME").unwrap_or_default()
),
};

let workflow = format!("{host_authority}/{project_path}//.gitlab-ci.yml@{git_ref}");

Some(serde_json::json!({
"version": 1,
"signer": {
"kind": "keyless",
"oidc_issuer": std::env::var("CI_SERVER_URL")
.unwrap_or_else(|_| "https://gitlab.com".to_string()),
"repository": project_path,
"workflow": workflow,
"ref": git_ref
}
}))
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Using unwrap_or_else and unwrap_or_default for required GitLab CI environment variables can lead to silently generating incorrect attestations if those variables are not present. For example, if CI_PROJECT_PATH is missing, it defaults to an empty string, resulting in a malformed workflow URL. This is particularly risky for self-hosted GitLab instances where the defaults for gitlab.com would be incorrect.

To make this more robust, this function should return a Result and fail explicitly if a required environment variable is missing. This ensures that you either generate a correct predicate or fail fast, preventing bad signatures.

This will require updating build_keyless_predicate to return a Result<serde_json::Value> and its callers (sign_file_keyless and run_sign_multi_keyless) to handle the result using ?.

fn gitlab_keyless_predicate() -> Result<Option<serde_json::Value>> {
    if std::env::var("GITLAB_CI").as_deref() != Ok("true") {
        return Ok(None);
    }

    fn get_env(key: &str) -> Result<String> {
        std::env::var(key).map_err(|_| nono::NonoError::TrustSigning {
            path: String::new(),
            reason: format!("missing required GitLab CI environment variable: {key}"),
        })
    }

    let host = get_env("CI_SERVER_HOST")?;
    let port = get_env("CI_SERVER_PORT")?;
    let project_path = get_env("CI_PROJECT_PATH")?;
    let oidc_issuer = get_env("CI_SERVER_URL")?;

    let host_authority = if port == "443" {
        host
    } else {
        format!("{host}:{port}")
    };

    let git_ref = match std::env::var("CI_COMMIT_TAG") {
        Ok(tag) if !tag.is_empty() => format!("refs/tags/{tag}"),
        _ => format!(
            "refs/heads/{}",
            get_env("CI_COMMIT_REF_NAME")?
        ),
    };

    let workflow = format!("{host_authority}/{project_path}//.gitlab-ci.yml@{git_ref}");

    Ok(Some(serde_json::json!({
        "version": 1,
        "signer": {
            "kind": "keyless",
            "oidc_issuer": oidc_issuer,
            "repository": project_path,
            "workflow": workflow,
            "ref": git_ref
        }
    })))
}

Comment on lines +1207 to +1272
fn build_keyless_predicate_defaults_to_github() {
std::env::remove_var("GITLAB_CI");
let predicate = build_keyless_predicate();
let signer = &predicate["signer"];
assert_eq!(signer["kind"], "keyless");
assert!(signer.get("oidc_issuer").is_some());
assert!(signer.get("repository").is_some());
assert!(signer.get("workflow").is_some());
assert!(signer.get("ref").is_some());
}

#[test]
fn build_keyless_predicate_gitlab() {
std::env::set_var("GITLAB_CI", "true");
std::env::set_var("CI_SERVER_HOST", "gitlab.com");
std::env::set_var("CI_SERVER_PORT", "443");
std::env::set_var("CI_PROJECT_PATH", "my-group/my-project");
std::env::set_var("CI_COMMIT_REF_NAME", "main");
std::env::remove_var("CI_COMMIT_TAG");
let predicate = build_keyless_predicate();
let signer = &predicate["signer"];
assert_eq!(signer["kind"], "keyless");
assert_eq!(signer["repository"], "my-group/my-project");
assert_eq!(
signer["workflow"],
"gitlab.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main"
);
assert_eq!(signer["ref"], "refs/heads/main");
std::env::remove_var("GITLAB_CI");
std::env::remove_var("CI_SERVER_HOST");
std::env::remove_var("CI_SERVER_PORT");
std::env::remove_var("CI_PROJECT_PATH");
std::env::remove_var("CI_COMMIT_REF_NAME");
}

#[test]
fn build_keyless_predicate_gitlab_custom_port() {
std::env::set_var("GITLAB_CI", "true");
std::env::set_var("CI_SERVER_HOST", "gitlab.example.com");
std::env::set_var("CI_SERVER_PORT", "8443");
std::env::set_var("CI_PROJECT_PATH", "team/app");
std::env::set_var("CI_COMMIT_REF_NAME", "develop");
std::env::remove_var("CI_COMMIT_TAG");
let predicate = build_keyless_predicate();
let signer = &predicate["signer"];
assert_eq!(
signer["workflow"],
"gitlab.example.com:8443/team/app//.gitlab-ci.yml@refs/heads/develop"
);
std::env::remove_var("GITLAB_CI");
std::env::remove_var("CI_SERVER_HOST");
std::env::remove_var("CI_SERVER_PORT");
std::env::remove_var("CI_PROJECT_PATH");
std::env::remove_var("CI_COMMIT_REF_NAME");
}

#[test]
fn build_keyless_predicate_gitlab_takes_precedence() {
std::env::set_var("GITLAB_CI", "true");
std::env::set_var("CI_SERVER_URL", "https://gitlab.example.com");
let predicate = build_keyless_predicate();
let signer = &predicate["signer"];
assert_eq!(signer["oidc_issuer"], "https://gitlab.example.com");
std::env::remove_var("GITLAB_CI");
std::env::remove_var("CI_SERVER_URL");
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Tests that modify environment variables (std::env::set_var, std::env::remove_var) are modifying a global, shared resource. Since Rust tests run in parallel by default, this can cause race conditions between tests, leading to flaky and non-deterministic failures that are hard to debug.

To fix this, you should ensure these tests are serialized by using a std::sync::Mutex. You can create a static mutex and lock it at the beginning of each test that modifies the environment.

Here's a helper function you can add to your tests module:

use std::sync::{Mutex, OnceLock};

fn env_mutex() -> &'static Mutex<()> {
    static MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
    MUTEX.get_or_init(|| Mutex::new(()))
}

Then, at the start of each test that modifies environment variables, add let _guard = env_mutex().lock().unwrap();. This should be applied to build_keyless_predicate_defaults_to_github, build_keyless_predicate_gitlab, build_keyless_predicate_gitlab_custom_port, and build_keyless_predicate_gitlab_takes_precedence.

@lukehinds
Copy link
Collaborator

awesome @erran , I had planned to do this , so grateful you're taking this on. Let me know of any thoughts you have on the current setup. I largely based it off how sigstore-python works.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants