use crate::linter::{Diagnostic, LintResult, Severity, Span};
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
let lines: Vec<&str> = source.lines().collect();
let mut stages = Vec::new();
let mut current_stage: Option<DockerStage> = None;
for (line_num, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some(stripped) = trimmed.strip_prefix("FROM ") {
if let Some(stage) = current_stage.take() {
stages.push(stage);
}
let from_image = stripped.trim();
let is_scratch = from_image.starts_with("scratch");
let is_named_stage = from_image.contains(" AS ");
current_stage = Some(DockerStage {
line: line_num + 1,
from_image: from_image.to_string(),
is_scratch,
is_named_stage,
has_user: false,
});
}
if trimmed.starts_with("USER ") {
if let Some(ref mut stage) = current_stage {
stage.has_user = true;
}
}
}
if let Some(stage) = current_stage {
stages.push(stage);
}
if let Some(final_stage) = stages.last() {
if !final_stage.is_scratch && !final_stage.has_user {
let span = Span::new(final_stage.line, 1, final_stage.line, 5);
let diag = Diagnostic::new(
"DOCKER001",
Severity::Warning,
"Missing USER directive - container runs as root (security risk). Add USER directive before CMD/ENTRYPOINT".to_string(),
span,
);
result.add(diag);
}
}
result
}
#[derive(Debug)]
struct DockerStage {
line: usize,
from_image: String,
is_scratch: bool,
is_named_stage: bool,
has_user: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_DOCKER001_missing_user_directive() {
let dockerfile = "FROM debian:12-slim\nCOPY app /app\n";
let result = check(dockerfile);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "DOCKER001");
assert!(diag.message.contains("USER"));
}
#[test]
fn test_DOCKER001_scratch_no_warning() {
let dockerfile = "FROM scratch\nCOPY app /app\n";
let result = check(dockerfile);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_DOCKER001_user_present() {
let dockerfile = "FROM debian:12-slim\nUSER appuser\nCOPY app /app\n";
let result = check(dockerfile);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_DOCKER001_multi_stage_final_no_user() {
let dockerfile =
"FROM debian AS builder\nRUN build\n\nFROM debian\nCOPY --from=builder /app /app\n";
let result = check(dockerfile);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_DOCKER001_multi_stage_final_scratch() {
let dockerfile =
"FROM debian AS builder\nRUN build\n\nFROM scratch\nCOPY --from=builder /app /app\n";
let result = check(dockerfile);
assert_eq!(result.diagnostics.len(), 0);
}
#[cfg(test)]
mod property_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(10))]
#[test]
fn prop_never_panics(dockerfile in ".*") {
let _ = check(&dockerfile);
}
#[test]
fn prop_scratch_never_warns(
commands in prop::collection::vec("(RUN|COPY|CMD|ENTRYPOINT) .*", 0..10)
) {
let dockerfile = format!("FROM scratch\n{}", commands.join("\n"));
let result = check(&dockerfile);
prop_assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn prop_with_user_never_warns(
base_image in "[a-z]+:[0-9.]+",
username in "[a-z][a-z0-9_]*"
) {
let dockerfile = format!("FROM {}\nUSER {}\nCMD [\"app\"]", base_image, username);
let result = check(&dockerfile);
prop_assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn prop_without_user_warns(
base_image in "[a-z]+:[0-9.]+"
) {
let dockerfile = format!("FROM {}\nCMD [\"app\"]", base_image);
let result = check(&dockerfile);
prop_assert_eq!(result.diagnostics.len(), 1);
}
}
}
}