use github_actions_models::common::Uses;
use subfeature::Subfeature;
use yamlpatch::{Op, Patch};
use super::{Audit, AuditLoadError, AuditState, audit_meta};
use crate::audit::AuditError;
use crate::config::{Config, UsesPolicy};
use crate::finding::location::{Locatable, Routable};
use crate::finding::{Confidence, Finding, Fix, Persona, Severity};
use crate::github;
use crate::models::uses::{RepositoryUsesExt, RepositoryUsesPattern};
use crate::models::version::Version;
use crate::models::workflow::ReusableWorkflowCallJob;
use crate::models::{
AsDocument, StepCommon, action::CompositeStep, uses::UsesExt as _, workflow::Step,
};
pub(crate) struct UnpinnedUses {
client: Option<github::Client>,
}
audit_meta!(UnpinnedUses, "unpinned-uses", "unpinned action reference");
impl UnpinnedUses {
async fn attempt_fix<'a, 'doc>(
&self,
parent: &impl Locatable<'doc>,
uses: &Uses,
) -> Option<Fix<'doc>> {
let client = self.client.as_ref()?;
let Uses::Repository(uses) = uses else {
return None;
};
if uses.ref_is_commit() {
return None;
}
if Version::parse(uses.git_ref()).is_err() {
tracing::debug!("not proposing an auto-fix for a non-version ref: {uses}");
return None;
}
let commit = match client
.commit_for_ref(uses.owner(), uses.repo(), uses.git_ref())
.await
{
Ok(Some(commit)) => commit,
Ok(None) => {
tracing::warn!("no commit matching {uses}");
return None;
}
Err(e) => {
tracing::warn!(
"failed to look up commit for {uses}: {e}",
uses = uses.raw()
);
return None;
}
};
let action = if let Some(subpath) = uses.subpath() {
format!("{}/{}", uses.slug(), subpath)
} else {
uses.slug().to_string()
};
Some(Fix {
title: format!("pin {action}@{ref} to {commit}", ref = uses.git_ref()),
key: parent.location().key,
disposition: Default::default(),
patches: vec![
Patch {
route: parent.route().with_key("uses"),
operation: Op::Replace(format!("{action}@{commit}").into()),
},
Patch {
route: parent.route().with_key("uses"),
operation: Op::EmplaceComment {
new: format!("# {ref}", ref = uses.git_ref()).into(),
},
},
],
})
}
async fn evaluate_pinning<'doc>(
&self,
parent: &impl Locatable<'doc>,
uses: &'doc Uses,
config: &Config,
) -> Option<(String, Severity, Persona, Option<Fix<'doc>>)> {
match uses {
Uses::Local(_) => None,
Uses::Docker(_) => {
if uses.unpinned() {
Some((
"image is not pinned to a tag, branch, or hash ref".into(),
Severity::Medium,
Persona::default(),
None,
))
} else if uses.unhashed() {
Some((
"action is not pinned to a hash".into(),
Severity::Low,
Persona::Pedantic,
None,
))
} else {
None
}
}
Uses::Repository(repo_uses) => {
let (pattern, policy) = config.unpinned_uses_policies.get_policy(repo_uses);
let pat_desc = match pattern {
Some(RepositoryUsesPattern::Any) | None => "blanket".into(),
Some(RepositoryUsesPattern::InOwner(owner)) => format!("{owner}/*"),
Some(RepositoryUsesPattern::InRepo { owner, repo }) => {
format!("{owner}/{repo}/*")
}
Some(RepositoryUsesPattern::ExactRepo { owner, repo }) => {
format!("{owner}/{repo}")
}
Some(RepositoryUsesPattern::ExactPath {
owner,
repo,
subpath,
}) => {
format!("{owner}/{repo}/{subpath}")
}
Some(RepositoryUsesPattern::ExactWithRef { .. }) => unreachable!(),
};
match policy {
UsesPolicy::Any => None,
UsesPolicy::RefPin => uses.unpinned().then_some((
format!(
"action is not pinned to a ref or hash (required by {pat_desc} policy)"
),
Severity::High,
Persona::default(),
self.attempt_fix(parent, uses).await,
)),
UsesPolicy::HashPin => uses.unhashed().then_some((
format!("action is not pinned to a hash (required by {pat_desc} policy)"),
Severity::High,
Persona::default(),
self.attempt_fix(parent, uses).await,
)),
}
}
}
}
async fn process_uses<'a, 'doc, S>(
&self,
uses: &'doc Uses,
parent: &'a S,
config: &Config,
) -> Result<Option<Finding<'doc>>, AuditError>
where
S: Locatable<'doc> + AsDocument<'a, 'doc>,
{
let Some((annotation, severity, persona, fix)) =
self.evaluate_pinning(parent, uses, config).await
else {
return Ok(None);
};
let mut builder = Self::finding()
.confidence(Confidence::High)
.severity(severity)
.persona(persona)
.add_location(
parent
.location()
.primary()
.with_keys(["uses".into()])
.subfeature(Subfeature::new(0, uses.raw()))
.annotated(annotation),
);
if let Some(fix) = fix {
builder = builder.fix(fix);
}
Ok(Some(builder.build(parent)?))
}
async fn process_step<'doc>(
&self,
step: &impl StepCommon<'doc>,
config: &Config,
) -> Result<Vec<Finding<'doc>>, AuditError> {
let Some(uses) = step.uses() else {
return Ok(vec![]);
};
Ok(self
.process_uses(uses, step, config)
.await?
.into_iter()
.collect())
}
}
#[async_trait::async_trait]
impl Audit for UnpinnedUses {
fn new(state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
Ok(Self {
client: state.gh_client.clone(),
})
}
async fn audit_step<'doc>(
&self,
step: &Step<'doc>,
config: &Config,
) -> Result<Vec<Finding<'doc>>, AuditError> {
self.process_step(step, config).await
}
async fn audit_composite_step<'a>(
&self,
step: &CompositeStep<'a>,
config: &Config,
) -> Result<Vec<Finding<'a>>, AuditError> {
self.process_step(step, config).await
}
async fn audit_reusable_job<'doc>(
&self,
job: &ReusableWorkflowCallJob<'doc>,
config: &Config,
) -> Result<Vec<Finding<'doc>>, AuditError> {
Ok(self
.process_uses(&job.uses, job, config)
.await?
.into_iter()
.collect())
}
}
#[cfg(feature = "gh-token-tests")]
#[cfg(test)]
mod tests {
use crate::audit::unpinned_uses::UnpinnedUses;
use crate::audit::{Audit as _, AuditCore as _};
use crate::config::Config;
use crate::github;
use crate::{
models::{AsDocument, workflow::Workflow},
registry::input::InputKey,
};
#[tokio::test]
async fn test_fix() {
let workflow_content = r#"
name: Test
on: push
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout with ref-pin
uses: actions/checkout@v6.0.1
"#;
let key = InputKey::local("fakegroup".into(), "test_unpinned_uses.yml", None::<&str>);
let workflow = Workflow::from_string(workflow_content.to_string(), key).unwrap();
let state = crate::state::AuditState::new(
false,
Some(
github::Client::new(
&github::GitHubHost::default(),
&github::GitHubToken::new(&std::env::var("GH_TOKEN").unwrap()).unwrap(),
"/tmp".into(),
)
.unwrap(),
),
);
let audit = UnpinnedUses::new(&state).unwrap();
let input = workflow.into();
let findings = audit
.audit(UnpinnedUses::ident(), &input, &Config::default())
.await
.unwrap();
let new_doc = findings[0].fixes[0].apply(input.as_document()).unwrap();
insta::assert_snapshot!(new_doc.source(), @"
name: Test
on: push
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout with ref-pin
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
");
}
#[tokio::test]
async fn test_fix_crlf() {
let workflow_content = r#"
name: Test
on: push
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout with ref-pin
uses: actions/checkout@v6.0.1
"#;
let workflow_content = workflow_content.replace("\n", "\r\n");
let key = InputKey::local("fakegroup".into(), "test_unpinned_uses.yml", None::<&str>);
let workflow = Workflow::from_string(workflow_content.to_string(), key).unwrap();
let state = crate::state::AuditState::new(
false,
Some(
github::Client::new(
&github::GitHubHost::default(),
&github::GitHubToken::new(&std::env::var("GH_TOKEN").unwrap()).unwrap(),
"/tmp".into(),
)
.unwrap(),
),
);
let audit = UnpinnedUses::new(&state).unwrap();
let input = workflow.into();
let findings = audit
.audit(UnpinnedUses::ident(), &input, &Config::default())
.await
.unwrap();
let new_doc = findings[0].fixes[0].apply(input.as_document()).unwrap();
insta::assert_snapshot!(new_doc.source(), @"
name: Test
on: push
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout with ref-pin
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
");
}
#[tokio::test]
async fn test_fix_overwrites_comment() {
let workflow_content = r#"
name: Test
on: push
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout with ref-pin
uses: actions/checkout@v6.0.1 # old comment
"#;
let key = InputKey::local(
"fakegroup".into(),
"test_unpinned_uses_overwrites_comment.yml",
None::<&str>,
);
let workflow = Workflow::from_string(workflow_content.to_string(), key).unwrap();
let state = crate::state::AuditState::new(
false,
Some(
github::Client::new(
&github::GitHubHost::default(),
&github::GitHubToken::new(&std::env::var("GH_TOKEN").unwrap()).unwrap(),
"/tmp".into(),
)
.unwrap(),
),
);
let audit = UnpinnedUses::new(&state).unwrap();
let input = workflow.into();
let findings = audit
.audit(UnpinnedUses::ident(), &input, &Config::default())
.await
.unwrap();
let new_doc = findings[0].fixes[0].apply(input.as_document()).unwrap();
insta::assert_snapshot!(new_doc.source(), @"
name: Test
on: push
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout with ref-pin
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
");
}
#[tokio::test]
async fn test_fix_bizarre_formatting() {
let workflow_content = r#"
name: Test
on: push
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
-
uses: actions/checkout@v6.0.1
"#;
let key = InputKey::local("fakegroup".into(), "test_unpinned_uses.yml", None::<&str>);
let workflow = Workflow::from_string(workflow_content.to_string(), key).unwrap();
let state = crate::state::AuditState::new(
false,
Some(
github::Client::new(
&github::GitHubHost::default(),
&github::GitHubToken::new(&std::env::var("GH_TOKEN").unwrap()).unwrap(),
"/tmp".into(),
)
.unwrap(),
),
);
let audit = UnpinnedUses::new(&state).unwrap();
let input = workflow.into();
let findings = audit
.audit(UnpinnedUses::ident(), &input, &Config::default())
.await
.unwrap();
let new_doc = findings[0].fixes[0].apply(input.as_document()).unwrap();
insta::assert_snapshot!(new_doc.source(), @"
name: Test
on: push
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
-
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
");
}
#[tokio::test]
async fn test_fix_subpath() {
let workflow_content = r#"
name: Test
on: push
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: bytecodealliance/actions/wasmtime/setup@v1.1.3
"#;
let key = InputKey::local(
"fakegroup".into(),
"test_unpinned_uses_subpath.yml",
None::<&str>,
);
let workflow = Workflow::from_string(workflow_content.to_string(), key).unwrap();
let state = crate::state::AuditState::new(
false,
Some(
github::Client::new(
&github::GitHubHost::default(),
&github::GitHubToken::new(&std::env::var("GH_TOKEN").unwrap()).unwrap(),
"/tmp".into(),
)
.unwrap(),
),
);
let audit = UnpinnedUses::new(&state).unwrap();
let input = workflow.into();
let findings = audit
.audit(UnpinnedUses::ident(), &input, &Config::default())
.await
.unwrap();
let new_doc = findings[0].fixes[0].apply(input.as_document()).unwrap();
insta::assert_snapshot!(new_doc.source(), @"
name: Test
on: push
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: bytecodealliance/actions/wasmtime/setup@9152e710e9f7182e4c29ad218e4f335a7b203613 # v1.1.3
");
}
#[tokio::test]
async fn test_no_fix_for_already_pinned() {
let workflow_content = r#"
name: Test
on: push
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout with commit pin
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
"#;
let key = InputKey::local(
"fakegroup".into(),
"test_no_fix_for_already_pinned.yml",
None::<&str>,
);
let workflow = Workflow::from_string(workflow_content.to_string(), key).unwrap();
let state = crate::state::AuditState::new(
false,
Some(
github::Client::new(
&github::GitHubHost::default(),
&github::GitHubToken::new(&std::env::var("GH_TOKEN").unwrap()).unwrap(),
"/tmp".into(),
)
.unwrap(),
),
);
let audit = UnpinnedUses::new(&state).unwrap();
let input = workflow.into();
let findings = audit
.audit(UnpinnedUses::ident(), &input, &Config::default())
.await
.unwrap();
assert!(findings.is_empty());
}
#[tokio::test]
async fn test_no_fix_for_non_version_ref() {
let workflow_content = r#"
name: Test
on: push
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@main
"#;
let key = InputKey::local(
"fakegroup".into(),
"test_no_fix_for_non_version_ref.yml",
None::<&str>,
);
let workflow = Workflow::from_string(workflow_content.to_string(), key).unwrap();
let state = crate::state::AuditState::new(
false,
Some(
github::Client::new(
&github::GitHubHost::default(),
&github::GitHubToken::new(&std::env::var("GH_TOKEN").unwrap()).unwrap(),
"/tmp".into(),
)
.unwrap(),
),
);
let audit = UnpinnedUses::new(&state).unwrap();
let input = workflow.into();
let findings = audit
.audit(UnpinnedUses::ident(), &input, &Config::default())
.await
.unwrap();
assert_eq!(findings.len(), 1);
assert!(findings[0].fixes.is_empty());
}
}