use super::discovery::{Artifact, ArtifactKind};
type ComplianceCheck<'a> = &'a [(&'a dyn Fn(&str) -> bool, &'a str)];
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum RuleId {
Posix,
Determinism,
Idempotency,
Security,
Quoting,
ShellCheck,
MakefileSafety,
DockerfileBest,
ConfigHygiene,
PzshBudget,
}
impl RuleId {
pub fn code(&self) -> &'static str {
match self {
RuleId::Posix => "COMPLY-001",
RuleId::Determinism => "COMPLY-002",
RuleId::Idempotency => "COMPLY-003",
RuleId::Security => "COMPLY-004",
RuleId::Quoting => "COMPLY-005",
RuleId::ShellCheck => "COMPLY-006",
RuleId::MakefileSafety => "COMPLY-007",
RuleId::DockerfileBest => "COMPLY-008",
RuleId::ConfigHygiene => "COMPLY-009",
RuleId::PzshBudget => "COMPLY-010",
}
}
pub fn name(&self) -> &'static str {
match self {
RuleId::Posix => "POSIX Compliance",
RuleId::Determinism => "Determinism",
RuleId::Idempotency => "Idempotency",
RuleId::Security => "Security",
RuleId::Quoting => "Variable Quoting",
RuleId::ShellCheck => "ShellCheck Clean",
RuleId::MakefileSafety => "Makefile Safety",
RuleId::DockerfileBest => "Dockerfile Best Practices",
RuleId::ConfigHygiene => "Config Hygiene",
RuleId::PzshBudget => "pzsh Startup Budget",
}
}
pub fn description(&self) -> &'static str {
match self {
RuleId::Posix => "Detects bash-specific constructs: [[ ]], (( )), <<<, select, ${var/}, pipefail, &>",
RuleId::Determinism => "Flags non-deterministic: $RANDOM, $SRANDOM, $BASHPID, $$, date, mktemp, shuf, /dev/urandom",
RuleId::Idempotency => "Requires safe-to-rerun: mkdir -p, rm -f, ln -sf, useradd guards, git clone checks",
RuleId::Security => "Checks SEC001-SEC008: eval injection, curl|bash, rm -rf, secrets, temp files",
RuleId::Quoting => "Detects unquoted variable expansions that risk word splitting or globbing",
RuleId::ShellCheck => "Lightweight ShellCheck: SC2006 backticks, SC2115 rm, SC2164 cd, SC2012 ls",
RuleId::MakefileSafety => "Makefile risks: eval in recipes, bare make, rm -rf with vars, missing .PHONY",
RuleId::DockerfileBest => "Dockerfile hygiene: unpinned base, ADD vs COPY, apt cleanup, USER directive",
RuleId::ConfigHygiene => "Config file quality: PATH manipulation, alias complexity, source safety",
RuleId::PzshBudget => "pzsh shell startup time budget enforcement",
}
}
pub fn applies_to(&self) -> &'static [&'static str] {
match self {
RuleId::Posix => &["shell"],
RuleId::Determinism => &["shell", "makefile"],
RuleId::Idempotency => &["shell", "makefile"],
RuleId::Security => &["shell", "makefile", "dockerfile", "config", "workflow"],
RuleId::Quoting => &["shell", "config"],
RuleId::ShellCheck => &["shell"],
RuleId::MakefileSafety => &["makefile"],
RuleId::DockerfileBest => &["dockerfile"],
RuleId::ConfigHygiene => &["config"],
RuleId::PzshBudget => &["config"],
}
}
pub fn all() -> &'static [RuleId] {
&[
RuleId::Posix,
RuleId::Determinism,
RuleId::Idempotency,
RuleId::Security,
RuleId::Quoting,
RuleId::ShellCheck,
RuleId::MakefileSafety,
RuleId::DockerfileBest,
RuleId::ConfigHygiene,
RuleId::PzshBudget,
]
}
pub fn weight(&self) -> u32 {
match self {
RuleId::Posix => 20,
RuleId::Determinism => 15,
RuleId::Idempotency => 15,
RuleId::Security => 20,
RuleId::Quoting => 10,
RuleId::ShellCheck => 10,
RuleId::MakefileSafety => 5,
RuleId::DockerfileBest => 5,
RuleId::ConfigHygiene => 5,
RuleId::PzshBudget => 5,
}
}
pub fn applicable_rules(kind: ArtifactKind) -> Vec<RuleId> {
match kind {
ArtifactKind::ShellScript => vec![
RuleId::Posix,
RuleId::Determinism,
RuleId::Idempotency,
RuleId::Security,
RuleId::Quoting,
RuleId::ShellCheck,
],
ArtifactKind::Makefile => vec![
RuleId::Determinism,
RuleId::Idempotency,
RuleId::Security,
RuleId::MakefileSafety,
],
ArtifactKind::Dockerfile => vec![RuleId::Security, RuleId::DockerfileBest],
ArtifactKind::ShellConfig => {
vec![RuleId::Security, RuleId::Quoting, RuleId::ConfigHygiene]
}
ArtifactKind::Workflow => vec![RuleId::Security],
ArtifactKind::DevContainer => vec![],
}
}
}
#[derive(Clone, Debug)]
pub struct RuleResult {
pub rule: RuleId,
pub passed: bool,
pub violations: Vec<Violation>,
}
#[derive(Clone, Debug)]
pub struct Violation {
pub rule: RuleId,
pub line: Option<usize>,
pub message: String,
}
impl std::fmt::Display for Violation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(line) = self.line {
write!(f, "{}: line {}: {}", self.rule.code(), line, self.message)
} else {
write!(f, "{}: {}", self.rule.code(), self.message)
}
}
}
pub fn check_rule(rule: RuleId, content: &str, artifact: &Artifact) -> RuleResult {
match rule {
RuleId::Determinism => check_determinism(content, artifact),
RuleId::Idempotency => check_idempotency(content, artifact),
RuleId::Security => check_security(content),
RuleId::Quoting => check_quoting(content),
RuleId::Posix => check_posix_patterns(content),
RuleId::ShellCheck => check_shellcheck_patterns(content),
RuleId::MakefileSafety => check_makefile_safety(content),
RuleId::DockerfileBest => check_dockerfile_best(content),
RuleId::ConfigHygiene => check_config_hygiene(content),
RuleId::PzshBudget => RuleResult {
rule,
passed: true,
violations: vec![],
}, }
}
fn check_determinism(content: &str, artifact: &Artifact) -> RuleResult {
let is_makefile = artifact.kind == ArtifactKind::Makefile;
let mut violations = Vec::new();
for (i, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
check_determinism_line(trimmed, i + 1, is_makefile, &mut violations);
}
RuleResult {
rule: RuleId::Determinism,
passed: violations.is_empty(),
violations,
}
}
fn check_determinism_line(
trimmed: &str,
line_num: usize,
is_makefile: bool,
violations: &mut Vec<Violation>,
) {
if trimmed.contains("$RANDOM") {
violations.push(Violation {
rule: RuleId::Determinism,
line: Some(line_num),
message: "Non-deterministic: $RANDOM".to_string(),
});
}
if trimmed.contains("$SRANDOM") {
violations.push(Violation {
rule: RuleId::Determinism,
line: Some(line_num),
message: "Non-deterministic: $SRANDOM".to_string(),
});
}
if trimmed.contains("$BASHPID") {
violations.push(Violation {
rule: RuleId::Determinism,
line: Some(line_num),
message: "Non-deterministic: $BASHPID".to_string(),
});
}
if !is_makefile && is_pid_usage(trimmed) {
violations.push(Violation {
rule: RuleId::Determinism,
line: Some(line_num),
message: "Non-deterministic: $$ (process ID)".to_string(),
});
}
if trimmed.contains("date +%s") || trimmed.contains("date +%N") {
violations.push(Violation {
rule: RuleId::Determinism,
line: Some(line_num),
message: "Non-deterministic: timestamp command".to_string(),
});
}
if trimmed.contains("/dev/urandom") || trimmed.contains("/dev/random") {
violations.push(Violation {
rule: RuleId::Determinism,
line: Some(line_num),
message: "Non-deterministic: /dev/urandom or /dev/random".to_string(),
});
}
if is_mktemp_call(trimmed) {
violations.push(Violation {
rule: RuleId::Determinism,
line: Some(line_num),
message: "Non-deterministic: mktemp generates random names".to_string(),
});
}
if is_shuf_call(trimmed) {
violations.push(Violation {
rule: RuleId::Determinism,
line: Some(line_num),
message: "Non-deterministic: shuf produces random output".to_string(),
});
}
}
fn is_mktemp_call(trimmed: &str) -> bool {
trimmed.starts_with("mktemp")
|| trimmed.contains("$(mktemp")
|| trimmed.contains("`mktemp")
|| trimmed.contains("| mktemp")
}
fn is_shuf_call(trimmed: &str) -> bool {
trimmed.starts_with("shuf ")
|| trimmed.starts_with("shuf\t")
|| trimmed.contains("| shuf")
|| trimmed.contains("$(shuf")
}
fn is_eval_command(trimmed: &str) -> bool {
if trimmed.starts_with("eval ") {
return true;
}
for sep in &["; ", "&& ", "|| "] {
if let Some(pos) = trimmed.find(sep) {
let after = trimmed[pos + sep.len()..].trim_start();
if after.starts_with("eval ") {
return true;
}
}
}
false
}
fn is_pid_usage(trimmed: &str) -> bool {
trimmed.contains("$$")
&& !trimmed.contains("\"$$")
&& !trimmed.contains("$$@")
&& !trimmed.contains("$$<")
&& !trimmed.contains("$$(")
&& !trimmed.contains("$$^")
}
fn check_idempotency(content: &str, artifact: &Artifact) -> RuleResult {
let mut violations = Vec::new();
if !matches!(
artifact.kind,
ArtifactKind::ShellScript | ArtifactKind::ShellConfig
) {
return RuleResult {
rule: RuleId::Idempotency,
passed: true,
violations,
};
}
for (i, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
check_idempotency_line(trimmed, i + 1, &mut violations);
}
RuleResult {
rule: RuleId::Idempotency,
passed: violations.is_empty(),
violations,
}
}
include!("rules_check.rs");