Back to sh0
sh0

34 Rules to Catch Deployment Mistakes Before They Happen

We built a pure-Rust static analysis engine with 34 rules across 8 categories to catch security issues, misconfigurations, and deployment mistakes before they reach production.

Thales & Claude | March 25, 2026 12 min sh0
ruststatic-analysissecuritycode-healthdeploymentdevops

A successful build is not the same thing as a successful deployment.

Your Docker image builds cleanly. The container starts. Thirty seconds later it crashes because Django's DEBUG = True is on, there is no start script in package.json, an API key is hard-coded in the source, or the app binds to 127.0.0.1 instead of 0.0.0.0 and is unreachable from outside the container.

These are not build errors. They are deployment errors -- the kind that pass CI, survive code review, and only surface at 2 AM when a customer reports that your app is down. We decided sh0 should catch them before the container ever starts.

The result: a pure-Rust static analysis engine with 34 rules across 8 categories, built on Day Zero as Phase 6 of the sh0 development marathon.

The Shift: From "Does It Build?" to "Will It Run?"

When we finished the build engine (Phase 5), sh0 could detect a project's stack, generate a Dockerfile, and build a container image. That was necessary but insufficient. A PaaS that builds your code and then lets it fail in production is not much of a platform.

The insight was simple: most deployment failures are not exotic. They are the same 30 or 40 mistakes, repeated across millions of projects. A missing start script. A hard-coded localhost address. An exposed secret. A debug flag left on. These are all detectable from source code alone, without running the application, without an LLM, without any external service.

We wanted sh0 to say: "Your code built successfully, but here are 3 things that will probably break in production."

Architecture: Function Pointers and a Read-Once Context

The engine has three layers: the ScanContext that reads the project, the Rule structs that analyse it, and the Engine that orchestrates everything.

ScanContext: Read Once, Scan Many

The most important performance decision was reading every file exactly once. Each rule needs to search file contents for patterns -- API keys, configuration values, import statements. Letting each rule read files independently would mean reading the same package.json 34 times.

pub struct ScanContext {
    pub files: HashMap<PathBuf, String>,
    pub package_json: Option<serde_json::Value>,
    pub gitignore_patterns: Vec<String>,
    pub file_count: usize,
}

