use std::collections::BTreeMap;
use starlark::syntax::AstModule;
use starlark_syntax::syntax::ast::{
ArgumentP, AssignTargetP, AstArgumentP, AstExprP, AstNoPayload, AstStmtP, BinOp, ClauseP,
ExprP, StmtP,
};
use starlark_syntax::syntax::uniplate::Visit;
const SECRET: &str = "secret";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Opaque {
Deferred,
Secret,
}
type Tainted = BTreeMap<String, Opaque>;
#[dacc_derive::doc_anchor(id = "config-comparisons")]
pub(crate) fn comparisons(module: &AstModule) -> Vec<String> {
let parameters = parameters(module);
let mut tainted = Tainted::new();
loop {
let before = tainted.len();
spread(&Visit::Stmt(module.statement()), &mut tainted, ¶meters);
if tainted.len() == before {
break;
}
}
let mut refusals = Vec::new();
walk(
module,
&tainted,
&Visit::Stmt(module.statement()),
&mut refusals,
);
refusals
}
fn spread(
node: &Visit<'_, AstNoPayload>,
tainted: &mut Tainted,
parameters: &BTreeMap<String, Vec<String>>,
) {
if let Visit::Stmt(statement) = node {
match &statement.node {
StmtP::Assign(assignment) => {
bind(
&assignment.lhs.node,
taint(&assignment.rhs, tainted),
tainted,
);
}
StmtP::AssignModify(target, _, rhs) => {
bind(&target.node, taint(rhs, tainted), tainted);
}
StmtP::For(loop_) => {
bind(&loop_.var.node, taint(&loop_.over, tainted), tainted);
}
StmtP::Def(definition) => {
if let Some(kind) = returned(&definition.body, tainted) {
tainted.entry(definition.name.ident.clone()).or_insert(kind);
}
}
_ => {}
}
}
if let Visit::Expr(expression) = node {
match &expression.node {
ExprP::Call(callee, arguments) => {
if let ExprP::Identifier(name) = &callee.node
&& let Some(names) = parameters.get(&name.node.ident)
{
let mut positional = 0;
for argument in &arguments.args {
let ArgumentP::Positional(expression) = &argument.node else {
continue;
};
if let Some(kind) = taint(expression, tainted)
&& let Some(parameter) = names.get(positional)
{
tainted.entry(parameter.clone()).or_insert(kind);
}
positional += 1;
}
}
}
ExprP::ListComprehension(_, clause, rest)
| ExprP::DictComprehension(_, clause, rest) => {
bind(&clause.var.node, taint(&clause.over, tainted), tainted);
for further in rest {
if let ClauseP::For(clause) = further {
bind(&clause.var.node, taint(&clause.over, tainted), tainted);
}
}
}
_ => {}
}
}
node.visit_children(|child| spread(&child, tainted, parameters));
}
fn parameters(module: &AstModule) -> BTreeMap<String, Vec<String>> {
let mut found = BTreeMap::new();
collect_parameters(&Visit::Stmt(module.statement()), &mut found);
found
}
fn collect_parameters(node: &Visit<'_, AstNoPayload>, found: &mut BTreeMap<String, Vec<String>>) {
if let Visit::Stmt(statement) = node
&& let StmtP::Def(definition) = &statement.node
{
let names: Vec<String> = definition
.params
.iter()
.filter_map(|parameter| parameter.node.ident())
.map(|name| name.ident.clone())
.collect();
found.insert(definition.name.ident.clone(), names);
}
node.visit_children(|child| collect_parameters(&child, found));
}
fn returned(body: &AstStmtP<AstNoPayload>, tainted: &Tainted) -> Option<Opaque> {
let mut found = None;
returns(&Visit::Stmt(body), tainted, &mut found);
found
}
fn returns(node: &Visit<'_, AstNoPayload>, tainted: &Tainted, found: &mut Option<Opaque>) {
if found.is_none()
&& let Visit::Stmt(statement) = node
&& let StmtP::Return(Some(expression)) = &statement.node
{
*found = taint(expression, tainted);
}
node.visit_children(|child| returns(&child, tainted, found));
}
fn bind(target: &AssignTargetP<AstNoPayload>, kind: Option<Opaque>, tainted: &mut Tainted) {
let Some(kind) = kind else {
return;
};
match target {
AssignTargetP::Identifier(name) => {
tainted.entry(name.node.ident.clone()).or_insert(kind);
}
AssignTargetP::Tuple(parts) => {
for part in parts {
bind(&part.node, Some(kind), tainted);
}
}
AssignTargetP::Index(..) | AssignTargetP::Dot(..) => {}
}
}
fn taint(expression: &AstExprP<AstNoPayload>, tainted: &Tainted) -> Option<Opaque> {
match &expression.node {
ExprP::Dot(..) => Some(Opaque::Deferred),
ExprP::Identifier(name) => tainted.get(&name.node.ident).copied(),
ExprP::Call(callee, arguments) => {
let held = match &callee.node {
ExprP::Identifier(name) if name.node.ident == SECRET => Some(Opaque::Secret),
ExprP::Dot(receiver, _) => taint(receiver, tainted),
_ => taint(callee, tainted),
};
held.or_else(|| {
arguments
.args
.iter()
.find_map(|argument| handed(argument, tainted))
})
}
ExprP::Index(pair) => taint(&pair.0, tainted),
ExprP::Index2(triple) => taint(&triple.0, tainted),
ExprP::Slice(base, ..) => taint(base, tainted),
ExprP::List(items) | ExprP::Tuple(items) => {
items.iter().find_map(|item| taint(item, tainted))
}
ExprP::Dict(pairs) => pairs
.iter()
.find_map(|(key, value)| taint(key, tainted).or_else(|| taint(value, tainted))),
ExprP::Op(left, _, right) => taint(left, tainted).or_else(|| taint(right, tainted)),
ExprP::If(triple) => taint(&triple.1, tainted).or_else(|| taint(&triple.2, tainted)),
ExprP::Not(inner) | ExprP::Minus(inner) | ExprP::Plus(inner) | ExprP::BitNot(inner) => {
taint(inner, tainted)
}
ExprP::ListComprehension(element, clause, _) => {
taint(element, tainted).or_else(|| taint(&clause.over, tainted))
}
ExprP::DictComprehension(pair, clause, _) => taint(&pair.0, tainted)
.or_else(|| taint(&pair.1, tainted))
.or_else(|| taint(&clause.over, tainted)),
ExprP::Lambda(lambda) => taint(&lambda.body, tainted),
ExprP::Literal(_) | ExprP::FString(_) => None,
}
}
fn handed(argument: &AstArgumentP<AstNoPayload>, tainted: &Tainted) -> Option<Opaque> {
match &argument.node {
ArgumentP::Positional(expression)
| ArgumentP::Named(_, expression)
| ArgumentP::Args(expression)
| ArgumentP::KwArgs(expression) => taint(expression, tainted),
}
}
fn walk(
module: &AstModule,
tainted: &Tainted,
node: &Visit<'_, AstNoPayload>,
refusals: &mut Vec<String>,
) {
if let Visit::Expr(expression) = node
&& let ExprP::Op(left, operator, right) = &expression.node
&& matches!(
operator,
BinOp::Equal | BinOp::NotEqual | BinOp::In | BinOp::NotIn
)
&& let Some((kind, named)) = offending(left, right, tainted)
{
let complaint = match kind {
Opaque::Deferred => format!(
"{named} will not exist until apply, and comparing it with {} is \
silently false rather than an error. Pass the value to another \
resource instead of testing it",
describe(*operator)
),
Opaque::Secret => format!(
"{named} is a secret, and comparing it with {} is silently false \
rather than an error. Pass it to a resource instead of testing it",
describe(*operator)
),
};
refusals.push(format!(
"{}: {complaint}",
module.file_span(expression.span)
));
}
node.visit_children(|child| walk(module, tainted, &child, refusals));
}
fn offending(
left: &AstExprP<AstNoPayload>,
right: &AstExprP<AstNoPayload>,
tainted: &Tainted,
) -> Option<(Opaque, String)> {
[left, right]
.into_iter()
.find_map(|side| taint(side, tainted).map(|kind| (kind, name_of(side))))
}
fn name_of(expression: &AstExprP<AstNoPayload>) -> String {
match &expression.node {
ExprP::Identifier(name) => name.node.ident.clone(),
ExprP::Dot(..) => attribute_path(expression).unwrap_or_else(|| "this value".to_owned()),
ExprP::Call(callee, _) => match &callee.node {
ExprP::Identifier(name) => format!("{}(...)", name.node.ident),
_ => "this value".to_owned(),
},
ExprP::Index(pair) => format!("{}[...]", name_of(&pair.0)),
ExprP::Index2(triple) => format!("{}[...]", name_of(&triple.0)),
ExprP::Slice(base, ..) => format!("{}[...]", name_of(base)),
_ => "this value".to_owned(),
}
}
fn attribute_path(expression: &AstExprP<AstNoPayload>) -> Option<String> {
let ExprP::Dot(receiver, attribute) = &expression.node else {
return None;
};
let base = match &receiver.node {
ExprP::Identifier(name) => name.node.ident.clone(),
ExprP::Dot(..) => attribute_path(receiver)?,
_ => return None,
};
Some(format!("{base}.{}", attribute.node))
}
fn describe(operator: BinOp) -> &'static str {
match operator {
BinOp::Equal => "==",
BinOp::NotEqual => "!=",
BinOp::In => "in",
BinOp::NotIn => "not in",
_ => "that comparison",
}
}