gha-github-service-proof 1.0.1

GitHub Actions service/API compatibility checker for offline CI: GITHUB_TOKEN permissions, REST/GraphQL/gh CLI calls, OIDC, checks/statuses, releases, and issues
Documentation
use anyhow::{Context, Result, bail};
use camino::Utf8PathBuf;
use clap::{Args, Parser, Subcommand};
use gha_github_service_proof::{
    CallOptions, CheckWorkflowOptions, GhLogOptions, OidcOptions, OutputFormat, PermissionSet,
    PermissionsOptions, check_workflows, classify_call, issue_oidc, permissions,
    permissions_command, render_receipt, replay_gh_log,
};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs;
use std::process::ExitCode;

#[derive(Debug, Parser)]
#[command(
    version,
    about = "Check GitHub Actions service/API compatibility with offline receipts"
)]
struct Cli {
    #[command(subcommand)]
    command: Command,

    #[arg(long, global = true, value_enum, default_value = "text")]
    format: OutputFormat,

    #[arg(long, global = true, value_name = "PATH")]
    output: Option<Utf8PathBuf>,

    #[arg(long, global = true)]
    strict: bool,
}

#[derive(Debug, Subcommand)]
enum Command {
    CheckWorkflow(CheckWorkflowArgs),
    Permissions(PermissionsArgs),
    Call(CallArgs),
    Oidc(OidcArgs),
    GhLog(GhLogArgs),
}

#[derive(Debug, Args)]
struct CheckWorkflowArgs {
    #[arg(long, value_name = "DIR", default_value = ".")]
    repo: Utf8PathBuf,

    #[arg(long, value_name = "PATH")]
    workflow: Vec<Utf8PathBuf>,
}

#[derive(Debug, Args)]
struct PermissionsArgs {
    #[arg(long, value_name = "PATH")]
    workflow: Utf8PathBuf,

    #[arg(long, value_name = "JOB")]
    job: Option<String>,
}

#[derive(Debug, Args)]
struct CallArgs {
    #[arg(long, value_name = "METHOD")]
    method: String,

    #[arg(long, value_name = "PATH")]
    path: String,

    #[arg(long, value_name = "URL")]
    url: Option<String>,

    #[arg(long, value_name = "JSON")]
    permissions: Option<String>,

    #[arg(long, value_name = "PATH")]
    permissions_file: Option<Utf8PathBuf>,

    #[arg(long, value_name = "ORIGIN")]
    origin: Option<String>,
}

#[derive(Debug, Args)]
struct OidcArgs {
    #[arg(long, value_name = "AUDIENCE")]
    audience: String,

    #[arg(long, value_name = "JSON")]
    permissions: Option<String>,

    #[arg(long, value_name = "PATH")]
    permissions_file: Option<Utf8PathBuf>,

    #[arg(long, value_name = "OWNER/REPO", default_value = "wildmason/oss")]
    repository: String,

    #[arg(long = "ref", value_name = "REF", default_value = "refs/heads/main")]
    git_ref: String,

    #[arg(
        long,
        value_name = "SHA",
        default_value = "0000000000000000000000000000000000000000"
    )]
    sha: String,

    #[arg(long, value_name = "WORKFLOW", default_value = "release.yml")]
    workflow: String,

    #[arg(long, value_name = "JOB", default_value = "deploy")]
    job: String,

    #[arg(long = "run-id", value_name = "RUN-ID", default_value = "1")]
    run_id: String,

    #[arg(long = "job-workflow-ref", value_name = "REF")]
    job_workflow_ref: Option<String>,

    #[arg(long = "ttl-seconds", value_name = "SECONDS")]
    ttl_seconds: Option<i64>,

    #[arg(long = "claim", value_name = "NAME=JSON")]
    extra_claims: Vec<String>,
}

#[derive(Debug, Args)]
struct GhLogArgs {
    #[arg(long, value_name = "PATH")]
    log: Utf8PathBuf,

    #[arg(long, value_name = "JSON")]
    permissions: Option<String>,

