package php import ( "cmp" "context" "encoding/json" "io" "os" "os/exec" "path/filepath" "slices" coreerr "forge.lthn.ai/core/go-log" ) // AuditOptions configures dependency security auditing. type AuditOptions struct { Dir string JSON bool // Output in JSON format Fix bool // Auto-fix vulnerabilities (npm only) Output io.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, coreerr.E("php.RunAudit", "get working directory", err) } 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 fileExists(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}, }) } } sortAuditAdvisories(result.Advisories) 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, }) } sortAuditAdvisories(result.Advisories) } else if err != nil { result.Error = err } } return result } func sortAuditAdvisories(advisories []AuditAdvisory) { slices.SortFunc(advisories, func(a, b AuditAdvisory) int { return cmp.Or( cmp.Compare(a.Package, b.Package), cmp.Compare(a.Title, b.Title), cmp.Compare(a.Severity, b.Severity), cmp.Compare(a.URL, b.URL), ) }) }