use anyhow::anyhow;
use github_actions_models::common::{RepositoryUses, Uses};
use super::{Audit, AuditLoadError, audit_meta};
use crate::{
audit::AuditError,
config::Config,
finding::{Confidence, Finding, Fix, Severity, location::Routable as _},
github,
models::{StepCommon, action::CompositeStep, uses::RepositoryUsesExt as _, workflow::Step},
state::AuditState,
};
use yamlpatch::{Op, Patch};
pub(crate) struct KnownVulnerableActions {
client: github::Client,
}
audit_meta!(
KnownVulnerableActions,
"known-vulnerable-actions",
"action has a known vulnerability"
);
impl KnownVulnerableActions {
async fn action_known_vulnerabilities(
&self,
uses: &RepositoryUses,
) -> Result<Vec<(Severity, String, Option<String>)>, AuditError> {
let version = match &uses.git_ref() {
version if !uses.ref_is_commit() => {
let Some(commit_ref) = self
.client
.commit_for_ref(uses.owner(), uses.repo(), version)
.await
.map_err(Self::err)?
else {
return Ok(vec![]);
};
match self
.client
.longest_tag_for_commit(uses.owner(), uses.repo(), &commit_ref)
.await
.map_err(Self::err)?
{
Some(tag) => tag.name,
None => version.to_string(),
}
}
commit_ref => {
match self
.client
.longest_tag_for_commit(uses.owner(), uses.repo(), commit_ref)
.await
.map_err(Self::err)?
{
Some(tag) => tag.name,
None => return Ok(vec![]),
}
}
};
let advisories = self
.client
.gha_advisories(uses.owner(), uses.repo(), &version)
.await
.map_err(Self::err)?;
let mut results = vec![];
for advisory in advisories {
let severity = match advisory.severity.as_str() {
"low" => Severity::Low,
"medium" => Severity::Medium,
"high" => Severity::High,
"critical" => Severity::High,
_ => Severity::High,
};
let first_patched_version = advisory
.vulnerabilities
.iter()
.find(|v| {
v.package.ecosystem == "actions"
&& v.package.name.eq_ignore_ascii_case(uses.slug())
})
.and_then(|v| v.first_patched_version.clone());
results.push((severity, advisory.ghsa_id, first_patched_version));
}
Ok(results)
}
async fn create_upgrade_fix<'doc>(
&self,
uses: &RepositoryUses,
target_version: String,
step: &impl StepCommon<'doc>,
) -> Result<Fix<'doc>, AuditError> {
let mut uses_slug = format!("{}/{}", uses.owner(), uses.repo());
if let Some(subpath) = &uses.subpath() {
uses_slug.push_str(&format!("/{subpath}"));
}
let (bare_version, prefixed_version) = if let Some(bare) = target_version.strip_prefix('v')
{
(bare.into(), target_version)
} else {
let prefixed = format!("v{target_version}");
(target_version, prefixed)
};
match uses.ref_is_commit() {
true => {
let (target_ref, target_commit) = match self
.client
.commit_for_ref(uses.owner(), uses.repo(), &prefixed_version)
.await
{
Ok(Some(commit)) => Some((&prefixed_version, commit)),
Ok(None) | Err(_) => self
.client
.commit_for_ref(uses.owner(), uses.repo(), &bare_version)
.await
.map_err(Self::err)?
.map(|commit| (&bare_version, commit)),
}
.ok_or_else(|| {
Self::err(anyhow!(
"Cannot resolve version {bare_version} to commit hash for {}/{}",
uses.owner(),
uses.repo()
))
})?;
let new_uses_value = format!("{uses_slug}@{target_commit}");
Ok(Fix {
title: format!("upgrade {uses_slug} to {target_ref}"),
key: step.location().key,
disposition: Default::default(),
patches: vec![
Patch {
route: step.route().with_key("uses"),
operation: Op::Replace(new_uses_value.into()),
},
Patch {
route: step.route().with_key("uses"),
operation: Op::ReplaceComment {
new: format!("# {target_ref}").into(),
},
},
],
})
}
false => {
let target_version_tag = if uses.git_ref().starts_with('v') {
prefixed_version
} else {
bare_version
};
let new_uses_value = format!("{uses_slug}@{target_version_tag}");
Ok(Fix {
title: format!("upgrade {uses_slug} to {target_version_tag}"),
key: step.location().key,
disposition: Default::default(),
patches: vec![Patch {
route: step.route().with_key("uses"),
operation: Op::Replace(new_uses_value.into()),
}],
})
}
}
}
async fn process_step<'doc>(
&self,
step: &impl StepCommon<'doc>,
) -> Result<Vec<Finding<'doc>>, AuditError> {
let mut findings = vec![];
let Some(Uses::Repository(uses)) = step.uses() else {
return Ok(findings);
};
for (severity, id, first_patched_version) in self.action_known_vulnerabilities(uses).await?
{
let mut finding_builder = Self::finding()
.confidence(Confidence::High)
.severity(severity)
.add_location(
step.location()
.primary()
.with_keys(["uses".into()])
.with_url(format!("https://github.com/advisories/{id}", id = &id))
.annotated(id),
);
if let Some(first_patched_version) = first_patched_version
&& let Ok(fix) = self
.create_upgrade_fix(uses, first_patched_version, step)
.await
{
finding_builder = finding_builder.fix(fix);
}
findings.push(finding_builder.build(step).map_err(Self::err)?);
}
Ok(findings)
}
}
#[async_trait::async_trait]
impl Audit for KnownVulnerableActions {
fn new(state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
if state.no_online_audits {
return Err(AuditLoadError::Skip(anyhow!(
"offline audits only requested"
)));
}
state
.gh_client
.clone()
.ok_or_else(|| AuditLoadError::Skip(anyhow!("can't run without a GitHub API token")))
.map(|client| KnownVulnerableActions { client })
}
async fn audit_step<'doc>(
&self,
step: &Step<'doc>,
_config: &Config,
) -> Result<Vec<Finding<'doc>>, AuditError> {
self.process_step(step).await
}
async fn audit_composite_step<'doc>(
&self,
step: &CompositeStep<'doc>,
_config: &Config,
) -> Result<Vec<Finding<'doc>>, AuditError> {
self.process_step(step).await
}
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use super::*;
use crate::{
models::{AsDocument, workflow::Workflow},
registry::input::InputKey,
};
fn create_test_audit() -> KnownVulnerableActions {
let state = crate::state::AuditState::new(
false,
Some(
github::Client::new(
&github::GitHubHost::default(),
&github::GitHubToken::new("fake").unwrap(),
"/tmp".into(),
)
.unwrap(),
),
);
KnownVulnerableActions::new(&state).unwrap()
}
#[tokio::test]
async fn test_fix_upgrade_actions_checkout() {
let workflow_content = r#"
name: Test Vulnerable Actions
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout with old version
uses: actions/checkout@v2
- name: Another step
run: echo "hello"
"#;
let key = InputKey::local("fakegroup".into(), "test_checkout.yml", None::<&str>);
let workflow = Workflow::from_string(workflow_content.to_string(), key).unwrap();
let job = workflow.jobs().next().unwrap();
let steps: Vec<_> = match job {
crate::models::workflow::Job::NormalJob(normal_job) => normal_job.steps().collect(),
_ => panic!("Expected normal job"),
};
let step = &steps[0];
let uses = RepositoryUses::parse("actions/checkout@v2").unwrap();
let audit = create_test_audit();
let fix = audit
.create_upgrade_fix(&uses, "v4".into(), step)
.await
.unwrap();
let fixed_document = fix.apply(workflow.as_document()).unwrap();
insta::assert_snapshot!(fixed_document.source(), @r#"
name: Test Vulnerable Actions
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout with old version
uses: actions/checkout@v4
- name: Another step
run: echo "hello"
"#);
}
#[tokio::test]
async fn test_fix_upgrade_actions_setup_node() {
let workflow_content = r#"
name: Test Node Setup
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Setup Node
uses: actions/setup-node@v1
with:
node-version: '18'
- name: Install dependencies
run: npm install
"#;
let key = InputKey::local("fakegroup".into(), "test_setup_node.yml", None::<&str>);
let workflow = Workflow::from_string(workflow_content.to_string(), key).unwrap();
let job = workflow.jobs().next().unwrap();
let steps: Vec<_> = match job {
crate::models::workflow::Job::NormalJob(normal_job) => normal_job.steps().collect(),
_ => panic!("Expected normal job"),
};
let step = &steps[0];
let uses = RepositoryUses::parse("actions/setup-node@v1").unwrap();
let audit = create_test_audit();
let fix = audit
.create_upgrade_fix(&uses, "v4".into(), step)
.await
.unwrap();
let fixed_document = fix.apply(workflow.as_document()).unwrap();
insta::assert_snapshot!(fixed_document.source(), @"
name: Test Node Setup
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install dependencies
run: npm install
");
}
#[tokio::test]
async fn test_fix_upgrade_third_party_action() {
let workflow_content = r#"
name: Test Third Party Action
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Upload to codecov
uses: codecov/codecov-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Another step
run: echo "test"
"#;
let key = InputKey::local("fakegroup".into(), "test_third_party.yml", None::<&str>);
let workflow = Workflow::from_string(workflow_content.to_string(), key).unwrap();
let job = workflow.jobs().next().unwrap();
let steps: Vec<_> = match job {
crate::models::workflow::Job::NormalJob(normal_job) => normal_job.steps().collect(),
_ => panic!("Expected normal job"),
};
let step = &steps[0];
let uses = RepositoryUses::parse("codecov/codecov-action@v1").unwrap();
let audit = create_test_audit();
let fix = audit
.create_upgrade_fix(&uses, "v4".into(), step)
.await
.unwrap();
let fixed_document = fix.apply(workflow.as_document()).unwrap();
insta::assert_snapshot!(fixed_document.source(), @r#"
name: Test Third Party Action
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Upload to codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Another step
run: echo "test"
"#);
}
#[tokio::test]
async fn test_fix_upgrade_multiple_vulnerable_actions() {
let workflow_content = r#"
name: Test Multiple Vulnerable Actions
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Setup Node
uses: actions/setup-node@v1
with:
node-version: '18'
- name: Cache dependencies
uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
run: npm install
"#;
let key = InputKey::local("fakegroup".into(), "test_multiple.yml", None::<&str>);
let workflow = Workflow::from_string(workflow_content.to_string(), key).unwrap();
let job = workflow.jobs().next().unwrap();
let steps: Vec<_> = match job {
crate::models::workflow::Job::NormalJob(normal_job) => normal_job.steps().collect(),
_ => panic!("Expected normal job"),
};
let mut current_document = workflow.as_document().clone();
let audit = create_test_audit();
let uses_checkout = RepositoryUses::parse("actions/checkout@v2").unwrap();
let fix_checkout = audit
.create_upgrade_fix(&uses_checkout, "v4".into(), &steps[0])
.await
.unwrap();
current_document = fix_checkout.apply(¤t_document).unwrap();
let uses_node = RepositoryUses::parse("actions/setup-node@v1").unwrap();
let fix_node = audit
.create_upgrade_fix(&uses_node, "v4".into(), &steps[1])
.await
.unwrap();
current_document = fix_node.apply(¤t_document).unwrap();
let uses_cache = RepositoryUses::parse("actions/cache@v2").unwrap();
let fix_cache = audit
.create_upgrade_fix(&uses_cache, "v4".into(), &steps[2])
.await
.unwrap();
current_document = fix_cache.apply(¤t_document).unwrap();
insta::assert_snapshot!(current_document.source(), @"
name: Test Multiple Vulnerable Actions
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
run: npm install
");
}
#[tokio::test]
async fn test_fix_upgrade_action_with_subpath() {
let workflow_content = r#"
name: Test Action with Subpath
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Custom action
uses: owner/repo/subpath@v1
with:
param: value
"#;
let key = InputKey::local("fakegroup".into(), "test_subpath.yml", None::<&str>);
let workflow = Workflow::from_string(workflow_content.to_string(), key).unwrap();
let job = workflow.jobs().next().unwrap();
let steps: Vec<_> = match job {
crate::models::workflow::Job::NormalJob(normal_job) => normal_job.steps().collect(),
_ => panic!("Expected normal job"),
};
let step = &steps[0];
let uses = RepositoryUses::parse("owner/repo/subpath@v1").unwrap();
let audit = create_test_audit();
let fix = audit
.create_upgrade_fix(&uses, "v2".into(), step)
.await
.unwrap();
let fixed_document = fix.apply(workflow.as_document()).unwrap();
insta::assert_snapshot!(fixed_document.source(), @"
name: Test Action with Subpath
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Custom action
uses: owner/repo/subpath@v2
with:
param: value
");
}
#[tokio::test]
async fn test_first_patched_version_priority() {
let workflow_content = r#"
name: Test First Patched Version Priority
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Vulnerable action
uses: actions/checkout@v2
"#;
let key = InputKey::local("fakegroup".into(), "test_first_patched.yml", None::<&str>);
let workflow = Workflow::from_string(workflow_content.to_string(), key).unwrap();
let job = workflow.jobs().next().unwrap();
let steps: Vec<_> = match job {
crate::models::workflow::Job::NormalJob(normal_job) => normal_job.steps().collect(),
_ => panic!("Expected normal job"),
};
let step = &steps[0];
let uses = RepositoryUses::parse("actions/checkout@v2").unwrap();
let audit = create_test_audit();
let fix_with_patched_version = audit
.create_upgrade_fix(&uses, "v3.1.0".into(), step)
.await
.unwrap();
let fixed_document = fix_with_patched_version
.apply(workflow.as_document())
.unwrap();
insta::assert_snapshot!(fixed_document.source(), @"
name: Test First Patched Version Priority
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Vulnerable action
uses: actions/checkout@v3.1.0
");
}
#[tokio::test]
async fn test_fix_symbolic_ref() {
let workflow_content = r#"
name: Test Non-Commit Ref
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Tag pinned action
uses: actions/checkout@v2 # this comment stays
"#;
let key = InputKey::local("fakegroup".into(), "test_non_commit.yml", None::<&str>);
let workflow = Workflow::from_string(workflow_content.to_string(), key).unwrap();
let job = workflow.jobs().next().unwrap();
let steps: Vec<_> = match job {
crate::models::workflow::Job::NormalJob(normal_job) => normal_job.steps().collect(),
_ => panic!("Expected normal job"),
};
let step = &steps[0];
let uses = RepositoryUses::parse("actions/checkout@v2").unwrap();
let audit = create_test_audit();
let fix = audit
.create_upgrade_fix(&uses, "v4".into(), step)
.await
.unwrap();
let new_doc = fix.apply(workflow.as_document()).unwrap();
assert_snapshot!(new_doc.source(), @"
name: Test Non-Commit Ref
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Tag pinned action
uses: actions/checkout@v4 # this comment stays
");
}
#[tokio::test]
async fn test_offline_audit_state_creation() {
let state = crate::state::AuditState::default();
let audit_result = KnownVulnerableActions::new(&state);
assert!(audit_result.is_err());
}
#[cfg(feature = "gh-token-tests")]
#[tokio::test]
async fn test_fix_commit_pin() {
let workflow_content = r#"
name: Test Commit Hash Pinning Real API
on: push
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Commit pinned action
uses: actions/download-artifact@7a1cd3216ca9260cd8022db641d960b1db4d1be4 # v4.0.0
"#;
let key = InputKey::local("fakegroup".into(), "dummy.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 = KnownVulnerableActions::new(&state).unwrap();
let input = workflow.into();
let findings = audit
.audit(KnownVulnerableActions::ident(), &input, &Config::default())
.await
.unwrap();
assert_eq!(findings.len(), 1);
let new_doc = findings[0].fixes[0].apply(input.as_document()).unwrap();
assert_snapshot!(new_doc.source(), @"
name: Test Commit Hash Pinning Real API
on: push
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Commit pinned action
uses: actions/download-artifact@87c55149d96e628cc2ef7e6fc2aab372015aec85 # v4.1.3
");
}
#[cfg(feature = "gh-token-tests")]
#[tokio::test]
async fn test_report_finding_no_commit_found() {
let workflow_content = r#"
name: Test Commit Hash Not Found
on: push
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Run Trivy vulnerability scanner in repo mode
uses: aquasecurity/trivy-action@c1824fd6edce30d7ab345a9989de00bbd46ef284 # v0.34.0
"#;
let key = InputKey::local("fakegroup".into(), "dummy.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 = KnownVulnerableActions::new(&state).unwrap();
let input = workflow.into();
let findings = audit
.audit(KnownVulnerableActions::ident(), &input, &Config::default())
.await
.unwrap();
assert!(
findings.len() >= 1,
"Expected at least one finding for vulnerable action"
);
let new_doc = findings[0].fixes[0].apply(input.as_document()).unwrap();
assert_snapshot!(new_doc.source(), @"
name: Test Commit Hash Not Found
on: push
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Run Trivy vulnerability scanner in repo mode
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
");
}
#[cfg(feature = "gh-token-tests")]
#[tokio::test]
async fn test_fix_commit_pin_no_comment() {
let workflow_content = r#"
name: Test Commit Hash Pinning Real API
on: push
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Commit pinned action
uses: actions/download-artifact@7a1cd3216ca9260cd8022db641d960b1db4d1be4
"#;
let key = InputKey::local("fakegroup".into(), "dummy.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 = KnownVulnerableActions::new(&state).unwrap();
let input = workflow.into();
let findings = audit
.audit(KnownVulnerableActions::ident(), &input, &Config::default())
.await
.unwrap();
assert_eq!(findings.len(), 1);
let new_doc = findings[0].fixes[0].apply(input.as_document()).unwrap();
assert_snapshot!(new_doc.source(), @"
name: Test Commit Hash Pinning Real API
on: push
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Commit pinned action
uses: actions/download-artifact@87c55149d96e628cc2ef7e6fc2aab372015aec85
");
}
}