use std::sync::LazyLock;
use github_actions_models::common::{
EnvValue, Uses,
expr::{ExplicitExpr, LoE},
};
use itertools::Itertools as _;
use subfeature::Subfeature;
use super::{Audit, AuditLoadError, audit_meta};
use crate::{
audit::AuditError,
finding::{Confidence, Finding, Fix, Persona, Severity, location::Routable as _},
github::{Client, ClientError},
models::{StepBodyCommon, StepCommon, uses::RepositoryUsesExt, version::Version},
state::AuditState,
utils::split_patterns,
};
use yamlpatch::{Op, Patch};
#[allow(clippy::unwrap_used)]
static V6: LazyLock<Version> = LazyLock::new(|| Version::parse("v6").unwrap());
pub(crate) struct Artipacked {
client: Option<Client>,
}
audit_meta!(
Artipacked,
"artipacked",
"credential persistence through GitHub Actions artifacts"
);
impl Artipacked {
async fn is_checkout_v6_or_higher(
&self,
uses: &github_actions_models::common::RepositoryUses,
) -> Result<Option<bool>, ClientError> {
let version = if !uses.ref_is_commit() {
uses.git_ref().to_string()
} else {
match self.client {
Some(ref client) => {
let tag = client
.longest_tag_for_commit(uses.owner(), uses.repo(), uses.git_ref())
.await?;
match tag {
Some(tag) => tag.name,
None => return Ok(None),
}
}
None => return Ok(None),
}
};
let Ok(version) = Version::parse(&version) else {
return Ok(None);
};
Ok(Some(version >= *V6))
}
fn determine_severity(
is_v6_or_higher: Option<bool>,
has_no_vulnerable_uploads: bool,
) -> Severity {
if is_v6_or_higher == Some(true) {
if has_no_vulnerable_uploads {
Severity::Low
} else {
Severity::Medium
}
} else if has_no_vulnerable_uploads {
Severity::Medium
} else {
Severity::High
}
}
async fn process_steps<'doc>(
&self,
steps: impl Iterator<Item = impl StepCommon<'doc>>,
) -> Result<Vec<Finding<'doc>>, AuditError> {
let mut findings = vec![];
let mut vulnerable_checkouts = vec![];
let mut vulnerable_uploads = vec![];
for step in steps {
let StepBodyCommon::Uses {
uses: Uses::Repository(uses),
with,
} = &step.body()
else {
continue;
};
let with = match with {
LoE::Literal(with) => with,
LoE::Expr(_) => {
findings.push(
Self::finding()
.severity(Severity::Informational)
.confidence(Confidence::High)
.persona(Persona::Pedantic)
.add_location(
step.location()
.with_keys(["uses".into()])
.subfeature(Subfeature::new(0, uses.raw()))
.annotated("this checkout"),
)
.add_location(
step.location()
.primary()
.with_keys(["with".into()])
.annotated("may not set persist-credentials: false"),
)
.build(&step)?,
);
continue;
}
};
if uses.matches("actions/checkout") {
let is_v6_or_higher = self
.is_checkout_v6_or_higher(uses)
.await
.map_err(Self::err)?;
match with
.get("persist-credentials")
.map(|v| v.to_string())
.as_deref()
{
Some("false") => continue,
Some("true") => {
vulnerable_checkouts.push((step, Persona::Auditor, is_v6_or_higher))
}
Some(v) if ExplicitExpr::from_curly(v).is_some() => {
vulnerable_checkouts.push((step, Persona::Pedantic, is_v6_or_higher));
}
_ => vulnerable_checkouts.push((step, Persona::default(), is_v6_or_higher)),
}
} else if uses.matches("actions/upload-artifact") {
let Some(EnvValue::String(path)) = with.get("path") else {
continue;
};
let dangerous_paths = self.dangerous_artifact_patterns(path);
if !dangerous_paths.is_empty() {
vulnerable_uploads.push(step)
}
}
}
if vulnerable_uploads.is_empty() {
for (checkout, persona, is_v6_or_higher) in &vulnerable_checkouts {
let severity =
Self::determine_severity(*is_v6_or_higher, vulnerable_uploads.is_empty());
findings.push(
Self::finding()
.severity(severity)
.confidence(Confidence::Low)
.persona(*persona)
.add_location(
checkout
.location()
.primary()
.annotated("does not set persist-credentials: false"),
)
.fix(Self::create_persist_credentials_fix(checkout))
.build(checkout)?,
);
}
} else {
for ((checkout, persona, is_v6_or_higher), upload) in vulnerable_checkouts
.iter()
.cartesian_product(vulnerable_uploads.iter())
{
if checkout.index() < upload.index() {
let severity =
Self::determine_severity(*is_v6_or_higher, vulnerable_uploads.is_empty());
findings.push(
Self::finding()
.severity(severity)
.confidence(Confidence::High)
.persona(*persona)
.add_location(
checkout
.location()
.primary()
.annotated("does not set persist-credentials: false"),
)
.add_location(
upload
.location()
.annotated("may leak the credentials persisted above"),
)
.fix(Self::create_persist_credentials_fix(checkout))
.build(checkout)?,
);
}
}
}
Ok(findings)
}
fn dangerous_artifact_patterns<'b>(&self, path: &'b str) -> Vec<&'b str> {
let mut patterns = vec![];
for path in split_patterns(path) {
match path {
"." | "./" | ".." | "../" => patterns.push(path),
path => match ExplicitExpr::from_curly(path) {
Some(expr) if expr.as_bare().contains("github.workspace") => {
patterns.push(path)
}
Some(_) => continue,
_ => continue,
},
}
}
patterns
}
fn create_persist_credentials_fix<'doc>(step: &impl StepCommon<'doc>) -> Fix<'doc> {
Fix {
title: "set persist-credentials: false".to_string(),
key: step.location().key,
disposition: Default::default(),
patches: vec![Patch {
route: step.route(),
operation: Op::MergeInto {
key: "with".to_string(),
updates: indexmap::IndexMap::from_iter([(
"persist-credentials".to_string(),
serde_yaml::Value::Bool(false),
)]),
},
}],
}
}
}
#[async_trait::async_trait]
impl Audit for Artipacked {
fn new(state: &AuditState) -> Result<Self, AuditLoadError> {
let client = if state.no_online_audits {
None
} else {
state.gh_client.clone()
};
Ok(Self { client })
}
async fn audit_action<'doc>(
&self,
action: &'doc crate::models::action::Action,
_config: &crate::config::Config,
) -> Result<Vec<Finding<'doc>>, AuditError> {
let Some(steps) = action.steps() else {
return Ok(vec![]);
};
self.process_steps(steps).await
}
async fn audit_normal_job<'doc>(
&self,
job: &super::NormalJob<'doc>,
_config: &crate::config::Config,
) -> Result<Vec<Finding<'doc>>, AuditError> {
self.process_steps(job.steps()).await
}
}
#[cfg(test)]
mod tests {
use github_actions_models::common::RepositoryUses;
use super::*;
use crate::{
config::Config,
models::{AsDocument, workflow::Workflow},
registry::input::InputKey,
state::AuditState,
};
macro_rules! test_workflow_audit {
($audit_type:ty, $filename:expr, $workflow_content:expr, $test_fn:expr) => {{
let key = InputKey::local("fakegroup".into(), $filename, None::<&str>);
let workflow = Workflow::from_string($workflow_content.to_string(), key).unwrap();
let audit_state = AuditState::default();
let audit = <$audit_type>::new(&audit_state).unwrap();
let findings = audit
.audit_workflow(&workflow, &Config::default())
.await
.unwrap();
$test_fn(&workflow, findings)
}};
}
fn apply_fix_for_snapshot(
document: &yamlpath::Document,
findings: Vec<Finding>,
) -> yamlpath::Document {
assert!(!findings.is_empty(), "Expected findings but got none");
let finding = &findings[0];
assert!(!finding.fixes.is_empty(), "Expected fixes but got none");
let fix = &finding.fixes[0];
assert_eq!(fix.title, "set persist-credentials: false");
fix.apply(document).unwrap()
}
#[tokio::test]
async fn test_is_checkout_v6_or_higher_offline() {
let v6 = RepositoryUses::parse("actions/checkout@v6").unwrap();
let v6_0 = RepositoryUses::parse("actions/checkout@v6.0").unwrap();
let v6_1_0 = RepositoryUses::parse("actions/checkout@v6.1.0").unwrap();
let v7 = RepositoryUses::parse("actions/checkout@v7").unwrap();
let v10 = RepositoryUses::parse("actions/checkout@v10").unwrap();
let artipacked = Artipacked { client: None };
assert_eq!(
artipacked.is_checkout_v6_or_higher(&v6).await.unwrap(),
Some(true)
);
assert_eq!(
artipacked.is_checkout_v6_or_higher(&v6_0).await.unwrap(),
Some(true)
);
assert_eq!(
artipacked.is_checkout_v6_or_higher(&v6_1_0).await.unwrap(),
Some(true)
);
assert_eq!(
artipacked.is_checkout_v6_or_higher(&v7).await.unwrap(),
Some(true)
);
assert_eq!(
artipacked.is_checkout_v6_or_higher(&v10).await.unwrap(),
Some(true)
);
let v4 = RepositoryUses::parse("actions/checkout@v4").unwrap();
let v5 = RepositoryUses::parse("actions/checkout@v5").unwrap();
let v5_9 = RepositoryUses::parse("actions/checkout@v5.9").unwrap();
assert_eq!(
artipacked.is_checkout_v6_or_higher(&v4).await.unwrap(),
Some(false)
);
assert_eq!(
artipacked.is_checkout_v6_or_higher(&v5).await.unwrap(),
Some(false)
);
assert_eq!(
artipacked.is_checkout_v6_or_higher(&v5_9).await.unwrap(),
Some(false)
);
let commit_sha =
RepositoryUses::parse("actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683")
.unwrap();
assert_eq!(
artipacked
.is_checkout_v6_or_higher(&commit_sha)
.await
.unwrap(),
None
);
let invalid = RepositoryUses::parse("actions/checkout@main").unwrap();
assert_eq!(
artipacked.is_checkout_v6_or_higher(&invalid).await.unwrap(),
None
);
}
#[cfg(feature = "online-tests")]
#[tokio::test]
async fn test_is_checkout_v6_or_higher_online() {
use crate::github;
let artipacked = Artipacked {
client: Some(
Client::new(
&github::GitHubHost::default(),
&github::GitHubToken::new(&std::env::var("GH_TOKEN").unwrap()).unwrap(),
"/tmp".into(),
)
.unwrap(),
),
};
let commit_sha_v6 =
RepositoryUses::parse("actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3")
.unwrap();
assert_eq!(
artipacked
.is_checkout_v6_or_higher(&commit_sha_v6)
.await
.unwrap(),
Some(true)
);
let commit_sha_v5 =
RepositoryUses::parse("actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd")
.unwrap();
assert_eq!(
artipacked
.is_checkout_v6_or_higher(&commit_sha_v5)
.await
.unwrap(),
Some(false)
);
}
#[test]
fn test_determine_severity() {
const IS_V6_OR_HIGHER: Option<bool> = Some(true);
const IS_OLDER_VERSION: Option<bool> = Some(false);
const UNKNOWN_VERSION: Option<bool> = None;
const HAS_NO_VULNERABLE_UPLOADS: bool = true;
const HAS_VULNERABLE_UPLOADS: bool = false;
assert_eq!(
Artipacked::determine_severity(IS_V6_OR_HIGHER, HAS_NO_VULNERABLE_UPLOADS),
Severity::Low
);
assert_eq!(
Artipacked::determine_severity(IS_V6_OR_HIGHER, HAS_VULNERABLE_UPLOADS),
Severity::Medium
);
assert_eq!(
Artipacked::determine_severity(IS_OLDER_VERSION, HAS_NO_VULNERABLE_UPLOADS),
Severity::Medium
);
assert_eq!(
Artipacked::determine_severity(IS_OLDER_VERSION, HAS_VULNERABLE_UPLOADS),
Severity::High
);
assert_eq!(
Artipacked::determine_severity(UNKNOWN_VERSION, HAS_NO_VULNERABLE_UPLOADS),
Severity::Medium
);
assert_eq!(
Artipacked::determine_severity(UNKNOWN_VERSION, HAS_VULNERABLE_UPLOADS),
Severity::High
);
}
#[test]
fn test_fix_title_and_description() {
let title = "set persist-credentials: false";
let description_keywords = [
"persist-credentials",
"GITHUB_TOKEN",
"credential persistence",
];
assert_eq!(title, "set persist-credentials: false");
for keyword in description_keywords {
assert!(!keyword.is_empty());
}
}
#[tokio::test]
async fn test_fix_merges_into_existing_with_block() {
let workflow_content = r#"
name: Test Workflow
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 2
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: my-artifact
path: .
"#;
test_workflow_audit!(
Artipacked,
"test_fix_merges_into_existing_with_block.yml",
workflow_content,
|workflow: &Workflow, findings| {
let fixed = apply_fix_for_snapshot(workflow.as_document(), findings);
insta::assert_snapshot!(fixed.source(), @"
name: Test Workflow
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 2
persist-credentials: false
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: my-artifact
path: .
");
}
);
}
#[tokio::test]
async fn test_fix_creates_with_block_when_missing() {
let workflow_content = r#"
name: Test Workflow
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: my-artifact
path: .
"#;
test_workflow_audit!(
Artipacked,
"test_fix_creates_with_block_when_missing.yml",
workflow_content,
|workflow: &Workflow, findings| {
let fixed = apply_fix_for_snapshot(workflow.as_document(), findings);
insta::assert_snapshot!(fixed.source(), @"
name: Test Workflow
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: my-artifact
path: .
");
}
);
}
}