diff --git a/Makefile b/Makefile index 6e14fc03..6d7ea8ff 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,10 @@ codeql-injection-sinks: crates/zizmor/data/codeql-injection-sinks.json crates/zizmor/data/codeql-injection-sinks.json: support/codeql-injection-sinks.py $< > $@ +.PHONY: archived-repos +archived-repos: + support/archived-repos.py + .PHONY: pinact pinact: pinact run --update --verify diff --git a/crates/zizmor/build.rs b/crates/zizmor/build.rs index 265240a1..884b652f 100644 --- a/crates/zizmor/build.rs +++ b/crates/zizmor/build.rs @@ -4,7 +4,7 @@ use std::fs::{self, File}; use std::path::Path; use std::{env, io}; -use fst::MapBuilder; +use fst::{MapBuilder, SetBuilder}; fn do_context_capabilities() { let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); @@ -55,7 +55,29 @@ fn do_codeql_injection_sinks() { fs::copy(source, target).unwrap(); } +fn do_archived_action_repos() { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let source = Path::new(&manifest_dir).join("data/archived-repos.txt"); + let target = Path::new(&env::var("OUT_DIR").unwrap()).join("archived-repos.fst"); + + print!( + "cargo::rerun-if-changed={source}", + source = source.display() + ); + + let out = io::BufWriter::new(File::create(target).unwrap()); + let mut build = SetBuilder::new(out).unwrap(); + + let contents = fs::read_to_string(source).unwrap(); + for line in contents.lines() { + build.insert(line).unwrap(); + } + + build.finish().unwrap(); +} + fn main() { do_context_capabilities(); do_codeql_injection_sinks(); + do_archived_action_repos(); } diff --git a/crates/zizmor/data/archived-repos.txt b/crates/zizmor/data/archived-repos.txt new file mode 100644 index 00000000..2aeb1ab8 --- /dev/null +++ b/crates/zizmor/data/archived-repos.txt @@ -0,0 +1,87 @@ +8398a7/action-slack +actions-rs/audit-check +actions-rs/cargo +actions-rs/clippy-check +actions-rs/components-nightly +actions-rs/grcov +actions-rs/install +actions-rs/tarpaulin +actions-rs/toolchain +actions/create-release +actions/setup-elixir +actions/setup-haskell +actions/setup-ruby +actions/upload-release-asset +andrewmcodes-archive/rubocop-linter-action +artichoke/setup-rust +aslafy-z/conventional-pr-title-action +azure/appconfiguration-sync +azure/appservice-actions +azure/azure-resource-login-action +azure/container-actions +azure/container-scan +azure/data-factory-deploy-action +azure/data-factory-export-action +azure/data-factory-validate-action +azure/get-keyvault-secrets +azure/k8s-actions +azure/manage-azure-policy +azure/publish-security-assessments +azure/run-sqlpackage-action +azure/spring-cloud-deploy +azure/webapps-container-deploy +cedrickring/golang-action +cirrus-actions/rebase +crazy-max/ghaction-docker-buildx +decathlon/pull-request-labeler-action +delaguardo/setup-graalvm +dulvui/godot-android-export +expo/expo-preview-action +fabasoad/setup-zizmor-action +facebook/pysa-action +fregante/release-with-changelog +google/mirror-branch-action +google/skywater-pdk-actions +gradle/gradle-build-action +grafana/k6-action +helaili/github-graphql-action +helaili/jekyll-action +ilshidur/action-slack +jakejarvis/backblaze-b2-action +jakejarvis/cloudflare-purge-action +jakejarvis/firebase-deploy-action +jakejarvis/hugo-build-action +jakejarvis/lighthouse-action +jakejarvis/s3-sync-action +justinribeiro/lighthouse-action +kanadgupta/glitch-sync +kxxt/chatgpt-action +machine-learning-apps/wandb-action +mansagroup/gcs-cache-action +marvinpinto/action-automatic-releases +marvinpinto/actions +maxheld83/ghpages +micnncim/action-lgtm-reaction +mikepenz/gradle-dependency-submission +orf/cargo-bloat-action +paambaati/codeclimate-action +primer/figma-action +repo-sync/pull-request +repo-sync/repo-sync +sagebind/docker-swarm-deploy-action +scottbrenner/generate-changelog-action +secrethub/actions +semgrep/semgrep-action +shaunlwm/action-release-debugapk +sonarsource/sonarcloud-github-action +stefanprodan/kube-tools +swiftdocorg/github-wiki-publish-action +tachiyomiorg/issue-moderator-action +technote-space/auto-cancel-redundant-workflow +technote-space/get-diff-action +tencentcloudbase/cloudbase-action +trmcnvn/chrome-addon +whelk-io/maven-settings-xml-action +yeslayla/build-godot-action +youyo/aws-cdk-github-actions +z0al/dependent-issues diff --git a/crates/zizmor/src/audit/archived_uses.rs b/crates/zizmor/src/audit/archived_uses.rs new file mode 100644 index 00000000..df59c2ef --- /dev/null +++ b/crates/zizmor/src/audit/archived_uses.rs @@ -0,0 +1,138 @@ +use std::sync::LazyLock; + +use fst::Set; +use github_actions_models::common::{RepositoryUses, Uses}; +use subfeature::Subfeature; + +use crate::{ + audit::{Audit, AuditError, AuditLoadError, audit_meta}, + config::Config, + finding::{Confidence, Finding, FindingBuilder, Persona, Severity, location::Locatable}, + models::{ + StepCommon as _, + action::CompositeStep, + workflow::{ReusableWorkflowCallJob, Step}, + }, + state::AuditState, +}; + +static ARCHIVED_REPOS_FST: LazyLock> = LazyLock::new(|| { + fst::Set::new(include_bytes!(concat!(env!("OUT_DIR"), "/archived-repos.fst")).as_slice()) + .expect("couldn't initialize archived repos FST") +}); + +pub(crate) struct ArchivedUses; + +audit_meta!( + ArchivedUses, + "archived-uses", + "action or reusable workflow from archived repository" +); + +impl ArchivedUses { + pub(crate) fn uses_is_archived<'doc>(uses: &RepositoryUses) -> Option> { + // TODO: Annoying that we need to allocate for case normalization here; can we use an + // automaton to search the FST case-insensitively? + let normalized = format!( + "{owner}/{repo}", + owner = uses.owner.to_lowercase(), + repo = uses.repo.to_lowercase() + ); + + ARCHIVED_REPOS_FST.contains(normalized.as_bytes()).then(|| { + Self::finding() + .confidence(Confidence::High) + .severity(Severity::Medium) + .persona(Persona::Regular) + }) + } +} + +#[async_trait::async_trait] +impl Audit for ArchivedUses { + fn new(_state: &AuditState) -> Result + where + Self: Sized, + { + Ok(Self) + } + + async fn audit_step<'doc>( + &self, + step: &Step<'doc>, + _config: &Config, + ) -> Result>, AuditError> { + let mut findings = vec![]; + + if let Some(Uses::Repository(uses)) = step.uses() + && let Some(finding) = Self::uses_is_archived(uses) + { + findings.push( + finding + .with_step(step) + .add_location( + step.location() + .with_keys(["uses".into()]) + .subfeature(Subfeature::new(uses.owner.len(), uses.repo.as_str())) + .annotated("repository is archived") + .primary(), + ) + .build(step)?, + ) + } + + Ok(findings) + } + + async fn audit_composite_step<'doc>( + &self, + step: &CompositeStep<'doc>, + _config: &Config, + ) -> Result>, AuditError> { + let mut findings = vec![]; + + if let Some(Uses::Repository(uses)) = step.uses() + && let Some(finding) = Self::uses_is_archived(uses) + { + findings.push( + finding + .with_step(step) + .add_location( + step.location() + .with_keys(["uses".into()]) + .annotated("repository is archived") + .primary(), + ) + .build(step)?, + ) + } + + Ok(findings) + } + + async fn audit_reusable_job<'doc>( + &self, + job: &ReusableWorkflowCallJob<'doc>, + _config: &Config, + ) -> Result>, AuditError> { + let mut findings = vec![]; + + if let Uses::Repository(uses) = &job.uses + && let Some(finding) = Self::uses_is_archived(uses) + { + findings.push( + finding + .with_job(job) + .add_location( + job.location() + .with_keys(["uses".into()]) + .annotated("repository is archived") + .primary(), + ) + .build(job)?, + ) + } + + Ok(findings) + } +} diff --git a/crates/zizmor/src/audit/bot_conditions.rs b/crates/zizmor/src/audit/bot_conditions.rs index d3847a17..2609b0d4 100644 --- a/crates/zizmor/src/audit/bot_conditions.rs +++ b/crates/zizmor/src/audit/bot_conditions.rs @@ -15,7 +15,7 @@ use super::{Audit, AuditLoadError, AuditState, audit_meta}; use crate::{ audit::AuditError, finding::{Confidence, Fix, FixDisposition, Severity, location::Locatable as _}, - models::workflow::{JobExt, Workflow}, + models::workflow::{JobCommon, Workflow}, utils::{self, ExtractedExpr}, }; use subfeature::Subfeature; @@ -130,7 +130,7 @@ impl Audit for BotConditions { finding_builder = finding_builder.fix(fix); } - findings.push(finding_builder.build(job.parent())?); + findings.push(finding_builder.build(job)?); } } diff --git a/crates/zizmor/src/audit/cache_poisoning.rs b/crates/zizmor/src/audit/cache_poisoning.rs index 78dbc936..0d5cac44 100644 --- a/crates/zizmor/src/audit/cache_poisoning.rs +++ b/crates/zizmor/src/audit/cache_poisoning.rs @@ -9,7 +9,7 @@ use crate::finding::location::{Locatable as _, Routable}; use crate::finding::{Confidence, Finding, Fix, FixDisposition, Severity}; use crate::models::StepCommon; use crate::models::coordinate::{ActionCoordinate, ControlExpr, ControlFieldType, Toggle, Usage}; -use crate::models::workflow::{JobExt as _, NormalJob, Step, Steps}; +use crate::models::workflow::{JobCommon as _, NormalJob, Step, Steps}; use crate::state::AuditState; use indexmap::IndexMap; @@ -444,7 +444,7 @@ impl CachePoisoning { finding_builder = finding_builder.fix(fix); } - finding_builder.build(step.workflow()).ok() + finding_builder.build(step).ok() } } diff --git a/crates/zizmor/src/audit/github_env.rs b/crates/zizmor/src/audit/github_env.rs index ae0c35a5..ff7dfe04 100644 --- a/crates/zizmor/src/audit/github_env.rs +++ b/crates/zizmor/src/audit/github_env.rs @@ -13,7 +13,7 @@ use crate::config::Config; use crate::finding::location::Locatable as _; use crate::finding::{Confidence, Finding, Severity}; use crate::models::StepCommon; -use crate::models::{workflow::JobExt as _, workflow::Step}; +use crate::models::{workflow::JobCommon as _, workflow::Step}; use crate::state::AuditState; use crate::utils; use crate::utils::once::static_regex; @@ -419,7 +419,7 @@ impl Audit for GitHubEnv { .with_keys(["run".into()]) .annotated(format!("write to {dest} may allow code execution")), ) - .build(step.workflow())?, + .build(step)?, ) } } @@ -463,7 +463,7 @@ impl Audit for GitHubEnv { .with_keys(["run".into()]) .annotated(format!("write to {dest} may allow code execution")), ) - .build(step.action())?, + .build(step)?, ) } diff --git a/crates/zizmor/src/audit/impostor_commit.rs b/crates/zizmor/src/audit/impostor_commit.rs index a8fbc3ba..e1e0c672 100644 --- a/crates/zizmor/src/audit/impostor_commit.rs +++ b/crates/zizmor/src/audit/impostor_commit.rs @@ -316,7 +316,7 @@ impl Audit for ImpostorCommit { finding_builder = finding_builder.fix(fix); } - findings.push(finding_builder.build(step.action()).map_err(Self::err)?); + findings.push(finding_builder.build(step).map_err(Self::err)?); } Ok(findings) diff --git a/crates/zizmor/src/audit/mod.rs b/crates/zizmor/src/audit/mod.rs index 65bc5949..c2158757 100644 --- a/crates/zizmor/src/audit/mod.rs +++ b/crates/zizmor/src/audit/mod.rs @@ -18,6 +18,7 @@ use crate::{ }; pub(crate) mod anonymous_definition; +pub(crate) mod archived_uses; pub(crate) mod artipacked; pub(crate) mod bot_conditions; pub(crate) mod cache_poisoning; diff --git a/crates/zizmor/src/audit/ref_confusion.rs b/crates/zizmor/src/audit/ref_confusion.rs index d0a69320..11dd7ea9 100644 --- a/crates/zizmor/src/audit/ref_confusion.rs +++ b/crates/zizmor/src/audit/ref_confusion.rs @@ -158,7 +158,7 @@ impl Audit for RefConfusion { .with_keys(["uses".into()]) .annotated(REF_CONFUSION_ANNOTATION), ) - .build(step.action()) + .build(step) .map_err(Self::err)?, ); } diff --git a/crates/zizmor/src/audit/secrets_inherit.rs b/crates/zizmor/src/audit/secrets_inherit.rs index 57d7b739..e41949e5 100644 --- a/crates/zizmor/src/audit/secrets_inherit.rs +++ b/crates/zizmor/src/audit/secrets_inherit.rs @@ -4,7 +4,6 @@ use super::{Audit, AuditLoadError, AuditState, audit_meta}; use crate::{ audit::AuditError, finding::{Confidence, location::Locatable as _}, - models::workflow::JobExt as _, }; pub(crate) struct SecretsInherit; @@ -47,7 +46,7 @@ impl Audit for SecretsInherit { ) .confidence(Confidence::High) .severity(crate::finding::Severity::Medium) - .build(job.parent())?, + .build(job)?, ); } diff --git a/crates/zizmor/src/audit/unpinned_images.rs b/crates/zizmor/src/audit/unpinned_images.rs index 96f2fda2..5580c2db 100644 --- a/crates/zizmor/src/audit/unpinned_images.rs +++ b/crates/zizmor/src/audit/unpinned_images.rs @@ -4,7 +4,6 @@ use crate::{ Confidence, Finding, Persona, Severity, location::{Locatable as _, SymbolicLocation}, }, - models::workflow::JobExt as _, state::AuditState, }; @@ -30,7 +29,7 @@ impl UnpinnedImages { .confidence(Confidence::High) .add_location(annotated_location) .persona(persona) - .build(job.parent()) + .build(job) } } diff --git a/crates/zizmor/src/audit/unsound_condition.rs b/crates/zizmor/src/audit/unsound_condition.rs index fc0a282c..bc84bc08 100644 --- a/crates/zizmor/src/audit/unsound_condition.rs +++ b/crates/zizmor/src/audit/unsound_condition.rs @@ -6,7 +6,7 @@ use crate::{ Confidence, Fix, FixDisposition, Severity, location::{Locatable as _, SymbolicLocation}, }, - models::{AsDocument, workflow::JobExt}, + models::AsDocument, utils, }; use yamlpatch::{Op, Patch}; @@ -158,7 +158,7 @@ impl Audit for UnsoundCondition { job: &crate::models::workflow::NormalJob<'doc>, _config: &crate::config::Config, ) -> Result>, AuditError> { - self.process_conditions(job.parent(), job.conditions()) + self.process_conditions(job, job.conditions()) } async fn audit_reusable_job<'doc>( @@ -167,7 +167,7 @@ impl Audit for UnsoundCondition { _config: &crate::config::Config, ) -> Result>, AuditError> { let conds = job.r#if.iter().map(|cond| (cond, job.location())); - self.process_conditions(job.parent(), conds) + self.process_conditions(job, conds) } async fn audit_action<'doc>( diff --git a/crates/zizmor/src/audit/unsound_contains.rs b/crates/zizmor/src/audit/unsound_contains.rs index dc179699..893e84d2 100644 --- a/crates/zizmor/src/audit/unsound_contains.rs +++ b/crates/zizmor/src/audit/unsound_contains.rs @@ -9,7 +9,6 @@ use super::{Audit, AuditLoadError, AuditState, audit_meta}; use crate::{ audit::AuditError, finding::{Confidence, Severity}, - models::workflow::JobExt as _, utils::{self, ExtractedExpr}, }; @@ -68,7 +67,7 @@ impl Audit for UnsoundContains { .primary() .annotated(format!("contains(..) condition can be bypassed if attacker can control '{context}'")), ) - .build(job.parent()) + .build(job) }) }) .collect() diff --git a/crates/zizmor/src/audit/use_trusted_publishing.rs b/crates/zizmor/src/audit/use_trusted_publishing.rs index f41b495d..33a170ff 100644 --- a/crates/zizmor/src/audit/use_trusted_publishing.rs +++ b/crates/zizmor/src/audit/use_trusted_publishing.rs @@ -12,7 +12,7 @@ use crate::{ models::{ StepBodyCommon, StepCommon, coordinate::{ActionCoordinate, ControlExpr, ControlFieldType, Toggle}, - workflow::JobExt as _, + workflow::JobCommon as _, }, state::AuditState, utils, diff --git a/crates/zizmor/src/finding.rs b/crates/zizmor/src/finding.rs index fcc1dba9..67a7dd1b 100644 --- a/crates/zizmor/src/finding.rs +++ b/crates/zizmor/src/finding.rs @@ -5,7 +5,12 @@ use clap::ValueEnum; use serde::{Deserialize, Serialize}; use self::location::{Location, SymbolicLocation}; -use crate::{InputKey, audit::AuditError, models::AsDocument, registry::input::Group}; +use crate::{ + InputKey, + audit::AuditError, + models::{AsDocument, StepCommon, workflow::JobCommon}, + registry::input::Group, +}; use yamlpatch::{self, Patch}; pub(crate) mod location; @@ -223,6 +228,44 @@ impl<'doc> FindingBuilder<'doc> { self } + /// Add a "useful" location for the given step, if it has a name or ID. + pub(crate) fn with_step(mut self, step: &impl StepCommon<'doc>) -> Self { + if step.name().is_some() { + self.locations.push( + step.location() + .with_keys(["name".into()]) + .annotated("this step"), + ); + } else if step.id().is_some() { + self.locations.push( + step.location() + .with_keys(["id".into()]) + .annotated("this step"), + ); + } + + self + } + + pub(crate) fn with_job(mut self, job: &impl JobCommon<'doc>) -> Self { + if job.name().is_some() { + self.locations.push( + job.location() + .with_keys(["name".into()]) + .annotated("this job"), + ); + } else { + self.locations.push( + job.parent() + .location() + .with_keys(["jobs".into(), job.id().into()]) + .annotated("this job"), + ); + } + + self + } + pub(crate) fn tip(mut self, tip: impl Into) -> Self { self.tip = Some(tip.into()); self diff --git a/crates/zizmor/src/models.rs b/crates/zizmor/src/models.rs index 2dc12f47..0f6efd71 100644 --- a/crates/zizmor/src/models.rs +++ b/crates/zizmor/src/models.rs @@ -37,6 +37,12 @@ pub(crate) enum StepBodyCommon<'s> { /// Common interfaces between workflow and action steps. pub(crate) trait StepCommon<'doc>: Locatable<'doc> + HasInputs { + /// Returns the step's name, if present. + fn name(&self) -> Option<&'doc str>; + + /// Returns the step's ID, if present. + fn id(&self) -> Option<&'doc str>; + /// Returns the step's index within its parent job or action. fn index(&self) -> usize; @@ -45,7 +51,7 @@ pub(crate) trait StepCommon<'doc>: Locatable<'doc> + HasInputs { fn env_is_static(&self, ctx: &context::Context) -> bool; /// Returns a [`common::Uses`] for this step, if it has one. - fn uses(&self) -> Option<&common::Uses>; + fn uses(&self) -> Option<&'doc common::Uses>; /// Returns this step's job's strategy, if present. /// diff --git a/crates/zizmor/src/models/action.rs b/crates/zizmor/src/models/action.rs index 006cb57e..1876b15b 100644 --- a/crates/zizmor/src/models/action.rs +++ b/crates/zizmor/src/models/action.rs @@ -190,6 +190,14 @@ impl HasInputs for CompositeStep<'_> { } impl<'doc> StepCommon<'doc> for CompositeStep<'doc> { + fn name(&self) -> Option<&'doc str> { + self.name.as_deref() + } + + fn id(&self) -> Option<&'doc str> { + self.id.as_deref() + } + fn index(&self) -> usize { self.index } @@ -198,7 +206,7 @@ impl<'doc> StepCommon<'doc> for CompositeStep<'doc> { utils::env_is_static(ctx, &[&self.env]) } - fn uses(&self) -> Option<&common::Uses> { + fn uses(&self) -> Option<&'doc common::Uses> { let action::StepBody::Uses { uses, .. } = &self.inner.body else { return None; }; diff --git a/crates/zizmor/src/models/workflow.rs b/crates/zizmor/src/models/workflow.rs index 6225135f..69e52814 100644 --- a/crates/zizmor/src/models/workflow.rs +++ b/crates/zizmor/src/models/workflow.rs @@ -286,7 +286,13 @@ impl<'doc> NormalJob<'doc> { } } -impl<'doc> JobExt<'doc> for NormalJob<'doc> { +impl<'a, 'doc> AsDocument<'a, 'doc> for NormalJob<'doc> { + fn as_document(&'a self) -> &'doc yamlpath::Document { + self.parent.as_document() + } +} + +impl<'doc> JobCommon<'doc> for NormalJob<'doc> { fn id(&self) -> &'doc str { self.id } @@ -329,7 +335,13 @@ impl<'doc> ReusableWorkflowCallJob<'doc> { } } -impl<'doc> JobExt<'doc> for ReusableWorkflowCallJob<'doc> { +impl<'a, 'doc> AsDocument<'a, 'doc> for ReusableWorkflowCallJob<'doc> { + fn as_document(&'a self) -> &'doc yamlpath::Document { + self.parent.as_document() + } +} + +impl<'doc> JobCommon<'doc> for ReusableWorkflowCallJob<'doc> { fn id(&self) -> &'doc str { self.id } @@ -352,7 +364,7 @@ impl<'doc> std::ops::Deref for ReusableWorkflowCallJob<'doc> { } /// Common behavior across both normal and reusable jobs. -pub(crate) trait JobExt<'doc> { +pub(crate) trait JobCommon<'doc>: Locatable<'doc> { /// The job's unique ID (i.e., its key in the workflow's `jobs:` block). fn id(&self) -> &'doc str; @@ -363,7 +375,7 @@ pub(crate) trait JobExt<'doc> { fn parent(&self) -> &'doc Workflow; } -impl<'doc, T: JobExt<'doc>> Locatable<'doc> for T { +impl<'doc, T: JobCommon<'doc>> Locatable<'doc> for T { /// Returns this job's [`SymbolicLocation`]. fn location(&self) -> SymbolicLocation<'doc> { self.parent() @@ -632,6 +644,14 @@ impl HasInputs for Step<'_> { } impl<'doc> StepCommon<'doc> for Step<'doc> { + fn name(&self) -> Option<&'doc str> { + self.inner.name.as_deref() + } + + fn id(&self) -> Option<&'doc str> { + self.inner.id.as_deref() + } + fn index(&self) -> usize { self.index } @@ -640,7 +660,7 @@ impl<'doc> StepCommon<'doc> for Step<'doc> { utils::env_is_static(ctx, &[&self.env, &self.job().env, &self.workflow().env]) } - fn uses(&self) -> Option<&common::Uses> { + fn uses(&self) -> Option<&'doc common::Uses> { let StepBody::Uses { uses, .. } = &self.inner.body else { return None; }; diff --git a/crates/zizmor/src/registry.rs b/crates/zizmor/src/registry.rs index 4766e0d4..4c8d4d9e 100644 --- a/crates/zizmor/src/registry.rs +++ b/crates/zizmor/src/registry.rs @@ -74,6 +74,7 @@ impl AuditRegistry { register_audit!(audit::dependabot_execution::DependabotExecution); register_audit!(audit::dependabot_cooldown::DependabotCooldown); register_audit!(audit::concurrency_limits::ConcurrencyLimits); + register_audit!(audit::archived_uses::ArchivedUses); Ok(registry) } diff --git a/crates/zizmor/tests/integration/audit/archived_uses.rs b/crates/zizmor/tests/integration/audit/archived_uses.rs new file mode 100644 index 00000000..90a7e881 --- /dev/null +++ b/crates/zizmor/tests/integration/audit/archived_uses.rs @@ -0,0 +1,67 @@ +use crate::common::{input_under_test, zizmor}; + +#[test] +fn test_regular_persona() -> anyhow::Result<()> { + insta::assert_snapshot!( + zizmor().input(input_under_test("archived-uses.yml")).run()?, + @r" + warning[archived-uses]: action or reusable workflow from archived repository + --> @@INPUT@@:17:23 + | + 16 | - name: setup ruby + | ---------------- this step + 17 | uses: actions/setup-ruby@e932e7af67fc4a8fc77bd86b744acd4e42fe3543 # v1.1.3 + | ^^^^^^^^^^ repository is archived + | + = note: audit confidence → High + + warning[archived-uses]: action or reusable workflow from archived repository + --> @@INPUT@@:20:23 + | + 19 | - name: SETUP RUBY BUT LOUDLY + | --------------------------- this step + 20 | uses: ACTIONS/SETUP-RUBY@e932e7af67fc4a8fc77bd86b744acd4e42fe3543 # v1.1.3 + | ^^^^^^^^^^ repository is archived + | + = note: audit confidence → High + + warning[archived-uses]: action or reusable workflow from archived repository + --> @@INPUT@@:24:5 + | + 23 | name: archived-uses-reusable + | ---------------------------- this job + 24 | uses: actions/setup-ruby/.github/workflows/notreal.yml@e932e7af67fc4a8fc77bd86b744acd4e42fe3543 # v1.1.3 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ repository is archived + | + = note: audit confidence → High + + 3 findings: 0 informational, 0 low, 3 medium, 0 high + " + ); + + Ok(()) +} + +#[test] +fn test_composite_action() -> anyhow::Result<()> { + insta::assert_snapshot!( + zizmor() + .input(input_under_test("archived-uses/action/")) + .run()?, + @r" + warning[archived-uses]: action or reusable workflow from archived repository + --> @@INPUT@@action.yml:9:7 + | + 8 | - name: setup ruby + | ---------------- this step + 9 | uses: actions/setup-ruby@e932e7af67fc4a8fc77bd86b744acd4e42fe3543 # v1.1.3 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ repository is archived + | + = note: audit confidence → High + + 1 finding: 0 informational, 0 low, 1 medium, 0 high + " + ); + + Ok(()) +} diff --git a/crates/zizmor/tests/integration/audit/mod.rs b/crates/zizmor/tests/integration/audit/mod.rs index 04c49308..8f8adad4 100644 --- a/crates/zizmor/tests/integration/audit/mod.rs +++ b/crates/zizmor/tests/integration/audit/mod.rs @@ -1,6 +1,7 @@ //! Per-audit integrationt tests, including snapshots. mod anonymous_definition; +mod archived_uses; mod artipacked; mod bot_conditions; mod cache_poisoning; diff --git a/crates/zizmor/tests/integration/test-data/archived-uses.yml b/crates/zizmor/tests/integration/test-data/archived-uses.yml new file mode 100644 index 00000000..a5dd1f29 --- /dev/null +++ b/crates/zizmor/tests/integration/test-data/archived-uses.yml @@ -0,0 +1,24 @@ +name: archived-uses + +on: [push, pull_request] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + archived-uses: + name: archived-uses + runs-on: ubuntu-latest + steps: + - name: setup ruby + uses: actions/setup-ruby@e932e7af67fc4a8fc77bd86b744acd4e42fe3543 # v1.1.3 + + - name: SETUP RUBY BUT LOUDLY + uses: ACTIONS/SETUP-RUBY@e932e7af67fc4a8fc77bd86b744acd4e42fe3543 # v1.1.3 + + archived-uses-reusable: + name: archived-uses-reusable + uses: actions/setup-ruby/.github/workflows/notreal.yml@e932e7af67fc4a8fc77bd86b744acd4e42fe3543 # v1.1.3 diff --git a/crates/zizmor/tests/integration/test-data/archived-uses/action/action.yml b/crates/zizmor/tests/integration/test-data/archived-uses/action/action.yml new file mode 100644 index 00000000..8dea5984 --- /dev/null +++ b/crates/zizmor/tests/integration/test-data/archived-uses/action/action.yml @@ -0,0 +1,9 @@ +name: archived-uses + +description: archived-uses composite action + +runs: + using: composite + steps: + - name: setup ruby + uses: actions/setup-ruby@e932e7af67fc4a8fc77bd86b744acd4e42fe3543 # v1.1.3 diff --git a/docs/audits.md b/docs/audits.md index 1ae8c73d..e0eb892e 100644 --- a/docs/audits.md +++ b/docs/audits.md @@ -60,6 +60,56 @@ Add a `name:` field to your workflow or action. - run: echo "Hello!" ``` +## `archived-uses` + +| Type | Examples | Introduced in | Works offline | Auto-fixes available | Configurable | +|----------|------------------|---------------|----------------|--------------------|--------------| +| Workflow, Action | [archived-uses.yml] | v1.19.0 | ✅ | ❌ | ❌ | + +[archived-uses.yml]: https://github.com/zizmorcore/zizmor/blob/main/crates/zizmor/tests/integration/test-data/archived-uses.yml + + +Detects `#!yaml uses:` clauses that reference [archived repositories]. + +[archived repositories]: https://docs.github.com/en/repositories/archiving-a-github-repository/archiving-repositories + +Archival on GitHub makes a repository read-only, and indicates that the +repository is no longer maintained. Using actions or reusable workflows from archived +represents a supply chain risk: + +- Unmaintained repositories are more likely to accumulate indirect vulnerabilties, + including in any dependencies that have been vendored into JavaScript actions + (or that are used indirectly through transitive dependencies that have gone + stale). + +- Any vulnerabilities discovered in the action or reusable workflow *itself* + are unlikely to be fixed, since the repository is read-only. + +Consequently, users are encouraged to avoid dependening on archived repositories +for actions or reusable workflows. + +### Remediation + +Depending on the archived repository's functionality, you may be able to: + +- _Remove_ the action/reusable workflow entirely. Actions @actions-rs/cargo, + for example, can be replaced by directly invoking the correct `#!bash cargo ...` + command in a `#!yaml run:` step. + +- _Replace_ the archived action/reusable workflow with a maintained alternative. + For example, @actions/setup-ruby can be replaced with @ruby/setup-ruby. + +!!! tip + + Many archived actions are thin wrappers around GitHub's REST and GraphQL + APIs. In most cases, you can replace these actions with usage of the + [`gh` CLI](https://cli.github.com/), which is pre-installed on GitHub-hosted + runners. + + For more information, see [Using GitHub CLI in workflows]. + + [Using GitHub CLI in workflows]: https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-github-cli + ## `artipacked` | Type | Examples | Introduced in | Works offline | Auto-fixes available | Configurable | diff --git a/docs/release-notes.md b/docs/release-notes.md index 34a025a8..3c5a1e4d 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -9,6 +9,11 @@ of `zizmor`. ## Next (UNRELEASED) +### New Features 🌈 + +* **New audit**: [archived-uses] detects usages of archived repositories in + `#!yaml uses:` clauses (#1411) + ### Enhancements 🌱 * The [use-trusted-publishing] audit now detects additional publishing command @@ -1311,5 +1316,6 @@ This is one of `zizmor`'s bigger recent releases! Key enhancements include: [dependabot-execution]: ./audits.md#dependabot-execution [dependabot-cooldown]: ./audits.md#dependabot-cooldown [concurrency-limits]: ./audits.md#concurrency-limits +[archived-uses]: ./audits.md#archived-uses [exit code]: ./usage.md#exit-codes diff --git a/support/archived-action-repos.txt b/support/archived-action-repos.txt new file mode 100644 index 00000000..9a96a1ab --- /dev/null +++ b/support/archived-action-repos.txt @@ -0,0 +1,147 @@ +# archived-action-repos.txt +# one per line, comment lines begin with # +# +# NOTE(ww): I somewhat aritrarily curated this manually from a GitHub search +# for `topic:github-actions archived:true` as of 2025-12-04. + +# official actions +actions/upload-release-asset +actions/create-release +actions/setup-ruby +actions/setup-elixir +actions/setup-haskell + + +# community actions +actions-rs/cargo +actions-rs/grcov +actions-rs/audit-check +actions-rs/toolchain +actions-rs/tarpaulin +actions-rs/clippy-check +actions-rs/install +actions-rs/components-nightly + +andrewmcodes-archive/rubocop-linter-action + +artichoke/setup-rust + +aslafy-z/conventional-pr-title-action + +Azure/AppConfiguration-Sync +Azure/appservice-actions +Azure/azure-resource-login-action +Azure/container-actions +Azure/container-scan +Azure/get-keyvault-secrets +Azure/k8s-actions +Azure/manage-azure-policy +Azure/data-factory-deploy-action +Azure/data-factory-export-action +Azure/data-factory-validate-action +Azure/publish-security-assessments +Azure/run-sqlpackage-action +Azure/spring-cloud-deploy +Azure/webapps-container-deploy + +cedrickring/golang-action + +cirrus-actions/rebase + +crazy-max/ghaction-docker-buildx + +Decathlon/pull-request-labeler-action + +DeLaGuardo/setup-graalvm + +dulvui/godot-android-export + +expo/expo-preview-action + +fabasoad/setup-zizmor-action + +facebook/pysa-action + +fregante/release-with-changelog + +google/mirror-branch-action +google/skywater-pdk-actions + +gradle/gradle-build-action + +grafana/k6-action + +helaili/github-graphql-action +helaili/jekyll-action + +Ilshidur/action-slack + +jakejarvis/backblaze-b2-action +jakejarvis/cloudflare-purge-action +jakejarvis/firebase-deploy-action +jakejarvis/hugo-build-action +jakejarvis/lighthouse-action +jakejarvis/s3-sync-action + +justinribeiro/lighthouse-action + +kanadgupta/glitch-sync + +kxxt/chatgpt-action + +machine-learning-apps/wandb-action + +MansaGroup/gcs-cache-action + +marvinpinto/actions +marvinpinto/action-automatic-releases + +maxheld83/ghpages + +micnncim/action-lgtm-reaction + +mikepenz/gradle-dependency-submission + +orf/cargo-bloat-action + +paambaati/codeclimate-action + +primer/figma-action + +repo-sync/pull-request +repo-sync/repo-sync + +sagebind/docker-swarm-deploy-action + +ScottBrenner/generate-changelog-action + +secrethub/actions + +semgrep/semgrep-action + +ShaunLWM/action-release-debugapk + +stefanprodan/kube-tools + +SonarSource/sonarcloud-github-action + +SwiftDocOrg/github-wiki-publish-action + +tachiyomiorg/issue-moderator-action + +technote-space/auto-cancel-redundant-workflow +technote-space/get-diff-action + +TencentCloudBase/cloudbase-action + +trmcnvn/chrome-addon + +whelk-io/maven-settings-xml-action + +yeslayla/build-godot-action + +youyo/aws-cdk-github-actions + +z0al/dependent-issues + +8398a7/action-slack diff --git a/support/archived-repos.py b/support/archived-repos.py new file mode 100755 index 00000000..628c53bf --- /dev/null +++ b/support/archived-repos.py @@ -0,0 +1,32 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.14" +# dependencies = [] +# /// +from pathlib import Path + +_HERE = Path(__file__).parent +_ARCHIVED_ACTION_REPOS = _HERE / "archived-action-repos.txt" + +assert _ARCHIVED_ACTION_REPOS.is_file(), f"Missing {_ARCHIVED_ACTION_REPOS}" + +_OUT = _HERE.parent / "crates" / "zizmor" / "data" / "archived-repos.txt" + + +def main() -> None: + lines = [] + for line in _ARCHIVED_ACTION_REPOS.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + + lines.append(line.lower()) + + lines.sort() + + with _OUT.open("w") as io: + print("\n".join(lines), file=io) + + +if __name__ == "__main__": + main()