zizmor 1.24.1

Static analysis for GitHub Actions
use github_actions_expressions::Expr;
use subfeature::Subfeature;

use crate::{
    audit::{Audit, AuditError, AuditLoadError, audit_meta},
    config::Config,
    finding::{
        Confidence, Finding, Persona, Severity,
        location::{Feature, Locatable, Location},
    },
    models::workflow::{JobCommon as _, NormalJob},
    state::AuditState,
    utils::{once::warn_once, parse_fenced_expressions_from_routable},
};

pub(crate) struct SecretsOutsideEnvironment;

audit_meta!(
    SecretsOutsideEnvironment,
    "secrets-outside-env",
    "secrets referenced without a dedicated environment"
);

#[async_trait::async_trait]
impl Audit for SecretsOutsideEnvironment {
    fn new(_state: &AuditState) -> Result<Self, AuditLoadError>
    where
        Self: Sized,
    {
        Ok(Self)
    }

    async fn audit_normal_job<'doc>(
        &self,
        job: &NormalJob<'doc>,
        config: &Config,
    ) -> Result<Vec<Finding<'doc>>, AuditError> {
        if job.parent().has_workflow_call() {
            // Reusable workflows and environments don't interact well, and are more or less
            // completely undocumented in terms of behavior. We don't flag any findings
            // for them, since users will discover that a reusable workflow that activates
            // an environment can't actually use that environment's secrets unless the
            // caller workflow passes `secrets: inherit`, which violates our `secrets-inherit`
            // audit.
            return Ok(vec![]);
        }

        if job.environment.is_some() {
            // If the job has an environment, then we assume that any secrets
            // used in the job are scoped to that environment.
            // This is not strictly true, since secrets that don't exist in
            // the environment will fall back to repository/org secrets, but
            // we don't currently has a low-privilege way of checking for that.
            // Consequently, we have a higher false-negative rate than is ideal here.
            return Ok(vec![]);
        }

        // Get every expression in the job's body, and look for accesses of the `secrets` context.
        // NOTE: In principle this is incomplete, since there are some places (like `if:`) where
        // GitHub Actions doesn't require fencing on expressions. In practice however GitHub Actions
        // doesn't allow users to reference secrets in `if:` clauses.
        let mut findings = vec![];
        for (expr, span) in parse_fenced_expressions_from_routable(job) {
            let Ok(parsed) = Expr::parse(expr.as_bare()) else {
                warn_once!("couldn't parse expression: {expr}", expr = expr.as_bare());
                continue;
            };

            for (context, origin) in parsed.contexts() {
                if !context.child_of("secrets") {
                    continue;
                }

                // Check to see whether we allow this secret. The policy always includes
                // GITHUB_TOKEN, since it's always latently available.
                if let Some(secret_name) = context.single_tail()
                    && config
                        .secrets_outside_env_policy
                        .allow
                        .contains(&secret_name.to_ascii_lowercase())
                {
                    continue;
                }

                let after = span.start + origin.span.start;
                let subfeature = Subfeature::new(after, origin.raw);

                findings.push(
                    Self::finding()
                        .persona(Persona::Auditor)
                        .severity(Severity::Medium)
                        .confidence(Confidence::High)
                        .add_location(job.location().key_only())
                        .add_raw_location(Location::new(
                            job.location()
                                .primary()
                                .annotated("secret is accessed outside of a dedicated environment"),
                            Feature::from_subfeature(&subfeature, job),
                        ))
                        .build(job)?,
                );
            }
        }

        Ok(findings)
    }
}