Updates skills (2026-07-10 16:33)

This commit is contained in:
android-devrel-github-bot
2026-07-10 16:33:36 +00:00
parent aaf42b970f
commit 57ff3c7d02
14 changed files with 5289 additions and 0 deletions
+169
View File
@@ -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_<uuid>`. **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., `<temp_dir>/worker_<goal_name>.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 <repo_root>/scripts/orchestrator.py init <app_dir>`.
- 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
<temp_dir>/prompt_worker_<goal_name>.md and execute. MANDATORY: You must
use your file-writing capabilities to save your final JSON findings directly
to the file system at <temp_dir>/worker_<goal_name>.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
`<temp_dir>/worker_<goal_name>.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 <repo_root>/scripts/orchestrator.py aggregate <temp_dir>`. 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 <temp_dir>/prompt_critic_<i>.md and execute. MANDATORY: You must use your file-writing capabilities to save your final JSON findings directly to the file system at <temp_dir>/critic_output_<i>.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 `<temp_dir>/critic_output_<i>.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 `<temp_dir>/prompt_worker_<goal_name>.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 `<temp_dir>/worker_<goal_name>.json`. **Do not**
summarize findings in your thoughts or chat; move to the next task.
- **Validate**: Confirm `<temp_dir>/worker_<goal_name>.json` exists before
moving to the next goal.
2. **Aggregate Findings**: Execute the python aggregation command:
`python3 <repo_root>/scripts/orchestrator.py --aggregate <temp_dir>`.
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 `<temp_dir>/prompt_critic_<i>.md`.
- Execute the steps yourself and save your findings to
`<temp_dir>/critic_output_<i>.json`.
- **Validate**: Confirm `<temp_dir>/critic_output_<i>.json` exists before
moving to the next chunk.
4. **Proceed to Finalization** (Step 4 below)
#### Finalization (Both Modes)
4. **Present findings**: Run `python3 <repo_root>/scripts/generate_report.py <temp_dir>`.
It will produce `<temp_dir>/compliance_report.md`. Present this output file to user.
5. **STOP**: The audit is complete. Await further instructions.
@@ -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.
@@ -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. |
<br>
| 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. |
@@ -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"
}
}
```
@@ -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
}
]
}
```
@@ -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 `<intent-filter>` 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 `<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" ...>` tag. | `CRITICAL` | Add the `<property>` 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 `<property>` 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 `<queries>` 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"
}
]
}
```
@@ -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:<br>1. **Reviewer Demo Credentials**: Submit active, non-expiring test credentials so Google Play reviewers can access your gated features.<br>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"
}
]
}
```
@@ -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 Androids 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 Androids 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 users email address."
},
"ADDRESS": {
"category": "Personal info",
"data_type": "Address",
"description": "A users address, such as a mailing or home address."
},
"PHONE": {
"category": "Personal info",
"data_type": "Phone number",
"description": "A users phone number."
},
"RACE_ETHNICITY": {
"category": "Personal info",
"data_type": "Race and ethnicity",
"description": "Information about a users race or ethnicity."
},
"POLITICAL_RELIGIOUS_BELIEFS": {
"category": "Personal info",
"data_type": "Political or religious beliefs",
"description": "Information about a users political or religious beliefs."
},
"SEXUAL_ORIENTATION": {
"category": "Personal info",
"data_type": "Sexual orientation",
"description": "Information about a users 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 users 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 users 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 users 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 users 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 users photos."
},
"VIDEOS": {
"category": "Photos and videos",
"data_type": "Videos",
"description": "A users videos."
},
"AUDIO": {
"category": "Audio files",
"data_type": "Voice or sound recordings",
"description": "A users voice such as a voicemail or a sound recording."
},
"MUSIC": {
"category": "Audio files",
"data_type": "Music files",
"description": "A users 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 users 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 users calendar such as events, event notes, and attendees."
},
"CONTACTS": {
"category": "Contacts",
"data_type": "Contacts",
"description": "Information about the users 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"
]
}
}
@@ -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"
]
}
+860
View File
@@ -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", "<br>")
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()
File diff suppressed because it is too large Load Diff
+410
View File
@@ -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 <a> 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,
}
+259
View File
@@ -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,
}
@@ -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)