1150 أسطر
37 KiB
Go
1150 أسطر
37 KiB
Go
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 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)
|
||
}
|