impl ScanContext { pub fn from_directory(path: &Path) -> Result { let mut files = HashMap::new();

for entry in WalkDir::new(path) .into_iter() .filter_entry(|e| !is_skippable(e)) { let entry = entry?; if !entry.file_type().is_file() { continue; } // Skip files larger than 1MB if entry.metadata()?.len() > 1_048_576 { continue; } if let Ok(content) = std::fs::read_to_string(entry.path()) { let rel = entry.path().strip_prefix(path)?; files.insert(rel.to_path_buf(), content); } }

let package_json = files.get(Path::new("package.json")) .and_then(|s| serde_json::from_str(s).ok());

let gitignore_patterns = files.get(Path::new(".gitignore")) .map(|s| s.lines().map(String::from).collect()) .unwrap_or_default();

let file_count = files.len();

Ok(Self { files, package_json, gitignore_patterns, file_count }) } } ```

The directory walker skips .git, node_modules, target, vendor, __pycache__, .venv, dist, .next, and .nuxt -- directories that can contain tens of thousands of files but never contain user-written source code. The 1MB size limit prevents binary files from consuming memory.

The context also provides helper methods that rules use constantly:

impl ScanContext {
    pub fn has_file(&self, name: &str) -> bool {
        self.files.contains_key(Path::new(name))
    }

pub fn read_file(&self, name: &str) -> Option<&str> { self.files.get(Path::new(name)).map(|s| s.as_str()) }

pub fn grep(&self, pattern: &str) -> Vec<(&Path, usize, &str)> { self.files.iter() .flat_map(|(path, content)| { content.lines().enumerate() .filter(|(_, line)| line.contains(pattern)) .map(move |(n, line)| (path.as_path(), n + 1, line)) }) .collect() }

pub fn grep_in(&self, file: &str, pattern: &str) -> Vec<(usize, &str)> { self.files.get(Path::new(file)) .map(|content| { content.lines().enumerate() .filter(|(_, line)| line.contains(pattern)) .map(|(n, line)| (n + 1, line)) .collect() }) .unwrap_or_default() }

pub fn is_test_file(&self, path: &Path) -> bool { let s = path.to_string_lossy(); s.contains("test") || s.contains("spec") || s.contains("__tests__") } } ```

The grep() method is the workhorse. It searches every file in the project for a pattern and returns the file path, line number, and line content. Rules use this to produce findings with precise locations: "API key found in src/config.js at line 42."

Rules: Function Pointers, Not Traits

Each rule is a struct containing a function pointer:

pub struct Rule {
    pub id: &'static str,
    pub name: &'static str,
    pub category: Category,
    pub severity: Severity,
    pub stacks: Option<Vec<Stack>>,  // None = applies to all stacks
    pub check: fn(&ScanContext) -> Vec<HealthIssue>,
}

We chose function pointers over trait objects for two reasons. First, simplicity: defining a new rule means writing a function, not implementing a trait on a new struct. Second, performance: function pointers are a single pointer-sized value with static dispatch, while trait objects require vtable indirection.

The stacks field enables per-stack filtering. The Django rules only run on Django projects. The Next.js rules only run on Next.js projects. Generic rules like security checks run on everything.

The 34 Rules

Security Rules (SEC001-SEC007)

The security rules are the most universally applicable. They scan every project regardless of stack.

SEC001 -- API Keys in Source Code:

fn check_api_keys(ctx: &ScanContext) -> Vec<HealthIssue> {
    let patterns = [
        "AKIA",                        // AWS access key prefix
        "sk_live_",                    // Stripe live secret key
        "sk_test_",                    // Stripe test secret key
        "ghp_",                        // GitHub personal access token
        "gho_",                        // GitHub OAuth access token
        "glpat-",                      // GitLab personal access token
        "xoxb-",                       // Slack bot token
        "xoxp-",                       // Slack user token
    ];

let mut issues = Vec::new(); for pattern in &patterns { for (path, line, content) in ctx.grep(pattern) { if ctx.is_test_file(path) || ctx.is_comment_line(content) { continue; } issues.push(issue( "SEC001", Severity::Blocking, format!("Possible API key ({}) found in {}:{}", pattern, path.display(), line), "Move secrets to environment variables and add the file to .gitignore.", )); } } issues } ```

The rule checks for well-known token prefixes. AWS access keys always start with AKIA. Stripe secret keys start with sk_live_ or sk_test_. GitHub tokens start with ghp_. These are not heuristics -- they are structural prefixes defined by each service.

The is_test_file and is_comment_line checks prevent false positives. A test file that includes "AKIA_FAKE_KEY" as a mock is not a security issue. A comment explaining "use AKIA format" is not a leaked key.

SEC002 -- Passwords in Source: ``rust fn check_passwords(ctx: &ScanContext) -> Vec { let patterns = [ "password = \"", "password = '", "PASSWORD = \"", "passwd = \"", "secret = \"", ]; // ... similar grep-based scanning } ``

SEC006 -- Debug Mode Enabled catches DEBUG = True in Django settings, debug: true in configuration files, and similar patterns across frameworks.

SEC007 -- .env in Repository checks whether .env is being tracked (present in the file tree but not in .gitignore):

fn check_env_file(ctx: &ScanContext) -> Vec<HealthIssue> {
    if ctx.has_file(".env") && !ctx.gitignore_patterns.iter().any(|p| p.trim() == ".env") {
        vec![issue(
            "SEC007",
            Severity::Blocking,
            ".env file found in project without .gitignore exclusion",
            "Add .env to .gitignore. Never commit environment files.",
        )]
    } else {
        vec![]
    }
}

Node.js Rules (NODE001-NODE005)

NODE001 -- Missing Start Script is the single most common reason Node.js deployments fail:

fn check_start_script(ctx: &ScanContext) -> Vec<HealthIssue> {
    if let Some(pkg) = &ctx.package_json {
        let has_start = pkg.get("scripts")
            .and_then(|s| s.get("start"))
            .is_some();

if !has_start { return vec![issue( "NODE001", Severity::Blocking, "No \"start\" script in package.json", "Add a \"start\" script (e.g., \"node dist/index.js\") to package.json.", )]; } } vec![] } ```

This is a blocking issue because without a start script, the container will start and immediately exit. The user will see "deployment failed" with no useful error message from their application.

NODE002 -- Hardcoded Port detects app.listen(3000) or server.listen(8080) without reading from process.env.PORT. In a PaaS environment, the platform assigns the port via environment variable.

NODE003 -- Dev Dependencies in Production checks for devDependencies entries like nodemon or ts-node in the start script -- tools that should never run in production.

Python Rules (PY001-PY006)

The Python rules focus on Django and FastAPI, the two most common production Python frameworks.

PY001 -- Django DEBUG = True: ``rust fn check_django_debug(ctx: &ScanContext) -> Vec { for (path, line, content) in ctx.grep("DEBUG") { if path.to_string_lossy().contains("settings") && content.contains("= True") && !ctx.is_comment_line(content) { return vec![issue( "PY001", Severity::Blocking, format!("Django DEBUG = True in {}:{}", path.display(), line), "Set DEBUG = os.environ.get('DEBUG', 'False') == 'True'", )]; } } vec![] } ``

Django with DEBUG = True in production exposes full stack traces, database queries, and configuration details to any visitor who triggers an error. It is not a warning -- it is a blocking issue.

PY002 -- Django SECRET_KEY Hardcoded detects SECRET_KEY = "..." in settings files. A hardcoded secret key means every deployment shares the same cryptographic material, and anyone with access to the source code can forge sessions.

PY005 -- Uvicorn with --reload catches uvicorn main:app --reload in production start commands. The --reload flag watches for file changes and restarts the server -- useful in development, a performance and reliability problem in production.

Go, Java, Build, and Configuration Rules

The remaining categories follow the same pattern:

