use std::ops::Deref;
use github_actions_expressions::{
Expr, SpannedExpr,
call::{Call, Function},
};
use crate::{
audit::AuditError,
finding::{
Confidence, Severity,
location::{Feature, Location},
},
utils::parse_fenced_expressions_from_routable,
};
use super::{Audit, AuditInput, AuditLoadError, AuditState, audit_meta};
pub(crate) struct OverprovisionedSecrets;
audit_meta!(
OverprovisionedSecrets,
"overprovisioned-secrets",
"excessively provisioned secrets"
);
#[async_trait::async_trait]
impl Audit for OverprovisionedSecrets {
fn new(_state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
Ok(Self)
}
async fn audit_raw<'doc>(
&self,
input: &'doc AuditInput,
_config: &crate::config::Config,
) -> Result<Vec<super::Finding<'doc>>, AuditError> {
let mut findings = vec![];
for (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;
};
for _ in Self::secrets_expansions(&parsed) {
findings.push(
Self::finding()
.confidence(Confidence::High)
.severity(Severity::Medium)
.add_raw_location(Location::new(
input
.location()
.annotated("injects the entire secrets context into the runner")
.primary(),
Feature::from_span(&span, input),
))
.build(input)
.map_err(Self::err)?,
);
}
}
findings.len();
Ok(findings)
}
}
impl OverprovisionedSecrets {
fn secrets_expansions(expr: &SpannedExpr) -> Vec<()> {
let mut results = vec![];
match &expr.inner {
Expr::Call(Call { func, args }) => {
if matches!(func, Function::ToJSON)
&& args.iter().any(
|arg| matches!(arg.deref(), Expr::Context(ctx) if ctx.matches("secrets")),
)
{
results.push(());
} else {
results.extend(args.iter().flat_map(Self::secrets_expansions));
}
}
Expr::Index(expr) => results.extend(Self::secrets_expansions(expr)),
Expr::Context(ctx) => {
match ctx.parts.as_slice() {
[
SpannedExpr {
inner: Expr::Identifier(ident),
..
},
SpannedExpr {
inner: Expr::Index(idx),
..
},
] if ident == "secrets" && !idx.is_literal() => results.push(()),
_ => results.extend(ctx.parts.iter().flat_map(Self::secrets_expansions)),
}
}
Expr::BinOp { lhs, op: _, rhs } => {
results.extend(Self::secrets_expansions(lhs));
results.extend(Self::secrets_expansions(rhs));
}
Expr::UnOp { op: _, expr } => results.extend(Self::secrets_expansions(expr)),
_ => (),
}
results
}
}
#[cfg(test)]
mod tests {
use github_actions_expressions::Expr;
#[test]
fn test_secrets_expansions() {
for (expr, count) in &[
("secrets", 0),
("toJSON(secrets.foo)", 0),
("toJSON(secrets)", 1),
("tojson(secrets)", 1),
("toJSON(SECRETS)", 1),
("tOjSoN(sECrEtS)", 1),
("false || toJSON(secrets)", 1),
("toJSON(secrets) || toJSON(secrets)", 2),
("format('{0}', toJSON(secrets))", 1),
("secrets[format('GH_PAT_%s', matrix.env)]", 1),
("SECRETS[format('GH_PAT_%s', matrix.env)]", 1),
("SECRETS[something.else]", 1),
("SECRETS['literal']", 0),
] {
let expr = Expr::parse(expr).unwrap();
assert_eq!(
super::OverprovisionedSecrets::secrets_expansions(&expr).len(),
*count
);
}
}
}