Port all PHP command files from core/cli internal/cmd/php/ into a standalone module. Inlines workspace dependency to avoid cross-module internal imports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
994 lines
24 KiB
Go
994 lines
24 KiB
Go
package php
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
goio "io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"forge.lthn.ai/core/go/pkg/cli"
|
|
"forge.lthn.ai/core/go/pkg/i18n"
|
|
)
|
|
|
|
// FormatOptions configures PHP code formatting.
|
|
type FormatOptions struct {
|
|
// Dir is the project directory (defaults to current working directory).
|
|
Dir string
|
|
|
|
// Fix automatically fixes formatting issues.
|
|
Fix bool
|
|
|
|
// Diff shows a diff of changes instead of modifying files.
|
|
Diff bool
|
|
|
|
// JSON outputs results in JSON format.
|
|
JSON bool
|
|
|
|
// Paths limits formatting to specific paths.
|
|
Paths []string
|
|
|
|
// Output is the writer for output (defaults to os.Stdout).
|
|
Output goio.Writer
|
|
}
|
|
|
|
// AnalyseOptions configures PHP static analysis.
|
|
type AnalyseOptions struct {
|
|
// Dir is the project directory (defaults to current working directory).
|
|
Dir string
|
|
|
|
// Level is the PHPStan analysis level (0-9).
|
|
Level int
|
|
|
|
// Paths limits analysis to specific paths.
|
|
Paths []string
|
|
|
|
// Memory is the memory limit for analysis (e.g., "2G").
|
|
Memory string
|
|
|
|
// JSON outputs results in JSON format.
|
|
JSON bool
|
|
|
|
// SARIF outputs results in SARIF format for GitHub Security tab.
|
|
SARIF bool
|
|
|
|
// Output is the writer for output (defaults to os.Stdout).
|
|
Output goio.Writer
|
|
}
|
|
|
|
// FormatterType represents the detected formatter.
|
|
type FormatterType string
|
|
|
|
// Formatter type constants.
|
|
const (
|
|
// FormatterPint indicates Laravel Pint code formatter.
|
|
FormatterPint FormatterType = "pint"
|
|
)
|
|
|
|
// AnalyserType represents the detected static analyser.
|
|
type AnalyserType string
|
|
|
|
// Static analyser type constants.
|
|
const (
|
|
// AnalyserPHPStan indicates standard PHPStan analyser.
|
|
AnalyserPHPStan AnalyserType = "phpstan"
|
|
// AnalyserLarastan indicates Laravel-specific Larastan analyser.
|
|
AnalyserLarastan AnalyserType = "larastan"
|
|
)
|
|
|
|
// DetectFormatter detects which formatter is available in the project.
|
|
func DetectFormatter(dir string) (FormatterType, bool) {
|
|
m := getMedium()
|
|
|
|
// Check for Pint config
|
|
pintConfig := filepath.Join(dir, "pint.json")
|
|
if m.Exists(pintConfig) {
|
|
return FormatterPint, true
|
|
}
|
|
|
|
// Check for vendor binary
|
|
pintBin := filepath.Join(dir, "vendor", "bin", "pint")
|
|
if m.Exists(pintBin) {
|
|
return FormatterPint, true
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
// DetectAnalyser detects which static analyser is available in the project.
|
|
func DetectAnalyser(dir string) (AnalyserType, bool) {
|
|
m := getMedium()
|
|
|
|
// Check for PHPStan config
|
|
phpstanConfig := filepath.Join(dir, "phpstan.neon")
|
|
phpstanDistConfig := filepath.Join(dir, "phpstan.neon.dist")
|
|
|
|
hasConfig := m.Exists(phpstanConfig) || m.Exists(phpstanDistConfig)
|
|
|
|
// Check for vendor binary
|
|
phpstanBin := filepath.Join(dir, "vendor", "bin", "phpstan")
|
|
hasBin := m.Exists(phpstanBin)
|
|
|
|
if hasConfig || hasBin {
|
|
// Check if it's Larastan (Laravel-specific PHPStan)
|
|
larastanPath := filepath.Join(dir, "vendor", "larastan", "larastan")
|
|
if m.Exists(larastanPath) {
|
|
return AnalyserLarastan, true
|
|
}
|
|
// Also check nunomaduro/larastan
|
|
larastanPath2 := filepath.Join(dir, "vendor", "nunomaduro", "larastan")
|
|
if m.Exists(larastanPath2) {
|
|
return AnalyserLarastan, true
|
|
}
|
|
return AnalyserPHPStan, true
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
// Format runs Laravel Pint to format PHP code.
|
|
func Format(ctx context.Context, opts FormatOptions) error {
|
|
if opts.Dir == "" {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return cli.WrapVerb(err, "get", "working directory")
|
|
}
|
|
opts.Dir = cwd
|
|
}
|
|
|
|
if opts.Output == nil {
|
|
opts.Output = os.Stdout
|
|
}
|
|
|
|
// Check if formatter is available
|
|
formatter, found := DetectFormatter(opts.Dir)
|
|
if !found {
|
|
return cli.Err("no formatter found (install Laravel Pint: composer require laravel/pint --dev)")
|
|
}
|
|
|
|
var cmdName string
|
|
var args []string
|
|
|
|
switch formatter {
|
|
case FormatterPint:
|
|
cmdName, args = buildPintCommand(opts)
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, cmdName, args...)
|
|
cmd.Dir = opts.Dir
|
|
cmd.Stdout = opts.Output
|
|
cmd.Stderr = opts.Output
|
|
|
|
return cmd.Run()
|
|
}
|
|
|
|
// Analyse runs PHPStan or Larastan for static analysis.
|
|
func Analyse(ctx context.Context, opts AnalyseOptions) error {
|
|
if opts.Dir == "" {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return cli.WrapVerb(err, "get", "working directory")
|
|
}
|
|
opts.Dir = cwd
|
|
}
|
|
|
|
if opts.Output == nil {
|
|
opts.Output = os.Stdout
|
|
}
|
|
|
|
// Check if analyser is available
|
|
analyser, found := DetectAnalyser(opts.Dir)
|
|
if !found {
|
|
return cli.Err("no static analyser found (install PHPStan: composer require phpstan/phpstan --dev)")
|
|
}
|
|
|
|
var cmdName string
|
|
var args []string
|
|
|
|
switch analyser {
|
|
case AnalyserPHPStan, AnalyserLarastan:
|
|
cmdName, args = buildPHPStanCommand(opts)
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, cmdName, args...)
|
|
cmd.Dir = opts.Dir
|
|
cmd.Stdout = opts.Output
|
|
cmd.Stderr = opts.Output
|
|
|
|
return cmd.Run()
|
|
}
|
|
|
|
// buildPintCommand builds the command for running Laravel Pint.
|
|
func buildPintCommand(opts FormatOptions) (string, []string) {
|
|
m := getMedium()
|
|
|
|
// Check for vendor binary first
|
|
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "pint")
|
|
cmdName := "pint"
|
|
if m.Exists(vendorBin) {
|
|
cmdName = vendorBin
|
|
}
|
|
|
|
var args []string
|
|
|
|
if !opts.Fix {
|
|
args = append(args, "--test")
|
|
}
|
|
|
|
if opts.Diff {
|
|
args = append(args, "--diff")
|
|
}
|
|
|
|
if opts.JSON {
|
|
args = append(args, "--format=json")
|
|
}
|
|
|
|
// Add specific paths if provided
|
|
args = append(args, opts.Paths...)
|
|
|
|
return cmdName, args
|
|
}
|
|
|
|
// buildPHPStanCommand builds the command for running PHPStan.
|
|
func buildPHPStanCommand(opts AnalyseOptions) (string, []string) {
|
|
m := getMedium()
|
|
|
|
// Check for vendor binary first
|
|
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "phpstan")
|
|
cmdName := "phpstan"
|
|
if m.Exists(vendorBin) {
|
|
cmdName = vendorBin
|
|
}
|
|
|
|
args := []string{"analyse"}
|
|
|
|
if opts.Level > 0 {
|
|
args = append(args, "--level", cli.Sprintf("%d", opts.Level))
|
|
}
|
|
|
|
if opts.Memory != "" {
|
|
args = append(args, "--memory-limit", opts.Memory)
|
|
}
|
|
|
|
// Output format - SARIF takes precedence over JSON
|
|
if opts.SARIF {
|
|
args = append(args, "--error-format=sarif")
|
|
} else if opts.JSON {
|
|
args = append(args, "--error-format=json")
|
|
}
|
|
|
|
// Add specific paths if provided
|
|
args = append(args, opts.Paths...)
|
|
|
|
return cmdName, args
|
|
}
|
|
|
|
// =============================================================================
|
|
// Psalm Static Analysis
|
|
// =============================================================================
|
|
|
|
// PsalmOptions configures Psalm static analysis.
|
|
type PsalmOptions struct {
|
|
Dir string
|
|
Level int // Error level (1=strictest, 8=most lenient)
|
|
Fix bool // Auto-fix issues where possible
|
|
Baseline bool // Generate/update baseline file
|
|
ShowInfo bool // Show info-level issues
|
|
JSON bool // Output in JSON format
|
|
SARIF bool // Output in SARIF format for GitHub Security tab
|
|
Output goio.Writer
|
|
}
|
|
|
|
// PsalmType represents the detected Psalm configuration.
|
|
type PsalmType string
|
|
|
|
// Psalm configuration type constants.
|
|
const (
|
|
// PsalmStandard indicates standard Psalm configuration.
|
|
PsalmStandard PsalmType = "psalm"
|
|
)
|
|
|
|
// DetectPsalm checks if Psalm is available in the project.
|
|
func DetectPsalm(dir string) (PsalmType, bool) {
|
|
m := getMedium()
|
|
|
|
// Check for psalm.xml config
|
|
psalmConfig := filepath.Join(dir, "psalm.xml")
|
|
psalmDistConfig := filepath.Join(dir, "psalm.xml.dist")
|
|
|
|
hasConfig := m.Exists(psalmConfig) || m.Exists(psalmDistConfig)
|
|
|
|
// Check for vendor binary
|
|
psalmBin := filepath.Join(dir, "vendor", "bin", "psalm")
|
|
if m.Exists(psalmBin) {
|
|
return PsalmStandard, true
|
|
}
|
|
|
|
if hasConfig {
|
|
return PsalmStandard, true
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
// RunPsalm runs Psalm static analysis.
|
|
func RunPsalm(ctx context.Context, opts PsalmOptions) error {
|
|
if opts.Dir == "" {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return cli.WrapVerb(err, "get", "working directory")
|
|
}
|
|
opts.Dir = cwd
|
|
}
|
|
|
|
if opts.Output == nil {
|
|
opts.Output = os.Stdout
|
|
}
|
|
|
|
m := getMedium()
|
|
|
|
// Build command
|
|
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "psalm")
|
|
cmdName := "psalm"
|
|
if m.Exists(vendorBin) {
|
|
cmdName = vendorBin
|
|
}
|
|
|
|
args := []string{"--no-progress"}
|
|
|
|
if opts.Level > 0 && opts.Level <= 8 {
|
|
args = append(args, cli.Sprintf("--error-level=%d", opts.Level))
|
|
}
|
|
|
|
if opts.Fix {
|
|
args = append(args, "--alter", "--issues=all")
|
|
}
|
|
|
|
if opts.Baseline {
|
|
args = append(args, "--set-baseline=psalm-baseline.xml")
|
|
}
|
|
|
|
if opts.ShowInfo {
|
|
args = append(args, "--show-info=true")
|
|
}
|
|
|
|
// Output format - SARIF takes precedence over JSON
|
|
if opts.SARIF {
|
|
args = append(args, "--output-format=sarif")
|
|
} else if opts.JSON {
|
|
args = append(args, "--output-format=json")
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, cmdName, args...)
|
|
cmd.Dir = opts.Dir
|
|
cmd.Stdout = opts.Output
|
|
cmd.Stderr = opts.Output
|
|
|
|
return cmd.Run()
|
|
}
|
|
|
|
// =============================================================================
|
|
// Security Audit
|
|
// =============================================================================
|
|
|
|
// AuditOptions configures dependency security auditing.
|
|
type AuditOptions struct {
|
|
Dir string
|
|
JSON bool // Output in JSON format
|
|
Fix bool // Auto-fix vulnerabilities (npm only)
|
|
Output goio.Writer
|
|
}
|
|
|
|
// AuditResult holds the results of a security audit.
|
|
type AuditResult struct {
|
|
Tool string
|
|
Vulnerabilities int
|
|
Advisories []AuditAdvisory
|
|
Error error
|
|
}
|
|
|
|
// AuditAdvisory represents a single security advisory.
|
|
type AuditAdvisory struct {
|
|
Package string
|
|
Severity string
|
|
Title string
|
|
URL string
|
|
Identifiers []string
|
|
}
|
|
|
|
// RunAudit runs security audits on dependencies.
|
|
func RunAudit(ctx context.Context, opts AuditOptions) ([]AuditResult, error) {
|
|
if opts.Dir == "" {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return nil, cli.WrapVerb(err, "get", "working directory")
|
|
}
|
|
opts.Dir = cwd
|
|
}
|
|
|
|
if opts.Output == nil {
|
|
opts.Output = os.Stdout
|
|
}
|
|
|
|
var results []AuditResult
|
|
|
|
// Run composer audit
|
|
composerResult := runComposerAudit(ctx, opts)
|
|
results = append(results, composerResult)
|
|
|
|
// Run npm audit if package.json exists
|
|
if getMedium().Exists(filepath.Join(opts.Dir, "package.json")) {
|
|
npmResult := runNpmAudit(ctx, opts)
|
|
results = append(results, npmResult)
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func runComposerAudit(ctx context.Context, opts AuditOptions) AuditResult {
|
|
result := AuditResult{Tool: "composer"}
|
|
|
|
args := []string{"audit", "--format=json"}
|
|
|
|
cmd := exec.CommandContext(ctx, "composer", args...)
|
|
cmd.Dir = opts.Dir
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
// composer audit returns non-zero if vulnerabilities found
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
output = append(output, exitErr.Stderr...)
|
|
}
|
|
}
|
|
|
|
// Parse JSON output
|
|
var auditData struct {
|
|
Advisories map[string][]struct {
|
|
Title string `json:"title"`
|
|
Link string `json:"link"`
|
|
CVE string `json:"cve"`
|
|
AffectedRanges string `json:"affectedVersions"`
|
|
} `json:"advisories"`
|
|
}
|
|
|
|
if jsonErr := json.Unmarshal(output, &auditData); jsonErr == nil {
|
|
for pkg, advisories := range auditData.Advisories {
|
|
for _, adv := range advisories {
|
|
result.Advisories = append(result.Advisories, AuditAdvisory{
|
|
Package: pkg,
|
|
Title: adv.Title,
|
|
URL: adv.Link,
|
|
Identifiers: []string{adv.CVE},
|
|
})
|
|
}
|
|
}
|
|
result.Vulnerabilities = len(result.Advisories)
|
|
} else if err != nil {
|
|
result.Error = err
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func runNpmAudit(ctx context.Context, opts AuditOptions) AuditResult {
|
|
result := AuditResult{Tool: "npm"}
|
|
|
|
args := []string{"audit", "--json"}
|
|
if opts.Fix {
|
|
args = []string{"audit", "fix"}
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, "npm", args...)
|
|
cmd.Dir = opts.Dir
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
output = append(output, exitErr.Stderr...)
|
|
}
|
|
}
|
|
|
|
if !opts.Fix {
|
|
// Parse JSON output
|
|
var auditData struct {
|
|
Metadata struct {
|
|
Vulnerabilities struct {
|
|
Total int `json:"total"`
|
|
} `json:"vulnerabilities"`
|
|
} `json:"metadata"`
|
|
Vulnerabilities map[string]struct {
|
|
Severity string `json:"severity"`
|
|
Via []any `json:"via"`
|
|
} `json:"vulnerabilities"`
|
|
}
|
|
|
|
if jsonErr := json.Unmarshal(output, &auditData); jsonErr == nil {
|
|
result.Vulnerabilities = auditData.Metadata.Vulnerabilities.Total
|
|
for pkg, vuln := range auditData.Vulnerabilities {
|
|
result.Advisories = append(result.Advisories, AuditAdvisory{
|
|
Package: pkg,
|
|
Severity: vuln.Severity,
|
|
})
|
|
}
|
|
} else if err != nil {
|
|
result.Error = err
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// =============================================================================
|
|
// Rector Automated Refactoring
|
|
// =============================================================================
|
|
|
|
// RectorOptions configures Rector code refactoring.
|
|
type RectorOptions struct {
|
|
Dir string
|
|
Fix bool // Apply changes (default is dry-run)
|
|
Diff bool // Show detailed diff
|
|
ClearCache bool // Clear cache before running
|
|
Output goio.Writer
|
|
}
|
|
|
|
// DetectRector checks if Rector is available in the project.
|
|
func DetectRector(dir string) bool {
|
|
m := getMedium()
|
|
|
|
// Check for rector.php config
|
|
rectorConfig := filepath.Join(dir, "rector.php")
|
|
if m.Exists(rectorConfig) {
|
|
return true
|
|
}
|
|
|
|
// Check for vendor binary
|
|
rectorBin := filepath.Join(dir, "vendor", "bin", "rector")
|
|
if m.Exists(rectorBin) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// RunRector runs Rector for automated code refactoring.
|
|
func RunRector(ctx context.Context, opts RectorOptions) error {
|
|
if opts.Dir == "" {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return cli.WrapVerb(err, "get", "working directory")
|
|
}
|
|
opts.Dir = cwd
|
|
}
|
|
|
|
if opts.Output == nil {
|
|
opts.Output = os.Stdout
|
|
}
|
|
|
|
m := getMedium()
|
|
|
|
// Build command
|
|
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "rector")
|
|
cmdName := "rector"
|
|
if m.Exists(vendorBin) {
|
|
cmdName = vendorBin
|
|
}
|
|
|
|
args := []string{"process"}
|
|
|
|
if !opts.Fix {
|
|
args = append(args, "--dry-run")
|
|
}
|
|
|
|
if opts.Diff {
|
|
args = append(args, "--output-format", "diff")
|
|
}
|
|
|
|
if opts.ClearCache {
|
|
args = append(args, "--clear-cache")
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, cmdName, args...)
|
|
cmd.Dir = opts.Dir
|
|
cmd.Stdout = opts.Output
|
|
cmd.Stderr = opts.Output
|
|
|
|
return cmd.Run()
|
|
}
|
|
|
|
// =============================================================================
|
|
// Infection Mutation Testing
|
|
// =============================================================================
|
|
|
|
// InfectionOptions configures Infection mutation testing.
|
|
type InfectionOptions struct {
|
|
Dir string
|
|
MinMSI int // Minimum mutation score indicator (0-100)
|
|
MinCoveredMSI int // Minimum covered mutation score (0-100)
|
|
Threads int // Number of parallel threads
|
|
Filter string // Filter files by pattern
|
|
OnlyCovered bool // Only mutate covered code
|
|
Output goio.Writer
|
|
}
|
|
|
|
// DetectInfection checks if Infection is available in the project.
|
|
func DetectInfection(dir string) bool {
|
|
m := getMedium()
|
|
|
|
// Check for infection config files
|
|
configs := []string{"infection.json", "infection.json5", "infection.json.dist"}
|
|
for _, config := range configs {
|
|
if m.Exists(filepath.Join(dir, config)) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Check for vendor binary
|
|
infectionBin := filepath.Join(dir, "vendor", "bin", "infection")
|
|
if m.Exists(infectionBin) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// RunInfection runs Infection mutation testing.
|
|
func RunInfection(ctx context.Context, opts InfectionOptions) error {
|
|
if opts.Dir == "" {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return cli.WrapVerb(err, "get", "working directory")
|
|
}
|
|
opts.Dir = cwd
|
|
}
|
|
|
|
if opts.Output == nil {
|
|
opts.Output = os.Stdout
|
|
}
|
|
|
|
m := getMedium()
|
|
|
|
// Build command
|
|
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "infection")
|
|
cmdName := "infection"
|
|
if m.Exists(vendorBin) {
|
|
cmdName = vendorBin
|
|
}
|
|
|
|
var args []string
|
|
|
|
// Set defaults
|
|
minMSI := opts.MinMSI
|
|
if minMSI == 0 {
|
|
minMSI = 50
|
|
}
|
|
minCoveredMSI := opts.MinCoveredMSI
|
|
if minCoveredMSI == 0 {
|
|
minCoveredMSI = 70
|
|
}
|
|
threads := opts.Threads
|
|
if threads == 0 {
|
|
threads = 4
|
|
}
|
|
|
|
args = append(args, cli.Sprintf("--min-msi=%d", minMSI))
|
|
args = append(args, cli.Sprintf("--min-covered-msi=%d", minCoveredMSI))
|
|
args = append(args, cli.Sprintf("--threads=%d", threads))
|
|
|
|
if opts.Filter != "" {
|
|
args = append(args, "--filter="+opts.Filter)
|
|
}
|
|
|
|
if opts.OnlyCovered {
|
|
args = append(args, "--only-covered")
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, cmdName, args...)
|
|
cmd.Dir = opts.Dir
|
|
cmd.Stdout = opts.Output
|
|
cmd.Stderr = opts.Output
|
|
|
|
return cmd.Run()
|
|
}
|
|
|
|
// =============================================================================
|
|
// QA Pipeline
|
|
// =============================================================================
|
|
|
|
// QAOptions configures the full QA pipeline.
|
|
type QAOptions struct {
|
|
Dir string
|
|
Quick bool // Only run quick checks
|
|
Full bool // Run all stages including slow checks
|
|
Fix bool // Auto-fix issues where possible
|
|
JSON bool // Output results as JSON
|
|
}
|
|
|
|
// QAStage represents a stage in the QA pipeline.
|
|
type QAStage string
|
|
|
|
// QA pipeline stage constants.
|
|
const (
|
|
// QAStageQuick runs fast checks only (audit, fmt, stan).
|
|
QAStageQuick QAStage = "quick"
|
|
// QAStageStandard runs standard checks including tests.
|
|
QAStageStandard QAStage = "standard"
|
|
// QAStageFull runs all checks including slow security scans.
|
|
QAStageFull QAStage = "full"
|
|
)
|
|
|
|
// QACheckResult holds the result of a single QA check.
|
|
type QACheckResult struct {
|
|
Name string
|
|
Stage QAStage
|
|
Passed bool
|
|
Duration string
|
|
Error error
|
|
Output string
|
|
}
|
|
|
|
// QAResult holds the results of the full QA pipeline.
|
|
type QAResult struct {
|
|
Stages []QAStage
|
|
Checks []QACheckResult
|
|
Passed bool
|
|
Summary string
|
|
}
|
|
|
|
// GetQAStages returns the stages to run based on options.
|
|
func GetQAStages(opts QAOptions) []QAStage {
|
|
if opts.Quick {
|
|
return []QAStage{QAStageQuick}
|
|
}
|
|
if opts.Full {
|
|
return []QAStage{QAStageQuick, QAStageStandard, QAStageFull}
|
|
}
|
|
// Default: quick + standard
|
|
return []QAStage{QAStageQuick, QAStageStandard}
|
|
}
|
|
|
|
// GetQAChecks returns the checks for a given stage.
|
|
func GetQAChecks(dir string, stage QAStage) []string {
|
|
switch stage {
|
|
case QAStageQuick:
|
|
checks := []string{"audit", "fmt", "stan"}
|
|
return checks
|
|
case QAStageStandard:
|
|
checks := []string{}
|
|
if _, found := DetectPsalm(dir); found {
|
|
checks = append(checks, "psalm")
|
|
}
|
|
checks = append(checks, "test")
|
|
return checks
|
|
case QAStageFull:
|
|
checks := []string{}
|
|
if DetectRector(dir) {
|
|
checks = append(checks, "rector")
|
|
}
|
|
if DetectInfection(dir) {
|
|
checks = append(checks, "infection")
|
|
}
|
|
return checks
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// =============================================================================
|
|
// Security Checks
|
|
// =============================================================================
|
|
|
|
// 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)
|
|
Output goio.Writer
|
|
}
|
|
|
|
// SecurityResult holds the results of security scanning.
|
|
type SecurityResult struct {
|
|
Checks []SecurityCheck
|
|
Summary SecuritySummary
|
|
}
|
|
|
|
// SecurityCheck represents a single security check result.
|
|
type SecurityCheck struct {
|
|
ID string
|
|
Name string
|
|
Description string
|
|
Severity string
|
|
Passed bool
|
|
Message string
|
|
Fix string
|
|
CWE string
|
|
}
|
|
|
|
// SecuritySummary summarizes security check results.
|
|
type SecuritySummary struct {
|
|
Total int
|
|
Passed int
|
|
Critical int
|
|
High int
|
|
Medium int
|
|
Low int
|
|
}
|
|
|
|
// 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, cli.WrapVerb(err, "get", "working directory")
|
|
}
|
|
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: i18n.Title(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 = cli.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
|
|
|
|
m := getMedium()
|
|
envPath := filepath.Join(dir, ".env")
|
|
envContent, err := m.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
|
|
m := getMedium()
|
|
|
|
// Check .env not in public
|
|
publicEnvPaths := []string{"public/.env", "public_html/.env"}
|
|
for _, path := range publicEnvPaths {
|
|
fullPath := filepath.Join(dir, path)
|
|
if m.Exists(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 m.Exists(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
|
|
}
|