From d3d9af57b1f37845a177aaefc750c43e261003f6 Mon Sep 17 00:00:00 2001 From: Shabbir Date: Tue, 1 Sep 2026 18:21:27 +0530 Subject: [PATCH 1/3] feat(privacy): add --format json, --output flags and summary to scan results (#31) --- internal/cli/privacy.go | 123 ++++++++++++++++++++++++------------ internal/privacy/scanner.go | 32 ++++++++++ 2 files changed, 116 insertions(+), 39 deletions(-) diff --git a/internal/cli/privacy.go b/internal/cli/privacy.go index 2e08f41..a233641 100644 --- a/internal/cli/privacy.go +++ b/internal/cli/privacy.go @@ -1,6 +1,7 @@ package cli import ( + "encoding/json" "fmt" "os" "strings" @@ -11,6 +12,11 @@ import ( "github.com/spf13/cobra" ) +var ( + privacyFormat string + privacyOutput string +) + var privacyCmd = &cobra.Command{ Use: "privacy [path]", Short: "Validate privacy manifest and Required Reason API compliance", @@ -33,6 +39,8 @@ No App Store Connect account needed — runs entirely offline.`, } func init() { + privacyCmd.Flags().StringVar(&privacyFormat, "format", "terminal", "output format: terminal, json") + privacyCmd.Flags().StringVar(&privacyOutput, "output", "", "write report to file (stdout if omitted)") rootCmd.AddCommand(privacyCmd) } @@ -50,8 +58,11 @@ func runPrivacy(cmd *cobra.Command, args []string) error { return fmt.Errorf("path must be a directory: %s", path) } - purple.Println("\n greenlight privacy — validate your privacy compliance.") - fmt.Printf(" Scanning: %s\n\n", path) + format := strings.ToLower(privacyFormat) + if format != "json" { + purple.Println("\n greenlight privacy — validate your privacy compliance.") + fmt.Printf(" Scanning: %s\n\n", path) + } start := time.Now() result, err := privacy.Scan(path) @@ -60,6 +71,26 @@ func runPrivacy(cmd *cobra.Command, args []string) error { } elapsed := time.Since(start) + var output *os.File + if privacyOutput != "" { + output, err = os.Create(privacyOutput) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer output.Close() + } else { + output = os.Stdout + } + + switch format { + case "json": + return writePrivacyJSON(output, result, elapsed) + default: + return writePrivacyTerminal(output, result, elapsed) + } +} + +func writePrivacyTerminal(w *os.File, result *privacy.ScanResult, elapsed time.Duration) error { red := color.New(color.FgRed, color.Bold) yellow := color.New(color.FgYellow) green := color.New(color.FgGreen) @@ -67,31 +98,31 @@ func runPrivacy(cmd *cobra.Command, args []string) error { // Status summary if result.HasPrivacyInfo { - green.Fprint(os.Stdout, " ✓ ") - fmt.Println("PrivacyInfo.xcprivacy found") + green.Fprint(w, " ✓ ") + fmt.Fprintln(w, "PrivacyInfo.xcprivacy found") } else { - red.Fprint(os.Stdout, " ✗ ") - fmt.Println("PrivacyInfo.xcprivacy NOT found") + red.Fprint(w, " ✗ ") + fmt.Fprintln(w, "PrivacyInfo.xcprivacy NOT found") } if len(result.DetectedAPIs) > 0 { - fmt.Printf(" Required Reason APIs detected: %s\n", strings.Join(result.DetectedAPIs, ", ")) + fmt.Fprintf(w, " Required Reason APIs detected: %s\n", strings.Join(result.DetectedAPIs, ", ")) } if len(result.DeclaredAPIs) > 0 { - fmt.Printf(" APIs declared in manifest: %s\n", strings.Join(result.DeclaredAPIs, ", ")) + fmt.Fprintf(w, " APIs declared in manifest: %s\n", strings.Join(result.DeclaredAPIs, ", ")) } if len(result.TrackingSDKs) > 0 { - yellow.Fprint(os.Stdout, " Tracking SDKs found: ") - fmt.Println(strings.Join(result.TrackingSDKs, ", ")) + yellow.Fprint(w, " Tracking SDKs found: ") + fmt.Fprintln(w, strings.Join(result.TrackingSDKs, ", ")) } - fmt.Println() + fmt.Fprintln(w) if len(result.Findings) == 0 { - green.Fprintln(os.Stdout, " No privacy issues found!") - fmt.Println() + green.Fprintln(w, " No privacy issues found!") + fmt.Fprintln(w) printPrivacyFooter(0, 0, 0, elapsed) return nil } @@ -110,53 +141,53 @@ func runPrivacy(cmd *cobra.Command, args []string) error { } if len(criticals) > 0 { - red.Println(" CRITICAL — Will be rejected") - fmt.Println() + red.Fprintln(w, " CRITICAL — Will be rejected") + fmt.Fprintln(w) for _, f := range criticals { - red.Fprint(os.Stdout, " [CRITICAL] ") + red.Fprint(w, " [CRITICAL] ") if f.Guideline != "" { - bold.Fprintf(os.Stdout, "§%s ", f.Guideline) + bold.Fprintf(w, "§%s ", f.Guideline) } - bold.Fprintln(os.Stdout, f.Title) - fmt.Printf(" %s\n", f.Detail) + bold.Fprintln(w, f.Title) + fmt.Fprintf(w, " %s\n", f.Detail) if f.Fix != "" { - green.Fprint(os.Stdout, " Fix: ") - fmt.Println(f.Fix) + green.Fprint(w, " Fix: ") + fmt.Fprintln(w, f.Fix) } - fmt.Println() + fmt.Fprintln(w) } } if len(warns) > 0 { - yellow.Println(" WARNING — High rejection risk") - fmt.Println() + yellow.Fprintln(w, " WARNING — High rejection risk") + fmt.Fprintln(w) for _, f := range warns { - yellow.Fprint(os.Stdout, " [WARN] ") + yellow.Fprint(w, " [WARN] ") if f.Guideline != "" { - bold.Fprintf(os.Stdout, "§%s ", f.Guideline) + bold.Fprintf(w, "§%s ", f.Guideline) } - bold.Fprintln(os.Stdout, f.Title) - fmt.Printf(" %s\n", f.Detail) + bold.Fprintln(w, f.Title) + fmt.Fprintf(w, " %s\n", f.Detail) if f.Fix != "" { - green.Fprint(os.Stdout, " Fix: ") - fmt.Println(f.Fix) + green.Fprint(w, " Fix: ") + fmt.Fprintln(w, f.Fix) } - fmt.Println() + fmt.Fprintln(w) } } if len(infos) > 0 { - dim.Println(" INFO — Best practices") - fmt.Println() + dim.Fprintln(w, " INFO — Best practices") + fmt.Fprintln(w) for _, f := range infos { - dim.Fprint(os.Stdout, " [INFO] ") - bold.Fprintln(os.Stdout, f.Title) - fmt.Printf(" %s\n", f.Detail) + dim.Fprint(w, " [INFO] ") + bold.Fprintln(w, f.Title) + fmt.Fprintf(w, " %s\n", f.Detail) if f.Fix != "" { - green.Fprint(os.Stdout, " Fix: ") - fmt.Println(f.Fix) + green.Fprint(w, " Fix: ") + fmt.Fprintln(w, f.Fix) } - fmt.Println() + fmt.Fprintln(w) } } @@ -164,6 +195,20 @@ func runPrivacy(cmd *cobra.Command, args []string) error { return nil } +func writePrivacyJSON(w *os.File, result *privacy.ScanResult, elapsed time.Duration) error { + output := struct { + privacy.ScanResult + Elapsed string `json:"elapsed"` + }{ + ScanResult: *result, + Elapsed: elapsed.Round(time.Millisecond).String(), + } + + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(output) +} + func printPrivacyFooter(criticals, warns, infos int, elapsed time.Duration) { red := color.New(color.FgRed, color.Bold) green := color.New(color.FgGreen, color.Bold) diff --git a/internal/privacy/scanner.go b/internal/privacy/scanner.go index 0fc7744..7de5ef0 100644 --- a/internal/privacy/scanner.go +++ b/internal/privacy/scanner.go @@ -20,6 +20,36 @@ type Finding struct { Line int `json:"line,omitempty"` } +// Summary holds aggregate results. +type Summary struct { + Total int `json:"total"` + Critical int `json:"critical"` + High int `json:"high"` + Warns int `json:"warns"` + Infos int `json:"infos"` + FilesScanned int `json:"files_scanned"` + Passed bool `json:"passed"` +} + +func ComputeSummary(findings []Finding, filesScanned int) Summary { + s := Summary{FilesScanned: filesScanned} + for _, f := range findings { + s.Total++ + switch f.Severity { + case "CRITICAL": + s.Critical++ + case "HIGH": + s.High++ + case "WARN": + s.Warns++ + case "INFO": + s.Infos++ + } + } + s.Passed = s.Critical == 0 + return s +} + // RequiredReasonAPI represents an Apple Required Reason API category. type RequiredReasonAPI struct { Name string // Human-readable name @@ -37,6 +67,7 @@ type ScanResult struct { DeclaredAPIs []string `json:"declared_apis"` TrackingSDKs []string `json:"tracking_sdks,omitempty"` Findings []Finding `json:"findings"` + Summary Summary `json:"summary"` } var requiredReasonAPIs = []RequiredReasonAPI{ @@ -264,6 +295,7 @@ func Scan(projectPath string) (*ScanResult, error) { }) } + result.Summary = ComputeSummary(result.Findings, 0) return result, nil } From 9579d82e1af2556a9f230f2bcaab81a94f223c64 Mon Sep 17 00:00:00 2001 From: Shabbir Date: Tue, 1 Sep 2026 19:02:08 +0530 Subject: [PATCH 2/3] fix(privacy): fix footer leaking to stdout when --output is used (#31) --- internal/cli/privacy.go | 50 ++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/internal/cli/privacy.go b/internal/cli/privacy.go index a233641..b608313 100644 --- a/internal/cli/privacy.go +++ b/internal/cli/privacy.go @@ -123,7 +123,7 @@ func writePrivacyTerminal(w *os.File, result *privacy.ScanResult, elapsed time.D if len(result.Findings) == 0 { green.Fprintln(w, " No privacy issues found!") fmt.Fprintln(w) - printPrivacyFooter(0, 0, 0, elapsed) + printPrivacyFooter(w, 0, 0, 0, elapsed) return nil } @@ -191,7 +191,7 @@ func writePrivacyTerminal(w *os.File, result *privacy.ScanResult, elapsed time.D } } - printPrivacyFooter(len(criticals), len(warns), len(infos), elapsed) + printPrivacyFooter(w, len(criticals), len(warns), len(infos), elapsed) return nil } @@ -209,46 +209,46 @@ func writePrivacyJSON(w *os.File, result *privacy.ScanResult, elapsed time.Durat return enc.Encode(output) } -func printPrivacyFooter(criticals, warns, infos int, elapsed time.Duration) { +func printPrivacyFooter(w *os.File, criticals, warns, infos int, elapsed time.Duration) { red := color.New(color.FgRed, color.Bold) green := color.New(color.FgGreen, color.Bold) total := criticals + warns + infos - dim.Fprintln(os.Stdout, " ─────────────────────────────────────────────") - fmt.Println() + dim.Fprintln(w, " ─────────────────────────────────────────────") + fmt.Fprintln(w) if criticals == 0 { - green.Fprint(os.Stdout, " GREENLIT") - fmt.Fprint(os.Stdout, " — privacy compliance looks good") + green.Fprint(w, " GREENLIT") + fmt.Fprint(w, " — privacy compliance looks good") } else { - red.Fprint(os.Stdout, " NOT READY") - fmt.Fprintf(os.Stdout, " — %d critical privacy issue(s)", criticals) + red.Fprint(w, " NOT READY") + fmt.Fprintf(w, " — %d critical privacy issue(s)", criticals) } - fmt.Println() + fmt.Fprintln(w) if total > 0 { - fmt.Fprintf(os.Stdout, " %d findings: ", total) + fmt.Fprintf(w, " %d findings: ", total) if criticals > 0 { - red.Fprintf(os.Stdout, "%d critical ", criticals) + red.Fprintf(w, "%d critical ", criticals) } if warns > 0 { - color.New(color.FgYellow).Fprintf(os.Stdout, "%d warn ", warns) + color.New(color.FgYellow).Fprintf(w, "%d warn ", warns) } if infos > 0 { - dim.Fprintf(os.Stdout, "%d info", infos) + dim.Fprintf(w, "%d info", infos) } - fmt.Println() + fmt.Fprintln(w) } - dim.Fprintf(os.Stdout, " completed in %s\n", elapsed.Round(time.Millisecond)) + dim.Fprintf(w, " completed in %s\n", elapsed.Round(time.Millisecond)) - fmt.Println() - dim.Fprintln(os.Stdout, " ─────────────────────────────────────────────") - fmt.Fprint(os.Stdout, " Built by ") - purple.Fprint(os.Stdout, "Revyl") - fmt.Fprintln(os.Stdout, " — the mobile reliability platform") - dim.Fprintln(os.Stdout, " Catch more than rejections. Catch bugs.") - fmt.Fprint(os.Stdout, " ") - color.New(color.Underline).Fprintln(os.Stdout, "https://revyl.com") - fmt.Println() + fmt.Fprintln(w) + dim.Fprintln(w, " ─────────────────────────────────────────────") + fmt.Fprint(w, " Built by ") + purple.Fprint(w, "Revyl") + fmt.Fprintln(w, " — the mobile reliability platform") + dim.Fprintln(w, " Catch more than rejections. Catch bugs.") + fmt.Fprint(w, " ") + color.New(color.Underline).Fprintln(w, "https://revyl.com") + fmt.Fprintln(w) } From 2702c18a703f13b425d04bbac9a3fe529672f48d Mon Sep 17 00:00:00 2001 From: Shabbir Date: Thu, 3 Sep 2026 09:06:52 +0530 Subject: [PATCH 3/3] fix(cli): make 'greenlight ipa --format json' emit valid json --- README.md | 4 + internal/cli/ipa.go | 197 +++++++++++++++++++++++++++++++------------- 2 files changed, 142 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 29ac30e..b88bd6e 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,8 @@ On scope: a source scan reads the `AndroidManifest.xml` and Gradle files in your ```bash greenlight privacy /path/to/project +greenlight privacy /path/to/project --format json # JSON for CI +greenlight privacy /path/to/project --format json --output report.json ``` Checks that PrivacyInfo.xcprivacy exists and is filled in, finds Required Reason APIs used in code and compares them against what the manifest declares, and cross-references detected tracking SDKs against your ATT implementation. @@ -193,6 +195,8 @@ Checks that PrivacyInfo.xcprivacy exists and is filled in, finds Required Reason ```bash greenlight ipa /path/to/build.ipa +greenlight ipa /path/to/build.ipa --format json # JSON for CI +greenlight ipa /path/to/build.ipa --format json --output report.json ``` Plists are parsed for real, binary and XML both, rather than string-matched. Inspects: diff --git a/internal/cli/ipa.go b/internal/cli/ipa.go index e920cb3..aa8172a 100644 --- a/internal/cli/ipa.go +++ b/internal/cli/ipa.go @@ -1,8 +1,10 @@ package cli import ( + "encoding/json" "fmt" "os" + "strings" "time" "github.com/RevylAI/greenlight/internal/ipa" @@ -10,7 +12,10 @@ import ( "github.com/spf13/cobra" ) -var ipaFormat string +var ( + ipaFormat string + ipaOutput string +) var ipaCmd = &cobra.Command{ Use: "ipa ", @@ -34,6 +39,7 @@ No App Store Connect account needed — works entirely offline.`, func init() { ipaCmd.Flags().StringVar(&ipaFormat, "format", "terminal", "output format: terminal, json") + ipaCmd.Flags().StringVar(&ipaOutput, "output", "", "write report to file (stdout if omitted)") rootCmd.AddCommand(ipaCmd) } @@ -44,8 +50,11 @@ func runIPA(cmd *cobra.Command, args []string) error { return fmt.Errorf("IPA file not found: %s", ipaPath) } - purple.Println("\n greenlight ipa — inspect your binary before submission.") - fmt.Printf(" IPA: %s\n\n", ipaPath) + format := strings.ToLower(ipaFormat) + if format != "json" { + purple.Println("\n greenlight ipa — inspect your binary before submission.") + fmt.Printf(" IPA: %s\n\n", ipaPath) + } start := time.Now() result, err := ipa.Inspect(ipaPath) @@ -54,16 +63,36 @@ func runIPA(cmd *cobra.Command, args []string) error { } elapsed := time.Since(start) + var output *os.File + if ipaOutput != "" { + output, err = os.Create(ipaOutput) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer output.Close() + } else { + output = os.Stdout + } + + switch format { + case "json": + return writeIPAJSON(output, result, elapsed) + default: + return writeIPATerminal(output, result, elapsed) + } +} + +func writeIPATerminal(w *os.File, result *ipa.InspectResult, elapsed time.Duration) error { if result.AppName != "" { - fmt.Printf(" App: %s\n", result.AppName) + fmt.Fprintf(w, " App: %s\n", result.AppName) } sizeMB := float64(result.Size) / (1024 * 1024) - fmt.Printf(" Size: %.1fMB\n\n", sizeMB) + fmt.Fprintf(w, " Size: %.1fMB\n\n", sizeMB) if len(result.Findings) == 0 { - color.New(color.FgGreen, color.Bold).Fprintln(os.Stdout, " No issues found!") - fmt.Println() - printIPAFooter(0, 0, 0, elapsed) + color.New(color.FgGreen, color.Bold).Fprintln(w, " No issues found!") + fmt.Fprintln(w) + printIPAFooter(w, 0, 0, 0, elapsed) return nil } @@ -86,105 +115,155 @@ func runIPA(cmd *cobra.Command, args []string) error { bold := color.New(color.Bold) if len(criticals) > 0 { - red.Println(" CRITICAL — Will be rejected") - fmt.Println() + red.Fprintln(w, " CRITICAL — Will be rejected") + fmt.Fprintln(w) for _, f := range criticals { - red.Fprint(os.Stdout, " [CRITICAL] ") + red.Fprint(w, " [CRITICAL] ") if f.Guideline != "" { - bold.Fprintf(os.Stdout, "§%s ", f.Guideline) + bold.Fprintf(w, "§%s ", f.Guideline) } - bold.Fprintln(os.Stdout, f.Title) - fmt.Printf(" %s\n", f.Detail) + bold.Fprintln(w, f.Title) + fmt.Fprintf(w, " %s\n", f.Detail) if f.Fix != "" { - green.Fprint(os.Stdout, " Fix: ") - fmt.Println(f.Fix) + green.Fprint(w, " Fix: ") + fmt.Fprintln(w, f.Fix) } - fmt.Println() + fmt.Fprintln(w) } } if len(warns) > 0 { - yellow.Println(" WARNING — High rejection risk") - fmt.Println() + yellow.Fprintln(w, " WARNING — High rejection risk") + fmt.Fprintln(w) for _, f := range warns { - yellow.Fprint(os.Stdout, " [WARN] ") + yellow.Fprint(w, " [WARN] ") if f.Guideline != "" { - bold.Fprintf(os.Stdout, "§%s ", f.Guideline) + bold.Fprintf(w, "§%s ", f.Guideline) } - bold.Fprintln(os.Stdout, f.Title) - fmt.Printf(" %s\n", f.Detail) + bold.Fprintln(w, f.Title) + fmt.Fprintf(w, " %s\n", f.Detail) if f.Fix != "" { - green.Fprint(os.Stdout, " Fix: ") - fmt.Println(f.Fix) + green.Fprint(w, " Fix: ") + fmt.Fprintln(w, f.Fix) } - fmt.Println() + fmt.Fprintln(w) } } if len(infos) > 0 { - dim.Println(" INFO — Best practices") - fmt.Println() + dim.Fprintln(w, " INFO — Best practices") + fmt.Fprintln(w) for _, f := range infos { - dim.Fprint(os.Stdout, " [INFO] ") + dim.Fprint(w, " [INFO] ") if f.Guideline != "" { - bold.Fprintf(os.Stdout, "§%s ", f.Guideline) + bold.Fprintf(w, "§%s ", f.Guideline) } - bold.Fprintln(os.Stdout, f.Title) - fmt.Printf(" %s\n", f.Detail) + bold.Fprintln(w, f.Title) + fmt.Fprintf(w, " %s\n", f.Detail) if f.Fix != "" { - green.Fprint(os.Stdout, " Fix: ") - fmt.Println(f.Fix) + green.Fprint(w, " Fix: ") + fmt.Fprintln(w, f.Fix) } - fmt.Println() + fmt.Fprintln(w) } } - printIPAFooter(len(criticals), len(warns), len(infos), elapsed) + printIPAFooter(w, len(criticals), len(warns), len(infos), elapsed) return nil } -func printIPAFooter(criticals, warns, infos int, elapsed time.Duration) { +func printIPAFooter(w *os.File, criticals, warns, infos int, elapsed time.Duration) { red := color.New(color.FgRed, color.Bold) green := color.New(color.FgGreen, color.Bold) total := criticals + warns + infos - dim.Fprintln(os.Stdout, " ─────────────────────────────────────────────") - fmt.Println() + dim.Fprintln(w, " ─────────────────────────────────────────────") + fmt.Fprintln(w) if criticals == 0 { - green.Fprint(os.Stdout, " GREENLIT") - fmt.Fprint(os.Stdout, " — no critical issues in binary") + green.Fprint(w, " GREENLIT") + fmt.Fprint(w, " — no critical issues in binary") } else { - red.Fprint(os.Stdout, " NOT READY") - fmt.Fprintf(os.Stdout, " — %d critical issue(s) must be fixed", criticals) + red.Fprint(w, " NOT READY") + fmt.Fprintf(w, " — %d critical issue(s) must be fixed", criticals) } - fmt.Println() + fmt.Fprintln(w) if total > 0 { - fmt.Fprintf(os.Stdout, " %d findings: ", total) + fmt.Fprintf(w, " %d findings: ", total) if criticals > 0 { - red.Fprintf(os.Stdout, "%d critical ", criticals) + red.Fprintf(w, "%d critical ", criticals) } if warns > 0 { - color.New(color.FgYellow).Fprintf(os.Stdout, "%d warn ", warns) + color.New(color.FgYellow).Fprintf(w, "%d warn ", warns) } if infos > 0 { - dim.Fprintf(os.Stdout, "%d info", infos) + dim.Fprintf(w, "%d info", infos) } - fmt.Println() + fmt.Fprintln(w) } - dim.Fprintf(os.Stdout, " completed in %s\n", elapsed.Round(time.Millisecond)) + dim.Fprintf(w, " completed in %s\n", elapsed.Round(time.Millisecond)) - fmt.Println() - dim.Fprintln(os.Stdout, " ─────────────────────────────────────────────") - fmt.Fprint(os.Stdout, " Built by ") - purple.Fprint(os.Stdout, "Revyl") - fmt.Fprintln(os.Stdout, " — the mobile reliability platform") - dim.Fprintln(os.Stdout, " Catch more than rejections. Catch bugs.") - fmt.Fprint(os.Stdout, " ") - color.New(color.Underline).Fprintln(os.Stdout, "https://revyl.com") - fmt.Println() + fmt.Fprintln(w) + dim.Fprintln(w, " ─────────────────────────────────────────────") + fmt.Fprint(w, " Built by ") + purple.Fprint(w, "Revyl") + fmt.Fprintln(w, " — the mobile reliability platform") + dim.Fprintln(w, " Catch more than rejections. Catch bugs.") + fmt.Fprint(w, " ") + color.New(color.Underline).Fprintln(w, "https://revyl.com") + fmt.Fprintln(w) return } + +func writeIPAJSON(w *os.File, result *ipa.InspectResult, elapsed time.Duration) error { + // Derive summary counts for JSON consumers (mirrors preflight/codescan summary). + var criticals, warns, infos int + for _, f := range result.Findings { + switch f.Severity { + case "CRITICAL": + criticals++ + case "WARN": + warns++ + case "INFO": + infos++ + } + } + payload := struct { + IPAPath string `json:"ipa_path"` + AppName string `json:"app_name"` + BundleID string `json:"bundle_id,omitempty"` + Size int64 `json:"size_bytes"` + Findings []ipa.Finding `json:"findings"` + Summary struct { + Total int `json:"total"` + Critical int `json:"critical"` + Warns int `json:"warns"` + Infos int `json:"infos"` + Passed bool `json:"passed"` + } `json:"summary"` + Elapsed string `json:"elapsed"` + }{ + IPAPath: result.IPAPath, + AppName: result.AppName, + BundleID: result.BundleID, + Size: result.Size, + Findings: result.Findings, + Elapsed: elapsed.Round(time.Millisecond).String(), + } + // Ensure empty findings encodes as [] not null. + if payload.Findings == nil { + payload.Findings = []ipa.Finding{} + } + payload.Summary.Total = len(result.Findings) + payload.Summary.Critical = criticals + payload.Summary.Warns = warns + payload.Summary.Infos = infos + payload.Summary.Passed = criticals == 0 + + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(payload) +}