use std::ops::Deref;
use github_actions_models::action;
use github_actions_models::common::Env;
use github_actions_models::common::expr::LoE;
use github_actions_models::workflow::job::StepBody;
use yamlpatch::{Op, Patch};
use super::{AuditLoadError, Job, audit_meta};
use crate::audit::{Audit, AuditError};
use crate::config::Config;
use crate::finding::location::Locatable as _;
use crate::finding::{
Confidence, Finding, Fix, FixDisposition, Persona, Severity, location::SymbolicLocation,
};
use crate::models::{AsDocument, workflow::Steps, workflow::Workflow};
use crate::state::AuditState;
pub(crate) struct InsecureCommands;
audit_meta!(
InsecureCommands,
"insecure-commands",
"execution of insecure workflow commands is enabled"
);
impl InsecureCommands {
fn create_fix<'doc>(&self, location: SymbolicLocation<'doc>) -> Fix<'doc> {
Fix {
title: "remove ACTIONS_ALLOW_UNSECURE_COMMANDS environment variable".into(),
key: location.key,
disposition: FixDisposition::default(),
patches: vec![Patch {
route: location
.route
.with_keys(["env".into(), "ACTIONS_ALLOW_UNSECURE_COMMANDS".into()]),
operation: Op::Remove,
}],
}
}
fn insecure_commands_maybe_present<'a, 'doc>(
&self,
doc: &'a impl AsDocument<'a, 'doc>,
location: SymbolicLocation<'doc>,
) -> Result<Finding<'doc>, AuditError> {
Self::finding()
.confidence(Confidence::Low)
.severity(Severity::High)
.persona(Persona::Auditor)
.add_location(
location.primary().with_keys(["env".into()]).annotated(
"non-static environment may contain ACTIONS_ALLOW_UNSECURE_COMMANDS",
),
)
.build(doc)
.map_err(Self::err)
}
fn insecure_commands_allowed<'s, 'doc>(
&self,
doc: &'s impl AsDocument<'s, 'doc>,
location: SymbolicLocation<'doc>,
) -> Result<Finding<'doc>, AuditError> {
let fix = self.create_fix(location.clone());
Self::finding()
.confidence(Confidence::High)
.severity(Severity::High)
.add_location(
location
.primary()
.with_keys(["env".into()])
.annotated("insecure commands enabled here"),
)
.fix(fix)
.build(doc)
.map_err(Self::err)
}
fn has_insecure_commands_enabled(&self, env: &Env) -> bool {
match env.get("ACTIONS_ALLOW_UNSECURE_COMMANDS") {
Some(value) => value.csharp_trueish(),
None => false,
}
}
fn audit_steps<'doc>(
&self,
workflow: &'doc Workflow,
steps: Steps<'doc>,
) -> Result<Vec<Finding<'doc>>, AuditError> {
steps
.into_iter()
.filter_map(|step| {
let StepBody::Run {
run: _,
working_directory: _,
shell: _,
} = &step.deref().body
else {
return None;
};
match &step.env {
LoE::Expr(_) => {
Some(self.insecure_commands_maybe_present(workflow, step.location()))
}
LoE::Literal(env) => self
.has_insecure_commands_enabled(env)
.then(|| self.insecure_commands_allowed(workflow, step.location())),
}
})
.collect()
}
}
#[async_trait::async_trait]
impl Audit for InsecureCommands {
fn new(_state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
Ok(Self)
}
async fn audit_workflow<'doc>(
&self,
workflow: &'doc Workflow,
_config: &Config,
) -> Result<Vec<Finding<'doc>>, AuditError> {
let mut results = vec![];
match &workflow.env {
LoE::Expr(_) => {
results.push(self.insecure_commands_maybe_present(workflow, workflow.location())?)
}
LoE::Literal(env) => {
if self.has_insecure_commands_enabled(env) {
results.push(self.insecure_commands_allowed(workflow, workflow.location())?)
}
}
}
for job in workflow.jobs() {
if let Job::NormalJob(normal) = job {
match &normal.env {
LoE::Expr(_) => results
.push(self.insecure_commands_maybe_present(workflow, normal.location())?),
LoE::Literal(env) => {
if self.has_insecure_commands_enabled(env) {
results
.push(self.insecure_commands_allowed(workflow, normal.location())?);
}
}
}
results.extend(self.audit_steps(workflow, normal.steps())?)
}
}
Ok(results)
}
async fn audit_composite_step<'doc>(
&self,
step: &super::CompositeStep<'doc>,
_config: &Config,
) -> Result<Vec<Finding<'doc>>, AuditError> {
let mut findings = vec![];
let action::StepBody::Run { .. } = &step.body else {
return Ok(findings);
};
match &step.env {
LoE::Expr(_) => {
findings.push(self.insecure_commands_maybe_present(step.action(), step.location())?)
}
LoE::Literal(env) => {
if self.has_insecure_commands_enabled(env) {
findings.push(self.insecure_commands_allowed(step.action(), step.location())?);
}
}
}
Ok(findings)
}
}
#[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)
}};
}
#[tokio::test]
async fn test_insecure_commands_fix_generation() {
let workflow_content = r#"
on: push
jobs:
test:
runs-on: ubuntu-latest
env:
ACTIONS_ALLOW_UNSECURE_COMMANDS: true
OTHER_VAR: keep-me
steps:
- run: echo "test"
"#;
test_workflow_audit!(
InsecureCommands,
"test_fix.yml",
workflow_content,
|_workflow: &Workflow, findings: Vec<Finding>| {
assert_eq!(findings.len(), 1);
let finding = &findings[0];
assert_eq!(finding.ident, "insecure-commands");
assert_eq!(finding.fixes.len(), 1);
let fix = &finding.fixes[0];
assert_eq!(
fix.title,
"remove ACTIONS_ALLOW_UNSECURE_COMMANDS environment variable"
);
assert_eq!(fix.patches.len(), 1);
let patch = &fix.patches[0];
assert!(matches!(patch.operation, Op::Remove));
}
);
}
#[tokio::test]
async fn test_fix_removes_insecure_commands_preserves_others() {
let workflow_content = r#"
on: push
jobs:
test:
runs-on: ubuntu-latest
env:
ACTIONS_ALLOW_UNSECURE_COMMANDS: true
OTHER_VAR: keep-me
ANOTHER_VAR: also-keep
steps:
- run: echo "test"
"#;
test_workflow_audit!(
InsecureCommands,
"test_fix_preserve.yml",
workflow_content,
|workflow: &Workflow, findings: Vec<Finding>| {
assert_eq!(findings.len(), 1);
let finding = &findings[0];
assert_eq!(finding.fixes.len(), 1);
let fix = &finding.fixes[0];
let fixed_document = fix.apply(workflow.as_document()).unwrap();
assert!(
!fixed_document
.source()
.contains("ACTIONS_ALLOW_UNSECURE_COMMANDS")
);
assert!(fixed_document.source().contains("OTHER_VAR: keep-me"));
assert!(fixed_document.source().contains("ANOTHER_VAR: also-keep"));
insta::assert_snapshot!(fixed_document.source(), @r#"
on: push
jobs:
test:
runs-on: ubuntu-latest
env:
OTHER_VAR: keep-me
ANOTHER_VAR: also-keep
steps:
- run: echo "test"
"#);
}
);
}
#[tokio::test]
async fn test_workflow_level_insecure_commands_fix() {
let workflow_content = r#"
on: push
env:
ACTIONS_ALLOW_UNSECURE_COMMANDS: true
GLOBAL_VAR: keep-me
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo "test"
"#;
test_workflow_audit!(
InsecureCommands,
"test_workflow_fix.yml",
workflow_content,
|workflow: &Workflow, findings: Vec<Finding>| {
assert_eq!(findings.len(), 1);
let finding = &findings[0];
assert_eq!(finding.fixes.len(), 1);
let fix = &finding.fixes[0];
let fixed_document = fix.apply(workflow.as_document()).unwrap();
assert!(
!fixed_document
.source()
.contains("ACTIONS_ALLOW_UNSECURE_COMMANDS")
);
assert!(fixed_document.source().contains("GLOBAL_VAR: keep-me"));
insta::assert_snapshot!(fixed_document.source(), @r#"
on: push
env:
GLOBAL_VAR: keep-me
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo "test"
"#);
}
);
}
#[tokio::test]
async fn test_step_level_insecure_commands_fix() {
let workflow_content = r#"
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: step with insecure commands
run: echo "test"
env:
ACTIONS_ALLOW_UNSECURE_COMMANDS: true
STEP_VAR: keep-me
"#;
test_workflow_audit!(
InsecureCommands,
"test_step_fix.yml",
workflow_content,
|workflow: &Workflow, findings: Vec<Finding>| {
assert_eq!(findings.len(), 1);
let finding = &findings[0];
assert_eq!(finding.fixes.len(), 1);
let fix = &finding.fixes[0];
let fixed_document = fix.apply(workflow.as_document()).unwrap();
assert!(
!fixed_document
.source()
.contains("ACTIONS_ALLOW_UNSECURE_COMMANDS")
);
assert!(fixed_document.source().contains("STEP_VAR: keep-me"));
insta::assert_snapshot!(fixed_document.source(), @r#"
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: step with insecure commands
run: echo "test"
env:
STEP_VAR: keep-me
"#);
}
);
}
#[tokio::test]
async fn test_string_value_insecure_commands_fix() {
let workflow_content = r#"
on: push
jobs:
test:
runs-on: ubuntu-latest
env:
ACTIONS_ALLOW_UNSECURE_COMMANDS: "true"
OTHER_VAR: keep-me
steps:
- run: echo "test"
"#;
test_workflow_audit!(
InsecureCommands,
"test_string_fix.yml",
workflow_content,
|workflow: &Workflow, findings: Vec<Finding>| {
assert_eq!(findings.len(), 1);
let finding = &findings[0];
assert_eq!(finding.fixes.len(), 1);
let fix = &finding.fixes[0];
let fixed_document = fix.apply(workflow.as_document()).unwrap();
insta::assert_snapshot!(fixed_document.source(), @r#"
on: push
jobs:
test:
runs-on: ubuntu-latest
env:
OTHER_VAR: keep-me
steps:
- run: echo "test"
"#);
}
);
}
}