feat: add an archived-uses audit (#1411)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Test site build (push) Has been cancelled
zizmor wheel builds for PyPI 🐍 / Build Linux wheels (manylinux) (push) Has been cancelled
zizmor wheel builds for PyPI 🐍 / Build Linux wheels (musllinux) (push) Has been cancelled
GitHub Actions Security Analysis with zizmor 🌈 / Run zizmor 🌈 (push) Has been cancelled
zizmor wheel builds for PyPI 🐍 / Build Windows wheels (push) Has been cancelled
Deploy zizmor documentation site 🌐 / Deploy zizmor documentation to GitHub Pages 🌐 (push) Has been cancelled
zizmor wheel builds for PyPI 🐍 / Build macOS wheels (push) Has been cancelled
zizmor wheel builds for PyPI 🐍 / Build source distribution (push) Has been cancelled
CI / All tests pass (push) Has been cancelled
zizmor wheel builds for PyPI 🐍 / Release (push) Has been cancelled

This commit is contained in:
William Woodruff 2025-12-04 22:15:33 -05:00 committed by GitHub
parent 0f386aa3c1
commit 7f8f24f90c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 691 additions and 28 deletions

View file

@ -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();
}

View file

@ -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

View file

@ -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<Set<&[u8]>> = 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<FindingBuilder<'doc>> {
// 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<Self, AuditLoadError>
where
Self: Sized,
{
Ok(Self)
}
async fn audit_step<'doc>(
&self,
step: &Step<'doc>,
_config: &Config,
) -> Result<Vec<Finding<'doc>>, 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<Vec<Finding<'doc>>, 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<Vec<Finding<'doc>>, 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)
}
}

View file

@ -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)?);
}
}

View file

@ -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()
}
}

View file

@ -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)?,
)
}

View file

@ -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)

View file

@ -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;

View file

@ -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)?,
);
}

View file

@ -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)?,
);
}

View file

@ -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)
}
}

View file

@ -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<Vec<crate::finding::Finding<'doc>>, 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<Vec<crate::finding::Finding<'doc>>, 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>(

View file

@ -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()

View file

@ -12,7 +12,7 @@ use crate::{
models::{
StepBodyCommon, StepCommon,
coordinate::{ActionCoordinate, ControlExpr, ControlFieldType, Toggle},
workflow::JobExt as _,
workflow::JobCommon as _,
},
state::AuditState,
utils,

View file

@ -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<String>) -> Self {
self.tip = Some(tip.into());
self

View file

@ -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.
///

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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)
}

View file

@ -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(())
}

View file

@ -1,6 +1,7 @@
//! Per-audit integrationt tests, including snapshots.
mod anonymous_definition;
mod archived_uses;
mod artipacked;
mod bot_conditions;
mod cache_poisoning;

View file

@ -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

View file

@ -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