use github_actions_models::common;
use crate::{
audit::{Audit, AuditError, audit_meta},
finding::{
Confidence, Fix, FixDisposition, Severity,
location::{Locatable as _, SymbolicLocation},
},
models::AsDocument,
utils,
};
use yamlpatch::{Op, Patch};
pub(crate) struct UnsoundCondition;
audit_meta!(
UnsoundCondition,
"unsound-condition",
"unsound conditional expression"
);
impl UnsoundCondition {
fn is_unsound_fenced_expansion(&self, cond: &common::If) -> bool {
let common::If::Expr(raw_expr) = cond else {
return false;
};
let Some((expr, _)) = utils::extract_fenced_expression(raw_expr, 0) else {
return false;
};
raw_expr.len() > expr.as_raw().len()
}
fn attempt_fix<'a, 'doc>(
&self,
cond: &common::If,
loc: &SymbolicLocation<'doc>,
doc: &'a impl AsDocument<'a, 'doc>,
) -> Option<Fix<'doc>> {
let common::If::Expr(raw_expr) = cond else {
return None;
};
if !raw_expr.ends_with('\n') {
return None;
}
let yaml_doc = doc.as_document();
let feature =
yamlpatch::route_to_feature_exact(&loc.route.with_key("if"), yaml_doc).ok()??;
let style = yamlpatch::Style::from_feature(&feature, yaml_doc);
let (old_indicator, new_indicator) = match style {
yamlpatch::Style::MultilineLiteralScalar => ("|", "|-"),
yamlpatch::Style::MultilineFoldedScalar => (">", ">-"),
_ => return None, };
Some(Fix {
title: format!(
"replace unsound block scalar style '{old_indicator}' with sound style '{new_indicator}'"
),
key: loc.key,
disposition: FixDisposition::Safe,
patches: vec![Patch {
route: loc.route.with_key("if"),
operation: Op::RewriteFragment {
from: subfeature::Subfeature::new(0, old_indicator),
to: new_indicator.into(),
},
}],
})
}
fn process_conditions<'a, 'doc>(
&self,
doc: &'a impl AsDocument<'a, 'doc>,
conditions: impl Iterator<Item = (&'doc common::If, SymbolicLocation<'doc>)>,
) -> Result<Vec<super::Finding<'doc>>, AuditError> {
let mut findings = vec![];
for (cond, loc) in conditions {
if self.is_unsound_fenced_expansion(cond) {
let mut finding_builder = Self::finding()
.severity(Severity::High)
.confidence(Confidence::High)
.add_location(loc.clone().hidden())
.add_location(
loc.with_keys(["if".into()])
.primary()
.annotated("condition always evaluates to true"),
);
if let Some(fix) = self.attempt_fix(cond, &loc, doc) {
finding_builder = finding_builder.fix(fix);
}
findings.push(finding_builder.build(doc)?);
}
}
Ok(findings)
}
}
#[async_trait::async_trait]
impl Audit for UnsoundCondition {
fn new(_state: &crate::state::AuditState) -> Result<Self, super::AuditLoadError>
where
Self: Sized,
{
Ok(Self)
}
async fn audit_normal_job<'doc>(
&self,
job: &crate::models::workflow::NormalJob<'doc>,
_config: &crate::config::Config,
) -> Result<Vec<crate::finding::Finding<'doc>>, AuditError> {
self.process_conditions(job, job.conditions())
}
async fn audit_reusable_job<'doc>(
&self,
job: &crate::models::workflow::ReusableWorkflowCallJob<'doc>,
_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, conds)
}
async fn audit_action<'doc>(
&self,
action: &'doc crate::models::action::Action,
_config: &crate::config::Config,
) -> Result<Vec<crate::finding::Finding<'doc>>, AuditError> {
self.process_conditions(action, action.conditions())
}
}
#[cfg(test)]
mod tests {
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<crate::finding::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!(fix.title.contains("replace unsound block scalar style"));
fix.apply(document).unwrap()
}
#[tokio::test]
async fn test_simple_literal_block_fix() {
let workflow_content = r#"
name: Test
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: simple case
if: |
${{ github.event_name == 'push' }}
run: echo "test"
"#;
test_workflow_audit!(
UnsoundCondition,
"test_simple_literal_block_fix.yml",
workflow_content,
|workflow: &Workflow, findings: Vec<crate::finding::Finding>| {
assert_eq!(findings.len(), 1);
let fixed_document = apply_fix_for_snapshot(workflow.as_document(), findings);
insta::assert_snapshot!(fixed_document.source(), @r#"
name: Test
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: simple case
if: |-
${{ github.event_name == 'push' }}
run: echo "test"
"#);
}
);
}
#[tokio::test]
async fn test_folded_block_fix() {
let workflow_content = r#"
name: Test
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: folded case
if: >
${{ github.actor == 'dependabot[bot]' }}
run: echo "test"
"#;
test_workflow_audit!(
UnsoundCondition,
"test_folded_block_fix.yml",
workflow_content,
|workflow: &Workflow, findings: Vec<crate::finding::Finding>| {
assert_eq!(findings.len(), 1);
let fixed_document = apply_fix_for_snapshot(workflow.as_document(), findings);
insta::assert_snapshot!(fixed_document.source(), @r#"
name: Test
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: folded case
if: >-
${{ github.actor == 'dependabot[bot]' }}
run: echo "test"
"#);
}
);
}
#[tokio::test]
async fn test_multiline_expression_fix() {
let workflow_content = r#"
name: Test
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: multiline case
if: |
${{ github.event_name == 'push'
&& github.ref == 'refs/heads/main' }}
run: echo "test"
"#;
test_workflow_audit!(
UnsoundCondition,
"test_multiline_expression_fix.yml",
workflow_content,
|workflow: &Workflow, findings: Vec<crate::finding::Finding>| {
assert_eq!(findings.len(), 1);
let fixed_document = apply_fix_for_snapshot(workflow.as_document(), findings);
insta::assert_snapshot!(fixed_document.source(), @r#"
name: Test
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: multiline case
if: |-
${{ github.event_name == 'push'
&& github.ref == 'refs/heads/main' }}
run: echo "test"
"#);
}
);
}
#[tokio::test]
async fn test_complex_multiline_fix() {
let workflow_content = r#"
name: Test
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: complex case
if: |
${{
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' ||
startsWith(github.ref, 'refs/heads/release/'))
}}
run: echo "test"
"#;
test_workflow_audit!(
UnsoundCondition,
"test_complex_multiline_fix.yml",
workflow_content,
|workflow: &Workflow, findings: Vec<crate::finding::Finding>| {
assert_eq!(findings.len(), 1);
let fixed_document = apply_fix_for_snapshot(workflow.as_document(), findings);
insta::assert_snapshot!(fixed_document.source(), @r#"
name: Test
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: complex case
if: |-
${{
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' ||
startsWith(github.ref, 'refs/heads/release/'))
}}
run: echo "test"
"#);
}
);
}
#[tokio::test]
async fn test_reusable_job_fix() {
let workflow_content = r#"
name: Test
on: push
jobs:
reusable-job:
if: |
${{ github.event_name == 'pull_request' }}
uses: ./.github/workflows/reusable.yml
"#;
test_workflow_audit!(
UnsoundCondition,
"test_reusable_job_fix.yml",
workflow_content,
|workflow: &Workflow, findings: Vec<crate::finding::Finding>| {
assert_eq!(findings.len(), 1);
let fixed_document = apply_fix_for_snapshot(workflow.as_document(), findings);
insta::assert_snapshot!(fixed_document.source(), @"
name: Test
on: push
jobs:
reusable-job:
if: |-
${{ github.event_name == 'pull_request' }}
uses: ./.github/workflows/reusable.yml
");
}
);
}
#[tokio::test]
async fn test_multiple_fixes_together() {
let workflow_content = r#"
name: Test
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: literal block
if: |
${{ github.event_name == 'push' }}
run: echo "test"
- name: folded block
if: >
${{ github.actor == 'dependabot[bot]' }}
run: echo "test"
- name: multiline expression
if: |
${{ github.event_name == 'push'
&& github.ref == 'refs/heads/main' }}
run: echo "test"
"#;
test_workflow_audit!(
UnsoundCondition,
"test_multiple_fixes_together.yml",
workflow_content,
|workflow: &Workflow, findings: Vec<crate::finding::Finding>| {
assert_eq!(findings.len(), 3);
let mut document = workflow.as_document().clone();
for finding in &findings {
for fix in &finding.fixes {
if let Ok(new_document) = fix.apply(&document) {
document = new_document;
}
}
}
insta::assert_snapshot!(document.source(), @r#"
name: Test
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: literal block
if: |-
${{ github.event_name == 'push' }}
run: echo "test"
- name: folded block
if: >-
${{ github.actor == 'dependabot[bot]' }}
run: echo "test"
- name: multiline expression
if: |-
${{ github.event_name == 'push'
&& github.ref == 'refs/heads/main' }}
run: echo "test"
"#);
}
);
}
#[tokio::test]
async fn test_no_fix_needed_cases() {
let workflow_content = r#"
name: Test
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
# These should not trigger any findings
- name: already plain
if: ${{ github.event_name == 'push' }}
run: echo "test"
- name: no fence
if: |
github.event_name == 'push'
run: echo "test"
- name: already stripped literal
if: |-
${{ github.event_name == 'push' }}
run: echo "test"
- name: already stripped folded
if: >-
${{ github.event_name == 'push' }}
run: echo "test"
"#;
test_workflow_audit!(
UnsoundCondition,
"test_no_fix_needed_cases.yml",
workflow_content,
|_workflow: &Workflow, findings: Vec<crate::finding::Finding>| {
assert_eq!(findings.len(), 0);
}
);
}
}