fn calculate_determinism_score(source: &str) -> f64 {
if source.trim().is_empty() {
return 0.0;
}
let (has_pinned_base_image, uses_latest_tag, package_installs, pinned_packages) =
scan_determinism_indicators(source);
let score = score_base_image_pinning(has_pinned_base_image, uses_latest_tag)
+ score_package_pinning(package_installs, pinned_packages);
score.min(10.0)
}
fn scan_determinism_indicators(source: &str) -> (bool, bool, u32, u32) {
let mut has_pinned_base_image = false;
let mut uses_latest_tag = false;
let mut package_installs = 0u32;
let mut pinned_packages = 0u32;
for line in source.lines() {
let trimmed = line.trim();
if trimmed.starts_with("FROM ") {
if trimmed.contains(":latest") || (!trimmed.contains(':') && !trimmed.contains('@')) {
uses_latest_tag = true;
} else if trimmed.contains(':') {
has_pinned_base_image = true;
}
}
if trimmed.starts_with("RUN ") {
let is_pkg_install = trimmed.contains("apk add")
|| trimmed.contains("apt-get install")
|| trimmed.contains("yum install");
if is_pkg_install {
package_installs += 1;
if trimmed.contains('=') && (trimmed.contains("apk add") || trimmed.contains("apt"))
{
pinned_packages += 1;
}
}
}
}
(
has_pinned_base_image,
uses_latest_tag,
package_installs,
pinned_packages,
)
}
fn score_base_image_pinning(has_pinned: bool, uses_latest: bool) -> f64 {
match (has_pinned, uses_latest) {
(true, false) => 5.0,
(true, true) => 3.0,
(false, false) => 1.0,
(false, true) => 0.0,
}
}
fn score_package_pinning(installs: u32, pinned: u32) -> f64 {
if installs > 0 {
(pinned as f64 / installs as f64) * 5.0
} else {
2.5 }
}
fn calculate_security_score(source: &str) -> f64 {
if source.trim().is_empty() {
return 0.0;
}
let is_final_stage_scratch = detect_final_stage_scratch(source);
let mut has_user_directive = false;
let mut uses_copy_not_add = true;
let mut has_bad_permissions = false;
let mut potential_secrets = false;
for line in source.lines() {
let trimmed = line.trim();
if trimmed.starts_with("USER ") && !trimmed.contains("USER root") {
has_user_directive = true;
}
if trimmed.starts_with("ADD ") && !trimmed.contains(".tar") {
uses_copy_not_add = false;
}
if trimmed.contains("chmod 777") || trimmed.contains("chmod 666") {
has_bad_permissions = true;
}
if trimmed.contains("PASSWORD")
|| trimmed.contains("SECRET")
|| trimmed.contains("API_KEY")
|| trimmed.contains("TOKEN")
{
potential_secrets = true;
}
}
let mut score: f64 = 5.0;
if is_final_stage_scratch {
score += 4.0; } else {
if has_user_directive {
score += 4.0;
} else {
score -= 2.0; }
}
if uses_copy_not_add {
score += 1.0;
}
if has_bad_permissions {
score -= 2.0;
}
if potential_secrets {
score -= 1.0;
}
score.clamp(0.0, 10.0)
}
fn detect_final_stage_scratch(source: &str) -> bool {
let mut last_from_is_scratch = false;
for line in source.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some(stripped) = trimmed.strip_prefix("FROM ") {
let from_image = stripped.trim();
last_from_is_scratch = from_image.starts_with("scratch");
}
}
last_from_is_scratch
}
fn calculate_grade(score: f64) -> String {
match score {
s if s >= 9.5 => "A+".to_string(),
s if s >= 9.0 => "A".to_string(),
s if s >= 8.5 => "B+".to_string(),
s if s >= 8.0 => "B".to_string(),
s if s >= 7.5 => "C+".to_string(),
s if s >= 7.0 => "C".to_string(),
s if s >= 6.0 => "D".to_string(),
_ => "F".to_string(),
}
}
fn generate_suggestions(source: &str, score: &DockerfileQualityScore) -> Vec<String> {
let mut suggestions = Vec::new();
let is_final_stage_scratch = detect_final_stage_scratch(source);
if score.safety < 7.0 {
let mut has_pipefail = false;
for line in source.lines() {
if line.contains("set -euo pipefail") {
has_pipefail = true;
break;
}
}
if !has_pipefail {
suggestions.push(
"Add 'set -euo pipefail &&' at the beginning of RUN commands for better error handling".to_string()
);
}
}
if score.layer_optimization < 7.0 {
suggestions.push("Combine RUN commands with && to reduce image layers".to_string());
suggestions.push(
"Clean up package manager cache in the same layer (rm -rf /var/cache/apk/*)"
.to_string(),
);
suggestions.push("Consider using --no-cache flag for package managers".to_string());
}
if score.determinism < 7.0 {
let has_latest = source.contains(":latest");
let has_version_pinning = source.contains('=');
if has_latest || !has_version_pinning {
suggestions
.push("Pin package versions for reproducibility (e.g., curl=8.2.1-r0)".to_string());
suggestions
.push("Use specific image tags instead of :latest (e.g., alpine:3.18)".to_string());
}
}
if score.security < 7.0 {
let has_user = source.contains("USER ");
if !is_final_stage_scratch && (!has_user || source.contains("USER root")) {
suggestions.push("Add USER directive to run container as non-root user".to_string());
suggestions.push("Create a dedicated user with adduser/addgroup".to_string());
}
}
if score.complexity < 7.0 {
suggestions
.push("Reduce the number of separate RUN commands by combining them".to_string());
suggestions
.push("Consider using multi-stage builds to reduce final image size".to_string());
}
suggestions
}
#[cfg(test)]
#[path = "dockerfile_scoring_tests_score_empty.rs"]
mod tests_extracted;