الملفات

1150 أسطر
37 KiB
Go
خام الرابط الدائم اللوم التاريخ

هذا الملف يحتوي على أحرف Unicode غير مرئية

هذا الملف يحتوي على أحرف Unicode غير مرئية لا يمكن التمييز بينها بعين الإنسان ولكن قد تتم معالجتها بشكل مختلف بواسطة الحاسوب. إذا كنت تعتقد أن هذا مقصود، يمكنك تجاهل هذا التحذير بأمان. استخدم زر الهروب للكشف عنها.

هذا الملف يحتوي على أحرف Unicode قد تُخلط مع أحرف أخرى. إذا كنت تعتقد أن هذا مقصود، يمكنك تجاهل هذا التحذير بأمان. استخدم زر الهروب للكشف عنها.

// ═══════════════════════════════════════════════════════════════════════════
// 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 (165535). "+
"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 (165535). "+
"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)
}