use std::{ops::Deref, sync::LazyLock};
use github_actions_expressions::{
Expr, SpannedExpr,
call::Call,
context::{Context, ContextPattern},
op::{BinOp, UnOp},
};
use github_actions_models::{
common::If,
workflow::event::{BareEvent, OptionalBody},
};
use super::{Audit, AuditLoadError, AuditState, audit_meta};
use crate::{
audit::AuditError,
finding::{Confidence, Fix, FixDisposition, Severity, location::Locatable as _},
models::workflow::{JobCommon, Workflow},
utils::{self, ExtractedExpr},
};
use subfeature::Subfeature;
use yamlpatch::{Op, Patch};
pub(crate) struct BotConditions;
audit_meta!(BotConditions, "bot-conditions", "spoofable bot actor check");
#[allow(clippy::unwrap_used)]
static SPOOFABLE_ACTOR_NAME_CONTEXTS: LazyLock<Vec<ContextPattern>> = LazyLock::new(|| {
vec![
ContextPattern::try_new("github.actor").unwrap(),
ContextPattern::try_new("github.triggering_actor").unwrap(),
ContextPattern::try_new("github.event.pull_request.sender.login").unwrap(),
]
});
#[allow(clippy::unwrap_used)]
static SPOOFABLE_ACTOR_ID_CONTEXTS: LazyLock<Vec<ContextPattern>> = LazyLock::new(|| {
vec![
ContextPattern::try_new("github.actor_id").unwrap(),
ContextPattern::try_new("github.event.pull_request.sender.id").unwrap(),
]
});
const BOT_ACTOR_IDS: &[&str] = &[
"29110", "49699333", "27856297", "29139614", ];
#[async_trait::async_trait]
impl Audit for BotConditions {
fn new(_state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
Ok(Self)
}
async fn audit_normal_job<'doc>(
&self,
job: &super::NormalJob<'doc>,
_config: &crate::config::Config,
) -> Result<Vec<super::Finding<'doc>>, AuditError> {
let mut findings = vec![];
let mut conds = vec![];
if let Some(If::Expr(expr)) = &job.r#if {
conds.push((
expr,
job.location_with_grip(),
job.location().with_keys(["if".into()]),
));
}
for step in job.steps() {
if let Some(If::Expr(expr)) = &step.r#if {
conds.push((
expr,
step.location_with_grip(),
step.location().with_keys(["if".into()]),
));
}
}
for (expr, parent, if_loc) in conds {
let bare = match utils::extract_fenced_expression(expr, 0) {
Some((expr, _)) => expr.as_bare(),
None => ExtractedExpr::new(expr).as_bare(),
};
let Ok(expr) = Expr::parse(bare) else {
tracing::warn!("couldn't parse expression: {expr}");
continue;
};
if let Some((subfeature, actor_context, confidence)) = Self::bot_condition(&expr) {
let if_route = if_loc.route.clone();
let mut finding_builder = Self::finding()
.severity(Severity::High)
.confidence(confidence)
.add_location(parent.clone())
.add_location(
if_loc
.primary()
.subfeature(subfeature)
.annotated("actor context may be spoofable"),
);
if let Some(fix) = Self::attempt_fix(job.parent(), actor_context, if_route) {
finding_builder = finding_builder.fix(fix);
}
findings.push(finding_builder.build(job.parent())?);
}
}
Ok(findings)
}
}
impl BotConditions {
fn get_user_contexts_for_triggers(workflow: &Workflow) -> Option<(&str, &str)> {
use github_actions_models::workflow::Trigger;
match &workflow.on {
Trigger::BareEvent(event) => Self::get_contexts_for_event(event),
Trigger::BareEvents(event_list) if event_list.len() == 1 => {
Self::get_contexts_for_event(&event_list[0])
}
Trigger::Events(event_map) if event_map.count() == 1 => {
if !matches!(event_map.issue_comment, OptionalBody::Missing) {
return Self::get_contexts_for_event(&BareEvent::IssueComment);
}
if !matches!(event_map.pull_request, OptionalBody::Missing) {
return Self::get_contexts_for_event(&BareEvent::PullRequest);
}
if !matches!(event_map.pull_request_target, OptionalBody::Missing) {
return Self::get_contexts_for_event(&BareEvent::PullRequestTarget);
}
if !matches!(event_map.discussion_comment, OptionalBody::Missing) {
return Self::get_contexts_for_event(&BareEvent::DiscussionComment);
}
if !matches!(event_map.pull_request_review, OptionalBody::Missing) {
return Self::get_contexts_for_event(&BareEvent::PullRequestReview);
}
if !matches!(event_map.pull_request_review_comment, OptionalBody::Missing) {
return Self::get_contexts_for_event(&BareEvent::PullRequestReviewComment);
}
if !matches!(event_map.issues, OptionalBody::Missing) {
return Self::get_contexts_for_event(&BareEvent::Issues);
}
if !matches!(event_map.discussion, OptionalBody::Missing) {
return Self::get_contexts_for_event(&BareEvent::Discussion);
}
if !matches!(event_map.release, OptionalBody::Missing) {
return Self::get_contexts_for_event(&BareEvent::Release);
}
if !matches!(event_map.push, OptionalBody::Missing) {
return Self::get_contexts_for_event(&BareEvent::Push);
}
if !matches!(event_map.milestone, OptionalBody::Missing) {
return Self::get_contexts_for_event(&BareEvent::Milestone);
}
if !matches!(event_map.label, OptionalBody::Missing) {
return Self::get_contexts_for_event(&BareEvent::Label);
}
if !matches!(event_map.project, OptionalBody::Missing) {
return Self::get_contexts_for_event(&BareEvent::Project);
}
if !matches!(event_map.watch, OptionalBody::Missing) {
return Self::get_contexts_for_event(&BareEvent::Watch);
}
None
}
_ => None,
}
}
fn get_contexts_for_event(event: &BareEvent) -> Option<(&str, &str)> {
match event {
BareEvent::IssueComment => Some((
"github.event.comment.user.login",
"github.event.comment.user.id",
)),
BareEvent::DiscussionComment => Some((
"github.event.comment.user.login",
"github.event.comment.user.id",
)),
BareEvent::PullRequestReview => Some((
"github.event.review.user.login",
"github.event.review.user.id",
)),
BareEvent::PullRequestReviewComment => Some((
"github.event.comment.user.login",
"github.event.comment.user.id",
)),
BareEvent::Issues => Some((
"github.event.issue.user.login",
"github.event.issue.user.id",
)),
BareEvent::Discussion => Some((
"github.event.discussion.user.login",
"github.event.discussion.user.id",
)),
BareEvent::PullRequest | BareEvent::PullRequestTarget => Some((
"github.event.pull_request.user.login",
"github.event.pull_request.user.id",
)),
BareEvent::Release => Some((
"github.event.release.author.login",
"github.event.release.author.id",
)),
BareEvent::Create | BareEvent::Delete => {
Some(("github.event.sender.login", "github.event.sender.id"))
}
BareEvent::Milestone => Some((
"github.event.milestone.creator.login",
"github.event.milestone.creator.id",
)),
BareEvent::Label
| BareEvent::Project
| BareEvent::Fork
| BareEvent::Watch
| BareEvent::Public => Some(("github.event.sender.login", "github.event.sender.id")),
_ => None,
}
}
fn walk_tree_for_bot_condition<'a, 'src>(
expr: &'a SpannedExpr<'src>,
dominating: bool,
) -> (Option<(&'a SpannedExpr<'src>, &'a SpannedExpr<'src>)>, bool) {
match expr.deref() {
Expr::Call(Call {
func: _,
args: exprs,
})
| Expr::Context(Context { parts: exprs, .. }) => exprs
.iter()
.map(|arg| Self::walk_tree_for_bot_condition(arg, false))
.reduce(|(bc, _), (bc_next, _)| (bc.or(bc_next), false))
.unwrap_or((None, dominating)),
Expr::Index(expr) => Self::walk_tree_for_bot_condition(expr, dominating),
Expr::BinOp { lhs, op, rhs } => match op {
BinOp::Or => {
let (bc_lhs, _) = Self::walk_tree_for_bot_condition(lhs, true);
let (bc_rhs, _) = Self::walk_tree_for_bot_condition(rhs, true);
(bc_lhs.or(bc_rhs), true)
}
BinOp::Eq => {
let context_expr = match (lhs.as_ref().deref(), rhs.as_ref().deref()) {
(Expr::Context(_), _) => lhs.as_ref(),
(_, Expr::Context(_)) => rhs.as_ref(),
_ => return (None, true),
};
match (lhs.as_ref().deref(), rhs.as_ref().deref()) {
(Expr::Context(ctx), Expr::Literal(lit))
| (Expr::Literal(lit), Expr::Context(ctx)) => {
if (SPOOFABLE_ACTOR_NAME_CONTEXTS.iter().any(|x| x.matches(ctx))
&& lit.as_str().ends_with("[bot]"))
|| (SPOOFABLE_ACTOR_ID_CONTEXTS.iter().any(|x| x.matches(ctx))
&& BOT_ACTOR_IDS.contains(&lit.as_str().as_ref()))
{
((Some((expr, context_expr))), true)
} else {
(None, true)
}
}
(_, _) => {
let (bc_lhs, _) = Self::walk_tree_for_bot_condition(lhs, true);
let (bc_rhs, _) = Self::walk_tree_for_bot_condition(rhs, true);
(bc_lhs.or(bc_rhs), true)
}
}
}
_ => {
let (bc_lhs, _) = Self::walk_tree_for_bot_condition(lhs, false);
let (bc_rhs, _) = Self::walk_tree_for_bot_condition(rhs, false);
(bc_lhs.or(bc_rhs), false)
}
},
Expr::UnOp { op, expr } => match op {
UnOp::Not => Self::walk_tree_for_bot_condition(expr, false),
},
_ => (None, dominating),
}
}
fn bot_condition<'a, 'doc>(
expr: &'a SpannedExpr<'doc>,
) -> Option<(Subfeature<'doc>, &'a SpannedExpr<'doc>, Confidence)> {
match Self::walk_tree_for_bot_condition(expr, true) {
(Some((expr, context_expr)), true) => {
Some((Subfeature::new(0, expr), context_expr, Confidence::High))
}
(Some((expr, context_expr)), false) => {
Some((Subfeature::new(0, expr), context_expr, Confidence::Medium))
}
(..) => None,
}
}
fn attempt_fix<'doc>(
workflow: &'doc Workflow,
spoofable_context: &SpannedExpr<'doc>,
if_route: yamlpath::Route<'doc>,
) -> Option<Fix<'doc>> {
let (safe_name_context, safe_id_context) = Self::get_user_contexts_for_triggers(workflow)?;
let spoofable_context_raw = spoofable_context.origin.raw;
let SpannedExpr {
origin: _,
inner: Expr::Context(spoofable_context),
} = spoofable_context
else {
return None;
};
let safe_context = if SPOOFABLE_ACTOR_NAME_CONTEXTS
.iter()
.any(|pat| pat.matches(spoofable_context))
{
safe_name_context
} else {
safe_id_context
};
Some(Fix {
title: "replace spoofable actor context".into(),
key: &workflow.key,
disposition: FixDisposition::Safe,
patches: vec![Patch {
route: if_route,
operation: Op::RewriteFragment {
from: subfeature::Subfeature::new(0, spoofable_context_raw),
to: safe_context.into(),
},
}],
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
config::Config,
finding::Finding,
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)
}};
}
#[test]
fn test_bot_condition() {
for (cond, context, confidence) in &[
(
"github.actor == 'dependabot[bot]'",
"github.actor",
Confidence::High,
),
(
"'dependabot[bot]' == github.actor",
"github.actor",
Confidence::High,
),
(
"'dependabot[bot]' == GitHub.actor",
"GitHub.actor",
Confidence::High,
),
(
"'dependabot[bot]' == GitHub.ACTOR",
"GitHub.ACTOR",
Confidence::High,
),
(
"'dependabot[bot]' == GitHub.triggering_actor",
"GitHub.triggering_actor",
Confidence::High,
),
(
"'dependabot[bot]' == github.actor || true",
"github.actor",
Confidence::High,
),
(
"'dependabot[bot]' == github.actor || false",
"github.actor",
Confidence::High,
),
(
"'dependabot[bot]' == github.actor || github.actor == 'foobar'",
"github.actor",
Confidence::High,
),
(
"github.actor == 'foobar' || 'dependabot[bot]' == github.actor",
"github.actor",
Confidence::High,
),
(
"'dependabot[bot]' == github.actor && something.else",
"github.actor",
Confidence::Medium,
),
(
"something.else && 'dependabot[bot]' == github.actor",
"github.actor",
Confidence::Medium,
),
] {
let cond = Expr::parse(cond).unwrap();
let (_, found_context, found_confidence) = BotConditions::bot_condition(&cond).unwrap();
assert_eq!(found_context.origin.raw, *context);
assert_eq!(found_confidence, *confidence);
}
}
#[tokio::test]
async fn test_replace_actor_fix() {
let workflow_content = r#"
name: Test Workflow
on:
pull_request_target:
jobs:
test:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Test Step
if: github.actor == 'dependabot[bot]'
run: echo "hello"
"#;
test_workflow_audit!(
BotConditions,
"test_replace_actor_fix.yml",
workflow_content,
|workflow: &Workflow, findings: Vec<Finding>| {
let mut document = workflow.as_document().clone();
for finding in &findings {
for fix in &finding.fixes {
if fix.title.contains("replace spoofable actor context") {
if let Ok(new_content) = fix.apply(&document) {
document = new_content;
}
}
}
}
insta::assert_snapshot!(document.source(), @r#"
name: Test Workflow
on:
pull_request_target:
jobs:
test:
runs-on: ubuntu-latest
if: github.event.pull_request.user.login == 'dependabot[bot]'
steps:
- name: Test Step
if: github.event.pull_request.user.login == 'dependabot[bot]'
run: echo "hello"
"#);
}
);
}
#[tokio::test]
async fn test_all_fixes_together() {
let workflow_content = r#"
name: Test Workflow
on:
pull_request_target:
jobs:
test:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Test Step
if: GITHUB['actor'] == 'dependabot[bot]'
run: echo "hello"
"#;
test_workflow_audit!(
BotConditions,
"test_all_fixes_together.yml",
workflow_content,
|workflow: &Workflow, findings: Vec<Finding>| {
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 Workflow
on:
pull_request_target:
jobs:
test:
runs-on: ubuntu-latest
if: github.event.pull_request.user.login == 'dependabot[bot]'
steps:
- name: Test Step
if: github.event.pull_request.user.login == 'dependabot[bot]'
run: echo "hello"
"#);
}
);
}
#[tokio::test]
async fn test_event_specific_contexts() {
let issue_comment_workflow = r#"
name: Test Issue Comment
on: issue_comment
jobs:
test:
runs-on: ubuntu-latest
if: github.ACTOR == 'dependabot[bot]'
steps:
- name: Test Step
if: github.actor == 'dependabot[bot]'
run: echo "hello"
"#;
test_workflow_audit!(
BotConditions,
"test_issue_comment.yml",
issue_comment_workflow,
|workflow: &Workflow, findings: Vec<Finding>| {
let mut document = workflow.as_document().clone();
for finding in &findings {
for fix in &finding.fixes {
if fix.title.contains("replace spoofable actor context") {
if let Ok(new_document) = fix.apply(&document) {
document = new_document;
}
}
}
}
insta::assert_snapshot!(document.source(), @r#"
name: Test Issue Comment
on: issue_comment
jobs:
test:
runs-on: ubuntu-latest
if: github.event.comment.user.login == 'dependabot[bot]'
steps:
- name: Test Step
if: github.event.comment.user.login == 'dependabot[bot]'
run: echo "hello"
"#);
}
);
let pr_review_workflow = r#"
name: Test PR Review
on: pull_request_review
jobs:
test:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Test Step
if: github.actor == 'dependabot[bot]'
run: echo "hello"
"#;
test_workflow_audit!(
BotConditions,
"test_pr_review.yml",
pr_review_workflow,
|workflow: &Workflow, findings: Vec<Finding>| {
let mut document = workflow.as_document().clone();
for finding in &findings {
for fix in &finding.fixes {
if fix.title.contains("replace spoofable actor context") {
if let Ok(new_document) = fix.apply(&document) {
document = new_document;
}
}
}
}
insta::assert_snapshot!(document.source(), @r#"
name: Test PR Review
on: pull_request_review
jobs:
test:
runs-on: ubuntu-latest
if: github.event.review.user.login == 'dependabot[bot]'
steps:
- name: Test Step
if: github.event.review.user.login == 'dependabot[bot]'
run: echo "hello"
"#);
}
);
let issues_workflow = r#"
name: Test Issues
on: issues
jobs:
test:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Test Step
if: github.actor == 'dependabot[bot]'
run: echo "hello"
"#;
test_workflow_audit!(
BotConditions,
"test_issues.yml",
issues_workflow,
|workflow: &Workflow, findings: Vec<Finding>| {
let mut document = workflow.as_document().clone();
for finding in &findings {
for fix in &finding.fixes {
if fix.title.contains("replace spoofable actor context") {
if let Ok(new_document) = fix.apply(&document) {
document = new_document;
}
}
}
}
insta::assert_snapshot!(document.source(), @r#"
name: Test Issues
on: issues
jobs:
test:
runs-on: ubuntu-latest
if: github.event.issue.user.login == 'dependabot[bot]'
steps:
- name: Test Step
if: github.event.issue.user.login == 'dependabot[bot]'
run: echo "hello"
"#);
}
);
let release_workflow = r#"
name: Test Release
on: release
jobs:
test:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Test Step
if: github.actor == 'dependabot[bot]'
run: echo "hello"
"#;
test_workflow_audit!(
BotConditions,
"test_release.yml",
release_workflow,
|workflow: &Workflow, findings: Vec<Finding>| {
let mut document = workflow.as_document().clone();
for finding in &findings {
for fix in &finding.fixes {
if fix.title.contains("replace spoofable actor context") {
if let Ok(new_document) = fix.apply(&document) {
document = new_document;
}
}
}
}
insta::assert_snapshot!(document.source(), @r#"
name: Test Release
on: release
jobs:
test:
runs-on: ubuntu-latest
if: github.event.release.author.login == 'dependabot[bot]'
steps:
- name: Test Step
if: github.event.release.author.login == 'dependabot[bot]'
run: echo "hello"
"#);
}
);
let create_workflow = r#"
name: Test Create
on: create
jobs:
test:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Test Step
if: github.actor == 'dependabot[bot]'
run: echo "hello"
"#;
test_workflow_audit!(
BotConditions,
"test_create.yml",
create_workflow,
|workflow: &Workflow, findings: Vec<Finding>| {
let mut document = workflow.as_document().clone();
for finding in &findings {
for fix in &finding.fixes {
if fix.title.contains("replace spoofable actor context") {
if let Ok(new_document) = fix.apply(&document) {
document = new_document;
}
}
}
}
insta::assert_snapshot!(document.source(), @r#"
name: Test Create
on: create
jobs:
test:
runs-on: ubuntu-latest
if: github.event.sender.login == 'dependabot[bot]'
steps:
- name: Test Step
if: github.event.sender.login == 'dependabot[bot]'
run: echo "hello"
"#);
}
);
}
#[tokio::test]
async fn test_fix_with_complex_conditions() {
let workflow_content = r#"
name: Test Workflow
on:
pull_request_target:
jobs:
test:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]' || github.actor == 'renovate[bot]'
steps:
- name: Test Step
if: github.actor == 'dependabot[bot]' && contains(github.event.pull_request.title, 'chore')
run: echo "hello"
"#;
test_workflow_audit!(
BotConditions,
"test_fix_with_complex_conditions.yml",
workflow_content,
|workflow: &Workflow, findings: Vec<Finding>| {
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 Workflow
on:
pull_request_target:
jobs:
test:
runs-on: ubuntu-latest
if: github.event.pull_request.user.login == 'dependabot[bot]' || github.actor == 'renovate[bot]'
steps:
- name: Test Step
if: github.event.pull_request.user.login == 'dependabot[bot]' && contains(github.event.pull_request.title, 'chore')
run: echo "hello"
"#);
}
);
}
}