Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/repo-hygiene.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ on:
- "project.yml"
- "TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved"
- "TablePro/Resources/ThirdPartyLicenses/licenses.yml"
- ".swiftlint.yml"
push:
branches: [main]
paths:
Expand All @@ -36,6 +37,7 @@ on:
- "project.yml"
- "TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved"
- "TablePro/Resources/ThirdPartyLicenses/licenses.yml"
- ".swiftlint.yml"
workflow_dispatch:

concurrency:
Expand Down Expand Up @@ -106,6 +108,16 @@ jobs:
- name: Check plugin sources that log import the module that defines it
run: python3 scripts/ci/check-plugin-os-import.py

# SwiftLint runs in CI only on a release tag, and its included: never reaches Plugins/,
# where fifteen log lines published an error's text unseen. This applies that one rule to
# the app, the packages and the plugins on every pull request, with the regex read from
# .swiftlint.yml.
- name: Check no source publishes an error's text to the system log
run: python3 scripts/ci/check-log-privacy.py

- name: Validate the log privacy check
run: python3 scripts/ci/test_check_log_privacy.py

# A plugin's String(localized:) resolves against Bundle.main, which is the host app, so its
# strings live in the app's catalog. Xcode extracts per target and the plugin targets are not
# the app target, so nothing puts them there: 537 strings had never reached a translator.
Expand Down
4 changes: 2 additions & 2 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,6 @@ custom_rules:
severity: error
public_error_text_in_log:
name: "Error Text In The System Log"
regex: '\.localizedDescription\s*,\s*privacy:\s*\.public'
message: "A driver or Foundation error's text carries row values, paths and server messages. Publish `error.publicLogShape` and log the description at .private."
regex: '(\.(localizedDescription|errorDescription|failureReason)(\.prefix\(\d+\))?(\s*\?\?\s*"[^"]*")?|String\(describing:\s*\w*[eE]rr(or)?\)|\\\((?!(has|is|did|was|should)[A-Z])(\w*[eE]rr(or)?|e)(\.(message|description|debugDescription))?)\s*,\s*privacy:\s*\.public'
message: "A driver or Foundation error's text carries row values, paths and server messages. Publish `error.publicLogShape`, or `LogRedaction.publicDescription(of: error)` in a plugin, and log the description at .private."
severity: error
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- A locked launch on iPhone and iPad connecting to the last session, and asking to trust a host key, before Face ID was answered.
- Code inside a plugin bundle, and its resource envelope, were not verified before the bundle was loaded.
- The system log carried query text, schema and table names, file paths and driver error messages, which can hold row values.
- Driver error messages and server replies published in the system log by database plugins.
- A chat tool registered at runtime could take the name of a tool TablePro ships.
- An open connection, a sheet and the app switcher preview left usable or visible behind the iOS app lock.
- **Require Face ID** turned off on iPhone and iPad without authenticating.
Expand Down
11 changes: 11 additions & 0 deletions Packages/TableProCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ let package = Package(
.library(name: "TableProWeaviateCore", targets: ["TableProWeaviateCore"]),
.library(name: "TableProNumberFormatting", targets: ["TableProNumberFormatting"]),
.library(name: "TableProDocumentPath", targets: ["TableProDocumentPath"]),
.library(name: "TableProLogRedaction", targets: ["TableProLogRedaction"]),
.library(name: "TableProR2SQLCore", targets: ["TableProR2SQLCore"]),
.library(name: "TableProConnectionLibrary", targets: ["TableProConnectionLibrary"]),
.library(name: "TableProSQLGrammar", targets: ["TableProSQLGrammar"]),
Expand All @@ -45,6 +46,11 @@ let package = Package(
dependencies: [],
path: "Sources/TableProDocumentPath"
),
.target(
name: "TableProLogRedaction",
dependencies: [],
path: "Sources/TableProLogRedaction"
),
.target(
name: "TableProCoreTypes",
dependencies: [],
Expand Down Expand Up @@ -185,6 +191,11 @@ let package = Package(
dependencies: ["TableProDocumentPath"],
path: "Tests/TableProDocumentPathTests"
),
.testTarget(
name: "TableProLogRedactionTests",
dependencies: ["TableProLogRedaction"],
path: "Tests/TableProLogRedactionTests"
),
.testTarget(
name: "TableProModelsTests",
dependencies: ["TableProModels", "TableProPluginKit"],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Foundation

public protocol PubliclyLoggableError: Error {
var publicLogDescription: String { get }
}

public enum LogRedaction {
private static let maximumDomainLength = 64
private static let identifierPunctuation: Set<Unicode.Scalar> = [".", "_", "-"]

public static func publicDescription(of error: Error) -> String {
if let loggable = error as? PubliclyLoggableError {
return loggable.publicLogDescription
}

let typeName = String(describing: type(of: error))
let mirror = Mirror(reflecting: error)

guard mirror.displayStyle == .enum else {
return bridgedShape(of: error as NSError, typeName: typeName)
}
if let caseName = mirror.children.first?.label {
return "\(typeName).\(caseName)"
}
guard !describesItself(type(of: error)) else { return typeName }
return "\(typeName).\(error)"
}

private static func bridgedShape(of error: NSError, typeName: String) -> String {
let domain = error.domain
guard !domain.hasSuffix(typeName), isConstantIdentifier(domain) else {
return "\(typeName)(\(error.code))"
}
return "\(typeName)(\(domain), \(error.code))"
}

private static func isConstantIdentifier(_ domain: String) -> Bool {
let scalars = domain.unicodeScalars
guard domain.utf8.count <= maximumDomainLength, let first = scalars.first, isASCIILetter(first) else {
return false
}
return scalars.allSatisfy { isASCIILetter($0) || ("0"..."9").contains($0) || identifierPunctuation.contains($0) }
}

private static func isASCIILetter(_ scalar: Unicode.Scalar) -> Bool {
("a"..."z").contains(scalar) || ("A"..."Z").contains(scalar)
}

private static func describesItself(_ type: any Error.Type) -> Bool {
type is CustomStringConvertible.Type || type is CustomDebugStringConvertible.Type
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import Foundation
import Testing

@testable import TableProLogRedaction

@Suite("Log redaction shapes")
struct LogRedactionShapeTests {
private static let serverText =
"ERROR: duplicate key value violates unique constraint \"users_email_key\" Key (email)=(a@b.com) already exists."

private enum DriverError: Error {
case executionFailed(String)
case disconnected
}

private enum DescribedError: Error, CustomStringConvertible {
case refused

var description: String { LogRedactionShapeTests.serverText }
}

private enum DebugDescribedError: Error, CustomDebugStringConvertible {
case refused

var debugDescription: String { LogRedactionShapeTests.serverText }
}

private struct BoxedError: Error {
let serverText: String
}

private enum SafeError: PubliclyLoggableError {
case readOnlyConnection

var publicLogDescription: String { "the connection is read-only" }
}

@Test("An enum case's payload never reaches the public description")
func enumPayloadIsDropped() {
#expect(LogRedaction.publicDescription(of: DriverError.executionFailed(Self.serverText)) == "DriverError.executionFailed")
}

@Test("A case with no payload keeps its name")
func payloadlessCaseKeepsItsName() {
#expect(LogRedaction.publicDescription(of: DriverError.disconnected) == "DriverError.disconnected")
}

@Test("A case whose type writes its own description publishes the type alone")
func customDescriptionIsNotPublished() {
#expect(LogRedaction.publicDescription(of: DescribedError.refused) == "DescribedError")
#expect(LogRedaction.publicDescription(of: DebugDescribedError.refused) == "DebugDescribedError")
}

@Test("A struct error publishes its type and bridged code, not its stored text")
func structErrorPublishesItsShape() {
let shape = LogRedaction.publicDescription(of: BoxedError(serverText: Self.serverText))

#expect(shape.hasPrefix("BoxedError("))
#expect(!shape.contains("a@b.com"))
}

@Test("A system error keeps the domain and code a reader can act on")
func systemDomainIsKept() {
let shape = LogRedaction.publicDescription(of: NSError(domain: NSPOSIXErrorDomain, code: 61))

#expect(shape == "NSError(\(NSPOSIXErrorDomain), 61)")
}

@Test("A constant domain from another framework is published with its code")
func constantDomainIsKept() {
#expect(LogRedaction.publicDescription(of: NSError(domain: "CKErrorDomain", code: 3)) == "NSError(CKErrorDomain, 3)")
#expect(LogRedaction.publicDescription(of: NSError(domain: "com.example.sync-kit", code: 2)) == "NSError(com.example.sync-kit, 2)")
}

@Test("A domain built from text is never published, whatever it holds", arguments: [
serverText,
"a@b.com",
"/Users/someone/Library/db.sqlite",
"Key(email)",
String(repeating: "a", count: 65),
""
])
func textDomainIsDropped(domain: String) {
#expect(LogRedaction.publicDescription(of: NSError(domain: domain, code: 7)) == "NSError(7)")
}

@Test("An error that declares its description safe is published in full")
func publiclyLoggableErrorIsPublishedInFull() {
#expect(LogRedaction.publicDescription(of: SafeError.readOnlyConnection) == "the connection is read-only")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -203,16 +203,14 @@ public final class OracleCoreConnection: @unchecked Sendable {
throw connectError(from: sqlError)
} catch let nioSslError as NIOSSLError {
let detail = String(describing: nioSslError)
osLogger.error("Oracle TLS error: \(detail, privacy: .public)")
throw OracleCoreError.tlsHandshakeFailed(
kind: OracleSSLClassifier.classifyTLSFailure(detail) ?? .unknown,
serverMessage: detail
)
let kind = OracleSSLClassifier.classifyTLSFailure(detail) ?? .unknown
osLogger.error("Oracle TLS error: \(String(describing: kind), privacy: .public) \(detail, privacy: .private)")
throw OracleCoreError.tlsHandshakeFailed(kind: kind, serverMessage: detail)
} catch let coreError as OracleCoreError {
throw coreError
} catch {
let detail = String(describing: error)
osLogger.error("Oracle connection failed: \(detail, privacy: .public)")
osLogger.error("Oracle connection failed: \(String(describing: type(of: error)), privacy: .public) \(detail, privacy: .private)")
if let kind = OracleSSLClassifier.classifyTLSFailure(detail) {
throw OracleCoreError.tlsHandshakeFailed(kind: kind, serverMessage: detail)
}
Expand Down Expand Up @@ -612,7 +610,7 @@ public final class OracleCoreConnection: @unchecked Sendable {
code, serverErrorNumber: sqlError.serverInfo.map { Int($0.number) }
) else {
guard let serverMessage = sqlError.serverInfo?.message else {
osLogger.error("Oracle statement failed: \(String(describing: sqlError), privacy: .public)")
osLogger.error("Oracle statement failed with \(code, privacy: .public): \(String(describing: sqlError), privacy: .private)")
return .queryFailed(String(format: OracleCoreError.driverErrorFormat, code))
}
return .queryFailed(serverMessage)
Expand Down Expand Up @@ -655,7 +653,7 @@ public final class OracleCoreConnection: @unchecked Sendable {
default:
markConnectionDead(reason: .transportError)
let detail = String(describing: error)
osLogger.error("Oracle connection reset after a transport error: \(detail, privacy: .public)")
osLogger.error("Oracle connection reset after a transport error: \(String(describing: type(of: error)), privacy: .public) \(detail, privacy: .private)")
return OracleCoreError.queryFailed(detail)
}
}
Expand Down Expand Up @@ -894,7 +892,7 @@ public final class OracleCoreConnection: @unchecked Sendable {
return unsupportedPlaceholder(for: cell.dataType)
}
} catch {
osLogger.error("Oracle decode failed for column '\(cell.columnName, privacy: .public)': \(String(describing: error), privacy: .public)")
osLogger.error("Oracle decode failed for column '\(cell.columnName, privacy: .private(mask: .hash))': \(String(describing: type(of: error)), privacy: .public) \(String(describing: error), privacy: .private)")
return "<decode error>"
}
}
Expand Down
3 changes: 2 additions & 1 deletion Plugins/BigQueryDriverPlugin/BigQueryConnection.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
import os
import TableProGoogleCloud
import TableProLogRedaction
import TableProPluginKit

internal struct BQTableFieldSchema: Codable, Sendable {
Expand Down Expand Up @@ -714,7 +715,7 @@ internal final class BigQueryConnection: @unchecked Sendable {
do {
try await cancelJob(jobId: jobId, location: location)
} catch {
Self.logger.warning("BigQuery job cancel failed: \(error.localizedDescription, privacy: .public)")
Self.logger.warning("BigQuery job cancel failed: \(LogRedaction.publicDescription(of: error), privacy: .public) \(error.localizedDescription, privacy: .private)")
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
import os
import TableProGoogleCloud
import TableProLogRedaction
import TableProPluginKit

internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
Expand Down Expand Up @@ -200,7 +201,7 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send
}
}
} catch {
Self.logger.info("Could not auto-select a dataset: \(error.localizedDescription, privacy: .public)")
Self.logger.info("Could not auto-select a dataset: \(LogRedaction.publicDescription(of: error), privacy: .public) \(error.localizedDescription, privacy: .private)")
}
}

Expand Down Expand Up @@ -380,7 +381,7 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send
return try await bulkColumns(datasetId: datasetId)
} catch {
Self.logger.info(
"Bulk column fetch failed, reading tables one by one: \(error.localizedDescription, privacy: .public)"
"Bulk column fetch failed, reading tables one by one: \(LogRedaction.publicDescription(of: error), privacy: .public) \(error.localizedDescription, privacy: .private)"
)
var columns: [String: [PluginColumnInfo]] = [:]
for table in try await fetchTables(schema: schema) {
Expand Down
5 changes: 3 additions & 2 deletions Plugins/CSVInspectorPlugin/CSVDocument.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AppKit
import TableProPluginKit
import os
import TableProLogRedaction
import TableProPluginKit

public final class CSVDocument: NSDocument, CSVConfigurableDocument {
static let logger = Logger(subsystem: "com.TablePro", category: "CSVInspector")
Expand Down Expand Up @@ -105,7 +106,7 @@ public final class CSVDocument: NSDocument, CSVConfigurableDocument {
do {
try revert(toContentsOf: url, ofType: fileType ?? "public.comma-separated-values-text")
} catch {
Self.logger.error("Auto-revert failed: \(error.localizedDescription, privacy: .public)")
Self.logger.error("Auto-revert failed: \(LogRedaction.publicDescription(of: error), privacy: .public) \(error.localizedDescription, privacy: .private)")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,15 +145,15 @@ internal final class ElasticsearchConnection: NSObject, @unchecked Sendable {
let response = try await request(method: "GET", path: "/\(encode(index))/_mapping")
guard response.statusCode == 200 else { throw mapError(response, fallback: "Failed to fetch mapping") }
guard let json = response.json as? [String: Any] else {
Self.logger.error("mappingProperties \(index, privacy: .public): response.json not a dictionary; raw=\(response.rawText.prefix(300), privacy: .public)")
Self.logger.error("mappingProperties \(index, privacy: .private(mask: .hash)): response.json not a dictionary; raw=\(response.rawText.prefix(300), privacy: .private)")
return []
}
let properties = ElasticsearchMappingFlattener.properties(fromMappingResponse: json, index: index)
let columns = ElasticsearchMappingFlattener.flattenMapping(properties: properties)
Self.logger.debug("""
mappingProperties \(index, privacy: .public): topKeys=[\(json.keys.joined(separator: ","), privacy: .public)] \
mappingProperties \(index, privacy: .private(mask: .hash)): topKeys=[\(json.keys.joined(separator: ","), privacy: .private)] \
propertyCount=\(properties.count) columnCount=\(columns.count) \
columns=[\(columns.map { "\($0.name):\($0.type)\($0.hasKeywordSubfield ? "+kw" : "")" }.joined(separator: ","), privacy: .public)]
columns=[\(columns.map { "\($0.name):\($0.type)\($0.hasKeywordSubfield ? "+kw" : "")" }.joined(separator: ","), privacy: .private)]
""")
return columns
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,9 @@ extension ElasticsearchPluginDriver {
let fields = ElasticsearchMappingFlattener.fieldInfo(from: mappingColumns)

Self.logger.debug("""
executeSearch index=\(parsed.index, privacy: .public) from=\(parsed.from) size=\(parsed.size) \
executeSearch index=\(parsed.index, privacy: .private(mask: .hash)) from=\(parsed.from) size=\(parsed.size) \
logic=\(parsed.logicMode, privacy: .public) \
filters=\(parsed.filters.map { "\($0.column) \($0.op) \($0.value)" }.joined(separator: " | "), privacy: .public) \
filters=\(parsed.filters.map { "\($0.column) \($0.op) \($0.value)" }.joined(separator: " | "), privacy: .private) \
sorts=\(parsed.sorts.map { "\($0.column) \($0.ascending ? "asc" : "desc")" }.joined(separator: " | "), privacy: .public) \
fieldInfoCount=\(fields.count) \
fields=\(fields.map { "\($0.key):\($0.value.type)\($0.value.hasKeywordSubfield ? "+kw" : "")" }.sorted().joined(separator: ","), privacy: .public)
Expand All @@ -199,10 +199,10 @@ extension ElasticsearchPluginDriver {
for: parsed, fields: fields, size: parsed.size, supportsCaseInsensitive: supportsCaseInsensitiveSearch
)
body["from"] = parsed.from
Self.logger.debug("POST /\(index, privacy: .public)/_search body=\(Self.jsonString(body), privacy: .public)")
Self.logger.debug("POST /\(index, privacy: .private(mask: .hash))/_search body=\(Self.jsonString(body), privacy: .private)")
let response = try await conn.search(index: index, body: body)
let hits = extractHits(response)
Self.logger.debug("_search returned \(hits.count) hit(s) for index=\(index, privacy: .public)")
Self.logger.debug("_search returned \(hits.count) hit(s) for index=\(index, privacy: .private(mask: .hash))")
return hits
}
return try await deepFetchHits(index: index, parsed: parsed, fields: fields, conn: conn)
Expand Down
Loading
Loading