diff --git a/play/play-policy-insights/SKILL.md b/play/play-policy-insights/SKILL.md new file mode 100644 index 0000000..a562751 --- /dev/null +++ b/play/play-policy-insights/SKILL.md @@ -0,0 +1,169 @@ +--- +name: play-policy-insights +description: Automated auditor designed to verify Android applications against Google Play Policy domains. It cross-references static code analysis with Play Store declarations to generate deterministic compliance reports, identifying undeclared data collection, architectural risks, and missing disclosures across Permissions and APIs Hygiene, User Account and Identity, and Data Safety and Privacy domains. +license: Complete terms in LICENSE.txt +metadata: + author: Google LLC + last-updated: '2026-07-10' + keywords: + - account deletion + - accessibility api + - all files access + - audio recording + - audit + - compliance + - contacts access + - data disclosure + - data safety + - data safety label + - data transmission + - demo credentials + - exact alarm + - foreground services + - location access + - login credentials + - manifest hygiene + - package visibility + - permissions hygiene + - photo and video access + - photopicker + - play policy + - pre-submission audit + - privacy policy + - prominent disclosure + - restricted permissions + - scoped storage + - sms and call log + - static analysis + - target sdk + - user consent +--- + +# Play Policy Insights: data safety, login credentials, and restricted permissions + +You must audit Android apps for three specific policy domains. You must check +data safety, demo login credentials, and restricted permissions. + +## Path Resolution + +* **repo_root**: Absolute path to the directory containing this `SKILL.md`. +* **app_dir**:: Absolute path to the directory containing app's code. +* **temp_dir**: Absolute path to the scratch directory at the workspace root. + It is located at `.scratch/play_policy_insights_`. **Containment + Mandate**: You must confine all file system writes, intermediate artifacts, + and logs strictly to this directory. This ensures the skill remains portable + and safe across diverse execution environments, including local harnesses + and CI/CD pipelines, by avoiding reliance on system-level temporary paths or + user home directories. + +## Critical mandates + +- **Execution Mode Awareness** Before starting Phase 2, evaluate if your + execution environment provides a tool to spawn or delegate tasks to + general-purpose sub-agents (e.g., tools often named `invoke_agent`, + `delegate_task`, or `spawn_worker`, using generic agent profiles like + 'generalist' or 'coding_agent'). + +- If **YES**, you MUST use **Mode A (Delegation)**. + +- If **NO**, use **Mode B (Sequential Self-Execution)**. You must read the + prompt files intended for the subagents, follow their instructions, and + write the expected output files to disk. + +- **Sub-agents orchestration:** + + - If you use "Mode A (Delegation)", wait for "SUCCESS" confirmation from + sub-agents to know when they are done. + + - **Idempotency & Timeout Safeguard**: If a sub-agent fails or times out, + you MUST verify the presence and integrity of its target output file + (e.g., `/worker_.json`) before retrying. If the + file exists and contains valid JSON, treat the execution as **SUCCESS** + and proceed. Otherwise, retry up to three times. + +- **Fail-fast mandate:** The automated audit in Phase 1 is the source of + truth. If `orchestrator.py` fails, you must stop immediately with an + explanation of failure. Do not use manual auditing as a fallback. + +## The two-phase protocol + +### Phase 1: Fact gathering and triage + +1. **Initialize and triage**: + - Run `python3 /scripts/orchestrator.py init `. + - This will create the scratch environment, perform static analysis, map + the codebase, identify audit goals, and produce prompts for subagents + for each audit goal and prompts for designated critic and aggregator + subagents. + - You must wait (up to 5 minutes) for the script to finish. +2. **Capture environment**: Note values of the `temp_dir`, and + `activated_goals` from the JSON output. You will need them in Phase 2. +3. **Evaluate goals**: If `activated_goals` is empty, skip to step 3 of Phase 2 + (Aggregation). Otherwise, proceed to step 1 of Phase 2 (Detailed analysis). + +### Phase 2: Goal-oriented audit + +Determine your execution capabilities and proceed with either Mode A OR Mode B. + +#### Mode A: Orchestrator WITH Delegation Capabilities (Parallel) + +1. **Detailed analysis**: For each goal in `activated_goals` (e.g., + `permissions_and_apis`, `data_safety_part_1`, `data_safety_part_2`), + delegate to a sub-agent. **Concurrency Limit:** You must not spawn more than + 3 sub-agents simultaneously. Spawn the first batch of up to 3, wait for + their completions, and then spawn the next batch. Repeat until all goals are + complete. Pass the prompt: `"Read your instructions from + /prompt_worker_.md and execute. MANDATORY: You must + use your file-writing capabilities to save your final JSON findings directly + to the file system at /worker_.json. You are strictly + forbidden from outputting the JSON in your chat response. To minimize + context usage, your final response must be exactly 'SUCCESS' and nothing + else."` **Validate**: Confirm every + `/worker_.json` exists and contains valid JSON. If a + sub-agent fails or times out, but the valid JSON output file is already + present on disk, do NOT retry; proceed normally. Only retry the + corresponding worker (up to three times) if the file is missing or invalid. +2. **Aggregate Findings**: Execute the python aggregation command: + `python3 /scripts/orchestrator.py aggregate `. This + produces `aggregated_findings.json` and returns a JSON object containing + `critic_chunks` representing the number of chunks to verify (e.g., + `{"temp_dir": "...", "critic_chunks": 2}`). +3. **Parallel Critic review**: For each chunk index `i` from 1 to + `critic_chunks`, delegate to a sub-agent. **Concurrency Limit:** You must not + spawn more than 3 critic sub-agents simultaneously. Batch them in groups of 3 + as above. Pass the prompt: + `"Read your instructions from /prompt_critic_.md and execute. MANDATORY: You must use your file-writing capabilities to save your final JSON findings directly to the file system at /critic_output_.json. You are strictly forbidden from outputting the JSON in your chat response. To minimize context usage, your final response must be exactly 'SUCCESS' and nothing else."` + **Validate**: Confirm each `/critic_output_.json` exists and + contains valid JSON before proceeding. If it failed or timed out, but the + valid JSON file is present, proceed normally. Otherwise, retry that specific + critic chunk. +4. **Proceed to Finalization** (Step 4 below) + +#### Mode B: Orchestrator WITHOUT Delegation Capabilities (Sequential) + +1. **Detailed Analysis**: For each goal in `activated_goals`, sequentially: + - Read the contents of `/prompt_worker_.md`. + - Execute the instructions contained within that file yourself. + - **CRITICAL**: You MUST format your findings exactly as requested in the + prompt and save them to `/worker_.json`. **Do not** + summarize findings in your thoughts or chat; move to the next task. + - **Validate**: Confirm `/worker_.json` exists before + moving to the next goal. +2. **Aggregate Findings**: Execute the python aggregation command: + `python3 /scripts/orchestrator.py --aggregate `. + This produces `aggregated_findings.json` and returns a JSON object containing + `critic_chunks` representing the number of chunks to verify. +3. **Sequential Critic review**: For each chunk index `i` from 1 to + `critic_chunks`, sequentially: + - Read the contents of `/prompt_critic_.md`. + - Execute the steps yourself and save your findings to + `/critic_output_.json`. + - **Validate**: Confirm `/critic_output_.json` exists before + moving to the next chunk. +4. **Proceed to Finalization** (Step 4 below) + +#### Finalization (Both Modes) + +4. **Present findings**: Run `python3 /scripts/generate_report.py `. + It will produce `/compliance_report.md`. Present this output file to user. +5. **STOP**: The audit is complete. Await further instructions. diff --git a/play/play-policy-insights/resources/common_mandates.md b/play/play-policy-insights/resources/common_mandates.md new file mode 100644 index 0000000..87857e1 --- /dev/null +++ b/play/play-policy-insights/resources/common_mandates.md @@ -0,0 +1,100 @@ +# Execution Mandates + +### Technical Rules + +1. **Absolute Paths Only**: Always resolve and use absolute paths. +2. **Containment**: Write all artifacts strictly within `{{TEMP_DIR}}`. +3. **Fail-fast**: If any required input file is missing, stop immediately and + report the failure. + +### Surgical Input Protocol & Efficient Search (MANDATORY) + +- **Direct Evidence First**: Prioritize files listed in the **Context & + Evidence** sections. Use the provided file/line evidence (e.g., from Data + Sources or Sinks) to jump directly to the relevant code. Do not perform + broad workspace searches if these surgical starting points are available. +- **Path Filtering Over File Crawling**: Locate target files by name, path, or + extension *first* using directory/file listing tools before performing any + text/content-based searches. Restrict searches and file reads strictly to + the target `{{APP_DIR}}`. +- **Strict Exclusions (The Noise Wall)**: Configure search, glob, and find + tools to ignore build, cache, dependencies, and testing folders. You MUST + exclude matches from: `**/build/**`, `**/.gradle/**`, `**/.scratch/**`, + `**/androidTest/**`, `**/test/**`, `**/node_modules/**`. +- **Targeted Extensions**: Restrict content searches and file reads strictly + to source and configuration files: `.java`, `.kt`, `.xml`, `.gradle`, `.kts` + (and `.js`, `.ts`, `.jsx`, `.tsx`, `.dart` if a hybrid/cross-platform + environment is analyzed). Never search or read inside compiled `.class` + files, binary resources, or output assets. +- **Surgical Queries & Limiters**: Use highly specific search patterns (e.g., + search for `getLastKnownLocation` or `deleteAccount` instead of general + words like `location` or `delete`). If search tools support limits or + pagination, cap results at a maximum of 50 matches. Do not load unlimited + search results into your context window. +- **Parallel Reading Required (Turn Efficiency)**: You are operating under a + strict maximum turn limit. To prevent timeouts, you MUST request to read + multiple target files concurrently in a single response. Do not read the + evidence files sequentially one-by-one. Issue all of your file-reading tool + calls simultaneously whenever possible. + +### Evidentiary Standard & Guardrails (CRITICAL) + +To prevent over-auditing, false positives, and speculative "prosecution" of +compliant code during extrapolation: + +1. **Presumption of Compliance**: Treat code as compliant unless there is + *definitive, visible evidence* in the provided files of a policy violation. + If code is ambiguous, or if network/database logic is hidden behind + abstractions (e.g., calling an interface or repository method like + `clearSession()`), you must assume standard compliant behavior. Do NOT guess + or speculate about what happens behind interfaces. +2. **Benefit of the Doubt**: When compliance cannot be strictly verified due to + code abstractions or missing source file contexts, you must downgrade your + finding: + - Never flag a `๐Ÿ”ด Critical` or `๐ŸŸก Important` finding based on suspicion or + lack of context. + - Instead, output a `๐Ÿ”ต Suggestion` (informational) to advise the developer + on what to double-check in their backend or configuration. +3. **Exclusion of Local State**: Local-only processing (e.g., caching theme + settings, user-selected visual configurations, or on-device-only database + operations) is explicitly exempt from Data Safety collection or Account + Deletion mandates. +4. **Concrete Attributions**: Every `๐Ÿ”ด Critical` or `๐ŸŸก Important` finding must + cite the exact file, line number, or configuration block containing the + direct violation. If you cannot cite the exact line of code containing the + violation, you cannot flag it as a violation. +5. **Empty-List Discipline**: If no policy violations, discrepancies, or review + items are identified during your audit, you MUST represent this as an empty + array `[]` for that field (e.g., `"findings": []`, `"verified_findings": + []`, or `"manual_verification_required": []`). **DO NOT** populate arrays + with "dummy" objects, placeholder strings, or `"N/A"` / `"None"` values. +6. **Heuristics & Extrapolation Boundaries**: Whenever applying specific + heuristics defined in your goal (e.g., searching for implicit logger leaks + or + SDK siphoning), you must strictly bound them to the provided evidence and + their immediate callers. You are strictly forbidden from initiating broad, + unbounded searches for custom paths or variables across the wider codebase. + Base your extrapolation only within the specific files already provided to + you in the prompt. + +### Finalization & Output Mandates (CRITICAL) + +- **Iterative Saving**: If your investigation requires multiple steps, save + partial or intermediate JSON states to disk as you progress. Do not hold all + data in memory until the very end to prevent data loss upon interruption. +- **Strict File Output (NO TRIPLE BACKTICKS)**: You MUST save your final JSON + output to disk at the exact path specified in the goal schema using your + file-writing capabilities. + - **CRITICAL: The content written to the file MUST be pure, raw JSON. DO + NOT wrap the contents inside the JSON file with Markdown code blocks + (such as triple backticks `json ...`). Writing markdown blocks into the + file makes the JSON unparseable by the compiler.** +- **NO Chat Summaries**: **MANDATORY: DO NOT summarize your findings, explain + your reasoning, or output JSON in your final chat response.** Your chat + output wastes context and is ignored by the orchestrator. +- **Verification Before Termination**: You MUST only terminate and return the + "SUCCESS" string *after* you have explicitly verified that your JSON file + successfully wrote to disk and contains valid JSON (e.g., by reading the + file back or checking the directory contents). +- **Final response**: Your final response MUST be exactly the word: "SUCCESS" + and nothing else. diff --git a/play/play-policy-insights/resources/compliance_report_template.md b/play/play-policy-insights/resources/compliance_report_template.md new file mode 100644 index 0000000..e9854c9 --- /dev/null +++ b/play/play-policy-insights/resources/compliance_report_template.md @@ -0,0 +1,58 @@ +# Play Policy Compliance Report: {{app_name_id}} + +**Status:** {{overall_compliance}} **Scan Date:** {{current_date}} + +> **Disclaimer:** This report is generated by an AI automated scanner and is for + **informational and advisory purposes only**. It does NOT constitute legal + advice, a guarantee of Play Store approval, or a definitive compliance check. + The Google Play Review team has final authority. Undiscovered issues may still + exist. + +--- + +## Policy Risks & Recommendations + +*Review these identified risks and follow the suggested recommendation steps.* + +{{findings_detail}} + +--- + +## Data Safety Discrepancies + +Review these discrepancies between detected behavior and Play Store +declarations. Discrepancies may stem from data collection/sharing practices or +new code features not yet reflected in your Data Safety label. + +{{data_safety_table}} + +{{suggested_declaration_section}} + +{{local_access_section}} + +--- + +## Remediation Checklist + +Follow these personalized steps to resolve the identified risks. + +{{personalized_checklist}} + +--- + +### Play Store Compliance Status Legend + +| Overall Status | Meaning & Rejection Risk | Required Action | +| :--- | :--- | :--- | +| **๐ŸŸข Compliant** | The application code and metadata appear fully compliant with analyzed Play Store policies. No active policy violations or high-risk discrepancies were identified. | **Safe to Submit**: Safe for Play Store submission. | +| **๐ŸŸก Needs Review** | No critical violations were found in the code, but the app implements features requiring manual Play Console configurations (e.g., submitting **Reviewer Demo Credentials** for login screens) or requests restricted, high-risk permissions (e.g., Exact Alarms, All Files Access, or Background Location) requiring Play Console declaration forms. | **Must Review**: Review the findings. You must either **migrate to modern scoped alternatives (highly recommended)** to bypass declaration scrutiny, OR **manually verify that your Play Console settings (like Reviewer Credentials and Declaration Forms) are completely and carefully configured** before submitting. | +| **๐Ÿ”ด Non-Compliant** | Severe, direct policy violations (e.g., silent background data tracking, default-only permission abuse) or active Data Safety declaration mismatches were identified. | **Must Fix**: Immediate rejection or account-level policy suspension risk. You must remediate these issues before submitting to the Play Store. | + +
+ +| Finding Severity | Description | Action Required | +| :--- | :--- | :--- | +| **๐Ÿ”ด Critical** | Direct, verified policy violation (e.g., unauthorized data collection/sharing). | **Must Fix**: Immediate rejection risk. | +| **๐ŸŸก Important** | Deprecated permission use or API discrepancy where scoped pathways are mandated. | **Should Fix**: Highly recommended to migrate to prevent manual reviewer flags. | +| **๐Ÿ”ต Suggestion** | Standard advisory reminder or administrative checklist item (fully compliant). | **Good to Know**: Clean-path confirmation or helper checklist. | + diff --git a/play/play-policy-insights/resources/critic.md b/play/play-policy-insights/resources/critic.md new file mode 100644 index 0000000..f39d7b5 --- /dev/null +++ b/play/play-policy-insights/resources/critic.md @@ -0,0 +1,86 @@ +# Review of specialized policy findings (Chunked Critic) + +Review a specific chunk of identified policy findings to identify potential +false positives, exaggerated claims, or inaccuracies due to lack of evidence. + +### Provided context files + +The prompt provides absolute paths to files and directories: +- `{{TEMP_DIR}}`: The temporary scratch directory for this audit. +- `input_file`: `{{TEMP_DIR}}/input_critic_{{CHUNK_INDEX}}.json` contains the + chunk of findings to verify. + +### Instructions + +1. **Read input chunk**: Read the JSON object in + `{{TEMP_DIR}}/input_critic_{{CHUNK_INDEX}}.json`. The keys (e.g. `"1"`, + `"2"`, `"3"`) correspond to the finding IDs. +2. **Verify each finding**: + - For each finding, analyze its evidence against the source files in + `{{APP_DIR}}`. + - Determine the verdict: + - `"VERIFIED"`: True Positive. The codebase confirms the policy + violation. + - `"MANUAL_REVIEW"`: Ambiguous code or abstract logic where automatic + verification is impossible. + - `"PRUNED"`: False Positive. The codebase is compliant, or the + finding is not supported by actual evidence. + - Provide your verification details in the output JSON. +3. **Optional Editorial Overrides**: If (and only if) you need to edit, refine, + or moderate the worker finding's text, you may include one or more of these + optional keys to your decision object. **If you agree with the worker's text, + you MUST omit these keys entirely from your JSON.** + - `"issue_summary"`: Write a more accurate, tailored summary. + - `"severity"`: Set to `"CRITICAL"`, `"IMPORTANT"`, or `"SUGGESTION"` to + override. + - `"recommendation"`: Write a tailored, codebase-specific remediation + step. +4. **Save Results**: Save your final "Thin JSON" mapping to + `{{TEMP_DIR}}/critic_output_{{CHUNK_INDEX}}.json`. + +### Audit principles for false positive detection + +- **Environment detection**: **Do not** flag emulator or root detection as + violations unless evidence shows malicious intent or review evasion. +- **Speculative collection**: Prune claims based solely on permission + presence. Require evidence of actual data access and transfer (whether + off-device network egress or on-device sharing to a third-party app). +- **Standard patterns**: **Do not** flag standard Android architectural + patterns unless used maliciously. +- **Surgical Evidence Standard**: Prune or downgrade any finding where the + worker has speculated on a transmission pathway that cannot be directly + verified in the immediate source files, or where the finding fails to cite a + concrete file and line number containing the direct policy violation. +- **Data Safety Flag Verification**: If a finding includes Data Safety flags + (e.g., `user_initiated`, `is_third_party`), strictly verify them. If the + worker claims `user_initiated: true`, ensure there is undeniable evidence of + explicit user interaction triggering the transfer. If the worker claims + `is_third_party: true`, verify the sink is definitively outside the + developer's control (e.g., Android Share Sheet, Social Media SDK). If evidence + is lacking, downgrade the finding or use `"issue_summary"` to correct the + claim. +- **Gatekeeper Validation**: If a worker claims a finding is compliant because + a disclosure exists (`prominent_disclosure_status: "DISCLOSED"`), you MUST + verify that the UI acts as a strict gatekeeper. If data collection begins + before the user taps "Accept", or if they can dismiss it and continue, + override the `"severity"` to `"CRITICAL"` and explicitly state the gatekeeper + is invalid. + +### Output JSON format + +**Important**: The output must be a pure JSON object mapping the sequential +finding IDs from your input file to their decisions. If there are no findings in +the chunk, return `{}`. + +```json +{ + "1": { + "action": "VERIFIED | MANUAL_REVIEW | PRUNED", + "confidence": "High | Medium | Low", + "critic_justification": "A concise, technical explanation of your decision based on codebase evidence.", + "issue_summary": "OPTIONAL: Overridden issue summary text", + "severity": "OPTIONAL: CRITICAL | IMPORTANT | SUGGESTION", + "recommendation": "OPTIONAL: Overridden recommendation text" + } +} +``` diff --git a/play/play-policy-insights/resources/goal_data_safety.md b/play/play-policy-insights/resources/goal_data_safety.md new file mode 100644 index 0000000..59a9337 --- /dev/null +++ b/play/play-policy-insights/resources/goal_data_safety.md @@ -0,0 +1,212 @@ +{{#IF_ANY data_sources, disclosure}} +{{ACTIVATE_GOAL}} +{{/IF_ANY}} +## Data Safety and Privacy Audit + +### Policies to Verify + +- **The Policy Spirit**: Users deserve complete transparency regarding what + personal or sensitive information leaves their devices. Under Play Store + rules, any off-device transmission of user data must be explicitly declared in + the Play Store Data Safety form, and sensitive or non-obvious data collection + requires prior prominent in-app disclosure and affirmative user consent. + +- **Common Evaluation Matrix**: + | Case | Technical Observation | `user_initiated` | `is_third_party` | Disclosure Status | Severity | Issue Summary | + | :--- | :--- | :--- | :--- | :--- | :--- | :--- | + | **1** | **Not Transferred** (Local only) | N/A | N/A | `EXEMPT` | `SUGGESTION` | "Data Safety Compliant (Local-Only)" | + | **2A** | **Transferred** + **Background** + **No Disclosure** | `false` | (Any) | `MISSING` | `CRITICAL` | "**Silent Background Transfer**": Policy violation. Data is sent without prior disclosure. | + | **2B** | **Transferred** + **Background** + **Disclosure Found** | `false` | (Any) | `DISCLOSED` | `IMPORTANT` | "**Background Transfer (Disclosure Claimed)**": Evidence found; requires Critic verification. | + | **3** | **Transferred** + **User-Initiated** + **First Party** | `true` | `false` | `EXEMPT` | `IMPORTANT` | "**User-Initiated Collection**": Disclosure exempt, but **must be declared** in Play Store form. | + | **4** | **Transferred** + **User-Initiated** + **Third Party** | `true` | `true` | `EXEMPT` | `SUGGESTION` | "**Manual Sharing**": **Policy Exempt**. User triggers transfer to 3P; no disclosure or declaration needed. | + +- **Exemptions Reference**: + - **Anonymous Data**: Fully anonymized data not linked to a user is exempt. + - **End-to-End Encryption (E2EE)**: Data E2EE where the developer cannot read + it is exempt. + - **Open Web WebView**: Data entered into a WebView navigating the open web is + exempt. + - *If any of these apply, downgrade severity to `SUGGESTION` and mark + `EXEMPT`.* + +--- + +### The Audit Protocol + +#### Step 0: Facts orientation + +- **Identify Third-Party SDKs**: If you observe network, crash-reporting, or + tracking telemetry, check the project's build files (e.g., build.gradle, + build.gradle.kts, libs.versions.toml) on-demand using your file-reading tools + to verify which SDKs (Analytics, Ads, etc.) are integrated. +- **Understand Off-Device Sinks**: Use your native knowledge of standard Android + framework components to identify transmission destinations. Focus on active + egress vectors such as standard HTTP/REST libraries (e.g., OkHttp, Retrofit, + Ktor, HttpURLConnection), WebViews passing data off-device, background syncing + tasks, or analytic/telemetry frameworks (e.g., Firebase, Mixpanel, Adjust, + AppsFlyer). +- **Identify Disclosures**: Review `disclosure` below. Use these as starting + points for Step 2. + +#### Step 1: Verify behavioral transmission (Forward Trace) + +For each source in `data_sources` listed below, trace the technical signal from +the exact location provided to its transmission endpoint. +- **Mandatory Starting Point**: Navigate to the file and line number specified + in the `data_sources` list. +- **Loop Discipline**: You must analyze the transmission path for *every* piece + of evidence listed to verify if it reaches an off-device sink. +- **Evidence**: Provide the file/line where the data is passed to the sink and a + brief justification of the sink's known transmission behavior (e.g., network + upload, telemetry payload). +- **Semantic Grounding**: Use the provided `*Description*` to verify that the + code evidence actually matches the semantic scope of the data type. If the + variable or logic refers to unrelated data (e.g., a `fileName` that does not + identify a user `NAME`), treat it as a false positive and follow the + **Evidentiary Standard** to downgrade or prune the finding. +- **Sink Categorization**: Determine the destination: + 1. **First Party / Service Provider (`is_third_party: false`)**: + Developer-controlled servers, Firebase, custom APIs, etc. + 2. **Third Party (`is_third_party: true`)**: Social SDKs, ad networks, or + user-selected apps via Android Share Sheet (`Intent.ACTION_SEND`). +- **Ambiguity Mandate**: If the destination is ambiguous (e.g., generic URL or + obfuscated SDK), you MUST default to `is_third_party: false` (erring on the + side of Collection). +- **On-Device Transfer**: Treat silent data sharing to another app via + Intents/ContentProviders as a transfer (`is_transferred: true`), even if it + doesn't use the network. + +#### Step 2: Semantic Search for Protection (UI Disclosure) + +**Conditional Execution**: Execute this step **ONLY IF** `is_transferred` is +`true` AND `user_initiated` is `false`. +- **Rationale**: User-initiated actions (Case 3 and 4) are exempt from Prominent + Disclosure. Do not search for disclosures if `user_initiated` is true. +- **Semantic Search**: Review the `disclosure` list below. +- **Gatekeeper Check**: Check if the Activity/Fragment displaying that consent + acts as a gatekeeper (i.e., gates the initialization of the data-collection + logic). Do not attempt to trace backwards from a low-level repository up to + the UI. +- **Content Check**: The UI must state *what* is collected and *how* it is used. +- **Evidence**: Provide the file/layout name and the specific text found. + +#### Step 3: Synthesis + +- **`findings` (CRITICAL)**: You must construct and output exactly **one** + object inside the `"findings"` array for **every** data type found in the + `data_sources` list below, regardless of whether it is compliant or a + violation. +- **Evaluating Keys**: For each data type object in `"findings"`, evaluate and + populate: + 1. **psl_constant**: Set this exactly to the uppercase **PSL Constant** of + the data type (listed as the **Data Type** heading in the **Data Sources to + Trace** section below, e.g., `"PRECISE_LOCATION"`, `"CONTACTS"`). This is + critical for downstream matching. + 2. **policy_id**: Set this exactly to either `"prominent_disclosure_policy"` + or `"data_safety_section"` depending on the check being performed. + 3. **is_transferred**: `true` (JSON Boolean) if the data is transferred + off-device or on-device to a third party. `false` (JSON Boolean) if local + only. + 4. **user_initiated**: `true` (JSON Boolean) if the user explicitly triggers + the transfer. + 5. **is_third_party**: `true` (JSON Boolean) if the destination is a Third + Party (e.g., Share Sheet, Social SDK). `false` (JSON Boolean) if it's a + First Party/Service Provider (e.g., your own backend). + 6. **prominent_disclosure_status**: Set strictly to one of these three + uppercase enums: `"DISCLOSED"` (visible disclosure and user consent found), + `"MISSING"` (no disclosure found, or is not an affirmative gatekeeper), or + `"EXEMPT"` (exempt from prominent disclosure under policies). + 7. **purpose**: Categorize why the data is being collected (e.g., "App + functionality", "Analytics", "Local functionality only"). + 8. **linked_to_user**: `true` (JSON Boolean) if the data is tied to an + identity, email, or device ID, otherwise `false` (JSON Boolean). + +- **Map findings strictly to the 5 Cases:** Use the Case matrix in "Policies to + Verify" to set the appropriate `severity` and `issue_summary` based on the + combination of `is_transferred`, `user_initiated`, `is_third_party`, and + `prominent_disclosure_status`. + +--- + +### Domain-Specific Heuristics + +Apply the following heuristics while strictly adhering to the Heuristics & +Extrapolation Boundaries defined in your Execution Mandates: +1. **Implicit Data Leaks (The Logger Loop)**: Inspect if any custom logging + frameworks (e.g., Crashlytics, custom error loggers, or telemetry SDKs) + receive sensitive variables as part of diagnostic payloads. If a user + identifier (e.g., email, account ID) or precise location is passed to a + logger that uploads payloads off-device, this counts as **transferred** + (`is_transferred`: true). +2. **Third-Party SDK Siphoning**: Look at the project's build files on-demand + (e.g., build.gradle or libs.versions.toml). If SDKs like Google Ads, or + Firebase are initialized and have access to the context, evaluate if + they are siphoning advertiser IDs or device identifiers automatically. If + those libraries are loaded and the manifest requests broad network + permissions, treat those device identifiers as **transferred** + (`is_transferred`: true) for analytics/marketing purposes. +3. **Indirect Consent**: If you find an `AlertDialog` or disclosure, verify if + it is an actual gatekeeper. If the app begins tracking user data *prior* to + the user tapping "Accept", or if the user can close the dialog and continue + using the app while data tracking remains active, flag this as a `CRITICAL` + violation under `prominent_disclosure_policy`. + +--- + +### Codebase Context & Evidence + +{{#IF semantic_files.LEGAL}} +**Semantic Triage Starting Points (Files of Interest)**: +The following files likely contain privacy or consent logic: +- **LEGAL** related: + {{#EACH semantic_files.LEGAL}} + - `{{ITEM}}` + {{/EACH}} +{{/IF}} + +{{#IF data_sources}} +**Data Sources to Trace**: +{{#EACH data_sources}} +- **Data Type**: {{KEY}} + *Description: {{VALUE.description}}* + {{#EACH VALUE.findings}} + - `{{ITEM}}` + {{/EACH}} +{{/EACH}} +{{/IF}} + +{{#IF disclosure}} +**UI Disclosure Starting Points**: +{{#EACH disclosure}} +- `{{ITEM}}` +{{/EACH}} +{{/IF}} + +## Output schema + +Save final JSON output to `{{TEMP_DIR}}/worker_{{GOAL_NAME}}.json`. + +```json +{ + "domain": "Data Safety and Privacy", + "findings": [ + { + "psl_constant": "STRING_VALUE (The exact PSL Constant, e.g., USER_ACCOUNT)", + "policy_id": "prominent_disclosure_policy | data_safety_section", + "issue_summary": "STRING_VALUE", + "severity": "CRITICAL | IMPORTANT | SUGGESTION", + "files_involved": ["STRING_VALUE"], + "evidence": "STRING_VALUE", + "recommendation": "STRING_VALUE", + + # UNIFIED DATA SAFETY METADATA LOOKUP KEYS: + "is_transferred": true | false, + "user_initiated": true | false, + "is_third_party": true | false, + "prominent_disclosure_status": "DISCLOSED | MISSING | EXEMPT", + "purpose": "STRING_VALUE", + "linked_to_user": true | false + } + ] +} +``` diff --git a/play/play-policy-insights/resources/goal_permissions_and_apis.md b/play/play-policy-insights/resources/goal_permissions_and_apis.md new file mode 100644 index 0000000..f933005 --- /dev/null +++ b/play/play-policy-insights/resources/goal_permissions_and_apis.md @@ -0,0 +1,459 @@ +{{#IF_ALL requested_permissions.exact_alarm, exact_alarm}}{{ACTIVATE_GOAL}}{{/IF_ALL}} +{{#IF_ALL requested_permissions.foreground_service, foreground_service}}{{ACTIVATE_GOAL}}{{/IF_ALL}} +{{#IF_ALL requested_permissions.accessibility, accessibility}}{{ACTIVATE_GOAL}}{{/IF_ALL}} +{{#IF_ALL requested_permissions.APPS_ON_DEVICE, data_sources.APPS_ON_DEVICE}}{{ACTIVATE_GOAL}}{{/IF_ALL}} +{{#IF_ALL requested_permissions.SMS_CALL_LOG, data_sources.SMS_CALL_LOG}}{{ACTIVATE_GOAL}}{{/IF_ALL}} +{{#IF_ALL requested_permissions.MEDIA, data_sources.MEDIA}}{{ACTIVATE_GOAL}}{{/IF_ALL}} +{{#IF_ALL requested_permissions.AUDIO, data_sources.AUDIO}}{{ACTIVATE_GOAL}}{{/IF_ALL}} +{{#IF_ALL requested_permissions.ALL_FILES, data_sources.FILES_AND_DOCS}}{{ACTIVATE_GOAL}}{{/IF_ALL}} +{{#IF_ALL requested_permissions.LOCATION, data_sources.PRECISE_LOCATION}}{{ACTIVATE_GOAL}}{{/IF_ALL}} +{{#IF_ALL requested_permissions.LOCATION, data_sources.APPROX_LOCATION}}{{ACTIVATE_GOAL}}{{/IF_ALL}} +{{#IF_ALL requested_permissions.CONTACTS, data_sources.CONTACTS}}{{ACTIVATE_GOAL}}{{/IF_ALL}} + +## Permissions and APIs Audit + +### Hint: Deducing Core Functionality + +Since you must determine if certain permissions are justified by the app's "core +purpose", use these fast heuristics: +1. **The "Broken" Test**: Is the feature essential to the app's primary purpose? + If the app would still be functional and useful without the feature, it is + NOT core functionality. +2. **Manifest Intent**: Review `AndroidManifest.xml`. The name of the `LAUNCHER` + Activity and specialized `` declarations (like default SMS + handlers) strongly indicate the app's main purpose. +3. **Naming**: The package name (`{{PACKAGE_NAME}}`) and app label + (`{{APP_NAME}}`) often describe the app's purpose explicitly. +4. **Execution Context**: Usage in classes like `BackupManager` suggest core + functionality, whereas usage in `AdHelper`, `CrashReporter`, or + `AnalyticsManager` indicates secondary features. +5. **Mandatory Rule**: Secondary features like **advertising, analytics, or + social sharing never justify** restricted permissions like Background + Location, All Files Access, or Broad Media Access. + +--- + +### Policies to Verify + +{{#IF_ALL requested_permissions.MEDIA, data_sources.MEDIA}} +#### Photo and Video Access Policy (Policy ID: photo_video_access_policy) + +- **Goal**: Evaluate if the app's core functionality justifies broad access to + photos or if it should migrate to the Android Photo Picker. +- **The Policy Spirit**: User privacy is paramount. Apps should only request + broad media storage permissions if they are dedicated media managers (like + Gallery or Backup apps). For standard tasks like profile picture uploads, + custom sharing, or attaching media, developers must use scoped APIs to prevent + security risks. +- **Evidence**: + {{#EACH data_sources.MEDIA}} + - `{{ITEM}}` + {{/EACH}} + **Relevant Permissions Requested**: + {{#EACH requested_permissions.MEDIA}} + - `{{ITEM}}` + {{/EACH}} + {{#IF TARGET_SDK}} + **Target SDK**: `{{TARGET_SDK}}` + {{/IF}} +- **Common Evaluation Matrix**: + | Target SDK | Broad Media Permission Requested? | Condition / Context Checked | Severity | Direct Actionable Recommendation | + | :--- | :--- | :--- | :--- | :--- | + | **33 or higher** | Yes | App requests broad media access (e.g. `READ_MEDIA_IMAGES`), but features only require user-selected media. | `IMPORTANT` | Migrate to the **Android Photo Picker** (`MediaStore.ACTION_PICK_IMAGES`) which does not require any permission prompt. | + | **Any** | Yes | App requests broad storage/media permissions but is not a dedicated media manager (e.g., a social or utility app). | `IMPORTANT` | Migrate to the **Android Photo Picker** for single-item or multi-item media selection. | + +- **Domain-Specific Heuristics (Strictly Bounded)**: + Look beyond standard native file pickers. Extrapolate using this heuristic: + 1. **User-Selected Media Heuristic**: Analyze where the code handles selected + files. If images or videos are loaded strictly via a user-facing button + click (e.g., "Upload Avatar", "Share Photo", "Attach File") and processed + one-at-a-time, broad filesystem media permissions are structurally + unnecessary. Flag an `IMPORTANT` violation and recommend Photo Picker + migration. +{{/IF_ALL}} + +{{#IF_ALL requested_permissions.ALL_FILES, data_sources.FILES_AND_DOCS}} +#### All Files Access Policy (Policy ID: all_files_access_policy) + +- **Goal**: Evaluate if the app's core purpose justifies the high-risk + `MANAGE_EXTERNAL_STORAGE` permission. +- **The Policy Spirit**: Full filesystem access is a restricted privilege. The + Play Store strictly limits `MANAGE_EXTERNAL_STORAGE` to apps where full disk + reads/writes are critical to the core purpose (e.g., file managers, antivirus + scanner, backup tools). Non-compliant apps must utilize Scoped Storage or + Storage Access Framework (SAF). +- **Evidence**: + {{#EACH data_sources.FILES_AND_DOCS}} + - `{{ITEM}}` + {{/EACH}} + **Relevant Permissions Requested**: + {{#EACH requested_permissions.ALL_FILES}} + - `{{ITEM}}` + {{/EACH}} +- **Common Evaluation Matrix**: + | Core App Purpose | Is Permission Justified? | Severity | Direct Actionable Recommendation | + | :--- | :--- | :--- | :--- | + | **File Manager, Antivirus, Backup/Restore, or Document Manager** | Yes (Compliant) | None | No action needed; core purpose justifies the restricted permission. | + | **Standard Utility, Social Media, Game, or Productivity App** | No (Violation) | `CRITICAL` | Remove `MANAGE_EXTERNAL_STORAGE` from the Manifest. For document picking or local file saving, migrate to the **Storage Access Framework (SAF)**. | + +- **Domain-Specific Heuristics (Strictly Bounded)**: + Surgically audit how the filesystem is queried: + 1. **Scoped Storage Sufficiency**: Check the files using the storage APIs. If + the app is using filesystem pathways solely to log diagnostics, store + custom caches, or save simple download artifacts, broad access is not + justified. Recommend using app-specific directories + (`Context.getExternalFilesDir()`) which require zero permissions. +{{/IF_ALL}} + +{{#IF requested_permissions.LOCATION}} +{{#IF_ANY data_sources.PRECISE_LOCATION, data_sources.APPROX_LOCATION}} +#### Location Access Policy (Policy ID: location_access_policy) + +- **Goal**: Verify that location access is essential, uses minimum scope, and is + properly disclosed. +- **The Policy Spirit**: User tracking is extremely sensitive. Apps must collect + the minimum scope of location required (approximate vs precise), provide clear + prominent disclosures, and must never utilize background tracking unless it is + essential for safety, navigation, or physical fitness features. +- **Evidence**: + {{#IF data_sources.PRECISE_LOCATION}} + - **Precise Location usage**: + {{#EACH data_sources.PRECISE_LOCATION}} + - `{{ITEM}}` + {{/EACH}} + {{/IF}} + {{#IF data_sources.APPROX_LOCATION}} + - **Approximate Location usage**: + {{#EACH data_sources.APPROX_LOCATION}} + - `{{ITEM}}` + {{/EACH}} + {{/IF}} + **Relevant Permissions Requested**: + {{#EACH requested_permissions.LOCATION}} + - `{{ITEM}}` + {{/EACH}} + {{#IF TARGET_SDK}} + **Target SDK**: `{{TARGET_SDK}}` + {{/IF}} +- **Common Evaluation Matrix**: + | Scope | Target SDK | Finding / Condition Checked | Severity | Direct Actionable Recommendation | + | :--- | :--- | :--- | :--- | :--- | + | **Foreground** | Any | Requests `ACCESS_FINE_LOCATION` but features only require city-level or approximate weather/search features. | `IMPORTANT` | Downgrade Manifest to `ACCESS_COARSE_LOCATION` to respect the minimum scope mandate. | + | **Foreground** | Any | App purpose (`{{APP_NAME}}`) does not imply location, yet foreground tracking is used, and prominent disclosure alert code is missing. | `IMPORTANT` | Implement a **Prominent In-App Disclosure** dialog explaining what location data is collected *before* requesting foreground permission. | + | **Background** | Any | Requests `ACCESS_BACKGROUND_LOCATION` solely for advertising, marketing, or general analytics. | `CRITICAL` | **High-Risk Violation**: Completely remove background location collection from the codebase. | + | **Background** | Any | Requests background location, but features could operate with foreground location access. | `IMPORTANT` | Downgrade the feature to foreground-only location tracking and remove background permission. | + | **Background** | Any | Background location is legitimate, but prominent disclosure does not mention "location" and "when the app is closed or not in use." | `IMPORTANT` | Update the Prominent Disclosure text to explicitly state "location" and "when closed or not in use". | + | **Foreground** | **37 or higher** | Precise location is requested on Android 17+. | `SUGGESTION` | Migrate to the **Location Button** API as the minimum scope mechanism for precise foreground location. | + +- **Domain-Specific Heuristics (Strictly Bounded)**: + Actively trace custom background workers and disclosures: + 1. **The Foreground Sufficiency Test**: Analyze background threads, + `WorkManager` tasks, or background services triggering location updates. If + the background process performs syncing or location calculations that could + be deferred to when the user is actively viewing the app, flag an + `IMPORTANT` violation. + 2. **The Reasonable Expectation Test**: If the app label (`{{APP_NAME}}`) or + packages suggest a utility that shouldn't logically track location, + evaluate any indirect geo-tracking (such as geo-lookup of network IP + addresses, or sending local Wi-Fi SSID logs off-device). If found, flag an + `IMPORTANT` missing prominent disclosure. +{{/IF_ANY}} +{{/IF}} + +{{#IF_ALL requested_permissions.CONTACTS, data_sources.CONTACTS}} +#### Contacts Access Policy (Policy ID: contacts_access_policy) + +- **Goal**: Evaluate if broad contacts access (`READ_CONTACTS`) is justified or + if the Android Contact Picker should be used. +- **The Policy Spirit**: Address books contain sensitive personal details. The + Play Store mandates that apps use scoped contact access unless broad, + continuous contacts synchronization is critical (such as in social network + friend-matching or full contact managers). +- **Evidence**: + {{#EACH data_sources.CONTACTS}} + - `{{ITEM}}` + {{/EACH}} + **Relevant Permissions Requested**: + {{#EACH requested_permissions.CONTACTS}} + - `{{ITEM}}` + {{/EACH}} + {{#IF TARGET_SDK}} + **Target SDK**: `{{TARGET_SDK}}` + {{/IF}} +- **Common Evaluation Matrix**: + | Target SDK | Access Model | Condition / Finding Checked | Severity | Direct Actionable Recommendation | + | :--- | :--- | :--- | :--- | :--- | + | **Any** | Broad (`READ_CONTACTS`) | Broad access is requested for one-time transactions, file sharing, referrals, or simple forms. | `IMPORTANT` | Migrate to the **Android Contact Picker** (`Intent.ACTION_PICK_CONTACTS`) to query contacts securely. | + | **37 or higher** | Broad (`READ_CONTACTS`) | Target SDK is 37+ (effective Oct 2026), and broad access is used for secondary or standard tasks. | `IMPORTANT` | Migrate to the **Android Contact Picker** as broad contacts access is restricted. | + +- **Domain-Specific Heuristics (Strictly Bounded)**: + Evaluate the depth of contact queries: + 1. **Single-Item Verification**: Search for database cursors reading contact + tables (`ContactsContract.CommonDataKinds`). If the cursors are used solely + to let a user select a single friend, mobile phone number, or email + address, broad contacts access is a violation. Recommend Contact Picker. +{{/IF_ALL}} + +{{#IF_ALL requested_permissions.exact_alarm, exact_alarm}} +#### Exact Alarm Policy (Policy ID: exact_alarm_policy) + +- **Goal**: Evaluate if the app's core functionality justifies the + `USE_EXACT_ALARM` permission. +- **The Policy Spirit**: Exact alarms degrade system performance and battery + life. The Play Store strictly limits the high-risk `USE_EXACT_ALARM` + permission to alarm clocks, timers, and calendar apps where precise, + down-to-the-second timing is critical. +- **Evidence**: + {{#EACH exact_alarm}} + - `{{ITEM}}` + {{/EACH}} + **Relevant Permissions Requested**: + {{#EACH requested_permissions.exact_alarm}} + - `{{ITEM}}` + {{/EACH}} +- **Common Evaluation Matrix**: + | Core App Purpose | Permission Requested | Justified? | Severity | Direct Actionable Recommendation | + | :--- | :--- | :--- | :--- | :--- | + | **Alarm Clock, Timer, or Calendar App** | `USE_EXACT_ALARM` | Yes (Compliant) | None | No action needed. | + | **Standard Utility, Game, Sync, or Productivity App** | `USE_EXACT_ALARM` | No (Violation) | `IMPORTANT` | Switch to `SCHEDULE_EXACT_ALARM` which respects system battery constraints, or use standard `AlarmManager` inexact scheduling. | + +- **Domain-Specific Heuristics (Strictly Bounded)**: + Examine alarm trigger targets: + 1. **Time-Insensitive Syncing**: Verify the files scheduling alarms. If alarms + are used to fetch network updates, clean cache files, trigger analytics + uploads, or post local daily notifications, exact timing is not justified. + Recommend using `WorkManager` for background tasks instead of + `AlarmManager`. +{{/IF_ALL}} + +{{#IF_ALL requested_permissions.foreground_service, foreground_service}} +#### Foreground Services (Policy ID: foreground_services_policy) + +- **Goal**: Verify the declaration and justification of foreground services. +- **The Policy Spirit**: Foreground services keep processes alive in the + background and must be highly visible to users. Every declared service must + have an appropriate `foregroundServiceType` defined in the Manifest, and + special types like `specialUse` require specific tag property justifications. +- **Evidence**: + {{#EACH foreground_service}} + - `{{ITEM}}` + {{/EACH}} + **Relevant Permissions Requested**: + {{#EACH requested_permissions.foreground_service}} + - `{{ITEM}}` + {{/EACH}} + {{#IF TARGET_SDK}} + **Target SDK**: `{{TARGET_SDK}}` + {{/IF}} +- **Common Evaluation Matrix**: + | Service Configuration | Justification Check | Severity | Direct Actionable Recommendation | + | :--- | :--- | :--- | :--- | + | **Missing type tag** | Foreground service is declared but lacks a `foregroundServiceType` attribute. | `CRITICAL` | Add the appropriate `android:foregroundServiceType` attribute to the service declaration in the Manifest. | + | **Lacks specialUse property** | Service type is `specialUse`, but Manifest lacks the required `` tag. | `CRITICAL` | Add the `` tag inside the service block with a valid subtype string. | + | **Type Misalignment** | Declared FGS type does not logically align with the app's core purpose. | `IMPORTANT` | Re-align FGS type to match app features, or migrate background operations to **WorkManager** if user-visible foreground presence is not justified. | + | **Declaration Reminder** | Foreground service is declared (even if type is correct). | `SUGGESTION` | **Play Console Declaration Required**: For apps targeting Android 14+, you must complete a Foreground Service declaration in the Play Console (App content section) for each type used, providing a functional description, user impact video, and a specific use case selection. | + +- **Domain-Specific Heuristics (Strictly Bounded)**: + Critique specialUse justifications and service behavior: + 1. **Justification String Audit**: Read the text of the `` tag for + `specialUse`. If the text contains weak, boilerplate, or placeholder + justifications (e.g., "requires background process for app to run"), flag + an `IMPORTANT` violation warning the developer that Google Play reviewers + will reject this service. + 2. **Notification Integrity**: Verify if the FGS implementation creates a + valid user-facing notification. If no `startForeground()` or notification + builder logic is associated with the service initiation, flag an + `IMPORTANT` violation. + 3. **Play Console Declaration Confirmation**: If any foreground service is + used, flag a `SUGGESTION` to remind the developer that a specialized + declaration form in the Play Console is mandatory, requiring a video + demonstration of the feature. +{{/IF_ALL}} + +{{#IF_ALL requested_permissions.accessibility, accessibility}} +#### Accessibility API Policy (Policy ID: accessibility_api_policy) + +- **Goal**: Verify the configuration and justification of Accessibility + Services. +- **The Policy Spirit**: Accessibility APIs provide deep system access to assist + users with disabilities. Using these APIs for non-accessibility tasks (like UI + automation, screen scraping, background monitoring, or ad blocking) is + strictly prohibited by Google Play and causes immediate app rejection. +- **Evidence**: + {{#EACH accessibility}} + - `{{ITEM}}` + {{/EACH}} + **Relevant Permissions Requested**: + {{#EACH requested_permissions.accessibility}} + - `{{ITEM}}` + {{/EACH}} +- **Common Evaluation Matrix**: + | Service Configuration | Real Code Usage | Justified? | Severity | Direct Actionable Recommendation | + | :--- | :--- | :--- | :--- | :--- | + | **`isAccessibilityTool="true"`** | Code performs screen scraping, ad blocking, or automated click routines for standard users. | No (Violation) | `CRITICAL` | **Immediate Rejection Risk**: Remove accessibility helper configs. Migrate UI automation to standard Android testing libraries. | + | **`isAccessibilityTool` false/missing** | Code implements an accessibility listener but is not a dedicated helper app. | No prominent disclosure | `CRITICAL` | **Immediate Rejection Risk**: Because you request accessibility permission for a standard utility, you MUST implement an in-app **Prominent Disclosure and Affirmative Consent screen** before asking the user to enable the service, otherwise your app will be rejected. | + +- **Domain-Specific Heuristics (Strictly Bounded)**: + Trace the ingestion of accessibility events: + 1. **Telemetry Siphoning**: Scan the Accessibility Service methods + (`onAccessibilityEvent`). If the service captures on-screen text, + notifications, or keystrokes and routes them to local caches, shared + preferences, or off-device network logging endpoints, flag a `CRITICAL` + violation under the Accessibility API policy. +{{/IF_ALL}} + +{{#IF_ALL requested_permissions.APPS_ON_DEVICE, data_sources.APPS_ON_DEVICE}} +#### Package Visibility (Policy ID: package_visibility_policy) + +- **Goal**: Evaluate if the app's core functionality justifies broad visibility + into installed apps (`QUERY_ALL_PACKAGES` or related package APIs). +- **The Policy Spirit**: The list of installed apps reveals sensitive user + habits. The Play Store strictly restricts the `QUERY_ALL_PACKAGES` permission + to apps that directly manage device safety (Antivirus, File Managers, Device + Search). +- **Evidence**: + {{#EACH data_sources.APPS_ON_DEVICE}} + - `{{ITEM}}` + {{/EACH}} + **Relevant Permissions Requested**: + {{#EACH requested_permissions.APPS_ON_DEVICE}} + - `{{ITEM}}` + {{/EACH}} +- **Common Evaluation Matrix**: + | Core App Purpose | Permission Requested | Justified? | Severity | Direct Actionable Recommendation | + | :--- | :--- | :--- | :--- | :--- | + | **Antivirus, File Manager, or Device Search** | `QUERY_ALL_PACKAGES` | Yes (Compliant) | None | No action needed. | + | **Any App** | `QUERY_ALL_PACKAGES` | No, used for ads, analytics, or secondary marketing. | `IMPORTANT` | **High-Risk Violation**: Remove the permission from the Manifest. Use specific `` intent declarations if package checks are strictly required for sharing/integration. | + +- **Domain-Specific Heuristics (Strictly Bounded)**: + Examine indirect queries: + 1. **Indirect Package Searching**: Check if the code queries package details + dynamically using `PackageManager.getInstalledPackages()` or checks intents + in loops. If the intent lists are used to deduce if competitor apps or + advertising profiles exist, flag an `IMPORTANT` violation of package + visibility guidelines. +{{/IF_ALL}} + +{{#IF_ALL requested_permissions.SMS_CALL_LOG, data_sources.SMS_CALL_LOG}} +#### SMS and Call Log Permissions (Policy ID: sms_call_log_policy) + +- **Goal**: Evaluate if the app's core functionality justifies access to + sensitive SMS or Call Log data. +- **The Policy Spirit**: SMS and Call details are high-risk. Google Play + restricts access to default phone handlers and default SMS handlers. Standard + utility or shopping apps must use non-privileged APIs for SMS-based + verification. +- **Evidence**: + {{#EACH data_sources.SMS_CALL_LOG}} + - `{{ITEM}}` + {{/EACH}} + **Relevant Permissions Requested**: + {{#EACH requested_permissions.SMS_CALL_LOG}} + - `{{ITEM}}` + {{/EACH}} +- **Common Evaluation Matrix**: + | Code Context | Finding / Trigger Checked | Severity | Direct Actionable Recommendation | + | :--- | :--- | :--- | :--- | + | **OTP/Account Verification** | App requests SMS permissions to automatically read login verification codes (OTPs). | `IMPORTANT` | Migrate to the **SMS Retriever API** or **SMS User Consent API** which require zero permissions. | + | **Utility or Secondary Feature** | App requests SMS/Call log access for non-core dashboard or notification features. | `CRITICAL` | **High-Risk Violation**: Completely remove SMS and Call Log permissions from the Manifest. | + | **Default SMS Handler** | App appears to be a legitimate handler (e.g. implements required intent filters). | `SUGGESTION` | **Play Console Declaration**: You must submit a **Permissions Declaration Form** in the Play Console and may be required to provide a video demonstration of this core functionality. | + +- **Domain-Specific Heuristics (Strictly Bounded)**: + Audit background receivers: + 1. **Background Incoming SMS Listeners**: Check if a `BroadcastReceiver` + listens to `android.provider.Telephony.SMS_RECEIVED`. If this receiver + parses text in the background without being a designated default SMS + handler, flag a `CRITICAL` violation. + 2. **Default Handler Confirmation**: If the app requests SMS/Call Log + permissions and correctly implements the system-mandated intent filters for + a Default Handler, flag a `SUGGESTION` to remind the developer about the + mandatory Play Console declaration form. +{{/IF_ALL}} + +{{#IF_ALL requested_permissions.AUDIO, data_sources.AUDIO}} +#### Audio Recording Policy (Policy ID: audio_recording_policy) + +- **Goal**: Evaluate if the app's core functionality justifies broad access to + audio recording. +- **The Policy Spirit**: Unprompted audio recording is a severe privacy breach. + Apps should request microphone access strictly for user-visible, time-bounded + actions. Target SDK 34+ encourages using the system-managed Microphone Button + for temporary needs. +- **Evidence**: + {{#EACH data_sources.AUDIO}} + - `{{ITEM}}` + {{/EACH}} + **Relevant Permissions Requested**: + {{#EACH requested_permissions.AUDIO}} + - `{{ITEM}}` + {{/EACH}} + {{#IF TARGET_SDK}} + **Target SDK**: `{{TARGET_SDK}}` + {{/IF}} +- **Common Evaluation Matrix**: + | Target SDK | Audio Recording Trigger Context | Severity | Direct Actionable Recommendation | + | :--- | :--- | :--- | :--- | + | **34 or higher** | App requests broad `RECORD_AUDIO` permission for occasional, user-initiated vocal input or short recording. | `IMPORTANT` | Migrate to the **Android Microphone Button** API to process temporary audio securely. | + | **Any** | App captures audio for secondary features (analytics, user-agent details, etc.) without explicit user control. | `IMPORTANT` | Remove microphone permissions. For general searches, integrate standard Android Speech Recognizer intents. | + +- **Domain-Specific Heuristics (Strictly Bounded)**: + Verify recording indicators and threads: + 1. **Continuous Capture**: Scan for active recorder threads (`AudioRecord` or + `MediaRecorder`). If recording loops can be active when the app is + minimized or without a visible user-facing indicator, flag this as a + `CRITICAL` violation. +{{/IF_ALL}} + +{{#IF_ALL requested_permissions.FILES_AND_DOCS, data_sources.FILES_AND_DOCS}} +#### Files and Docs Access Policy (Policy ID: files_and_docs_policy) + +- **Goal**: Evaluate if broad file access (non-media) is justified or if the + Storage Access Framework should be used. +- **The Policy Spirit**: Storage isolation (Scoped Storage) is mandatory on + modern Android versions. Broad access to shared files is heavily restricted. + Standard files, documents, and download folders should be navigated using + scoped contracts to prevent global filesystem snooping. +- **Evidence**: + {{#EACH data_sources.FILES_AND_DOCS}} + - `{{ITEM}}` + {{/EACH}} + **Relevant Permissions Requested**: + {{#EACH requested_permissions.FILES_AND_DOCS}} + - `{{ITEM}}` + {{/EACH}} + {{#IF TARGET_SDK}} + **Target SDK**: `{{TARGET_SDK}}` + {{/IF}} +- **Common Evaluation Matrix**: + | Target SDK | Storage Configuration | Justified? | Severity | Direct Actionable Recommendation | + | :--- | :--- | :--- | :--- | :--- | + | **30 or higher** | App requests broad `READ_EXTERNAL_STORAGE` or `WRITE_EXTERNAL_STORAGE` for simple document selection. | No (Violation) | `IMPORTANT` | **Scoped Storage Mandate**: Migrate your document/file selections to the **Storage Access Framework (SAF)** (`Intent.ACTION_OPEN_DOCUMENT`). | + +- **Domain-Specific Heuristics (Strictly Bounded)**: + Critique external directory creation: + 1. **Manual File Sync Heuristic**: Check if the code creates custom root-level + folders on external storage (e.g. + `Environment.getExternalStorageDirectory() + "/my_folder"`). If directories + are created for standard document outputs or logging, flag an `IMPORTANT` + violation. Direct the developer to utilize scoped storage paths. +{{/IF_ALL}} + +## Output schema + +Save final JSON output to `{{TEMP_DIR}}/worker_{{GOAL_NAME}}.json`. + +```json +{ + "domain": "Permissions and APIs", + "findings": [ + { + "policy_id": "STRING_VALUE (The exact Policy ID, e.g., photo_video_access_policy)", + "issue_summary": "STRING_VALUE", + "severity": "CRITICAL | IMPORTANT | SUGGESTION", + "files_involved": ["STRING_VALUE"], + "evidence": "STRING_VALUE", + "recommendation": "STRING_VALUE" + } + ] +} +``` diff --git a/play/play-policy-insights/resources/goal_user_account.md b/play/play-policy-insights/resources/goal_user_account.md new file mode 100644 index 0000000..336e58d --- /dev/null +++ b/play/play-policy-insights/resources/goal_user_account.md @@ -0,0 +1,106 @@ +{{#IF_ANY data_sources.USER_ACCOUNT, data_sources.ACCOUNT_DELETION}} +{{ACTIVATE_GOAL}} +{{/IF_ANY}} +## User Account and Identity Audit + +### Hint: Deducing Account Presence + +To determine if an app handles user accounts (even if it uses third-party +providers like Google Sign-In or Firebase Auth), look for: +1. **Login/Auth Screens**: Semantic files or layouts named `login`, `auth`, + `signin`, or `signup`. +2. **Account Management**: APIs like `AccountManager`, `CredentialManager`, or + `Firebase.auth`. +3. **User Profile**: UI strings or data models referencing `profile`, + `my account`, or `user settings`. + +### Policies to Verify + +{{#IF_ANY data_sources.USER_ACCOUNT, semantic_files.USER_ACCOUNT}} +#### Play Console Requirements (Policy ID: login_credentials) + +- **Goal**: Identify if the app implements a login wall or authentication + screen. +- **The Policy Spirit**: To ensure Play Store reviewers can successfully test + and audit apps, developers must submit functional, non-expiring credentials in + Play Console if the app's features are gated behind a login screen. +- **Evidence**: + {{#IF semantic_files.USER_ACCOUNT}} + - **Account Files**: + `{{#EACH semantic_files.USER_ACCOUNT}}{{ITEM}}, {{/EACH}}` + {{/IF}} + {{#IF data_sources.USER_ACCOUNT}} + - **Account Signals**: + `{{#EACH data_sources.USER_ACCOUNT}}{{ITEM}}, {{/EACH}}` + {{/IF}} +- **Common Evaluation Matrix**: + | App State | Finding / Condition | Severity | Actionable Recommendation | + | :--- | :--- | :--- | :--- | + | **Login Screen Detected** | App displays or contains a login screen, registration wall, or authentication interface. | `IMPORTANT` | **Administrative Console Requirements**: Because your app implements a login flow, you MUST complete two manual setup steps in the Play Console dashboard to pass review:
1. **Reviewer Demo Credentials**: Submit active, non-expiring test credentials so Google Play reviewers can access your gated features.
2. **Account Deletion Link**: Submit a public-facing web link for account deletion to satisfy Google Play's data deletion policies. | + +- **Domain-Specific Heuristics (Strictly Bounded)**: + Look beyond standard native login buttons. Extrapolate using this heuristic: + 1. **Hidden Gatekeepers**: Analyze if certain critical features (e.g., + synchronizing local database, checkout forms, or member-only dashboards) + require authentication even if the app opens directly to a main page. If + you deduce that functional workflows require a login, treat it as a login + gate and output the `IMPORTANT` console credentials and deletion link + reminders. +{{/IF_ANY}} + +{{#IF_ANY data_sources.USER_ACCOUNT, data_sources.ACCOUNT_DELETION}} +#### Account Deletion Requirement (Policy ID: account_deletion) + +- **Goal**: If the app handles user accounts, it must provide a discoverable + in-app account deletion mechanism. +- **The Policy Spirit**: Users have a fundamental right to request data erasure. + If they can create an account in-app, they must be able to delete it in-app. + Deletion must wipe remote database records, not just sign out. +- **Evidence**: + {{#IF data_sources.USER_ACCOUNT}} + - **Account signals (Presence)**: + `{{#EACH data_sources.USER_ACCOUNT}}{{ITEM}}, {{/EACH}}` + {{/IF}} + {{#IF data_sources.ACCOUNT_DELETION}} + - **Deletion signals (Mechanism)**: + `{{#EACH data_sources.ACCOUNT_DELETION}}{{ITEM}}, {{/EACH}}` + {{/IF}} +- **Common Evaluation Matrix**: + | App Account Status | Finding / Deletion Evidence | Severity | Actionable Recommendation | + | :--- | :--- | :--- | :--- | + | **Handles User Accounts** | App manages user accounts, but NO code, layout, or string suggests an in-app deletion button or process. | `IMPORTANT` | **Implement in-app deletion**: Create a highly discoverable path (e.g., under User Profile/Account Settings) to let users initiate account deletion directly in the app. | + +- **Domain-Specific Heuristics (Strictly Bounded)**: + Do not limit your analysis to basic "Delete" buttons. Actively analyze custom + and grey-area implementations: + 1. **The Partial Deletion Trap**: Read the implementation of any found + deletion mechanisms. If the code merely calls `clearPreferences()`, clears + a local cookie, or triggers a standard `logout()` without sending a remote + delete/purge network API call to clean up backend user records, flag this + as a `IMPORTANT` violation of the deletion mandate. + 2. **Indirect User Accounts**: If the app uses third-party sign-in bridges + (e.g., Google Sign-In, Firebase) but does not store an + explicit account profile on its own server, it still handles user account + details if any user preferences or device identifiers are cached remotely. + If so, a delete link/button is still required. +{{/IF_ANY}} + +## Output schema + +Save final JSON output to `{{TEMP_DIR}}/worker_{{GOAL_NAME}}.json`. + +```json +{ + "domain": "User Account and Identity", + "findings": [ + { + "policy_id": "STRING_VALUE (The exact Policy ID, e.g., account_deletion)", + "issue_summary": "STRING_VALUE", + "severity": "CRITICAL | IMPORTANT | SUGGESTION", + "files_involved": ["STRING_VALUE"], + "evidence": "STRING_VALUE", + "recommendation": "STRING_VALUE" + } + ] +} +``` diff --git a/play/play-policy-insights/resources/policies.json b/play/play-policy-insights/resources/policies.json new file mode 100644 index 0000000..d069a83 --- /dev/null +++ b/play/play-policy-insights/resources/policies.json @@ -0,0 +1,321 @@ +{ + "data_safety_section": { + "name": "Data safety section", + "category": "User Data", + "urls": [ + "https://support.google.com/googleplay/android-developer/answer/10144311#safetysection", + "https://developer.android.com/privacy-and-security/declare-data-use", + "https://support.google.com/googleplay/android-developer/answer/10787469" + ], + "taxonomy": { + "PRECISE_LOCATION": { + "category": "Location", + "data_type": "Precise location", + "description": "User or device physical location within an area less than 3 square kilometers, such as location provided by Androidโ€™s ACCESS_FINE_LOCATION permission." + }, + "APPROX_LOCATION": { + "category": "Location", + "data_type": "Approximate location", + "description": "User or device physical location to an area greater than or equal to 3 square kilometers, such as the city a user is in, or location provided by Androidโ€™s ACCESS_COARSE_LOCATION permission." + }, + "USER_ACCOUNT": { + "category": "Personal info", + "data_type": "User IDs", + "description": "Identifiers that relate to an identifiable person. For example, an account ID, account number, or account name." + }, + "NAME": { + "category": "Personal info", + "data_type": "Name", + "description": "How a user refers to themselves, such as their first or last name, or nickname." + }, + "EMAIL": { + "category": "Personal info", + "data_type": "Email address", + "description": "A userโ€™s email address." + }, + "ADDRESS": { + "category": "Personal info", + "data_type": "Address", + "description": "A userโ€™s address, such as a mailing or home address." + }, + "PHONE": { + "category": "Personal info", + "data_type": "Phone number", + "description": "A userโ€™s phone number." + }, + "RACE_ETHNICITY": { + "category": "Personal info", + "data_type": "Race and ethnicity", + "description": "Information about a userโ€™s race or ethnicity." + }, + "POLITICAL_RELIGIOUS_BELIEFS": { + "category": "Personal info", + "data_type": "Political or religious beliefs", + "description": "Information about a userโ€™s political or religious beliefs." + }, + "SEXUAL_ORIENTATION": { + "category": "Personal info", + "data_type": "Sexual orientation", + "description": "Information about a userโ€™s sexual orientation." + }, + "PERSONAL_INFO_OTHER": { + "category": "Personal info", + "data_type": "Other info", + "description": "Any other personal information such as date of birth, gender identity, veteran status, etc." + }, + "CREDIT_DEBIT_BANK_ACCOUNT_NUMBER": { + "category": "Financial info", + "data_type": "User payment info", + "description": "Information about a userโ€™s financial accounts such as credit card number." + }, + "PURCHASE_HISTORY": { + "category": "Financial info", + "data_type": "Purchase history", + "description": "Information about purchases or transactions a user has made." + }, + "CREDIT_SCORE": { + "category": "Financial info", + "data_type": "Credit score", + "description": "Information about a userโ€™s credit score." + }, + "FINANCIAL_INFO_OTHER": { + "category": "Financial info", + "data_type": "Other financial info", + "description": "Any other financial information such as user salary or debts." + }, + "HEALTH": { + "category": "Health and fitness", + "data_type": "Health info", + "description": "Information about a user's health, such as medical records or symptoms." + }, + "FITNESS": { + "category": "Health and fitness", + "data_type": "Fitness info", + "description": "Information about a user's fitness, such as exercise or other physical activity." + }, + "EMAILS": { + "category": "Messages", + "data_type": "Emails", + "description": "A userโ€™s emails including the email subject line, sender, recipients, and the content of the email." + }, + "SMS_CALL_LOG": { + "category": "Messages", + "data_type": "SMS or MMS", + "description": "A userโ€™s text messages including the sender, recipients, and the content of the message." + }, + "OTHER_MESSAGES": { + "category": "Messages", + "data_type": "Other in-app messages", + "description": "Any other types of messages. For example, instant messages or chat content." + }, + "PHOTOS": { + "category": "Photos and videos", + "data_type": "Photos", + "description": "A userโ€™s photos." + }, + "VIDEOS": { + "category": "Photos and videos", + "data_type": "Videos", + "description": "A userโ€™s videos." + }, + "AUDIO": { + "category": "Audio files", + "data_type": "Voice or sound recordings", + "description": "A userโ€™s voice such as a voicemail or a sound recording." + }, + "MUSIC": { + "category": "Audio files", + "data_type": "Music files", + "description": "A userโ€™s music files." + }, + "OTHER_AUDIO": { + "category": "Audio files", + "data_type": "Other audio files", + "description": "Any other user-created or user-provided audio files." + }, + "FILES_AND_DOCS": { + "category": "Files and docs", + "data_type": "Files and docs", + "description": "A userโ€™s files or documents, or information about their files or documents such as file names." + }, + "CALENDAR": { + "category": "Calendar", + "data_type": "Calendar events", + "description": "Information from a userโ€™s calendar such as events, event notes, and attendees." + }, + "CONTACTS": { + "category": "Contacts", + "data_type": "Contacts", + "description": "Information about the userโ€™s contacts such as contact names, message history, and social graph information like usernames, contact recency, contact frequency, interaction duration and call history." + }, + "APP_INTERACTIONS": { + "category": "App activity", + "data_type": "App interactions", + "description": "Information about how a user interacts with the app. For example, the number of times they visit a page or sections they tap on." + }, + "IN_APP_SEARCH_HISTORY": { + "category": "App activity", + "data_type": "In-app search history", + "description": "Information about what a user has searched for in your app." + }, + "APPS_ON_DEVICE": { + "category": "App activity", + "data_type": "Installed apps", + "description": "Information about the apps installed on a user's device." + }, + "USER_GENERATED_CONTENT": { + "category": "App activity", + "data_type": "Other user-generated content", + "description": "Any other user-generated content not listed here, or in any other section. For example, user bios, notes, or open-ended responses." + }, + "OTHER_ACTIONS": { + "category": "App activity", + "data_type": "Other actions", + "description": "Any other user activity or actions in-app not listed here such as gameplay, likes, and dialog options." + }, + "WEB_BROWSING_HISTORY": { + "category": "Web browsing", + "data_type": "Web browsing history", + "description": "Information about the websites a user has visited." + }, + "CRASH_LOGS": { + "category": "App info and performance", + "data_type": "Crash logs", + "description": "Crash log data from your app. For example, the number of times your app has crashed, stack traces, or other information directly related to a crash." + }, + "PERFORMANCE_DIAGNOSTICS": { + "category": "App info and performance", + "data_type": "Diagnostics", + "description": "Information about the performance of your app. For example battery life, loading time, latency, framerate, or any technical diagnostics." + }, + "OTHER_APP_PERFORMANCE": { + "category": "App info and performance", + "data_type": "Other app performance data", + "description": "Any other app performance data excluding crash log data (e.g., stack traces) and diagnostics (e.g., battery life, loading time, latency, or framerate)." + }, + "DEVICE_ID": { + "category": "Device or other IDs", + "data_type": "Device or other IDs", + "description": "Identifiers that relate to an individual device, browser or app. For example, an IMEI number, MAC address, Widevine Device ID, Firebase installation ID, or advertising identifier." + } + } + }, + "login_credentials": { + "name": "Login credentials", + "category": "Play Console Requirements", + "urls": [ + "https://support.google.com/googleplay/android-developer/answer/15748846" + ] + }, + "sms_call_log_policy": { + "name": "SMS and Call Log Permissions", + "category": "Permissions", + "urls": [ + "https://support.google.com/googleplay/android-developer/answer/16558241#sms-call-permissions", + "https://support.google.com/googleplay/android-developer/answer/10208820" + ] + }, + "package_visibility_policy": { + "name": "Package Visibility", + "category": "Permissions", + "urls": [ + "https://support.google.com/googleplay/android-developer/answer/16558241#package-vis", + "https://support.google.com/googleplay/android-developer/answer/10158779" + ] + }, + "all_files_access_policy": { + "name": "All Files Access", + "category": "Permissions", + "urls": [ + "https://support.google.com/googleplay/android-developer/answer/16558241#all-files-access", + "https://developer.android.com/training/data-storage/manage-all-files", + "https://support.google.com/googleplay/android-developer/answer/10467955" + ] + }, + "photo_video_access_policy": { + "name": "Photo and Video Permissions", + "category": "Permissions", + "urls": [ + "https://support.google.com/googleplay/android-developer/answer/16558241#photo-and-video-permissions", + "https://support.google.com/googleplay/android-developer/answer/16935362", + "https://developer.android.com/training/data-storage/shared/media" + ] + }, + "location_access_policy": { + "name": "Location Permissions", + "category": "Permissions", + "urls": [ + "https://support.google.com/googleplay/android-developer/answer/16558241", + "https://support.google.com/googleplay/android-developer/answer/9799150" + ] + }, + "contacts_access_policy": { + "name": "Contacts Permissions", + "category": "Permissions", + "urls": [ + "https://support.google.com/googleplay/android-developer/answer/16909972#contacts-permissions", + "https://support.google.com/googleplay/android-developer/answer/16935362" + ] + }, + "audio_recording_policy": { + "name": "Audio Recording Policy", + "category": "Permissions", + "urls": [ + "https://support.google.com/googleplay/android-developer/answer/10144311", + "https://support.google.com/googleplay/android-developer/answer/16558241" + ] + }, + "files_and_docs_policy": { + "name": "Files and Docs Access Policy", + "category": "Permissions", + "urls": [ + "https://support.google.com/googleplay/android-developer/answer/10467955", + "https://support.google.com/googleplay/android-developer/answer/16558241" + ] + }, + "prominent_disclosure_policy": { + "name": "Prominent Disclosure Policy", + "category": "User Data", + "urls": [ + "https://support.google.com/googleplay/android-developer/answer/10144311" + ] + }, + "account_deletion": { + "name": "Account Deletion Requirement", + "category": "User Data", + "urls": [ + "https://support.google.com/googleplay/android-developer/answer/10144311#account_deletion", + "https://support.google.com/googleplay/android-developer/answer/13327111" + ] + }, + "target_api_level": { + "name": "Target API Level Requirements", + "category": "Privacy, Deception and Device Abuse", + "urls": [ + "https://support.google.com/googleplay/android-developer/answer/16561298", + "https://support.google.com/googleplay/android-developer/answer/11926878" + ] + }, + "accessibility_api_policy": { + "name": "Accessibility API Policy", + "category": "Permissions", + "urls": [ + "https://support.google.com/googleplay/android-developer/answer/16558241#accessibility" + ] + }, + "exact_alarm_policy": { + "name": "Exact Alarm Policy", + "category": "Permissions", + "urls": [ + "https://support.google.com/googleplay/android-developer/answer/16558241#exact_alarm" + ] + }, + "foreground_services_policy": { + "name": "Foreground Services", + "category": "Privacy, Deception and Device Abuse", + "urls": [ + "https://support.google.com/googleplay/android-developer/answer/13392821", + "https://support.google.com/googleplay/android-developer/answer/16559646#foreground_service" + ] + } +} diff --git a/play/play-policy-insights/resources/scanner_config.json b/play/play-policy-insights/resources/scanner_config.json new file mode 100644 index 0000000..6126957 --- /dev/null +++ b/play/play-policy-insights/resources/scanner_config.json @@ -0,0 +1,799 @@ +{ + "signal_categories": { + "data_sources": { + "PRECISE_LOCATION": [ + "ACCESS_FINE_LOCATION", + "FusedLocationProviderClient", + "requestSingleUpdate", + "getLastKnownLocation", + "navigator.geolocation", + "Geolocator", + "location.getLocation()", + "latitude", + "longitude", + "gps_coords", + "watchPosition", + "getCurrentPosition", + "getPositionStream", + "LocationAccuracy.high", + "react-native-geolocation-service", + "Expo.Location" + ], + "APPROX_LOCATION": [ + "ACCESS_COARSE_LOCATION", + "ACCESS_BACKGROUND_LOCATION", + "LocationManager", + "coarse_location", + "ip_location", + "ip_country", + "LocationAccuracy.low", + "LocationAccuracy.balanced" + ], + "USER_ACCOUNT": [ + "AccountManager", + "registerUser", + "login", + "signIn", + "signUp", + "createAccount", + "GET_ACCOUNTS", + "isLoggedIn", + "sign in", + "firebaseAuth", + "CredentialManager", + "credentials", + "auth_token", + "session_token", + "user_id", + "uid", + "FirebaseAuth", + "GoogleSignIn", + "SignInWithApple", + "flutter_facebook_auth", + "Auth0", + "@react-native-firebase/auth", + "react-native-google-signin", + "react-native-app-auth" + ], + "ACCOUNT_DELETION": [ + "deleteAccount", + "purgeUserData", + "closeAccount", + "removeUser", + "deactivate", + "requestDelete", + "delete_profile", + "destroy_account" + ], + "NAME": [ + "textPersonName", + "user_name", + "firstName", + "lastName", + "display_name", + "full_name", + "fullName", + "real_name", + "realName", + "family_name", + "familyName", + "given_name", + "contact_name", + "profile_name", + "NAME_DATA_EXTRA", + "middleName", + "nickname", + "TextInputType.name" + ], + "EMAIL": [ + "textEmailAddress", + "email_address", + "user_email", + ".email", + "emailAddress", + "userEmail", + "accountEmail", + "senderEmail", + "recipientEmail", + "mailAddress", + "getEmail(", + "setEmail(", + "email_id", + "primary_email", + "keyboardType=\"email-address\"", + "TextInputType.emailAddress" + ], + "ADDRESS": [ + "textPostalAddress", + "postal_address", + "user_address", + "street_address", + "billing_address", + "shipping_address", + "postal_code", + "zip_code", + "country_code", + "autoComplete=\"postal-address\"", + "TextInputType.streetAddress" + ], + "PHONE": [ + "textPhone", + "phone_number", + "user_phone", + "getLine1Number", + "READ_PHONE_NUMBERS", + "mobile_phone", + "telephone", + "phone_id", + "textContentType=\"telephoneNumber\"", + "TextInputType.phone" + ], + "RACE_ETHNICITY": [ + "race", + "ethnicity", + "tribal", + "demographics" + ], + "POLITICAL_RELIGIOUS_BELIEFS": [ + "religion", + "religious", + "political_affiliation", + "political_party", + "political_beliefs" + ], + "SEXUAL_ORIENTATION": [ + "sexual_orientation", + "gender_identity", + "gender_preference" + ], + "PERSONAL_INFO_OTHER": [ + "date_of_birth", + "dob", + "birth_date", + "gender", + "sex", + "salutation" + ], + "CREDIT_DEBIT_BANK_ACCOUNT_NUMBER": [ + "cardNumber", + "card_number", + "cvv", + "expiry_date", + "stripe", + "paypal", + "bank_account", + "iban", + "swift_code", + "credit_card", + "card_holder", + "routing_number", + "flutter_stripe", + "stripe-react-native", + "initPaymentSheet" + ], + "PURCHASE_HISTORY": [ + "purchaseHistory", + "transactionHistory", + "orderHistory", + "orderId", + "purchaseToken", + "queryPurchases", + "queryPurchasesAsync", + "startPurchaseFlow", + "PurchasesUpdatedListener", + "BillingClient", + "receipt", + "invoice", + "payment_history", + "in_app_purchase", + "react-native-iap", + "RNIap" + ], + "CREDIT_SCORE": [ + "credit_score", + "fico", + "credit_rating", + "equifax", + "experian", + "transunion" + ], + "FINANCIAL_INFO_OTHER": [ + "salary", + "income", + "net_worth", + "outstanding_debt", + "user_debt" + ], + "HEALTH": [ + "BODY_SENSORS", + "BODY_SENSORS_BACKGROUND", + "SensorManager", + "Health Connect", + "medical_record", + "symptoms", + "blood_pressure", + "glucose_level", + "HealthFactory", + "HealthDataType", + "getHealthDataFromTypes", + "HealthConnectClient", + "AppleHealthKit" + ], + "FITNESS": [ + "ACTIVITY_RECOGNITION", + "Google Fit", + "HealthConnect", + "Sleep API", + "step_counter", + "HeartRate", + "Heart_Rate", + "Cadence", + "PowerMeter", + "CyclingPower", + "CaloriesBurned", + "Workout", + "PhysicalActivity", + "GPX", + "ANT+", + "react-native-google-fit", + "GoogleFit", + "fitbit" + ], + "EMAILS": [ + "javax.mail", + "smtp", + "imap", + "pop3", + "message/rfc822", + "mailserver", + "email_body", + "sendMail", + "mail_subject", + "email_attachment" + ], + "SMS_CALL_LOG": [ + "READ_SMS", + "RECEIVE_SMS", + "SEND_SMS", + "READ_CALL_LOG", + "WRITE_CALL_LOG", + "SmsRetriever" + ], + "OTHER_MESSAGES": [ + "chat_message", + "instant_message", + "xmpp", + "matrix_sdk", + "chat_history", + "direct_message", + "send_message", + "websocket_chat" + ], + "MEDIA": [ + "READ_MEDIA_IMAGES", + "READ_MEDIA_VIDEO", + "PhotoPicker", + "MediaStore", + "ACTION_GET_CONTENT", + "ACTION_OPEN_DOCUMENT", + "image/*", + "video/*" + ], + "PHOTOS": [ + "READ_MEDIA_IMAGES", + "READ_MEDIA_VISUAL_USER_SELECTED", + "PhotoPicker", + "PickVisualMedia", + "MediaStore.Images", + "ACTION_PICK", + "ACTION_GET_CONTENT", + "image/*", + "TakePicture", + "ImageCapture", + "CAMERA", + "image_picker", + "launchImageLibrary", + "launchCamera", + "react-native-image-picker", + "expo-image-picker", + "wechat_assets_picker" + ], + "VIDEOS": [ + "READ_MEDIA_VIDEO", + "READ_MEDIA_VISUAL_USER_SELECTED", + "MediaStore.Video", + "video/*", + "PickVisualMedia.VideoOnly", + "ACTION_VIDEO_CAPTURE", + "VideoCapture", + "MediaRecorder", + "ExoPlayer", + "MediaController", + "pickVideo", + "react-native-vision-camera", + "CameraRoll", + "expo-image-picker" + ], + "AUDIO": [ + "AudioRecord", + "MediaRecorder", + "RECORD_AUDIO", + "CAPTURE_AUDIO_OUTPUT", + "SpeechRecognizer", + "RecognitionListener", + "VoiceInteractionService", + "audio/*", + "audio/amr", + "audio/3gpp", + "voice_note", + "voice_memo", + "voicemail", + "dictation", + "voice_recorder", + "ambient_sound", + "record_button", + "mic_icon", + "record", + "Record.hasPermission", + "flutter_sound", + "FlutterSoundRecorder", + "react-native-audio-recorder-player", + "expo-av" + ], + "MUSIC": [ + "MediaPlayer", + "ExoPlayer", + "SimpleExoPlayer", + "AudioTrack", + "MediaController", + "MediaSession", + "PlaybackState", + "IS_MUSIC", + "MediaStore.Audio.Media", + "MediaStore.Audio.Playlists", + "MediaStore.Audio.Albums", + "MediaStore.Audio.Artists", + "audio/mpeg", + "audio/mp3", + "audio/flac", + "audio/x-wav", + "music", + "playlist", + "artist", + "album", + "track", + "song", + "mp3", + "flac", + "m4a", + "wma", + "audioplayers", + "assets_audio_player", + "react-native-track-player", + "react-native-sound" + ], + "OTHER_AUDIO": [ + "RingtoneManager", + "SoundPool", + "ACTUAL_DEFAULT_RINGTONE_URI", + "ACTION_RINGTONE_PICKER", + "IS_RINGTONE", + "IS_NOTIFICATION", + "IS_ALARM", + "IS_PODCAST", + "audio/ogg", + "audio/aac", + "audio/webm", + "ringtone", + "notification_sound", + "alarm_sound", + "podcast", + "sound_effect", + "audio_effect", + "sfx", + "soundboard" + ], + "FILES_AND_DOCS": [ + "READ_EXTERNAL_STORAGE", + "WRITE_EXTERNAL_STORAGE", + "MANAGE_EXTERNAL_STORAGE", + "*/*", + "localStorage", + "sessionStorage", + "AsyncStorage", + "shared_preferences", + "flutter_secure_storage", + "Hive", + "sqflite", + "application/pdf", + "application/msword", + "text/plain", + "path_provider", + "getApplicationDocumentsDirectory", + "getExternalStorageDirectory", + "getTemporaryDirectory", + "react-native-fs", + "RNFS", + "expo-file-system", + "MMKV", + "react-native-mmkv", + "Sembast", + "RxDB", + "Realm" + ], + "CALENDAR": [ + "READ_CALENDAR", + "WRITE_CALENDAR", + "CalendarContract", + "device_calendar", + "retrieveCalendars", + "react-native-calendar-events", + "RNCalendarEvents" + ], + "CONTACTS": [ + "READ_CONTACTS", + "WRITE_CONTACTS", + "ContactsContract", + "contacts_service", + "ContactsService.getContacts", + "flutter_contacts", + "FlutterContacts", + "react-native-contacts", + "Contacts.getAll" + ], + "APP_INTERACTIONS": [ + "logEvent", + "trackScreen", + "onItemClick", + "onClickListener", + "firebase.analytics()", + "Mixpanel", + "Amplitude", + "FirebaseAnalytics", + "AmplitudeFlutter", + "user_session", + "event_params", + "page_view", + "FirebaseAnalytics.instance", + "facebook_app_events", + "amplitude_flutter", + "mixpanel_flutter", + "@react-native-firebase/analytics", + "react-native-fbsdk-next", + "mixpanel-react-native", + "AppEventsLogger" + ], + "IN_APP_SEARCH_HISTORY": [ + "searchHistory", + "search_query", + "SearchRecentSuggestions", + "search_term", + "query_string" + ], + "APPS_ON_DEVICE": [ + "QUERY_ALL_PACKAGES", + "getPackageInfo", + "getInstalledPackages", + "queryIntentActivities" + ], + "USER_GENERATED_CONTENT": [ + "textMultiLine", + "edit_text", + "user_note", + "user_bio", + "user_feedback", + "comment_text" + ], + "WEB_BROWSING_HISTORY": [ + "WebView", + "loadUrl", + "WebSettings", + "setJavaScriptEnabled", + "browser_history", + "visited_url" + ], + "CRASH_LOGS": [ + "ApplicationErrorReport", + "Crashlytics", + "ACRA", + "Bugsnag", + "Sentry", + "uncaughtException", + "recordFlutterError", + "recordError", + "SentryFlutter", + "crashlytics()" + ], + "PERFORMANCE_DIAGNOSTICS": [ + "ActivityManager", + "BatteryManager", + "Choreographer", + "Debug", + "PowerManager", + "StrictMode", + "FirebasePerf", + "trace", + "api_latency", + "write_latency", + "query_time", + "elapsed_time", + "execution_time" + ], + "OTHER_APP_PERFORMANCE": [ + "WorkInfo", + "sync_status", + "sync_failed", + "api_error", + "request_timeout", + "status_code", + "getDatabasePath", + "db_size", + "database_size", + "Macrobenchmark", + "benchmark", + "telemetry", + "cpu_usage", + "memory_usage", + "heap_usage", + "bytes_sent", + "bytes_received", + "payload_size", + "heartbeat", + "foreground_duration", + "background_duration", + "cache_hit_rate", + "cache_size", + "queue_depth" + ], + "DEVICE_ID": [ + "TelephonyManager", + "getDeviceId", + "getSubscriberId", + "IMEI", + "IMSI", + "AndroidId", + "Settings.Secure.ANDROID_ID", + "AdvertisingIdClient", + "instanceId", + "firebase_token", + "push_token", + "fcm_token", + "device_info_plus", + "DeviceInfoPlugin", + "AndroidDeviceInfo", + "react-native-device-info", + "DeviceInfo", + "getUniqueId", + "getAndroidId", + "getAdvertisingId" + ], + "OTHER_ACTIONS": [ + "gameplay", + "button_click", + "option_selected", + "user_action" + ] + }, + "network_transmission": { + "-": [ + "fetch", + "axios", + "XMLHttpRequest", + "WebSocket", + "http.get", + "http.post", + "Dio", + "HttpClient" + ] + }, + "exact_alarm": { + "-": [ + "USE_EXACT_ALARM", + "SCHEDULE_EXACT_ALARM", + "setExact", + "setExactAndAllowWhileIdle", + "AlarmManager" + ] + }, + "accessibility": { + "-": [ + "AccessibilityService", + "AccessibilityEvent", + "BIND_ACCESSIBILITY_SERVICE" + ] + }, + "foreground_service": { + "-": [ + "FOREGROUND_SERVICE", + "startForeground", + "foregroundServiceType", + "ServiceInfo.FOREGROUND_SERVICE_TYPE", + "FOREGROUND_SERVICE_SPECIAL_USE", + "PROPERTY_SPECIAL_USE_FGS_SUBTYPE", + "specialUse" + ] + }, + "disclosure": { + "-": [ + "AlertDialog", + "Dialog", + "MaterialAlertDialogBuilder", + "Consent", + "Disclosure", + "Privacy Policy", + "Accept", + "Agree", + "collect", + "share", + "transmit", + "data safety", + "privacy" + ] + } + }, + "file_patterns": { + "USER_ACCOUNT": [ + "account", + "login", + "auth" + ], + "SUPPORT": [ + "support", + "help" + ], + "LEGAL": [ + "privacy", + "terms", + "legal" + ], + "CONFIG": [ + "config" + ], + "AUDIO": [ + "record", + "voice", + "mic" + ], + "MUSIC": [ + "music", + "playlist", + "song", + "player" + ], + "OTHER_AUDIO": [ + "ringtone", + "sound", + "effect", + "podcast", + "sfx" + ], + "OTHER_APP_PERFORMANCE": [ + "telemetry", + "benchmark", + "sync", + "performance" + ], + "PHOTOS": [ + "photo", + "image", + "picture", + "camera", + "avatar", + "gallery" + ], + "VIDEOS": [ + "video", + "movie", + "playback", + "camcorder", + "recording" + ] + }, + "permission_groups": { + "LOCATION": [ + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION", + "android.permission.ACCESS_BACKGROUND_LOCATION" + ], + "CONTACTS": [ + "android.permission.READ_CONTACTS", + "android.permission.WRITE_CONTACTS" + ], + "SMS_CALL_LOG": [ + "android.permission.READ_SMS", + "android.permission.RECEIVE_SMS", + "android.permission.SEND_SMS", + "android.permission.RECEIVE_MMS", + "android.permission.RECEIVE_WAP_PUSH", + "android.permission.READ_CALL_LOG", + "android.permission.WRITE_CALL_LOG", + "android.permission.PROCESS_OUTGOING_CALLS" + ], + "MEDIA": [ + "android.permission.READ_MEDIA_IMAGES", + "android.permission.READ_MEDIA_VIDEO", + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.WRITE_EXTERNAL_STORAGE" + ], + "PHOTOS": [ + "android.permission.READ_MEDIA_IMAGES", + "android.permission.READ_MEDIA_VISUAL_USER_SELECTED", + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.WRITE_EXTERNAL_STORAGE", + "android.permission.CAMERA" + ], + "VIDEOS": [ + "android.permission.READ_MEDIA_VIDEO", + "android.permission.READ_MEDIA_VISUAL_USER_SELECTED", + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.WRITE_EXTERNAL_STORAGE", + "android.permission.CAMERA", + "android.permission.RECORD_AUDIO" + ], + "ALL_FILES": [ + "android.permission.MANAGE_EXTERNAL_STORAGE" + ], + "AUDIO": [ + "android.permission.RECORD_AUDIO", + "android.permission.READ_MEDIA_AUDIO", + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.WRITE_EXTERNAL_STORAGE" + ], + "MUSIC": [ + "android.permission.READ_MEDIA_AUDIO", + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.WRITE_EXTERNAL_STORAGE" + ], + "OTHER_AUDIO": [ + "android.permission.READ_MEDIA_AUDIO", + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.WRITE_EXTERNAL_STORAGE" + ], + "FILES_AND_DOCS": [ + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.WRITE_EXTERNAL_STORAGE" + ], + "APPS_ON_DEVICE": [ + "android.permission.QUERY_ALL_PACKAGES" + ], + "exact_alarm": [ + "android.permission.USE_EXACT_ALARM", + "android.permission.SCHEDULE_EXACT_ALARM" + ], + "foreground_service": [ + "android.permission.FOREGROUND_SERVICE" + ], + "accessibility": [ + "android.permission.BIND_ACCESSIBILITY_SERVICE" + ] + }, + "ignored_directories": [ + ".git", + ".scratch", + "build", + ".gradle", + ".idea", + "test", + "androidTest", + "tests", + "testFixtures", + "node_modules", + ".pub-cache", + "ios", + "web", + "macos", + "windows", + "linux" + ], + "supported_extensions": [ + ".java", + ".kt", + ".js", + ".ts", + ".jsx", + ".tsx", + ".dart", + ".cs", + ".vue" + ] +} diff --git a/play/play-policy-insights/scripts/generate_report.py b/play/play-policy-insights/scripts/generate_report.py new file mode 100755 index 0000000..6628216 --- /dev/null +++ b/play/play-policy-insights/scripts/generate_report.py @@ -0,0 +1,860 @@ +#!/usr/bin/env python3 + +# Copyright 2026 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Generates a comprehensive compliance report from synthesized findings.""" + +import argparse +from datetime import datetime +import glob +import json +import os +import re +import sys +import play_store_scraper +from template_engine import render_template + +# Precompiled regex patterns at module scope for performance optimizations +EMOJI_PATTERN = re.compile( + r"[\U00010000-\U0010ffff\u2600-\u27bf\u2300-\u23ff\u2b50]+", + flags=re.UNICODE, +) +CODE_LINK_PATTERN = re.compile(r"^(.*?)(?:[:\s]+L?(\d+))?$") +SEVERITY_CLEAN_PATTERN = re.compile(r"['\"`*๐Ÿ”ด๐ŸŸก๐Ÿ”ต\s]+") + + +def run_scraper(package_name, output_dir): + """Invokes the Play Store scraper directly.""" + try: + print(f"Auditing {package_name} from Play Store...", file=sys.stderr) + result = play_store_scraper.scrape_app_details(package_name) + + # Save to temp for consistency with existing report logic if needed + os.makedirs(output_dir, exist_ok=True) + out_path = os.path.join(output_dir, "play_store_declaration.json") + with open(out_path, "w") as f: + json.dump(result, f, indent=4, sort_keys=True) + + return result + except Exception as e: + print(f"Warning: Play Store scrape failed: {e}", file=sys.stderr) + return {} + + +def strip_emojis(text): + """Removes emoji characters from a string.""" + if not isinstance(text, str): + return text + return EMOJI_PATTERN.sub(r"", text) + + +def format_code_link(file_val): + """Formats file paths and line numbers into Markdown links.""" + if not file_val or file_val == "N/A": + return "N/A" + files = [] + if isinstance(file_val, str): + if "," in file_val: + files = [f.strip() for f in file_val.split(",")] + else: + files = [file_val.strip()] + elif isinstance(file_val, list): + files = [str(f).strip() for f in file_val] + formatted_links = [] + for f in files: + f_str = str(f) + if f_str.startswith("["): + formatted_links.append(f_str) + continue + match = CODE_LINK_PATTERN.search(f_str) + if match: + path = match.group(1) + line = match.group(2) + basename = os.path.basename(path) + if line: + link = f"[{basename}:L{line}](file://{path}#L{line})" + else: + link = f"[{basename}](file://{path})" + formatted_links.append(link) + else: + formatted_links.append(f_str) + return ", ".join(formatted_links) + + +def render_table( + items, columns, column_names, empty_message="No items identified." +): + """Renders a Markdown table from a list of dictionaries.""" + if not items: + return f"\n* {empty_message}\n" + + widths = [len(name) for name in column_names] + rows = [] + for item in items: + row = [] + for col in columns: + val = str(item.get(col, "N/A")).replace("|", "\\|").replace("\n", "
") + row.append(val) + rows.append(row) + for i, val in enumerate(row): + widths[i] = max(widths[i], len(val)) + + def format_row(vals): + return ( + "| " + + " | ".join(val.ljust(widths[i]) for i, val in enumerate(vals)) + + " |" + ) + + header = format_row(column_names) + separator = ( + "| " + " | ".join("-" * widths[i] for i in range(len(widths))) + " |" + ) + body = "\n".join(format_row(row) for row in rows) + + return f"\n{header}\n{separator}\n{body}\n" + + +def parse_boolean(val): + """Safely converts boolean or sloppy string yes/no/true/false.""" + if isinstance(val, bool): + return val + if isinstance(val, str): + v_clean = val.strip().lower() + return v_clean in ("yes", "true", "1") + return False + + +def parse_disclosure_status(val): + """Safely parses disclosure enums or visual yes/no.""" + if not val: + return "No" + + val_clean = str(val).strip().upper() + if "EXEMPT" in val_clean: + return "Exempt (Obvious)" + if "DISCLOSED" in val_clean or "YES" in val_clean or "TRUE" in val_clean: + return "Yes" + + return "No" + + +def clean_severity(sev_str): + """Robustly cleans severity strings by removing quotes, asterisks, + emojis, and whitespace. + """ + if not isinstance(sev_str, str): + return "SUGGESTION" + + # Convert to uppercase and strip formatting characters + cleaned = sev_str.upper() + cleaned = SEVERITY_CLEAN_PATTERN.sub("", cleaned) + + if "CRITICAL" in cleaned: + return "CRITICAL" + if "IMPORTANT" in cleaned: + return "IMPORTANT" + if "SUGGESTION" in cleaned: + return "SUGGESTION" + + return "SUGGESTION" + + +def render_finding_card(finding, policy_refs=None): + """Renders a detailed finding card in Markdown.""" + severity = clean_severity(finding.get("severity")) + severity_map = { + "CRITICAL": "๐Ÿ”ด Critical", + "IMPORTANT": "๐ŸŸก Important", + "SUGGESTION": "๐Ÿ”ต Suggestion", + } + visual_severity = severity_map.get(severity, "๐Ÿ”ต Suggestion") + + title = strip_emojis( + finding.get("issue") or finding.get("issue_summary") or "Policy Risk" + ) + p_id = ( + finding.get("policy_id") + or finding.get("policy_reference") + or "Unknown Policy" + ) + policy_link = f"**{p_id}**" + + policy_info = policy_refs.get(p_id) if policy_refs else None + + policy_link = f"**{p_id}**" + policy_urls = [] + if p_id and policy_info: + policy_link = f"**{policy_info.get('name', p_id)}**" + policy_urls = policy_info.get("urls", []) + + files = finding.get("files_involved") + has_local_override = False + local_override_file = None + + if files and isinstance(files, list): + for f in files: + if "play_store_declaration.json" in f: + has_local_override = True + local_override_file = f + break + + files_formatted = format_code_link(files) + recommendation = finding.get("recommendation", "Review and remediate.") + + if has_local_override: + recommendation = ( + "Update the local Play Store declaration file at " + f"{format_code_link(local_override_file)}." + ) + + card = f"\n#### {title}\n" + card += f"- **Policy**: {policy_link}\n" + card += f"- **Severity**: {visual_severity}\n" + card += f"- **Files**: {files_formatted}\n" + card += f"- **Evidence**: {finding.get('evidence', 'N/A')}\n" + card += f"- **Recommendation**: {recommendation}\n" + + if policy_urls: + card += "- **References**:\n" + for url in policy_urls: + card += f" - {url}\n" + + return card + + +def load_json(file_path): + """Loads JSON from a file if it exists.""" + if os.path.exists(file_path): + try: + with open(file_path, "r") as f: + return json.load(f) + except Exception as e: + print(f"Warning: Failed to load {file_path}: {e}", file=sys.stderr) + return None + + +def aggregate_findings(temp_dir, taxonomy): + """Consolidates audit findings from workers. + + Produces a unified report data object. + """ + play_store_info = ( + load_json(os.path.join(temp_dir, "play_store_info.json")) or {} + ) + + aggregated_findings_path = os.path.join(temp_dir, "aggregated_findings.json") + + raw_findings = [] + critic_decisions = {} + + if os.path.exists(aggregated_findings_path): + master_data = load_json(aggregated_findings_path) or {} + raw_findings = master_data.get("findings", []) + + # Load all chunked critic outputs: critic_output_*.json + critic_files = sorted( + glob.glob(os.path.join(temp_dir, "critic_output_*.json")) + ) + for c_file in critic_files: + c_data = load_json(c_file) or {} + for fid, dec in c_data.items(): + critic_decisions[fid] = dec + + # 2. Process Findings and Apply Decoupled Critic Verdicts + identified_risks = [] + manual_review_needed = [] + data_safety_inventory = [] + + # Sort raw findings by finding_id numerically for stable processing order + def finding_sort_key(f): + fid = f.get("finding_id", "0") + try: + return int(fid) + except (ValueError, TypeError): + return 0 + + sorted_raw_findings = sorted(raw_findings, key=finding_sort_key) + + for finding in sorted_raw_findings: + p_id = finding.get("policy_id", "Unknown") + psl = finding.get("psl_constant") + fid = finding.get("finding_id") + wf = finding.get("worker_file") + + # Retrieve decision + decision = critic_decisions.get(str(fid)) + if not decision: + # Automatically approve/verify findings that bypassed the Critic + # (like SUGGESTIONs) + decision = { + "action": "VERIFIED", + "confidence": "High", + "critic_justification": ( + "Automatically verified (bypassed critic review)." + ), + } + + action = str(decision.get("action", "VERIFIED")).upper().strip() + confidence = decision.get("confidence", "Medium") + justification = decision.get("critic_justification", "Verified by Critic.") + + # Apply Critic's editorial overrides if specified + severity = decision.get("severity") or finding.get("severity", "SUGGESTION") + severity = clean_severity(severity) + + issue_summary = decision.get("issue_summary") or finding.get( + "issue_summary", "Unknown Issue" + ) + recommendation = decision.get("recommendation") or finding.get( + "recommendation", "" + ) + + files_involved = finding.get("files_involved", []) + if isinstance(files_involved, list): + files_involved = sorted([str(f) for f in files_involved]) + + evidence = finding.get("evidence", "") + + reconstructed_finding = { + "policy_id": p_id, + "finding_id": fid or f"{wf}_{p_id}", + "issue_summary": issue_summary, + "severity": severity, + "files_involved": files_involved, + "evidence": ( + f"{evidence}\n\n**Critic Verification**: {justification}" + f" (Confidence: {confidence})" + ), + "recommendation": recommendation, + } + + # Programmatically evaluate if this is a compliant Data Safety finding + # to avoid card clutter + is_compliant_ds = False + if "is_transferred" in finding: + is_transferred = parse_boolean(finding.get("is_transferred")) + user_initiated = parse_boolean(finding.get("user_initiated")) + is_third_party = parse_boolean(finding.get("is_third_party")) + disc_status = ( + str(finding.get("prominent_disclosure_status", "MISSING")) + .upper() + .strip() + ) + + # Compliant if either local-only OR transmitted but disclosed/exempt + # OR user initiated + if not is_transferred: + is_compliant_ds = True + elif user_initiated: + is_compliant_ds = True + elif "DISCLOSED" in disc_status or "EXEMPT" in disc_status: + is_compliant_ds = True + + # Route the finding based on Critic's action + if action == "VERIFIED": + if not is_compliant_ds: + identified_risks.append(reconstructed_finding) + elif action == "MANUAL_REVIEW": + if not is_compliant_ds: + manual_review_needed.append(reconstructed_finding) + + # 3. Process Data Safety Inventory Programmatically (If it's a DS Finding) + if "is_transferred" in finding: + # Create a localized copy of the data safety keys (psl_constant is + # natively preserved) + inv_item = { + "psl_constant": psl, + "is_transferred": parse_boolean(finding.get("is_transferred")), + "user_initiated": parse_boolean(finding.get("user_initiated")), + "is_third_party": parse_boolean(finding.get("is_third_party")), + "prominent_disclosure_status": ( + str(finding.get("prominent_disclosure_status", "MISSING")) + .upper() + .strip() + ), + "purpose": finding.get("purpose", "N/A"), + "linked_to_user": parse_boolean(finding.get("linked_to_user")), + "behavioral_proof": evidence, + "disclosure_proof": recommendation, + } + + # Programmatically align/vet inventory based on Critic's verdict + if action == "PRUNED": + inv_item["is_transferred"] = False + inv_item["purpose"] = "Local functionality only" + inv_item["behavioral_proof"] = f"Pruned by Critic: {justification}" + + data_safety_inventory.append(inv_item) + + # 4. Consolidate and Deduplicate Data Safety Inventory by psl_constant + merged_inventory = {} + for item in data_safety_inventory: + psl_id = item.get("psl_constant") + if not psl_id: + continue + + if psl_id not in merged_inventory: + merged_inventory[psl_id] = { + "psl_constant": psl_id, + "is_transferred": False, + "user_initiated": True, # Default to True for AND logic + "is_third_party": False, + "linked_to_user": False, + "prominent_disclosure_status": "EXEMPT", + "purposes": set(), + "behavioral_proofs": set(), + "disclosure_proofs": set(), + } + + current = merged_inventory[psl_id] + + # Transmission (True if any is True) + if item.get("is_transferred"): + current["is_transferred"] = True + + # User Initiated (True only if ALL are True) + if not item.get("user_initiated"): + current["user_initiated"] = False + + # Third Party (True if any is True) + if item.get("is_third_party"): + current["is_third_party"] = True + + # Linked (True if any is True) + if item.get("linked_to_user"): + current["linked_to_user"] = True + + # Disclosure logic precedence: MISSING > DISCLOSED > EXEMPT + new_disc = ( + str(item.get("prominent_disclosure_status", "EXEMPT")).upper().strip() + ) + curr_disc = current["prominent_disclosure_status"] + + if "MISSING" in curr_disc or "MISSING" in new_disc: + current["prominent_disclosure_status"] = "MISSING" + elif "DISCLOSED" in curr_disc or "DISCLOSED" in new_disc: + current["prominent_disclosure_status"] = "DISCLOSED" + else: + current["prominent_disclosure_status"] = "EXEMPT" + + if item.get("purpose") and item.get("purpose") != "N/A": + current["purposes"].add(str(item["purpose"]).strip()) + if item.get("behavioral_proof") and item.get("behavioral_proof") != "N/A": + current["behavioral_proofs"].add(str(item["behavioral_proof"]).strip()) + if item.get("disclosure_proof") and item.get("disclosure_proof") != "N/A": + current["disclosure_proofs"].add(str(item["disclosure_proof"]).strip()) + + # Reconstruct the list with joined sets + normalized_inventory = [] + sorted_psl_ids = sorted(merged_inventory.keys()) + for psl_id in sorted_psl_ids: + item = merged_inventory[psl_id] + reconstructed = { + "psl_constant": psl_id, + "is_transferred": "Yes" if item["is_transferred"] else "No", + "user_initiated": "Yes" if item.get("user_initiated") else "No", + "is_third_party": "Yes" if item.get("is_third_party") else "No", + "linked_to_user": "Yes" if item["linked_to_user"] else "No", + "prominent_disclosure_status": parse_disclosure_status( + item["prominent_disclosure_status"] + ), + "purpose": ", ".join(sorted(list(item["purposes"]))) or "N/A", + "behavioral_proof": ( + ", ".join(sorted(list(item["behavioral_proofs"]))) or "N/A" + ), + "disclosure_proof": ( + ", ".join(sorted(list(item["disclosure_proofs"]))) or "N/A" + ), + } + normalized_inventory.append(reconstructed) + + # 5. Decorate and Filter Data Safety Inventory + decorated_inventory = [] + local_access_only = [] + + for item in normalized_inventory: + psl_id = item.get("psl_constant") + tax_info = taxonomy.get(psl_id, {"category": "Other", "data_type": "Other"}) + item["category"] = tax_info["category"] + item["data_type"] = tax_info["data_type"] + + is_local = ( + item.get("is_transferred") == "No" + or item.get("purpose") == "Local functionality only" + ) + if is_local: + local_access_only.append(item) + else: + decorated_inventory.append(item) + + # Cross-reference with Play Store + matches = [] + mismatches = [] + is_published = play_store_info.get("is_published", False) + + if is_published: + play_declarations = play_store_info.get("data_safety", {}).get( + "data_collected", [] + ) + play_data_types = set() + for category_dict in play_declarations: + for type_dict in category_dict.get("types", []): + play_data_types.add(type_dict.get("type")) + + for item in decorated_inventory: + dt = item["data_type"] + if dt in play_data_types: + matches.append({"data_type": dt, "status": "Declared and detected"}) + else: + is_obvious = "Exempt" in item.get( + "prominent_disclosure_status", "" + ) or "Obvious" in item.get("prominent_disclosure_status", "") + mismatches.append({ + "data_type": dt, + "local_view": ( + f"Detected in code (Evidence: {item.get('behavioral_proof')})" + ), + "play_view": "Not declared in Play Store", + "status": ( + "Exempt: Obvious core functionality" + if is_obvious + else "Discrepancy" + ), + }) + + # Check for unjustified declarations (in Play Store but not in code) + detected_data_types = { + item["data_type"] for item in decorated_inventory + local_access_only + } + for category_dict in play_declarations: + for type_dict in category_dict.get("types", []): + dt = type_dict.get("type") + if dt not in detected_data_types: + mismatches.append({ + "data_type": dt, + "local_view": "Not detected in code", + "play_view": "Declared in Play Store", + "status": "Discrepancy", + }) + + # 7. Determine Compliance + overall_compliance = "Compliant" + critical_risks = any( + clean_severity(r.get("severity")) == "CRITICAL" for r in identified_risks + ) + active_mismatches = any(m.get("status") == "Discrepancy" for m in mismatches) + + if critical_risks or active_mismatches: + overall_compliance = "Non-compliant" + elif manual_review_needed or any( + clean_severity(r.get("severity")) == "IMPORTANT" for r in identified_risks + ): + overall_compliance = "Needs review" + + # 8. Sort result lists for deterministic output + def report_finding_sort_key(f): + pid = f.get("policy_id", "") + fid = f.get("finding_id", "0") + try: + num_fid = int(fid) + except (ValueError, TypeError): + num_fid = 0 + return (pid, num_fid) + + identified_risks.sort(key=report_finding_sort_key) + manual_review_needed.sort(key=lambda x: x.get("issue_summary", "")) + matches.sort(key=lambda x: x.get("data_type", "")) + mismatches.sort(key=lambda x: x.get("data_type", "")) + + # 9. Generate Summary + + risk_count = len(identified_risks) + mismatch_count = len( + [m for m in mismatches if m.get("status") == "Discrepancy"] + ) + exempt_count = len([ + m + for m in mismatches + if m.get("status") == "Exempt: Obvious core functionality" + ]) + + summary_parts = [] + summary_parts.append( + "The automated audit of" + f" {play_store_info.get('title', 'the application')} is complete." + ) + summary_parts.append( + f"Identified {risk_count} potential policy risks and {mismatch_count}" + " active Data Safety discrepancies." + ) + if exempt_count > 0: + summary_parts.append( + f"{exempt_count} detections were flagged as 'Obvious' and exempt from" + " prominent disclosure." + ) + + if overall_compliance == "Non-compliant": + summary_parts.append( + "Immediate remediation is required for critical findings and" + " declaration mismatches." + ) + elif overall_compliance == "Needs review": + summary_parts.append( + "Manual review is recommended for several ambiguous findings." + ) + else: + summary_parts.append( + "The application appears broadly compliant with analyzed policies." + ) + + # 9. Final Report Construction + report = { + "overall_compliance": overall_compliance, + "summary": " ".join(summary_parts), + "package_name": play_store_info.get("package_name", "unknown"), + "is_published": is_published, + "identified_risks": identified_risks, + "data_safety_comparison": {"matches": matches, "mismatches": mismatches}, + "local_data_access": [ + { + "data_type": item["data_type"], + "category": item["category"], + "evidence": item.get("behavioral_proof"), + } + for item in local_access_only + ], + "manual_review_needed": [ + {"issue_summary": r.get("issue_summary")} + for r in manual_review_needed + ], + "suggested_data_safety_declaration": { + "collected_data": [ + { + "data_type": item["data_type"], + "purpose": item.get("purpose"), + "linked_to_user": item.get("linked_to_user") == "Yes", + } + for item in decorated_inventory + ] + }, + } + + return report + + +def main(): + """Main entry point for report generation.""" + parser = argparse.ArgumentParser(description="Compliance report generator.") + parser.add_argument( + "temp_dir", + help="Path to the temporary scratch directory containing audit findings.", + ) + + args = parser.parse_args() + temp_dir = os.path.abspath(args.temp_dir) + repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + policy_refs = ( + load_json(os.path.join(repo_root, "resources", "policies.json")) + ) + if not policy_refs: + policy_refs = {} + taxonomy = policy_refs.get("data_safety_section", {}).get("taxonomy", {}) + + manifest_details = ( + load_json(os.path.join(temp_dir, "manifest_details.json")) or {} + ) + package_name = manifest_details.get("package_name") + + # Load Play Store info (potentially ingested by orchestrator) + play_store_info_path = os.path.join(temp_dir, "play_store_info.json") + play_store_info = {} + if os.path.exists(play_store_info_path): + play_store_info = load_json(play_store_info_path) or {} + + if not play_store_info and package_name: + play_store_info = run_scraper(package_name, temp_dir) + # Cache it for report reuse + with open(play_store_info_path, "w") as f: + json.dump(play_store_info, f, indent=4, sort_keys=True) + + report_data = aggregate_findings(temp_dir, taxonomy) + + template_path = os.path.join( + repo_root, "resources", "compliance_report_template.md" + ) + if not os.path.exists(template_path): + print(f"Error: Template not found at {template_path}", file=sys.stderr) + sys.exit(1) + + try: + with open(template_path, "r") as f: + content = f.read() + except Exception as e: + print(f"Error reading template: {e}", file=sys.stderr) + sys.exit(1) + + output_path = os.path.join(temp_dir, "compliance_report.md") + + violations = report_data.get("identified_risks", []) + mismatches = report_data.get("data_safety_comparison", {}).get( + "mismatches", [] + ) + manual = report_data.get("manual_review_needed", []) + + compliance_raw = report_data.get("overall_compliance") or "Needs review" + status_map = { + "Non-compliant": "๐Ÿ”ด Non-Compliant", + "Needs review": "๐ŸŸก Needs Review", + "Compliant": "๐ŸŸข Compliant", + } + compliance_ui = status_map.get(compliance_raw, "๐ŸŸก Needs Review") + + app_id = report_data.get("package_name") or "Unknown App" + + findings_content = "" + if not violations: + findings_content = "\n* No policy risks identified in this scan.\n" + else: + grouped_findings = {} + for v in violations: + p_id = v.get("policy_id") + macro_cat = "Other Policies" + if p_id and p_id in policy_refs: + macro_cat = policy_refs[p_id].get("category", "Other Policies") + + if macro_cat not in grouped_findings: + grouped_findings[macro_cat] = [] + grouped_findings[macro_cat].append(v) + + ordered_cats = [ + "Restricted Content", + "Privacy, Deception and Device Abuse", + "Monetization and Ads", + "Store Listing and Promotion", + "Developer Account Management", + ] + + all_cats = ordered_cats + [ + cat for cat in grouped_findings if cat not in ordered_cats + ] + + for cat in all_cats: + if cat in grouped_findings: + findings_content += f"\n### {cat}\n" + for v in grouped_findings[cat]: + findings_content += render_finding_card(v, policy_refs) + "\n" + + is_not_published = report_data.get("is_published") is False + + d_table_msg = "Code detection matches Play Store declarations." + if is_not_published: + d_table_msg = ( + "N/A - App is not yet published. No declarations found for comparison." + ) + + d_table = render_table( + mismatches, + ["data_type", "local_view", "play_view", "status"], + ["Data Type", "Code Detection", "Play Store Declaration", "Status"], + d_table_msg, + ) + + # Local Access Section + local_access = report_data.get("local_data_access", []) + la_content = "" + if local_access: + la_table = render_table( + local_access, + ["data_type", "category", "evidence"], + ["Data Type", "Category", "Access Evidence"], + "No local-only access detected.", + ) + la_content = ( + "## Local Data Access (No Transmission)\nThe following data types are" + " accessed by the code but no evidence of network transmission or" + " exfiltration was detected. These typically do not require a" + f" 'Collection' declaration in the Data Safety section.\n{la_table}" + ) + + # Suggested Declaration (Conditional Section) + suggested_dec = report_data.get("suggested_data_safety_declaration", {}) + dec_content = "" + if suggested_dec and any(suggested_dec.values()): + dec_content = ( + "### Suggested Data Safety Declaration" + f" Updates\n```json\n{json.dumps(suggested_dec, indent=4)}\n```" + ) + + checklist_items = [] + for m in manual: + m_issue = strip_emojis( + m.get("issue") or m.get("issue_summary") or "Review item" + ) + checklist_items.append(f"- [ ] {m_issue}") + + risk_categories = set( + [v.get("category") for v in violations if v.get("category")] + ) + mapping = { + "Undeclared Collection": "Update Data Safety section in Play Console.", + "Permissions": "Review and minimize requested permissions.", + } + for cat in risk_categories: + if cat in mapping: + checklist_items.append(f"- [ ] {mapping[cat]}") + if not checklist_items: + checklist_items.append("- [ ] Review all identified policy risks.") + + template_context = { + "overall_compliance": compliance_ui, + "current_date": datetime.now().strftime("%Y-%m-%d"), + "app_name_id": app_id, + "findings_detail": findings_content, + "data_safety_table": d_table, + "local_access_section": la_content, + "suggested_declaration_section": dec_content, + "personalized_checklist": "\n".join(checklist_items), + } + + content = render_template(content, template_context) + + with open(output_path, "w") as f: + f.write(content) + + json_output_path = output_path.replace(".md", ".json") + with open(json_output_path, "w") as f: + json.dump( + { + "metadata": { + "app_id": app_id, + "scan_date": datetime.now().isoformat(), + "overall_compliance": compliance_raw, + }, + "findings": violations, + "data_safety_mismatches": mismatches, + "manual_review": manual, + "suggested_declaration": suggested_dec, + }, + f, + indent=4, + sort_keys=True, + ) + print(f"Reports generated successfully at {output_path}") + + +if __name__ == "__main__": + main() diff --git a/play/play-policy-insights/scripts/orchestrator.py b/play/play-policy-insights/scripts/orchestrator.py new file mode 100755 index 0000000..6cb5b20 --- /dev/null +++ b/play/play-policy-insights/scripts/orchestrator.py @@ -0,0 +1,1207 @@ +#!/usr/bin/env python3 + +# Copyright 2026 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Coordinates the end-to-end Play Policy audit flow. + +This manages everything from triage to analysis aggregation. +""" + +import argparse +import concurrent.futures +import glob +import json +import os +import re +import sys +import uuid +import xml.etree.ElementTree as ET + +import scanner +from template_engine import render_template + +# Constants for Gradle parsing +GRADLE_APP_ID_PATTERN = ( + r'applicationId\s*(?:[:=]|\.set\(?)\s*[\'"]?([a-zA-Z0-9._]+)[\'"]?\)?' +) +GRADLE_APP_ID_PROP_PATTERN = ( + r'applicationId\s*[:=]?\s*project\.property\(\s*[\'"]([^\'"]+)[\'"]\s*\)' +) +GRADLE_NAMESPACE_PATTERN = ( + r'namespace\s*(?:[:=]|\.set\(?)\s*[\'"]?([a-zA-Z0-9._]+)[\'"]?\)?' +) +GRADLE_NAMESPACE_PROP_PATTERN = ( + r'namespace\s*[:=]?\s*project\.property\(\s*[\'"]([^\'"]+)[\'"]\s*\)' +) +GRADLE_TARGET_SDK_PATTERN = ( + r"targetSdk(?:Version)?(?:\.set\(?)?\s*[:=" + r" (]*\s*([\'\"]?[\w\d_\.]+[\'\"]?)\)?" +) + +# Well-known framework defaults for unresolved dynamic variables (e.g. Flutter) +FRAMEWORK_DEFAULTS = { + "flutter.targetSdkVersion": 35, + "flutter.compileSdkVersion": 35, + "flutter.minSdkVersion": 21, + "flutter.ndkVersion": "25.1.8937393", +} + +# Patterns for diverse assignment styles (Groovy & Kotlin DSL) +# 1. Standard: targetSdkVersion = 35, ext.targetSdkVersion = 35, etc. +# 2. Delegated: val targetSdkVersion by extra(35) +# 3. Functional: set("targetSdkVersion", 35) or +# extra.set("targetSdkVersion", 35) +# 4. Indexer: extra["targetSdkVersion"] = 35 +GRADLE_ASSIGNMENT_PATTERNS = [ + re.compile( + r"(?:\b(?:val|var|def|const|static|internal|private|" + r"public|protected|ext|final)\s+)*" + r"([\w\d_\.]+)(?:\s*:\s*[\w\d_\.]+)?\s*=\s*['\"]?" + r"([^\x27\"\s\(\)<>\{\}]+)['\"]?" + ), + re.compile( + r"val\s+([\w\d_\.]+)(?:\s*:\s*[\w\d_\.]+)?\s+by\s+" + r"extra\s*\(\s*['\"]?([^\x27\"\s\(\)]+)['\"]?\s*\)" + ), + re.compile( + r"(?:extra\.)?set\s*\(\s*['\"]([\w\d_\.]+)['\"]\s*,\s*" + r"['\"]?([^\x27\"\s\(\)]+)['\"]?\s*\)" + ), + re.compile( + r"extra\s*\[\s*['\"]([\w\d_\.]+)['\"]\s*\]\s*=\s*" + r"['\"]?([^\x27\"\s]+)['\"]?" + ), +] + + +def _strip_inline_comment(line, support_quotes=True): + """Strips inline comments starting with '#' while preserving quotes.""" + in_double = False + in_single = False + for i, char in enumerate(line): + if support_quotes: + if char == '"' and not in_single: + in_double = not in_double + elif char == "'" and not in_double: + in_single = not in_single + + if char == "#" and not in_double and not in_single: + return line[:i] + return line + + +def load_gradle_properties(target_dir): + """Loads properties from gradle.properties and local.properties.""" + properties = {} + for filename in ["gradle.properties", "local.properties"]: + path = os.path.join(target_dir, filename) + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + for line in f: + line = _strip_inline_comment(line, support_quotes=False).strip() + if line and not line.startswith("#") and "=" in line: + key, val = line.split("=", 1) + properties[key.strip()] = val.strip() + except Exception as e: # pylint: disable=broad-exception-caught + print( + f"Warning: Failed to load properties from {path}: {e}", + file=sys.stderr, + ) + return properties + + +def load_version_catalog(target_dir): + """Loads versions from gradle/libs.versions.toml if it exists.""" + catalog = {} + toml_path = os.path.join(target_dir, "gradle", "libs.versions.toml") + if os.path.exists(toml_path): + try: + with open(toml_path, "r", encoding="utf-8", errors="ignore") as f: + current_section = None + for line in f: + line = _strip_inline_comment(line).strip() + if not line or line.startswith("#"): + continue + if line.startswith("[") and line.endswith("]"): + current_section = line[1:-1].strip() + continue + if current_section == "versions" and "=" in line: + key, val = line.split("=", 1) + val = val.strip().strip('"').strip("'") + catalog[key.strip()] = val.strip() + except Exception as e: # pylint: disable=broad-exception-caught + print( + f"Warning: Failed to load version catalog from {toml_path}: {e}", + file=sys.stderr, + ) + return catalog + + +def load_custom_gradle_versions(target_dir, file_inventory=None): + """Loads versions from shared Gradle files and project configuration.""" + versions = {} + if not target_dir or not os.path.isdir(target_dir): + return versions + + # 1. Identify Candidate Files (Root files are the primary targets) + candidate_files = [] + + # Leaking Strategy: If target_dir is a sub-module, leak up to find the root + # e.g. /path/to/project/app -> check /path/to/project for root build files + search_roots = [target_dir] + parent = os.path.dirname(os.path.normpath(target_dir)) + if parent and parent != target_dir: + search_roots.append(parent) + + for s_root in search_roots: + for f in ["build.gradle", "build.gradle.kts"]: + root_file = os.path.normpath(os.path.join(s_root, f)) + if os.path.exists(root_file) and root_file not in candidate_files: + candidate_files.append(root_file) + + # Use inventory if provided to avoid redundant file walk + if file_inventory: + search_lists = [ + file_inventory.get("gradles", []), + file_inventory.get("source_code", []), + ] + for file_list in search_lists: + for path in file_list: + norm_path = os.path.normpath(path) + if norm_path not in candidate_files: + file_lower = os.path.basename(norm_path).lower() + if any( + name in file_lower + for name in [ + "versions.gradle", + "dependencies.gradle", + "variables.gradle", + ] + ): + candidate_files.append(norm_path) + elif any( + d in norm_path.lower() for d in ["build-logic", "buildsrc"] + ) and file_lower.endswith((".gradle", ".gradle.kts", ".kt")): + candidate_files.append(norm_path) + else: + # Fallback to walk if no inventory provided + config = scanner.get_scanner_config() + ignored_dirs = set(config.get("ignored_directories", [])) + for root, dirs, files in os.walk(target_dir): + dirs[:] = [d for d in dirs if d not in ignored_dirs] + for f in files: + if f.lower().endswith((".gradle", ".gradle.kts", ".kt")): + candidate_files.append(os.path.join(root, f)) + + # 2. Parse candidate files for assignments + for f_path in candidate_files: + try: + with open(f_path, "r", encoding="utf-8", errors="ignore") as f: + content = f.read() + for pattern in GRADLE_ASSIGNMENT_PATTERNS: + for key, val in pattern.findall(content): + # Clean up the key (remove 'ext.' or 'project.' prefixes) + clean_key = key.split(".")[-1] + if clean_key not in versions: + versions[clean_key] = val.strip().strip('"').strip("'") + except Exception as e: # pylint: disable=broad-exception-caught + print( + f"Warning: Failed to parse Gradle file {f_path}: {e}", file=sys.stderr + ) + + return versions + + +def _load_json_file(path): + """Safely loads a JSON file.""" + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Warning: Failed to load JSON from {path}: {e}", file=sys.stderr) + return {} + + +def _read_text_file(path): + """Safely reads a text file.""" + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + return f.read() + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Warning: Failed to read file {path}: {e}", file=sys.stderr) + return "" + + +def _write_json_file(path, data): + """Safely writes a JSON file.""" + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4, sort_keys=True) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Warning: Failed to write JSON to {path}: {e}", file=sys.stderr) + + +def _write_text_file(path, content): + """Safely writes a text file.""" + try: + with open(path, "w", encoding="utf-8") as f: + f.write(content) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Warning: Failed to write file {path}: {e}", file=sys.stderr) + + +def _filter_by_flavor(data, detected_flavors, prioritized_flavors): + """Recursively filters lists and dicts based on flavor path segments.""" + if isinstance(data, dict): + filtered_dict = {} + for k, v in data.items(): + filtered_val = _filter_by_flavor(v, detected_flavors, prioritized_flavors) + if isinstance(filtered_val, (dict, list)) and not filtered_val: + continue + filtered_dict[k] = filtered_val + return filtered_dict + + elif isinstance(data, list): + filtered_list = [] + for entry in data: + # Handle tuple entries (like in codebase_map.all_files) + entry_str = ( + entry[1] + if isinstance(entry, tuple) and len(entry) > 1 + else str(entry) + ) + + is_excluded = any( + f not in prioritized_flavors and f"src/{f}/" in entry_str + for f in detected_flavors + ) + if not is_excluded: + filtered_list.append(entry) + return filtered_list + + return data # Fallback for primitive types + + +def parse_application_modules( + gradle_files, + manifest_files, + gradle_properties, + version_catalog, + custom_gradle_versions=None, +): + """Identifies distinct application modules in the project.""" + modules = [] + lookups = { + "properties": gradle_properties, + "catalog": version_catalog, + "custom": custom_gradle_versions or {}, + } + + # First, check Gradle files for the application plugin + for g_file in gradle_files: + try: + with open(g_file, "r", encoding="utf-8", errors="ignore") as f: + content = f.read() + if re.search(GRADLE_APP_PLUGIN_PATTERN, content): + # Found an app module! + mod_dir = os.path.dirname(g_file) + mod_name = os.path.basename(mod_dir) + + # Try to extract details from this Gradle file + app_id = None + target_sdk = None + app_label = None + + id_match = re.search(GRADLE_APP_ID_PATTERN, content) + if id_match: + app_id = id_match.group(1) + else: + prop_match = re.search(GRADLE_APP_ID_PROP_PATTERN, content) + if prop_match: + app_id = lookups["properties"].get(prop_match.group(1)) + + sdk_match = re.search(GRADLE_TARGET_SDK_PATTERN, content) + if sdk_match: + target_sdk = _resolve_to_int(sdk_match.group(1), lookups) + + # Fallback to namespace if applicationId is missing + if not app_id: + ns_match = re.search(GRADLE_NAMESPACE_PATTERN, content) + if ns_match: + app_id = ns_match.group(1) + else: + ns_prop_match = re.search(GRADLE_NAMESPACE_PROP_PATTERN, content) + if ns_prop_match: + app_id = lookups["properties"].get(ns_prop_match.group(1)) + + modules.append({ + "name": mod_name, + "path": mod_dir, + "application_id": app_id, + "target_sdk": target_sdk, + "app_label": app_label, + }) + except Exception as e: # pylint: disable=broad-exception-caught + print( + f"Warning: Failed to parse Gradle file {g_file}: {e}", file=sys.stderr + ) + + return modules + + +def determine_primary_identity( + app_modules, + gradle_files, + manifest_files, + gradle_properties, + version_catalog, + custom_gradle_versions, + app_dir, +): + """Heuristically determines the primary app ID and target SDK.""" + primary_id = None + primary_sdk = None + primary_label = None + + # 1. Prefer explicitly defined app modules + for mod in app_modules: + if mod.get("application_id"): + primary_id = mod["application_id"] + primary_sdk = mod["target_sdk"] + primary_label = mod["app_label"] + break + + # 2. Fallback to global Gradle properties + if not primary_id: + primary_id = gradle_properties.get("applicationId") or ( + gradle_properties.get("namespace") + ) + if not primary_sdk: + primary_sdk = _resolve_to_int( + gradle_properties.get("targetSdkVersion") + or gradle_properties.get("targetSdk"), + {"properties": gradle_properties, "catalog": version_catalog}, + ) + + # 3. Fallback to scanning manifests if still missing + if not primary_id or not primary_sdk: + for m_file in manifest_files: + try: + with open(m_file, "r", encoding="utf-8") as f: + content = f.read() + details = extract_manifest_details(content) + if not primary_id: + primary_id = details.get("package_name") + if not primary_sdk: + primary_sdk = details.get("target_sdk") + if not primary_label: + primary_label = details.get("app_label") + except Exception: # pylint: disable=broad-exception-caught + pass + + # 4. Cross-Platform Framework Detection + if not primary_id or not primary_sdk: + # Check for Flutter (pubspec.yaml) + pubspec_path = os.path.join(app_dir, "pubspec.yaml") + if os.path.exists(pubspec_path): + try: + with open(pubspec_path, "r", encoding="utf-8") as f: + for line in f: + if line.startswith("name:"): + primary_id = line.split(":")[1].strip() + break + primary_sdk = FRAMEWORK_DEFAULTS.get("flutter.targetSdkVersion") + except Exception: # pylint: disable=broad-exception-caught + pass + + # Check for React Native (package.json) + pkg_json_path = os.path.join(app_dir, "package.json") + if os.path.exists(pkg_json_path): + try: + with open(pkg_json_path, "r", encoding="utf-8") as f: + data = json.load(f) + primary_id = data.get("name") + except Exception: # pylint: disable=broad-exception-caught + pass + + return primary_id, primary_sdk, primary_label + + +def extract_manifest_details(xml_content): + """Parses AndroidManifest.xml for key policy signals.""" + details = { + "package_name": None, + "target_sdk": None, + "app_label": None, + "permissions": [], + "foreground_services": [], + } + + try: + # Handle XML with namespaces + root = ET.fromstring(xml_content) + ns = {"android": "http://schemas.android.com/apk/res/android"} + + details["package_name"] = root.get("package") + + # Target SDK + uses_sdk = root.find("uses-sdk", ns) + if uses_sdk is not None: + details["target_sdk"] = uses_sdk.get( + "{http://schemas.android.com/apk/res/android}targetSdkVersion" + ) + + # App Label + application = root.find("application", ns) + if application is not None: + label = application.get( + "{http://schemas.android.com/apk/res/android}label" + ) + if label and label.startswith("@string/"): + details["app_label"] = label[8:] + else: + details["app_label"] = label + + # Permissions + for tag in ["uses-permission", "uses-permission-sdk-23"]: + for perm in root.findall(tag, ns): + name = perm.get("{http://schemas.android.com/apk/res/android}name") + if name: + details["permissions"].append(name) + + # Permissions from component declarations + for tag in [ + "service", + "receiver", + "activity", + "provider", + "activity-alias", + ]: + for component in root.findall(f".//{tag}", ns): + perm = component.get( + "{http://schemas.android.com/apk/res/android}permission" + ) + if perm: + details["permissions"].append(perm) + + if tag == "provider": + read_perm = component.get( + "{http://schemas.android.com/apk/res/android}readPermission" + ) + if read_perm: + details["permissions"].append(read_perm) + write_perm = component.get( + "{http://schemas.android.com/apk/res/android}writePermission" + ) + if write_perm: + details["permissions"].append(write_perm) + + # Foreground Services + for service in root.findall(".//service", ns): + # Check for foregroundServiceType + fgs_type = service.get( + "{http://schemas.android.com/apk/res/android}foregroundServiceType" + ) + if fgs_type: + details["foreground_services"].append({ + "name": service.get( + "{http://schemas.android.com/apk/res/android}name" + ), + "type": fgs_type, + }) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Warning: Failed to parse XML: {e}", file=sys.stderr) + + return details + + +def generate_triage_summary(manifest_details, data_safety_scan): + """Generates a high-level summary of the audit landscape.""" + summary = [] + + package = manifest_details.get("package_name") or "Unknown" + summary.append(f"Audit Target: {package}") + + sdk = manifest_details.get("target_sdk") or "Unknown" + summary.append(f"Target SDK: {sdk}") + + perms_count = len(manifest_details.get("permissions", [])) + summary.append(f"Permissions Requested: {perms_count}") + + signals_count = 0 + if isinstance(data_safety_scan, dict): + inner_scan = data_safety_scan.get("data_safety_scan", {}) + if "data_sources" in inner_scan: + signals_count = len(inner_scan["data_sources"]) + summary.append(f"Sensitive Data Signals Found: {signals_count}") + + return "\n".join(summary) + + +def run_scanner_direct(target_dir, file_inventory, output_file): + """Invokes the scanner directly using the provided inventory.""" + try: + # This calls the internal scan logic from scanner.py + results = scanner.perform_scan(target_dir, file_inventory) + _write_json_file(output_file, results) + return results + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error during scan: {e}", file=sys.stderr) + return {} + + +def write_agent_prompts(env_data, goal_map, output_data): + """Generates surgical prompt files for worker agents and infers activation.""" + repo_root = env_data.get("repo_root") + temp_dir = env_data.get("temp_dir") + app_dir = env_data.get("app_dir") + activated_goals = [] + + # 1. Load Shared Resources + policies_path = os.path.join(repo_root, "resources", "policies.json") + policies_data = _load_json_file(policies_path) + taxonomy = policies_data.get("data_safety_section", {}).get("taxonomy", {}) + taxonomy_keys = set(taxonomy.keys()) + + common_mandates_path = os.path.join( + repo_root, "resources", "common_mandates.md" + ) + common_mandates = _read_text_file(common_mandates_path) + + config = scanner.get_scanner_config() + permission_groups = config.get("permission_groups", {}) + + # 2. Write Slices + if output_data: + for slice_name in ["manifest_details", "codebase_map"]: + slice_path = os.path.join(temp_dir, f"{slice_name}.json") + _write_json_file(slice_path, output_data.get(slice_name, {})) + + # 3. Build Base Context + scan_results = output_data.get("data_safety_scan", {}) + base_context = scan_results.get("data_safety_scan", {}).copy() + + # Also ensure other metadata is included + base_context["found_urls"] = scan_results.get("found_urls", []) + base_context["semantic_files"] = scan_results.get("semantic_files", {}) + + # Flatten anonymous categories (those with only a "-" key) + for cat in [ + "exact_alarm", + "accessibility", + "foreground_service", + "disclosure", + ]: + if ( + cat in base_context + and isinstance(base_context[cat], dict) + and "-" in base_context[cat] + ): + base_context[cat] = base_context[cat]["-"] + + manifest_details = output_data.get("manifest_details", {}) + base_context["TARGET_SDK"] = manifest_details.get("target_sdk") + base_context["APP_NAME"] = output_data.get("app_name") + base_context["PACKAGE_NAME"] = output_data.get("package_name") + manifest_perms = manifest_details.get("permissions", []) + + # Populate requested_permissions dictionary based on groups + requested_perms_dict = {} + for group_name, perms_to_check in permission_groups.items(): + matched = [p for p in perms_to_check if p in manifest_perms] + if matched: + requested_perms_dict[group_name] = matched + + base_context["requested_permissions"] = requested_perms_dict + base_context["TEMP_DIR"] = temp_dir + base_context["APP_DIR"] = app_dir + base_context["REPO_ROOT"] = repo_root + + # 4. Smart Filtering for data_sources (Noise Reduction) + if "data_sources" in base_context: + filtered_sources = {} + for data_type, findings in base_context["data_sources"].items(): + # Deduplicate by File (max 2 per unique file) + file_counts = {} + diverse_findings = [] + for f in findings: + file_path = f.split(":L")[0] if ":L" in f else f + if file_counts.get(file_path, 0) < 2: + diverse_findings.append(f) + file_counts[file_path] = file_counts.get(file_path, 0) + 1 + + # Global Cap (max 3 per data type) + filtered_sources[data_type] = diverse_findings[:3] + base_context["data_sources"] = filtered_sources + + max_evidence_per_worker = 6 + + # Helper to evaluate and save a goal instance + def _save_goal_instance(instance_name, context, template_content): + full_template = template_content + "\n\n" + common_mandates + final_prompt = render_template(full_template, context) + + if "{{ACTIVATE_GOAL}}" in final_prompt: + final_prompt = final_prompt.replace("{{ACTIVATE_GOAL}}", "") + + goal_input_file = os.path.join( + temp_dir, f"input_worker_{instance_name}.json" + ) + _write_json_file(goal_input_file, context) + + output_path = os.path.join(temp_dir, f"prompt_worker_{instance_name}.md") + _write_text_file(output_path, final_prompt) + return True + return False + + # 5. Process Goals Sequentially (Removes Concurrency GIL Overhead & State + # Copies) + for goal_name, goal_conf in goal_map.items(): + template_path = os.path.join( + repo_root, "resources", goal_conf["prompt_file"] + ) + template_content = _read_text_file(template_path) + if not template_content: + continue + + # Identify chunks for this goal + chunks = [] + + if goal_name == "data_safety" and "data_sources" in base_context: + all_evidence = [] + for data_type, findings in base_context["data_sources"].items(): + if not taxonomy_keys or data_type in taxonomy_keys: + for finding in findings: + all_evidence.append((data_type, finding)) + + for i in range(0, len(all_evidence), max_evidence_per_worker): + chunk = all_evidence[i : i + max_evidence_per_worker] + chunk_dict = {} + for dt, finding in chunk: + if dt not in chunk_dict: + chunk_dict[dt] = { + "description": taxonomy.get(dt, {}).get( + "description", "User data type." + ), + "findings": [], + } + chunk_dict[dt]["findings"].append(finding) + chunks.append(chunk_dict) + + if not chunks: + # Single pass for small goals + if _save_goal_instance(goal_name, base_context, template_content): + activated_goals.append(goal_name) + else: + # Multipass for large data safety sets + for idx, chunk_data in enumerate(chunks, 1): + instance_name = f"{goal_name}_part_{idx}" + chunk_context = base_context.copy() + chunk_context["data_sources"] = chunk_data + chunk_context["GOAL_NAME"] = instance_name + if _save_goal_instance(instance_name, chunk_context, template_content): + activated_goals.append(instance_name) + + return activated_goals + + +def run_aggregation(temp_dir, repo_root): + """Aggregates and chunks findings for the Critic.""" + worker_pattern = os.path.join(temp_dir, "worker_*.json") + worker_files = sorted(glob.glob(worker_pattern)) + + all_findings = [] + finding_id_counter = 1 + + for w_file in worker_files: + basename = os.path.basename(w_file) + try: + with open(w_file, "r") as f: + w_data = json.load(f) + findings_list = w_data.get("findings", []) + if isinstance(findings_list, list): + for finding in findings_list: + # Store the source filename so generate_report can associate it + # correctly + finding["worker_file"] = basename + # Assign sequential ID + finding["finding_id"] = str(finding_id_counter) + finding_id_counter += 1 + all_findings.append(finding) + except Exception as e: # pylint: disable=broad-exception-caught + print( + f"Warning: Failed to load worker file {w_file}: {e}", file=sys.stderr + ) + + # Save the master list of all findings + aggregated_path = os.path.join(temp_dir, "aggregated_findings.json") + with open(aggregated_path, "w") as f: + json.dump({"findings": all_findings}, f, indent=4) + + # Filter findings that need Critic evaluation: skip only SUGGESTION findings + critic_findings = [] + for f in all_findings: + severity = str(f.get("severity", "SUGGESTION")).upper().strip() + if severity == "SUGGESTION": + continue + critic_findings.append(f) + + # Chunk the findings for the Critic + chunk_size = 3 + chunks = [ + critic_findings[i : i + chunk_size] + for i in range(0, len(critic_findings), chunk_size) + ] + + # Render Critic template for each chunk + critic_template_path = os.path.join(repo_root, "resources", "critic.md") + critic_template = "" + if os.path.exists(critic_template_path): + with open(critic_template_path, "r") as f: + critic_template = f.read() + + common_mandates_path = os.path.join( + repo_root, "resources", "common_mandates.md" + ) + common_mandates = "" + if os.path.exists(common_mandates_path): + with open(common_mandates_path, "r") as f: + common_mandates = f.read() + + full_template = critic_template + "\n\n" + common_mandates + + # Load contextual values from manifest_details.json + manifest_path = os.path.join(temp_dir, "manifest_details.json") + base_context = {} + if os.path.exists(manifest_path): + try: + with open(manifest_path, "r") as f: + m_details = json.load(f) + base_context["APP_DIR"] = m_details.get("app_dir") + except Exception: # pylint: disable=broad-exception-caught + pass + + if not repo_root: + repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + base_context["TEMP_DIR"] = temp_dir + base_context["REPO_ROOT"] = repo_root + + for idx, chunk in enumerate(chunks, 1): + # Create input_critic_.json + chunk_dict = {f["finding_id"]: f for f in chunk} + chunk_input_path = os.path.join(temp_dir, f"input_critic_{idx}.json") + with open(chunk_input_path, "w") as f: + json.dump(chunk_dict, f, indent=4) + + # Render prompt_critic_.md + chunk_context = base_context.copy() + chunk_context["CHUNK_INDEX"] = str(idx) + + final_prompt = render_template(full_template, chunk_context) + chunk_prompt_path = os.path.join(temp_dir, f"prompt_critic_{idx}.md") + with open(chunk_prompt_path, "w") as f: + f.write(final_prompt) + + return len(chunks) + + +GRADLE_APP_PLUGIN_PATTERN = ( + r"(?:id\s*\(?\s*[\x27\x22]com\.android\.application[\x27\x22]\s*\)?|" + r"apply\s*\(?\s*(?:plugin:\s*)?[\x27\x22]com\.android\.application" + r"[\x27\x22]\s*\)?)(?!\s+apply\s+false)" +) + + +def _resolve_to_int(val, lookups, visited=None): + """Recursively resolves a variable name to an integer value.""" + if visited is None: + visited = set() + + if not val: + return None + + # Clean and normalize the value + val_str = str(val).strip().strip('"').strip("'") + + # Support project.property("key") syntax + prop_match = re.search( + r'project\.property\s*\(\s*[\'"](.*?)[\'"]\s*\)', val_str + ) + if prop_match: + val_str = prop_match.group(1).strip() + + # Robust resolution for Gradle Version Catalogs and dynamic getters + # Strip common accessors and indirection prefixes + val_str = re.sub( + r"\.(?:get|toInt|getOrElse|provider)(?:\s*\([^)]*\))?", "", val_str + ) + if val_str.startswith("libs.versions."): + val_str = val_str[14:] + elif val_str.startswith("libs."): + val_str = val_str[5:] + elif val_str.startswith("versions."): + val_str = val_str[9:] + + if val_str.isdigit(): + return int(val_str) + + if val_str in visited: + return None # Circular reference protection + visited.add(val_str) + + # 1. Direct and Namespaced Lookups + search_keys = [val_str] + if "." in val_str: + # If A.B.C fails, try B.C, then C + parts = val_str.split(".") + for i in range(1, len(parts)): + search_keys.append(".".join(parts[i:])) + + for key in search_keys: + # Try custom versions first (usually has highest signal) + if key in lookups.get("custom", {}): + res = _resolve_to_int(lookups["custom"][key], lookups, visited) + if res is not None: + return res + + # Try gradle properties + if key in lookups.get("properties", {}): + res = _resolve_to_int(lookups["properties"][key], lookups, visited) + if res is not None: + return res + + # Try version catalog + if key in lookups.get("catalog", {}): + res = _resolve_to_int(lookups["catalog"][key], lookups, visited) + if res is not None: + return res + + return None + + +def main(): + """Deterministic context-gathering tool for the Reviewer skill.""" + parser = argparse.ArgumentParser( + description="Deterministic context-gathering and state-query tool." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + # Init subcommand + init_parser = subparsers.add_parser( + "init", help="Initialize scratch environment." + ) + init_parser.add_argument("app_dir", help="Path to the Android application.") + + # Aggregate subcommand + agg_parser = subparsers.add_parser("aggregate", help="Aggregate findings.") + agg_parser.add_argument( + "temp_dir", help="Path to the temporary scratch directory." + ) + + args = parser.parse_args() + + repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + if args.command == "aggregate": + temp_dir = os.path.abspath(args.temp_dir) + if not os.path.isdir(temp_dir): + print( + f"Error: Specified temporary directory does not exist: {temp_dir}", + file=sys.stderr, + ) + sys.exit(1) + + chunk_count = run_aggregation(temp_dir, repo_root) + print(json.dumps({"temp_dir": temp_dir, "critic_chunks": chunk_count})) + sys.exit(0) + + # Initialization Mode + app_dir = os.path.abspath(args.app_dir) + if not os.path.isdir(app_dir): + print(f"Error: {app_dir} is not a valid directory.", file=sys.stderr) + sys.exit(1) + + scratch_id = str(uuid.uuid4()) + workspace_root = os.getcwd() + temp_dir = os.path.join( + workspace_root, ".scratch", f"play_policy_insights_{scratch_id}" + ) + os.makedirs(temp_dir, exist_ok=True) + + # Perform a single master filesystem walk using the scanner + file_inventory = scanner.collect_target_files(app_dir) + + gradle_properties = load_gradle_properties(app_dir) + version_catalog = load_version_catalog(app_dir) + # Pass inventory to avoid redundant walk + custom_gradle_versions = load_custom_gradle_versions(app_dir, file_inventory) + + codebase_map = { + "key_files": { + "manifests": file_inventory["manifests"], + "gradles": [ + g + for g in file_inventory["gradles"] + if g.endswith((".gradle", ".gradle.kts")) + ], + } + } + + # 1. Detect and Filter Flavors (Play Store Prioritization) + detected_flavors = set() + src_dirs = [] + + config = scanner.get_scanner_config() + ignored_dirs = set(config.get("ignored_directories", [])) + + # Find all 'src' directories + for root, dirs, _ in os.walk(app_dir): + dirs[:] = [d for d in dirs if d not in ignored_dirs] + if "src" in dirs: + src_dirs.append(os.path.join(root, "src")) + dirs.remove("src") # Don't recurse deeper for 'src' search + + for s_dir in src_dirs: + for flavor in os.listdir(s_dir): + if ( + os.path.isdir(os.path.join(s_dir, flavor)) + and flavor not in ignored_dirs + ): + detected_flavors.add(flavor) + + prioritized_flavors = [] + if "play" in detected_flavors: + prioritized_flavors = ["main", "play"] + + if prioritized_flavors: + # Filter codebase_map to only include prioritized flavors + print( + f"Prioritizing flavors: {', '.join(prioritized_flavors)}", + file=sys.stderr, + ) + + filtered_files = {} + for key, paths in codebase_map.get("key_files", {}).items(): + filtered_paths = [] + for p in paths: + # Check if path contains any non-prioritized flavor + is_excluded = False + for f in detected_flavors: + if f not in prioritized_flavors and f"src/{f}/" in p: + is_excluded = True + break + if not is_excluded: + filtered_paths.append(p) + filtered_files[key] = filtered_paths + codebase_map["key_files"] = filtered_files + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + # 1. Start the Data Safety Scanner immediately (independent task) + data_safety_scan_file = os.path.join(temp_dir, "data_safety_scan.json") + scanner_future = executor.submit( + run_scanner_direct, app_dir, file_inventory, data_safety_scan_file + ) + + # 2. Extract Application Modules and Primary Identity + app_modules = parse_application_modules( + codebase_map.get("key_files", {}).get("gradles", []), + codebase_map.get("key_files", {}).get("manifests", []), + gradle_properties, + version_catalog, + custom_gradle_versions=custom_gradle_versions, + ) + primary_app_id, primary_target_sdk, primary_app_label = ( + determine_primary_identity( + app_modules, + codebase_map.get("key_files", {}).get("gradles", []), + codebase_map.get("key_files", {}).get("manifests", []), + gradle_properties, + version_catalog, + custom_gradle_versions, + app_dir, + ) + ) + + manifest_content = "" + manifest_details = { + "package_name": primary_app_id, + "app_label": primary_app_label, + "target_sdk": primary_target_sdk, + "app_dir": app_dir, + "permissions": [], + "foreground_services": [], + } + + if codebase_map.get("key_files", {}).get("manifests"): + for _, manifest_path in enumerate(codebase_map["key_files"]["manifests"]): + # Filter Test-Bleed based on relative path + rel_path = os.path.relpath(manifest_path, app_dir) + normalized_rel_path = "/" + rel_path.replace("\\", "/") + if any( + x in normalized_rel_path + for x in [ + "/debug/", + "/test/", + "/androidTest/", + "/testFixtures/", + ] + ): + continue + + if os.path.exists(manifest_path): + try: + with open(manifest_path, "r") as f: + content = f.read() + if not manifest_content: + manifest_content = content + + details = extract_manifest_details(content) + + # Identity Fallbacks + if details.get("package_name") and not manifest_details.get( + "package_name" + ): + manifest_details["package_name"] = details["package_name"] + + if details.get("target_sdk") and not manifest_details.get( + "target_sdk" + ): + manifest_details["target_sdk"] = details["target_sdk"] + + if details.get("app_label") and not manifest_details.get( + "app_label" + ): + manifest_details["app_label"] = details["app_label"] + + manifest_details["permissions"].extend( + details.get("permissions", []) + ) + manifest_details["foreground_services"].extend( + details.get("foreground_services", []) + ) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Warning: Failed to read manifest: {e}", file=sys.stderr) + + manifest_details["permissions"] = sorted( + list(set(manifest_details["permissions"])) + ) + + # Deduplicate and sort foreground services + unique_services = [] + seen_services = set() + for svc in manifest_details["foreground_services"]: + svc_key = (svc.get("name"), svc.get("type")) + if svc_key not in seen_services: + seen_services.add(svc_key) + unique_services.append(svc) + + # Final stable sort + manifest_details["foreground_services"] = sorted( + unique_services, + key=lambda x: (x.get("name") or "", x.get("type") or ""), + ) + + # 3. Gather results + data_safety_scan = scanner_future.result() + + # 5. Post-Scan Flavor Filtering + if prioritized_flavors: + data_safety_scan = _filter_by_flavor( + data_safety_scan, detected_flavors, prioritized_flavors + ) + + # 6. Ingest local Play Store info + local_info_path = os.path.join( + app_dir, "play_store_assets", "play_store_info.json" + ) + if os.path.exists(local_info_path): + try: + with open(local_info_path, "r", encoding="utf-8") as f: + play_store_info = json.load(f) + if "is_published" not in play_store_info: + play_store_info["is_published"] = True + if "store_url" not in play_store_info: + play_store_info["store_url"] = f"file://{local_info_path}" + + with open(os.path.join(temp_dir, "play_store_info.json"), "w") as out_f: + json.dump(play_store_info, out_f, indent=4) + print( + f"Ingested local Play Store info from {local_info_path}", + file=sys.stderr, + ) + except Exception as e: # pylint: disable=broad-exception-caught + print( + f"Warning: Failed to ingest local Play Store info: {e}", + file=sys.stderr, + ) + + reasoning = [] + goal_map = {} + resources_dir = os.path.join(repo_root, "resources") + if os.path.exists(resources_dir): + for f in os.listdir(resources_dir): + if f.startswith("goal_") and f.endswith(".md"): + goal_name = f[5:-3] + goal_map[goal_name] = {"prompt_file": f} + + if prioritized_flavors: + reasoning.append( + f"Prioritizing Play Store flavors: {', '.join(prioritized_flavors)}" + ) + reasoning.append("Activated via template evaluation") + + output_data = { + "app_name": ( + manifest_details.get("app_label") + or os.path.basename(os.path.normpath(app_dir)) + ), + "package_name": manifest_details.get("package_name"), + "target_dir": app_dir, + "codebase_map": codebase_map, + "manifest_content": manifest_content, + "manifest_details": manifest_details, + "application_modules": app_modules, + "data_safety_scan": data_safety_scan, + "triage_summary": generate_triage_summary( + manifest_details, data_safety_scan + ), + "triage_reasoning": "\n".join(reasoning), + "detected_flavors": sorted(list(detected_flavors)), + "prioritized_flavors": prioritized_flavors, + } + + try: + activated_goals = write_agent_prompts( + {"repo_root": repo_root, "temp_dir": temp_dir, "app_dir": app_dir}, + goal_map, + output_data, + ) + print(f"Prompts generated in {temp_dir}", file=sys.stderr) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Warning: Failed to generate prompts: {e}", file=sys.stderr) + + print( + json.dumps( + {"temp_dir": temp_dir, "activated_goals": activated_goals}, indent=4 + ) + ) + + +if __name__ == "__main__": + main() diff --git a/play/play-policy-insights/scripts/play_store_scraper.py b/play/play-policy-insights/scripts/play_store_scraper.py new file mode 100755 index 0000000..bab9bdc --- /dev/null +++ b/play/play-policy-insights/scripts/play_store_scraper.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 + +# Copyright 2026 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Scrapes and parses app metadata and safety info from Play Store.""" + +import html.parser +import json +import os +import ssl +import sys +import urllib.error +import urllib.request + + +def fetch_html(url, verify_ssl=True): + """Fetches HTML content from a URL with a realistic User-Agent.""" + headers = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/119.0.0.0 Safari/537.36" + ), + "Accept-Language": "en-US,en;q=0.9", + } + req = urllib.request.Request(url, headers=headers) + if verify_ssl: + context = ssl.create_default_context() + else: + context = ssl._create_unverified_context() + try: + with urllib.request.urlopen(req, timeout=10, context=context) as resp: + return resp.read().decode("utf-8") + except urllib.error.HTTPError as e: + if e.code == 404: + print(f"App not found on Play Store (404): {url}", file=sys.stderr) + else: + print(f"HTTP Error: {e.code} {e.reason}", file=sys.stderr) + return None + except Exception as e: + print(f"Error fetching {url}: {e}", file=sys.stderr) + return None + + +class PlayStoreMetadataParser(html.parser.HTMLParser): + """Parses core app metadata from Play Store HTML.""" + + def __init__(self): + super().__init__() + self.ld_json = None + self.meta_category = None + self.meta_content_rating = None + self.meta_description = None + self.title_text = None + self.developer_name = None + self.privacy_policy_url = None + + self.in_ld_json = False + self.json_data = [] + self.in_title = False + self.in_dev_link = False + + self.in_a_tag = False + self.current_a_href = None + + def handle_starttag(self, tag, attrs): + attrs_dict = dict(attrs) + + if tag == "script" and attrs_dict.get("type") == "application/ld+json": + self.in_ld_json = True + self.json_data = [] + + if tag == "meta": + itemprop = attrs_dict.get("itemprop") + if itemprop == "applicationCategory": + self.meta_category = attrs_dict.get("content") + elif itemprop == "contentRating": + self.meta_content_rating = attrs_dict.get("content") + + name = attrs_dict.get("name") + prop = attrs_dict.get("property") + if prop == "og:description" or name == "description": + if not self.meta_description: + self.meta_description = attrs_dict.get("content") + + if tag == "h1": + self.in_title = True + + if tag == "a": + href = attrs_dict.get("href", "") + if "/store/apps/dev" in href: + self.in_dev_link = True + + self.in_a_tag = True + self.current_a_href = href + + def handle_endtag(self, tag): + if tag == "script" and self.in_ld_json: + self.in_ld_json = False + try: + self.ld_json = json.loads("".join(self.json_data)) + if isinstance(self.ld_json, list): + self.ld_json = self.ld_json[0] + except (json.JSONDecodeError, TypeError, IndexError): + pass + if tag == "h1": + self.in_title = False + if tag == "a": + self.in_dev_link = False + self.in_a_tag = False + self.current_a_href = None + + def handle_data(self, data): + if self.in_ld_json: + self.json_data.append(data) + elif self.in_title and not self.title_text: + self.title_text = data.strip() + elif self.in_dev_link and not self.developer_name: + self.developer_name = data.strip() + elif self.in_a_tag and self.current_a_href: + if "privacy policy" in data.lower(): + if "google.com" not in self.current_a_href.lower(): + if not self.privacy_policy_url: + self.privacy_policy_url = self.current_a_href + + +class PlayStoreDataSafetyParser(html.parser.HTMLParser): + """Extracts visible text content from HTML structurally.""" + + def __init__(self): + super().__init__() + self.text_parts = [] + self.ignore_tags = {"script", "style", "noscript", "svg", "path"} + self.in_ignored_tag = 0 + + def handle_starttag(self, tag, attrs): + if tag.lower() in self.ignore_tags: + self.in_ignored_tag += 1 + + def handle_endtag(self, tag): + if tag.lower() in self.ignore_tags: + self.in_ignored_tag = max(0, self.in_ignored_tag - 1) + + def handle_data(self, data): + if self.in_ignored_tag == 0: + cleaned = data.strip() + if cleaned: + self.text_parts.append(cleaned) + + +def load_taxonomy(): + """Loads the valid data safety taxonomy from policies.json.""" + repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + policy_path = os.path.join(repo_root, "resources", "policies.json") + try: + with open(policy_path, "r") as f: + data = json.load(f) + taxonomy = data.get("data_safety_section", {}).get("taxonomy", {}) + return { + item["data_type"].lower() + for item in taxonomy.values() + if "data_type" in item + } + except Exception as e: + print( + f"Warning: Failed to load taxonomy from {policy_path}: {e}", + file=sys.stderr, + ) + return set() + + +# Official Play Store Data Safety Taxonomy for validation +VALID_TAXONOMY = load_taxonomy() + + +def extract_metadata(html_content): + """Extracts all metadata into a clean dictionary.""" + metadata = { + "title": "Unknown App", + "developer": "Unknown Developer", + "category": None, + "content_rating": None, + "description": None, + "privacy_policy_url": None, + } + + if not html_content: + return metadata + + parser = PlayStoreMetadataParser() + parser.feed(html_content) + + # 1. Prioritize ld+json payload + if parser.ld_json: + metadata["title"] = parser.ld_json.get("name", metadata["title"]) + + author = parser.ld_json.get("author", {}) + if isinstance(author, dict): + metadata["developer"] = author.get("name", metadata["developer"]) + elif isinstance(author, str): + metadata["developer"] = author + + metadata["category"] = parser.ld_json.get( + "applicationCategory", metadata["category"] + ) + metadata["content_rating"] = parser.ld_json.get( + "contentRating", metadata["content_rating"] + ) + metadata["description"] = parser.ld_json.get( + "description", metadata["description"] + ) + + # 2. Fall back to HTML tags if json was missing elements + if metadata["title"] == "Unknown App" and parser.title_text: + metadata["title"] = parser.title_text + if metadata["developer"] == "Unknown Developer" and parser.developer_name: + metadata["developer"] = parser.developer_name + if not metadata["category"] and parser.meta_category: + metadata["category"] = parser.meta_category + if not metadata["content_rating"] and parser.meta_content_rating: + metadata["content_rating"] = parser.meta_content_rating + if not metadata["description"] and parser.meta_description: + metadata["description"] = parser.meta_description + + # Privacy Policy is only found in tags + metadata["privacy_policy_url"] = parser.privacy_policy_url + + return metadata + + +def extract_data_safety(html_content): + """Extracts Data Safety information structurally.""" + if not html_content: + return {"data_collected": [], "data_shared": []} + + parser = PlayStoreDataSafetyParser() + parser.feed(html_content) + parts = parser.text_parts + + result = {"data_collected": [], "data_shared": []} + + purposes_keywords = [ + "App functionality", + "Analytics", + "Fraud prevention, security, and compliance", + "Personalization", + "Account management", + "Advertising or marketing", + "Developer communications", + ] + + known_categories = { + "Location", + "Personal info", + "Financial info", + "Health and fitness", + "Messages", + "Photos and videos", + "Audio files", + "Files and docs", + "Calendar", + "Contacts", + "App activity", + "Web browsing", + "App info and performance", + "Device or other IDs", + } + + # Robust parsing ignoring UI icons + cur_sec = None + cur_cat = None + cur_type = None + + for p in parts: + p_lower = p.lower() + + # Section Transitions + if ( + p_lower == "data shared" + or p_lower + == "here's more information the developer has provided about the kinds" + " of data this app may share" + ): + cur_sec = "data_shared" + cur_cat = None + continue + elif ( + p_lower == "data collected" + or p_lower + == "here's more information the developer has provided about the kinds" + " of data this app may collect" + ): + cur_sec = "data_collected" + cur_cat = None + continue + elif p_lower == "security practices" or p_lower == "data safety": + if cur_sec: + break + + if not cur_sec: + continue + + # Ignore UI artifacts + if p_lower in [ + "expand_more", + "expand_less", + "info", + "data collected and for what purpose", + "data shared and for what purpose", + ]: + continue + + # If it's a known purpose keyword, attach it to the current type + matched_purposes = [kw for kw in purposes_keywords if kw.lower() in p_lower] + if matched_purposes: + if cur_type: + cur_type["purposes"].extend(matched_purposes) + cur_type["purposes"] = list(set(cur_type["purposes"])) + continue + + if p_lower == "ยท optional": + if cur_type: + cur_type["optional"] = True + continue + + # If it's not a purpose or an artifact, it's either a Category or a + # Data Type + if len(p) > 50: + continue + + if p in known_categories: + if not cur_cat or cur_cat["category"] != p: + cur_cat = {"category": p, "types": []} + result[cur_sec].append(cur_cat) + cur_type = None + continue + + if cur_cat: + if p == cur_cat["category"]: + continue + + cur_type = {"type": p, "purposes": [], "optional": False} + cur_cat["types"].append(cur_type) + + # Post-process to remove summary headers (UI groupings) + for cat in result["data_collected"] + result["data_shared"]: + filtered_types = [] + cat_name = cat["category"].lower() + + for t in cat["types"]: + t_name_lower = t["type"].lower() + + # Indicators that this is a summary header rather than a declaration: + # 1. It has no associated purposes (headers are never assigned purposes) + # 2. AND it either: + # a. Matches the category name exactly (redundant UI artifact) + # b. Is not a valid item in the official taxonomy (summary string) + if not t["purposes"]: + if ( + t_name_lower == cat_name + or t_name_lower not in VALID_TAXONOMY + ): + continue + + filtered_types.append(t) + + cat["types"] = filtered_types + + return result + + +def scrape_app_details(pkg_name, verify_ssl=True): + """Scrapes app metadata and Data Safety info for a given package.""" + details_url = ( + f"https://play.google.com/store/apps/details?id={pkg_name}&hl=en&gl=US" + ) + ds_url = ( + f"https://play.google.com/store/apps/datasafety?id={pkg_name}&hl=en&gl=US" + ) + + details_html = fetch_html(details_url, verify_ssl=verify_ssl) + metadata = extract_metadata(details_html) + + ds_html = fetch_html(ds_url, verify_ssl=verify_ssl) + data_safety = extract_data_safety(ds_html) + + return { + "package_name": pkg_name, + "is_published": bool(details_html), + "store_url": details_url, + "title": metadata["title"], + "developer": metadata["developer"], + "description": metadata["description"], + "category": metadata["category"], + "app_info": {"content_rating": metadata["content_rating"]}, + "developer_links": {"privacy_policy_url": metadata["privacy_policy_url"]}, + "data_safety": data_safety, + } diff --git a/play/play-policy-insights/scripts/scanner.py b/play/play-policy-insights/scripts/scanner.py new file mode 100755 index 0000000..ec5305f --- /dev/null +++ b/play/play-policy-insights/scripts/scanner.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 + +# Copyright 2026 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Scans Android codebase for data safety signals and sensitive API usage.""" + +import concurrent.futures +import functools +import json +import os +import re +import sys + +# Pre-compiled regex patterns at module scope for thread-safe performance +URL_PATTERN = re.compile(r'"(https?://[^"]+)"') +XML_COMMENT_PATTERN = re.compile(r"", flags=re.DOTALL) +COMMENT_STRIP_PATTERN = re.compile( + r'("(?:\\.|[^"\\])*")|//[^\n]*|/\*.*?\*/', + flags=re.DOTALL, +) + + +@functools.lru_cache(maxsize=1) +def get_scanner_config(): + """Loads the scanner configuration from scanner_config.json with caching.""" + config_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "resources", + "scanner_config.json", + ) + try: + with open(config_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + print( + f"Warning: Failed to load scanner config from {config_path}: {e}", + file=sys.stderr, + ) + return {} + + +@functools.lru_cache(maxsize=1) +def get_supported_extensions(): + """Returns a tuple of supported file extensions from config.""" + return tuple(get_scanner_config().get("supported_extensions", [])) + + +def collect_target_files(target_dir): + """Performs a single master walk to categorize files.""" + categorized = { + "manifests": [], + "gradles": [], + "source_code": [], + "all_files": [], + } + + normalized_target = os.path.abspath(target_dir) + hybrid_root = normalized_target + target_parent = os.path.dirname(normalized_target) + + for potential_root in [normalized_target, target_parent]: + if os.path.exists( + os.path.join(potential_root, "package.json") + ) or os.path.exists(os.path.join(potential_root, "pubspec.yaml")): + hybrid_root = potential_root + print( + f"Hybrid app detected. Expanding scan root to: {hybrid_root}", + file=sys.stderr, + ) + break + + config = get_scanner_config() + ignored_dirs = set(config.get("ignored_directories", [])) + supported_exts = get_supported_extensions() + + for root, dirs, files in os.walk(hybrid_root): + dirs[:] = sorted([d for d in dirs if d not in ignored_dirs]) + for file in sorted(files): + file_path = os.path.join(root, file) + categorized["all_files"].append((file, file_path, root)) + + # Surgical filter for XMLs to avoid scanning thousands of low-signal + # assets. + if file.endswith(".xml"): + # Exclude test-bleed XMLs + if not any( + x in file_path.lower() + for x in ["/debug/", "/test/", "/androidtest/", "/testfixtures/"] + ): + if file == "AndroidManifest.xml": + categorized["manifests"].append(file_path) + else: + path_parts = file_path.split(os.sep) + is_valuable_xml = False + for i, part in enumerate(path_parts): + if part in ["res", "resources"] and i + 1 < len(path_parts): + next_part = path_parts[i + 1] + if any( + next_part.startswith(d) + for d in ["layout", "values", "xml", "navigation"] + ): + is_valuable_xml = True + break + if is_valuable_xml: + categorized["source_code"].append(file_path) + + elif file.lower().endswith((".gradle", ".gradle.kts")): + categorized["gradles"].append(file_path) + + elif file.lower().endswith(supported_exts) or ( + file.lower() == "pubspec.yaml" or file.lower() == "package.json" + ): + # Filter (Gap 3 - OS-Specific Path Separators and Test-Bleed based on + # filename/path) + file_lower = file.lower() + is_test_or_debug = ( + any( + x in file_path.lower() + for x in [ + "/debug/", + "/test/", + "/androidtest/", + "/testfixtures/", + ".spec.js", + ".spec.ts", + ".spec.jsx", + ".spec.tsx", + ] + ) + or file_lower.endswith("_test.dart") + ) + if not is_test_or_debug: + categorized["source_code"].append(file_path) + + return categorized + + +def _identify_semantic_files(all_files): + """Maps files to semantic categories based on filename patterns.""" + semantic_files = {} + file_patterns = get_scanner_config().get("file_patterns", {}) + supported_exts = get_supported_extensions() + + for file, file_path, root in all_files: + file_lower = file.lower() + # Check if it's a relevant source/config file + if file_lower.endswith(supported_exts + (".xml",)): + rel_path = os.path.relpath(file_path, root) + for category, patterns in file_patterns.items(): + if any(p in file_lower for p in patterns): + if category not in semantic_files: + semantic_files[category] = [] + semantic_files[category].append(rel_path) + + return semantic_files + + +def _scan_single_file(file_path, all_signal_categories, target_dir): + """Internal worker to scan a single file for all signal categories.""" + local_api_usage = {} + found_urls = [] + supported_exts = get_supported_extensions() + + try: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: + content = f.read() + + # URLs - with quick check for policy relatedness + policy_url_keywords = ["privacy", "support", "delete", "terms", "policy"] + for match in URL_PATTERN.finditer(content): + url = match.group(1) + if any(kw in url.lower() for kw in policy_url_keywords): + found_urls.append(url) + + # Cleanup comments based on file type + file_path_lower = file_path.lower() + if file_path_lower.endswith(".xml"): + content = XML_COMMENT_PATTERN.sub("", content) + elif file_path_lower.endswith(supported_exts): + content = COMMENT_STRIP_PATTERN.sub( + lambda m: m.group(1) if m.group(1) else "", content + ) + + # Signal Patterns + for cat_name, signals in all_signal_categories.items(): + for signal_id, patterns in signals.items(): + for p in patterns: + if p in content: + if cat_name not in local_api_usage: + local_api_usage[cat_name] = {} + if signal_id not in local_api_usage[cat_name]: + local_api_usage[cat_name][signal_id] = [] + + rel_path = os.path.relpath(file_path, target_dir) + local_api_usage[cat_name][signal_id].append( + f"{rel_path} (Pattern: {p})" + ) + break # Move to next signal once pattern found in file + + except Exception: # pylint: disable=broad-exception-caught + pass + + return {"api_usage": local_api_usage, "urls": found_urls} + + +def perform_scan(target_dir, file_inventory): + """Orchestrates the parallel scanning of identified files.""" + api_usage = {} + all_urls = [] + all_signal_categories = get_scanner_config().get("signal_categories", {}) + + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor: + futures = [ + executor.submit( + _scan_single_file, f, all_signal_categories, target_dir + ) + for f in file_inventory.get("source_code", []) + ] + + for future in concurrent.futures.as_completed(futures): + res = future.result() + for cat_name, cat_findings in res["api_usage"].items(): + if cat_name not in api_usage: + api_usage[cat_name] = {} + for signal_id, findings in cat_findings.items(): + if signal_id not in api_usage[cat_name]: + api_usage[cat_name][signal_id] = [] + api_usage[cat_name][signal_id].extend(findings) + + for u in res["urls"]: + all_urls.append(u) + + # Final cleanup and formatting + for cat_name in api_usage: + for signal_id in api_usage[cat_name]: + api_usage[cat_name][signal_id] = sorted( + list(set(api_usage[cat_name][signal_id])) + ) + + return { + "data_safety_scan": api_usage, + "found_urls": sorted(list(set(all_urls))), + "semantic_files": _identify_semantic_files( + file_inventory.get("all_files", []) + ), + "codebase_map": file_inventory, + } diff --git a/play/play-policy-insights/scripts/template_engine.py b/play/play-policy-insights/scripts/template_engine.py new file mode 100644 index 0000000..1c4fccb --- /dev/null +++ b/play/play-policy-insights/scripts/template_engine.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 + +# Copyright 2026 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Small logic-less template engine for Markdown generation.""" + +import re + +# Precompiled regex patterns at module scope for linear-time optimizations +TAG_PATTERN = re.compile(r"\{\{(#|/)?(IF_ANY|IF_ALL|IF|EACH)?\s*(.*?)\}\}") +NEWLINE_COLLAPSE_PATTERN = re.compile(r"\n{3,}") +VAR_VALID_PATTERN = re.compile(r"^[A-Za-z0-9_.]+$") + + +def get_nested_val(ctx, key): + """Resolves nested dot-notation keys from context (e.g., app.info.package).""" + parts = key.split(".") + val = ctx + for p in parts: + if isinstance(val, dict): + val = val.get(p) + else: + return None + return val + + +class Node: + """Base class for all Abstract Syntax Tree (AST) node elements.""" + + def render(self, ctx, buffer): + raise NotImplementedError + + +class TextNode(Node): + """Represents raw static text blocks.""" + + def __init__(self, text): + self.text = text + + def render(self, ctx, buffer): + buffer.append(self.text) + + +class VarNode(Node): + """Represents dynamic variable interpolation with literal fallbacks.""" + + def __init__(self, key, raw_text): + self.key = key + self.raw_text = raw_text + + def render(self, ctx, buffer): + val = get_nested_val(ctx, self.key) + if val is not None: + buffer.append(str(val)) + else: + # Preserve the unresolved tag exactly like the original engine + buffer.append(self.raw_text) + + +class IfNode(Node): + """Represents conditional block logic (IF, IF_ALL, IF_ANY).""" + + def __init__(self, keys_str, mode, children): + self.keys = [k.strip() for k in keys_str.split(",")] + self.mode = mode + self.children = children + + def render(self, ctx, buffer): + condition_met = False + if self.mode == "IF": + condition_met = bool(get_nested_val(ctx, self.keys[0])) + elif self.mode == "IF_ANY": + condition_met = any(get_nested_val(ctx, k) for k in self.keys) + elif self.mode == "IF_ALL": + condition_met = all(get_nested_val(ctx, k) for k in self.keys) + + if condition_met: + for child in self.children: + child.render(ctx, buffer) + + +def strip_nodes(nodes): + """Helper to strip leading/trailing whitespace from first/last TextNodes.""" + if not nodes: + return nodes + + # Strip leading whitespace from first TextNode + if isinstance(nodes[0], TextNode): + nodes[0].text = nodes[0].text.lstrip() + if not nodes[0].text: + nodes.pop(0) + + # Strip trailing whitespace from last TextNode + if nodes and isinstance(nodes[-1], TextNode): + nodes[-1].text = nodes[-1].text.rstrip() + if not nodes[-1].text: + nodes.pop() + + return nodes + + +class EachNode(Node): + """Represents standard iteration logic {{#EACH}} over lists/dicts.""" + + def __init__(self, key, children): + self.key = key + self.children = strip_nodes(children) + + def _render_children(self, local_ctx): + """Helper to render nested children under a specific loop context.""" + sub_buffer = [] + for child in self.children: + child.render(local_ctx, sub_buffer) + return "".join(sub_buffer) + + def render(self, ctx, buffer): + val = get_nested_val(ctx, self.key) + if not val: + return + + rendered_items = [] + if isinstance(val, dict): + for k, v in val.items(): + if k == "-": + continue + new_ctx = ctx.copy() + new_ctx["KEY"] = str(k) + new_ctx["VALUE"] = v + rendered_items.append(self._render_children(new_ctx)) + + elif isinstance(val, list): + for item in val: + new_ctx = ctx.copy() + new_ctx["ITEM"] = item + rendered_items.append(self._render_children(new_ctx)) + + # Join iterations together with single newline + buffer.append("\n".join(rendered_items)) + + +class _ParserFrame: + """Helper class representing an active stack frame in the compiler.""" + + def __init__(self, tag_type, children, match): + self.tag_type = tag_type + self.children = children + self.match = match + + +def compile_template(template_str): + """Parses template_str forward-only, building the AST Node tree.""" + root_children = [] + stack = [_ParserFrame(None, root_children, None)] + + pos = 0 + for match in TAG_PATTERN.finditer(template_str): + start, end = match.span() + + # 1. Capture static text prior to the tag + if start > pos: + stack[-1].children.append(TextNode(template_str[pos:start])) + + prefix = match.group(1) + tag_type = match.group(2) + arg = match.group(3).strip() if match.group(3) else "" + raw_tag = match.group(0) + + if prefix == "#": + if tag_type: + # Pushing a new nested frame block onto the stack + new_children = [] + stack.append(_ParserFrame(tag_type, new_children, match)) + else: + # Invalid tag starting with #, treat as normal text block + stack[-1].children.append(TextNode(raw_tag)) + + elif prefix == "/": + if tag_type: + # Block closer tag + if len(stack) > 1 and stack[-1].tag_type == tag_type: + frame = stack.pop() + open_arg = frame.match.group(3).strip() + + if frame.tag_type == "EACH": + node = EachNode(open_arg, frame.children) + else: + node = IfNode(open_arg, frame.tag_type, frame.children) + + stack[-1].children.append(node) + else: + # Mismatched closer, treat as static text + stack[-1].children.append(TextNode(raw_tag)) + else: + # Invalid closer, treat as static text + stack[-1].children.append(TextNode(raw_tag)) + + else: + # Variable tag or unknown braces + if VAR_VALID_PATTERN.match(arg): + stack[-1].children.append(VarNode(arg, raw_tag)) + else: + stack[-1].children.append(TextNode(raw_tag)) + + pos = end + + # Capture any trailing text blocks + if pos < len(template_str): + stack[-1].children.append(TextNode(template_str[pos:])) + + # Graceful recovery for unclosed tags to prevent runtime crashes + while len(stack) > 1: + frame = stack.pop() + # Re-inject the unclosed opening tag as plain text, followed by its children + stack[-1].children.append(TextNode(frame.match.group(0))) + stack[-1].children.extend(frame.children) + + return root_children + + +def render_template(template_str, context_dict): + """Renders a markdown template with high performance and zero dependencies.""" + nodes = compile_template(template_str) + + buffer = [] + for node in nodes: + node.render(context_dict, buffer) + + final_string = "".join(buffer) + + # Collapse 3 or more consecutive newlines into exactly 2 (preserves layout) + return NEWLINE_COLLAPSE_PATTERN.sub("\n\n", final_string)