lint/pkg/php/security.go

232 lines
6.1 KiB
Go
Raw Normal View History

package php
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
)
// SecurityOptions configures security scanning.
type SecurityOptions struct {
Dir string
Severity string // Minimum severity (critical, high, medium, low)
JSON bool // Output in JSON format
SARIF bool // Output in SARIF format
URL string // URL to check HTTP headers (optional)
}
// SecurityResult holds the results of security scanning.
type SecurityResult struct {
Checks []SecurityCheck `json:"checks"`
Summary SecuritySummary `json:"summary"`
}
// SecurityCheck represents a single security check result.
type SecurityCheck struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Severity string `json:"severity"`
Passed bool `json:"passed"`
Message string `json:"message,omitempty"`
Fix string `json:"fix,omitempty"`
CWE string `json:"cwe,omitempty"`
}
// SecuritySummary summarises security check results.
type SecuritySummary struct {
Total int `json:"total"`
Passed int `json:"passed"`
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
}
// capitalise returns s with the first letter upper-cased.
func capitalise(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
// RunSecurityChecks runs security checks on the project.
func RunSecurityChecks(ctx context.Context, opts SecurityOptions) (*SecurityResult, error) {
if opts.Dir == "" {
cwd, err := os.Getwd()
if err != nil {
return nil, coreerr.E("RunSecurityChecks", "get working directory", err)
}
opts.Dir = cwd
}
result := &SecurityResult{}
// Run composer audit
auditResults, _ := RunAudit(ctx, AuditOptions{Dir: opts.Dir})
for _, audit := range auditResults {
check := SecurityCheck{
ID: audit.Tool + "_audit",
Name: capitalise(audit.Tool) + " Security Audit",
Description: "Check " + audit.Tool + " dependencies for vulnerabilities",
Severity: "critical",
Passed: audit.Vulnerabilities == 0 && audit.Error == nil,
CWE: "CWE-1395",
}
if !check.Passed {
check.Message = fmt.Sprintf("Found %d vulnerabilities", audit.Vulnerabilities)
}
result.Checks = append(result.Checks, check)
}
// Check .env file for security issues
envChecks := runEnvSecurityChecks(opts.Dir)
result.Checks = append(result.Checks, envChecks...)
// Check filesystem security
fsChecks := runFilesystemSecurityChecks(opts.Dir)
result.Checks = append(result.Checks, fsChecks...)
// Calculate summary
for _, check := range result.Checks {
result.Summary.Total++
if check.Passed {
result.Summary.Passed++
} else {
switch check.Severity {
case "critical":
result.Summary.Critical++
case "high":
result.Summary.High++
case "medium":
result.Summary.Medium++
case "low":
result.Summary.Low++
}
}
}
return result, nil
}
func runEnvSecurityChecks(dir string) []SecurityCheck {
var checks []SecurityCheck
envPath := filepath.Join(dir, ".env")
envContent, err := coreio.Local.Read(envPath)
if err != nil {
return checks
}
envLines := strings.Split(envContent, "\n")
envMap := make(map[string]string)
for _, line := range envLines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
envMap[parts[0]] = parts[1]
}
}
// Check APP_DEBUG
if debug, ok := envMap["APP_DEBUG"]; ok {
check := SecurityCheck{
ID: "debug_mode",
Name: "Debug Mode Disabled",
Description: "APP_DEBUG should be false in production",
Severity: "critical",
Passed: strings.ToLower(debug) != "true",
CWE: "CWE-215",
}
if !check.Passed {
check.Message = "Debug mode exposes sensitive information"
check.Fix = "Set APP_DEBUG=false in .env"
}
checks = append(checks, check)
}
// Check APP_KEY
if key, ok := envMap["APP_KEY"]; ok {
check := SecurityCheck{
ID: "app_key_set",
Name: "Application Key Set",
Description: "APP_KEY must be set and valid",
Severity: "critical",
Passed: len(key) >= 32,
CWE: "CWE-321",
}
if !check.Passed {
check.Message = "Missing or weak encryption key"
check.Fix = "Run: php artisan key:generate"
}
checks = append(checks, check)
}
// Check APP_URL for HTTPS
if url, ok := envMap["APP_URL"]; ok {
check := SecurityCheck{
ID: "https_enforced",
Name: "HTTPS Enforced",
Description: "APP_URL should use HTTPS in production",
Severity: "high",
Passed: strings.HasPrefix(url, "https://"),
CWE: "CWE-319",
}
if !check.Passed {
check.Message = "Application not using HTTPS"
check.Fix = "Update APP_URL to use https://"
}
checks = append(checks, check)
}
return checks
}
func runFilesystemSecurityChecks(dir string) []SecurityCheck {
var checks []SecurityCheck
// Check .env not in public
publicEnvPaths := []string{"public/.env", "public_html/.env"}
for _, path := range publicEnvPaths {
fullPath := filepath.Join(dir, path)
if fileExists(fullPath) {
checks = append(checks, SecurityCheck{
ID: "env_not_public",
Name: ".env Not Publicly Accessible",
Description: ".env file should not be in public directory",
Severity: "critical",
Passed: false,
Message: "Environment file exposed to web at " + path,
CWE: "CWE-538",
})
}
}
// Check .git not in public
publicGitPaths := []string{"public/.git", "public_html/.git"}
for _, path := range publicGitPaths {
fullPath := filepath.Join(dir, path)
if fileExists(fullPath) {
checks = append(checks, SecurityCheck{
ID: "git_not_public",
Name: ".git Not Publicly Accessible",
Description: ".git directory should not be in public",
Severity: "critical",
Passed: false,
Message: "Git repository exposed to web (source code leak)",
CWE: "CWE-538",
})
}
}
return checks
}