use github_actions_expressions::{Expr, Origin, SpannedExpr};
use github_actions_models::common::{RepositoryUses, Uses, expr::LoE};
use yamlpatch::{Op, Patch};
use crate::{
Confidence, Severity,
audit::AuditError,
config::Config,
finding::{
Finding, Fix, FixDisposition, Persona,
location::{Feature, Location, Routable},
},
models::{StepCommon, action::CompositeStep, workflow::Step},
utils::parse_fenced_expressions_from_routable,
};
use subfeature::Subfeature;
use super::{Audit, AuditInput, AuditLoadError, AuditState, audit_meta};
pub(crate) struct Obfuscation;
audit_meta!(
Obfuscation,
"obfuscation",
"obfuscated usage of GitHub Actions features"
);
impl Obfuscation {
fn obfuscated_repo_uses(&self, uses: &RepositoryUses) -> Vec<&'static str> {
let mut annotations = vec![];
if let Some(subpath) = uses.subpath() {
for component in subpath.split('/') {
match component {
"." => {
annotations.push("actions reference contains '.'");
}
".." => {
annotations.push("actions reference contains '..'");
}
_ if component.is_empty() => {
annotations.push("actions reference contains empty component");
}
_ => {}
}
}
}
annotations
}
fn normalize_uses_path(&self, uses: &RepositoryUses) -> Option<String> {
let subpath = uses.subpath()?;
let mut components = Vec::new();
for component in subpath.split('/') {
match component {
"" | "." => continue,
".." => {
if components.is_empty() {
return None;
}
components.pop();
}
other => components.push(other),
}
}
if components.is_empty() {
Some(format!(
"{}/{}@{}",
uses.owner(),
uses.repo(),
uses.git_ref()
))
} else {
Some(format!(
"{}/{}/{}@{}",
uses.owner(),
uses.repo(),
components.join("/"),
uses.git_ref()
))
}
}
fn create_uses_fix<'doc>(
&self,
uses: &RepositoryUses,
step: &impl StepCommon<'doc>,
) -> Option<Fix<'doc>> {
let normalized_uses = self.normalize_uses_path(uses)?;
Some(Fix {
title: "normalize uses path".into(),
key: step.location().key,
disposition: FixDisposition::Safe,
patches: vec![Patch {
route: step.route().with_key("uses"),
operation: Op::Replace(normalized_uses.into()),
}],
})
}
fn create_expression_fix<'doc>(
&self,
expr: &SpannedExpr<'doc>,
input: &'doc crate::audit::AuditInput,
after: usize,
raw: &'doc str,
) -> Option<Fix<'doc>> {
let evaluated = expr
.consteval()
.map(|evaluation| evaluation.sema().to_string())?;
Some(Fix {
title: "replace with evaluated constant".into(),
key: input.location().key,
disposition: FixDisposition::Safe,
patches: vec![Patch {
route: input.location().route,
operation: Op::RewriteFragment {
from: Subfeature::new(after, raw),
to: evaluated.into(),
},
}],
})
}
fn obfuscated_exprs<'src>(
&self,
expr: &SpannedExpr<'src>,
) -> Vec<(&'static str, Origin<'src>, Persona)> {
let mut annotations = vec![];
if expr.constant_reducible() {
annotations.push((
"can be replaced by its static evaluation",
expr.origin,
Persona::Regular,
));
} else {
for subexpr in expr.constant_reducible_subexprs() {
annotations.push((
"can be reduced to a constant",
subexpr.origin,
Persona::Regular,
));
}
}
for index_expr in expr.computed_indices() {
annotations.push((
"index expression is computed",
index_expr.origin,
Persona::Pedantic,
));
}
annotations
}
fn process_step<'doc>(
&self,
step: &impl StepCommon<'doc>,
) -> Result<Vec<Finding<'doc>>, AuditError> {
let mut findings = vec![];
if let crate::models::StepBodyCommon::Uses {
uses: Uses::Repository(uses),
with,
} = step.body()
{
let obfuscated_annotations = self.obfuscated_repo_uses(uses);
if !obfuscated_annotations.is_empty() {
let mut finding_builder = Self::finding()
.confidence(Confidence::High)
.severity(Severity::Low);
for annotation in &obfuscated_annotations {
finding_builder = finding_builder.add_location(
step.location()
.primary()
.with_keys(["uses".into()])
.annotated(*annotation),
);
}
if let Some(fix) = self.create_uses_fix(uses, step) {
finding_builder = finding_builder.fix(fix);
}
findings.push(finding_builder.build(step).map_err(Self::err)?);
}
if let LoE::Expr(_) = with {
findings.push(
Self::finding()
.confidence(Confidence::High)
.severity(Severity::Informational)
.persona(Persona::Regular)
.add_location(
step.location()
.with_keys(["uses".into()])
.annotated("this action"),
)
.add_location(
step.location()
.primary()
.with_keys(["with".into()])
.annotated("use of an expression for `with:` prevents analysis"),
)
.build(step)
.map_err(Self::err)?,
);
}
}
Ok(findings)
}
}
#[async_trait::async_trait]
impl Audit for Obfuscation {
fn new(_state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
Ok(Self)
}
async fn audit_raw<'doc>(
&self,
input: &'doc AuditInput,
_config: &Config,
) -> Result<Vec<Finding<'doc>>, AuditError> {
let mut findings = vec![];
for (expr, expr_span) in parse_fenced_expressions_from_routable(input) {
let Ok(parsed) = Expr::parse(expr.as_bare()) else {
tracing::warn!("couldn't parse expression: {expr}", expr = expr.as_bare());
continue;
};
let obfuscated_annotations = self.obfuscated_exprs(&parsed);
if !obfuscated_annotations.is_empty() {
let mut finding_builder = Self::finding()
.confidence(Confidence::High)
.severity(Severity::Low);
for (annotation, origin, persona) in &obfuscated_annotations {
let after = expr_span.start + origin.span.start;
let subfeature = Subfeature::new(after, origin.raw);
finding_builder =
finding_builder
.persona(*persona)
.add_raw_location(Location::new(
input.location().annotated(*annotation).primary(),
Feature::from_subfeature(&subfeature, input),
));
}
if parsed.constant_reducible()
&& let Some(fix) =
self.create_expression_fix(&parsed, input, expr_span.start, expr.as_raw())
{
finding_builder = finding_builder.fix(fix);
} else {
for subexpr in parsed.constant_reducible_subexprs() {
if let Some(fix) = self.create_expression_fix(
subexpr,
input,
expr_span.start + subexpr.origin.span.start,
subexpr.origin.raw,
) {
finding_builder = finding_builder.fix(fix);
break; }
}
}
findings.push(finding_builder.build(input).map_err(Self::err)?);
}
}
Ok(findings)
}
async fn audit_step<'doc>(
&self,
step: &Step<'doc>,
_config: &Config,
) -> Result<Vec<Finding<'doc>>, AuditError> {
self.process_step(step)
}
async fn audit_composite_step<'a>(
&self,
step: &CompositeStep<'a>,
_config: &Config,
) -> Result<Vec<Finding<'a>>, AuditError> {
self.process_step(step)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::Audit;
use crate::models::{AsDocument, workflow::Workflow};
use crate::registry::input::InputKey;
use crate::state::AuditState;
async fn apply_fix_for_snapshot(workflow_content: &str, _audit_name: &str) -> String {
let key = InputKey::local("dummy".into(), "test.yml", None::<&str>);
let workflow =
AuditInput::from(Workflow::from_string(workflow_content.to_string(), key).unwrap());
let audit_state = AuditState {
no_online_audits: false,
gh_client: None,
};
let audit = Obfuscation::new(&audit_state).unwrap();
let findings = audit
.audit(Obfuscation::ident(), &workflow, &Default::default())
.await
.unwrap();
assert!(!findings.is_empty(), "Expected findings but got none");
let finding_with_fix = findings
.iter()
.find(|f| !f.fixes.is_empty())
.expect("Expected at least one finding with a fix");
assert!(
!finding_with_fix.fixes.is_empty(),
"Expected fixes but got none"
);
let fix = &finding_with_fix.fixes[0];
let document = workflow.as_document();
let fixed_document = fix.apply(document).unwrap();
fixed_document.source().to_string()
}
#[tokio::test]
async fn test_obfuscation_fix_static_evaluation() {
let workflow_content = r#"
name: Test Workflow
on: push
permissions: {}
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # ... because release-please scans historical commits to build releases, so we need all the history.
persist-credentials: false
- id: release
uses: ./vendor/github.com/googleapis/release-please-action
with:
config-file: "tools/releasing/config.release-please.json"
manifest-file: "tools/releasing/manifest.release-please.json"
target-branch: "${{ inputs.rp_target_branch }}"
outputs:
iac/terraform/attribution.tfm--release_created: ${{ 'steps.release.outputs.iac/terraform/attribution.tfm--release_created' }}
"#;
let result = apply_fix_for_snapshot(workflow_content, "obfuscation").await;
insta::assert_snapshot!(result, @r#"
name: Test Workflow
on: push
permissions: {}
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # ... because release-please scans historical commits to build releases, so we need all the history.
persist-credentials: false
- id: release
uses: ./vendor/github.com/googleapis/release-please-action
with:
config-file: "tools/releasing/config.release-please.json"
manifest-file: "tools/releasing/manifest.release-please.json"
target-branch: "${{ inputs.rp_target_branch }}"
outputs:
iac/terraform/attribution.tfm--release_created: steps.release.outputs.iac/terraform/attribution.tfm--release_created
"#);
}
#[tokio::test]
async fn test_obfuscation_fix_uses_path_empty_components() {
let workflow_content = r#"
name: Test Workflow
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout////@v4
"#;
let result = apply_fix_for_snapshot(workflow_content, "obfuscation").await;
insta::assert_snapshot!(result, @"
name: Test Workflow
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
");
}
#[tokio::test]
async fn test_obfuscation_fix_uses_path_dot() {
let workflow_content = r#"
name: Test Workflow
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: github/codeql-action/./init@v2
"#;
let result = apply_fix_for_snapshot(workflow_content, "obfuscation").await;
insta::assert_snapshot!(result, @"
name: Test Workflow
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: github/codeql-action/init@v2
");
}
#[tokio::test]
async fn test_obfuscation_fix_uses_path_double_dot() {
let workflow_content = r#"
name: Test Workflow
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/cache/save/../save@v4
"#;
let result = apply_fix_for_snapshot(workflow_content, "obfuscation").await;
insta::assert_snapshot!(result, @"
name: Test Workflow
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/cache/save@v4
");
}
}