@sun-asterisk/sunlint 1.3.48 → 1.3.49
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.
- package/core/file-targeting-service.js +148 -15
- package/core/init-command.js +118 -70
- package/core/project-detector.js +517 -0
- package/core/tui-select.js +245 -0
- package/engines/arch-detect/rules/layered/l001-presentation-layer.js +7 -15
- package/engines/arch-detect/rules/layered/l002-business-layer.js +7 -15
- package/engines/arch-detect/rules/layered/l003-data-layer.js +7 -15
- package/engines/arch-detect/rules/layered/l004-model-layer.js +7 -15
- package/engines/arch-detect/rules/layered/l005-layer-separation.js +22 -2
- package/engines/arch-detect/rules/layered/l006-dependency-direction.js +8 -5
- package/engines/arch-detect/rules/modular/m005-no-deep-imports.js +67 -29
- package/engines/arch-detect/rules/presentation/pr001-view-layer.js +16 -9
- package/engines/arch-detect/rules/presentation/pr006-router-layer.js +33 -8
- package/engines/arch-detect/rules/presentation/pr007-interactor-layer.js +35 -6
- package/engines/arch-detect/rules/project-scanner/ps003-framework-detection.js +56 -10
- package/package.json +1 -1
- package/skill-assets/sunlint-code-quality/rules/dart/C006-verb-noun-functions.md +45 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C013-no-dead-code.md +53 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C014-dependency-injection.md +92 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C017-no-constructor-logic.md +62 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C018-generic-errors.md +57 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C019-error-log-level.md +50 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C020-no-unused-imports.md +46 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C022-no-unused-variables.md +50 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C023-no-duplicate-names.md +56 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C024-centralize-constants.md +75 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C029-catch-log-root-cause.md +53 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C030-custom-error-classes.md +86 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C033-separate-data-access.md +90 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C035-error-context-logging.md +62 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C041-no-hardcoded-secrets.md +75 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C042-boolean-naming.md +73 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C052-widget-parsing.md +84 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C060-superclass-logic.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C067-no-hardcoded-config.md +108 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/AGENTS.md +149 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN001-abort-after-response.md +75 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN002-request-context.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN003-bind-error-handling.md +70 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN004-dependency-injection.md +78 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN005-route-groups-middleware.md +71 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN006-http-status-codes.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN007-release-mode.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN008-struct-validation-tags.md +90 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN009-recovery-middleware.md +68 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN010-context-scope.md +68 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN011-middleware-concerns.md +92 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN012-no-log-sensitive.md +84 -0
- package/skill-assets/sunlint-code-quality/rules/java/J001-try-with-resources.md +86 -0
- package/skill-assets/sunlint-code-quality/rules/java/J002-equals-and-hashcode.md +88 -0
- package/skill-assets/sunlint-code-quality/rules/java/J003-string-comparison.md +72 -0
- package/skill-assets/sunlint-code-quality/rules/java/J004-use-java-time.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/java/J005-no-print-stack-trace.md +80 -0
- package/skill-assets/sunlint-code-quality/rules/java/J006-no-system-println.md +89 -0
- package/skill-assets/sunlint-code-quality/rules/java/J007-proper-logger.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/java/J008-thread-safe-singleton.md +119 -0
- package/skill-assets/sunlint-code-quality/rules/java/J009-utility-class-constructor.md +82 -0
- package/skill-assets/sunlint-code-quality/rules/java/J010-preserve-stack-trace.md +119 -0
- package/skill-assets/sunlint-code-quality/rules/java/J011-null-safe-compare.md +88 -0
- package/skill-assets/sunlint-code-quality/rules/java/J012-use-enum-collections.md +104 -0
- package/skill-assets/sunlint-code-quality/rules/java/J013-return-empty-not-null.md +102 -0
- package/skill-assets/sunlint-code-quality/rules/java/J014-hardcoded-crypto-key.md +108 -0
- package/skill-assets/sunlint-code-quality/rules/java/J015-optional-instead-of-null.md +109 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/AGENTS.md +124 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV001-form-request-validation.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV002-eager-load-no-n-plus-1.md +58 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV003-config-not-env.md +54 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV004-fillable-mass-assignment.md +51 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV005-policies-gates-authorization.md +71 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV006-queue-heavy-tasks.md +68 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV007-hash-passwords.md +51 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV008-route-model-binding.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV009-api-resources.md +72 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV010-chunk-large-datasets.md +58 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV011-db-transactions.md +73 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV012-service-layer.md +78 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV013-testing-factories.md +75 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV014-service-container.md +61 -0
- package/skill-assets/sunlint-code-quality/rules/python/P001-mutable-default-argument.md +55 -0
- package/skill-assets/sunlint-code-quality/rules/python/P002-specify-file-encoding.md +45 -0
- package/skill-assets/sunlint-code-quality/rules/python/P003-context-manager-for-resources.md +54 -0
- package/skill-assets/sunlint-code-quality/rules/python/P004-no-bare-except.md +65 -0
- package/skill-assets/sunlint-code-quality/rules/python/P005-use-isinstance.md +60 -0
- package/skill-assets/sunlint-code-quality/rules/python/P006-timezone-aware-datetime.md +58 -0
- package/skill-assets/sunlint-code-quality/rules/python/P007-use-pathlib.md +62 -0
- package/skill-assets/sunlint-code-quality/rules/python/P008-no-wildcard-import.md +52 -0
- package/skill-assets/sunlint-code-quality/rules/python/P009-logging-lazy-format.md +50 -0
- package/skill-assets/sunlint-code-quality/rules/python/P010-exception-chaining.md +57 -0
- package/skill-assets/sunlint-code-quality/rules/python/P011-subprocess-check.md +59 -0
- package/skill-assets/sunlint-code-quality/rules/python/P012-requests-timeout.md +70 -0
- package/skill-assets/sunlint-code-quality/rules/python/P013-no-global-statement.md +73 -0
- package/skill-assets/sunlint-code-quality/rules/python/P014-no-modify-collection-while-iterating.md +66 -0
- package/skill-assets/sunlint-code-quality/rules/python/P015-prefer-fstrings.md +61 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/AGENTS.md +121 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR001-strong-parameters.md +55 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR002-eager-load-includes.md +51 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR003-service-objects.md +99 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR004-active-job-background.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR005-pagination.md +53 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR006-find-each-batches.md +53 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR007-http-status-codes.md +76 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR008-before-action-auth.md +77 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR009-rails-credentials.md +61 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR010-scopes.md +57 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR011-counter-cache.md +59 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR012-render-json-status.md +42 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C006-verb-noun-functions.md +37 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C013-no-dead-code.md +55 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C014-dependency-injection.md +69 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C017-no-constructor-logic.md +66 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C018-generic-errors.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C019-error-log-level.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C020-no-unused-imports.md +47 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C022-no-unused-variables.md +46 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C023-no-duplicate-names.md +55 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C024-centralize-constants.md +68 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C029-catch-log-root-cause.md +69 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C030-custom-error-classes.md +77 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C033-separate-data-access.md +89 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C035-error-context-logging.md +66 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C041-no-hardcoded-secrets.md +65 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C042-boolean-naming.md +60 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C052-controller-parsing.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C060-superclass-logic.md +95 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C067-no-hardcoded-config.md +80 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S003-sql-injection.md +65 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S004-no-log-credentials.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S005-server-authorization.md +73 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S006-default-credentials.md +76 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S007-output-encoding.md +96 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S009-approved-crypto.md +86 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S010-csprng.md +71 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S011-insecure-deserialization.md +74 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S012-secrets-management.md +81 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S013-tls-connections.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S017-parameterized-queries.md +86 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S019-session-management.md +131 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S020-kvc-injection.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S025-input-validation.md +125 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S029-brute-force-protection.md +120 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S036-path-traversal.md +102 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S039-tls-certificate-validation.md +109 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S041-logout-invalidation.md +103 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S043-password-hashing.md +116 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S044-critical-changes-reauth.md +145 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S045-debug-info-exposure.md +116 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S046-unvalidated-redirect.md +140 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S051-token-expiry.md +134 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S053-jwt-validation.md +139 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S059-background-snapshot-protection.md +113 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S060-data-protection-api.md +106 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S061-jailbreak-detection.md +132 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Xử lý token hết hạn và implement silent refresh - không để user bị logout đột ngột
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: Không kiểm tra expiry trước khi dùng token dẫn đến 401 errors gây UX xấu, hoặc ngược lại không refresh token đúng cách dẫn đến sử dụng token đã hết hạn mà không detect.
|
|
5
|
+
tags: swift, ios, token-expiry, refresh-token, jwt, oauth, api-security
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Xử lý token hết hạn và implement silent refresh - không để user bị logout đột ngột
|
|
9
|
+
|
|
10
|
+
Kiểm tra JWT expiry (`exp` claim) trước mỗi request quan trọng. Implement silent refresh: khi access token sắp hết hạn (ví dụ còn <5 phút), dùng refresh token để lấy token mới tự động. Khi refresh token cũng hết hạn, mới force logout.
|
|
11
|
+
|
|
12
|
+
**Incorrect (không kiểm tra expiry):**
|
|
13
|
+
|
|
14
|
+
```swift
|
|
15
|
+
import Foundation
|
|
16
|
+
|
|
17
|
+
class APIClient {
|
|
18
|
+
// !! Dùng token mà không kiểm tra còn hạn không
|
|
19
|
+
func fetchUserProfile() async throws -> UserProfile {
|
|
20
|
+
let token = try KeychainService.readToken(key: "access_token")
|
|
21
|
+
var request = URLRequest(url: URL(string: "https://api.example.com/profile")!)
|
|
22
|
+
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
23
|
+
let (data, _) = try await URLSession.shared.data(for: request)
|
|
24
|
+
return try JSONDecoder().decode(UserProfile.self, from: data)
|
|
25
|
+
// Nếu 401 → crash hoặc hiển thị error mà không cố refresh
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Correct (proactive token check và silent refresh):**
|
|
31
|
+
|
|
32
|
+
```swift
|
|
33
|
+
import Foundation
|
|
34
|
+
|
|
35
|
+
struct JWTTokenManager {
|
|
36
|
+
// Parse JWT claims mà không verify signature (signature verify ở server)
|
|
37
|
+
static func expiryDate(from jwtToken: String) -> Date? {
|
|
38
|
+
let parts = jwtToken.components(separatedBy: ".")
|
|
39
|
+
guard parts.count == 3 else { return nil }
|
|
40
|
+
|
|
41
|
+
var base64 = parts[1]
|
|
42
|
+
// Pad base64
|
|
43
|
+
let padded = base64.count % 4 == 0 ? base64 : base64 + String(repeating: "=", count: 4 - base64.count % 4)
|
|
44
|
+
guard let payloadData = Data(base64Encoded: padded),
|
|
45
|
+
let payload = try? JSONDecoder().decode([String: AnyCodable].self, from: payloadData),
|
|
46
|
+
let exp = payload["exp"]?.value as? TimeInterval else { return nil }
|
|
47
|
+
return Date(timeIntervalSince1970: exp)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static func isTokenExpiringSoon(_ token: String, withinSeconds: TimeInterval = 300) -> Bool {
|
|
51
|
+
guard let expiry = expiryDate(from: token) else { return true }
|
|
52
|
+
return expiry.timeIntervalSinceNow < withinSeconds
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
actor TokenRefreshManager {
|
|
57
|
+
private var refreshTask: Task<String, Error>?
|
|
58
|
+
|
|
59
|
+
// SAFE: Single refresh task để tránh thundering herd
|
|
60
|
+
func getValidAccessToken() async throws -> String {
|
|
61
|
+
let currentToken = try KeychainService.readToken(key: "access_token")
|
|
62
|
+
|
|
63
|
+
// Nếu token còn hạn đủ dùng, return luôn
|
|
64
|
+
if !JWTTokenManager.isTokenExpiringSoon(currentToken) {
|
|
65
|
+
return currentToken
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Nếu đang refresh, chờ task hiện tại
|
|
69
|
+
if let existing = refreshTask {
|
|
70
|
+
return try await existing.value
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Tạo refresh task mới
|
|
74
|
+
let task = Task<String, Error> {
|
|
75
|
+
defer { self.refreshTask = nil }
|
|
76
|
+
return try await performTokenRefresh()
|
|
77
|
+
}
|
|
78
|
+
self.refreshTask = task
|
|
79
|
+
return try await task.value
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private func performTokenRefresh() async throws -> String {
|
|
83
|
+
guard let refreshToken = try? KeychainService.readToken(key: "refresh_token") else {
|
|
84
|
+
throw TokenError.noRefreshToken
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
var request = URLRequest(url: URL(string: "https://api.example.com/auth/refresh")!)
|
|
88
|
+
request.httpMethod = "POST"
|
|
89
|
+
request.httpBody = try? JSONEncoder().encode(["refresh_token": refreshToken])
|
|
90
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
91
|
+
|
|
92
|
+
let (data, response) = try await URLSession.shared.data(for: request)
|
|
93
|
+
guard let httpResponse = response as? HTTPURLResponse else {
|
|
94
|
+
throw TokenError.networkError
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if httpResponse.statusCode == 401 {
|
|
98
|
+
// Refresh token hết hạn → force logout
|
|
99
|
+
throw TokenError.sessionExpired
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data)
|
|
103
|
+
try KeychainService.saveToken(tokenResponse.accessToken, key: "access_token")
|
|
104
|
+
if let newRefresh = tokenResponse.refreshToken {
|
|
105
|
+
try KeychainService.saveToken(newRefresh, key: "refresh_token")
|
|
106
|
+
}
|
|
107
|
+
return tokenResponse.accessToken
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
class APIClient {
|
|
112
|
+
private let tokenManager = TokenRefreshManager()
|
|
113
|
+
|
|
114
|
+
// SAFE: Auto-refresh trước khi dùng token
|
|
115
|
+
func fetchUserProfile() async throws -> UserProfile {
|
|
116
|
+
do {
|
|
117
|
+
let token = try await tokenManager.getValidAccessToken()
|
|
118
|
+
var request = URLRequest(url: URL(string: "https://api.example.com/profile")!)
|
|
119
|
+
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
120
|
+
let (data, _) = try await URLSession.shared.data(for: request)
|
|
121
|
+
return try JSONDecoder().decode(UserProfile.self, from: data)
|
|
122
|
+
} catch TokenError.sessionExpired {
|
|
123
|
+
// Xử lý logout khi session thực sự hết hạn
|
|
124
|
+
await AuthManager.shared.logout()
|
|
125
|
+
throw TokenError.sessionExpired
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
enum TokenError: Error { case noRefreshToken, networkError, sessionExpired }
|
|
131
|
+
struct TokenResponse: Decodable { let accessToken: String; let refreshToken: String? }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Tools:** JWTDecode.swift (library), OWASP MASVS-AUTH-3, OAuth 2.0 RFC 6749
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Validate JWT đầy đủ - kiểm tra signature, issuer, audience và expiry
|
|
3
|
+
impact: CRITICAL
|
|
4
|
+
impactDescription: Chấp nhận JWT chỉ dựa vào decode mà không verify signature cho phép attacker forge token với bất kỳ claims nào. Missing audience/issuer check cho phép token của service khác được dùng.
|
|
5
|
+
tags: swift, ios, jwt, token-validation, signature, claims, security
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Validate JWT đầy đủ - kiểm tra signature, issuer, audience và expiry
|
|
9
|
+
|
|
10
|
+
JWT phải được validate server-side. Client-side chỉ nên đọc claims để biết expiry, user ID cho UX - không dùng claims để ra quyết định security. Khi nhận JWT từ server, phải đảm bảo server đã validate đầy đủ trước khi trust bất kỳ claim nào.
|
|
11
|
+
|
|
12
|
+
**Incorrect (decode JWT mà không verify, dùng claims cho security decision):**
|
|
13
|
+
|
|
14
|
+
```swift
|
|
15
|
+
import Foundation
|
|
16
|
+
|
|
17
|
+
struct JWTDecoder {
|
|
18
|
+
// !! Decode không verify signature
|
|
19
|
+
static func decode(token: String) -> [String: Any]? {
|
|
20
|
+
let parts = token.components(separatedBy: ".")
|
|
21
|
+
guard parts.count == 3 else { return nil }
|
|
22
|
+
var base64 = parts[1]
|
|
23
|
+
let padded = base64 + String(repeating: "=", count: (4 - base64.count % 4) % 4)
|
|
24
|
+
guard let data = Data(base64Encoded: padded) else { return nil }
|
|
25
|
+
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class AuthorizationChecker {
|
|
30
|
+
// !! Dùng JWT claims (không verify) để kiểm tra quyền - attacker có thể forge!
|
|
31
|
+
func isAdmin(token: String) -> Bool {
|
|
32
|
+
let claims = JWTDecoder.decode(token: token)
|
|
33
|
+
return claims?["role"] as? String == "admin" // Không verify signature!
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// !! Không check issuer/audience - token của service khác có thể dùng được
|
|
37
|
+
func validateToken(_ token: String) -> Bool {
|
|
38
|
+
guard let claims = JWTDecoder.decode(token: token),
|
|
39
|
+
let exp = claims["exp"] as? TimeInterval else { return false }
|
|
40
|
+
return Date().timeIntervalSince1970 < exp // Chỉ check expiry!
|
|
41
|
+
// Không check: iss (issuer), aud (audience), alg, nbf
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Correct (validate server-side, client chỉ đọc claims cho UX):**
|
|
47
|
+
|
|
48
|
+
```swift
|
|
49
|
+
import Foundation
|
|
50
|
+
|
|
51
|
+
// SAFE: Server-side validation là bắt buộc. Client chỉ đọc non-security claims.
|
|
52
|
+
struct JWTClaims: Decodable {
|
|
53
|
+
let sub: String // Subject (user ID)
|
|
54
|
+
let exp: TimeInterval // Expiry
|
|
55
|
+
let iat: TimeInterval // Issued at
|
|
56
|
+
let iss: String // Issuer - phải match expected value
|
|
57
|
+
let aud: String // Audience - phải match app's client_id
|
|
58
|
+
let jti: String? // JWT ID - để detect reuse nếu cần
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
struct ClientSideJWTReader {
|
|
62
|
+
private let expectedIssuer: String
|
|
63
|
+
private let expectedAudience: String
|
|
64
|
+
|
|
65
|
+
init(issuer: String, audience: String) {
|
|
66
|
+
self.expectedIssuer = issuer
|
|
67
|
+
self.expectedAudience = audience
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Client-side decode CHỈ để đọc UX data (user ID, expiry)
|
|
71
|
+
// KHÔNG dùng cho security decision - server phải verify signature
|
|
72
|
+
func readClaimsForUX(from token: String) throws -> JWTClaims {
|
|
73
|
+
let parts = token.components(separatedBy: ".")
|
|
74
|
+
guard parts.count == 3 else { throw JWTError.malformedToken }
|
|
75
|
+
|
|
76
|
+
let base64 = parts[1]
|
|
77
|
+
let padded = base64 + String(repeating: "=", count: (4 - base64.count % 4) % 4)
|
|
78
|
+
guard let data = Data(base64Encoded: padded) else { throw JWTError.malformedToken }
|
|
79
|
+
|
|
80
|
+
let decoder = JSONDecoder()
|
|
81
|
+
let claims = try decoder.decode(JWTClaims.self, from: data)
|
|
82
|
+
|
|
83
|
+
// Validate claims cơ bản cho UX (không thay thế server validation)
|
|
84
|
+
guard claims.iss == expectedIssuer else {
|
|
85
|
+
throw JWTError.invalidIssuer(claims.iss)
|
|
86
|
+
}
|
|
87
|
+
guard claims.aud == expectedAudience else {
|
|
88
|
+
throw JWTError.invalidAudience(claims.aud)
|
|
89
|
+
}
|
|
90
|
+
guard Date().timeIntervalSince1970 < claims.exp else {
|
|
91
|
+
throw JWTError.tokenExpired
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// NOTE: Signature chưa được verify ở đây!
|
|
95
|
+
// Tất cả API request phải gửi token để SERVER verify signature
|
|
96
|
+
return claims
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
class AuthManager {
|
|
101
|
+
private let jwtReader = ClientSideJWTReader(
|
|
102
|
+
issuer: "https://auth.example.com",
|
|
103
|
+
audience: "com.example.myapp"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// SAFE: Server verify full JWT, client chỉ dùng userId từ claims để hiển thị UI
|
|
107
|
+
func setupAfterLogin(accessToken: String) throws {
|
|
108
|
+
let claims = try jwtReader.readClaimsForUX(from: accessToken)
|
|
109
|
+
// Chỉ dùng sub (user ID) để fetch profile UI, không phải security check
|
|
110
|
+
currentUserId = claims.sub
|
|
111
|
+
tokenExpiryDate = Date(timeIntervalSince1970: claims.exp)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// SAFE: Authorization check thông qua server API, không phải local JWT claims
|
|
115
|
+
func checkAdminAccess() async throws -> Bool {
|
|
116
|
+
let token = try KeychainService.readToken(key: "access_token")
|
|
117
|
+
// Server endpoint sẽ verify JWT và check role
|
|
118
|
+
var request = URLRequest(url: URL(string: "https://api.example.com/admin/verify")!)
|
|
119
|
+
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
120
|
+
let (_, response) = try await URLSession.shared.data(for: request)
|
|
121
|
+
return (response as? HTTPURLResponse)?.statusCode == 200
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
enum JWTError: LocalizedError {
|
|
126
|
+
case malformedToken, invalidIssuer(String), invalidAudience(String), tokenExpired
|
|
127
|
+
|
|
128
|
+
var errorDescription: String? {
|
|
129
|
+
switch self {
|
|
130
|
+
case .malformedToken: return "Malformed JWT token"
|
|
131
|
+
case .invalidIssuer(let iss): return "Invalid issuer: \(iss)"
|
|
132
|
+
case .invalidAudience(let aud): return "Invalid audience: \(aud)"
|
|
133
|
+
case .tokenExpired: return "Token has expired"
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**Tools:** JWTDecode.swift, Auth0 SDK, OWASP MASVS-AUTH-1, jwt.io (debug)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Che nội dung nhạy cảm khi app vào background để tránh lộ qua App Switcher
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: iOS chụp screenshot app khi vào background để hiển thị trong App Switcher. Màn hình chứa số dư tài khoản, tin nhắn riêng tư, hoặc thông tin cá nhân sẽ bị lưu trong bộ nhớ và có thể bị trích xuất từ device backup trên jailbroken device.
|
|
5
|
+
tags: swift, ios, background-snapshot, app-switcher, data-privacy, ui-security, security
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Che nội dung nhạy cảm khi app vào background để tránh lộ qua App Switcher
|
|
9
|
+
|
|
10
|
+
Khi app vào background (`applicationWillResignActive`/`sceneWillResignActive`), iOS chụp snapshot để hiển thị trong App Switcher. Phải che màn hình chứa dữ liệu nhạy cảm bằng cách thêm blur overlay hoặc splash screen trước khi app vào background.
|
|
11
|
+
|
|
12
|
+
**Incorrect (không che màn hình khi vào background):**
|
|
13
|
+
|
|
14
|
+
```swift
|
|
15
|
+
import UIKit
|
|
16
|
+
|
|
17
|
+
// !! App không làm gì khi vào background
|
|
18
|
+
// Màn hình banking balance, tin nhắn, health data bị capture trong App Switcher snapshot
|
|
19
|
+
@main
|
|
20
|
+
class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
21
|
+
// Không có xử lý background snapshot protection
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// !! Màn hình hiển thị số dư tiền mà không bảo vệ
|
|
25
|
+
class AccountBalanceViewController: UIViewController {
|
|
26
|
+
@IBOutlet weak var balanceLabel: UILabel! // "$12,345.67" hiển thị trong snapshot!
|
|
27
|
+
|
|
28
|
+
override func viewDidLoad() {
|
|
29
|
+
super.viewDidLoad()
|
|
30
|
+
balanceLabel.text = "$12,345.67"
|
|
31
|
+
// Không register notification để hide khi background
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Correct (blur overlay khi vào background):**
|
|
37
|
+
|
|
38
|
+
```swift
|
|
39
|
+
import UIKit
|
|
40
|
+
|
|
41
|
+
// SAFE: Scene-based approach (iOS 13+)
|
|
42
|
+
class SceneDelegate: UIResponder, UISceneDelegate {
|
|
43
|
+
private var privacyOverlay: UIView?
|
|
44
|
+
|
|
45
|
+
func sceneWillResignActive(_ scene: UIScene) {
|
|
46
|
+
addPrivacyOverlay(to: scene)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func sceneDidBecomeActive(_ scene: UIScene) {
|
|
50
|
+
removePrivacyOverlay()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private func addPrivacyOverlay(to scene: UIScene) {
|
|
54
|
+
guard let windowScene = scene as? UIWindowScene,
|
|
55
|
+
let window = windowScene.windows.first else { return }
|
|
56
|
+
|
|
57
|
+
if privacyOverlay != nil { return } // Đã có overlay
|
|
58
|
+
|
|
59
|
+
let overlay = UIView(frame: window.bounds)
|
|
60
|
+
overlay.backgroundColor = .systemBackground
|
|
61
|
+
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
62
|
+
|
|
63
|
+
// Thêm logo app thay vì màn trắng để đẹp hơn
|
|
64
|
+
let imageView = UIImageView(image: UIImage(named: "AppLogo"))
|
|
65
|
+
imageView.contentMode = .scaleAspectFit
|
|
66
|
+
imageView.center = CGPoint(x: overlay.bounds.midX, y: overlay.bounds.midY)
|
|
67
|
+
overlay.addSubview(imageView)
|
|
68
|
+
|
|
69
|
+
window.addSubview(overlay)
|
|
70
|
+
privacyOverlay = overlay
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private func removePrivacyOverlay() {
|
|
74
|
+
privacyOverlay?.removeFromSuperview()
|
|
75
|
+
privacyOverlay = nil
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// SAFE: Màn hình cụ thể tự bảo vệ
|
|
80
|
+
class SensitiveDataViewController: UIViewController {
|
|
81
|
+
@IBOutlet weak var balanceLabel: UILabel!
|
|
82
|
+
private var blurView: UIVisualEffectView?
|
|
83
|
+
|
|
84
|
+
override func viewDidLoad() {
|
|
85
|
+
super.viewDidLoad()
|
|
86
|
+
let nc = NotificationCenter.default
|
|
87
|
+
nc.addObserver(self, selector: #selector(appWillResignActive),
|
|
88
|
+
name: UIApplication.willResignActiveNotification, object: nil)
|
|
89
|
+
nc.addObserver(self, selector: #selector(appDidBecomeActive),
|
|
90
|
+
name: UIApplication.didBecomeActiveNotification, object: nil)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@objc private func appWillResignActive() {
|
|
94
|
+
let blur = UIBlurEffect(style: .systemThickMaterial)
|
|
95
|
+
let blurView = UIVisualEffectView(effect: blur)
|
|
96
|
+
blurView.frame = view.bounds
|
|
97
|
+
blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
98
|
+
view.addSubview(blurView)
|
|
99
|
+
self.blurView = blurView
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@objc private func appDidBecomeActive() {
|
|
103
|
+
blurView?.removeFromSuperview()
|
|
104
|
+
blurView = nil
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
deinit {
|
|
108
|
+
NotificationCenter.default.removeObserver(self)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Tools:** OWASP MASVS-PLATFORM-4, iMazing (inspect snapshots in backup), Simulator App Switcher testing
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Bật Data Protection (NSFileProtectionComplete) cho file chứa dữ liệu nhạy cảm
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: File không có Data Protection class có thể bị đọc ngay cả khi device bị khóa (accessible after first unlock). Với NSFileProtectionComplete, file chỉ accessible khi device đang mở khóa, được mã hóa bằng user passcode.
|
|
5
|
+
tags: swift, ios, data-protection, file-encryption, nsdatawritingoptions, secure-storage, security
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Bật Data Protection (NSFileProtectionComplete) cho file chứa dữ liệu nhạy cảm
|
|
9
|
+
|
|
10
|
+
iOS Data Protection mã hóa file bằng key kết hợp từ passcode device và hardware key. Khi tạo hoặc ghi file nhạy cảm, phải set attribute `NSFileProtectionComplete` (mạnh nhất - chỉ decrypt khi device đang unlock). Database CoreData và SQLite cũng cần bật encryption.
|
|
11
|
+
|
|
12
|
+
**Incorrect (không set data protection):**
|
|
13
|
+
|
|
14
|
+
```swift
|
|
15
|
+
import Foundation
|
|
16
|
+
|
|
17
|
+
class DocumentManager {
|
|
18
|
+
// !! Ghi file không có data protection - accessible mọi lúc
|
|
19
|
+
func savePrivateDocument(content: Data, filename: String) throws {
|
|
20
|
+
let url = documentsDirectory.appendingPathComponent(filename)
|
|
21
|
+
// Không set protection attribute - mặc định là NSFileProtectionCompleteUntilFirstUserAuthentication
|
|
22
|
+
try content.write(to: url)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// !! SQLite database không có data protection
|
|
26
|
+
func openDatabase() -> OpaquePointer? {
|
|
27
|
+
let dbPath = documentsDirectory.appendingPathComponent("app.db").path
|
|
28
|
+
var db: OpaquePointer?
|
|
29
|
+
sqlite3_open(dbPath, &db) // Không có encryption!
|
|
30
|
+
return db
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Correct (NSFileProtectionComplete + FMDB encryption):**
|
|
36
|
+
|
|
37
|
+
```swift
|
|
38
|
+
import Foundation
|
|
39
|
+
|
|
40
|
+
class SecureDocumentManager {
|
|
41
|
+
private let documentsDirectory = FileManager.default
|
|
42
|
+
.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
43
|
+
|
|
44
|
+
// SAFE: Data Protection Complete cho file nhạy cảm
|
|
45
|
+
func savePrivateDocument(content: Data, filename: String) throws {
|
|
46
|
+
let url = documentsDirectory.appendingPathComponent(filename)
|
|
47
|
+
// NSFileProtectionComplete: chỉ accessible khi device unlocked
|
|
48
|
+
try content.write(
|
|
49
|
+
to: url,
|
|
50
|
+
options: [.atomic, .completeFileProtection] // .completeFileProtection = NSFileProtectionComplete
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// SAFE: Set protection cho file đã tồn tại
|
|
55
|
+
func upgradeFileProtection(at url: URL) throws {
|
|
56
|
+
try FileManager.default.setAttributes(
|
|
57
|
+
[.protectionKey: FileProtectionType.complete],
|
|
58
|
+
ofItemAtPath: url.path
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// SAFE: Kiểm tra data protection của file
|
|
63
|
+
func verifyFileProtection(at url: URL) throws -> Bool {
|
|
64
|
+
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
|
65
|
+
let protection = attributes[.protectionKey] as? FileProtectionType
|
|
66
|
+
return protection == .complete || protection == .completeUnlessOpen
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// SAFE: Tạo directory với data protection
|
|
70
|
+
func createSecureDirectory(named name: String) throws -> URL {
|
|
71
|
+
let dirURL = documentsDirectory.appendingPathComponent(name)
|
|
72
|
+
try FileManager.default.createDirectory(
|
|
73
|
+
at: dirURL,
|
|
74
|
+
withIntermediateDirectories: true,
|
|
75
|
+
attributes: [.protectionKey: FileProtectionType.complete]
|
|
76
|
+
)
|
|
77
|
+
return dirURL
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// SAFE: CoreData với data protection (trong NSPersistentStoreDescription)
|
|
82
|
+
class CoreDataStack {
|
|
83
|
+
lazy var persistentContainer: NSPersistentContainer = {
|
|
84
|
+
let container = NSPersistentContainer(name: "AppModel")
|
|
85
|
+
let description = NSPersistentStoreDescription()
|
|
86
|
+
description.url = FileManager.default
|
|
87
|
+
.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
88
|
+
.appendingPathComponent("AppModel.sqlite")
|
|
89
|
+
// SAFE: Enable SQLite data protection
|
|
90
|
+
description.setOption(
|
|
91
|
+
FileProtectionType.complete as NSObject,
|
|
92
|
+
forKey: NSPersistentStoreFileProtectionKey
|
|
93
|
+
)
|
|
94
|
+
container.persistentStoreDescriptions = [description]
|
|
95
|
+
container.loadPersistentStores { _, error in
|
|
96
|
+
if let error = error { fatalError("CoreData load error: \(error)") }
|
|
97
|
+
}
|
|
98
|
+
return container
|
|
99
|
+
}()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// SAFE: App entitlement - trong Xcode → Signing & Capabilities → Data Protection
|
|
103
|
+
// Chọn "Complete Protection" trong Data Protection capability
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**Tools:** OWASP MASVS-STORAGE-4, iMazing (verify protection class), `filecmds` on jailbroken device, Xcode Capabilities (Data Protection entitlement)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Hạn chế tính năng nhạy cảm trên jailbroken device
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: Trên jailbroken device, Keychain có thể bị extract, SSL pinning bị bypass bởi SSL Kill Switch, và file system accessible không có restriction. Banking/healthcare app cần detect jailbreak để warn hoặc restrict.
|
|
5
|
+
tags: swift, ios, jailbreak-detection, root-detection, security-posture, masvs, security
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Hạn chế tính năng nhạy cảm trên jailbroken device
|
|
9
|
+
|
|
10
|
+
Jailbreak detection giúp app nhận biết môi trường không tin cậy để warn user hoặc disable tính năng có rủi ro cao (như lưu credential, xem sensitive data). Lưu ý: detection không 100% và có thể bị bypass, nhưng vẫn là lớp bảo vệ quan trọng cho app tài chính/y tế.
|
|
11
|
+
|
|
12
|
+
**Incorrect (không có jailbreak awareness):**
|
|
13
|
+
|
|
14
|
+
```swift
|
|
15
|
+
// App banking hoạt động bình thường trên device đã jailbreak
|
|
16
|
+
// Keychain contents bị extract bằng keychain-dumper
|
|
17
|
+
// Certificate pinning bị bypass bằng SSL Kill Switch 2
|
|
18
|
+
// File sandbox bị đọc bởi bất kỳ process nào có root
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**Correct (basic jailbreak detection với graceful response):**
|
|
22
|
+
|
|
23
|
+
```swift
|
|
24
|
+
import UIKit
|
|
25
|
+
import Foundation
|
|
26
|
+
|
|
27
|
+
struct JailbreakDetector {
|
|
28
|
+
// Chỉ run trong production build
|
|
29
|
+
#if !targetEnvironment(simulator)
|
|
30
|
+
static func isJailbroken() -> Bool {
|
|
31
|
+
return hasJailbreakFiles()
|
|
32
|
+
|| canWriteOutsideSandbox()
|
|
33
|
+
|| hasSuspiciousApps()
|
|
34
|
+
|| hasDynamicLibraryInjection()
|
|
35
|
+
}
|
|
36
|
+
#else
|
|
37
|
+
static func isJailbroken() -> Bool { return false }
|
|
38
|
+
#endif
|
|
39
|
+
|
|
40
|
+
// Kiểm tra file đặc trưng của jailbreak tools
|
|
41
|
+
private static func hasJailbreakFiles() -> Bool {
|
|
42
|
+
let jailbreakPaths = [
|
|
43
|
+
"/Applications/Cydia.app",
|
|
44
|
+
"/Applications/Sileo.app",
|
|
45
|
+
"/Library/MobileSubstrate/MobileSubstrate.dylib",
|
|
46
|
+
"/bin/bash",
|
|
47
|
+
"/usr/sbin/sshd",
|
|
48
|
+
"/etc/apt",
|
|
49
|
+
"/private/var/lib/apt/",
|
|
50
|
+
"/private/var/lib/cydia",
|
|
51
|
+
"/usr/bin/ssh",
|
|
52
|
+
"/var/jb" // Palera1n
|
|
53
|
+
]
|
|
54
|
+
return jailbreakPaths.contains { FileManager.default.fileExists(atPath: $0) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Thử ghi file ngoài sandbox - chỉ jailbroken device cho phép
|
|
58
|
+
private static func canWriteOutsideSandbox() -> Bool {
|
|
59
|
+
let testPath = "/private/jailbreak_test_\(UUID().uuidString)"
|
|
60
|
+
do {
|
|
61
|
+
try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
|
|
62
|
+
try FileManager.default.removeItem(atPath: testPath)
|
|
63
|
+
return true
|
|
64
|
+
} catch {
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Kiểm tra app jailbreak phổ biến qua URL scheme
|
|
70
|
+
private static func hasSuspiciousApps() -> Bool {
|
|
71
|
+
let schemes = ["cydia://", "sileo://", "zbra://", "filza://"]
|
|
72
|
+
return schemes.contains { scheme in
|
|
73
|
+
guard let url = URL(string: scheme) else { return false }
|
|
74
|
+
return UIApplication.shared.canOpenURL(url)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Kiểm tra DYLD injection
|
|
79
|
+
private static func hasDynamicLibraryInjection() -> Bool {
|
|
80
|
+
if let dyldEnv = ProcessInfo.processInfo.environment["DYLD_INSERT_LIBRARIES"],
|
|
81
|
+
!dyldEnv.isEmpty {
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// SAFE: Respond gracefully - warn, không block hoàn toàn (tránh false positive)
|
|
89
|
+
class SecurityCheckManager {
|
|
90
|
+
enum SecurityRisk {
|
|
91
|
+
case jailbroken
|
|
92
|
+
case clean
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func assessSecurityRisk() -> SecurityRisk {
|
|
96
|
+
return JailbreakDetector.isJailbroken() ? .jailbroken : .clean
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Tùy từng loại app mà quyết định hành động
|
|
100
|
+
func handleJailbreakDetected(in viewController: UIViewController) {
|
|
101
|
+
let alert = UIAlertController(
|
|
102
|
+
title: "Security Risk Detected",
|
|
103
|
+
message: "Your device appears to be modified (jailbroken). " +
|
|
104
|
+
"This app's security features may not work as expected. " +
|
|
105
|
+
"Sensitive features have been disabled for your protection.",
|
|
106
|
+
preferredStyle: .alert
|
|
107
|
+
)
|
|
108
|
+
// Tài chính/banking: offer option để proceed với risk accepted
|
|
109
|
+
alert.addAction(UIAlertAction(title: "I Understand", style: .destructive) { _ in
|
|
110
|
+
// Disable high-risk features: biometric storage, show account numbers, etc.
|
|
111
|
+
SecurityFeatureFlags.disableHighRiskFeatures()
|
|
112
|
+
})
|
|
113
|
+
alert.addAction(UIAlertAction(title: "Exit", style: .cancel) { _ in
|
|
114
|
+
exit(0) // Hoặc chỉ close app, không kill process
|
|
115
|
+
})
|
|
116
|
+
viewController.present(alert, animated: true)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
struct SecurityFeatureFlags {
|
|
121
|
+
static var highRiskFeaturesEnabled = true
|
|
122
|
+
|
|
123
|
+
static func disableHighRiskFeatures() {
|
|
124
|
+
highRiskFeaturesEnabled = false
|
|
125
|
+
// Disable: show full card number, biometric auto-fill, remember device, etc.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**Tools:** IOSSecuritySuite (library - comprehensive detection), DTTJailbreakDetection, OWASP MASVS-RESILIENCE-1, frida-ios-dump (test bypass)
|
|
131
|
+
|
|
132
|
+
**Note:** Jailbreak detection là một lớp bảo vệ trong defense-in-depth, không phải giải pháp duy nhất. Thiết kế hệ thống sao cho server-side security không phụ thuộc vào integrity của client.
|