    #[arg(long, value_name = "PATH")]
    permissions_file: Option<Utf8PathBuf>,

    #[arg(long = "unsafe-full-payloads")]
    unsafe_full_payloads: bool,
}

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("error: {error:#}");
            ExitCode::FAILURE
        }
    }
}

fn run() -> Result<()> {
    let cli = Cli::parse();
    let receipt = match &cli.command {
        Command::CheckWorkflow(args) => {
            if !args.repo.is_dir() {
                bail!("--repo must be an existing directory: {}", args.repo);
            }
            check_workflows(&CheckWorkflowOptions {
                repo_root: args.repo.clone(),
                workflows: args.workflow.clone(),
            })?
        }
        Command::Permissions(args) => permissions_command(&PermissionsOptions {
            workflow_path: args.workflow.clone(),
            job: args.job.clone(),
        })?,
        Command::Call(args) => {
            let permissions = load_permissions(
                args.permissions.as_deref(),
                args.permissions_file.as_deref(),
            )?;
            classify_call(&CallOptions {
                method: args.method.clone(),
                path: args.path.clone(),
                url: args.url.clone(),
                permissions,
                origin: args.origin.clone(),
            })?
        }
        Command::Oidc(args) => {
            let permissions = load_permissions(
                args.permissions.as_deref(),
                args.permissions_file.as_deref(),
            )?;
            let extra_claims = parse_extra_claims(&args.extra_claims)?;
            issue_oidc(&OidcOptions {
                audience: args.audience.clone(),
                repository: args.repository.clone(),
                git_ref: args.git_ref.clone(),
                sha: args.sha.clone(),
                workflow: args.workflow.clone(),
                job: args.job.clone(),
                run_id: args.run_id.clone(),
                job_workflow_ref: args.job_workflow_ref.clone(),
                permissions,
                ttl_seconds: args.ttl_seconds,
                extra_claims,
            })?
        }
        Command::GhLog(args) => {
            let permissions = load_permissions(
                args.permissions.as_deref(),
                args.permissions_file.as_deref(),
            )?;
            replay_gh_log(&GhLogOptions {
                log_path: args.log.clone(),
                permissions,
                unsafe_full_payloads: args.unsafe_full_payloads,
            })?
        }
    };

    let rendered = render_receipt(&receipt, cli.format)?;
    if let Some(output) = cli.output {
        if let Some(parent) = output.parent() {
            fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
        }
        fs::write(&output, rendered).with_context(|| format!("writing {output}"))?;
    } else {
        print!("{rendered}");
    }

    if receipt.summary.failed > 0 || (cli.strict && receipt.summary.warnings > 0) {
        bail!("github service proof failed");
    }

    Ok(())
}

fn load_permissions(
    inline: Option<&str>,
    file: Option<&camino::Utf8Path>,
) -> Result<Option<PermissionSet>> {
    if let Some(path) = file {
        let raw =
            fs::read_to_string(path).with_context(|| format!("reading permissions file {path}"))?;
        let value: Value = serde_json::from_str(&raw)
            .with_context(|| format!("parsing permissions file {path}"))?;
        return Ok(Some(permissions::parse_json_permissions(&value)?));
    }
    if let Some(text) = inline {
        let trimmed = text.trim();
        let value: Value = match serde_json::from_str(trimmed) {
            Ok(value) => value,
            Err(_) => Value::String(trimmed.to_owned()),
        };
        return Ok(Some(permissions::parse_json_permissions(&value)?));
    }
    Ok(None)
}

fn parse_extra_claims(entries: &[String]) -> Result<BTreeMap<String, Value>> {
    let mut claims = BTreeMap::new();
    for entry in entries {
        let Some((name, value_text)) = entry.split_once('=') else {
            bail!("--claim entries must be NAME=JSON, got '{entry}'");
        };
        let value: Value = serde_json::from_str(value_text.trim())
            .with_context(|| format!("parsing JSON for claim '{name}'"))?;
        claims.insert(name.trim().to_owned(), value);
    }
    Ok(claims)
}