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.
- apsa/__init__.py +3 -0
- apsa/__main__.py +3 -0
- apsa/cli.py +3 -0
- apsa-1.0.3.dist-info/METADATA +228 -0
- apsa-1.0.3.dist-info/RECORD +62 -0
- apsa-1.0.3.dist-info/WHEEL +4 -0
- apsa-1.0.3.dist-info/entry_points.txt +4 -0
- apsa-1.0.3.dist-info/licenses/LICENSE +21 -0
- mobile_audit/__init__.py +3 -0
- mobile_audit/__main__.py +3 -0
- mobile_audit/_job_worker.py +72 -0
- mobile_audit/_parser_worker.py +125 -0
- mobile_audit/audit.py +369 -0
- mobile_audit/binary_analysis.py +794 -0
- mobile_audit/cli.py +543 -0
- mobile_audit/core.py +237 -0
- mobile_audit/data/demo/AndroidManifest.xml +13 -0
- mobile_audit/data/demo/Demo.swift +4 -0
- mobile_audit/data/demo/Info.plist +7 -0
- mobile_audit/data/demo/MainActivity.kt +13 -0
- mobile_audit/data/demo/build.gradle.kts +3 -0
- mobile_audit/data/rules.json +174 -0
- mobile_audit/data/skills/apsa/SKILL.md +38 -0
- mobile_audit/data/skills/mobile-audit/SKILL.md +38 -0
- mobile_audit/data/skills/quaygate/SKILL.md +38 -0
- mobile_audit/data/structural-rules.json +280 -0
- mobile_audit/engine.py +66 -0
- mobile_audit/inputs.py +574 -0
- mobile_audit/intel.py +813 -0
- mobile_audit/jobs.py +188 -0
- mobile_audit/mcp_server.py +322 -0
- mobile_audit/output.py +192 -0
- mobile_audit/policy.py +424 -0
- mobile_audit/processes.py +78 -0
- mobile_audit/quaygate_analysis.py +228 -0
- mobile_audit/rules.py +165 -0
- mobile_audit/runtime.py +804 -0
- mobile_audit/skills.py +61 -0
- mobile_audit/source_analysis.py +927 -0
- mobile_audit/store.py +492 -0
- mobile_audit/tools.py +40 -0
- mobile_audit/tui.py +449 -0
- mobile_audit/watch.py +45 -0
- quaygate/__init__.py +3 -0
- quaygate/__main__.py +5 -0
- quaygate/adb.py +55 -0
- quaygate/apk.py +136 -0
- quaygate/appchecks.py +705 -0
- quaygate/apps.py +136 -0
- quaygate/arsc.py +171 -0
- quaygate/axml.py +160 -0
- quaygate/cert.py +91 -0
- quaygate/checks.py +289 -0
- quaygate/cli.py +20 -0
- quaygate/dex.py +37 -0
- quaygate/dexcode.py +271 -0
- quaygate/elf.py +99 -0
- quaygate/indicators.py +148 -0
- quaygate/ipa.py +404 -0
- quaygate/legacy_cli.py +145 -0
- quaygate/network_security.py +141 -0
- 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
|
+
}
|