// ═══════════════════════════════════════════════════════════════════════════ // Ghaymah Pre-Deployment Security Scanner & Linter // Version: 1.0.0 // Author: Ziad Mahmoud Ahmed Abdelgwad — Cybersecurity Specialist // // A standalone, production-grade scanner that inspects a deployment // directory for Dockerfile anti-patterns, configuration poisoning // vectors, and build reliability issues BEFORE the artifact is // packaged and uploaded. // // Usage: // go run gy-scanner.go [PATH] # scan a directory // go run gy-scanner.go # scan current directory // go run gy-scanner.go --json [PATH] # structured JSON output // go run gy-scanner.go --strict [PATH] # exit code 1 on any WARNING+ // ═══════════════════════════════════════════════════════════════════════════ package main import ( "bufio" "encoding/json" "fmt" "os" "path/filepath" "regexp" "strconv" "strings" "time" "unicode" ) // ─────────────────────────────────── Types ─────────────────────────────── // Severity levels for scan findings. type Severity string const ( SeverityCritical Severity = "CRITICAL" SeverityWarning Severity = "WARNING" SeverityPass Severity = "PASS" SeverityInfo Severity = "INFO" ) // Finding represents a single scan result. type Finding struct { ID string `json:"id"` Category string `json:"category"` Severity Severity `json:"severity"` Title string `json:"title"` Description string `json:"description"` File string `json:"file,omitempty"` Line int `json:"line,omitempty"` Remediation string `json:"remediation,omitempty"` } // ScanReport is the top-level JSON output. type ScanReport struct { Scanner string `json:"scanner"` Version string `json:"version"` Timestamp string `json:"timestamp"` Directory string `json:"directory"` DurationMs int64 `json:"duration_ms"` Summary Summary `json:"summary"` Findings []Finding `json:"findings"` } // Summary counts findings by severity. type Summary struct { Total int `json:"total"` Critical int `json:"critical"` Warning int `json:"warning"` Pass int `json:"pass"` Info int `json:"info"` } // ──────────────────────────────── Constants ────────────────────────────── const ( scannerName = "Ghaymah Pre-Deploy Security Scanner" scannerVersion = "1.0.0" ) // ──────────────────────────────── Patterns ─────────────────────────────── // Dockerfile anti-patterns var ( // Matches COPY . /, COPY ./ ., COPY . ., ADD . /, etc. wildcardCopyPattern = regexp.MustCompile( `(?i)^\s*(COPY|ADD)\s+(\.\s|\.\/\s|\.\s+\/|\.\s+\.\s|\.\/\s+\.\/?)`, ) // Matches FROM instruction fromPattern = regexp.MustCompile(`(?i)^\s*FROM\s+\S+`) // Matches EXPOSE instruction with port exposePattern = regexp.MustCompile(`(?i)^\s*EXPOSE\s+(.+)`) // Matches USER instruction userPattern = regexp.MustCompile(`(?i)^\s*USER\s+(\S+)`) // Matches ARG/ENV with inline secrets secretLeakPattern = regexp.MustCompile( `(?i)^\s*(ARG|ENV)\s+\S*(PASSWORD|SECRET|TOKEN|API_KEY|PRIVATE_KEY|AWS_SECRET|DB_PASS)\S*\s*=\s*\S+`, ) // Matches RUN with curl|wget piped to sh/bash curlPipePattern = regexp.MustCompile( `(?i)^\s*RUN\s+.*\b(curl|wget)\b.*\|\s*(sh|bash|/bin/sh|/bin/bash)\b`, ) // Matches --no-check-certificate or -k (insecure) insecureDownloadPattern = regexp.MustCompile( `(?i)(--no-check-certificate|-k\s|--insecure)`, ) // Matches RUN with chmod 777 chmod777Pattern = regexp.MustCompile( `(?i)^\s*RUN\s+.*chmod\s+777\b`, ) // Matches ADD with remote URL addRemotePattern = regexp.MustCompile( `(?i)^\s*ADD\s+(https?://\S+)`, ) ) // Env variable poisoning patterns var ( // Shell command substitution: $(cmd) or `cmd` shellSubstitutionPattern = regexp.MustCompile(`\$\(.*\)|` + "`" + `.*` + "`") // Unclosed single or double quotes unclosedSingleQuote = regexp.MustCompile(`^[^']*'[^']*$`) unclosedDoubleQuote = regexp.MustCompile(`^[^"]*"[^"]*$`) // Shell special operators that could cause parser issues shellOperatorPattern = regexp.MustCompile(`[;&|]|\$\{`) // Invalid env var key characters (must be [A-Za-z_][A-Za-z0-9_]*) validEnvKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) ) // ─────────────────────────────── Scanner ───────────────────────────────── // Scanner holds state for a single scan run. type Scanner struct { dir string findings []Finding idSeq int } // NewScanner creates a scanner for the given directory. func NewScanner(dir string) *Scanner { return &Scanner{dir: dir} } // nextID generates sequential finding IDs. func (s *Scanner) nextID(prefix string) string { s.idSeq++ return fmt.Sprintf("%s-%03d", prefix, s.idSeq) } // add appends a finding. func (s *Scanner) add(f Finding) { s.findings = append(s.findings, f) } // ────────────────────────── Scan Orchestrator ──────────────────────────── // Run executes all scan phases and returns the report. func (s *Scanner) Run() *ScanReport { start := time.Now() s.scanDockerfile() s.scanDockerignore() s.scanGyJSON() s.scanEnvFile() s.scanDotEnvLeakage() elapsed := time.Since(start) report := &ScanReport{ Scanner: scannerName, Version: scannerVersion, Timestamp: time.Now().UTC().Format(time.RFC3339), Directory: s.dir, DurationMs: elapsed.Milliseconds(), Findings: s.findings, } for _, f := range s.findings { report.Summary.Total++ switch f.Severity { case SeverityCritical: report.Summary.Critical++ case SeverityWarning: report.Summary.Warning++ case SeverityPass: report.Summary.Pass++ case SeverityInfo: report.Summary.Info++ } } return report } // ─────────────────────── Phase A: Dockerfile Scan ─────────────────────── func (s *Scanner) scanDockerfile() { dockerfilePath := filepath.Join(s.dir, "Dockerfile") data, err := os.ReadFile(dockerfilePath) if err != nil { s.add(Finding{ ID: s.nextID("DF"), Category: "Dockerfile", Severity: SeverityInfo, Title: "No Dockerfile Found", Description: "No Dockerfile was detected in the deployment directory. " + "The CLI will auto-generate one. This check is informational.", File: "Dockerfile", }) return } lines := strings.Split(string(data), "\n") hasFrom := false hasNonRootUser := false wildcardCopyLines := []int{} lastUserIsRoot := true for i, line := range lines { lineNum := i + 1 trimmed := strings.TrimSpace(line) // Skip comments and empty lines if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } // ── Check A.1: Valid FROM instruction ── if fromPattern.MatchString(trimmed) { hasFrom = true } // ── Check A.2: Wildcard COPY/ADD ── if wildcardCopyPattern.MatchString(trimmed) { wildcardCopyLines = append(wildcardCopyLines, lineNum) } // ── Check A.3: USER directive ── if userPattern.MatchString(trimmed) { matches := userPattern.FindStringSubmatch(trimmed) if len(matches) > 1 { user := strings.ToLower(matches[1]) if user != "root" && user != "0" { hasNonRootUser = true lastUserIsRoot = false } else { lastUserIsRoot = true } } } // ── Check A.4: EXPOSE port validation ── if exposePattern.MatchString(trimmed) { matches := exposePattern.FindStringSubmatch(trimmed) if len(matches) > 1 { s.validateExposePorts(matches[1], lineNum) } } // ── Check A.5: Hardcoded secrets in ARG/ENV ── if secretLeakPattern.MatchString(trimmed) { s.add(Finding{ ID: s.nextID("DF"), Category: "Dockerfile", Severity: SeverityCritical, Title: "Hardcoded Secret in Dockerfile", Description: fmt.Sprintf( "Line %d contains what appears to be a hardcoded secret in an ARG or ENV instruction. "+ "Secrets embedded in Dockerfiles persist in image layers and are trivially extractable.", lineNum, ), File: "Dockerfile", Line: lineNum, Remediation: "Use Docker BuildKit secrets (--mount=type=secret) or inject at runtime via orchestrator.", }) } // ── Check A.6: Curl/wget piped to shell ── if curlPipePattern.MatchString(trimmed) { s.add(Finding{ ID: s.nextID("DF"), Category: "Dockerfile", Severity: SeverityWarning, Title: "Remote Script Execution Without Verification", Description: fmt.Sprintf( "Line %d downloads and executes a remote script without integrity verification. "+ "A MITM attacker could inject malicious code.", lineNum, ), File: "Dockerfile", Line: lineNum, Remediation: "Download the script first, verify its checksum, then execute it.", }) } // ── Check A.7: Insecure download flags ── if insecureDownloadPattern.MatchString(trimmed) { s.add(Finding{ ID: s.nextID("DF"), Category: "Dockerfile", Severity: SeverityWarning, Title: "Insecure Download (TLS Verification Disabled)", Description: fmt.Sprintf( "Line %d uses --no-check-certificate or --insecure, disabling TLS certificate verification. "+ "This enables MITM attacks during the build.", lineNum, ), File: "Dockerfile", Line: lineNum, Remediation: "Remove insecure flags and ensure valid TLS certificates are used.", }) } // ── Check A.8: chmod 777 ── if chmod777Pattern.MatchString(trimmed) { s.add(Finding{ ID: s.nextID("DF"), Category: "Dockerfile", Severity: SeverityWarning, Title: "Overly Permissive File Permissions (chmod 777)", Description: fmt.Sprintf( "Line %d sets world-writable permissions. This allows any process in the container "+ "to modify critical files.", lineNum, ), File: "Dockerfile", Line: lineNum, Remediation: "Use restrictive permissions (e.g., chmod 755 for directories, 644 for files).", }) } // ── Check A.9: ADD with remote URL ── if addRemotePattern.MatchString(trimmed) { s.add(Finding{ ID: s.nextID("DF"), Category: "Dockerfile", Severity: SeverityWarning, Title: "ADD Instruction with Remote URL", Description: fmt.Sprintf( "Line %d uses ADD to fetch a remote URL. "+ "This bypasses checksum verification and can introduce malicious binaries.", lineNum, ), File: "Dockerfile", Line: lineNum, Remediation: "Use RUN curl/wget with checksum verification instead of ADD.", }) } } // ── Emit FROM result ── if len(lines) == 0 { s.add(Finding{ ID: s.nextID("DF"), Category: "Dockerfile", Severity: SeverityCritical, Title: "Empty Dockerfile", Description: "The Dockerfile is completely empty. " + "This will cause the Docker build to fail immediately.", File: "Dockerfile", Remediation: "Provide a valid Dockerfile starting with FROM.", }) } else if !hasFrom { s.add(Finding{ ID: s.nextID("DF"), Category: "Dockerfile", Severity: SeverityCritical, Title: "Missing FROM Instruction", Description: "The Dockerfile does not start with a valid FROM instruction. " + "This will cause the Docker build to fail immediately.", File: "Dockerfile", Remediation: "Add a valid FROM instruction (e.g., FROM node:20-alpine).", }) } else { s.add(Finding{ ID: s.nextID("DF"), Category: "Dockerfile", Severity: SeverityPass, Title: "Valid FROM Instruction Present", File: "Dockerfile", }) } // ── Emit wildcard COPY results ── if len(wildcardCopyLines) > 0 { lineList := make([]string, len(wildcardCopyLines)) for i, l := range wildcardCopyLines { lineList[i] = strconv.Itoa(l) } s.add(Finding{ ID: s.nextID("DF"), Category: "Dockerfile", Severity: SeverityCritical, Title: "Wildcard COPY/ADD Detected — Sensitive File Leakage Risk", Description: fmt.Sprintf( "Lines [%s] use wildcard copy instructions (COPY . / or ADD . /) which will include "+ "ALL files in the build context — including .env files, .git directories, credentials, "+ "and other secrets — into the container image layers. These are trivially extractable.", strings.Join(lineList, ", "), ), File: "Dockerfile", Line: wildcardCopyLines[0], Remediation: "1. Create a .dockerignore file excluding .env, .git, *.pem, etc.\n" + "2. Use specific COPY instructions (e.g., COPY package.json .) instead of wildcards.\n" + "3. Use multi-stage builds to isolate build and runtime artifacts.", }) } else { s.add(Finding{ ID: s.nextID("DF"), Category: "Dockerfile", Severity: SeverityPass, Title: "No Wildcard COPY/ADD Instructions", File: "Dockerfile", }) } // ── Emit USER result ── if !hasNonRootUser || lastUserIsRoot { s.add(Finding{ ID: s.nextID("DF"), Category: "Dockerfile", Severity: SeverityWarning, Title: "Container Runs as Root User", Description: "No non-root USER directive was found, or the final USER is root. " + "Running containers as root increases the blast radius of container escape vulnerabilities.", File: "Dockerfile", Remediation: "Add 'RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot' " + "and 'USER nonroot' before the CMD/ENTRYPOINT instruction.", }) } else { s.add(Finding{ ID: s.nextID("DF"), Category: "Dockerfile", Severity: SeverityPass, Title: "Non-Root User Configured", File: "Dockerfile", }) } } // validateExposePorts checks that EXPOSE ports are within valid TCP range. func (s *Scanner) validateExposePorts(portsStr string, lineNum int) { // EXPOSE can have multiple ports: EXPOSE 8080 3000/tcp parts := strings.Fields(portsStr) for _, part := range parts { // Strip protocol suffix (/tcp, /udp) portStr := strings.Split(part, "/")[0] // Handle variable substitution like ${PORT} if strings.Contains(portStr, "$") { continue } port, err := strconv.Atoi(portStr) if err != nil { s.add(Finding{ ID: s.nextID("DF"), Category: "Dockerfile", Severity: SeverityWarning, Title: "Invalid EXPOSE Port Value", Description: fmt.Sprintf( "Line %d: EXPOSE value '%s' is not a valid integer port number.", lineNum, portStr, ), File: "Dockerfile", Line: lineNum, }) continue } if port < 1 || port > 65535 { s.add(Finding{ ID: s.nextID("DF"), Category: "Dockerfile", Severity: SeverityCritical, Title: "EXPOSE Port Out of Valid Range", Description: fmt.Sprintf( "Line %d: EXPOSE port %d is outside the valid TCP range (1–65535). "+ "This will cause deployment failures or undefined behavior.", lineNum, port, ), File: "Dockerfile", Line: lineNum, Remediation: "Use a valid port number between 1 and 65535.", }) } else { s.add(Finding{ ID: s.nextID("DF"), Category: "Dockerfile", Severity: SeverityPass, Title: fmt.Sprintf("EXPOSE Port %d Is Valid", port), File: "Dockerfile", Line: lineNum, }) } } } // ─────────────────── Phase A.2: .dockerignore Check ───────────────────── func (s *Scanner) scanDockerignore() { ignorePath := filepath.Join(s.dir, ".dockerignore") if _, err := os.Stat(ignorePath); os.IsNotExist(err) { // Check if a Dockerfile exists (only relevant if Dockerfile is present) if _, dfErr := os.Stat(filepath.Join(s.dir, "Dockerfile")); dfErr == nil { s.add(Finding{ ID: s.nextID("DI"), Category: "Dockerignore", Severity: SeverityWarning, Title: "Missing .dockerignore File", Description: "No .dockerignore file was found. Without it, the entire directory " + "(including .env files, .git, node_modules, and other sensitive data) will be " + "included in the Docker build context and potentially baked into the image.", File: ".dockerignore", Remediation: "Create a .dockerignore file with at minimum:\n" + " .env\n .env.*\n .git\n .gitignore\n node_modules\n" + " *.pem\n *.key\n .gy.json\n *.log", }) } return } // .dockerignore exists — verify it blocks critical patterns data, err := os.ReadFile(ignorePath) if err != nil { return } content := string(data) criticalPatterns := map[string]string{ ".env": ".env files (credentials)", ".git": ".git directory (commit history, potentially secrets)", "*.pem": "PEM certificate/key files", "*.key": "Private key files", } missingPatterns := []string{} for pattern, desc := range criticalPatterns { if !strings.Contains(content, pattern) { missingPatterns = append(missingPatterns, fmt.Sprintf(" - %s → %s", pattern, desc)) } } if len(missingPatterns) > 0 { s.add(Finding{ ID: s.nextID("DI"), Category: "Dockerignore", Severity: SeverityWarning, Title: ".dockerignore Missing Critical Exclusions", Description: fmt.Sprintf( "The .dockerignore file exists but does not exclude the following sensitive patterns:\n%s", strings.Join(missingPatterns, "\n"), ), File: ".dockerignore", Remediation: "Add the missing patterns to your .dockerignore file.", }) } else { s.add(Finding{ ID: s.nextID("DI"), Category: "Dockerignore", Severity: SeverityPass, Title: ".dockerignore Covers Critical Patterns", File: ".dockerignore", }) } } // ─────────────────── Phase B: .gy.json Validation ─────────────────────── func (s *Scanner) scanGyJSON() { gyPath := filepath.Join(s.dir, ".gy.json") data, err := os.ReadFile(gyPath) if err != nil { // .gy.json is optional return } var config map[string]interface{} if err := json.Unmarshal(data, &config); err != nil { s.add(Finding{ ID: s.nextID("GY"), Category: "Configuration", Severity: SeverityCritical, Title: "Malformed .gy.json — Parse Error", Description: fmt.Sprintf( "The .gy.json file contains invalid JSON: %s. "+ "This will cause the deployment to fail or behave unpredictably.", err.Error(), ), File: ".gy.json", Remediation: "Fix the JSON syntax. Use 'python3 -m json.tool .gy.json' to validate.", }) return } // ── Validate port if present ── if portVal, ok := config["port"]; ok { switch p := portVal.(type) { case float64: port := int(p) if port < 1 || port > 65535 { s.add(Finding{ ID: s.nextID("GY"), Category: "Configuration", Severity: SeverityCritical, Title: "Invalid Port in .gy.json", Description: fmt.Sprintf( "Port value %d is outside the valid TCP range (1–65535). "+ "The backend may reject or misinterpret this value.", port, ), File: ".gy.json", Remediation: "Set port to a valid value between 1 and 65535.", }) } else { s.add(Finding{ ID: s.nextID("GY"), Category: "Configuration", Severity: SeverityPass, Title: fmt.Sprintf("Port %d in .gy.json Is Valid", port), File: ".gy.json", }) } case string: s.add(Finding{ ID: s.nextID("GY"), Category: "Configuration", Severity: SeverityWarning, Title: "Port in .gy.json Is a String Instead of Integer", Description: fmt.Sprintf( "Port value '%s' is a string. The CLI expects an integer. "+ "This may cause type errors during deployment.", p, ), File: ".gy.json", Remediation: "Change the port value to an integer (e.g., \"port\": 8080).", }) } } // ── Validate app name if present ── if nameVal, ok := config["app"]; ok { if name, isStr := nameVal.(string); isStr { s.validateAppName(name) } } // ── Validate env vars if present ── if envVal, ok := config["env"]; ok { if envMap, isMap := envVal.(map[string]interface{}); isMap { for key, val := range envMap { valStr := fmt.Sprintf("%v", val) s.validateEnvVar(key, valStr, ".gy.json") } } } } // validateAppName checks the app name for injection payloads. func (s *Scanner) validateAppName(name string) { validName := regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) if !validName.MatchString(name) { severity := SeverityWarning desc := fmt.Sprintf( "App name '%s' contains invalid characters. "+ "Valid names must match ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$", name, ) // Escalate if it looks like an injection attempt if strings.ContainsAny(name, "<>\"';&|$`(){}") || strings.Contains(name, "..") { severity = SeverityCritical desc = fmt.Sprintf( "App name '%s' contains characters associated with injection attacks "+ "(XSS, path traversal, or shell injection). This is a security violation.", name, ) } s.add(Finding{ ID: s.nextID("GY"), Category: "Configuration", Severity: severity, Title: "Invalid App Name in .gy.json", Description: desc, File: ".gy.json", Remediation: "Use only lowercase letters (a-z), digits (0-9), and hyphens (-).", }) } } // ─────────────────── Phase B.2: .env File Validation ──────────────────── func (s *Scanner) scanEnvFile() { envFiles := []string{".env", ".env.local", ".env.production", ".env.staging"} for _, envFile := range envFiles { envPath := filepath.Join(s.dir, envFile) file, err := os.Open(envPath) if err != nil { continue } defer file.Close() scanner := bufio.NewScanner(file) lineNum := 0 for scanner.Scan() { lineNum++ line := strings.TrimSpace(scanner.Text()) // Skip comments and empty lines if line == "" || strings.HasPrefix(line, "#") { continue } // Parse KEY=VALUE eqIdx := strings.IndexByte(line, '=') if eqIdx < 0 { s.add(Finding{ ID: s.nextID("ENV"), Category: "Environment", Severity: SeverityWarning, Title: "Malformed Line in Env File", Description: fmt.Sprintf( "%s line %d: '%s' has no '=' separator. "+ "This may cause the backend parser to skip or crash on this entry.", envFile, lineNum, truncate(line, 60), ), File: envFile, Line: lineNum, }) continue } key := line[:eqIdx] value := line[eqIdx+1:] // Strip inline comments (e.g., "value # comment") commentIdx := strings.Index(value, " #") if commentIdx != -1 { value = strings.TrimRight(value[:commentIdx], " \t") } s.validateEnvVar(key, value, envFile) } } } // validateEnvVar checks a single environment variable for poisoning vectors. func (s *Scanner) validateEnvVar(key, value, sourceFile string) { // ── Check key format ── if !validEnvKeyPattern.MatchString(key) { s.add(Finding{ ID: s.nextID("ENV"), Category: "Environment", Severity: SeverityWarning, Title: "Invalid Environment Variable Key", Description: fmt.Sprintf( "Key '%s' in %s contains invalid characters. "+ "Env var keys must match [A-Za-z_][A-Za-z0-9_]*. "+ "Invalid keys may cause parser failures in the backend pipeline.", truncate(key, 40), sourceFile, ), File: sourceFile, Remediation: "Rename the key to use only letters, digits, and underscores.", }) } // ── Check for shell command substitution ── if shellSubstitutionPattern.MatchString(value) { s.add(Finding{ ID: s.nextID("ENV"), Category: "Environment", Severity: SeverityCritical, Title: "Shell Command Substitution in Env Value — Configuration Poisoning", Description: fmt.Sprintf( "Key '%s' in %s contains shell command substitution patterns "+ "($(...) or backticks). If the backend pipeline evaluates these values "+ "in a shell context, this could lead to Remote Code Execution (RCE). "+ "Even without execution, malformed substitutions can cause the parser to hang "+ "(Denial of Service via Configuration Poisoning).\n\n"+ "Value (truncated): '%s'", key, sourceFile, truncate(value, 80), ), File: sourceFile, Remediation: "Remove shell substitution patterns. Use literal values only.\n" + "If dynamic values are needed, compute them at runtime in your app, not in env vars.", }) } // ── Check for shell operators (;, &&, ||, pipe) ── if shellOperatorPattern.MatchString(value) && !strings.Contains(value, "://") { // Exclude URLs which legitimately contain special chars s.add(Finding{ ID: s.nextID("ENV"), Category: "Environment", Severity: SeverityWarning, Title: "Shell Operators in Env Value", Description: fmt.Sprintf( "Key '%s' in %s contains shell operators (;, &, |, or ${...}). "+ "These may cause unexpected behavior if the backend evaluates the value in a shell context.", key, sourceFile, ), File: sourceFile, Remediation: "Wrap the value in single quotes or escape special characters.", }) } // ── Check for unclosed quotes ── singleCount := strings.Count(value, "'") doubleCount := strings.Count(value, "\"") if singleCount%2 != 0 { s.add(Finding{ ID: s.nextID("ENV"), Category: "Environment", Severity: SeverityWarning, Title: "Unclosed Single Quote in Env Value", Description: fmt.Sprintf( "Key '%s' in %s has an odd number of single quotes (%d). "+ "Unclosed quotes can cause the backend parser to hang indefinitely "+ "waiting for a closing delimiter (Configuration Poisoning → DoS).", key, sourceFile, singleCount, ), File: sourceFile, Remediation: "Ensure all quotes are properly closed or escaped.", }) } if doubleCount%2 != 0 { s.add(Finding{ ID: s.nextID("ENV"), Category: "Environment", Severity: SeverityWarning, Title: "Unclosed Double Quote in Env Value", Description: fmt.Sprintf( "Key '%s' in %s has an odd number of double quotes (%d). "+ "Unclosed quotes can cause the backend parser to hang indefinitely.", key, sourceFile, doubleCount, ), File: sourceFile, Remediation: "Ensure all quotes are properly closed or escaped.", }) } // ── Check for extremely long values (memory exhaustion) ── if len(value) > 4096 { s.add(Finding{ ID: s.nextID("ENV"), Category: "Environment", Severity: SeverityWarning, Title: "Excessively Long Env Value", Description: fmt.Sprintf( "Key '%s' in %s has a value of %d bytes. "+ "Extremely long values can cause memory exhaustion or parser slowdowns "+ "in the backend pipeline.", key, sourceFile, len(value), ), File: sourceFile, Remediation: "Keep environment variable values under 4096 bytes.", }) } // ── Check for non-printable / control characters ── for _, r := range value { if r != '\t' && r != '\n' && r != '\r' && unicode.IsControl(r) { s.add(Finding{ ID: s.nextID("ENV"), Category: "Environment", Severity: SeverityWarning, Title: "Control Characters in Env Value", Description: fmt.Sprintf( "Key '%s' in %s contains non-printable control characters (U+%04X). "+ "These can cause log injection, parser confusion, or display corruption.", key, sourceFile, r, ), File: sourceFile, Remediation: "Remove control characters. Use only printable ASCII/UTF-8 in env values.", }) break } } } // ────────────────── Phase C: .env Leakage Detection ───────────────────── func (s *Scanner) scanDotEnvLeakage() { envPath := filepath.Join(s.dir, ".env") dockerignorePath := filepath.Join(s.dir, ".dockerignore") dockerfilePath := filepath.Join(s.dir, "Dockerfile") // Only relevant if .env AND Dockerfile exist if _, err := os.Stat(envPath); os.IsNotExist(err) { return } if _, err := os.Stat(dockerfilePath); os.IsNotExist(err) { return } // Check if .dockerignore excludes .env dockerignoreData, err := os.ReadFile(dockerignorePath) if err != nil { // No .dockerignore — .env WILL be included s.add(Finding{ ID: s.nextID("LEAK"), Category: "Secret Leakage", Severity: SeverityCritical, Title: ".env File Will Be Included in Docker Image", Description: ".env file exists in the deployment directory but there is no .dockerignore to exclude it. " + "The .env file (which typically contains database passwords, API keys, and other secrets) " + "will be copied into the Docker image layers and uploaded to the cloud build infrastructure. " + "Anyone with access to the image can extract these secrets.", File: ".env", Remediation: "Create a .dockerignore file and add '.env' and '.env.*' to it.", }) return } if !strings.Contains(string(dockerignoreData), ".env") { s.add(Finding{ ID: s.nextID("LEAK"), Category: "Secret Leakage", Severity: SeverityCritical, Title: ".env Not Excluded by .dockerignore", Description: ".env file exists and .dockerignore exists, but .dockerignore does NOT exclude .env files. " + "The .env file will be included in the Docker build context and baked into the image.", File: ".env", Remediation: "Add '.env' and '.env.*' to your .dockerignore file.", }) } } // ─────────────────────────── Utilities ─────────────────────────────────── func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "..." } // ──────────────────────────── CLI Output ───────────────────────────────── // severityColor returns ANSI color codes for terminal output. func severityColor(sev Severity) string { switch sev { case SeverityCritical: return "\033[1;31m" // Bold Red case SeverityWarning: return "\033[1;33m" // Bold Yellow case SeverityPass: return "\033[1;32m" // Bold Green case SeverityInfo: return "\033[1;36m" // Bold Cyan default: return "\033[0m" } } const resetColor = "\033[0m" func printHumanReport(report *ScanReport) { fmt.Println() fmt.Println("═══════════════════════════════════════════════════════════════════") fmt.Printf(" 🛡️ %s v%s\n", report.Scanner, report.Version) fmt.Printf(" 📁 Directory: %s\n", report.Directory) fmt.Printf(" 🕐 Duration: %dms\n", report.DurationMs) fmt.Println("═══════════════════════════════════════════════════════════════════") fmt.Println() // Group findings by severity for display criticals := []Finding{} warnings := []Finding{} passes := []Finding{} infos := []Finding{} for _, f := range report.Findings { switch f.Severity { case SeverityCritical: criticals = append(criticals, f) case SeverityWarning: warnings = append(warnings, f) case SeverityPass: passes = append(passes, f) case SeverityInfo: infos = append(infos, f) } } // Print criticals first for _, f := range criticals { fmt.Printf(" %s🔴 CRITICAL%s [%s] %s\n", severityColor(SeverityCritical), resetColor, f.ID, f.Title) if f.File != "" { loc := f.File if f.Line > 0 { loc = fmt.Sprintf("%s:%d", f.File, f.Line) } fmt.Printf(" 📄 %s\n", loc) } if f.Description != "" { // Wrap description to 80 chars for _, line := range strings.Split(f.Description, "\n") { fmt.Printf(" %s\n", line) } } if f.Remediation != "" { fmt.Printf(" %s💡 Fix:%s\n", "\033[2m", resetColor) for _, line := range strings.Split(f.Remediation, "\n") { fmt.Printf(" %s\n", line) } } fmt.Println() } for _, f := range warnings { fmt.Printf(" %s🟡 WARNING%s [%s] %s\n", severityColor(SeverityWarning), resetColor, f.ID, f.Title) if f.File != "" { loc := f.File if f.Line > 0 { loc = fmt.Sprintf("%s:%d", f.File, f.Line) } fmt.Printf(" 📄 %s\n", loc) } if f.Description != "" { for _, line := range strings.Split(f.Description, "\n") { fmt.Printf(" %s\n", line) } } if f.Remediation != "" { fmt.Printf(" %s💡 Fix:%s\n", "\033[2m", resetColor) for _, line := range strings.Split(f.Remediation, "\n") { fmt.Printf(" %s\n", line) } } fmt.Println() } for _, f := range passes { fmt.Printf(" %s🟢 PASS%s [%s] %s\n", severityColor(SeverityPass), resetColor, f.ID, f.Title) } for _, f := range infos { fmt.Printf(" %sℹ️ INFO%s [%s] %s\n", severityColor(SeverityInfo), resetColor, f.ID, f.Title) } fmt.Println() fmt.Println("───────────────────────────────────────────────────────────────────") fmt.Printf(" Summary: %s%d CRITICAL%s | %s%d WARNING%s | %s%d PASS%s | %d INFO | %d Total\n", severityColor(SeverityCritical), report.Summary.Critical, resetColor, severityColor(SeverityWarning), report.Summary.Warning, resetColor, severityColor(SeverityPass), report.Summary.Pass, resetColor, report.Summary.Info, report.Summary.Total, ) fmt.Println("───────────────────────────────────────────────────────────────────") if report.Summary.Critical > 0 { fmt.Printf("\n %s⛔ DEPLOYMENT BLOCKED — %d critical finding(s) must be resolved.%s\n\n", severityColor(SeverityCritical), report.Summary.Critical, resetColor) } else if report.Summary.Warning > 0 { fmt.Printf("\n %s⚠️ DEPLOYMENT ALLOWED — but %d warning(s) should be addressed.%s\n\n", severityColor(SeverityWarning), report.Summary.Warning, resetColor) } else { fmt.Printf("\n %s✅ ALL CHECKS PASSED — Deployment is safe to proceed.%s\n\n", severityColor(SeverityPass), resetColor) } } // ─────────────────────────────── Main ──────────────────────────────────── func main() { dir := "." jsonOutput := false strictMode := false args := os.Args[1:] for i := 0; i < len(args); i++ { switch args[i] { case "--json": jsonOutput = true case "--strict": strictMode = true case "--help", "-h": fmt.Println("Usage: gy-scanner [OPTIONS] [PATH]") fmt.Println() fmt.Println("Ghaymah Pre-Deployment Security Scanner & Linter") fmt.Println() fmt.Println("Options:") fmt.Println(" --json Output structured JSON report") fmt.Println(" --strict Exit with code 1 if any WARNING or CRITICAL is found") fmt.Println(" --help, -h Show this help message") fmt.Println() fmt.Println("Examples:") fmt.Println(" gy-scanner # Scan current directory") fmt.Println(" gy-scanner ./my-app # Scan a specific directory") fmt.Println(" gy-scanner --json ./my-app # JSON output") fmt.Println(" gy-scanner --strict --json . # CI mode: fail on any issue") os.Exit(0) default: if !strings.HasPrefix(args[i], "-") { dir = args[i] } } } // Resolve absolute path absDir, err := filepath.Abs(dir) if err != nil { fmt.Fprintf(os.Stderr, "Error: cannot resolve path '%s': %v\n", dir, err) os.Exit(2) } // Verify directory exists info, err := os.Stat(absDir) if err != nil || !info.IsDir() { fmt.Fprintf(os.Stderr, "Error: '%s' is not a valid directory\n", absDir) os.Exit(2) } // Run scan scanner := NewScanner(absDir) report := scanner.Run() // Output if jsonOutput { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") enc.Encode(report) } else { printHumanReport(report) } // Exit code if report.Summary.Critical > 0 { os.Exit(1) } if strictMode && report.Summary.Warning > 0 { os.Exit(1) } os.Exit(0) }