Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 78 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# greenlight

**Know before you submit.** Pre-submission compliance scanner for the Apple App Store.
**Know before you submit.** Pre-submission compliance scanner for the Apple App Store and Google Play.

Greenlight scans your app — source code, privacy manifests, IPA binaries, and App Store Connect metadata — against Apple's Review Guidelines, catching rejection risks before Apple does. Fully offline, no account, runs in under a second.
Greenlight scans your app — source code, privacy manifests, Android manifests and Gradle builds, IPA binaries, and App Store Connect metadata — against Apple's Review Guidelines and Google Play's Developer Program Policies, catching rejection risks before the stores do. Fully offline, no account, runs in under a second.

> **Optional runtime tier:** want to confirm flow-dependent guidelines (account deletion, restore purchases, Sign in with Apple) actually *work*, not just exist in source? `greenlight verify` validates them on a cloud device via [Revyl](https://revyl.com). It's entirely separate and opt-in — the static scanner above never needs it. See [`greenlight verify`](#greenlight-verify-path--runtime-flow-validation-via-revyl).

Expand Down Expand Up @@ -53,6 +53,7 @@ greenlight preflight . --output report.json # write to file
| **metadata** | app.json / Info.plist: name, version, bundle ID format, icon, privacy policy URL, purpose strings |
| **codescan** | 30+ code patterns: private APIs, secrets, payment violations, missing ATT, social login, placeholders |
| **privacy** | PrivacyInfo.xcprivacy completeness, Required Reason APIs, tracking SDKs vs ATT implementation |
| **playscan** | Google Play: target API level deadline, restricted permissions, foreground service types, Play Billing version, manifest requirements (Android projects only) |
| **ipa** | Binary: Info.plist keys, launch storyboard, app icons, app size, framework privacy manifests |

### `greenlight codescan [path]` — Code pattern scan
Expand Down Expand Up @@ -80,6 +81,81 @@ Scans Swift, Objective-C, React Native, and Expo projects for:
- Missing encryption export-compliance declaration
- Expo config issues (§2.1)

### `greenlight playscan [path]` — Google Play policy scan

```bash
greenlight playscan /path/to/project
greenlight playscan --apk app-release.apk # built artifact: merged manifest
greenlight playscan --aab app-release.aab # app bundle
greenlight playscan . --format json
greenlight playscan . --exit-code # CI gating
```

Checks an Android app against Google Play's Developer Program Policies and its
published distribution deadlines. Android projects are also picked up
automatically by `greenlight preflight`, including the `android/` directory of
an Expo or React Native app, so a cross-platform repo is checked against both
stores in one pass.

**Deadlines**
- **Target API level** — new apps and updates must target API 36 from
August 31, 2026; apps below API 35 already lose distribution to new users on
newer devices — **CRITICAL / HIGH**
- **Play Billing Library** — v7 and below lose support August 31, 2026, and
there is no direct v7 → v9 upgrade path. Versions reached through a variable
or a version catalog `version.ref` are resolved — **HIGH**

**Restricted permissions** (each needs an approved use case or declaration form)
- SMS and Call Log — including the July 2026 change that drops phone-call
account verification as a permitted `READ_CALL_LOG` use
- `MANAGE_EXTERNAL_STORAGE` (All files access)
- `QUERY_ALL_PACKAGES`, `REQUEST_INSTALL_PACKAGES`
- `ACCESS_BACKGROUND_LOCATION` (declaration + demo video)
- Broad photo/video access over the system Photo Picker (API 33+)
- Accessibility Service, VPN service, device admin — declared as a component's
`android:permission` rather than `<uses-permission>`
- Overlays, usage stats
- Contacts, ahead of the 2026 Contact Permissions policy

**Manifest and build**
- Foreground services missing the base `FOREGROUND_SERVICE` permission, or a
declared type missing its `FOREGROUND_SERVICE_*` permission — both throw at
`startForeground()` — **CRITICAL**
- `specialUse` foreground services needing a Console justification
- `android:exported` missing on components with an intent filter (API 31+) —
the package fails to install — **CRITICAL**
- `android:debuggable="true"` — **CRITICAL**
- `android:usesCleartextTraffic="true"`
- Ads SDK shipped without `com.google.android.gms.permission.AD_ID`, which
silently returns a zeroed advertising ID
- Account creation without the required in-app **and** web deletion paths

Every finding cites the policy page it comes from.

**Scanning a built artifact** (`--apk` / `--aab`)

Passing a build reads the *merged* manifest, so it sees permissions contributed
by library manifests that a source scan structurally cannot. Every policy check
above runs against it, plus the native code checks:

- **16 KB page size** — Google Play requires apps targeting Android 15+ to
support 16 KB memory pages. Checks ELF `LOAD` segment alignment on
`arm64-v8a` libraries, 16 KB zip alignment of uncompressed libraries, and
`GNU_RELRO` presence — **CRITICAL / HIGH**

Both formats are read directly: an APK's compiled binary XML and an AAB's
protobuf manifest are decoded in pure Go, so no Android SDK, `aapt2`, or
`bundletool` is needed.

**Scope.** Scanning *source* reads the `AndroidManifest.xml` and Gradle files in
your repo, which is the *pre-merge* manifest. Permissions contributed by library
manifests only appear once the build merges them, so a clean source scan is not
proof of a clean merged manifest — scan the built artifact to close that gap.
`targetSdk` is resolved from the app module, convention plugins
(`build-logic/`, `buildSrc/`, `build-plugin/`), version catalogs,
`gradle.properties`, and named constants; when it cannot be resolved the scan
says so rather than reporting a pass.

### `greenlight privacy [path]` — Privacy manifest validator

```bash
Expand Down
279 changes: 279 additions & 0 deletions internal/cli/playscan.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
package cli

import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"time"

"github.com/RevylAI/greenlight/internal/playscan"
"github.com/fatih/color"
"github.com/spf13/cobra"
)

var (
playscanFormat string
playscanOutput string
playscanExitCode bool
playscanArchive string
)

var playscanCmd = &cobra.Command{
Use: "playscan [path]",
Short: "Scan an Android project against Google Play's policies",
Long: `Check an Android app against Google Play's Developer Program Policies
and its published distribution deadlines, before you upload.

Checks:
• Target API level — the annual requirement that silently pulls apps
from distribution when missed
• Play Billing Library — versions past their support window
• Restricted permissions — SMS, Call Log, All files access, QUERY_ALL_PACKAGES,
background location, broad photo/video access
• Foreground services — service types missing their required permission
• Manifest requirements — android:exported, debuggable, cleartext traffic
• Advertising ID — ads SDK shipped without the AD_ID permission
• Account deletion — the in-app and web deletion requirement

Pass --apk or --aab to scan a BUILT artifact instead of source. That reads the
merged manifest, so it sees permissions contributed by library manifests that a
source scan structurally cannot, and adds the native code checks:

• 16 KB page size — ELF LOAD alignment, zip alignment, GNU_RELRO

Runs entirely offline. No Play Console account needed.

Scope: scanning source reads the AndroidManifest.xml and Gradle files in your
repo, which is the pre-merge manifest. Permissions contributed by library
manifests only appear after the build merges them, so a clean source scan is
not proof of a clean merged manifest. Scan the built artifact to close that gap.

Usage:
greenlight playscan .
greenlight playscan --apk app-release.apk
greenlight playscan --aab app-release.aab
greenlight playscan ./android --format json
greenlight playscan . --exit-code`,
Args: cobra.MaximumNArgs(1),
RunE: runPlayscan,
}

func init() {
playscanCmd.Flags().StringVar(&playscanFormat, "format", "terminal", "output format: terminal, json")
playscanCmd.Flags().StringVar(&playscanOutput, "output", "", "write report to file (stdout if omitted)")
playscanCmd.Flags().BoolVar(&playscanExitCode, "exit-code", false, "exit non-zero on any CRITICAL or HIGH finding — for CI gating")
playscanCmd.Flags().StringVar(&playscanArchive, "apk", "", "scan a built .apk (merged manifest + native code checks) instead of source")
playscanCmd.Flags().StringVar(&playscanArchive, "aab", "", "scan a built .aab (merged manifest + native code checks) instead of source")
rootCmd.AddCommand(playscanCmd)
}

func runPlayscan(cmd *cobra.Command, args []string) error {
path := "."
if len(args) > 0 {
path = args[0]
}

// --apk / --aab share one variable: they select the same archive scan and
// the format is detected from the file, so passing the wrong one is not an
// error the user needs to care about.
archive := playscanArchive
if archive != "" {
if info, err := os.Stat(archive); err != nil {
return fmt.Errorf("cannot access archive: %w", err)
} else if info.IsDir() {
return fmt.Errorf("--apk/--aab expects a file, not a directory: %s", archive)
}
} else {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("cannot access path: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("path must be a directory: %s", path)
}
}

isJSON := strings.ToLower(playscanFormat) == "json"
if !isJSON {
purple.Println("\n greenlight playscan — know before you upload to Google Play.")
if archive != "" {
fmt.Printf(" Archive: %s\n\n", archive)
} else {
fmt.Printf(" Project: %s\n\n", path)
}
}

start := time.Now()
var result *playscan.ScanResult
var err error
if archive != "" {
result, err = playscan.ScanArchive(archive)
} else {
result, err = playscan.Scan(path)
}
if err != nil {
return fmt.Errorf("play scan failed: %w", err)
}
elapsed := time.Since(start)

out := os.Stdout
if playscanOutput != "" {
out, err = os.Create(playscanOutput)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer out.Close()
}

if isJSON {
enc := json.NewEncoder(out)
enc.SetIndent("", " ")
if err := enc.Encode(result); err != nil {
return err
}
} else {
printPlayscanReport(out, result, elapsed)
}

if playscanExitCode {
for _, f := range result.Findings {
if f.Severity == "CRITICAL" || f.Severity == "HIGH" {
return ErrThreshold
}
}
}
return nil
}

func printPlayscanReport(w *os.File, result *playscan.ScanResult, elapsed time.Duration) {
red := color.New(color.FgRed, color.Bold)
hiYellow := color.New(color.FgHiYellow, color.Bold)
yellow := color.New(color.FgYellow)
green := color.New(color.FgGreen, color.Bold)
greenC := color.New(color.FgGreen)
bold := color.New(color.Bold)

if result.ManifestPath == "" && result.TargetSDK == 0 {
dim.Fprintln(w, " No Android project found at this path.")
dim.Fprintln(w, " Looked for AndroidManifest.xml and Gradle build files.")
fmt.Fprintln(w)
return
}

// Project context
if result.ArchiveKind != "" {
fmt.Fprintf(w, " Format: %s (merged manifest)\n", result.ArchiveKind)
}
if result.PackageName != "" {
fmt.Fprintf(w, " Package: %s\n", result.PackageName)
}
if result.TargetSDK > 0 {
fmt.Fprintf(w, " targetSdk: %d\n", result.TargetSDK)
}
if result.ManifestPath != "" {
fmt.Fprintf(w, " Manifest: %s\n", result.ManifestPath)
}
if result.IsArchive {
fmt.Fprintf(w, " Native libs: %d\n", result.NativeLibCount)
}
fmt.Fprintln(w)

// Sort by severity, then policy, so the output is stable run to run.
sevRank := map[string]int{"CRITICAL": 4, "HIGH": 3, "WARN": 2, "INFO": 1}
findings := append([]playscan.Finding{}, result.Findings...)
sort.SliceStable(findings, func(i, j int) bool {
if ri, rj := sevRank[findings[i].Severity], sevRank[findings[j].Severity]; ri != rj {
return ri > rj
}
return findings[i].Policy < findings[j].Policy
})

var criticals, highs, warns, infos int
for _, f := range findings {
switch f.Severity {
case "CRITICAL":
criticals++
case "HIGH":
highs++
case "WARN":
warns++
case "INFO":
infos++
}
}

lastSeverity := ""
for _, f := range findings {
if f.Severity != lastSeverity {
switch f.Severity {
case "CRITICAL":
red.Fprintln(w, " CRITICAL — Will be rejected or already losing distribution")
case "HIGH":
hiYellow.Fprintln(w, " HIGH — Likely rejection")
case "WARN":
yellow.Fprintln(w, " WARNING — Worth fixing")
case "INFO":
dim.Fprintln(w, " INFO — Best practices")
}
fmt.Fprintln(w)
lastSeverity = f.Severity
}

switch f.Severity {
case "CRITICAL":
red.Fprintf(w, " [CRITICAL] ")
case "HIGH":
hiYellow.Fprintf(w, " [HIGH] ")
case "WARN":
yellow.Fprintf(w, " [WARN] ")
case "INFO":
dim.Fprintf(w, " [INFO] ")
}
if f.Policy != "" {
bold.Fprintf(w, "%s: ", f.Policy)
}
bold.Fprintln(w, f.Title)

if f.File != "" {
loc := f.File
if f.Line > 0 {
loc = fmt.Sprintf("%s:%d", f.File, f.Line)
}
dim.Fprintf(w, " %s\n", loc)
}
fmt.Fprintf(w, " %s\n", f.Detail)
if f.Fix != "" {
greenC.Fprintf(w, " Fix: ")
fmt.Fprintln(w, f.Fix)
}
if f.Doc != "" {
dim.Fprintf(w, " %s\n", f.Doc)
}
fmt.Fprintln(w)
}

dim.Fprintln(w, " ─────────────────────────────────────────────")
fmt.Fprintln(w)

switch {
case criticals > 0:
red.Fprint(w, " NOT READY")
fmt.Fprintf(w, " — %d critical, %d high, %d warnings\n", criticals, highs, warns)
case highs > 0:
hiYellow.Fprint(w, " NEEDS REVIEW")
fmt.Fprintf(w, " — %d high, %d warnings\n", highs, warns)
default:
green.Fprint(w, " LOOKS GOOD")
fmt.Fprintf(w, " — %d warnings, %d notes\n", warns, infos)
}

fmt.Fprintf(w, " Scanned in %s\n\n", elapsed.Round(time.Millisecond))

// Console-side obligations no static scan can see.
dim.Fprintln(w, " Not checkable from source — verify in Play Console:")
dim.Fprintln(w, " • Data safety form matches what the app actually collects")
dim.Fprintln(w, " • Advertising ID declaration, content rating, target audience")
dim.Fprintf(w, " • %s\n\n", playscan.DocAppContent)
}
Loading
Loading