  • GO001-GO003: Hardcoded listen addresses, missing go.mod, absence of graceful shutdown handling
  • JAVA001-JAVA004: H2 console exposed, Spring Actuator without security, dev profile active, missing JVM memory flags
  • BUILD001-BUILD003: TypeScript configured but not compiled, missing build script, lockfile absent
  • CFG001-CFG004: Missing start command, hardcoded port, localhost binding, absent .dockerignore

The Scoring System

After all applicable rules have run, the engine computes a health score:

pub fn compute_score(issues: &[HealthIssue]) -> u8 {
    let blocking = issues.iter().filter(|i| i.severity == Severity::Blocking).count();
    let warnings = issues.iter().filter(|i| i.severity == Severity::Warning).count();
    let info = issues.iter().filter(|i| i.severity == Severity::Info).count();

let penalty = (blocking 20) + (warnings 5) + (info * 1); 100u8.saturating_sub(penalty as u8) } ```

The formula: start at 100, subtract 20 for each blocking issue, 5 for each warning, 1 for each informational finding. The score clamps at 0.

A project with one hardcoded API key (blocking, -20) and a missing .dockerignore (warning, -5) scores 75. A project with three blocking security issues scores 40. The score gives users an instant, quantifiable sense of their deployment readiness.

The engine also computes project complexity based on file count:

pub fn compute_complexity(file_count: usize) -> Complexity {
    let level = if file_count < 20 {
        ComplexityLevel::Simple
    } else if file_count < 100 {
        ComplexityLevel::Medium
    } else {
        ComplexityLevel::Complex
    };

Complexity { level, file_count } } ```

Complexity is informational, not punitive. It helps users understand the scope of the scan.

Why Pure Rust, No LLM

We made a deliberate decision to implement every rule as deterministic pattern matching, not LLM-based analysis.

Speed. The entire scan runs in milliseconds. An LLM call takes seconds, minimum. When a developer pushes code, they want to know immediately if something is wrong -- not after a 10-second API call to a language model.

Determinism. The same code produces the same findings every time. There are no hallucinated issues, no missed detections because the model was having an off day, no "works in GPT-4o but not in GPT-4o-mini" inconsistencies. If SEC001 fires, there is a string matching AKIA in line 42 of your source file. Full stop.

Offline operation. sh0 is a self-hosted platform. It might run on a server with no internet access, behind a corporate firewall, or on an air-gapped network. A dependency on an external LLM service would break these deployments.

Cost. Every LLM call costs money. sh0 users might push code dozens of times a day. Charging for health checks (or eating the cost ourselves) would be unsustainable.

The trade-off is that pure pattern matching cannot catch semantic issues. It cannot tell you that your authentication logic has a time-of-check-to-time-of-use bug, or that your SQL queries are vulnerable to injection. But it can catch the 34 most common deployment mistakes, and that covers the vast majority of the problems real users hit in production.

Integration: Non-Blocking by Default

The health check engine integrates into the build pipeline as an informational step:

impl Builder {
    pub async fn build(&self, opts: BuildOpts) -> Result<BuildOutput, BuilderError> {
        let detected = detect(&opts.source_path);

// Health check runs after detection, before build let health = check_health(&opts.source_path, &detected).await?;

// Build proceeds regardless of health score let dockerfile = generate_dockerfile(&detected); let image_id = self.docker.build_image(/ ... /).await?;

Ok(BuildOutput { image_id, stack: detected, health_report: Some(health), // ... }) } } ```

The health report is attached to the build output but does not block the build. A project with score 40 still deploys. The findings are presented to the user in the dashboard, letting them decide what to fix and when.

There is also a standalone check() function for running health checks without building:

pub async fn check(path: &Path) -> Result<HealthReport, BuilderError> {
    let detected = detect(path);
    check_health(path, &detected).await
}

This powers the sh0 check CLI command, which developers can run locally before pushing.

Verification: 82 Tests

The health check engine added 59 new tests on top of the 23 existing build engine tests, bringing the sh0-builder crate to 82 tests total. Every rule has at least one positive test (the pattern is present, the rule fires) and one negative test (the pattern is absent, the rule does not fire). Edge cases like patterns in test files, patterns in comments, and partial matches are covered.

cargo test -p sh0-builder     82 tests passed
cargo clippy -p sh0-builder   0 warnings

What Came Next

With 34 rules covering security, configuration, framework-specific issues, and build hygiene, sh0's health check engine gives users actionable feedback before their code reaches production. It is not a replacement for a security audit or a thorough code review. It is a fast, deterministic safety net that catches the obvious mistakes -- the ones that account for 80% of "why is my deployment broken?" support tickets on every PaaS platform.

The next phases of sh0's development moved from static analysis to runtime infrastructure: the reverse proxy with automatic SSL (Phase 7), the full deploy pipeline (Phase 8), and the authentication system (Phase 9). Those are stories for upcoming articles in this series.

---

This is Part 4 of the "How We Built sh0.dev" series.

Series Navigation: - [1] Day Zero: 10 Rust Crates in 24 Hours - [2] Writing a Docker Engine Client from Scratch in Rust - [3] Auto-Detecting 19 Tech Stacks from Source Code - [4] 34 Rules to Catch Deployment Mistakes Before They Happen (you are here)

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles