apsa 1.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. apsa/__init__.py +3 -0
  2. apsa/__main__.py +3 -0
  3. apsa/cli.py +3 -0
  4. apsa-1.0.3.dist-info/METADATA +228 -0
  5. apsa-1.0.3.dist-info/RECORD +62 -0
  6. apsa-1.0.3.dist-info/WHEEL +4 -0
  7. apsa-1.0.3.dist-info/entry_points.txt +4 -0
  8. apsa-1.0.3.dist-info/licenses/LICENSE +21 -0
  9. mobile_audit/__init__.py +3 -0
  10. mobile_audit/__main__.py +3 -0
  11. mobile_audit/_job_worker.py +72 -0
  12. mobile_audit/_parser_worker.py +125 -0
  13. mobile_audit/audit.py +369 -0
  14. mobile_audit/binary_analysis.py +794 -0
  15. mobile_audit/cli.py +543 -0
  16. mobile_audit/core.py +237 -0
  17. mobile_audit/data/demo/AndroidManifest.xml +13 -0
  18. mobile_audit/data/demo/Demo.swift +4 -0
  19. mobile_audit/data/demo/Info.plist +7 -0
  20. mobile_audit/data/demo/MainActivity.kt +13 -0
  21. mobile_audit/data/demo/build.gradle.kts +3 -0
  22. mobile_audit/data/rules.json +174 -0
  23. mobile_audit/data/skills/apsa/SKILL.md +38 -0
  24. mobile_audit/data/skills/mobile-audit/SKILL.md +38 -0
  25. mobile_audit/data/skills/quaygate/SKILL.md +38 -0
  26. mobile_audit/data/structural-rules.json +280 -0
  27. mobile_audit/engine.py +66 -0
  28. mobile_audit/inputs.py +574 -0
  29. mobile_audit/intel.py +813 -0
  30. mobile_audit/jobs.py +188 -0
  31. mobile_audit/mcp_server.py +322 -0
  32. mobile_audit/output.py +192 -0
  33. mobile_audit/policy.py +424 -0
  34. mobile_audit/processes.py +78 -0
  35. mobile_audit/quaygate_analysis.py +228 -0
  36. mobile_audit/rules.py +165 -0
  37. mobile_audit/runtime.py +804 -0
  38. mobile_audit/skills.py +61 -0
  39. mobile_audit/source_analysis.py +927 -0
  40. mobile_audit/store.py +492 -0
  41. mobile_audit/tools.py +40 -0
  42. mobile_audit/tui.py +449 -0
  43. mobile_audit/watch.py +45 -0
  44. quaygate/__init__.py +3 -0
  45. quaygate/__main__.py +5 -0
  46. quaygate/adb.py +55 -0
  47. quaygate/apk.py +136 -0
  48. quaygate/appchecks.py +705 -0
  49. quaygate/apps.py +136 -0
  50. quaygate/arsc.py +171 -0
  51. quaygate/axml.py +160 -0
  52. quaygate/cert.py +91 -0
  53. quaygate/checks.py +289 -0
  54. quaygate/cli.py +20 -0
  55. quaygate/dex.py +37 -0
  56. quaygate/dexcode.py +271 -0
  57. quaygate/elf.py +99 -0
  58. quaygate/indicators.py +148 -0
  59. quaygate/ipa.py +404 -0
  60. quaygate/legacy_cli.py +145 -0
  61. quaygate/network_security.py +141 -0
  62. quaygate/report.py +128 -0
mobile_audit/audit.py ADDED
@@ -0,0 +1,369 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import re
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+
8
+ from packaging.version import InvalidVersion, Version
9
+
10
+ from . import __version__
11
+ from .core import RULE_VERSION, finding, finding_identity, now, report_incomplete, severity_rank, uid
12
+ from .engine import analyze_target
13
+ from .intel import query_dependencies, source_health
14
+ from .store import Store
15
+
16
+
17
+ def in_cve_range(version: str, affected: dict) -> bool | None:
18
+ """Only interpret explicit ranges we can compare. An unknown range stays unknown."""
19
+ try:
20
+ current = Version(version)
21
+ except InvalidVersion:
22
+ return None
23
+ decisions = []
24
+ for entry in affected.get("versions", []):
25
+ status = entry.get("status", "unknown")
26
+ if status not in {"affected", "unaffected"}:
27
+ continue
28
+ lower = entry.get("version", "")
29
+ upper = entry.get("lessThan") or entry.get("lessThanOrEqual")
30
+ if lower in {"unspecified", "n/a", "*"} or upper == "*" or entry.get("changes"):
31
+ continue
32
+ # Vendor-defined custom orderings and version branches cannot be guessed.
33
+ if entry.get("versionType", "semver") not in {"semver", "python", "version"} and upper:
34
+ continue
35
+ try:
36
+ start = Version(lower)
37
+ if upper:
38
+ end = Version(upper)
39
+ matches = current >= start and (
40
+ current <= end if "lessThanOrEqual" in entry else current < end
41
+ )
42
+ else:
43
+ matches = current == start
44
+ except InvalidVersion:
45
+ continue
46
+ if matches:
47
+ decisions.append(status == "affected")
48
+ if decisions:
49
+ return decisions[0] if all(d == decisions[0] for d in decisions) else None
50
+ # Don't infer unaffected from an incomplete list or an unparseable range.
51
+ return None
52
+
53
+
54
+ def correlate(
55
+ inventory: dict, records: list[dict], environment: dict | None = None
56
+ ) -> tuple[list[dict], list[dict]]:
57
+ findings = []
58
+ advisories = []
59
+ known = {r["id"] for r in records if r.get("known_exploited")}
60
+ rejected = {r["id"] for r in records if r.get("state") == "REJECTED"}
61
+ for record in records:
62
+ if record["id"] in rejected or record.get("withdrawn"):
63
+ continue
64
+ dep = record.get("query_match")
65
+ if dep:
66
+ for current in inventory["dependencies"]:
67
+ if (dep["name"], dep["ecosystem"], dep["version"]) != (
68
+ current["name"],
69
+ current["ecosystem"],
70
+ current["version"],
71
+ ):
72
+ continue
73
+ findings.append(
74
+ finding(
75
+ record["id"],
76
+ record["title"],
77
+ max(
78
+ (record.get("severity") or "medium", "high" if record["id"] in known else "info"),
79
+ key=severity_rank,
80
+ ),
81
+ "version-affected",
82
+ [
83
+ {
84
+ "dependency": current,
85
+ "basis": "OSV package/version query",
86
+ "advisory_id": record.get("osv_id"),
87
+ "intel_snapshot_hash": record.get("snapshot_hash"),
88
+ "advisory_modified": record.get("modified"),
89
+ }
90
+ ],
91
+ "Upgrade to a vendor-fixed dependency release, then rescan the actual build and test the affected feature.",
92
+ "MASVS-CODE",
93
+ record.get("references", []),
94
+ known_exploited=record["id"] in known,
95
+ reachability="unknown",
96
+ reproduced=False,
97
+ origin="intelligence",
98
+ severity_basis=record.get("severity_basis", "unknown"),
99
+ )
100
+ )
101
+ platform = record.get("platform")
102
+ if platform not in inventory["platforms"]:
103
+ continue
104
+ state = "device-info-required"
105
+ basis = "OS advisory; application files cannot establish installed platform patch state."
106
+ if environment and environment.get("platform") == platform:
107
+ if environment.get("simulator"):
108
+ state = "simulator-only"
109
+ basis = "Simulator observations cannot establish the security patch state of physical iOS devices."
110
+ elif platform == "android" and record.get("fixed_patch_level"):
111
+ actual = environment.get("security_patch", "")
112
+ versions = record.get("updated_aosp_versions", "")
113
+ release = environment.get("version", "").split(".")[0]
114
+ if re.fullmatch(r"20\d\d-\d\d-\d\d", actual):
115
+ if actual >= record["fixed_patch_level"]:
116
+ state = "vendor-patch-level-satisfied"
117
+ elif release and release in re.findall(r"\b\d+\b", versions):
118
+ state = "potentially-affected"
119
+ else:
120
+ state = "applicability-unknown"
121
+ basis = "Observed Android security patch level; old patch level alone does not prove this component is vulnerable."
122
+ elif platform == "ios":
123
+ cve = next((r for r in records if r["id"] == record["id"] and r["source"] == "cve"), None)
124
+ decisions = []
125
+ for product in (cve or {}).get("affected", []):
126
+ if product.get("product", "").lower() in {"ios", "ipados", "iphone os"}:
127
+ decisions.append(in_cve_range(environment.get("version", ""), product))
128
+ if True in decisions:
129
+ state = "version-affected"
130
+ elif decisions and all(d is False for d in decisions):
131
+ state = "outside-published-affected-range"
132
+ else:
133
+ state = "applicability-unknown"
134
+ basis = "Observed iOS version compared with explicit CNA ranges where supported; fixed-release labels are not used as universal lower bounds."
135
+ entry = {
136
+ "id": record["id"],
137
+ "title": record["title"],
138
+ "component": record.get("component", ""),
139
+ "platform": platform,
140
+ "state": state,
141
+ "known_exploited": record["id"] in known,
142
+ "exploitation_reported": record.get("exploitation_reported", False),
143
+ "references": record.get("references", []),
144
+ "basis": basis,
145
+ "intel_snapshot_hash": record.get("snapshot_hash"),
146
+ "environment": environment if environment and environment.get("platform") == platform else None,
147
+ }
148
+ advisories.append(entry)
149
+ if state == "version-affected":
150
+ findings.append(
151
+ finding(
152
+ "OS-" + record["id"],
153
+ "Observed OS version is in a published affected range: " + record["id"],
154
+ "high",
155
+ "version-affected",
156
+ [entry],
157
+ "Apply the vendor's fixed OS release. Review component applicability; this is version correlation, not exploit reproduction.",
158
+ "MASVS-CODE",
159
+ record.get("references", []),
160
+ origin="intelligence",
161
+ scope="environment",
162
+ reachability="unknown",
163
+ reproduced=False,
164
+ )
165
+ )
166
+ # A CVE can have several patched branches. Keep its advisory references together.
167
+ unique = {}
168
+ for entry in advisories:
169
+ key = (entry["id"], entry["state"])
170
+ if key in unique:
171
+ unique[key]["references"] = sorted(set(unique[key]["references"] + entry["references"]))
172
+ else:
173
+ unique[key] = entry
174
+ return list({f["id"]: f for f in findings}.values()), list(unique.values())
175
+
176
+
177
+ def summarize(report: dict) -> dict:
178
+ return {
179
+ "findings": len(report["findings"]),
180
+ "confirmed": sum(
181
+ f["status"] in {"runtime-confirmed", "configuration-confirmed"} for f in report["findings"]
182
+ ),
183
+ "candidates": sum(f["status"] == "candidate" for f in report["findings"]),
184
+ "version_affected": sum(f["status"] == "version-affected" for f in report["findings"]),
185
+ "not_run": sum(c["state"] == "not-run" for c in report["coverage"]),
186
+ "environment_advisories": len(report.get("environment_advisories", [])),
187
+ "warnings": len(report.get("warnings", [])),
188
+ "partial": sum(c["state"] == "partial" for c in report["coverage"]),
189
+ "incomplete": report_incomplete(report),
190
+ }
191
+
192
+
193
+ def scan(
194
+ store: Store,
195
+ target: Path,
196
+ online=False,
197
+ sbom: Path | None = None,
198
+ environment: dict | None = None,
199
+ progress: Callable[[str, int], None] | None = None,
200
+ expected_target: Path | None = None,
201
+ ) -> dict:
202
+ if progress:
203
+ progress("input-analysis", 5)
204
+ if environment:
205
+ if environment.get("platform") not in {"android", "ios"}:
206
+ raise ValueError("Device info requires platform: android or ios")
207
+ environment = {
208
+ k: v
209
+ for k, v in environment.items()
210
+ if k
211
+ in {
212
+ "platform",
213
+ "version",
214
+ "security_patch",
215
+ "webview_version",
216
+ "model",
217
+ "simulator",
218
+ "observed_at",
219
+ }
220
+ }
221
+ analyzed = analyze_target(target, sbom, expected_target)
222
+ inventory, findings, coverage = analyzed["inventory"], analyzed["findings"], analyzed["coverage"]
223
+ if progress:
224
+ progress("dependency-intelligence" if online else "cached-intelligence", 50)
225
+ warnings = inventory["warnings"].copy()
226
+ failures = []
227
+ if online:
228
+ _, failures = query_dependencies(store, inventory["dependencies"])
229
+ warnings.extend(failures)
230
+ records = store.intelligence(limit=100000)
231
+ feeds = source_health(store)
232
+ feed_map = {f["source"]: f for f in feeds}
233
+ for dep in inventory["dependencies"]:
234
+ key = f"osv:{dep['ecosystem']}:{dep['name']}:{dep['version']}"
235
+ health = feed_map.get(key)
236
+ coverage.append(
237
+ {
238
+ "rule_id": "DEPENDENCY-CVE",
239
+ "dependency": dep,
240
+ "state": "checked"
241
+ if health and health["status"] == "ok" and not health["stale"]
242
+ else "not-run",
243
+ "method": "OSV exact package/version query",
244
+ "note": "Declared source versions may differ from the built artifact. Reachability is not inferred from version matching.",
245
+ }
246
+ )
247
+ if not inventory["dependencies"]:
248
+ coverage.append(
249
+ {
250
+ "rule_id": "DEPENDENCY-CVE",
251
+ "state": "not-run",
252
+ "method": "dependency inventory",
253
+ "note": "No exact dependencies identified. Supply a CycloneDX SBOM or dependency lock files.",
254
+ }
255
+ )
256
+ intel_findings, advisories = correlate(inventory, records, environment)
257
+ if progress:
258
+ progress("evidence-report", 85)
259
+ findings.extend(intel_findings)
260
+ coverage.append(
261
+ {
262
+ "rule_id": "OS-CVE",
263
+ "state": "checked"
264
+ if environment and any(a["platform"] == environment.get("platform") for a in advisories)
265
+ else "not-run",
266
+ "method": "vendor advisory/environment correlation",
267
+ "note": "Not a platform exploit test. Component/backport applicability may remain unknown.",
268
+ }
269
+ )
270
+ if not records:
271
+ warnings.append("No cached intelligence. Run intel sync; offline scan did not check current CVEs.")
272
+ if any(f["stale"] or f["status"] != "ok" for f in feeds):
273
+ warnings.append("Some intelligence sources are stale or failed. Inspect intel status for coverage.")
274
+ report = {
275
+ "schema_version": 2,
276
+ "tool_version": __version__,
277
+ "id": uid("audit"),
278
+ "created": now(),
279
+ "target": inventory["target"],
280
+ "requested_target": str(target.expanduser().absolute()),
281
+ "rule_version": RULE_VERSION,
282
+ "inventory": inventory,
283
+ "findings": sorted(findings, key=lambda f: -severity_rank(f["severity"])),
284
+ "coverage": coverage,
285
+ "environment_advisories": advisories,
286
+ "intel_snapshot": feeds,
287
+ "environment": environment,
288
+ "warnings": warnings,
289
+ "runtime": [],
290
+ "online_query": {"requested": bool(online), "errors": failures},
291
+ "scope": "Targeted OWASP-aligned checks; not exhaustive MASVS certification or undisclosed-zero-day detection.",
292
+ }
293
+ report["summary"] = summarize(report)
294
+ return store.save_report(report)
295
+
296
+
297
+ def refresh_report(store: Store, report_id: str, *, save=True) -> dict:
298
+ previous = store.report(report_id)
299
+ report = copy.deepcopy(previous)
300
+ report["id"] = uid("audit")
301
+ report["created"] = now()
302
+ report["parent_report"] = previous["id"]
303
+ report["findings"] = [
304
+ f
305
+ for f in report["findings"]
306
+ if f.get("origin") != "intelligence"
307
+ and not f["rule_id"].startswith(("CVE-", "GHSA-", "OSV-", "OS-CVE-"))
308
+ ]
309
+ records = store.intelligence(limit=100000)
310
+ current, advisories = correlate(report["inventory"], records, report.get("environment"))
311
+ report["findings"].extend(current)
312
+ report["findings"].sort(key=lambda f: -severity_rank(f["severity"]))
313
+ report["environment_advisories"] = advisories
314
+ report["intel_snapshot"] = source_health(store)
315
+ health = {f["source"]: f for f in report["intel_snapshot"]}
316
+ for check in report["coverage"]:
317
+ if check["rule_id"] == "DEPENDENCY-CVE" and check.get("dependency"):
318
+ dep = check["dependency"]
319
+ feed = health.get(f"osv:{dep['ecosystem']}:{dep['name']}:{dep['version']}")
320
+ check["state"] = "checked" if feed and feed["status"] == "ok" and not feed["stale"] else "not-run"
321
+ elif check["rule_id"] == "OS-CVE":
322
+ check["state"] = (
323
+ "checked"
324
+ if report.get("environment")
325
+ and any(a["platform"] == report["environment"].get("platform") for a in advisories)
326
+ else "not-run"
327
+ )
328
+ report["warnings"] = [
329
+ w
330
+ for w in report["warnings"]
331
+ if not w.startswith(("No cached intelligence.", "Some intelligence sources"))
332
+ ]
333
+ if not records:
334
+ report["warnings"].append(
335
+ "No cached intelligence. Run intel sync; offline scan did not check current CVEs."
336
+ )
337
+ if any(f["stale"] or f["status"] != "ok" for f in report["intel_snapshot"]):
338
+ report["warnings"].append(
339
+ "Some intelligence sources are stale or failed. Inspect intel status for coverage."
340
+ )
341
+ report["summary"] = summarize(report)
342
+ return store.save_report(report) if save else report
343
+
344
+
345
+ def compare(store: Store, before: str, after: str) -> dict:
346
+ a, b = store.report(before), store.report(after)
347
+
348
+ def key(f):
349
+ return finding_identity(f["rule_id"], f["evidence"], f.get("scope", "app"))
350
+
351
+ left, right = {key(f): f for f in a["findings"]}, {key(f): f for f in b["findings"]}
352
+ return {
353
+ "before": a["id"],
354
+ "after": b["id"],
355
+ "added": [right[k] for k in right.keys() - left.keys()],
356
+ "no_longer_observed": [left[k] for k in left.keys() - right.keys()],
357
+ "unchanged": sum(
358
+ (left[k]["status"], left[k]["severity"]) == (right[k]["status"], right[k]["severity"])
359
+ for k in left.keys() & right.keys()
360
+ ),
361
+ "changed": [
362
+ {"before": left[k], "after": right[k]}
363
+ for k in sorted(left.keys() & right.keys())
364
+ if (left[k]["status"], left[k]["severity"]) != (right[k]["status"], right[k]["severity"])
365
+ ],
366
+ "coverage_before": a["coverage"],
367
+ "coverage_after": b["coverage"],
368
+ "note": "No longer observed means absent from this scan, not proof of remediation. Compare input and coverage before closing a finding.",
369
+ }