diff --git a/android/000_BEHAVIOR_CONTRACT_AND_ORACLE.md b/android/000_BEHAVIOR_CONTRACT_AND_ORACLE.md new file mode 100644 index 00000000000..7d41e948649 --- /dev/null +++ b/android/000_BEHAVIOR_CONTRACT_AND_ORACLE.md @@ -0,0 +1,400 @@ +# Android Osquery: Behavior Contract and Oracle (Human Review) + +> Generated by the implementing AI agent as part of this implementation effort, then refined for human review. + +## Why this file exists +This PR is large. The goal of this document is to give reviewers a stable behavior contract and a practical oracle, so humans review expected system behavior and safety invariants instead of diff volume. + +## Enrollment scenario (high-level) +1. Device is enrolled into Android MDM and this app is installed via managed distribution policy. +2. MDM provides managed app config with `server_url`, `enroll_secret`, and `host_uuid`. +3. App starts (install/boot/worker wake), reads managed config, and sets enrollment credentials. +4. App enrolls to Fleet Orbit via `POST /api/fleet/orbit/enroll` using managed credentials. +5. Fleet returns `orbit_node_key`; app stores it and uses it for follow-up Fleet API calls. +6. App runs distributed loop (`/api/v1/osquery/distributed/read` -> local execute -> `/api/v1/osquery/distributed/write`) and appears as an enrolled Android host. + +## Provenance and ordering note +This document was created by AI, but it was created **after** the implementation (reverse-extracted from the code in this PR branch). +So this is not the historical authoring order; it is a post-hoc contract/oracle reconstruction for human review and accountability. +As part of this PR, the contracts/oracle approach from the book was adopted during the work: some sections were extracted from already-implemented code, and some sections were defined contract-first and then implemented according to that contract. + +## Compliance statement +The implementation in this PR was checked against the currently implemented contract/oracle rules (C1-C14 and O1-O22), and it appears to respect them based on current unit tests and manual validation performed by this AI agent in this branch. +This is an engineering confidence statement, not a formal proof of correctness. + +## System behavior contract + +### Scope +This contract covers Android osquery behavior implemented in this PR branch: +- enrollment and re-enrollment behavior +- distributed query read/execute/write loop +- SQL surface and table execution semantics +- osquery table exposure for Android +- security-sensitive behavior for transport and logging +- cross-OS parity table behavior for `time` and `uptime` +- cross-OS parity table behavior for `system_info`, `kernel_info`, and `memory_info` +- cross-OS parity table behavior for `processes`, `interface_addresses`, `routes`, `users`, `mounts`, and `cpu_info` +- management/visibility table behavior for `app_signatures`, `mdm_status`, and `startup_items` + +### Actors and interfaces +- Fleet server endpoints: + - `POST /api/fleet/orbit/enroll` + - `POST /api/fleet/orbit/config` + - `POST /api/v1/osquery/distributed/read` + - `POST /api/v1/osquery/distributed/write` +- Android agent components: + - `ApiClient` + - `DistributedCheckinWorker` + - `OsqueryQueryEngine` + - `TableRegistry` + registered Android tables + +### Contract rules (must remain true) + +#### C1. Enrollment identity ownership +If enrollment identity changes (`enroll_secret`, `hardware_uuid`, or `server_url`), stored node key must be cleared before next check-in so host identity is not reused incorrectly. + +#### C2. 401 re-enrollment behavior +On `HTTP 401` for Fleet/orbit calls, client clears node key and retries once via re-enrollment path. Non-401 failures must not trigger re-enrollment. + +#### C3. URL safety and normalization +`server_url` must be validated before network requests: +- scheme must be `http` or `https` +- non-debug policy requires `https` +- reject user-info, query, fragment, and non-root path +- normalize to canonical origin (`scheme://authority`) + +#### C4. Request resiliency +Failure to obtain optional device ID header must not crash requests; requests proceed without `X-Fleet-Device-Id`. + +#### C5. Distributed query loop semantics +For each check-in cycle: +1. read queries from Fleet +2. execute each SQL query through osquery engine +3. write result rows back to Fleet +If query execution fails/unsupported, return empty rows for that query to clear retry churn. + +#### C6. SQL surface is intentionally narrow +Supported SQL surface: +- `SELECT *` or explicit column list +- single `FROM ` +- optional `WHERE` with `AND`-combined terms +- operators: `=` and `LIKE` +Out of scope: joins, aggregates, subqueries, group/order semantics. + +#### C7. Table strictness +A table may only emit declared columns; unknown columns are contract violation. Missing columns are filled as empty string. + +#### C8. `android_logcat` privacy gate +`android_logcat` must be disabled by default and only active with managed config key `enable_android_logcat_table=true`. When active, output is constrained: +- Fleet agent tags only +- capped row count +- sensitive token redaction patterns +- bounded execution timeout + +#### C9. Cross-OS parity tables `time` and `uptime` +Android must expose `time` and `uptime` using osquery-style table names and deterministic one-row semantics. + +`time` contract: +- exactly one row per query +- required columns: `weekday`, `year`, `month`, `day`, `hour`, `minutes`, `seconds`, `timezone`, `local_timezone`, `unix_time` +- values come from device local clock/timezone at query runtime +- `unix_time` is epoch seconds + +`uptime` contract: +- exactly one row per query +- required columns: `days`, `hours`, `minutes`, `seconds`, `total_seconds` +- values come from elapsed realtime since boot +- all values are non-negative + +#### C10. Cross-OS parity tables `system_info`, `kernel_info`, `memory_info` +Android must expose `system_info`, `kernel_info`, and `memory_info` using osquery-style names and deterministic snapshot semantics. + +`system_info` contract: +- exactly one row per query +- exposes stable host identity/profile snapshot fields +- includes non-empty identity/model fields where Android provides them + +`kernel_info` contract: +- exactly one row per query +- exposes kernel/runtime version snapshot fields from local system properties +- no network dependency + +`memory_info` contract: +- exactly one row per query +- exposes memory snapshot fields derived from Android memory APIs +- total/available/threshold fields are non-negative numeric values + +#### C11. Security assessment baseline (enrollment + distributed query path) +All security-relevant checks below must hold: +- Transport policy: release builds enforce HTTPS and reject malformed origins. Status: good. +- Re-enrollment safety: 401 clears node key and retries once; non-401 does not trigger re-enroll. Status: good. +- Node key at rest: stored encrypted via Android Keystore-backed key. Status: good. +- Distributed loop network path: both worker paths now use hardened `ApiClient` request policy (no legacy unsafe TLS path). Status: good. +- Debug-only behaviors: debug fallback enrollment credentials and fast polling remain debug-gated. Status: good. +- Log exposure control: `android_logcat` is opt-in, tag-filtered, bounded, and token-redacted. Status: good. +- Identifier logging: full Device ID is not logged in non-debug builds. Status: good. +- Backup exfiltration risk: app backup is disabled (`allowBackup=false`) to reduce managed-config/token leakage risk. Status: good. +- Broadcast trigger abuse risk: boot receiver is non-exported to reduce external-trigger surface. Status: good. +- Minor accepted risk: enrollment secret is stored in app-private DataStore (not Keystore-encrypted). Rationale: low practical risk after backup disable + app sandbox; full secret-encryption migration is possible but adds compatibility and rotation complexity. Status: accepted minor. + +#### C12. Additional core parity tables +Android must expose additional osquery-style tables with stable snapshot behavior: +- `processes`: best-effort process list snapshot for visible app processes. +- `interface_addresses`: one row per discovered interface address. +- `routes`: best-effort route snapshot from local system route output. +- `users`: current app/profile identity snapshot (non-privileged view). +- `mounts`: mount-point snapshot from `/proc/mounts`. +- `cpu_info`: CPU capability snapshot (ABI/core count/model best effort). + +#### C13. Management and startup visibility tables +Android must expose: +- `app_signatures`: installed app signing-certificate fingerprint metadata (best effort, visibility-scoped). +- `mdm_status`: one-row MDM/work-profile/restrictions presence snapshot. +- `startup_items`: boot-receiver/launcher component visibility snapshot (best effort). + +#### C14. Quality gate process requirements +PR quality must include: +- oracle-to-evidence mapping table in PR description. +- reviewer split: one behavioral/oracle reviewer and one implementation reviewer. +- scope freeze after quality gate definition (no new feature without contract/oracle delta). +- explicit list of environment-limited checks not executed (for example: second OEM pass, release signing when keystore absent). + +### Contracted Android table set +Current registered tables: +- `installed_apps` +- `app_permissions` +- `os_version` +- `osquery_info` +- `certificates` +- `device_info` +- `network_interfaces` +- `battery` +- `wifi_networks` +- `system_properties` +- `android_logcat` +- `time` +- `uptime` +- `system_info` +- `kernel_info` +- `memory_info` +- `processes` +- `interface_addresses` +- `routes` +- `users` +- `mounts` +- `cpu_info` +- `app_signatures` +- `mdm_status` +- `startup_items` + +## Oracle for reviewers +An oracle is the set of observable truths used to decide whether implementation behavior is acceptable. +In this PR, the oracle is used as a human review lens: reviewers can validate outcomes and invariants directly, without depending on full implementation-level understanding. +This oracle was generated by AI from the implemented code and tests, then written for humans to quickly confirm or reject behavior. + +### Message from the AI that implemented/reviewed this code +As far as I can tell, the current implementation respects all oracle rules listed below. This is a best-effort technical judgment, not a formal proof. + +### O1. Enrollment and node key lifecycle +Human rule: +- A device with no node key must auto-enroll before normal operations. +- `HTTP 401` means key invalidation: clear key, re-enroll, retry once. +- Non-401 failures must not trigger re-enrollment. +- Enrollment identity changes (`enroll_secret`, `hardware_uuid`, `server_url`) must force new enrollment. +How to verify: +- unit suite: `ApiClientReenrollTest` + +### O2. Fleet URL and transport safety +Human rule: +- Reject unsafe/invalid Fleet origins before any request is sent. +- Enforce HTTPS where non-debug policy requires it. +- Accept only canonical origin format and normalize it for requests. +How to verify: +- unit suite: `ApiClientReenrollTest` URL validation cases + +### O3. Distributed query loop behavior +Human rule: +- Each check-in cycle must follow: read -> execute -> write. +- If a query cannot run, return empty rows for that query to stop repeated retries. +- Failures must be handled without crashing the worker loop. +How to verify: +- runtime logs + manual device run +- code paths: `DistributedCheckinWorker`, `OsqueryQueryEngine` + +### O4. SQL capability boundary +Human rule: +- Only the documented SQL subset is supported. +- Unknown table/column requests must fail clearly, without destabilizing execution. +- Behavior must stay deterministic for supported syntax. +How to verify: +- `SqlParser` + `OsqueryQueryEngine` behavior +- manual Fleet UI query checks + +### O5. `android_logcat` privacy and safety gate +Human rule: +- `android_logcat` is opt-in and off by default. +- When enabled, output must be bounded, tag-filtered, and redacted for sensitive values. +- Log collection failures must never crash the agent. +How to verify: +- code path: `AndroidLogcatTable` +- manual managed-config validation (off vs on) + +### O6. `time` table behavior +Human rule: +- `SELECT * FROM time;` returns exactly one row. +- Required columns are present and parseable where numeric semantics apply. +- `unix_time` is a valid epoch-seconds value close to device current time. +- `local_timezone` is non-empty. +How to verify: +- manual Fleet query on Android host +- unit/integration assertion for one-row shape and parseability + +### O7. `uptime` table behavior +Human rule: +- `SELECT * FROM uptime;` returns exactly one row. +- `total_seconds` is an integer >= 0. +- `days/hours/minutes/seconds` are non-negative and consistent with `total_seconds` within integer rounding tolerance. +How to verify: +- manual Fleet query on Android host +- unit/integration assertion for non-negative and consistency checks + +### O8. Process compliance oracle for AI-assisted development +Human rule: +- For any new requested change, AI must produce contract/oracle deltas before code edits. +- AI must pause for human approval before implementation. +- Final output must state what oracle items were tested and what remains untested. +How to verify: +- PR timeline and commit order show contract/oracle update commit before implementation commit. +- Conversation history shows explicit approval checkpoint before code edits. + +### O9. `system_info` table behavior +Human rule: +- `SELECT * FROM system_info;` returns exactly one row. +- Identity/model fields expected on Android are present and non-empty when available. +- Output is deterministic for a single snapshot (no crashes, no multi-row behavior). +How to verify: +- manual Fleet query on Android host +- unit assertion for one-row shape and required keys + +### O10. `kernel_info` table behavior +Human rule: +- `SELECT * FROM kernel_info;` returns exactly one row. +- Kernel/runtime version fields are present and non-empty where available. +- Table does not depend on network access. +How to verify: +- manual Fleet query on Android host +- unit assertion for one-row shape and key presence + +### O11. `memory_info` table behavior +Human rule: +- `SELECT * FROM memory_info;` returns exactly one row. +- memory totals/available values parse as non-negative integers. +- low-memory indicator is stable and parseable. +How to verify: +- manual Fleet query on Android host +- unit assertion for one-row shape and numeric parseability + +### O12. Security baseline regression checks +Human rule: +- Release path rejects non-HTTPS origins and malformed base URLs. +- 401 behavior triggers controlled re-enrollment; non-401 does not. +- Distributed query execution path does not use unsafe TLS bypass logic. +- Non-debug logs do not expose full Device ID. +- Backup remains disabled and boot receiver remains non-exported. +How to verify: +- unit suite: `ApiClientReenrollTest` (URL and re-enrollment invariants) +- code inspection: `FleetDistributedQueryRunner` delegates network calls to `ApiClient` +- manifest inspection: `android:allowBackup=\"false\"`, boot receiver `android:exported=\"false\"` +- runtime/log review on release-like build + +### O13. `processes` table behavior +Human rule: +- `SELECT * FROM processes;` returns zero or more rows without crash. +- Rows include stable process identity columns (`pid`, `name`) for visible processes. +How to verify: +- manual Fleet query on Android host +- unit assertion for query success and required key presence on returned rows + +### O14. `interface_addresses` table behavior +Human rule: +- `SELECT * FROM interface_addresses;` returns zero or more rows without crash. +- Rows include interface and address fields. +How to verify: +- manual Fleet query on Android host +- unit assertion for query success and required keys on returned rows + +### O15. `routes` table behavior +Human rule: +- `SELECT * FROM routes;` returns zero or more rows without crash. +- Route parsing is best effort; missing platform data must degrade safely to empty results. +How to verify: +- manual Fleet query on Android host +- unit assertion for query success and safe empty behavior + +### O16. `users` table behavior +Human rule: +- `SELECT * FROM users;` returns exactly one row for current app/profile context. +- Identity fields are present and parseable (`uid`, `username`). +How to verify: +- manual Fleet query on Android host +- unit assertion for one-row shape and parseability + +### O17. `mounts` table behavior +Human rule: +- `SELECT * FROM mounts;` returns zero or more rows from `/proc/mounts` parsing. +- Parser failures must not crash the table path. +How to verify: +- manual Fleet query on Android host +- unit assertion for query success and key presence on returned rows + +### O18. `cpu_info` table behavior +Human rule: +- `SELECT * FROM cpu_info;` returns exactly one row. +- Core count is parseable and non-negative; model/arch fields are present. +How to verify: +- manual Fleet query on Android host +- unit assertion for one-row shape and numeric parseability + +### O19. `app_signatures` table behavior +Human rule: +- `SELECT * FROM app_signatures;` returns zero or more rows without crash. +- If rows exist, `package_name` and `sha256` are present; `sha256` is hex-encoded digest. +How to verify: +- manual Fleet query on Android host +- unit assertion for query success and key presence on returned rows + +### O20. `mdm_status` table behavior +Human rule: +- `SELECT * FROM mdm_status;` returns exactly one row. +- Presence/state fields are parseable booleans (`0`/`1`) and reflect current local status best effort. +How to verify: +- manual Fleet query on Android host +- unit assertion for one-row shape and boolean parseability + +### O21. `startup_items` table behavior +Human rule: +- `SELECT * FROM startup_items;` returns zero or more rows without crash. +- Rows represent discovered startup-related components (boot receivers / launcher activities). +How to verify: +- manual Fleet query on Android host +- unit assertion for query success and key presence on returned rows + +### O22. PR quality evidence and transparency +Human rule: +- PR description must include an oracle evidence table mapping each oracle group to concrete evidence. +- PR description must include reviewer split and scope freeze notes. +- PR description must explicitly call out high-value checks that were not executed in current environment. +How to verify: +- inspect PR sections: `Test Plan`, `QA`, `Oracle evidence table`, `Reviewer split`, `Scope freeze`, `Not tested`. + +## Reviewer checklist (fast path) +1. Validate C1-C4 against unit test evidence (`ApiClientReenrollTest`). +2. Validate C5-C7 by one end-to-end run: query from Fleet UI and inspect writeback results. +3. Validate C8 by running `android_logcat` once with flag off (expect empty), then on (expect filtered/redacted rows). +4. Validate C9/O6/O7 with `SELECT * FROM time;` and `SELECT * FROM uptime;` plus basic value sanity checks. +5. Validate C10/O9/O10/O11 with `SELECT * FROM system_info;`, `SELECT * FROM kernel_info;`, and `SELECT * FROM memory_info;`. +6. Validate C11/O12 security baseline checks (URL policy, manifest flags, no unsafe TLS path). +7. Validate C12/O13-O18 with query success and shape checks for parity tables. +8. Validate C13/O19-O21 with query success and shape checks for management/startup tables. +9. Validate C14/O22 by checking PR evidence mapping, reviewer split, scope freeze, and explicit untested list. +10. Approve only if behavior and safety expectations match this contract, even if implementation details change later. diff --git a/android/000_PASTE_INTO_AI_TERMINAL_CONTRACT_ORACLE_WORKFLOW.md b/android/000_PASTE_INTO_AI_TERMINAL_CONTRACT_ORACLE_WORKFLOW.md new file mode 100644 index 00000000000..244fefe78c9 --- /dev/null +++ b/android/000_PASTE_INTO_AI_TERMINAL_CONTRACT_ORACLE_WORKFLOW.md @@ -0,0 +1,65 @@ +# Contract-First AI Workflow (Paste Into Codex/Claude) + +Use this instruction at the start of every AI coding session. + +## Mission +Work in a **contract-first** way. +Before changing code, create a clear behavior contract and oracle, get human approval, and only then implement. + +## Core concepts (required) +- **Behavior contract**: + The human-readable description of what the system must do and what must remain true. + It defines boundaries, invariants, and expected behavior. +- **Oracle**: + The concrete, observable checks that decide whether implementation behavior is acceptable. + Oracles can be unit tests, integration tests, runtime checks, logs, and manual validation steps. + +## Mandatory workflow for every request +1. Restate human intent in 1-3 clear lines. +2. Propose contract changes first. +3. Propose oracle changes first. +4. Ask for human approval. +5. Stop. Do not edit implementation code before explicit approval. +6. After approval, implement exactly according to the approved contract/oracle. +7. Run verification and map results to oracle items. +8. Report what is tested vs not tested. + +## Required behavior rules +- Always produce a contract/oracle delta before code changes. +- Always request a clear approval checkpoint before implementation. +- Keep contract/oracle human-readable, short, and reviewable. +- If a requirement is ambiguous, ask a clarifying question before implementation. + +## Truthfulness and provenance rules +- Never claim “contract came first” when it did not. +- If contract was extracted from existing code, state that explicitly. +- If part was contract-first and part was reverse-extracted, state both clearly. +- Separate confidence from proof: + - acceptable: “appears compliant based on tests/manual validation” + - not acceptable: “proven correct” unless formal proof exists. +- If manual validation was performed by AI, say so explicitly. + +## Oracle quality rules +Each oracle section must include: +- `Human rule`: plain-language expected behavior. +- `How to verify`: exact checks (test command, query, runtime check, etc.). +- `Coverage status`: tested / not tested. + +Prefer deterministic checks and avoid vague acceptance criteria. + +## PR reporting rules +In PR description (or equivalent summary), include: +- Link to contract/oracle file. +- Short `Tested` section mapped to oracle IDs. +- Short `Not tested` section mapped to oracle IDs. + +## Output style +- Be concise and direct. +- Use simple language. +- Optimize for human review speed. + +## Start behavior (important) +After receiving this instruction, your first action on any new task is: +1. Draft contract/oracle changes. +2. Ask for review/approval. +3. Wait. diff --git a/android/001_TEST_PLAN_ANDROID_OSQUERY.md b/android/001_TEST_PLAN_ANDROID_OSQUERY.md new file mode 100644 index 00000000000..c43cdf0a7cd --- /dev/null +++ b/android/001_TEST_PLAN_ANDROID_OSQUERY.md @@ -0,0 +1,256 @@ +# Android Osquery Test Plan + +## Purpose +Provide a practical, repeatable test plan for validating Android osquery behavior, enrollment reliability, and security controls before merge/release. + +## Scope +This plan covers: +- Enrollment and re-enrollment behavior +- Distributed query read/execute/write loop +- SQL surface and table behavior +- Security controls in enrollment/distributed paths +- Real-device validation and regression checks + +Out of scope: +- Full certificate/SCEP matrix (covered by dedicated certificate tests) +- Non-Android platforms + +## Reference contract/oracle +- Contract + oracle: `android/000_BEHAVIOR_CONTRACT_AND_ORACLE.md` +- Key mappings used in this plan: + - Enrollment/transport: O1, O2 + - Distributed loop: O3, O4 + - Table oracles: O5, O6, O7, O9, O10, O11 + - Security baseline: O12 + +## Test environments +- Local dev: + - macOS + Android SDK + JDK 17+ + - Fleet server reachable at localhost/LAN + - Android device connected by ADB +- CI/local unit: + - JVM tests via Gradle +- Real device matrix (manual): + - At least 2 Android versions + - At least 2 OEMs if possible + +## Entry criteria +- Branch builds locally +- Contract/oracle updated and reviewed +- Test device enrolled or ready to enroll + +## Exit criteria +- All P0/P1 tests pass +- No open high-severity security issues +- Known minor risks documented in contract and PR QA + +## Priority levels +- P0: Must pass for merge +- P1: Should pass before review sign-off +- P2: Nice-to-have confidence expansion + +## Automated test suite (P0) + +### A1. Enrollment + URL hardening +Command: +```bash +./gradlew :app:testDebugUnitTest --tests com.fleetdm.agent.ApiClientReenrollTest --console=plain --no-daemon +``` +Expected: +- Re-enrollment on 401 works +- Non-401 does not re-enroll +- Identity change clears node key +- URL validation rules enforced + +### A2. Time/uptime table oracle checks +Command: +```bash +./gradlew :app:testDebugUnitTest --tests com.fleetdm.agent.osquery.TimeAndUptimeTableTest --console=plain --no-daemon +``` +Expected: +- `time` and `uptime` return one row +- numeric/time sanity checks pass + +### A3. System/kernel/memory table oracle checks +Command: +```bash +./gradlew :app:testDebugUnitTest --tests com.fleetdm.agent.osquery.SystemKernelMemoryTableTest --console=plain --no-daemon +``` +Expected: +- `system_info`, `kernel_info`, `memory_info` one-row shape and value parseability checks pass + +### A4. Combined gate +Command: +```bash +./gradlew :app:compileDebugKotlin :app:testDebugUnitTest \ + --tests com.fleetdm.agent.ApiClientReenrollTest \ + --tests com.fleetdm.agent.osquery.TimeAndUptimeTableTest \ + --tests com.fleetdm.agent.osquery.SystemKernelMemoryTableTest \ + --tests com.fleetdm.agent.osquery.AdditionalParityTablesTest \ + --tests com.fleetdm.agent.osquery.ManagementVisibilityTablesTest \ + --console=plain --no-daemon +``` +Expected: +- Full targeted suite passes + +## Manual end-to-end validation (P0/P1) + +### M1. Device enrollment smoke (P0) +Steps: +1. Confirm device: +```bash +adb devices +``` +2. Reverse localhost: +```bash +adb reverse tcp:8080 tcp:8080 +``` +3. Install debug app: +```bash +FLEET_SERVER_URL='http://127.0.0.1:8080' ./gradlew installDebug +``` +4. Reset app state and launch: +```bash +adb shell pm clear com.fleetdm.agent +adb shell monkey -p com.fleetdm.agent -c android.intent.category.LAUNCHER 1 +``` +Expected: +- Host appears in Fleet within expected check-in window + +### M2. Distributed query loop smoke (P0) +Run in Fleet Live Query: +```sql +SELECT name, version, platform, security_patch FROM os_version; +``` +Expected: +- Result returns from Android host +- No repeated stuck query behavior + +### M3. Cross-OS table smoke (P1) +Run in Fleet Live Query: +```sql +SELECT * FROM time; +SELECT * FROM uptime; +SELECT * FROM system_info; +SELECT * FROM kernel_info; +SELECT * FROM memory_info; +SELECT * FROM processes LIMIT 20; +SELECT * FROM interface_addresses LIMIT 20; +SELECT * FROM routes LIMIT 20; +SELECT * FROM users; +SELECT * FROM mounts LIMIT 20; +SELECT * FROM cpu_info; +SELECT * FROM app_signatures LIMIT 20; +SELECT * FROM mdm_status; +SELECT * FROM startup_items LIMIT 20; +``` +Expected: +- Exactly one row per table +- Key sanity: + - `time.unix_time` close to current epoch + - `uptime.total_seconds >= 0` + - `system_info.uuid` non-empty + - `kernel_info.platform='android'` + - memory numeric fields non-negative + - `users.uid` parseable and non-negative + - `cpu_info.cores` parseable and non-negative + - `mdm_status` boolean fields are parseable as `0`/`1` + +### M4. Logcat table gating (P1) +1. Query with flag OFF: +```sql +SELECT * FROM android_logcat LIMIT 20; +``` +Expected: +- Empty result +2. Enable managed config `enable_android_logcat_table=true` and retry. +Expected: +- Filtered Fleet-tag rows, no obvious secret leakage + +## Security-focused tests (P0/P1) + +### S1. URL policy (P0) +Verify behavior from automated tests (A1) and code paths: +- Non-debug requires HTTPS +- Reject path/query/fragment/userinfo in base URL + +### S1b. Negative URL/config path (P0) +Command: +```bash +./gradlew :app:testDebugUnitTest --tests com.fleetdm.agent.SecurityNegativePathsTest --console=plain --no-daemon +``` +Expected: +- Missing enrollment config fails closed (`Credentials not set`) + +### S2. Re-enrollment control (P0) +Simulate/validate: +- 401 -> clear key -> re-enroll -> retry +- non-401 -> no re-enroll + +### S3. Manifest security controls (P0) +Verify in manifest: +- `android:allowBackup="false"` +- `BootReceiver` exported false + +### S4. Distributed path hardening (P0) +Code verification: +- `FleetDistributedQueryRunner` uses `ApiClient.distributedRead/distributedWrite` +- no custom unsafe TLS bypass path in active distributed loop + +### S5. Identifier logging control (P1) +Release-like behavior: +- full Device ID not logged in non-debug + +### S6. Accepted minor risk verification (P2) +- Enrollment secret currently in app-private DataStore (not Keystore-encrypted) +- Confirm risk remains documented in contract + PR QA + +### S7. Negative SQL parser path (P1) +Command: +```bash +./gradlew :app:testDebugUnitTest --tests com.fleetdm.agent.SecurityNegativePathsTest --console=plain --no-daemon +``` +Expected: +- Malformed SQL is rejected and does not crash worker/query engine path + +## Lifecycle and corner-case tests (P1/P2) + +### L1. Host delete + re-enroll (P1) +Flow: +1. Enroll device +2. Delete host in Fleet UI +3. Trigger next check-in +Expected: +- 401 path re-enrolls and host recovers + +### L2. App uninstall/reinstall (P1) +Expected: +- Fresh enroll works, no stale-key failure + +### L3. Work profile reset (P2) +Expected: +- Re-enrollment and query loop recover after profile recreation + +### L4. Network disruption (P2) +Cases: +- captive portal +- intermittent connectivity +- TLS interception/proxy +Expected: +- Failures are controlled, no crash loops, recovery when network normalizes + +## Test reporting template +For each run, record: +- Build/commit +- Device model + Android version +- Tests run (IDs) +- Result: pass/fail +- Evidence: command output, screenshot, query result, log snippet +- Follow-ups/issues + +## Recommended merge gate +Minimum for merge: +- A4 pass +- M1 + M2 pass on at least one real device +- S1/S2/S3/S4 pass +- Open issues triaged, high severity resolved diff --git a/android/README.md b/android/README.md index d661cbb25b0..b98d72d5e99 100644 --- a/android/README.md +++ b/android/README.md @@ -291,3 +291,149 @@ See `gradle/libs.versions.toml` for complete list. **Delete device from Android MDM:** - Delete Work profile on Android device - Using `tools/android/android.go`, delete the device and delete the associated policy (as of 2025/11/21, Fleet server does not do this) + +## Osquery on Android + +This project extends the Fleet Android agent toward an Android osquery runtime that executes distributed queries and reports results back to Fleet. +The goal is to make Android hosts first-class query targets with osquery-style tables and behavior. + +### Enrollment summary + +The app reads `server_url`, `enroll_secret`, and `host_uuid` from Android managed configuration. +It enrolls with Fleet Orbit and stores the node key securely on device for future API calls. +In debug builds, if managed configuration is missing, it can fall back to debug-provided server URL and enroll secret. + +### Query polling cadence + +For distributed queries, the app checks in with Fleet on a loop using WorkManager. +In the current implementation, the debug polling interval is hardcoded to **15 seconds** in `DistributedCheckinWorker`. +This interval is **not configurable yet**; making it configurable (for example via managed config or a build setting) is a good next step. + +### Android table reference + +| Table | Summary | Quick query | +| --- | --- | --- | +| `installed_apps` | Installed app inventory and versions | `SELECT app_name, package_name, version_name FROM installed_apps LIMIT 25;` | +| `app_permissions` | App permissions and grant state | `SELECT app_name, permission, granted FROM app_permissions LIMIT 50;` | +| `os_version` | Android version/build/security patch info | `SELECT name, version, build, security_patch FROM os_version;` | +| `osquery_info` | Agent runtime metadata | `SELECT uuid, instance_id, version FROM osquery_info;` | +| `certificates` | Certificate records visible to the agent | `SELECT alias, subject, issuer, not_after FROM certificates LIMIT 50;` | +| `device_info` | Device model/manufacturer/hardware metadata | `SELECT manufacturer, brand, model, device FROM device_info;` | +| `network_interfaces` | Interface and addressing details | `SELECT name, mac, mtu, addresses FROM network_interfaces;` | +| `battery` | Battery state and health | `SELECT percent_remaining, charging, health FROM battery;` | +| `wifi_networks` | Wi-Fi connection/network details | `SELECT ssid, bssid, rssi, is_connected FROM wifi_networks;` | +| `system_properties` | Android system property key/value pairs | `SELECT key, value FROM system_properties LIMIT 50;` | +| `android_logcat` | Recent logcat entries | `SELECT timestamp, level, tag, message FROM android_logcat LIMIT 100;` | +| `time` | Current local time/timezone snapshot | `SELECT weekday, hour, minutes, local_timezone, unix_time FROM time;` | +| `uptime` | Device uptime duration snapshot | `SELECT days, hours, minutes, seconds, total_seconds FROM uptime;` | +| `system_info` | Host identity, hardware, and memory summary snapshot | `SELECT hostname, uuid, hardware_vendor, hardware_model, physical_memory FROM system_info;` | +| `kernel_info` | Kernel and runtime version snapshot | `SELECT version, release, build, platform FROM kernel_info;` | +| `memory_info` | Current memory totals and low-memory state | `SELECT total_bytes, available_bytes, threshold_bytes, low_memory FROM memory_info;` | +| `processes` | Visible process snapshot | `SELECT pid, name, uid, package_name, importance FROM processes LIMIT 50;` | +| `interface_addresses` | Interface-to-address rows | `SELECT interface, address, family FROM interface_addresses LIMIT 50;` | +| `routes` | Best-effort route snapshot | `SELECT destination, gateway, interface FROM routes LIMIT 50;` | +| `users` | Current app/profile identity row | `SELECT uid, gid, username, directory FROM users;` | +| `mounts` | Filesystem mount snapshot | `SELECT device, path, type, flags FROM mounts LIMIT 50;` | +| `cpu_info` | CPU capability snapshot | `SELECT cores, arch, model, hardware, vendor FROM cpu_info;` | +| `app_signatures` | Installed app signing certificate fingerprint metadata | `SELECT package_name, sha256, subject, issuer FROM app_signatures LIMIT 50;` | +| `mdm_status` | MDM/restrictions presence snapshot | `SELECT has_device_owner, has_work_profile, restrictions_present, enroll_secret_present FROM mdm_status;` | +| `startup_items` | Boot/launcher component visibility snapshot | `SELECT package_name, component, type, enabled, exported FROM startup_items LIMIT 50;` | + +### Quick start (5 minutes) + +Run these commands from `android/`: + +```bash +adb devices +adb reverse tcp:8080 tcp:8080 +export FLEET_ENROLL_SECRET='YOUR_ENROLL_SECRET' +FLEET_SERVER_URL='http://127.0.0.1:8080' ./gradlew installDebug +adb shell pm clear com.fleetdm.agent +adb shell monkey -p com.fleetdm.agent -c android.intent.category.LAUNCHER 1 +``` + +### Verify it works + +1. Open Fleet UI and check that the Android host appears in **Hosts**. +2. Run a simple live query: + +```sql +SELECT name, version, platform, security_patch FROM os_version; +``` + +3. Confirm logs show successful read/write loop: + +```bash +adb logcat | rg "fleet-ApiClient|fleet-distributed|Successfully enrolled host|distributed/read" +``` + +### QA quick checks for new cross-OS tables + +Run these in Fleet live query and confirm exactly 1 row each: + +```sql +SELECT * FROM time; +SELECT * FROM uptime; +SELECT * FROM system_info; +SELECT * FROM kernel_info; +SELECT * FROM memory_info; +SELECT * FROM processes LIMIT 20; +SELECT * FROM interface_addresses LIMIT 20; +SELECT * FROM routes LIMIT 20; +SELECT * FROM users; +SELECT * FROM mounts LIMIT 20; +SELECT * FROM cpu_info; +SELECT * FROM app_signatures LIMIT 20; +SELECT * FROM mdm_status; +SELECT * FROM startup_items LIMIT 20; +``` + +Expected sanity checks: +- `time.unix_time` is close to current epoch time, and `local_timezone` is non-empty. +- `uptime.total_seconds` is non-negative and consistent with `days/hours/minutes/seconds`. +- `system_info.uuid` is non-empty. +- `kernel_info.platform` is `android`. +- `memory_info.total_bytes`/`available_bytes`/`threshold_bytes` are non-negative. +- `users.uid` is parseable and non-negative. +- `cpu_info.cores` is parseable and non-negative. +- `mdm_status` boolean fields are `0`/`1`. + +### Architecture at a glance + +```text +Managed config (server_url + enroll_secret + host_uuid) + -> AgentApplication refreshes credentials + -> ApiClient enrolls via /api/fleet/orbit/enroll (node key persisted) + -> DistributedCheckinWorker polls /api/v1/osquery/distributed/read + -> OsqueryQueryEngine runs SQL on Android-backed tables + -> Results posted to /api/v1/osquery/distributed/write +``` + +### Current limitations + +- SQL support is intentionally limited (basic `SELECT` + simple `WHERE`). +- Debug distributed polling interval is currently hardcoded to 15 seconds. +- Debug builds allow cleartext HTTP for local development; production should use managed secure config. + +### Roadmap / next steps + +- Make distributed polling interval configurable (managed config or build config). +- Add explicit platform validation updates for Android query targeting. +- Add more Android-specific tables and improve schema/docs coverage. +- Expand integration tests for distributed query loop behavior. + +### Troubleshooting by symptom + +| Symptom | Likely cause | Fix | +| --- | --- | --- | +| Host not appearing in Fleet | Wrong `server_url` or enroll secret | Use `adb reverse`, set `FLEET_SERVER_URL=http://127.0.0.1:8080`, reinstall debug build | +| `localhost` does not work from phone | Phone resolves localhost to itself | Use `adb reverse tcp:8080 tcp:8080` or LAN IP | +| Query returns no rows | Unsupported table/columns or no local data | Start with `os_version` and `device_info` queries | +| No distributed logs | App not started after install/clear | Launch with `adb shell monkey -p com.fleetdm.agent -c android.intent.category.LAUNCHER 1` | +| Build/install fails | Missing execute bit or SDK/JDK setup | Run `chmod +x ./gradlew` and verify SDK/JDK requirements | + +### Security model (summary) + +- Enrollment uses managed config (`server_url`, `enroll_secret`, `host_uuid`) or debug fallback in debug builds only. +- Node key and API credentials are stored encrypted on-device using Android Keystore-backed encryption. +- Network access is restricted by build type; debug permits local development workflows, while production is expected to use secure managed configuration. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5d8f4eae115..f95776a6f5d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -2,6 +2,14 @@ import org.gradle.testing.jacoco.plugins.JacocoTaskExtension import java.io.FileInputStream import java.util.Properties + + +fun fleetProp(name: String): String = + (project.findProperty(name) as String?) + ?: (System.getenv(name.replace('.', '_').uppercase()) ?: "") + + + // ==================== PLUGINS ==================== plugins { @@ -101,6 +109,8 @@ android { debug { enableUnitTestCoverage = true enableAndroidTestCoverage = true + buildConfigField("String", "DEBUG_FLEET_SERVER_URL", "\"${fleetProp("fleet.server_url")}\"") + buildConfigField("String", "DEBUG_FLEET_ENROLL_SECRET", "\"${fleetProp("fleet.enroll_secret")}\"") } release { if (keystorePropertiesFile.exists()) { @@ -255,6 +265,7 @@ dependencies { implementation(libs.androidx.work.runtime.ktx) implementation(libs.amapi.sdk) implementation(libs.kotlinx.serialization.json) + implementation("com.squareup.okhttp3:okhttp:4.12.0") // SCEP (Simple Certificate Enrollment Protocol) implementation(libs.jscep) diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000000..f23f576df98 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/android/app/src/debug/res/xml/network_security_config_debug.xml b/android/app/src/debug/res/xml/network_security_config_debug.xml new file mode 100644 index 00000000000..e0ebce10d80 --- /dev/null +++ b/android/app/src/debug/res/xml/network_security_config_debug.xml @@ -0,0 +1,10 @@ + + + + + localhost + 127.0.0.1 + 10.0.2.2 + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8dff2518919..96bcaf54737 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -20,7 +20,7 @@ + android:exported="false"> diff --git a/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt b/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt index a822589fe7e..95445f38e91 100644 --- a/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt +++ b/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt @@ -12,11 +12,14 @@ import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkRequest +import com.fleetdm.agent.device.DeviceIdManager import java.util.concurrent.TimeUnit import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import com.fleetdm.agent.device.Storage +import com.fleetdm.agent.osquery.core.TableRegistry /** * Custom Application class for Fleet Agent. @@ -30,11 +33,6 @@ class AgentApplication : Application() { companion object { private const val TAG = "fleet-app" - /** - * Gets the CertificateOrchestrator instance from the Application. - * @param context Any context (will use applicationContext) - * @return The shared CertificateOrchestrator instance - */ fun getCertificateOrchestrator(context: Context): CertificateOrchestrator = (context.applicationContext as AgentApplication).certificateOrchestrator } @@ -46,6 +44,13 @@ class AgentApplication : Application() { Log.i(TAG, "Fleet agent process started") FleetLog.initialize(this) + Storage.init(this) + + // Device ID is useful for local debugging; avoid logging full identifier in non-debug builds. + val deviceId = DeviceIdManager.getOrCreateDeviceId() + if (BuildConfig.DEBUG) { + Log.i(TAG, "DeviceId=${deviceId.take(8)}...") + } val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> @@ -56,46 +61,80 @@ class AgentApplication : Application() { android.os.Process.killProcess(android.os.Process.myPid()) } } - // Initialize dependencies ApiClient.initialize(this) + + // Register osquery table plugins + com.fleetdm.agent.osquery.OsqueryTables.registerAll(this) + + // Register core osquery tables (including android_logcat) + TableRegistry.ensureRegistered() + + if (BuildConfig.DEBUG) { + DistributedCheckinWorker.scheduleNextDebug(this) + } + certificateOrchestrator = CertificateOrchestrator() refreshEnrollmentCredentials() schedulePeriodicCertificateEnrollment() } + /** + * Production path: MDM managed configuration (RestrictionsManager). + * Debug-only fallback: BuildConfig.DEBUG_* values, ONLY if MDM values are missing. + */ private fun refreshEnrollmentCredentials() { applicationScope.launch { try { - val restrictionsManager = getSystemService(Context.RESTRICTIONS_SERVICE) - as? RestrictionsManager - val appRestrictions = restrictionsManager?.applicationRestrictions ?: return@launch - - val enrollSecret = appRestrictions.getString("enroll_secret") - val hostUUID = appRestrictions.getString("host_uuid") - val serverURL = appRestrictions.getString("server_url") - - if (enrollSecret != null && hostUUID != null && serverURL != null) { - Log.d(TAG, "Refreshing enrollment credentials from MDM config") - ApiClient.setEnrollmentCredentials( - enrollSecret = enrollSecret, - hardwareUUID = hostUUID, - serverUrl = serverURL, - computerName = "${Build.BRAND} ${Build.MODEL}", - ) - - // Only enroll if not already enrolled - if (ApiClient.getApiKey() == null) { - val configResult = ApiClient.getOrbitConfig() - configResult.onSuccess { - Log.d(TAG, "Successfully enrolled host with Fleet server") - }.onFailure { error -> - Log.w(TAG, "Host enrollment failed: ${error.message}") - } + val restrictionsManager = + getSystemService(Context.RESTRICTIONS_SERVICE) as? RestrictionsManager + val appRestrictions = restrictionsManager?.applicationRestrictions + + val mdmEnrollSecret = appRestrictions?.getString("enroll_secret") + val mdmHostUUID = appRestrictions?.getString("host_uuid") + val mdmServerURL = appRestrictions?.getString("server_url") + + val (enrollSecret, hostUUID, serverURL) = if ( + !mdmEnrollSecret.isNullOrBlank() && + !mdmHostUUID.isNullOrBlank() && + !mdmServerURL.isNullOrBlank() + ) { + Log.d(TAG, "Using MDM enrollment credentials (managed config)") + Triple(mdmEnrollSecret, mdmHostUUID, mdmServerURL) + } else if (BuildConfig.DEBUG) { + val debugUrl = getOptionalBuildConfigString("DEBUG_FLEET_SERVER_URL") + val debugSecret = getOptionalBuildConfigString("DEBUG_FLEET_ENROLL_SECRET") + + if (!debugUrl.isNullOrBlank() && !debugSecret.isNullOrBlank()) { + // Debug fallback host UUID: stable per app install (acceptable for dev) + val debugHostUUID = DeviceIdManager.getOrCreateDeviceId() + + Log.w(TAG, "MDM config missing; using DEBUG enrollment credentials") + Triple(debugSecret, debugHostUUID, debugUrl) + } else { Log.d(TAG, "MDM config missing and DEBUG values not set") + return@launch } } else { Log.d(TAG, "MDM enrollment credentials not available") + return@launch + } + + ApiClient.setEnrollmentCredentials( + enrollSecret = enrollSecret, + hardwareUUID = hostUUID, + serverUrl = serverURL, + computerName = "${Build.BRAND} ${Build.MODEL}", + ) + + // Only enroll if not already enrolled + if (ApiClient.getApiKey() == null) { + val configResult = ApiClient.getOrbitConfig() + configResult.onSuccess { + Log.d(TAG, "Successfully enrolled host with Fleet server") + }.onFailure { error -> + Log.w(TAG, "Host enrollment failed: ${error.message}") + } } } catch (e: Exception) { FleetLog.e(TAG, "Error refreshing enrollment credentials", e) @@ -103,6 +142,17 @@ class AgentApplication : Application() { } } + + private fun getOptionalBuildConfigString(fieldName: String): String? { + return try { + val clazz = Class.forName("${packageName}.BuildConfig") + val field = clazz.getField(fieldName) + (field.get(null) as? String)?.takeIf { it.isNotBlank() } + } catch (_: Throwable) { + null + } + } + private fun schedulePeriodicCertificateEnrollment() { val workRequest = PeriodicWorkRequestBuilder( 15, // 15 minutes is the minimum diff --git a/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt b/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt index d85235f4a87..b262eaa2add 100644 --- a/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt +++ b/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt @@ -27,6 +27,10 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import com.fleetdm.agent.device.DeviceIdManager /** * Converts a java.util.Date to ISO8601 format string. @@ -72,6 +76,34 @@ object ApiClient : CertificateApiClient { private val enrollmentMutex = Mutex() + private fun shouldRequireHttps(): Boolean = !BuildConfig.DEBUG && !KeystoreManager.isTestModeEnabled() + + internal fun validateBaseUrl(baseUrl: String, requireHttps: Boolean): Result { + return try { + val parsedUrl = URL(baseUrl) + if (parsedUrl.protocol !in listOf("https", "http")) { + return Result.failure(Exception("Base URL must use HTTP or HTTPS scheme")) + } + if (requireHttps && parsedUrl.protocol != "https") { + return Result.failure(Exception("Base URL must use HTTPS in non-debug builds")) + } + if (parsedUrl.userInfo != null) { + return Result.failure(Exception("Base URL must not include user info")) + } + if (parsedUrl.path.isNotEmpty() && parsedUrl.path != "/") { + return Result.failure(Exception("Base URL must not include a path")) + } + if (!parsedUrl.query.isNullOrEmpty() || !parsedUrl.ref.isNullOrEmpty()) { + return Result.failure(Exception("Base URL must not include query or fragment")) + } + + val normalized = "${parsedUrl.protocol}://${parsedUrl.authority}".trimEnd('/') + Result.success(normalized) + } catch (e: Exception) { + Result.failure(Exception("Invalid base URL format: ${e.message}")) + } + } + fun initialize(context: Context) { Log.d(TAG, "initializing api client") if (!::dataStore.isInitialized) { @@ -79,6 +111,46 @@ object ApiClient : CertificateApiClient { } } + suspend fun distributedRead(): Result = withReenrollOnUnauthorized { + val nodeKey = getNodeKeyOrEnroll().getOrElse { error -> + return@withReenrollOnUnauthorized Result.failure(error) + } + + makeRequest( + endpoint = "/api/v1/osquery/distributed/read", + method = "POST", + body = DistributedReadRequest(nodeKey = nodeKey), + bodySerializer = DistributedReadRequest.serializer(), + responseSerializer = DistributedReadResponse.serializer(), + authorized = false, + ) + } + + suspend fun distributedWrite( + queryResults: Map>>, + ): Result = withReenrollOnUnauthorized { + val nodeKey = getNodeKeyOrEnroll().getOrElse { error -> + return@withReenrollOnUnauthorized Result.failure(error) + } + + val req = DistributedWriteRequest(nodeKey = nodeKey, queries = queryResults) + + // Fleet usually returns an empty JSON object; we don't care about the body. + val res = makeRequest( + endpoint = "/api/v1/osquery/distributed/write", + method = "POST", + body = req, + bodySerializer = DistributedWriteRequest.serializer(), + responseSerializer = JsonElement.serializer(), + authorized = false, + ) + + res.fold( + onSuccess = { Result.success(Unit) }, + onFailure = { Result.failure(it) }, + ) + } + private suspend fun setApiKey(key: String) { dataStore.edit { preferences -> preferences[API_KEY] = KeystoreManager.encrypt(key) @@ -122,21 +194,10 @@ object ApiClient : CertificateApiClient { Exception("Base URL not configured"), ) - // Validate base URL format and scheme - try { - val parsedUrl = URL(baseUrl) - if (parsedUrl.protocol !in listOf("https", "http")) { - return@withContext Result.failure( - Exception("Base URL must use HTTP or HTTPS scheme"), - ) - } - } catch (e: Exception) { - return@withContext Result.failure( - Exception("Invalid base URL format: ${e.message}"), - ) - } + val normalizedBaseUrl = validateBaseUrl(baseUrl, requireHttps = shouldRequireHttps()) + .getOrElse { error -> return@withContext Result.failure(error) } - val url = URL("$baseUrl$endpoint") + val url = URL("$normalizedBaseUrl$endpoint") connection = url.openConnection() as HttpURLConnection connection.apply { @@ -144,6 +205,12 @@ object ApiClient : CertificateApiClient { useCaches = false doInput = true setRequestProperty("Content-Type", "application/json") + val deviceId = runCatching { DeviceIdManager.getOrCreateDeviceId() } + .onFailure { FleetLog.e(TAG, "Failed to retrieve device id: ${it.message}") } + .getOrNull() + if (!deviceId.isNullOrBlank()) { + setRequestProperty("X-Fleet-Device-Id", deviceId) + } if (authorized) { getNodeKeyOrEnroll().fold( onFailure = { throwable -> return@withContext Result.failure(throwable) }, @@ -164,13 +231,18 @@ object ApiClient : CertificateApiClient { } val responseCode = connection.responseCode - val response = if (responseCode in 200..299) { + var response = if (responseCode in 200..299) { connection.inputStream.bufferedReader().use { it.readText() } } else { connection.errorStream?.bufferedReader()?.use { it.readText() } ?: "HTTP $responseCode" } + // Some Fleet endpoints may respond with an empty body on success. + if (responseCode in 200..299 && response.isBlank()) { + response = "{}" + } + Log.d(TAG, "server response from $method $endpoint ($responseCode)") if (responseCode in 200..299) { @@ -254,6 +326,20 @@ object ApiClient : CertificateApiClient { suspend fun setEnrollmentCredentials(enrollSecret: String, hardwareUUID: String, computerName: String, serverUrl: String) { dataStore.edit { preferences -> + val currentEnrollSecret = preferences[ENROLL_SECRET] + val currentHardwareUUID = preferences[HARDWARE_UUID] + val currentServerUrl = preferences[SERVER_URL_KEY] + + val identityChanged = currentEnrollSecret != null && + (currentEnrollSecret != enrollSecret || + currentHardwareUUID != hardwareUUID || + currentServerUrl != serverUrl) + + if (identityChanged) { + preferences.remove(API_KEY) + Log.i(TAG, "Enrollment identity changed, cleared stored node key") + } + preferences[ENROLL_SECRET] = enrollSecret preferences[HARDWARE_UUID] = hardwareUUID preferences[COMPUTER_NAME] = computerName @@ -386,6 +472,32 @@ object ApiClient : CertificateApiClient { ) } +@Serializable +data class DistributedReadRequest( + @SerialName("node_key") + val nodeKey: String, + @SerialName("queries") + val queries: Map = emptyMap(), +) + +@Serializable +data class DistributedReadResponse( + @SerialName("queries") + val queries: Map = emptyMap(), +) + +@Serializable +data class DistributedWriteRequest( + @SerialName("node_key") + val nodeKey: String, + + // Map: queryName -> rows[] where each row is {col: value} + @SerialName("queries") + val queries: Map>> = emptyMap(), +) + + + @Serializable data class EnrollRequest( @SerialName("enroll_secret") diff --git a/android/app/src/main/java/com/fleetdm/agent/DistributedCheckinWorker.kt b/android/app/src/main/java/com/fleetdm/agent/DistributedCheckinWorker.kt new file mode 100644 index 00000000000..3c2baf95c5a --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/DistributedCheckinWorker.kt @@ -0,0 +1,105 @@ +package com.fleetdm.agent + +import android.content.Context +import android.util.Log +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.fleetdm.agent.osquery.OsqueryQueryEngine +import java.util.concurrent.TimeUnit + +class DistributedCheckinWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + try { + Log.d(TAG, "Distributed check-in: starting") + + val readResult = ApiClient.distributedRead() + readResult.fold( + onSuccess = { resp -> + val queries = resp.queries + Log.d(TAG, "Distributed check-in: received ${queries.size} query(ies)") + + // 1) Log received queries + queries.forEach { (name, _) -> + Log.i(TAG, "Distributed query [$name] received") + } + + // 2) Execute what we can, and write results back + val results = linkedMapOf>>() + + for ((name, sql) in queries) { + if (sql.isBlank()) continue + + try { + val rows = OsqueryQueryEngine.execute(sql) + results[name] = rows + } catch (e: Exception) { + val msg = e.message ?: e.javaClass.simpleName + Log.w(TAG, "Distributed query failed [$name]: $msg") + + // "clear" unknown/unsupported queries so Fleet stops re-sending them + results[name] = emptyList() + } + } + + if (results.isNotEmpty()) { + ApiClient.distributedWrite(results).fold( + onSuccess = { + Log.d(TAG, "Distributed check-in: wrote ${results.size} result set(s)") + }, + onFailure = { err -> + Log.w(TAG, "Distributed check-in: write failed: ${err.message}") + }, + ) + } + }, + onFailure = { err -> + Log.w(TAG, "Distributed check-in: read failed: ${err.message}") + }, + ) + + return Result.success() + } finally { + // Debug-only fast polling (15s). Release scheduling handled elsewhere (15m). + if (BuildConfig.DEBUG) { + scheduleNextDebug(applicationContext) + Log.d(TAG, "Distributed check-in: scheduled next run in 15 seconds") + } + } + } + + companion object { + private const val TAG = "fleet-distributed" + + /** Single logical chain for debug polling */ + private const val WORK_NAME_DEBUG = "fleet_distributed_checkin_debug" + + fun scheduleNextDebug(context: Context) { + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(15, TimeUnit.SECONDS) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .addTag(WORK_NAME_DEBUG) + .build() + + WorkManager.getInstance(context) + .beginUniqueWork( + WORK_NAME_DEBUG, + ExistingWorkPolicy.APPEND, // do NOT cancel running work + request + ) + .enqueue() + } + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/KeystoreManager.kt b/android/app/src/main/java/com/fleetdm/agent/KeystoreManager.kt index c37de9438cd..b484fd23041 100644 --- a/android/app/src/main/java/com/fleetdm/agent/KeystoreManager.kt +++ b/android/app/src/main/java/com/fleetdm/agent/KeystoreManager.kt @@ -36,6 +36,8 @@ object KeystoreManager { testKey = null } + internal fun isTestModeEnabled(): Boolean = testMode + private fun getOrCreateKey(): SecretKey { if (testMode) { return testKey ?: error("Test mode enabled but no test key available") diff --git a/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt b/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt index 9c93c4bc4b6..10c5e1b431b 100644 --- a/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt +++ b/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt @@ -30,12 +30,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -49,10 +47,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri @@ -62,18 +57,15 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.fleetdm.agent.ui.theme.FleetTextDark import com.fleetdm.agent.ui.theme.MyApplicationTheme +import com.fleetdm.agent.device.DeviceIdManager +import com.fleetdm.agent.device.Storage import kotlinx.serialization.Serializable const val CLICKS_TO_DEBUG = 8 -@Serializable -object MainDestination - -@Serializable -object DebugDestination - -@Serializable -object LogsDestination +@Serializable object MainDestination +@Serializable object DebugDestination +@Serializable object LogsDestination class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -106,17 +98,6 @@ fun AppNavigation() { onNavigateToDebug = { navController.navigate(DebugDestination) }, ) } - - composable { - DebugScreen( - onNavigateBack = { navController.navigateUp() }, - onNavigateToLogs = { navController.navigate(LogsDestination) }, - ) - } - - composable { - LogsScreen(onNavigateBack = { navController.navigateUp() }) - } } } @@ -125,90 +106,65 @@ fun MainScreen(onNavigateToDebug: () -> Unit) { val context = LocalContext.current val orchestrator = remember { AgentApplication.getCertificateOrchestrator(context) } + val deviceId = remember { + Storage.init(context) + DeviceIdManager.getOrCreateDeviceId() + } + var versionClicks by remember { mutableStateOf(0) } - val installedCerts by orchestrator.installedCertsFlow(context).collectAsStateWithLifecycle(initialValue = emptyMap()) + val installedCerts by orchestrator + .installedCertsFlow(context) + .collectAsStateWithLifecycle(initialValue = emptyMap()) Scaffold( modifier = Modifier.fillMaxSize(), content = { paddingValues -> - Column(Modifier.padding(paddingValues = paddingValues)) { + Column( + Modifier + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { LogoHeader() HorizontalDivider() AboutFleet { val intent = Intent(Intent.ACTION_VIEW, BuildConfig.INFO_URL.toUri()) - if (intent.resolveActivity(context.packageManager) != null) { - // A browser is available, open the URL directly - context.startActivity(intent) - } else { - // A browser is not available in the work profile, display a toast with the URL - val toast = Toast.makeText(context, "Visit ${BuildConfig.INFO_URL}\nfor more information", Toast.LENGTH_LONG) - toast.show() - } + context.startActivity(intent) } HorizontalDivider() CertificateList(certificates = installedCerts) AppVersion { if (++versionClicks >= CLICKS_TO_DEBUG) { onNavigateToDebug() - } else if (versionClicks == 1) { - val clipboard = context.getSystemService(ClipboardManager::class.java) - ?: error("ClipboardManager not available") - clipboard.setPrimaryClip(ClipData.newPlainText("", "Fleet Android Agent: ${BuildConfig.VERSION_NAME}")) - Toast.makeText(context, "Fleet Agent version copied", Toast.LENGTH_SHORT).show() } } + DeviceIdRow(deviceId = deviceId) { + val clipboard = context.getSystemService(ClipboardManager::class.java) + clipboard.setPrimaryClip( + ClipData.newPlainText("Device ID", deviceId) + ) + Toast.makeText(context, "Device ID copied", Toast.LENGTH_SHORT).show() + } } }, ) } @Composable -fun AboutFleet(modifier: Modifier = Modifier, onLearnClick: () -> Unit = {}) { - Column(modifier = modifier.padding(20.dp)) { - Text( - text = stringResource(R.string.app_description), - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier - .padding(top = 10.dp) - .clickable(onClick = onLearnClick), +fun DeviceIdRow(deviceId: String, onClick: () -> Unit = {}) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp), ) { Text( - text = stringResource(R.string.learn_about_fleet), - fontWeight = FontWeight.Bold, + text = "Device ID", color = FleetTextDark, + fontWeight = FontWeight.Bold, ) - Icon(imageVector = Icons.AutoMirrored.Default.ArrowForward, contentDescription = "forward arrow") - } - } -} - -@Composable -fun LogoHeader(modifier: Modifier = Modifier) { - Image( - modifier = modifier.padding(20.dp), - painter = painterResource(R.drawable.fleet_logo), - contentDescription = stringResource(R.string.fleet_logo), - ) -} - -@Composable -fun CertificateList(modifier: Modifier = Modifier, certificates: CertificateStateMap) { - Column(modifier = modifier.padding(all = 20.dp)) { - Text( - text = stringResource(R.string.certificate_list_title), - color = FleetTextDark, - fontWeight = FontWeight.Bold, - ) - certificates.ifEmpty { - Text(text = stringResource(R.string.certificate_list_no_certificates)) - } - certificates.forEach { (_, value) -> - if (value.status == CertificateStatus.INSTALLED || value.status == CertificateStatus.INSTALLED_UNREPORTED) { - Text(text = value.alias) - } + Text(text = deviceId) } } } @@ -221,8 +177,7 @@ fun AppVersion(onClick: () -> Unit = {}) { .clickable(onClick = onClick), ) { Column( - modifier = Modifier - .padding(horizontal = 20.dp), + modifier = Modifier.padding(horizontal = 20.dp), ) { Text( text = stringResource(R.string.app_version_title), @@ -234,30 +189,55 @@ fun AppVersion(onClick: () -> Unit = {}) { } } -@Preview(showBackground = true) @Composable -fun FleetScreenPreview() { - MyApplicationTheme { - Column { - LogoHeader() - HorizontalDivider() - AboutFleet() - HorizontalDivider() - CertificateList( - certificates = mapOf( - 1 to CertificateState(alias = "WIFI-1", status = CertificateStatus.INSTALLED), - 2 to CertificateState(alias = "VPN-3", status = CertificateStatus.FAILED), - ), +fun AboutFleet(onLearnClick: () -> Unit = {}) { + Column(Modifier.padding(20.dp)) { + Text(text = stringResource(R.string.app_description)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(top = 10.dp) + .clickable(onClick = onLearnClick), + ) { + Text( + text = stringResource(R.string.learn_about_fleet), + fontWeight = FontWeight.Bold, + color = FleetTextDark, + ) + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowForward, + contentDescription = null, ) - AppVersion(onClick = {}) } } } -@Preview(showBackground = true) @Composable -fun AboutFleetPreview() { - MyApplicationTheme { - AboutFleet() +fun LogoHeader() { + Image( + modifier = Modifier.padding(20.dp), + painter = painterResource(R.drawable.fleet_logo), + contentDescription = null, + ) +} + +@Composable +fun CertificateList(certificates: CertificateStateMap) { + Column(Modifier.padding(20.dp)) { + Text( + text = stringResource(R.string.certificate_list_title), + color = FleetTextDark, + fontWeight = FontWeight.Bold, + ) + certificates.ifEmpty { + Text(text = stringResource(R.string.certificate_list_no_certificates)) + } + certificates.forEach { (_, value) -> + if (value.status == CertificateStatus.INSTALLED || + value.status == CertificateStatus.INSTALLED_UNREPORTED + ) { + Text(text = value.alias) + } + } } } diff --git a/android/app/src/main/java/com/fleetdm/agent/device/DeviceIdManager.kt b/android/app/src/main/java/com/fleetdm/agent/device/DeviceIdManager.kt new file mode 100644 index 00000000000..efefb792d7e --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/device/DeviceIdManager.kt @@ -0,0 +1,85 @@ +package com.fleetdm.agent.device + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +object DeviceIdManager { + + private const val KEY_ALIAS = "fleet_device_id_key" + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val IV_SIZE = 12 + private const val TAG_SIZE = 128 + + fun getOrCreateDeviceId(): String { + val key = getOrCreateKey() + val stored = readEncrypted(key) + if (stored != null) return stored + + val newId = UUID.randomUUID().toString() + writeEncrypted(key, newId) + return newId + } + + private fun getOrCreateKey(): SecretKey { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + + keyStore.getKey(KEY_ALIAS, null)?.let { + return it as SecretKey + } + + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + ANDROID_KEYSTORE + ) + + keyGenerator.init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build() + ) + + return keyGenerator.generateKey() + } + + private fun writeEncrypted(key: SecretKey, value: String) { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key) + + val encrypted = cipher.doFinal(value.toByteArray()) + val combined = cipher.iv + encrypted + + Storage.write(Base64.encodeToString(combined, Base64.NO_WRAP)) + } + + private fun readEncrypted(key: SecretKey): String? { + val stored = Storage.read() ?: return null + val data = Base64.decode(stored, Base64.NO_WRAP) + + if (data.size <= IV_SIZE) return null + + val iv = data.copyOfRange(0, IV_SIZE) + val encrypted = data.copyOfRange(IV_SIZE, data.size) + + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init( + Cipher.DECRYPT_MODE, + key, + GCMParameterSpec(TAG_SIZE, iv) + ) + + return String(cipher.doFinal(encrypted)) + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/device/Storage.kt b/android/app/src/main/java/com/fleetdm/agent/device/Storage.kt new file mode 100644 index 00000000000..5feca3c99a1 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/device/Storage.kt @@ -0,0 +1,29 @@ +package com.fleetdm.agent.device + +import android.content.Context + +object Storage { + + private lateinit var context: Context + private const val FILE = "fleet_device_id" + + fun init(ctx: Context) { + context = ctx.applicationContext + } + + fun write(value: String) { + context.openFileOutput(FILE, Context.MODE_PRIVATE).use { + it.write(value.toByteArray()) + } + } + + fun read(): String? { + return try { + context.openFileInput(FILE) + .bufferedReader() + .use { it.readText() } + } catch (_: Exception) { + null + } + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/FleetDistributedQueryRunner.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/FleetDistributedQueryRunner.kt new file mode 100644 index 00000000000..c98d0c87ada --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/FleetDistributedQueryRunner.kt @@ -0,0 +1,112 @@ +package com.fleetdm.agent.osquery + +import android.util.Log +import com.fleetdm.agent.ApiClient +import com.fleetdm.agent.osquery.core.TableQueryContext +import com.fleetdm.agent.osquery.core.TableRegistry +import com.fleetdm.agent.osquery.core.WhereCond +import com.fleetdm.agent.osquery.core.WhereOp +import com.fleetdm.agent.osquery.core.parseSelectSql + +object FleetDistributedQueryRunner { + + private const val tag = "FleetOsquery" + + // If true, when we cannot run a query, we still answer it with [] so Fleet stops sending it. + var clearUnknownQueries: Boolean = true + + suspend fun runOnce() { + val startMs = System.currentTimeMillis() + val readResp = ApiClient.distributedRead() + .getOrElse { throw RuntimeException("distributed/read failed: ${it.message}") } + val queryNames = readResp.queries.keys.sorted() + + val resultsToWrite = linkedMapOf>>() + var handled = 0 + + for (qName in queryNames) { + val sql = readResp.queries[qName].orEmpty() + if (sql.isBlank()) continue + + try { + val rows = executeSqlViaTables(sql) + resultsToWrite[qName] = rows + handled++ + } catch (e: Exception) { + val msg = e.message ?: e.javaClass.simpleName + Log.w(tag, "Query failed: $qName err=$msg") + + if (clearUnknownQueries) { + resultsToWrite[qName] = emptyList() + handled++ + } + } + } + + if (resultsToWrite.isNotEmpty()) { + ApiClient.distributedWrite(resultsToWrite) + .getOrElse { throw RuntimeException("distributed/write failed: ${it.message}") } + } + + val took = System.currentTimeMillis() - startMs + Log.i(tag, "runOnce handled=$handled wrote=${resultsToWrite.size} tookMs=$took") + } + + private suspend fun executeSqlViaTables(sql: String): List> { + val parsed = parseSelectSql(sql) + + val fullRows = TableRegistry.runTable(parsed.tableName, TableQueryContext()) + + val filteredRows = + if (parsed.where.isEmpty()) fullRows + else fullRows.filter { row -> matchesWhere(row, parsed.where) } + + if (parsed.selectAll) return filteredRows + + val schemaCols = TableRegistry.getColumns(parsed.tableName).map { it.name } + val schemaLowerToReal = schemaCols.associateBy { it.lowercase() } + + val selectedRealCols = parsed.selectedColumns.mapNotNull { sel -> + schemaLowerToReal[sel.lowercase()] + } + + if (selectedRealCols.size != parsed.selectedColumns.size) { + val missing = parsed.selectedColumns.filter { + schemaLowerToReal[it.lowercase()] == null + } + throw IllegalArgumentException( + "Unknown columns for table '${parsed.tableName}': ${missing.joinToString(", ")}" + ) + } + + return TableRegistry.projectRows(filteredRows, selectedRealCols) + } + + private fun matchesWhere(row: Map, where: List): Boolean { + for (cond in where) { + val actual = + row[cond.column] + ?: row.entries.firstOrNull { + it.key.equals(cond.column, ignoreCase = true) + }?.value + ?: return false + + when (cond.op) { + WhereOp.EQ -> + if (!actual.equals(cond.value, ignoreCase = true)) return false + + WhereOp.LIKE -> + if (!likeMatch(actual, cond.value)) return false + } + } + return true + } + + private fun likeMatch(actual: String, pattern: String): Boolean { + val escaped = Regex.escape(pattern) + .replace("%", ".*") + .replace("_", ".") + val re = Regex("^$escaped$", RegexOption.IGNORE_CASE) + return re.matches(actual) + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/FleetPreferences.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/FleetPreferences.kt new file mode 100644 index 00000000000..52fe56b9399 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/FleetPreferences.kt @@ -0,0 +1,31 @@ +package com.fleetdm.agent.osquery + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.first + +private val Context.dataStore by preferencesDataStore(name = "fleet_prefs") + +object FleetPreferences { + + private val NODE_KEY = stringPreferencesKey("fleet_node_key") + + suspend fun getNodeKey(context: Context): String? { + return context.dataStore.data.first()[NODE_KEY] + } + + suspend fun setNodeKey(context: Context, nodeKey: String) { + context.dataStore.edit { prefs -> + prefs[NODE_KEY] = nodeKey + } + } + + suspend fun clearNodeKey(context: Context) { + context.dataStore.edit { prefs -> + prefs.remove(NODE_KEY) + } + } +} + diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/OsqueryQueryEngine.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/OsqueryQueryEngine.kt new file mode 100644 index 00000000000..63276261666 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/OsqueryQueryEngine.kt @@ -0,0 +1,68 @@ +package com.fleetdm.agent.osquery + +import com.fleetdm.agent.osquery.core.TableQueryContext +import com.fleetdm.agent.osquery.core.TableRegistry +import com.fleetdm.agent.osquery.core.WhereCond +import com.fleetdm.agent.osquery.core.WhereOp +import com.fleetdm.agent.osquery.core.parseSelectSql + +/** + * Tiny, "good enough" osquery-ish executor: + * - Supports SELECT * / SELECT col1,col2 + * - FROM
+ * - WHERE with AND + (= | LIKE) + */ +object OsqueryQueryEngine { + + suspend fun execute(sql: String): List> { + val parsed = parseSelectSql(sql) + + val fullRows = TableRegistry.runTable(parsed.tableName, TableQueryContext()) + + val filteredRows = if (parsed.where.isEmpty()) { + fullRows + } else { + fullRows.filter { row -> matchesWhere(row, parsed.where) } + } + + if (parsed.selectAll) return filteredRows + + val schemaCols = TableRegistry.getColumns(parsed.tableName).map { it.name } + val schemaLowerToReal = schemaCols.associateBy { it.lowercase() } + + val selectedRealCols = parsed.selectedColumns.mapNotNull { sel -> + schemaLowerToReal[sel.lowercase()] + } + + if (selectedRealCols.size != parsed.selectedColumns.size) { + val missing = parsed.selectedColumns.filter { schemaLowerToReal[it.lowercase()] == null } + throw IllegalArgumentException( + "Unknown columns for table '${parsed.tableName}': ${missing.joinToString(", ")}" + ) + } + + return TableRegistry.projectRows(filteredRows, selectedRealCols) + } + + private fun matchesWhere(row: Map, where: List): Boolean { + for (cond in where) { + val actual = row[cond.column] + ?: row.entries.firstOrNull { it.key.equals(cond.column, ignoreCase = true) }?.value + ?: return false + + when (cond.op) { + WhereOp.EQ -> if (!actual.equals(cond.value, ignoreCase = true)) return false + WhereOp.LIKE -> if (!likeMatch(actual, cond.value)) return false + } + } + return true + } + + private fun likeMatch(actual: String, pattern: String): Boolean { + val escaped = Regex.escape(pattern) + .replace("%", ".*") + .replace("_", ".") + val re = Regex("^$escaped$", RegexOption.IGNORE_CASE) + return re.matches(actual) + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/OsqueryTables.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/OsqueryTables.kt new file mode 100644 index 00000000000..18d7ac01e08 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/OsqueryTables.kt @@ -0,0 +1,61 @@ +package com.fleetdm.agent.osquery + +import com.fleetdm.agent.osquery.core.TableRegistry +import com.fleetdm.agent.osquery.tables.AppPermissionsTable +import com.fleetdm.agent.osquery.tables.InstalledAppsTable +import com.fleetdm.agent.osquery.tables.OsVersionTable +import com.fleetdm.agent.osquery.tables.OsqueryInfoTable +import com.fleetdm.agent.osquery.tables.CertificatesTable +import com.fleetdm.agent.osquery.tables.DeviceInfoTable +import com.fleetdm.agent.osquery.tables.NetworkInterfacesTable +import com.fleetdm.agent.osquery.tables.BatteryTable +import com.fleetdm.agent.osquery.tables.WifiNetworksTable +import com.fleetdm.agent.osquery.tables.SystemPropertiesTable +import com.fleetdm.agent.osquery.tables.AndroidLogcatTable +import com.fleetdm.agent.osquery.tables.TimeTable +import com.fleetdm.agent.osquery.tables.UptimeTable +import com.fleetdm.agent.osquery.tables.SystemInfoTable +import com.fleetdm.agent.osquery.tables.KernelInfoTable +import com.fleetdm.agent.osquery.tables.MemoryInfoTable +import com.fleetdm.agent.osquery.tables.ProcessesTable +import com.fleetdm.agent.osquery.tables.InterfaceAddressesTable +import com.fleetdm.agent.osquery.tables.RoutesTable +import com.fleetdm.agent.osquery.tables.UsersTable +import com.fleetdm.agent.osquery.tables.MountsTable +import com.fleetdm.agent.osquery.tables.CpuInfoTable +import com.fleetdm.agent.osquery.tables.AppSignaturesTable +import com.fleetdm.agent.osquery.tables.MdmStatusTable +import com.fleetdm.agent.osquery.tables.StartupItemsTable + + + +object OsqueryTables { + fun registerAll(context: android.content.Context) { + TableRegistry.register(InstalledAppsTable(context)) + TableRegistry.register(AppPermissionsTable(context)) + TableRegistry.register(OsVersionTable()) + TableRegistry.register(OsqueryInfoTable(context)) + TableRegistry.register(CertificatesTable()) + TableRegistry.register(DeviceInfoTable()) + TableRegistry.register(NetworkInterfacesTable(context)) + TableRegistry.register(BatteryTable(context)) + TableRegistry.register(WifiNetworksTable(context)) + TableRegistry.register(SystemPropertiesTable()) + TableRegistry.register(AndroidLogcatTable(context)) + TableRegistry.register(TimeTable()) + TableRegistry.register(UptimeTable()) + TableRegistry.register(SystemInfoTable(context)) + TableRegistry.register(KernelInfoTable()) + TableRegistry.register(MemoryInfoTable(context)) + TableRegistry.register(ProcessesTable(context)) + TableRegistry.register(InterfaceAddressesTable()) + TableRegistry.register(RoutesTable()) + TableRegistry.register(UsersTable()) + TableRegistry.register(MountsTable()) + TableRegistry.register(CpuInfoTable()) + TableRegistry.register(AppSignaturesTable(context)) + TableRegistry.register(MdmStatusTable(context)) + TableRegistry.register(StartupItemsTable(context)) + + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/OsqueryWorker.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/OsqueryWorker.kt new file mode 100644 index 00000000000..18374350e03 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/OsqueryWorker.kt @@ -0,0 +1,84 @@ +package com.fleetdm.agent.osquery + +import android.content.Context +import android.util.Log +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.fleetdm.agent.BuildConfig +import java.util.concurrent.TimeUnit +import kotlin.random.Random + + +class OsqueryWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + + companion object { + private val lock = Any() + private var running = false + } + + override suspend fun doWork(): Result { + synchronized(lock) { + if (running) { + Log.i("FleetOsquery", "OsqueryWorker already running, skipping") + return Result.success() + } + running = true + } + + try { + Log.i("FleetOsquery", "OsqueryWorker doWork start") + + OsqueryTables.registerAll(applicationContext) + Log.i("FleetOsquery", "About to call FleetDistributedQueryRunner.runOnce()") + FleetDistributedQueryRunner.runOnce() + Log.i("FleetOsquery", "FleetDistributedQueryRunner.runOnce() finished") + + scheduleNext() + return Result.success() + } catch (e: IllegalArgumentException) { + Log.e("FleetOsquery", "OsqueryWorker misconfigured: ${e.message}", e) + return Result.failure() + } catch (e: Exception) { + Log.e("FleetOsquery", "OsqueryWorker error", e) + scheduleNext() + return Result.retry() + } finally { + synchronized(lock) { running = false } + } + } + + private fun scheduleNext() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val baseDelaySeconds = + if (BuildConfig.DEBUG) 5L else 60L + + val jitterSeconds = + if (BuildConfig.DEBUG) 0L else Random.nextLong(from = 0L, until = 15L) + + val delaySeconds = baseDelaySeconds + jitterSeconds + + val next = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setInitialDelay(delaySeconds, TimeUnit.SECONDS) + .build() + + WorkManager.getInstance(applicationContext).enqueueUniqueWork( + "fleetOsqueryLoop", + ExistingWorkPolicy.REPLACE, + next, + ) + } + + +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/core/OsqueryIdentityStore.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/core/OsqueryIdentityStore.kt new file mode 100644 index 00000000000..f3399157178 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/core/OsqueryIdentityStore.kt @@ -0,0 +1,24 @@ +package com.fleetdm.agent.osquery.core + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.first +import java.util.UUID + +private val Context.osqueryDataStore by preferencesDataStore(name = "osquery") + +object OsqueryIdentityStore { + private val KEY_UUID = stringPreferencesKey("osquery_uuid") + + suspend fun getOrCreateUuid(context: Context): String { + val prefs = context.osqueryDataStore.data.first() + val existing = prefs[KEY_UUID] + if (!existing.isNullOrBlank()) return existing + + val created = UUID.randomUUID().toString() + context.osqueryDataStore.edit { it[KEY_UUID] = created } + return created + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/core/SqlParser.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/core/SqlParser.kt new file mode 100644 index 00000000000..86eb9de5e1d --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/core/SqlParser.kt @@ -0,0 +1,153 @@ +package com.fleetdm.agent.osquery.core + +enum class WhereOp { EQ, LIKE } + +data class WhereCond( + val column: String, + val op: WhereOp, + val value: String +) + +data class ParsedSql( + val tableName: String, + val selectedColumns: List, + val selectAll: Boolean, + val where: List = emptyList() +) + +/** + * Supports: + * SELECT * FROM table + * SELECT a,b FROM table + * SELECT ... FROM table WHERE col = 'x' + * SELECT ... FROM table WHERE col LIKE '%x%' AND other = "y" + * + * Notes: + * - Only WHERE with AND is supported. + * - Operators supported: = , LIKE + * - Values can be: 'single quoted', "double quoted", or bare tokens without spaces. + */ +fun parseSelectSql(sql: String): ParsedSql { + val s = sql.trim().removeSuffix(";").trim() + + // Capture: + // 1) columns part + // 2) table name + // 3) optional where clause + val re = Regex( + pattern = """(?is)^\s*select\s+(.+?)\s+from\s+([a-zA-Z0-9_]+)(?:\s+where\s+(.+?))?\s*$""" + ) + val m = re.find(s) ?: throw IllegalArgumentException("Bad SQL. Expected: SELECT FROM
[WHERE ...]") + + val colsPart = m.groupValues[1].trim() + val table = m.groupValues[2].trim() + val wherePart = m.groupValues.getOrNull(3)?.trim().orEmpty() + + val (selectAll, cols) = if (colsPart == "*") { + true to emptyList() + } else { + val list = colsPart.split(",").map { it.trim() }.filter { it.isNotEmpty() } + if (list.isEmpty()) throw IllegalArgumentException("No columns selected") + false to list + } + + val where = if (wherePart.isBlank()) emptyList() else parseWhere(wherePart) + + return ParsedSql( + tableName = table, + selectedColumns = cols, + selectAll = selectAll, + where = where + ) +} + +private fun parseWhere(where: String): List { + val parts = splitByAnd(where) + if (parts.isEmpty()) throw IllegalArgumentException("Bad WHERE clause") + + return parts.map { term -> + val t = term.trim() + val m = Regex("""(?is)^\s*([a-zA-Z0-9_]+)\s*(=|like)\s*(.+?)\s*$""").find(t) + ?: throw IllegalArgumentException("Bad WHERE term: $term") + + val col = m.groupValues[1].trim() + val opStr = m.groupValues[2].trim().lowercase() + val rawVal = m.groupValues[3].trim() + + val value = unquote(rawVal) + + val op = when (opStr) { + "=" -> WhereOp.EQ + "like" -> WhereOp.LIKE + else -> throw IllegalArgumentException("Unsupported WHERE operator: $opStr") + } + + WhereCond(column = col, op = op, value = value) + } +} + +/** + * Splits on AND, but respects quotes so this works: + * col = "a and b" AND x = 1 + */ +private fun splitByAnd(s: String): List { + val out = mutableListOf() + val sb = StringBuilder() + + var inSingle = false + var inDouble = false + var i = 0 + + fun flush() { + val part = sb.toString().trim() + if (part.isNotEmpty()) out.add(part) + sb.setLength(0) + } + + while (i < s.length) { + val c = s[i] + + if (c == '\'' && !inDouble) { + inSingle = !inSingle + sb.append(c) + i++ + continue + } + if (c == '"' && !inSingle) { + inDouble = !inDouble + sb.append(c) + i++ + continue + } + + // Check for AND when not inside quotes + if (!inSingle && !inDouble) { + // match case-insensitive " AND " with surrounding whitespace + if (i + 3 <= s.length) { + val remaining = s.substring(i) + val m = Regex("""(?is)^\s+and\s+""").find(remaining) + if (m != null && m.range.first == 0) { + flush() + i += m.value.length + continue + } + } + } + + sb.append(c) + i++ + } + + flush() + return out +} + +private fun unquote(v: String): String { + if (v.length >= 2 && v.first() == '\'' && v.last() == '\'') { + return v.substring(1, v.length - 1) + } + if (v.length >= 2 && v.first() == '"' && v.last() == '"') { + return v.substring(1, v.length - 1) + } + return v +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/core/TableRegistry.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/core/TableRegistry.kt new file mode 100644 index 00000000000..d700280627f --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/core/TableRegistry.kt @@ -0,0 +1,70 @@ +package com.fleetdm.agent.osquery.core + +import org.json.JSONArray +import org.json.JSONObject + +object TableRegistry { + + private val tables = mutableMapOf() + private var initialized = false + + /** + * Must be called once on app startup. + */ + fun ensureRegistered() { + if (initialized) return + initialized = true + } + + fun register(table: TablePlugin) { + tables[table.name.lowercase()] = table + } + + fun getTable(tableName: String): TablePlugin = + tables[tableName.lowercase()] ?: error("unknown table: $tableName") + + fun getColumns(tableName: String): List = + getTable(tableName).columns() + + suspend fun runTable( + tableName: String, + ctx: TableQueryContext + ): List> { + + val t = getTable(tableName) + val schema = t.columns().map { it.name }.toSet() + val rows = t.generate(ctx) + + // strict: table must not return unknown columns + for (row in rows) { + for (k in row.keys) { + require(k in schema) { + "table '$tableName' returned unknown column '$k'" + } + } + } + + // fill missing columns with empty string + return rows.map { row -> + schema.associateWith { col -> row[col] ?: "" } + } + } + + fun projectRows( + rows: List>, + selectedCols: List + ): List> { + val selectedSet = selectedCols.toSet() + return rows.map { row -> row.filterKeys { it in selectedSet } } + } + + fun rowsToJson(rows: List>): String { + val arr = JSONArray() + for (row in rows) { + val obj = JSONObject() + for ((k, v) in row) obj.put(k, v) + arr.put(obj) + } + return arr.toString() + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/core/TableTypes.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/core/TableTypes.kt new file mode 100644 index 00000000000..0ff668994fa --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/core/TableTypes.kt @@ -0,0 +1,14 @@ +package com.fleetdm.agent.osquery.core + +data class ColumnDef(val name: String, val type: String = "TEXT") + +data class TableQueryContext( + val constraints: Map = emptyMap() +) + +interface TablePlugin { + val name: String + fun columns(): List + suspend fun generate(ctx: TableQueryContext): List> +} + diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/AndroidLogcatTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/AndroidLogcatTable.kt new file mode 100644 index 00000000000..c3b4886c0c3 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/AndroidLogcatTable.kt @@ -0,0 +1,105 @@ +package com.fleetdm.agent.osquery.tables + +import android.content.Context +import android.content.RestrictionsManager +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TableQueryContext +import com.fleetdm.agent.osquery.core.TablePlugin +import java.io.BufferedReader +import java.io.InputStreamReader +import java.util.concurrent.TimeUnit + +class AndroidLogcatTable(private val context: Context) : TablePlugin { + + override val name: String = "android_logcat" + + override fun columns(): List = listOf( + ColumnDef("timestamp"), + ColumnDef("level"), + ColumnDef("tag"), + ColumnDef("message"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + if (!isLogcatTableEnabled()) return emptyList() + + val rows = mutableListOf>() + val command = + listOf( + "logcat", "-d", "-v", "brief", + "fleet-app:V", + "fleet-ApiClient:V", + "fleet-distributed:V", + "fleet-CertificateEnrollmentWorker:V", + "fleet-CertificateOrchestrator:V", + "fleet-boot:V", + "fleet-RoleNotificationReceiverService:V", + "fleet-crash:V", + "FleetOsquery:V", + "*:S", + ).toTypedArray() + + try { + val process = Runtime.getRuntime().exec(command) + + BufferedReader(InputStreamReader(process.inputStream)).useLines { lines -> + lines.take(200).forEach { line -> + // Example: + // D/TagName( 1234): message + val regex = Regex("""^([VDIWEF])\/([^ ]+).*?: (.*)$""") + val match = regex.find(line) ?: return@forEach + + val (level, tag, message) = match.destructured + + rows.add( + mapOf( + "timestamp" to System.currentTimeMillis().toString(), + "level" to level, + "tag" to tag, + "message" to redactSensitiveValues(message).take(500), + ) + ) + } + } + + if (!process.waitFor(2, TimeUnit.SECONDS)) { + process.destroyForcibly() + } + } catch (_: Exception) { + // osquery tables must never crash the agent + } + + return rows + } + + private fun isLogcatTableEnabled(): Boolean { + val restrictionsManager = context.getSystemService(Context.RESTRICTIONS_SERVICE) as? RestrictionsManager + ?: return false + val appRestrictions = restrictionsManager.applicationRestrictions ?: return false + val key = "enable_android_logcat_table" + + if (!appRestrictions.containsKey(key)) return false + + if (appRestrictions.getBoolean(key, false)) return true + return appRestrictions.getString(key)?.equals("true", ignoreCase = true) == true + } + + private fun redactSensitiveValues(input: String): String { + val patterns = + listOf( + Regex("(?i)(authorization\\s*[:=]\\s*)(\\S+)"), + Regex("(?i)(bearer\\s+)([A-Za-z0-9._-]+)"), + Regex("(?i)(node[_ -]?key\\s*[:=]\\s*)(\\S+)"), + Regex("(?i)(api[_ -]?key\\s*[:=]\\s*)(\\S+)"), + Regex("(?i)(enroll_secret\\s*[:=]\\s*)(\\S+)"), + Regex("(?i)(token\\s*[:=]\\s*)(\\S+)"), + Regex("(?i)(password\\s*[:=]\\s*)(\\S+)"), + ) + + var redacted = input + for (pattern in patterns) { + redacted = pattern.replace(redacted, "$1") + } + return redacted + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/AppPermissionsTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/AppPermissionsTable.kt new file mode 100644 index 00000000000..9a39a2d972b --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/AppPermissionsTable.kt @@ -0,0 +1,110 @@ +package com.fleetdm.agent.osquery.tables + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.PermissionInfo +import android.os.Build +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext + +class AppPermissionsTable(private val context: Context) : TablePlugin { + override val name: String = "app_permissions" + + override fun columns(): List = listOf( + ColumnDef("package_name"), + ColumnDef("app_name"), + ColumnDef("permission"), + ColumnDef("protection_level"), // normal | dangerous | signature | other + ColumnDef("granted"), // true | false + ColumnDef("is_system"), // true | false + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val pm = context.packageManager + + val packages: List = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getInstalledPackages(PackageManager.PackageInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + pm.getInstalledPackages(0) + } + + val rows = mutableListOf>() + + for (pi in packages) { + val pkg = pi.packageName ?: continue + val appInfo = pi.applicationInfo ?: continue + + val appLabel = try { + pm.getApplicationLabel(appInfo).toString() + } catch (_: Exception) { + "" + } + + val isSystem = + (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0 + + // Need requestedPermissions -> fetch PackageInfo with GET_PERMISSIONS + val piWithPerms: PackageInfo = try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getPackageInfo( + pkg, + PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong()) + ) + } else { + @Suppress("DEPRECATION") + pm.getPackageInfo(pkg, PackageManager.GET_PERMISSIONS) + } + } catch (_: Exception) { + continue + } + + val requested = piWithPerms.requestedPermissions ?: emptyArray() + if (requested.isEmpty()) continue + + for (perm in requested) { + if (perm.isNullOrBlank()) continue + + val granted = + pm.checkPermission(perm, pkg) == PackageManager.PERMISSION_GRANTED + + val protection = getProtectionLevelString(pm, perm) + + rows.add( + mapOf( + "package_name" to pkg, + "app_name" to appLabel, + "permission" to perm, + "protection_level" to protection, + "granted" to granted.toString(), + "is_system" to isSystem.toString(), + ) + ) + } + } + + return rows + } + + private fun getProtectionLevelString(pm: PackageManager, permission: String): String { + val pi: PermissionInfo = try { + pm.getPermissionInfo(permission, 0) + } catch (_: Exception) { + return "other" + } + + val base = pi.protectionLevel and PermissionInfo.PROTECTION_MASK_BASE + + return when (base) { + PermissionInfo.PROTECTION_NORMAL -> "normal" + PermissionInfo.PROTECTION_DANGEROUS -> "dangerous" + PermissionInfo.PROTECTION_SIGNATURE -> "signature" + else -> "other" + } + } +} + + diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/AppSignaturesTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/AppSignaturesTable.kt new file mode 100644 index 00000000000..491366bd516 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/AppSignaturesTable.kt @@ -0,0 +1,78 @@ +package com.fleetdm.agent.osquery.tables + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext +import java.security.MessageDigest +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate + +class AppSignaturesTable(private val context: Context) : TablePlugin { + override val name: String = "app_signatures" + + override fun columns(): List = listOf( + ColumnDef("app_name"), + ColumnDef("package_name"), + ColumnDef("sha256"), + ColumnDef("subject"), + ColumnDef("issuer"), + ColumnDef("version_name"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val pm = context.packageManager + val packages = getInstalledPackages(pm) + val out = mutableListOf>() + + for (pkg in packages) { + val signers = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + pkg.signingInfo?.apkContentsSigners?.toList().orEmpty() + } else { + @Suppress("DEPRECATION") + pkg.signatures?.toList().orEmpty() + } + if (signers.isEmpty()) continue + + val signer = signers.first() + val cert = parseX509(signer.toByteArray()) + val digest = sha256Hex(signer.toByteArray()) + out.add( + mapOf( + "app_name" to (pkg.applicationInfo?.loadLabel(pm)?.toString() ?: pkg.packageName), + "package_name" to pkg.packageName, + "sha256" to digest, + "subject" to (cert?.subjectX500Principal?.name ?: ""), + "issuer" to (cert?.issuerX500Principal?.name ?: ""), + "version_name" to (pkg.versionName ?: ""), + ), + ) + } + + return out + } + + private fun getInstalledPackages(pm: PackageManager) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getInstalledPackages( + PackageManager.PackageInfoFlags.of( + (PackageManager.GET_SIGNING_CERTIFICATES or PackageManager.GET_META_DATA).toLong(), + ), + ) + } else { + @Suppress("DEPRECATION") + pm.getInstalledPackages(PackageManager.GET_SIGNATURES or PackageManager.GET_META_DATA) + } + + private fun parseX509(bytes: ByteArray): X509Certificate? = runCatching { + val cf = CertificateFactory.getInstance("X.509") + cf.generateCertificate(bytes.inputStream()) as X509Certificate + }.getOrNull() + + private fun sha256Hex(bytes: ByteArray): String { + val md = MessageDigest.getInstance("SHA-256") + return md.digest(bytes).joinToString("") { "%02x".format(it) } + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/BatteryTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/BatteryTable.kt new file mode 100644 index 00000000000..931fb490e83 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/BatteryTable.kt @@ -0,0 +1,89 @@ +package com.fleetdm.agent.osquery.tables + +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.BatteryManager +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext + +class BatteryTable( + private val context: Context, +) : TablePlugin { + + override val name: String = "battery" + + override fun columns(): List = listOf( + ColumnDef("percent_remaining"), + ColumnDef("charging"), + ColumnDef("plugged"), + ColumnDef("health"), + ColumnDef("status"), + ColumnDef("technology"), + ColumnDef("temperature_c"), + ColumnDef("voltage_mv"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val intent = context.registerReceiver( + null, + IntentFilter(Intent.ACTION_BATTERY_CHANGED), + ) ?: return emptyList() + + val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) + val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1) + val percent = if (level >= 0 && scale > 0) { + (level * 100 / scale).toString() + } else { + "" + } + + val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) + val charging = ( + status == BatteryManager.BATTERY_STATUS_CHARGING || + status == BatteryManager.BATTERY_STATUS_FULL + ).toString() + + val plugged = when (intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1)) { + BatteryManager.BATTERY_PLUGGED_AC -> "ac" + BatteryManager.BATTERY_PLUGGED_USB -> "usb" + BatteryManager.BATTERY_PLUGGED_WIRELESS -> "wireless" + else -> "unplugged" + } + + val health = when (intent.getIntExtra(BatteryManager.EXTRA_HEALTH, -1)) { + BatteryManager.BATTERY_HEALTH_GOOD -> "good" + BatteryManager.BATTERY_HEALTH_OVERHEAT -> "overheat" + BatteryManager.BATTERY_HEALTH_DEAD -> "dead" + BatteryManager.BATTERY_HEALTH_OVER_VOLTAGE -> "over_voltage" + BatteryManager.BATTERY_HEALTH_UNSPECIFIED_FAILURE -> "failure" + BatteryManager.BATTERY_HEALTH_COLD -> "cold" + else -> "unknown" + } + + val statusStr = when (status) { + BatteryManager.BATTERY_STATUS_CHARGING -> "charging" + BatteryManager.BATTERY_STATUS_DISCHARGING -> "discharging" + BatteryManager.BATTERY_STATUS_FULL -> "full" + BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "not_charging" + else -> "unknown" + } + + val tempC = intent.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) / 10.0 + val voltage = intent.getIntExtra(BatteryManager.EXTRA_VOLTAGE, 0) + + return listOf( + mapOf( + "percent_remaining" to percent, + "charging" to charging, + "plugged" to plugged, + "health" to health, + "status" to statusStr, + "technology" to (intent.getStringExtra(BatteryManager.EXTRA_TECHNOLOGY) ?: ""), + "temperature_c" to tempC.toString(), + "voltage_mv" to voltage.toString(), + ), + ) + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/CertificatesTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/CertificatesTable.kt new file mode 100644 index 00000000000..fed9ebf8cd3 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/CertificatesTable.kt @@ -0,0 +1,74 @@ +package com.fleetdm.agent.osquery.tables + +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext +import java.security.KeyStore +import java.security.MessageDigest +import java.security.cert.X509Certificate +import java.util.Locale + +class CertificatesTable : TablePlugin { + override val name: String = "certificates" + + override fun columns(): List = listOf( + ColumnDef("alias"), + ColumnDef("subject"), + ColumnDef("issuer"), + ColumnDef("serial"), + ColumnDef("not_before"), + ColumnDef("not_after"), + ColumnDef("sha256"), + ColumnDef("store"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val rows = mutableListOf>() + + // This is the important part: + // AndroidCAStore exposes system+user trusted CA certs. + val ks = KeyStore.getInstance("AndroidCAStore") + ks.load(null) + + val aliases = ks.aliases() + while (aliases.hasMoreElements()) { + val alias = aliases.nextElement() + + val cert = ks.getCertificate(alias) + val x509 = cert as? X509Certificate ?: continue + + rows.add( + mapOf( + "alias" to alias, + "subject" to (x509.subjectX500Principal?.name ?: ""), + "issuer" to (x509.issuerX500Principal?.name ?: ""), + "serial" to (x509.serialNumber?.toString() ?: ""), + "not_before" to (x509.notBefore?.toInstant()?.toString().orEmpty()), + "not_after" to (x509.notAfter?.toInstant()?.toString().orEmpty()), + "sha256" to sha256Hex(x509.encoded), + "store" to storeLabelFromAlias(alias), + ), + ) + } + + return rows + } + + private fun sha256Hex(bytes: ByteArray): String { + val md = MessageDigest.getInstance("SHA-256") + val digest = md.digest(bytes) + return digest.joinToString("") { "%02x".format(it) } + } + + private fun storeLabelFromAlias(alias: String): String { + // Convention in AndroidCAStore: + // "system:" prefix = system store + // "user:" prefix = user-installed + val a = alias.lowercase(Locale.ROOT) + return when { + a.startsWith("system:") -> "system" + a.startsWith("user:") -> "user" + else -> "unknown" + } + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/CpuInfoTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/CpuInfoTable.kt new file mode 100644 index 00000000000..ffa22e0b31e --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/CpuInfoTable.kt @@ -0,0 +1,54 @@ +package com.fleetdm.agent.osquery.tables + +import android.os.Build +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext +import java.io.File + +class CpuInfoTable : TablePlugin { + override val name: String = "cpu_info" + + override fun columns(): List = listOf( + ColumnDef("cores"), + ColumnDef("arch"), + ColumnDef("model"), + ColumnDef("hardware"), + ColumnDef("vendor"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val cores = Runtime.getRuntime().availableProcessors().coerceAtLeast(0) + val arch = Build.SUPPORTED_ABIS.firstOrNull().orEmpty() + val hardware = Build.HARDWARE.orEmpty() + val model = readCpuInfoValue("model name") + ?: readCpuInfoValue("Processor") + ?: Build.MODEL.orEmpty() + val vendor = readCpuInfoValue("vendor_id") + ?: readCpuInfoValue("Hardware") + ?: Build.MANUFACTURER.orEmpty() + + return listOf( + mapOf( + "cores" to cores.toString(), + "arch" to arch, + "model" to model, + "hardware" to hardware, + "vendor" to vendor, + ), + ) + } + + private fun readCpuInfoValue(key: String): String? { + return runCatching { + val f = File("/proc/cpuinfo") + if (!f.exists()) return null + f.useLines { lines -> + lines.firstOrNull { it.startsWith("$key", ignoreCase = true) } + ?.substringAfter(":", "") + ?.trim() + ?.takeIf { it.isNotEmpty() } + } + }.getOrNull() + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/DemoABTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/DemoABTable.kt new file mode 100644 index 00000000000..8c10de7ba0b --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/DemoABTable.kt @@ -0,0 +1,25 @@ +package com.fleetdm.agent.osquery.tables + +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext + +object DemoABTable : TablePlugin { + override val name: String = "demo_ab" + + override fun columns(): List = listOf( + ColumnDef("A"), + ColumnDef("B"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + return listOf( + mapOf( + "A" to "A", + "B" to "B", + ) + ) + } +} + + diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/DeviceInfoTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/DeviceInfoTable.kt new file mode 100644 index 00000000000..66b67ab44b6 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/DeviceInfoTable.kt @@ -0,0 +1,45 @@ +package com.fleetdm.agent.osquery.tables + +import android.os.Build +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext +import java.util.Locale + +class DeviceInfoTable : TablePlugin { + override val name: String = "device_info" + + override fun columns(): List = listOf( + ColumnDef("device"), + ColumnDef("model"), + ColumnDef("manufacturer"), + ColumnDef("brand"), + ColumnDef("product"), + ColumnDef("hardware"), + ColumnDef("board"), + ColumnDef("fingerprint"), + ColumnDef("bootloader"), + ColumnDef("tags"), + ColumnDef("type"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + fun s(v: String?): String = (v ?: "").trim() + + return listOf( + mapOf( + "device" to s(Build.DEVICE), + "model" to s(Build.MODEL), + "manufacturer" to s(Build.MANUFACTURER), + "brand" to s(Build.BRAND), + "product" to s(Build.PRODUCT), + "hardware" to s(Build.HARDWARE), + "board" to s(Build.BOARD), + "fingerprint" to s(Build.FINGERPRINT), + "bootloader" to s(Build.BOOTLOADER), + "tags" to s(Build.TAGS).lowercase(Locale.ROOT), + "type" to s(Build.TYPE).lowercase(Locale.ROOT), + ), + ) + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/InstalledAppsTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/InstalledAppsTable.kt new file mode 100644 index 00000000000..f9b2d82c0ea --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/InstalledAppsTable.kt @@ -0,0 +1,66 @@ +package com.fleetdm.agent.osquery.tables + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext + +class InstalledAppsTable(private val context: Context) : TablePlugin { + override val name: String = "installed_apps" + + override fun columns(): List = listOf( + ColumnDef("package_name"), + ColumnDef("app_name"), + ColumnDef("version_name"), + ColumnDef("version_code"), + ColumnDef("first_install_time"), + ColumnDef("last_update_time"), + ColumnDef("is_system"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val pm = context.packageManager + + val packages: List = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getInstalledPackages(PackageManager.PackageInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + pm.getInstalledPackages(0) + } + + return packages.mapNotNull { pi -> + val appInfo = pi.applicationInfo ?: return@mapNotNull null + + val appLabel = try { + pm.getApplicationLabel(appInfo).toString() + } catch (_: Exception) { + "" + } + + val isSystem = + (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0 + + val versionCode = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + pi.longVersionCode.toString() + } else { + @Suppress("DEPRECATION") + pi.versionCode.toString() + } + + mapOf( + "package_name" to pi.packageName, + "app_name" to appLabel, + "version_name" to (pi.versionName ?: ""), + "version_code" to versionCode, + "first_install_time" to pi.firstInstallTime.toString(), + "last_update_time" to pi.lastUpdateTime.toString(), + "is_system" to isSystem.toString(), + ) + } + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/InterfaceAddressesTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/InterfaceAddressesTable.kt new file mode 100644 index 00000000000..9cb1e3a2ff6 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/InterfaceAddressesTable.kt @@ -0,0 +1,39 @@ +package com.fleetdm.agent.osquery.tables + +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext +import java.net.NetworkInterface + +class InterfaceAddressesTable : TablePlugin { + override val name: String = "interface_addresses" + + override fun columns(): List = listOf( + ColumnDef("interface"), + ColumnDef("address"), + ColumnDef("family"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + return runCatching { + val rows = mutableListOf>() + val interfaces = NetworkInterface.getNetworkInterfaces() ?: return@runCatching emptyList>() + while (interfaces.hasMoreElements()) { + val ni = interfaces.nextElement() + val addrs = ni.inetAddresses + while (addrs.hasMoreElements()) { + val addr = addrs.nextElement() + val family = if (addr.hostAddress?.contains(":") == true) "ipv6" else "ipv4" + rows.add( + mapOf( + "interface" to (ni.name ?: ""), + "address" to (addr.hostAddress ?: ""), + "family" to family, + ), + ) + } + } + rows + }.getOrElse { emptyList() } + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/KernelInfoTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/KernelInfoTable.kt new file mode 100644 index 00000000000..20274070e99 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/KernelInfoTable.kt @@ -0,0 +1,30 @@ +package com.fleetdm.agent.osquery.tables + +import android.os.Build +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext + +class KernelInfoTable : TablePlugin { + override val name: String = "kernel_info" + + override fun columns(): List = listOf( + ColumnDef("version"), + ColumnDef("release"), + ColumnDef("build"), + ColumnDef("platform"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val osVersion = System.getProperty("os.version").orEmpty() + + return listOf( + mapOf( + "version" to osVersion, + "release" to Build.VERSION.RELEASE.orEmpty(), + "build" to Build.ID.orEmpty(), + "platform" to "android", + ), + ) + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/MdmStatusTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/MdmStatusTable.kt new file mode 100644 index 00000000000..7cd1cbefabe --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/MdmStatusTable.kt @@ -0,0 +1,57 @@ +package com.fleetdm.agent.osquery.tables + +import android.app.admin.DevicePolicyManager +import android.content.Context +import android.content.RestrictionsManager +import android.os.Build +import android.os.UserManager +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext + +class MdmStatusTable(private val context: Context) : TablePlugin { + override val name: String = "mdm_status" + + override fun columns(): List = listOf( + ColumnDef("has_device_owner"), + ColumnDef("device_owner_package"), + ColumnDef("has_work_profile"), + ColumnDef("restrictions_present"), + ColumnDef("enroll_secret_present"), + ColumnDef("host_uuid_present"), + ColumnDef("server_url_present"), + ColumnDef("is_debug_build"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as? DevicePolicyManager + val um = context.getSystemService(Context.USER_SERVICE) as? UserManager + val rm = context.getSystemService(Context.RESTRICTIONS_SERVICE) as? RestrictionsManager + val restrictions = rm?.applicationRestrictions + + val hasDeviceOwner = dpm?.isDeviceOwnerApp(context.packageName) == true + val ownerPkg = if (hasDeviceOwner) context.packageName else "" + val hasWorkProfile = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) um?.isManagedProfile == true else false + + val enrollPresent = !restrictions?.getString("enroll_secret").isNullOrBlank() + val hostPresent = !restrictions?.getString("host_uuid").isNullOrBlank() + val serverPresent = !restrictions?.getString("server_url").isNullOrBlank() + val restrictionsPresent = restrictions != null && restrictions.keySet().isNotEmpty() + + return listOf( + mapOf( + "has_device_owner" to bool01(hasDeviceOwner), + "device_owner_package" to ownerPkg, + "has_work_profile" to bool01(hasWorkProfile), + "restrictions_present" to bool01(restrictionsPresent), + "enroll_secret_present" to bool01(enrollPresent), + "host_uuid_present" to bool01(hostPresent), + "server_url_present" to bool01(serverPresent), + "is_debug_build" to bool01(com.fleetdm.agent.BuildConfig.DEBUG), + ), + ) + } + + private fun bool01(v: Boolean) = if (v) "1" else "0" +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/MemoryInfoTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/MemoryInfoTable.kt new file mode 100644 index 00000000000..1484d2ab01a --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/MemoryInfoTable.kt @@ -0,0 +1,33 @@ +package com.fleetdm.agent.osquery.tables + +import android.app.ActivityManager +import android.content.Context +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext + +class MemoryInfoTable(private val context: Context) : TablePlugin { + override val name: String = "memory_info" + + override fun columns(): List = listOf( + ColumnDef("total_bytes"), + ColumnDef("available_bytes"), + ColumnDef("threshold_bytes"), + ColumnDef("low_memory"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val memoryInfo = ActivityManager.MemoryInfo() + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager + activityManager?.getMemoryInfo(memoryInfo) + + return listOf( + mapOf( + "total_bytes" to memoryInfo.totalMem.coerceAtLeast(0L).toString(), + "available_bytes" to memoryInfo.availMem.coerceAtLeast(0L).toString(), + "threshold_bytes" to memoryInfo.threshold.coerceAtLeast(0L).toString(), + "low_memory" to if (memoryInfo.lowMemory) "1" else "0", + ), + ) + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/MountsTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/MountsTable.kt new file mode 100644 index 00000000000..7b88402f123 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/MountsTable.kt @@ -0,0 +1,34 @@ +package com.fleetdm.agent.osquery.tables + +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext +import java.io.File + +class MountsTable : TablePlugin { + override val name: String = "mounts" + + override fun columns(): List = listOf( + ColumnDef("device"), + ColumnDef("path"), + ColumnDef("type"), + ColumnDef("flags"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + return runCatching { + val f = File("/proc/mounts") + if (!f.exists()) return@runCatching emptyList>() + f.readLines().mapNotNull { line -> + val parts = line.split(Regex("\\s+")) + if (parts.size < 4) return@mapNotNull null + mapOf( + "device" to parts[0], + "path" to parts[1], + "type" to parts[2], + "flags" to parts[3], + ) + } + }.getOrElse { emptyList() } + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/NetworkInterfacesTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/NetworkInterfacesTable.kt new file mode 100644 index 00000000000..f35ebde0b92 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/NetworkInterfacesTable.kt @@ -0,0 +1,65 @@ +package com.fleetdm.agent.osquery.tables + +import android.content.Context +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext +import java.net.NetworkInterface +import java.util.Collections + +class NetworkInterfacesTable(private val context: Context) : TablePlugin { + override val name: String = "network_interfaces" + + override fun columns(): List = listOf( + ColumnDef("name"), + ColumnDef("is_up"), // true | false + ColumnDef("mac"), + ColumnDef("mtu"), + ColumnDef("is_loopback"), // true | false + ColumnDef("is_virtual"), // true | false + ColumnDef("addresses"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val rows = mutableListOf>() + + val en = try { + NetworkInterface.getNetworkInterfaces() + } catch (_: Exception) { + null + } ?: return rows + + for (ni in Collections.list(en)) { + val ifaceName = ni.name ?: continue + + val mac = try { + val hw = ni.hardwareAddress + if (hw == null) "" else hw.joinToString(":") { b -> "%02x".format(b) } + } catch (_: Exception) { + "" + } + + val addresses = try { + Collections.list(ni.inetAddresses) + .mapNotNull { it.hostAddress } + .joinToString(",") + } catch (_: Exception) { + "" + } + + rows.add( + mapOf( + "name" to ifaceName, + "is_up" to ni.isUp.toString(), + "mac" to mac, + "mtu" to ni.mtu.toString(), + "is_loopback" to ni.isLoopback.toString(), + "is_virtual" to ni.isVirtual.toString(), + "addresses" to addresses, + ) + ) + } + + return rows + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/OsVersionTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/OsVersionTable.kt new file mode 100644 index 00000000000..f5db907125d --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/OsVersionTable.kt @@ -0,0 +1,65 @@ +package com.fleetdm.agent.osquery.tables + +import android.os.Build +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext +import java.util.Locale + +class OsVersionTable : TablePlugin { + override val name: String = "os_version" + + override fun columns(): List = listOf( + ColumnDef("name"), + ColumnDef("version"), + ColumnDef("major"), + ColumnDef("minor"), + ColumnDef("patch"), + ColumnDef("build"), + ColumnDef("platform"), + ColumnDef("arch"), + ColumnDef("security_patch"), + ) + + + override suspend fun generate(ctx: TableQueryContext): List> { + val versionName = Build.VERSION.RELEASE ?: "" + val major = Build.VERSION.SDK_INT.toString() + + val buildId = Build.ID ?: "" + val platform = "android" + val osName = "Android" + + val arch = Build.SUPPORTED_ABIS.firstOrNull() ?: "" + val securityPatch = Build.VERSION.SECURITY_PATCH ?: "" + + val (minor, patch) = parseMinorPatch(versionName) + + return listOf( + mapOf( + "name" to osName, + "version" to versionName, + "major" to major, + "minor" to minor, + "patch" to patch, + "build" to buildId, + "platform" to platform, + "arch" to arch, + "security_patch" to securityPatch, + ), + ) + } + + + private fun parseMinorPatch(version: String): Pair { + // Android version often looks like "14" or "13" or "12.1" + // We keep major as SDK_INT, and try to extract minor and patch from RELEASE if present. + val parts = version.trim().split(".") + .map { it.trim() } + .filter { it.isNotEmpty() } + + val minor = parts.getOrNull(1)?.takeWhile { it.isDigit() } ?: "0" + val patch = parts.getOrNull(2)?.takeWhile { it.isDigit() } ?: "0" + return minor.lowercase(Locale.ROOT) to patch.lowercase(Locale.ROOT) + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/OsqueryInfoTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/OsqueryInfoTable.kt new file mode 100644 index 00000000000..96ddba62a47 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/OsqueryInfoTable.kt @@ -0,0 +1,40 @@ +package com.fleetdm.agent.osquery.tables + +import android.content.Context +import com.fleetdm.agent.BuildConfig +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.OsqueryIdentityStore +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext + +class OsqueryInfoTable(private val context: Context) : TablePlugin { + override val name: String = "osquery_info" + + override fun columns(): List = listOf( + ColumnDef("uuid"), + ColumnDef("instance_id"), + ColumnDef("version"), + ColumnDef("config_hash"), + ColumnDef("extensions"), + ColumnDef("build_platform"), + ColumnDef("build_distro"), + ColumnDef("platform_mask"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val uuid = OsqueryIdentityStore.getOrCreateUuid(context) + + return listOf( + mapOf( + "uuid" to uuid, + "instance_id" to uuid, + "version" to BuildConfig.VERSION_NAME, + "config_hash" to "", + "extensions" to "", + "build_platform" to "android", + "build_distro" to "android", + "platform_mask" to "0", + ), + ) + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/PhoneTimeTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/PhoneTimeTable.kt new file mode 100644 index 00000000000..af70db18e0c --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/PhoneTimeTable.kt @@ -0,0 +1,31 @@ +package com.fleetdm.agent.osquery.tables + + +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext +import java.util.Calendar + +object PhoneTimeTable : TablePlugin { + override val name: String = "phone_time" + + override fun columns(): List = listOf( + ColumnDef("hour"), + ColumnDef("minute"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val cal = Calendar.getInstance() + val hour = cal.get(Calendar.HOUR_OF_DAY) + val minute = cal.get(Calendar.MINUTE) + + return listOf( + mapOf( + "hour" to hour.toString(), + "minute" to minute.toString(), + ) + ) + } +} + + diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/ProcessesTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/ProcessesTable.kt new file mode 100644 index 00000000000..fcdd6f99a6e --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/ProcessesTable.kt @@ -0,0 +1,35 @@ +package com.fleetdm.agent.osquery.tables + +import android.app.ActivityManager +import android.content.Context +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext + +class ProcessesTable(private val context: Context) : TablePlugin { + override val name: String = "processes" + + override fun columns(): List = listOf( + ColumnDef("pid"), + ColumnDef("name"), + ColumnDef("uid"), + ColumnDef("package_name"), + ColumnDef("importance"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val am = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager + ?: return emptyList() + val procs = am.runningAppProcesses ?: return emptyList() + + return procs.map { p -> + mapOf( + "pid" to p.pid.toString(), + "name" to (p.processName ?: ""), + "uid" to p.uid.toString(), + "package_name" to (p.pkgList?.firstOrNull() ?: ""), + "importance" to p.importance.toString(), + ) + } + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/RoutesTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/RoutesTable.kt new file mode 100644 index 00000000000..49a7fec8118 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/RoutesTable.kt @@ -0,0 +1,54 @@ +package com.fleetdm.agent.osquery.tables + +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext +import java.io.BufferedReader +import java.io.InputStreamReader +import java.util.concurrent.TimeUnit + +class RoutesTable : TablePlugin { + override val name: String = "routes" + + override fun columns(): List = listOf( + ColumnDef("destination"), + ColumnDef("gateway"), + ColumnDef("interface"), + ColumnDef("raw"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + return runCatching { + val process = ProcessBuilder("ip", "route", "show") + .redirectErrorStream(true) + .start() + + val rows = mutableListOf>() + BufferedReader(InputStreamReader(process.inputStream)).useLines { lines -> + lines.take(256).forEach { line -> + val destination = line.substringBefore(" ").trim() + val gateway = extractTokenAfter(line, "via") + val iface = extractTokenAfter(line, "dev") + rows.add( + mapOf( + "destination" to destination, + "gateway" to gateway, + "interface" to iface, + "raw" to line.take(512), + ), + ) + } + } + if (!process.waitFor(2, TimeUnit.SECONDS)) { + process.destroyForcibly() + } + rows + }.getOrElse { emptyList() } + } + + private fun extractTokenAfter(line: String, token: String): String { + val parts = line.split(Regex("\\s+")) + val idx = parts.indexOf(token) + return if (idx >= 0 && idx + 1 < parts.size) parts[idx + 1] else "" + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/StartupItemsTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/StartupItemsTable.kt new file mode 100644 index 00000000000..84519c3bd33 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/StartupItemsTable.kt @@ -0,0 +1,71 @@ +package com.fleetdm.agent.osquery.tables + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext + +class StartupItemsTable(private val context: Context) : TablePlugin { + override val name: String = "startup_items" + + override fun columns(): List = listOf( + ColumnDef("package_name"), + ColumnDef("component"), + ColumnDef("type"), + ColumnDef("enabled"), + ColumnDef("exported"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val pm = context.packageManager + val out = mutableListOf>() + + val bootIntent = Intent(Intent.ACTION_BOOT_COMPLETED) + val bootResolvers = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.queryBroadcastReceivers(bootIntent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_ALL.toLong())) + } else { + @Suppress("DEPRECATION") + pm.queryBroadcastReceivers(bootIntent, PackageManager.MATCH_ALL) + } + for (r in bootResolvers) { + val ai = r.activityInfo ?: continue + out.add( + mapOf( + "package_name" to ai.packageName, + "component" to ComponentName(ai.packageName, ai.name).flattenToShortString(), + "type" to "boot_receiver", + "enabled" to bool01(ai.enabled), + "exported" to bool01(ai.exported), + ), + ) + } + + val launcherIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + val launchResolvers = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.queryIntentActivities(launcherIntent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_ALL.toLong())) + } else { + @Suppress("DEPRECATION") + pm.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL) + } + for (r in launchResolvers) { + val ai = r.activityInfo ?: continue + out.add( + mapOf( + "package_name" to ai.packageName, + "component" to ComponentName(ai.packageName, ai.name).flattenToShortString(), + "type" to "launcher_activity", + "enabled" to bool01(ai.enabled), + "exported" to bool01(ai.exported), + ), + ) + } + + return out.distinctBy { "${it["package_name"]}|${it["component"]}|${it["type"]}" } + } + + private fun bool01(v: Boolean) = if (v) "1" else "0" +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/SystemInfoTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/SystemInfoTable.kt new file mode 100644 index 00000000000..4d609bd6d9a --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/SystemInfoTable.kt @@ -0,0 +1,48 @@ +package com.fleetdm.agent.osquery.tables + +import android.app.ActivityManager +import android.content.Context +import android.os.Build +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.OsqueryIdentityStore +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext + +class SystemInfoTable(private val context: Context) : TablePlugin { + override val name: String = "system_info" + + override fun columns(): List = listOf( + ColumnDef("hostname"), + ColumnDef("computer_name"), + ColumnDef("uuid"), + ColumnDef("hardware_vendor"), + ColumnDef("hardware_model"), + ColumnDef("hardware_version"), + ColumnDef("cpu_brand"), + ColumnDef("physical_memory"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val uuid = OsqueryIdentityStore.getOrCreateUuid(context) + val model = Build.MODEL.orEmpty() + val manufacturer = Build.MANUFACTURER.orEmpty() + val abi = Build.SUPPORTED_ABIS.firstOrNull().orEmpty() + + val memInfo = ActivityManager.MemoryInfo() + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager + activityManager?.getMemoryInfo(memInfo) + + return listOf( + mapOf( + "hostname" to model, + "computer_name" to model, + "uuid" to uuid, + "hardware_vendor" to manufacturer, + "hardware_model" to model, + "hardware_version" to Build.HARDWARE.orEmpty(), + "cpu_brand" to abi, + "physical_memory" to memInfo.totalMem.coerceAtLeast(0L).toString(), + ), + ) + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/SystemPropertiesTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/SystemPropertiesTable.kt new file mode 100644 index 00000000000..2c4146ed974 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/SystemPropertiesTable.kt @@ -0,0 +1,50 @@ +package com.fleetdm.agent.osquery.tables + +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext +import java.io.BufferedReader +import java.io.InputStreamReader + +class SystemPropertiesTable : TablePlugin { + override val name: String = "system_properties" + + override fun columns(): List = listOf( + ColumnDef("key"), + ColumnDef("value"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val rows = mutableListOf>() + + val proc = ProcessBuilder("getprop") + .redirectErrorStream(true) + .start() + + BufferedReader(InputStreamReader(proc.inputStream)).use { br -> + while (true) { + val line = br.readLine() ?: break + // getprop output format: [ro.build.version.release]: [14] + val parsed = parseGetpropLine(line) ?: continue + rows.add(mapOf("key" to parsed.first, "value" to parsed.second)) + } + } + + // Don’t hang forever if something weird happens + runCatching { proc.waitFor() } + + return rows + } + + private fun parseGetpropLine(line: String): Pair? { + val trimmed = line.trim() + if (!trimmed.startsWith("[") || !trimmed.contains("]: [")) return null + + val mid = trimmed.indexOf("]: [") + if (mid <= 1) return null + + val key = trimmed.substring(1, mid) + val value = trimmed.substring(mid + 4, trimmed.length - 1) // after "]: [" and before trailing "]" + return key to value + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/TimeTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/TimeTable.kt new file mode 100644 index 00000000000..50504f7a22a --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/TimeTable.kt @@ -0,0 +1,49 @@ +package com.fleetdm.agent.osquery.tables + +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext +import java.util.Calendar +import java.util.Locale +import java.util.TimeZone + +class TimeTable : TablePlugin { + override val name: String = "time" + + override fun columns(): List = listOf( + ColumnDef("weekday"), + ColumnDef("year"), + ColumnDef("month"), + ColumnDef("day"), + ColumnDef("hour"), + ColumnDef("minutes"), + ColumnDef("seconds"), + ColumnDef("timezone"), + ColumnDef("local_timezone"), + ColumnDef("unix_time"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val cal = Calendar.getInstance() + val tz = TimeZone.getDefault() + val nowMs = System.currentTimeMillis() + + val weekdayName = cal.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.LONG, Locale.US) + ?: cal.get(Calendar.DAY_OF_WEEK).toString() + + return listOf( + mapOf( + "weekday" to weekdayName, + "year" to cal.get(Calendar.YEAR).toString(), + "month" to (cal.get(Calendar.MONTH) + 1).toString(), + "day" to cal.get(Calendar.DAY_OF_MONTH).toString(), + "hour" to cal.get(Calendar.HOUR_OF_DAY).toString(), + "minutes" to cal.get(Calendar.MINUTE).toString(), + "seconds" to cal.get(Calendar.SECOND).toString(), + "timezone" to tz.id, + "local_timezone" to tz.id, + "unix_time" to (nowMs / 1000L).toString(), + ), + ) + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/UptimeTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/UptimeTable.kt new file mode 100644 index 00000000000..6192101b2cc --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/UptimeTable.kt @@ -0,0 +1,38 @@ +package com.fleetdm.agent.osquery.tables + +import android.os.SystemClock +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext +import kotlin.math.max + +class UptimeTable : TablePlugin { + override val name: String = "uptime" + + override fun columns(): List = listOf( + ColumnDef("days"), + ColumnDef("hours"), + ColumnDef("minutes"), + ColumnDef("seconds"), + ColumnDef("total_seconds"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val totalSeconds = max(0L, SystemClock.elapsedRealtime() / 1000L) + + val days = totalSeconds / 86400 + val hours = (totalSeconds % 86400) / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + + return listOf( + mapOf( + "days" to days.toString(), + "hours" to hours.toString(), + "minutes" to minutes.toString(), + "seconds" to seconds.toString(), + "total_seconds" to totalSeconds.toString(), + ), + ) + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/UsersTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/UsersTable.kt new file mode 100644 index 00000000000..4cdf305164c --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/UsersTable.kt @@ -0,0 +1,31 @@ +package com.fleetdm.agent.osquery.tables + +import android.os.Process +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext + +class UsersTable : TablePlugin { + override val name: String = "users" + + override fun columns(): List = listOf( + ColumnDef("uid"), + ColumnDef("gid"), + ColumnDef("username"), + ColumnDef("directory"), + ColumnDef("shell"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val uid = Process.myUid() + return listOf( + mapOf( + "uid" to uid.toString(), + "gid" to uid.toString(), + "username" to "android_app_uid_$uid", + "directory" to "/data/user", + "shell" to "", + ), + ) + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/tables/WifiNetworksTable.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/WifiNetworksTable.kt new file mode 100644 index 00000000000..d0be5c94661 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/osquery/tables/WifiNetworksTable.kt @@ -0,0 +1,112 @@ +package com.fleetdm.agent.osquery.tables + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.wifi.WifiInfo +import android.net.wifi.WifiManager +import android.os.Build +import com.fleetdm.agent.osquery.core.ColumnDef +import com.fleetdm.agent.osquery.core.TablePlugin +import com.fleetdm.agent.osquery.core.TableQueryContext + +class WifiNetworksTable( + private val context: Context, +) : TablePlugin { + + override val name: String = "wifi_networks" + + override fun columns(): List = listOf( + ColumnDef("ssid"), + ColumnDef("bssid"), + ColumnDef("ip_address"), + ColumnDef("mac_address"), + ColumnDef("rssi"), + ColumnDef("link_speed_mbps"), + ColumnDef("frequency_mhz"), + ColumnDef("network_id"), + ColumnDef("is_connected"), + ColumnDef("transport"), + ) + + override suspend fun generate(ctx: TableQueryContext): List> { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val active = cm.activeNetwork ?: return emptyList() + val caps = cm.getNetworkCapabilities(active) ?: return emptyList() + + val isWifi = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + val transport = when { + isWifi -> "wifi" + caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + else -> "other" + } + + if (!isWifi) { + // Keep it simple: if not on Wi-Fi, return a single row saying not connected. + return listOf( + mapOf( + "ssid" to "", + "bssid" to "", + "ip_address" to "", + "mac_address" to "", + "rssi" to "", + "link_speed_mbps" to "", + "frequency_mhz" to "", + "network_id" to "", + "is_connected" to "false", + "transport" to transport, + ), + ) + } + + val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + val info: WifiInfo? = wm.connectionInfo + + val ssid = info?.ssid?.let { s -> + // Android sometimes returns quoted SSID + if (s.startsWith("\"") && s.endsWith("\"") && s.length >= 2) s.substring(1, s.length - 1) else s + } ?: "" + + val bssid = info?.bssid ?: "" + + val ip = info?.ipAddress?.takeIf { it != 0 }?.let { intIpToString(it) } ?: "" + + // MAC is heavily restricted on modern Android; often returns 02:00:00:00:00:00 + val mac = info?.macAddress ?: "" + + val rssi = info?.rssi?.takeIf { it != -127 }?.toString() ?: "" + val speed = info?.linkSpeed?.takeIf { it > 0 }?.toString() ?: "" + val freq = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + info?.frequency?.takeIf { it > 0 }?.toString() ?: "" + } else { + "" + } + + val networkId = info?.networkId?.takeIf { it >= 0 }?.toString() ?: "" + + return listOf( + mapOf( + "ssid" to ssid, + "bssid" to bssid, + "ip_address" to ip, + "mac_address" to mac, + "rssi" to rssi, + "link_speed_mbps" to speed, + "frequency_mhz" to freq, + "network_id" to networkId, + "is_connected" to "true", + "transport" to transport, + ), + ) + } + + private fun intIpToString(ip: Int): String { + // WifiInfo.ipAddress is little-endian + val b1 = ip and 0xff + val b2 = (ip shr 8) and 0xff + val b3 = (ip shr 16) and 0xff + val b4 = (ip shr 24) and 0xff + return "$b1.$b2.$b3.$b4" + } +} diff --git a/android/app/src/test/java/com/fleetdm/agent/ApiClientReenrollTest.kt b/android/app/src/test/java/com/fleetdm/agent/ApiClientReenrollTest.kt index 38a0d615810..13d0be9a77f 100644 --- a/android/app/src/test/java/com/fleetdm/agent/ApiClientReenrollTest.kt +++ b/android/app/src/test/java/com/fleetdm/agent/ApiClientReenrollTest.kt @@ -1,12 +1,15 @@ package com.fleetdm.agent import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.RecordedRequest import okhttp3.mockwebserver.MockWebServer import android.content.Context import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -15,6 +18,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import kotlinx.coroutines.test.runTest +import java.util.concurrent.TimeUnit /** * Integration tests for ApiClient 401 re-enrollment logic using MockWebServer. @@ -88,6 +92,12 @@ class ApiClientReenrollTest { ) } + private fun takeRequestOrFail(): RecordedRequest { + val request = mockWebServer.takeRequest(2, TimeUnit.SECONDS) + assertNotNull("Expected request but none arrived within timeout", request) + return request!! + } + @Test fun `getOrbitConfig re-enrolls on 401 and retries with new key`() = runTest { // First call: no key exists, so enrollment happens, then config succeeds @@ -99,12 +109,12 @@ class ApiClientReenrollTest { assertEquals(2, mockWebServer.requestCount) // enroll + config // Verify first enrollment used the enroll secret - val firstEnroll = mockWebServer.takeRequest() + val firstEnroll = takeRequestOrFail() assertEquals("/api/fleet/orbit/enroll", firstEnroll.path) assertTrue(firstEnroll.body.readUtf8().contains("test-enroll-secret")) // Verify first config used first-node-key - val firstConfig = mockWebServer.takeRequest() + val firstConfig = takeRequestOrFail() assertEquals("/api/fleet/orbit/config", firstConfig.path) assertTrue(firstConfig.body.readUtf8().contains("first-node-key")) @@ -118,17 +128,17 @@ class ApiClientReenrollTest { assertEquals(5, mockWebServer.requestCount) // +3: config(401) + enroll + config // Verify: config with old key returned 401 - val rejectedConfig = mockWebServer.takeRequest() + val rejectedConfig = takeRequestOrFail() assertEquals("/api/fleet/orbit/config", rejectedConfig.path) assertTrue(rejectedConfig.body.readUtf8().contains("first-node-key")) // Verify: re-enrollment happened - val reEnroll = mockWebServer.takeRequest() + val reEnroll = takeRequestOrFail() assertEquals("/api/fleet/orbit/enroll", reEnroll.path) assertTrue(reEnroll.body.readUtf8().contains("test-enroll-secret")) // Verify: retry used new key - val retryConfig = mockWebServer.takeRequest() + val retryConfig = takeRequestOrFail() assertEquals("/api/fleet/orbit/config", retryConfig.path) assertTrue(retryConfig.body.readUtf8().contains("second-node-key")) } @@ -139,8 +149,8 @@ class ApiClientReenrollTest { enqueueEnrollmentSuccess("first-node-key") enqueueConfigSuccess() ApiClient.getOrbitConfig() - mockWebServer.takeRequest() // enroll - mockWebServer.takeRequest() // config + takeRequestOrFail() // enroll + takeRequestOrFail() // config // Now test getCertificateTemplate with 401 enqueue401() @@ -169,7 +179,7 @@ class ApiClientReenrollTest { assertTrue("Expected success but got: ${result.exceptionOrNull()}", result.isSuccess) // Verify the flow: cert request (401) -> enroll -> cert request (success) - val rejectedRequest = mockWebServer.takeRequest() + val rejectedRequest = takeRequestOrFail() assertEquals("/api/fleetd/certificates/123", rejectedRequest.path) // GET requests send node key in Authorization header, not body assertTrue( @@ -177,10 +187,10 @@ class ApiClientReenrollTest { rejectedRequest.getHeader("Authorization")?.contains("first-node-key") == true, ) - val enrollRequest = mockWebServer.takeRequest() + val enrollRequest = takeRequestOrFail() assertEquals("/api/fleet/orbit/enroll", enrollRequest.path) - val retryRequest = mockWebServer.takeRequest() + val retryRequest = takeRequestOrFail() assertEquals("/api/fleetd/certificates/123", retryRequest.path) // Verify retry uses new key in Authorization header assertTrue( @@ -198,7 +208,7 @@ class ApiClientReenrollTest { val initialRequestCount = mockWebServer.requestCount // Clear recorded requests - repeat(initialRequestCount) { mockWebServer.takeRequest() } + repeat(initialRequestCount) { takeRequestOrFail() } // Return 500 error mockWebServer.enqueue( @@ -239,4 +249,76 @@ class ApiClientReenrollTest { // 2 requests: config(401) + failed enrollment (no retry after failed enrollment) assertEquals(2, mockWebServer.requestCount - initialRequestCount) } + + @Test + fun `changing enrollment identity clears node key and re-enrolls`() = runTest { + // Initial enrollment with old identity + enqueueEnrollmentSuccess("old-node-key") + enqueueConfigSuccess() + val first = ApiClient.getOrbitConfig() + assertTrue(first.isSuccess) + takeRequestOrFail() // enroll + takeRequestOrFail() // config + + // Change identity in credentials (simulates updated managed config) + val serverUrl = mockWebServer.url("/").toString().trimEnd('/') + ApiClient.setEnrollmentCredentials( + enrollSecret = "new-enroll-secret", + hardwareUUID = "new-hardware-uuid", + computerName = "test-device", + serverUrl = serverUrl, + ) + + // Next config call should re-enroll before config + enqueueEnrollmentSuccess("new-node-key") + enqueueConfigSuccess() + val second = ApiClient.getOrbitConfig() + assertTrue(second.isSuccess) + + val enrollReq = takeRequestOrFail() + assertEquals("/api/fleet/orbit/enroll", enrollReq.path) + val body = enrollReq.body.readUtf8() + assertTrue(body.contains("new-enroll-secret")) + assertTrue(body.contains("new-hardware-uuid")) + + val configReq = takeRequestOrFail() + assertEquals("/api/fleet/orbit/config", configReq.path) + assertTrue(configReq.body.readUtf8().contains("new-node-key")) + } + + @Test + fun `base url with path is rejected before request`() = runTest { + // Configure credentials with invalid base URL containing path + context.prefDataStore.edit { + it[serverUrlPref] = "http://example.com/dashboard" + it[enrollSecretPref] = "test-enroll-secret" + it[hardwareUuidPref] = "test-hardware-uuid" + it[computerNamePref] = "test-device" + } + + val result = ApiClient.getOrbitConfig() + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.message?.contains("must not include a path") == true) + assertEquals(0, mockWebServer.requestCount) + } + + @Test + fun `base url validator enforces https when required`() { + val httpRejected = ApiClient.validateBaseUrl("http://example.com", requireHttps = true) + assertTrue(httpRejected.isFailure) + + val httpsAccepted = ApiClient.validateBaseUrl("https://example.com", requireHttps = true) + assertTrue(httpsAccepted.isSuccess) + + val httpAcceptedInDebugPolicy = ApiClient.validateBaseUrl("http://example.com", requireHttps = false) + assertTrue(httpAcceptedInDebugPolicy.isSuccess) + } + + @Test + fun `base url validator rejects query fragment and user info`() { + assertFalse(ApiClient.validateBaseUrl("https://user:pass@example.com", requireHttps = true).isSuccess) + assertFalse(ApiClient.validateBaseUrl("https://example.com?x=1", requireHttps = true).isSuccess) + assertFalse(ApiClient.validateBaseUrl("https://example.com#frag", requireHttps = true).isSuccess) + } } diff --git a/android/app/src/test/java/com/fleetdm/agent/SecurityNegativePathsTest.kt b/android/app/src/test/java/com/fleetdm/agent/SecurityNegativePathsTest.kt new file mode 100644 index 00000000000..09ea849c852 --- /dev/null +++ b/android/app/src/test/java/com/fleetdm/agent/SecurityNegativePathsTest.kt @@ -0,0 +1,50 @@ +package com.fleetdm.agent + +import android.content.Context +import androidx.datastore.preferences.core.edit +import com.fleetdm.agent.osquery.OsqueryQueryEngine +import com.fleetdm.agent.osquery.OsqueryTables +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class SecurityNegativePathsTest { + + private lateinit var context: Context + + @Before + fun setup() = runTest { + KeystoreManager.enableTestMode() + context = RuntimeEnvironment.getApplication() + ApiClient.initialize(context) + context.prefDataStore.edit { it.clear() } + OsqueryTables.registerAll(context) + } + + @After + fun tearDown() { + KeystoreManager.disableTestMode() + } + + @Test + fun `missing enrollment config fails closed`() = runTest { + val result = ApiClient.getOrbitConfig() + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.message?.contains("Credentials not set") == true) + } + + @Test + fun `malformed SQL is rejected`() = runTest { + val result = runCatching { + OsqueryQueryEngine.execute("SELECT FROM os_version") + } + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.message?.contains("Bad SQL") == true) + } +} diff --git a/android/app/src/test/java/com/fleetdm/agent/osquery/AdditionalParityTablesTest.kt b/android/app/src/test/java/com/fleetdm/agent/osquery/AdditionalParityTablesTest.kt new file mode 100644 index 00000000000..78c49d8e891 --- /dev/null +++ b/android/app/src/test/java/com/fleetdm/agent/osquery/AdditionalParityTablesTest.kt @@ -0,0 +1,70 @@ +package com.fleetdm.agent.osquery + +import android.content.Context +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class AdditionalParityTablesTest { + + @Test + fun `users table returns one row with parseable uid`() = runBlocking { + val context: Context = RuntimeEnvironment.getApplication() + OsqueryTables.registerAll(context) + + val rows = OsqueryQueryEngine.execute("SELECT * FROM users;") + assertEquals(1, rows.size) + val row = rows.first() + assertTrue(row.containsKey("uid")) + assertTrue(row.getValue("uid").toLong() >= 0L) + assertTrue(row.getValue("username").isNotBlank()) + } + + @Test + fun `cpu_info table returns one row with non-negative cores`() = runBlocking { + val context: Context = RuntimeEnvironment.getApplication() + OsqueryTables.registerAll(context) + + val rows = OsqueryQueryEngine.execute("SELECT * FROM cpu_info;") + assertEquals(1, rows.size) + val row = rows.first() + assertTrue(row.getValue("cores").toLong() >= 0L) + assertTrue(row.containsKey("arch")) + assertTrue(row.containsKey("model")) + } + + @Test + fun `processes and network style tables execute without crash`() = runBlocking { + val context: Context = RuntimeEnvironment.getApplication() + OsqueryTables.registerAll(context) + + val processes = OsqueryQueryEngine.execute("SELECT * FROM processes;") + if (processes.isNotEmpty()) { + assertTrue(processes.first().containsKey("pid")) + assertTrue(processes.first().containsKey("name")) + } + + val interfaceAddresses = OsqueryQueryEngine.execute("SELECT * FROM interface_addresses;") + if (interfaceAddresses.isNotEmpty()) { + assertTrue(interfaceAddresses.first().containsKey("interface")) + assertTrue(interfaceAddresses.first().containsKey("address")) + } + + val routes = OsqueryQueryEngine.execute("SELECT * FROM routes;") + if (routes.isNotEmpty()) { + assertTrue(routes.first().containsKey("destination")) + assertTrue(routes.first().containsKey("raw")) + } + + val mounts = OsqueryQueryEngine.execute("SELECT * FROM mounts;") + if (mounts.isNotEmpty()) { + assertTrue(mounts.first().containsKey("path")) + assertTrue(mounts.first().containsKey("type")) + } + } +} diff --git a/android/app/src/test/java/com/fleetdm/agent/osquery/ManagementVisibilityTablesTest.kt b/android/app/src/test/java/com/fleetdm/agent/osquery/ManagementVisibilityTablesTest.kt new file mode 100644 index 00000000000..3835aa9b672 --- /dev/null +++ b/android/app/src/test/java/com/fleetdm/agent/osquery/ManagementVisibilityTablesTest.kt @@ -0,0 +1,54 @@ +package com.fleetdm.agent.osquery + +import android.content.Context +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class ManagementVisibilityTablesTest { + + @Test + fun `mdm_status returns one row with boolean flags`() = runBlocking { + val context: Context = RuntimeEnvironment.getApplication() + OsqueryTables.registerAll(context) + + val rows = OsqueryQueryEngine.execute("SELECT * FROM mdm_status;") + assertEquals(1, rows.size) + val row = rows.first() + + listOf( + "has_device_owner", + "has_work_profile", + "restrictions_present", + "enroll_secret_present", + "host_uuid_present", + "server_url_present", + "is_debug_build", + ).forEach { key -> + assertTrue(row[key] == "0" || row[key] == "1") + } + } + + @Test + fun `app_signatures and startup_items execute safely`() = runBlocking { + val context: Context = RuntimeEnvironment.getApplication() + OsqueryTables.registerAll(context) + + val signatures = OsqueryQueryEngine.execute("SELECT * FROM app_signatures;") + if (signatures.isNotEmpty()) { + assertTrue(signatures.first().containsKey("package_name")) + assertTrue(signatures.first().containsKey("sha256")) + } + + val startup = OsqueryQueryEngine.execute("SELECT * FROM startup_items;") + if (startup.isNotEmpty()) { + assertTrue(startup.first().containsKey("package_name")) + assertTrue(startup.first().containsKey("type")) + } + } +} diff --git a/android/app/src/test/java/com/fleetdm/agent/osquery/SystemKernelMemoryTableTest.kt b/android/app/src/test/java/com/fleetdm/agent/osquery/SystemKernelMemoryTableTest.kt new file mode 100644 index 00000000000..2f6ba2a4b4a --- /dev/null +++ b/android/app/src/test/java/com/fleetdm/agent/osquery/SystemKernelMemoryTableTest.kt @@ -0,0 +1,72 @@ +package com.fleetdm.agent.osquery + +import android.content.Context +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class SystemKernelMemoryTableTest { + + @Test + fun `system_info returns one row with required keys`() = runBlocking { + val context: Context = RuntimeEnvironment.getApplication() + OsqueryTables.registerAll(context) + + val rows = OsqueryQueryEngine.execute("SELECT * FROM system_info;") + assertEquals(1, rows.size) + val row = rows.first() + + val required = listOf( + "hostname", + "computer_name", + "uuid", + "hardware_vendor", + "hardware_model", + "hardware_version", + "cpu_brand", + "physical_memory", + ) + required.forEach { key -> assertTrue("Missing key $key", row.containsKey(key)) } + assertTrue(row.getValue("uuid").isNotBlank()) + assertTrue(row.getValue("physical_memory").toLong() >= 0L) + } + + @Test + fun `kernel_info returns one row with kernel snapshot fields`() = runBlocking { + val context: Context = RuntimeEnvironment.getApplication() + OsqueryTables.registerAll(context) + + val rows = OsqueryQueryEngine.execute("SELECT * FROM kernel_info;") + assertEquals(1, rows.size) + val row = rows.first() + + val required = listOf("version", "release", "build", "platform") + required.forEach { key -> assertTrue("Missing key $key", row.containsKey(key)) } + assertEquals("android", row.getValue("platform")) + } + + @Test + fun `memory_info returns one row with parseable non-negative memory fields`() = runBlocking { + val context: Context = RuntimeEnvironment.getApplication() + OsqueryTables.registerAll(context) + + val rows = OsqueryQueryEngine.execute("SELECT * FROM memory_info;") + assertEquals(1, rows.size) + val row = rows.first() + + val total = row.getValue("total_bytes").toLong() + val avail = row.getValue("available_bytes").toLong() + val threshold = row.getValue("threshold_bytes").toLong() + val low = row.getValue("low_memory") + + assertTrue(total >= 0L) + assertTrue(avail >= 0L) + assertTrue(threshold >= 0L) + assertTrue(low == "0" || low == "1") + } +} diff --git a/android/app/src/test/java/com/fleetdm/agent/osquery/TimeAndUptimeTableTest.kt b/android/app/src/test/java/com/fleetdm/agent/osquery/TimeAndUptimeTableTest.kt new file mode 100644 index 00000000000..f9ddf2d679d --- /dev/null +++ b/android/app/src/test/java/com/fleetdm/agent/osquery/TimeAndUptimeTableTest.kt @@ -0,0 +1,75 @@ +package com.fleetdm.agent.osquery + +import android.content.Context +import com.fleetdm.agent.osquery.core.TableRegistry +import java.util.TimeZone +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class TimeAndUptimeTableTest { + + @Test + fun `time table returns one row with required columns and sane values`() = runBlocking { + val context: Context = RuntimeEnvironment.getApplication() + OsqueryTables.registerAll(context) + + val rows = OsqueryQueryEngine.execute("SELECT * FROM time;") + assertEquals(1, rows.size) + + val row = rows[0] + val required = listOf( + "weekday", + "year", + "month", + "day", + "hour", + "minutes", + "seconds", + "timezone", + "local_timezone", + "unix_time", + ) + required.forEach { col -> assertTrue("Missing column $col", row.containsKey(col)) } + + val unixTime = row.getValue("unix_time").toLong() + val now = System.currentTimeMillis() / 1000L + assertTrue("unix_time drift too large", kotlin.math.abs(now - unixTime) <= 300L) + + assertTrue(row.getValue("local_timezone").isNotBlank()) + assertEquals(TimeZone.getDefault().id, row.getValue("local_timezone")) + } + + @Test + fun `uptime table returns one row with non-negative and consistent values`() = runBlocking { + val context: Context = RuntimeEnvironment.getApplication() + OsqueryTables.registerAll(context) + + val rows = OsqueryQueryEngine.execute("SELECT * FROM uptime;") + assertEquals(1, rows.size) + + val row = rows[0] + val required = listOf("days", "hours", "minutes", "seconds", "total_seconds") + required.forEach { col -> assertTrue("Missing column $col", row.containsKey(col)) } + + val days = row.getValue("days").toLong() + val hours = row.getValue("hours").toLong() + val minutes = row.getValue("minutes").toLong() + val seconds = row.getValue("seconds").toLong() + val totalSeconds = row.getValue("total_seconds").toLong() + + assertTrue(days >= 0) + assertTrue(hours >= 0) + assertTrue(minutes >= 0) + assertTrue(seconds >= 0) + assertTrue(totalSeconds >= 0) + + val recomposed = days * 86400 + hours * 3600 + minutes * 60 + seconds + assertTrue("recomposed uptime differs from total_seconds", kotlin.math.abs(recomposed - totalSeconds) <= 1L) + } +} diff --git a/android/gradlew b/android/gradlew old mode 100755 new mode 100644 diff --git a/schema/README.md b/schema/README.md index 6bbd26f964e..73084731938 100644 --- a/schema/README.md +++ b/schema/README.md @@ -25,12 +25,12 @@ examples: |- # (optional) string - An example query for this table. Note: This f # Add examples here notes: |- # (optional) string - Notes about this table. Note: This field supports markdown. # Add notes here -platforms: |- # (optional) array - A list of supported platforms for this table (any of: `darwin`, `windows`, `linux`, `chrome`) +platforms: |- # (optional) array - A list of supported platforms for this table (any of: `darwin`, `windows`, `linux`, `chrome`, `android`) # Add platforms here columns: # (required) array - An array of columns in this table - name: # (required) string - The name of the column description: # (required) string - The column's description type: # (required) string - the column's data type required: # (required) boolean - whether or not this column is required to query this table. - platforms: # (optional) array - List of supported platforms, used to clarify when a column isn't available on every platform its table supports (any of: `darwin`, `windows`, `linux`, `chrome`) + platforms: # (optional) array - List of supported platforms, used to clarify when a column isn't available on every platform its table supports (any of: `darwin`, `windows`, `linux`, `chrome`, `android`) ``` diff --git a/schema/tables/android_logcat.yml b/schema/tables/android_logcat.yml new file mode 100644 index 00000000000..4d4ea9ec712 --- /dev/null +++ b/schema/tables/android_logcat.yml @@ -0,0 +1,31 @@ +name: android_logcat +evented: false +platforms: + - android +description: Recent Android logcat entries exposed as queryable rows. +examples: |- + View recent error-level log messages. + + ``` + SELECT timestamp, level, tag, message + FROM android_logcat + WHERE level = 'E' + LIMIT 100; + ``` +columns: + - name: timestamp + type: text + required: false + description: Log entry timestamp. + - name: level + type: text + required: false + description: Log level (for example V, D, I, W, E). + - name: tag + type: text + required: false + description: Log tag. + - name: message + type: text + required: false + description: Log message body. diff --git a/schema/tables/app_permissions.yml b/schema/tables/app_permissions.yml new file mode 100644 index 00000000000..57a91ee7dff --- /dev/null +++ b/schema/tables/app_permissions.yml @@ -0,0 +1,38 @@ +name: app_permissions +evented: false +platforms: + - android +description: Permissions requested and granted by installed Android applications. +examples: |- + Show granted dangerous permissions on Android devices. + + ``` + SELECT app_name, package_name, permission + FROM app_permissions + WHERE granted='true' AND protection_level='dangerous'; + ``` +columns: + - name: package_name + type: text + required: false + description: Android package name. + - name: app_name + type: text + required: false + description: Human-readable application name. + - name: permission + type: text + required: false + description: Android permission name. + - name: protection_level + type: text + required: false + description: Permission protection level (for example normal or dangerous). + - name: granted + type: text + required: false + description: Whether the permission is granted. + - name: is_system + type: text + required: false + description: Whether the app is a system app. diff --git a/schema/tables/device_info.yml b/schema/tables/device_info.yml new file mode 100644 index 00000000000..a68b18cdbb2 --- /dev/null +++ b/schema/tables/device_info.yml @@ -0,0 +1,57 @@ +name: device_info +evented: false +platforms: + - android +description: Static Android device identity and hardware metadata. +examples: |- + Get device model and manufacturer details. + + ``` + SELECT manufacturer, brand, model, device, product + FROM device_info; + ``` +columns: + - name: device + type: text + required: false + description: Device codename. + - name: model + type: text + required: false + description: End-user-visible model name. + - name: manufacturer + type: text + required: false + description: Device manufacturer. + - name: brand + type: text + required: false + description: Device brand. + - name: product + type: text + required: false + description: Product name. + - name: hardware + type: text + required: false + description: Hardware name. + - name: board + type: text + required: false + description: Underlying board name. + - name: fingerprint + type: text + required: false + description: Build fingerprint. + - name: bootloader + type: text + required: false + description: Bootloader version. + - name: tags + type: text + required: false + description: Build tags. + - name: type + type: text + required: false + description: Build type (for example user or userdebug). diff --git a/schema/tables/installed_apps.yml b/schema/tables/installed_apps.yml new file mode 100644 index 00000000000..e05a30c2e4c --- /dev/null +++ b/schema/tables/installed_apps.yml @@ -0,0 +1,42 @@ +name: installed_apps +evented: false +platforms: + - android +description: Installed application inventory and package metadata on Android devices. +examples: |- + List installed apps and versions on Android hosts. + + ``` + SELECT app_name, package_name, version_name, version_code + FROM installed_apps + LIMIT 50; + ``` +columns: + - name: package_name + type: text + required: false + description: Android package name. + - name: app_name + type: text + required: false + description: Human-readable application name. + - name: version_name + type: text + required: false + description: Application version name. + - name: version_code + type: text + required: false + description: Application version code. + - name: first_install_time + type: text + required: false + description: First install time on device. + - name: last_update_time + type: text + required: false + description: Last update time on device. + - name: is_system + type: text + required: false + description: Whether the app is a system app. diff --git a/schema/tables/os_version.yml b/schema/tables/os_version.yml index c4f5da7e064..5592e010df5 100644 --- a/schema/tables/os_version.yml +++ b/schema/tables/os_version.yml @@ -4,6 +4,7 @@ platforms: - linux - windows - chrome + - android examples: |- See the OS version as well as the CPU architecture in use (X86 vs ARM for example) diff --git a/schema/tables/osquery_info.yml b/schema/tables/osquery_info.yml index 9c1da99983a..17d20d431a2 100644 --- a/schema/tables/osquery_info.yml +++ b/schema/tables/osquery_info.yml @@ -4,6 +4,7 @@ platforms: - windows - linux - chrome + - android columns: - name: pid platforms: diff --git a/schema/tables/system_properties.yml b/schema/tables/system_properties.yml new file mode 100644 index 00000000000..dee6f22abc6 --- /dev/null +++ b/schema/tables/system_properties.yml @@ -0,0 +1,22 @@ +name: system_properties +evented: false +platforms: + - android +description: Android system properties exposed as key/value pairs. +examples: |- + Inspect Android runtime properties. + + ``` + SELECT key, value + FROM system_properties + LIMIT 50; + ``` +columns: + - name: key + type: text + required: false + description: Property key. + - name: value + type: text + required: false + description: Property value.