diff --git a/cmd/qa/cmd_health.go b/cmd/qa/cmd_health.go index 2ba99c4..050bdfd 100644 --- a/cmd/qa/cmd_health.go +++ b/cmd/qa/cmd_health.go @@ -269,8 +269,15 @@ func summariseHealthResults(totalRepos int, filteredRepos int, results []RepoHea summary := HealthSummary{ TotalRepos: totalRepos, FilteredRepos: filteredRepos, - ByStatus: make(map[string]int), - ProblemsOnly: problemsOnly, + ByStatus: map[string]int{ + "passing": 0, + "failing": 0, + "error": 0, + "pending": 0, + "disabled": 0, + "no_ci": 0, + }, + ProblemsOnly: problemsOnly, } for _, health := range results { diff --git a/cmd/qa/cmd_php.go b/cmd/qa/cmd_php.go index 3eebda1..4067325 100644 --- a/cmd/qa/cmd_php.go +++ b/cmd/qa/cmd_php.go @@ -424,35 +424,52 @@ func addPHPSecurityCommand(parent *cli.Command) { } type auditJSONOutput struct { - Results []AuditResultJSON `json:"results"` + Results []auditResultJSON `json:"results"` HasVulnerabilities bool `json:"has_vulnerabilities"` Vulnerabilities int `json:"vulnerabilities"` } -type AuditResultJSON struct { +type auditResultJSON struct { Tool string `json:"tool"` Vulnerabilities int `json:"vulnerabilities"` - Advisories []php.AuditAdvisory `json:"advisories"` + Advisories []auditAdvisoryJSON `json:"advisories"` Error string `json:"error,omitempty"` } +type auditAdvisoryJSON struct { + Package string `json:"package"` + Severity string `json:"severity,omitempty"` + Title string `json:"title,omitempty"` + URL string `json:"url,omitempty"` + Identifiers []string `json:"identifiers,omitempty"` +} + func mapAuditResultsForJSON(results []php.AuditResult) auditJSONOutput { output := auditJSONOutput{ - Results: make([]AuditResultJSON, 0, len(results)), + Results: make([]auditResultJSON, 0, len(results)), } sort.Slice(results, func(i, j int) bool { return results[i].Tool < results[j].Tool }) for _, result := range results { - entry := AuditResultJSON{ + entry := auditResultJSON{ Tool: result.Tool, Vulnerabilities: result.Vulnerabilities, - Advisories: append([]php.AuditAdvisory(nil), result.Advisories...), } if result.Error != nil { entry.Error = result.Error.Error() } + entry.Advisories = make([]auditAdvisoryJSON, 0, len(result.Advisories)) + for _, advisory := range result.Advisories { + entry.Advisories = append(entry.Advisories, auditAdvisoryJSON{ + Package: advisory.Package, + Severity: advisory.Severity, + Title: advisory.Title, + URL: advisory.URL, + Identifiers: append([]string(nil), advisory.Identifiers...), + }) + } sort.Slice(entry.Advisories, func(i, j int) bool { if entry.Advisories[i].Package == entry.Advisories[j].Package { return entry.Advisories[i].Title < entry.Advisories[j].Title diff --git a/cmd/qa/cmd_php_test.go b/cmd/qa/cmd_php_test.go index 97d4faa..f6c9fc2 100644 --- a/cmd/qa/cmd_php_test.go +++ b/cmd/qa/cmd_php_test.go @@ -153,6 +153,63 @@ func TestPHPSecuritySARIFOutput_IsStructuredAndChromeFree(t *testing.T) { assert.NotContains(t, output, "Summary:") } +func TestPHPAuditJSONOutput_UsesLowerCaseAdvisoryKeys(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, filepath.Join(dir, "composer.json"), "{}") + writeExecutable(t, filepath.Join(dir, "composer"), `#!/bin/sh +cat <<'JSON' +{ + "advisories": { + "vendor/package-a": [ + { + "title": "Remote Code Execution", + "link": "https://example.com/advisory/1", + "cve": "CVE-2025-1234", + "affectedVersions": ">=1.0,<1.5" + } + ] + } +} +JSON +`) + + restoreWorkingDir(t, dir) + prependPath(t, dir) + resetPHPAuditFlags(t) + + parent := &cli.Command{Use: "qa"} + addPHPAuditCommand(parent) + command := findSubcommand(t, parent, "audit") + require.NoError(t, command.Flags().Set("json", "true")) + + var runErr error + output := captureStdout(t, func() { + runErr = command.RunE(command, nil) + }) + + require.Error(t, runErr) + + var payload struct { + Results []struct { + Tool string `json:"tool"` + Advisories []struct { + Package string `json:"package"` + } `json:"advisories"` + } `json:"results"` + HasVulnerabilities bool `json:"has_vulnerabilities"` + Vulnerabilities int `json:"vulnerabilities"` + } + require.NoError(t, json.Unmarshal([]byte(output), &payload)) + require.Len(t, payload.Results, 1) + assert.Equal(t, "composer", payload.Results[0].Tool) + require.Len(t, payload.Results[0].Advisories, 1) + assert.Equal(t, "vendor/package-a", payload.Results[0].Advisories[0].Package) + assert.True(t, payload.HasVulnerabilities) + assert.Equal(t, 1, payload.Vulnerabilities) + assert.NotContains(t, output, "\"Package\"") + assert.NotContains(t, output, "Dependency Audit") +} + func TestPHPTestJUnitOutput_PrintsOnlyXML(t *testing.T) { dir := t.TempDir() writeTestFile(t, filepath.Join(dir, "composer.json"), "{}") @@ -258,6 +315,18 @@ func resetPHPSecurityFlags(t *testing.T) { }) } +func resetPHPAuditFlags(t *testing.T) { + t.Helper() + oldJSON := phpAuditJSON + oldFix := phpAuditFix + phpAuditJSON = false + phpAuditFix = false + t.Cleanup(func() { + phpAuditJSON = oldJSON + phpAuditFix = oldFix + }) +} + func resetPHPTestFlags(t *testing.T) { t.Helper() oldParallel := phpTestParallel diff --git a/pkg/php/security.go b/pkg/php/security.go index fc6803d..2d729cc 100644 --- a/pkg/php/security.go +++ b/pkg/php/security.go @@ -1,10 +1,12 @@ package php import ( + "cmp" "context" "fmt" "os" "path/filepath" + "slices" "strings" coreio "forge.lthn.ai/core/go-io" @@ -112,6 +114,12 @@ func RunSecurityChecks(ctx context.Context, opts SecurityOptions) (*SecurityResu } } + // Keep the check order stable for callers that consume the package result + // directly instead of going through the CLI layer. + slices.SortFunc(result.Checks, func(a, b SecurityCheck) int { + return cmp.Compare(a.ID, b.ID) + }) + return result, nil } diff --git a/tests/cli/qa/audit/Taskfile.yaml b/tests/cli/qa/audit/Taskfile.yaml index bd4c8f6..b9dbd22 100644 --- a/tests/cli/qa/audit/Taskfile.yaml +++ b/tests/cli/qa/audit/Taskfile.yaml @@ -16,5 +16,5 @@ tasks: run_capture_stdout 1 "$output" ../../bin/core qa audit --json jq -e '.results[0].tool == "composer" and .results[0].vulnerabilities == 1' "$output" >/dev/null jq -e '.has_vulnerabilities == true and .vulnerabilities == 1' "$output" >/dev/null - jq -e '.results[0].advisories[0].Package == "vendor/package-a"' "$output" >/dev/null + jq -e '.results[0].advisories[0].package == "vendor/package-a"' "$output" >/dev/null EOF