use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Severity {
Info,
Warn,
Error,
}
impl Severity {
pub(crate) fn as_str(self) -> &'static str {
match self {
Severity::Info => "info",
Severity::Warn => "warn",
Severity::Error => "error",
}
}
pub(crate) fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"info" => Some(Severity::Info),
"warn" | "warning" => Some(Severity::Warn),
"error" | "err" => Some(Severity::Error),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Issue {
pub rule_id: String,
pub severity: Severity,
pub env_name: Option<String>,
pub title: String,
pub detail: String,
pub suggestion: Option<String>,
pub fields: BTreeMap<String, String>,
}
#[derive(Debug, Clone)]
pub(crate) struct LintContext<'a> {
pub env: &'a crate::aws::Environment,
pub options: &'a [(String, String, String)],
pub newer_stack_available: Option<&'a str>,
pub required_tags: &'a [String],
pub env_tag_keys: Option<&'a [String]>,
pub dlq_depth: Option<i64>,
pub healthy_instance_count: Option<i64>,
pub xray_trace_denied: Option<bool>,
pub health_probe_failure: Option<&'a str>,
pub waf_missing: Option<bool>,
}
impl<'a> LintContext<'a> {
pub(crate) fn for_env(
env: &'a crate::aws::Environment,
options: &'a [(String, String, String)],
) -> Self {
Self {
env,
options,
newer_stack_available: None,
required_tags: &[],
env_tag_keys: None,
dlq_depth: None,
healthy_instance_count: None,
xray_trace_denied: None,
health_probe_failure: None,
waf_missing: None,
}
}
pub(crate) fn with_newer_stack_available(mut self, newer_stack: &'a str) -> Self {
self.newer_stack_available = Some(newer_stack);
self
}
pub(crate) fn with_required_tags(mut self, required_tags: &'a [String]) -> Self {
self.required_tags = required_tags;
self
}
pub(crate) fn with_env_tag_keys(mut self, env_tag_keys: &'a [String]) -> Self {
self.env_tag_keys = Some(env_tag_keys);
self
}
pub(crate) fn with_dlq_depth(mut self, dlq_depth: i64) -> Self {
self.dlq_depth = Some(dlq_depth);
self
}
pub(crate) fn with_healthy_count(mut self, healthy_instance_count: i64) -> Self {
self.healthy_instance_count = Some(healthy_instance_count);
self
}
pub(crate) fn with_xray_trace_denied(mut self, denied: bool) -> Self {
self.xray_trace_denied = Some(denied);
self
}
pub(crate) fn with_health_probe_failure(mut self, reason: &'a str) -> Self {
self.health_probe_failure = Some(reason);
self
}
pub(crate) fn with_waf_missing(mut self, missing: bool) -> Self {
self.waf_missing = Some(missing);
self
}
}
pub(crate) fn is_prod_named(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
lower.contains("prod") || lower.contains("prd")
}
pub(crate) trait Rule: Send + Sync {
fn id(&self) -> &'static str;
fn severity(&self) -> Severity;
fn applies(&self, ctx: &LintContext) -> Option<Issue>;
fn fix(&self, _ctx: &LintContext) -> Option<FixAction> {
None
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum FixAction {
SetOption {
namespace: String,
name: String,
value: String,
description: String,
},
Manual { instructions: String },
}
pub(crate) fn run_rules(rules: &[Box<dyn Rule>], ctx: &LintContext) -> Vec<Issue> {
let mut out: Vec<Issue> = rules.iter().filter_map(|r| r.applies(ctx)).collect();
out.sort_by(|a, b| {
b.severity
.cmp(&a.severity)
.then_with(|| a.rule_id.cmp(&b.rule_id))
});
out
}
pub(crate) fn render_report_json(issues: &[Issue], degraded_reasons: &[String]) -> String {
let issues_json = render_issues_json(issues);
let trimmed = issues_json
.strip_suffix('}')
.unwrap_or(issues_json.as_str());
let mut out = String::from(trimmed);
out.push_str(",\"degraded\":");
out.push_str(if degraded_reasons.is_empty() {
"false"
} else {
"true"
});
out.push_str(",\"degraded_reasons\":[");
for (i, r) in degraded_reasons.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push('"');
out.push_str(&json_escape(r));
out.push('"');
}
out.push_str("]}");
out
}
pub(crate) fn render_issues_json(issues: &[Issue]) -> String {
let mut out = String::from("{\"issues\":[");
for (i, issue) in issues.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push('{');
push_kv(&mut out, "rule_id", &issue.rule_id);
out.push(',');
push_kv(&mut out, "severity", issue.severity.as_str());
out.push(',');
if let Some(env) = &issue.env_name {
push_kv(&mut out, "env", env);
out.push(',');
}
push_kv(&mut out, "title", &issue.title);
out.push(',');
push_kv(&mut out, "detail", &issue.detail);
if let Some(s) = &issue.suggestion {
out.push(',');
push_kv(&mut out, "suggestion", s);
}
if !issue.fields.is_empty() {
out.push_str(",\"fields\":{");
for (j, (k, v)) in issue.fields.iter().enumerate() {
if j > 0 {
out.push(',');
}
push_kv(&mut out, k, v);
}
out.push('}');
}
out.push('}');
}
out.push_str("]}");
out
}
pub(crate) fn issue_identity_hash(
rule_id: &str,
env_name: Option<&str>,
fields: &BTreeMap<String, String>,
) -> String {
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
hasher.update(rule_id.as_bytes());
hasher.update(b"\0");
if let Some(env) = env_name {
hasher.update(env.as_bytes());
}
hasher.update(b"\0");
for (k, v) in fields {
hasher.update(k.as_bytes());
hasher.update(b"=");
hasher.update(v.as_bytes());
hasher.update(b"\0");
}
let digest = hasher.finalize();
digest[..8].iter().map(|b| format!("{b:02x}")).collect()
}
pub(crate) fn issue_identity(issue: &Issue) -> String {
issue_identity_hash(&issue.rule_id, issue.env_name.as_deref(), &issue.fields)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct BaselineIssue {
pub identity: String,
pub rule_id: String,
pub env_name: Option<String>,
pub title: String,
}
pub(crate) fn parse_baseline(text: &str) -> Result<Vec<BaselineIssue>, String> {
let value: serde_json::Value =
serde_json::from_str(text).map_err(|e| format!("baseline JSON parse failed: {e}"))?;
let issues = value
.get("issues")
.and_then(|v| v.as_array())
.ok_or_else(|| "baseline JSON missing `issues` array".to_string())?;
let mut out = Vec::with_capacity(issues.len());
for item in issues {
let Some(obj) = item.as_object() else {
continue;
};
let rule_id = obj
.get("rule_id")
.and_then(|v| v.as_str())
.ok_or_else(|| "baseline issue missing rule_id".to_string())?
.to_string();
let env_name = obj.get("env").and_then(|v| v.as_str()).map(String::from);
let title = obj
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let mut fields: BTreeMap<String, String> = BTreeMap::new();
if let Some(f) = obj.get("fields").and_then(|v| v.as_object()) {
for (k, v) in f {
if let Some(v_str) = v.as_str() {
fields.insert(k.to_string(), v_str.to_string());
}
}
}
let identity = issue_identity_hash(&rule_id, env_name.as_deref(), &fields);
out.push(BaselineIssue {
identity,
rule_id,
env_name,
title,
});
}
Ok(out)
}
fn push_kv(out: &mut String, k: &str, v: &str) {
out.push('"');
out.push_str(&json_escape(k));
out.push_str("\":\"");
out.push_str(&json_escape(v));
out.push('"');
}
use crate::util::json_escape;
pub(crate) fn option_value<'a>(
options: &'a [(String, String, String)],
namespace: &str,
name: &str,
) -> &'a str {
options
.iter()
.find(|(n, k, _)| n == namespace && k == name)
.map(|(_, _, v)| v.as_str())
.unwrap_or("")
}
fn parse_i32(s: &str) -> Option<i32> {
s.trim().parse().ok()
}
pub(crate) mod rules;
pub(crate) use rules::*;
#[cfg(test)]
mod tests;