From d3d879b20ccba5a945e271d9f7ef3ba9fa5b6f18 Mon Sep 17 00:00:00 2001 From: devansh nigam Date: Sat, 10 Oct 2026 16:59:46 +0530 Subject: [PATCH 1/2] feat: hide copied secrets and optionally delete them A copied API key, token, password or card number showed in full on its card, in the preview, in the paste stack and in the menu bar list, and stayed in history for as long as anything else. Hide Secrets (Settings > Privacy, on by default) draws the middle of each secret as bullets, keeping a few characters at each end so the key is still recognizable. An eye button on the card's hover actions and in the preview reveals it; a revealed card is masked again when the shelf closes. Masking is display only: pasting, copying and dragging use the real text. Delete Secrets After (Never by default; 1 hour to 7 days) removes an item that contains a secret once that long has passed since it was first copied. Favorites and pinboard items are never deleted, the same promise the history retention makes. SecretDetector is deliberately conservative. It recognizes provider key formats by their prefix, JWTs, private key blocks and card numbers with a valid checksum, and a generic name = value only when the value is quoted, the name is an ALL-CAPS .env name, or it is a URL parameter. Migration v4 adds a nullable item.containsSecret column. It is filled in by the deletion job, so existing history is only scanned once the user picks a lifetime. Demo mode seeds four fake secrets to show the feature, and Debug builds get a 1 Minute lifetime for testing. Every key in the tests and the demo data is fake and assembled from pieces. Signed-off-by: devansh nigam --- Copy/App/AppCoordinator.swift | 46 ++++- Copy/App/AppDelegate.swift | 2 +- Copy/App/DemoData.swift | 17 ++ Copy/PasteStack/PasteStackModel.swift | 4 + Copy/PasteStack/PasteStackView.swift | 4 +- Copy/Settings/PrivacySettings.swift | 53 +++++ Copy/Settings/SettingsStore.swift | 77 +++++++ Copy/Shelf/ItemCardView.swift | 51 ++++- Copy/Shelf/PreviewPane.swift | 34 ++- Copy/Shelf/ShelfRootView.swift | 8 +- Copy/Shelf/ShelfViewModel.swift | 19 ++ .../CopyCore/Models/ClipItem+Display.swift | 20 +- .../Sources/CopyCore/Models/Records.swift | 4 + .../CopyCore/Rules/SecretDetector.swift | 194 ++++++++++++++++++ .../CopyCore/Storage/DatabaseManager.swift | 7 + .../Sources/CopyCore/Storage/ItemStore.swift | 53 +++++ .../CopyCoreTests/SecretDetectorTests.swift | 181 ++++++++++++++++ .../CopyCoreTests/SecretRetentionTests.swift | 97 +++++++++ README.md | 2 + 19 files changed, 856 insertions(+), 17 deletions(-) create mode 100644 CopyCore/Sources/CopyCore/Rules/SecretDetector.swift create mode 100644 CopyCore/Tests/CopyCoreTests/SecretDetectorTests.swift create mode 100644 CopyCore/Tests/CopyCoreTests/SecretRetentionTests.swift diff --git a/Copy/App/AppCoordinator.swift b/Copy/App/AppCoordinator.swift index 29109d2..2be2dd2 100644 --- a/Copy/App/AppCoordinator.swift +++ b/Copy/App/AppCoordinator.swift @@ -14,6 +14,7 @@ final class AppCoordinator { private let persistQueue = DispatchQueue(label: "com.tarikbc.copy.persist", qos: .utility) private let saveErrors = SaveErrorReporter() private var retentionTimer: DispatchSourceTimer? + private var secretPruneTimer: DispatchSourceTimer? private(set) var isPaused = false /// True in a DEBUG build when the demo-mode flag is set (toggled from the status menu): /// runs against an isolated, reseeded demo database with curated mock data, and never @@ -446,6 +447,10 @@ final class AppCoordinator { self?.shelfController.setHideDuringScreenSharing(hide) self?.pasteStackController.setHideDuringScreenSharing(hide) } + pasteStackModel.hideSecrets = settings.hideSecrets + settings.onHideSecretsChange = { [pasteStackModel] hide in + pasteStackModel.hideSecrets = hide + } settings.onFloatingShelfChange = { [weak self] floating in self?.shelfController.setFloatingShelf(floating) } @@ -486,8 +491,12 @@ final class AppCoordinator { } func start() { + // Secret deletion runs in demo mode too: it is off unless the user chose a + // lifetime, and the demo database is rebuilt on every launch anyway. + settings.onSecretLifetimeChange = { [weak self] _ in self?.scheduleSecretPrune() } + scheduleSecretPrune() // Demo mode never captures the real clipboard (keeps the curated set pristine) and - // never prunes (its timestamps are backdated for the Time facets). + // never prunes by age (its timestamps are backdated for the Time facets). guard !isDemoMode else { return } monitor.start() runRetentionPrune() @@ -554,6 +563,41 @@ final class AppCoordinator { } } + /// Deletes items that contain a secret once they are older than + /// `SettingsStore.secretLifetime`, on the persist queue. Favorites and pinboard members + /// are spared (`ItemStore.pruneSecrets`). + private func runSecretPrune() { + guard let lifetime = settings.secretLifetime.interval else { return } + let cutoff = Date().addingTimeInterval(-lifetime) + let store = self.store + persistQueue.async { + do { + let deleted = try store.pruneSecrets(olderThan: cutoff) + if deleted > 0 { + NSLog("Copy: secret deletion removed \(deleted) item(s)") + } + } catch { + NSLog("Copy: secret deletion failed: \(error)") + } + } + } + + /// Applies the current `secretLifetime`: checks now, then again a few times within each + /// lifetime (every five minutes at most), so a secret outlives its limit only briefly. + /// With `.never` there is no timer at all. + private func scheduleSecretPrune() { + secretPruneTimer?.cancel() + secretPruneTimer = nil + guard let lifetime = settings.secretLifetime.interval else { return } + runSecretPrune() + let interval = min(5 * 60, max(15, lifetime / 4)) + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(5)) + timer.setEventHandler { [weak self] in self?.runSecretPrune() } + timer.resume() + secretPruneTimer = timer + } + private func scheduleRetentionTimer() { let timer = DispatchSource.makeTimerSource(queue: .main) timer.schedule(deadline: .now() + Self.retentionInterval, repeating: Self.retentionInterval) diff --git a/Copy/App/AppDelegate.swift b/Copy/App/AppDelegate.swift index 4431645..cdf36df 100644 --- a/Copy/App/AppDelegate.swift +++ b/Copy/App/AppDelegate.swift @@ -178,7 +178,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { menu.addItem(empty) } for item in items { - let menuItem = NSMenuItem(title: item.displayTitle, + let menuItem = NSMenuItem(title: item.displayTitle(hidingSecrets: coordinator.settings.hideSecrets), action: #selector(pasteMenuItem(_:)), keyEquivalent: "") menuItem.target = self diff --git a/Copy/App/DemoData.swift b/Copy/App/DemoData.swift index ca687fa..9e02302 100644 --- a/Copy/App/DemoData.swift +++ b/Copy/App/DemoData.swift @@ -123,6 +123,23 @@ enum DemoData { from: notes, at: ago(28 * minute)) try store.setTitle(itemID: checklistID, "Launch checklist") + // Secrets, to show Hide Secrets (`SecretDetector`) at work: an .env file, a bare + // token, a card number in a sentence and a URL carrying a token. All fake, and + // assembled from pieces so no secret scanner mistakes this file for a leak. + let fakeOpenAIKey = "sk" + "-proj-" + "FAKEfakeFAKEfake1234567890abcd" + let fakeStripeKey = "sk" + "_test_" + "FAKE0000FAKE0000FAKE0000" + let fakeGitHubToken = "gh" + "p_" + "abcdefghijklmnopqrstuvwxyz0123456789" + let envID = try text( + "APP_ENV=staging\nOPENAI_API_KEY=\(fakeOpenAIKey)\nSTRIPE_SECRET_KEY=\(fakeStripeKey)\nDB_PASSWORD=Tr0ub4dor-and-3-more\nLOG_LEVEL=debug", + from: vscode, at: ago(24 * minute)) + try store.setTitle(itemID: envID, ".env (staging)") + try text(fakeGitHubToken, from: terminal, at: ago(25 * minute)) + try text("Card on file: 4242 4242 4242 4242, exp 04/29. Use it for the domain renewal.", + from: mail, at: ago(26 * minute)) + try link("https://api.example.com/v1/export?format=csv&access_token=a1b2c3d4e5f6g7h8i9j0", + title: "Export — Example API", + from: safari, at: ago(27 * minute), favicon: .systemTeal) + let figmaLinkID = try link("https://figma.com/file/9aB2/Copy-Marketing", title: "Copy — Marketing site (Figma)", from: figma, at: ago(34 * minute), favicon: .systemPurple) diff --git a/Copy/PasteStack/PasteStackModel.swift b/Copy/PasteStack/PasteStackModel.swift index 72af89e..cbd2737 100644 --- a/Copy/PasteStack/PasteStackModel.swift +++ b/Copy/PasteStack/PasteStackModel.swift @@ -25,6 +25,10 @@ final class PasteStackModel { let store: ItemStore + /// `SettingsStore.hideSecrets`, pushed by `AppCoordinator`: row titles mask API keys + /// and other secrets. A row has no reveal; the shelf card does. + var hideSecrets = true + /// Bumped after an in-place edit so `PasteStackView` re-renders (the queue's uuids /// don't change on an edit, so nothing else would trigger a refresh). var revision = 0 diff --git a/Copy/PasteStack/PasteStackView.swift b/Copy/PasteStack/PasteStackView.swift index 208b0e8..70e4edb 100644 --- a/Copy/PasteStack/PasteStackView.swift +++ b/Copy/PasteStack/PasteStackView.swift @@ -115,6 +115,7 @@ struct PasteStackView: View { isNext: order[item.uuid] == 1, isDragging: draggingUUID == item.uuid, isEditable: isEditable(item), + hideSecrets: model.hideSecrets, onEdit: { onEdit(item) }, onRemove: { model.queue.remove(item.uuid) } ) @@ -216,6 +217,7 @@ private struct PasteStackRow: View { let isNext: Bool var isDragging: Bool = false var isEditable: Bool = true + var hideSecrets: Bool = true let onEdit: () -> Void let onRemove: () -> Void @@ -236,7 +238,7 @@ private struct PasteStackRow: View { .foregroundStyle(.secondary) .frame(width: 16) - Text(item.displayTitle) + Text(item.displayTitle(hidingSecrets: hideSecrets)) .font(Tokens.bodyMono) .lineLimit(1) diff --git a/Copy/Settings/PrivacySettings.swift b/Copy/Settings/PrivacySettings.swift index 068ec89..da12ec7 100644 --- a/Copy/Settings/PrivacySettings.swift +++ b/Copy/Settings/PrivacySettings.swift @@ -7,6 +7,7 @@ import UniformTypeIdentifiers struct PrivacySettings: View { @Bindable var settings: SettingsStore @State private var selectedBundleID: String? + @State private var showingSecretLifetimeInfo = false var body: some View { Form { @@ -20,6 +21,51 @@ struct PrivacySettings: View { .foregroundStyle(.secondary) } + Section { + Toggle("Hide Secrets", isOn: $settings.hideSecrets) + } header: { + Text("Secrets") + } footer: { + Text("Masks API keys, tokens, passwords and card numbers on cards and in previews. Pasting still uses the real value.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Section { + LabeledContent { + Picker("Delete Secrets After", selection: $settings.secretLifetime) { + ForEach(SecretLifetime.allCases) { lifetime in + Text(lifetime.title).tag(lifetime) + } + } + .labelsHidden() + .fixedSize() + } label: { + HStack(spacing: 6) { + Text("Delete Secrets After") + Button { + showingSecretLifetimeInfo.toggle() + } label: { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("How to keep a secret") + .popover(isPresented: $showingSecretLifetimeInfo, arrowEdge: .bottom) { + Text("To keep a card that has a secret, mark it as a favorite or file it on a pinboard. Favorites and pinboard items are never deleted automatically.") + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 260, alignment: .leading) + .padding(12) + } + } + } + } footer: { + Text(secretLifetimeFooter) + .font(.footnote) + .foregroundStyle(.secondary) + } + Section { if settings.excludedBundleIDs.isEmpty { Text("No excluded apps") @@ -62,6 +108,13 @@ struct PrivacySettings: View { .formStyle(.grouped) } + private var secretLifetimeFooter: String { + if settings.secretLifetime == .never { + return "Cards with a secret are kept like any other card." + } + return "A card with an API key, token, password or card number is deleted \(settings.secretLifetime.title.lowercased()) after you copy it. Favorites and pinboard items are always kept." + } + private func excludedAppRow(_ bundleID: String) -> some View { HStack(spacing: 8) { if let icon = AppIconCache.icon(forBundleID: bundleID) { diff --git a/Copy/Settings/SettingsStore.swift b/Copy/Settings/SettingsStore.swift index 31ad53c..fe1b21e 100644 --- a/Copy/Settings/SettingsStore.swift +++ b/Copy/Settings/SettingsStore.swift @@ -89,6 +89,53 @@ enum RetentionPeriod: String, CaseIterable { static let sliderOrder: [RetentionPeriod] = [.day, .week, .month, .threeMonths, .unlimited] } +/// How long an item that contains a secret (an API key, token, password or card number, +/// per `SecretDetector`) is kept after it is copied. Favorites and pinboard items are +/// never deleted, the same promise `RetentionPeriod` makes. +enum SecretLifetime: String, CaseIterable, Identifiable { + case never + #if DEBUG + /// Debug builds only: short enough to watch a deletion happen while testing. + case minute + #endif + case hour + case fiveHours + case twelveHours + case day + case week + + var id: String { rawValue } + + /// How long after it is copied a secret is deleted; `nil` keeps it. + var interval: TimeInterval? { + switch self { + case .never: return nil + #if DEBUG + case .minute: return 60 + #endif + case .hour: return 3_600 + case .fiveHours: return 5 * 3_600 + case .twelveHours: return 12 * 3_600 + case .day: return 24 * 3_600 + case .week: return 7 * 24 * 3_600 + } + } + + var title: String { + switch self { + case .never: return "Never" + #if DEBUG + case .minute: return "1 Minute (Debug)" + #endif + case .hour: return "1 Hour" + case .fiveHours: return "5 Hours" + case .twelveHours: return "12 Hours" + case .day: return "24 Hours" + case .week: return "7 Days" + } + } +} + /// UserDefaults-backed app settings. Mutating `excludedBundleIDs` (directly or via /// `addExcludedApp`/`removeExcludedApp`) fires `onRulesChange` so the coordinator can /// push a fresh `RulesEngine` into the clipboard monitor. @@ -109,6 +156,8 @@ final class SettingsStore { static let doubleClickToPasteKey = "doubleClickToPaste" static let copySoundKey = "copySound" static let hapticFeedbackKey = "hapticFeedback" + static let hideSecretsKey = "hideSecrets" + static let secretLifetimeKey = "secretLifetime" var retention: RetentionPeriod { didSet { @@ -177,6 +226,31 @@ final class SettingsStore { } @ObservationIgnored var onFloatingShelfChange: ((Bool) -> Void)? + /// Shows API keys, tokens, passwords and card numbers masked on cards, previews and + /// titles (see `SecretDetector`). On by default. Display only: the stored item and + /// what gets pasted are the real value. `onHideSecretsChange` pushes it to the paste + /// stack, which has no view of this store. + var hideSecrets: Bool { + didSet { + guard hideSecrets != oldValue else { return } + defaults.set(hideSecrets, forKey: Self.hideSecretsKey) + onHideSecretsChange?(hideSecrets) + } + } + @ObservationIgnored var onHideSecretsChange: ((Bool) -> Void)? + + /// When items that contain a secret are deleted. `.never` by default: nothing is + /// removed unless the user asks for it. `onSecretLifetimeChange` lets the coordinator + /// apply a new choice at once instead of at the next scheduled check. + var secretLifetime: SecretLifetime { + didSet { + guard secretLifetime != oldValue else { return } + defaults.set(secretLifetime.rawValue, forKey: Self.secretLifetimeKey) + onSecretLifetimeChange?(secretLifetime) + } + } + @ObservationIgnored var onSecretLifetimeChange: ((SecretLifetime) -> Void)? + /// Hides favorites presentation without deleting saved marks or retention protection. var favoritesEnabled: Bool { didSet { @@ -282,6 +356,9 @@ final class SettingsStore { fetchLinkPreviews = (defaults.object(forKey: Self.fetchLinkPreviewsKey) as? Bool) ?? true recognizeImageText = (defaults.object(forKey: Self.recognizeImageTextKey) as? Bool) ?? true hideDuringScreenSharing = (defaults.object(forKey: Self.hideDuringScreenSharingKey) as? Bool) ?? false + hideSecrets = (defaults.object(forKey: Self.hideSecretsKey) as? Bool) ?? true + secretLifetime = defaults.string(forKey: Self.secretLifetimeKey) + .flatMap(SecretLifetime.init(rawValue:)) ?? .never favoritesEnabled = (defaults.object(forKey: Self.favoritesEnabledKey) as? Bool) ?? true floatingShelf = (defaults.object(forKey: Self.floatingShelfKey) as? Bool) ?? false compactShelf = (defaults.object(forKey: Self.compactShelfKey) as? Bool) ?? false diff --git a/Copy/Shelf/ItemCardView.swift b/Copy/Shelf/ItemCardView.swift index b0c23e2..a5840fc 100644 --- a/Copy/Shelf/ItemCardView.swift +++ b/Copy/Shelf/ItemCardView.swift @@ -20,6 +20,11 @@ struct ItemCardView: View { var searchQuery: String = "" /// Briefly true after a "Show in History" jump lands on this card — draws an accent ring. var isFlashing: Bool = false + /// `SettingsStore.hideSecrets`: API keys and other secrets in the card's text are drawn + /// masked unless `secretsRevealed`. The pasted and dragged content is always the real text. + var hideSecrets: Bool = false + /// The user asked to see this card's secrets (the hover eye button, `onToggleSecrets`). + var secretsRevealed: Bool = false /// The card's ⌥-digit paste number (1-9), shown as a badge while ⌥ is held; `nil` hides it. var pasteNumber: Int? = nil /// Whether the "Show in History" context item applies (a search/filter is active, or the @@ -42,6 +47,7 @@ struct ItemCardView: View { let onCommitInlineRename: (String) -> Void let onCancelInlineRename: () -> Void let onToggleFavorite: () -> Void + var onToggleSecrets: () -> Void = {} let onAddToPinboard: (Int64) -> Void let onRemoveFromPinboard: () -> Void let onAddToPasteStack: () -> Void @@ -67,6 +73,24 @@ struct ItemCardView: View { } } + private var masksSecrets: Bool { hideSecrets && !secretsRevealed } + + /// How much of the text a card body ever shows. + private static let bodyCap = 1_500 + + /// The text the card draws: the first `cap` characters, with secrets masked when + /// `masksSecrets`. Masking keeps the length, so highlight tokens still line up. + private func displayText(_ text: String, cap: Int) -> String { + masksSecrets ? SecretDetector.maskedPrefix(text, count: cap) : String(text.prefix(cap)) + } + + /// Whether the card's visible text has a secret in it, which is what earns it the eye + /// button. Only asked while hovering. + private var hasSecret: Bool { + guard hideSecrets, isEditable, let text = item.plainText else { return false } + return SecretDetector.maskedPrefix(text, count: Self.bodyCap) != String(text.prefix(Self.bodyCap)) + } + /// File URL(s) still on disk for this item, used to gate the "Quick Look" menu /// item — see `QuickLookController.fileURLs(for:store:)`. private var quickLookURLs: [URL] { @@ -166,6 +190,8 @@ struct ItemCardView: View { if isHovering && !isInlineRenaming { CardHoverActions(favoritesEnabled: favoritesEnabled, isFavorite: item.isFavorite, onToggleFavorite: onToggleFavorite, + secretsRevealed: hasSecret ? secretsRevealed : nil, + onToggleSecrets: onToggleSecrets, // Only offer unpin while viewing a pinboard, where the card // actually belongs to one it can be removed from. onUnpin: currentPinboardID != nil ? onRemoveFromPinboard : nil, @@ -262,7 +288,7 @@ struct ItemCardView: View { private var dragPreview: some View { HStack(spacing: 6) { dragPreviewGlyph - Text(item.displayTitle) + Text(item.displayTitle(hidingSecrets: masksSecrets)) .font(.system(size: 12, weight: .medium)) .lineLimit(1) .truncationMode(.tail) @@ -349,9 +375,9 @@ struct ItemCardView: View { /// drawn on screen. @ViewBuilder private func codeAwareBody(text: String, cap: Int) -> some View { - let capped = String(text.prefix(cap)) + // Tokens come from the real text; the masked text is the same length, so they fit. let highlight = CodeHighlightCache.shared.result(for: text, uuid: item.uuid, cap: cap) - let display = Self.preservingIndentation(capped) + let display = Self.preservingIndentation(displayText(text, cap: cap)) if highlight.language != nil { highlightedText(display, tokens: highlight.tokens) .font(.system(size: 12, design: .monospaced)) @@ -381,7 +407,7 @@ struct ItemCardView: View { if let hex = HexColor.normalized(item.plainText ?? "") { colorSwatchBody(hex) } else { - codeAwareBody(text: item.plainText ?? "", cap: 1_500) + codeAwareBody(text: item.plainText ?? "", cap: Self.bodyCap) .font(Tokens.cardBody) .lineLimit(bodyLineLimit(standard: 11, compact: 6)) .multilineTextAlignment(.leading) @@ -457,7 +483,7 @@ struct ItemCardView: View { .multilineTextAlignment(.leading) } // Give the URL the remaining body lines while keeping the footer visible. - Text(String((item.plainText ?? "").prefix(1_500))) + Text(displayText(item.plainText ?? "", cap: Self.bodyCap)) .font(Tokens.bodyMono) .foregroundStyle(.secondary) .lineLimit(bodyLineLimit(standard: 8, compact: 3)) @@ -651,12 +677,17 @@ private struct CardClickGesture: ViewModifier { /// The floating action pill shown on card hover (see `ItemCardView`'s top-trailing /// overlay). Surfaces favorite and delete, the two high-value actions otherwise hidden -/// in the right-click menu, plus unpin while viewing a pinboard. Stays a quiet affordance -/// rather than a toolbar; the rest remains in the context menu and via drag. +/// in the right-click menu, plus unpin while viewing a pinboard and, on a card with a +/// masked secret, the eye that reveals it. Stays a quiet affordance rather than a +/// toolbar; the rest remains in the context menu and via drag. private struct CardHoverActions: View { let favoritesEnabled: Bool let isFavorite: Bool let onToggleFavorite: () -> Void + /// Whether the card's secrets are showing; `nil` (and so no eye button) when the card + /// has none to hide. + var secretsRevealed: Bool? = nil + var onToggleSecrets: () -> Void = {} /// Removes the card from the pinboard currently being viewed. `nil` (and so hidden) /// outside a pinboard, where there's nothing to unpin from. let onUnpin: (() -> Void)? @@ -664,6 +695,12 @@ private struct CardHoverActions: View { var body: some View { HStack(spacing: 1) { + if let secretsRevealed { + IconButton(systemName: secretsRevealed ? "eye.slash" : "eye", + fontSize: 11, size: CGSize(width: 22, height: 22), + help: secretsRevealed ? "Hide secrets" : "Show secrets", + action: onToggleSecrets) + } if favoritesEnabled { IconButton(systemName: isFavorite ? "star.fill" : "star", fontSize: 11, size: CGSize(width: 22, height: 22), diff --git a/Copy/Shelf/PreviewPane.swift b/Copy/Shelf/PreviewPane.swift index 26baa5a..66fe571 100644 --- a/Copy/Shelf/PreviewPane.swift +++ b/Copy/Shelf/PreviewPane.swift @@ -6,6 +6,10 @@ import UniformTypeIdentifiers struct PreviewPane: View { let item: ClipItem let store: ItemStore + /// `SettingsStore.hideSecrets`: secrets in the text are masked unless `secretsRevealed`. + var hideSecrets: Bool = false + var secretsRevealed: Bool = false + var onToggleSecrets: () -> Void = {} @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var imagePixelSize: CGSize? @@ -34,6 +38,14 @@ struct PreviewPane: View { /// the paste's actual size. Anything beyond the cap still renders (up to the /// existing 200k display cap below), just without color. private let highlightCap = 10_000 + /// The most text the preview ever shows. + private static let displayCap = 200_000 + + /// Whether the previewed text has a secret in it, which is what earns it the eye button. + private var hasSecret: Bool { + guard hideSecrets, let text = item.plainText else { return false } + return SecretDetector.maskedPrefix(text, count: Self.displayCap) != String(text.prefix(Self.displayCap)) + } /// Builds the default-branch preview text: the same 200k-character display cap as /// before, with syntax colors applied to only the first `highlightCap` characters @@ -41,7 +53,10 @@ struct PreviewPane: View { /// Any remainder past the highlighted portion still renders, just as plain mono /// text, exactly as the whole thing did before this feature existed. private func codeAwarePreviewText(_ text: String) -> Text { - let displayText = String(text.prefix(200_000)) + // Masking keeps the length, so tokens from the real text still fit the masked one. + let displayText = hideSecrets && !secretsRevealed + ? SecretDetector.maskedPrefix(text, count: Self.displayCap) + : String(text.prefix(Self.displayCap)) let cap = min(displayText.count, highlightCap) let highlightPortion = String(displayText.prefix(cap)) let highlight = CodeHighlightCache.shared.result(for: text, uuid: item.uuid, cap: cap) @@ -80,6 +95,23 @@ struct PreviewPane: View { .frame(maxWidth: .infinity, alignment: .topLeading) .padding(14) } + .overlay(alignment: .topTrailing) { + if hasSecret { + // Same pill as a card's hover actions, so the text can scroll under it. + IconButton(systemName: secretsRevealed ? "eye.slash" : "eye", + fontSize: 11, size: CGSize(width: 22, height: 22), + help: secretsRevealed ? "Hide secrets" : "Show secrets", + action: onToggleSecrets) + .padding(2) + .background( + Capsule(style: .continuous) + .fill(.regularMaterial) + .overlay(Capsule(style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 0.5)) + ) + .padding(8) + } + } } } .frame(width: previewSize.width, height: previewSize.height) diff --git a/Copy/Shelf/ShelfRootView.swift b/Copy/Shelf/ShelfRootView.swift index cf1c5b6..3652be7 100644 --- a/Copy/Shelf/ShelfRootView.swift +++ b/Copy/Shelf/ShelfRootView.swift @@ -563,6 +563,8 @@ private struct ShelfItemsRow: View { compact: compact, searchQuery: viewModel.searchQuery.text, isFlashing: viewModel.flashItemID == item.uuid, + hideSecrets: viewModel.settings.hideSecrets, + secretsRevealed: viewModel.revealedSecretIDs.contains(item.uuid), pasteNumber: (viewModel.optionHeld && index < 9) ? index + 1 : nil, canShowInHistory: !viewModel.searchQuery.isEmpty || currentPinboardID != nil, onClick: { modifiers in viewModel.handleCardClick(item, modifiers: modifiers) }, @@ -581,6 +583,7 @@ private struct ShelfItemsRow: View { onCommitInlineRename: { viewModel.commitInlineRename(item, to: $0) }, onCancelInlineRename: { viewModel.cancelInlineRename() }, onToggleFavorite: { viewModel.toggleFavorite(item) }, + onToggleSecrets: { viewModel.toggleSecretsRevealed(item) }, onAddToPinboard: { id in viewModel.addItem(item, toPinboard: id) }, onRemoveFromPinboard: { if let currentPinboardID { @@ -622,7 +625,10 @@ private struct ShelfItemsRow: View { get: { viewModel.isSelected(item) && item.uuid == viewModel.selection.primary && viewModel.previewShown }, set: { viewModel.previewShown = $0 } )) { - PreviewPane(item: item, store: viewModel.store) + PreviewPane(item: item, store: viewModel.store, + hideSecrets: viewModel.settings.hideSecrets, + secretsRevealed: viewModel.revealedSecretIDs.contains(item.uuid), + onToggleSecrets: { viewModel.toggleSecretsRevealed(item) }) } } } diff --git a/Copy/Shelf/ShelfViewModel.swift b/Copy/Shelf/ShelfViewModel.swift index 314fc93..df60405 100644 --- a/Copy/Shelf/ShelfViewModel.swift +++ b/Copy/Shelf/ShelfViewModel.swift @@ -101,6 +101,10 @@ final class ShelfViewModel { /// The uuid of the card the pointer is currently over, so a force-click can act on /// exactly the card under the cursor (set by `ItemCardView`'s hover callback). var hoveredItemID: String? + /// Cards whose secrets the user has chosen to see (by item uuid), while + /// `SettingsStore.hideSecrets` masks the rest. Forgotten when the shelf closes, so a + /// revealed key is hidden again the next time the shelf opens. + private(set) var revealedSecretIDs: Set = [] /// The pinboard tab a dragged tab would land beside, and on which side, so that tab /// shows its insertion edge. Driven by `ShelfHostingView` (see `shelfDragMoved`). var reorderTargetedPinboardID: Int64? @@ -402,6 +406,7 @@ final class ShelfViewModel { showingTips = false commandHeld = false hoveredItemID = nil + revealedSecretIDs = [] if !searchQuery.isEmpty { searchQuery = SearchQuery() } suggestions = [] cachedApps = [] @@ -678,6 +683,20 @@ final class ShelfViewModel { if !searchQuery.isEmpty { refresh() } } + /// Whether `item`'s secrets are currently drawn masked. + func hidesSecrets(in item: ClipItem) -> Bool { + settings.hideSecrets && !revealedSecretIDs.contains(item.uuid) + } + + /// The eye button on a card or its preview: shows or re-hides that card's secrets. + func toggleSecretsRevealed(_ item: ClipItem) { + if revealedSecretIDs.contains(item.uuid) { + revealedSecretIDs.remove(item.uuid) + } else { + revealedSecretIDs.insert(item.uuid) + } + } + func toggleFavorite(_ item: ClipItem) { guard settings.favoritesEnabled else { return } guard let id = item.id else { return } diff --git a/CopyCore/Sources/CopyCore/Models/ClipItem+Display.swift b/CopyCore/Sources/CopyCore/Models/ClipItem+Display.swift index 17279d6..2f6abbc 100644 --- a/CopyCore/Sources/CopyCore/Models/ClipItem+Display.swift +++ b/CopyCore/Sources/CopyCore/Models/ClipItem+Display.swift @@ -2,22 +2,32 @@ import Foundation public extension ClipItem { /// One-line human title for menus and compact rows. - var menuTitle: String { + var menuTitle: String { menuTitle(hidingSecrets: false) } + + /// The user-assigned `title` when set, otherwise `menuTitle`. + var displayTitle: String { displayTitle(hidingSecrets: false) } + + /// `menuTitle`, with any API key or other secret in it masked when `hidingSecrets` + /// (see `SecretDetector`). + func menuTitle(hidingSecrets: Bool) -> String { switch kind { case .image: return "Image" case .file: return plainText?.components(separatedBy: "\n").first ?? "File" default: - let text = String((plainText ?? "").prefix(200)) + let source = plainText ?? "" + let head = hidingSecrets ? SecretDetector.maskedPrefix(source, count: 200) : String(source.prefix(200)) + let text = head .replacingOccurrences(of: "\n", with: " ") .trimmingCharacters(in: .whitespaces) return text.count > 50 ? String(text.prefix(50)) + "…" : text } } - /// The user-assigned `title` when set, otherwise `menuTitle`. - var displayTitle: String { - title?.isEmpty == false ? title! : menuTitle + /// `displayTitle`, masking secrets in the derived title when `hidingSecrets`. A title + /// the user typed is shown as written. + func displayTitle(hidingSecrets: Bool) -> String { + title?.isEmpty == false ? title! : menuTitle(hidingSecrets: hidingSecrets) } } diff --git a/CopyCore/Sources/CopyCore/Models/Records.swift b/CopyCore/Sources/CopyCore/Models/Records.swift index 2d7c0d1..2b1dc4e 100644 --- a/CopyCore/Sources/CopyCore/Models/Records.swift +++ b/CopyCore/Sources/CopyCore/Models/Records.swift @@ -18,6 +18,10 @@ public struct ClipItem: Codable, Equatable, Identifiable, FetchableRecord, Mutab public var isFavorite: Bool public var title: String? public var recognizedText: String? + /// Whether `SecretDetector` found an API key or other secret in the text. `nil` until + /// the secret clean-up has looked at the item (`ItemStore.pruneSecrets`), and again + /// after an edit, so a new or changed item is always re-checked. + public var containsSecret: Bool? public mutating func didInsert(_ inserted: InsertionSuccess) { id = inserted.rowID diff --git a/CopyCore/Sources/CopyCore/Rules/SecretDetector.swift b/CopyCore/Sources/CopyCore/Rules/SecretDetector.swift new file mode 100644 index 0000000..af03722 --- /dev/null +++ b/CopyCore/Sources/CopyCore/Rules/SecretDetector.swift @@ -0,0 +1,194 @@ +import Foundation + +/// Finds secrets in copied text (API keys, tokens, private keys, card numbers) so the app +/// can show them masked. Masking is display-only: the stored item and what gets pasted are +/// untouched. +/// +/// Deliberately conservative, like `CodeDetector`: it prefers missing an unusual secret to +/// masking ordinary text. It recognizes provider key formats by their fixed prefixes, and a +/// generic `name = value` only where the shape is unambiguous: a quoted value, an +/// ALL-CAPS `.env`-style name, or a URL query parameter. An unquoted lowercase +/// `api_key=value` is skipped on purpose, because code (`login(password=user_password1)`) +/// looks exactly like it. +public enum SecretDetector { + /// The character a masked secret is drawn with. + public static let maskCharacter: Character = "•" + + /// Whether `text` contains anything `masked(_:)` would hide. + public static func containsSecret(_ text: String) -> Bool { + masked(text) != text + } + + /// `containsSecret` without the display cache, for scanning a whole history: caching + /// every item's text would only push out the entries the shelf is drawing. + public static func scan(_ text: String) -> Bool { + computeMasked(text) != text + } + + /// `text` with the middle of each secret replaced by `maskCharacter`. + /// + /// The result always has the same length as `text`, in characters and in UTF-16 units: + /// every hidden character is ASCII and is swapped one-for-one. Syntax-highlight token + /// ranges computed against the original text therefore still line up with the masked one. + public static func masked(_ text: String) -> String { + let key = text as NSString + if let cached = cache.object(forKey: key) { return cached as String } + let result = computeMasked(text) + cache.setObject(result as NSString, forKey: key) + return result + } + + /// The first `count` characters of `text`, masked. Scans a little past the cutoff, so a + /// secret the cutoff slices in half is still recognized instead of showing its first part. + public static func maskedPrefix(_ text: String, count: Int) -> String { + String(masked(String(text.prefix(count + cutoffMargin))).prefix(count)) + } + + // MARK: - Detection + + /// How a matched secret is hidden. + private enum Style { + /// Keep a few characters at each end, so a key is still recognizable. + case token + /// Keep only the last four digits; separators stay. + case card + /// Hide every character but keep the line breaks (a private key's body). + case block + } + + private struct Rule { + let regex: NSRegularExpression + /// The capture group holding the secret; 0 is the whole match. + let group: Int + let style: Style + } + + private static let cutoffMargin = 4_096 + /// Bounds the work on a huge paste; text past this is left as it is. + private static let scanLimit = 250_000 + private static let maskUnit = maskCharacter.utf16.first! + + private static let cache: NSCache = { + let cache = NSCache() + cache.countLimit = 600 + return cache + }() + + /// Names that mark a value as a secret, for the generic `name = value` rules. + private static let secretName = "(?:api[_-]?key|secret|token|passw(?:or)?d)" + /// What a generic unquoted value may contain. No `.`, `(`, `&` or `$`: those are + /// property accesses, calls, the next URL parameter and variable references. + private static let bareValue = "[A-Za-z0-9_+/=~!@#%^*-]" + /// A generic value must contain a letter and a digit, and not be a path, a variable + /// reference or a URL. `valueClass` is the character class the value is made of. + private static func looksLikeSecret(_ valueClass: String) -> String { + "(?![/~.$]|https?:)(?=\(valueClass)*[A-Za-z])(?=\(valueClass)*[0-9])" + } + + private static let rules: [Rule] = { + func rule(_ pattern: String, group: Int = 0, style: Style = .token, + options: NSRegularExpression.Options = []) -> Rule { + // The patterns are literals, so a failure here is a typo caught by the tests. + Rule(regex: try! NSRegularExpression(pattern: pattern, options: options), group: group, style: style) + } + let quotedValue = "[\\x21\\x23-\\x26\\x28-\\x7E]" // printable ASCII except quotes + return [ + // Provider formats, identified by their fixed prefix. + rule("\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b"), // AWS access key ID + rule("\\bgh[pousr]_[A-Za-z0-9]{36,}\\b"), // GitHub token + rule("\\bgithub_pat_[A-Za-z0-9_]{50,}\\b"), // GitHub fine-grained token + rule("\\bsk-(?=[A-Za-z0-9_-]*[0-9])[A-Za-z0-9_-]{20,}"), // OpenAI, Anthropic + rule("\\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}"), // Stripe + rule("\\bwhsec_[A-Za-z0-9]{24,}"), // Stripe webhook secret + rule("\\bxox[abprs]-[A-Za-z0-9-]{10,}"), // Slack + rule("\\bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])"), // Google API key + rule("\\bnpm_[A-Za-z0-9]{36}\\b"), // npm + rule("\\bSG\\.[A-Za-z0-9_-]{16,}\\.[A-Za-z0-9_-]{16,}"), // SendGrid + rule("\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}"), // JWT + rule("\\bbearer\\s+(?=[A-Za-z0-9._~+/=-]*[0-9])([A-Za-z0-9._~+/=-]{20,})", + group: 1, options: [.caseInsensitive]), + // A private key: hide the body, keep the BEGIN/END lines. An unterminated block + // (the cutoff landed inside it) is hidden to the end. + rule("-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----([\\s\\S]*?)(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|\\z)", + group: 1, style: .block), + // name = "value", in any language or config format. + rule("\(secretName)[A-Za-z0-9_-]{0,40}[\"']?\\s{0,4}[:=]\\s{0,4}[\"']" + + "\(looksLikeSecret(quotedValue))(\(quotedValue){12,})[\"']", + group: 1, options: [.caseInsensitive]), + // NAME=value, the .env and shell form. Case-sensitive on purpose: see the type's + // doc comment. + rule("(? String { + var units = Array(text.utf16) + let source = text as NSString + let scanRange = NSRange(location: 0, length: min(source.length, scanLimit)) + var changed = false + + // Every rule runs against the original text; the masks are then laid over each + // other, so where two rules overlap a character shows only if both leave it visible. + for rule in rules { + for match in rule.regex.matches(in: text, options: [], range: scanRange) { + let range = match.range(at: rule.group) + guard range.location != NSNotFound, range.length > 0 else { continue } + switch rule.style { + case .token: + let keep = range.length >= 16 ? 4 : (range.length >= 8 ? 2 : 0) + for index in (range.location + keep)..<(range.location + range.length - keep) { + units[index] = maskUnit + } + case .card: + let digits = (range.location..<(range.location + range.length)) + .filter { isDigit(source.character(at: $0)) } + guard isCardNumber(digits.map { source.character(at: $0) }) else { continue } + for index in digits.dropLast(4) { units[index] = maskUnit } + case .block: + for index in range.location..<(range.location + range.length) + where !isLineBreak(source.character(at: index)) { + units[index] = maskUnit + } + } + changed = true + } + } + return changed ? String(utf16CodeUnits: units, count: units.count) : text + } + + private static func isDigit(_ unit: unichar) -> Bool { unit >= 48 && unit <= 57 } + private static func isLineBreak(_ unit: unichar) -> Bool { unit == 10 || unit == 13 } + + /// A payment card number: a known issuer prefix for its length, and a valid Luhn + /// checksum. Without the prefix check one random 16-digit number in ten would pass. + private static func isCardNumber(_ units: [unichar]) -> Bool { + let digits = units.map { Int($0) - 48 } + let firstTwo = digits[0] * 10 + digits[1] + switch digits.count { + case 15: + guard firstTwo == 34 || firstTwo == 37 else { return false } // American Express + case 16: + let knownIssuer = digits[0] == 4 // Visa + || (51...55).contains(firstTwo) || (22...27).contains(firstTwo) // Mastercard + || firstTwo == 35 || digits[0] == 6 // JCB, Discover + guard knownIssuer else { return false } + default: + return false + } + var sum = 0 + for (offset, digit) in digits.reversed().enumerated() { + let doubled = offset % 2 == 1 ? digit * 2 : digit + sum += doubled > 9 ? doubled - 9 : doubled + } + return sum % 10 == 0 + } +} diff --git a/CopyCore/Sources/CopyCore/Storage/DatabaseManager.swift b/CopyCore/Sources/CopyCore/Storage/DatabaseManager.swift index 70405aa..40ebb2c 100644 --- a/CopyCore/Sources/CopyCore/Storage/DatabaseManager.swift +++ b/CopyCore/Sources/CopyCore/Storage/DatabaseManager.swift @@ -106,6 +106,13 @@ public final class DatabaseManager { t.add(column: "emoji", .text) } } + migrator.registerMigration("v4") { db in + // NULL = not checked yet. Left empty here on purpose: `ItemStore.pruneSecrets` + // fills it in, so existing history is only scanned if secret deletion is used. + try db.alter(table: "item") { t in + t.add(column: "containsSecret", .boolean) + } + } return migrator } } diff --git a/CopyCore/Sources/CopyCore/Storage/ItemStore.swift b/CopyCore/Sources/CopyCore/Storage/ItemStore.swift index 26fd228..0d23705 100644 --- a/CopyCore/Sources/CopyCore/Storage/ItemStore.swift +++ b/CopyCore/Sources/CopyCore/Storage/ItemStore.swift @@ -565,6 +565,7 @@ public struct ItemStore { item.linkTitle = nil item.contentHash = hash item.sizeBytes = text.utf8.count + item.containsSecret = nil item.lastUsedAt = now try item.update(db) try insertRepresentations( @@ -606,6 +607,7 @@ public struct ItemStore { item.linkTitle = nil item.contentHash = hash item.sizeBytes = plainText.utf8.count + item.containsSecret = nil item.lastUsedAt = now try item.update(db) try insertRepresentations( @@ -663,6 +665,57 @@ public struct ItemStore { } } + /// Deletes items that contain a secret (`SecretDetector`) and were copied before + /// `cutoff`, sparing favorites and pinboard members like `prune`. The clock is + /// `createdAt`, when the item first landed in the shelf: pasting it or copying it + /// again later does not extend it. + /// + /// Items nobody has checked yet (`containsSecret` is NULL: new copies, edits, and the + /// whole history the first time this runs) are classified first, in batches so a long + /// history never holds the database for long. + @discardableResult + public func pruneSecrets(olderThan cutoff: Date) throws -> Int { + try classifyUncheckedItems() + return try writer.write { db in + let doomed = try Int64.fetchAll(db, sql: """ + SELECT id FROM item + WHERE containsSecret = true AND createdAt < ? AND isFavorite = false + AND id NOT IN (SELECT DISTINCT itemId FROM pinboard_item) + """, arguments: [cutoff]) + guard !doomed.isEmpty else { return 0 } + try deleteItems(ClipItem.filter(doomed.contains(Column("id"))), in: db) + return doomed.count + } + } + + /// Fills in `containsSecret` wherever it is still NULL. Only text can hold a secret the + /// detector reads, so every other kind is settled without looking at it. + private func classifyUncheckedItems() throws { + let textKinds = [ItemKind.text, .richText, .link].map(\.rawValue) + try writer.write { db in + try db.execute(sql: """ + UPDATE item SET containsSecret = false + WHERE containsSecret IS NULL AND (plainText IS NULL OR kind NOT IN (?, ?, ?)) + """, arguments: StatementArguments(textKinds)) + } + while true { + // Read and write in one transaction, so an edit can't slip in between. + let classified = try writer.write { db -> Int in + let rows = try Row.fetchAll(db, sql: """ + SELECT id, plainText FROM item WHERE containsSecret IS NULL LIMIT ? + """, arguments: [Self.deleteChunkSize]) + for row in rows { + let id: Int64 = row["id"] + let text: String = row["plainText"] + try db.execute(sql: "UPDATE item SET containsSecret = ? WHERE id = ?", + arguments: [SecretDetector.scan(text), id]) + } + return rows.count + } + if classified == 0 { break } + } + } + public func setLinkTitle(itemID: Int64, _ title: String?) throws { try writer.write { db in try db.execute( diff --git a/CopyCore/Tests/CopyCoreTests/SecretDetectorTests.swift b/CopyCore/Tests/CopyCoreTests/SecretDetectorTests.swift new file mode 100644 index 0000000..201f43a --- /dev/null +++ b/CopyCore/Tests/CopyCoreTests/SecretDetectorTests.swift @@ -0,0 +1,181 @@ +import XCTest +@testable import CopyCore + +/// Every key below is fake, and assembled from pieces so no secret scanner mistakes this +/// file for a leak. +final class SecretDetectorTests: XCTestCase { + private let awsKey = "AKIA" + "IOSFODNN7EXAMPLE" + private let githubToken = "gh" + "p_" + "abcdefghijklmnopqrstuvwxyz0123456789" + private let openAIKey = "sk" + "-proj-" + "FAKEfakeFAKEfake1234567890abcd" + private let stripeKey = "sk" + "_test_" + "FAKE0000FAKE0000FAKE0000" + private let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + + ".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0" + + ".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + private func mask(_ text: String) -> String { SecretDetector.masked(text) } + private let dot = String(SecretDetector.maskCharacter) + + // MARK: Provider formats + + func testMasksProviderKeysKeepingBothEnds() { + XCTAssertEqual(mask(awsKey), "AKIA" + String(repeating: dot, count: 12) + "MPLE") + XCTAssertEqual(mask(githubToken), "ghp_" + String(repeating: dot, count: 32) + "6789") + XCTAssertTrue(mask(openAIKey).hasPrefix("sk-p" + dot)) + XCTAssertTrue(mask(openAIKey).hasSuffix(dot + "abcd")) + XCTAssertTrue(SecretDetector.containsSecret(stripeKey)) + XCTAssertTrue(SecretDetector.containsSecret(jwt)) + } + + func testMasksOnlyTheSecretInsideSurroundingText() { + let text = "Use \(awsKey) for the staging bucket." + XCTAssertEqual(mask(text), "Use AKIA" + String(repeating: dot, count: 12) + "MPLE for the staging bucket.") + } + + func testBearerTokenIsMaskedButTheHeaderNameStays() { + let masked = mask("Authorization: Bearer abcdef0123456789abcdef0123456789") + XCTAssertTrue(masked.hasPrefix("Authorization: Bearer abcd" + dot)) + XCTAssertFalse(masked.contains("0123456789abcdef")) + } + + // MARK: name = value + + func testMasksValueOfEnvStyleAssignmentButNotItsName() { + let masked = mask("DB_PASSWORD=Tr0ub4dor-and-3-more") + XCTAssertTrue(masked.hasPrefix("DB_PASSWORD=Tr0u" + dot)) + XCTAssertTrue(masked.hasSuffix(dot + "more")) + } + + func testMasksQuotedValueInJSONAndCode() { + XCTAssertTrue(SecretDetector.containsSecret(#"{ "api_key": "a1b2c3d4e5f6g7h8" }"#)) + XCTAssertTrue(SecretDetector.containsSecret(#"let clientSecret = "a1b2c3d4e5f6g7h8""#)) + XCTAssertTrue(mask(#"password: 'hunter2hunter2'"#).hasPrefix("password: 'hu" + dot)) + } + + func testMasksTokenInURLQueryButKeepsTheRestOfTheURL() { + let masked = mask("https://example.com/hook?access_token=a1b2c3d4e5f6g7h8&page=2") + XCTAssertTrue(masked.hasPrefix("https://example.com/hook?access_token=a1b2" + dot)) + XCTAssertTrue(masked.hasSuffix("g7h8&page=2")) + } + + func testMasksEachSecretInAMultiLineEnvFile() { + let env = "APP_NAME=Copy\nAPI_KEY=\(openAIKey)\nSTRIPE_SECRET=\(stripeKey)\nDEBUG=true" + let lines = mask(env).components(separatedBy: "\n") + XCTAssertEqual(lines[0], "APP_NAME=Copy") + XCTAssertTrue(lines[1].hasPrefix("API_KEY=sk-p" + dot)) + XCTAssertTrue(lines[2].hasPrefix("STRIPE_SECRET=sk_t" + dot)) + XCTAssertEqual(lines[3], "DEBUG=true") + } + + // MARK: Cards and private keys + + func testCardNumberKeepsOnlyLastFourDigitsAndItsSpacing() { + let group = String(repeating: dot, count: 4) + XCTAssertEqual(mask("4242 4242 4242 4242"), "\(group) \(group) \(group) 4242") + XCTAssertEqual(mask("Card: 4111-1111-1111-1111."), "Card: \(group)-\(group)-\(group)-1111.") + XCTAssertEqual(mask("378282246310005"), String(repeating: dot, count: 11) + "0005") // Amex, 15 digits + } + + func testDigitRunsThatAreNotCardsStayVisible() { + for text in [ + "1234 5678 9012 3456", // unknown issuer prefix + "4242 4242 4242 4241", // right prefix, wrong checksum + "1728561234567", // 13 digits: a millisecond timestamp + "42424242424242424242", // 20 digits + "+1 (555) 010-1234", + "Order 4242424242424242x", // runs into a letter + ] { + XCTAssertEqual(mask(text), text, text) + } + } + + func testPrivateKeyBodyIsHiddenAndItsLinesKept() { + let begin = "-----BEGIN OPENSSH PRIVATE KEY-----", end = "-----END OPENSSH PRIVATE KEY-----" + let hidden = String(repeating: dot, count: 15) + XCTAssertEqual(mask("\(begin)\nb3BlbnNzaC1rZXk\nAAAAB3NzaC1yc2E\n\(end)"), + "\(begin)\n\(hidden)\n\(hidden)\n\(end)") + } + + func testUnterminatedPrivateKeyIsHiddenToTheEnd() { + XCTAssertEqual(mask("-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg"), + "-----BEGIN PRIVATE KEY-----\n" + String(repeating: dot, count: 14)) + } + + // MARK: Ordinary text + + func testOrdinaryTextIsLeftAlone() { + for text in [ + "9d352d5a1b2c3d4e5f60718293a4b5c6d7e8f901", // git commit hash + "6F9619FF-8B86-D011-B42D-00C04FC964FF", // UUID + "#4C9DFF", + "https://github.com/tarikbc/Copy/pull/18", + "my password is on a sticky note", + "The token: please bring your ticket to the desk", + "Standup at 10:30, demo the smart search and paste stack.", + "the task-management-system-design-document", // "sk-" inside a word + "", + ] { + XCTAssertFalse(SecretDetector.containsSecret(text), text) + } + } + + /// Code that merely mentions a secret-ish name must not be masked: these are + /// identifiers, labels, paths and references, not secret values. + func testCodeThatOnlyNamesASecretIsLeftAlone() { + for text in [ + #"let passwordLabel = "Enter your password""#, // quoted, but prose + "login(password=user_password1)", // lowercase kwarg + "token = session2Token", // spaced assignment to an identifier + "password = hashPassword(input1)", + "let apiKey = config.value(for: key2)", + "SECRET_PATH=/etc/app/secrets2", // a path + "API_KEY=$OPENAI_API_KEY_V2", // a variable reference + #"callbackToken = "https://example.com/v2/callback""#, // a URL + "DEBUG_TOKEN=true", // too short, no digit + ] { + XCTAssertEqual(mask(text), text, text) + } + } + + // MARK: Titles + + func testDisplayTitleMasksSecretsOnlyWhenAskedAndNeverATitleTheUserTyped() { + var item = ClipItem( + id: 1, uuid: "u", kind: .text, createdAt: Date(), lastUsedAt: Date(), + plainText: awsKey, linkTitle: nil, appBundleID: nil, appName: nil, + contentHash: "h", sizeBytes: 20, isFavorite: false) + + XCTAssertEqual(item.displayTitle, awsKey) + XCTAssertEqual(item.displayTitle(hidingSecrets: false), awsKey) + XCTAssertEqual(item.displayTitle(hidingSecrets: true), mask(awsKey)) + + item.title = "AWS staging key" + XCTAssertEqual(item.displayTitle(hidingSecrets: true), "AWS staging key") + } + + // MARK: Guarantees the UI relies on + + func testMaskingNeverChangesTheLength() { + for text in [awsKey, githubToken, jwt, "API_KEY=\(openAIKey)", "4242 4242 4242 4242", + "héllo 👋 \(stripeKey) wörld", "-----BEGIN PRIVATE KEY-----\r\nabc\r\ndef\r\n"] { + let masked = mask(text) + XCTAssertEqual(masked.count, text.count, text) + XCTAssertEqual(masked.utf16.count, text.utf16.count, text) + } + } + + func testMaskedPrefixHidesASecretTheCutoffSlicesThrough() { + let text = "note " + githubToken + // The cutoff lands 10 characters into the token; none of its hidden part may show. + XCTAssertEqual(SecretDetector.maskedPrefix(text, count: 15), "note ghp_" + String(repeating: dot, count: 6)) + XCTAssertEqual(SecretDetector.maskedPrefix("plain text", count: 5), "plain") + } + + /// A long unbroken run of key-like characters (a pasted base64 image, say) must not + /// send any rule into quadratic backtracking. Correct code takes milliseconds here. + func testAHugeRunOfKeyLikeCharactersDoesNotHang() { + let blob = String(repeating: "aGVsbG8gd29ybGQ", count: 20_000) // 300k characters + let started = Date() + XCTAssertFalse(SecretDetector.containsSecret(blob)) + XCTAssertLessThan(Date().timeIntervalSince(started), 2) + } +} diff --git a/CopyCore/Tests/CopyCoreTests/SecretRetentionTests.swift b/CopyCore/Tests/CopyCoreTests/SecretRetentionTests.swift new file mode 100644 index 0000000..c92ad1a --- /dev/null +++ b/CopyCore/Tests/CopyCoreTests/SecretRetentionTests.swift @@ -0,0 +1,97 @@ +import XCTest +@testable import CopyCore + +/// `ItemStore.pruneSecrets`: items holding a secret are deleted once they are older than +/// the cutoff. Every key here is fake and assembled from pieces (see `SecretDetectorTests`). +final class SecretRetentionTests: XCTestCase { + private let secret = "API_KEY=" + "sk" + "-proj-" + "FAKEfakeFAKEfake1234567890abcd" + private let otherSecret = "gh" + "p_" + "abcdefghijklmnopqrstuvwxyz0123456789" + + private let copiedAt = Date(timeIntervalSince1970: 1_000) + private let cutoff = Date(timeIntervalSince1970: 2_000) + private let afterCutoff = Date(timeIntervalSince1970: 3_000) + + private func texts(in store: ItemStore) throws -> Set { + Set(try store.recentItems(limit: 100).compactMap(\.plainText)) + } + + func testDeletesOldSecretsAndNothingElse() throws { + let store = try makeTempStore() + try store.save(makeText(secret), now: copiedAt) + try store.save(makeText("an old note with nothing to hide"), now: copiedAt) + try store.save(makeText(otherSecret), now: afterCutoff) + + XCTAssertEqual(try store.pruneSecrets(olderThan: cutoff), 1) + XCTAssertEqual(try texts(in: store), ["an old note with nothing to hide", otherSecret]) + } + + func testSparesFavoritesAndPinboardMembers() throws { + let (store, pinboards) = try makeTempStores() + let favorite = try store.save(makeText(secret), now: copiedAt) + let pinned = try store.save(makeText(otherSecret), now: copiedAt) + try store.setFavorite(itemID: favorite.id!, true) + let board = try pinboards.create(name: "Keys", symbol: "key") + try pinboards.add(itemID: pinned.id!, to: board.id!) + + XCTAssertEqual(try store.pruneSecrets(olderThan: cutoff), 0) + XCTAssertEqual(try texts(in: store), [secret, otherSecret]) + + // Letting go of the favorite makes it deletable again. + try store.setFavorite(itemID: favorite.id!, false) + XCTAssertEqual(try store.pruneSecrets(olderThan: cutoff), 1) + XCTAssertEqual(try texts(in: store), [otherSecret]) + } + + /// The clock is when the item was first copied; using or re-copying it doesn't extend it. + func testPastingOrCopyingAgainDoesNotExtendTheClock() throws { + let store = try makeTempStore() + let pasted = try store.save(makeText(secret), now: copiedAt) + try store.touch(itemID: pasted.id!, now: afterCutoff) + try store.save(makeText(otherSecret), now: copiedAt) + try store.save(makeText(otherSecret), now: afterCutoff) // same content: dedups, bumps lastUsedAt + + XCTAssertEqual(try store.pruneSecrets(olderThan: cutoff), 2) + XCTAssertTrue(try texts(in: store).isEmpty) + } + + func testAnEditIsCheckedAgain() throws { + let store = try makeTempStore() + let becomesSecret = try store.save(makeText("just a note"), now: copiedAt) + let stopsBeingSecret = try store.save(makeText(secret), now: copiedAt) + + // A first run settles both, with nothing old enough to delete. + XCTAssertEqual(try store.pruneSecrets(olderThan: Date(timeIntervalSince1970: 500)), 0) + + try store.replaceContent(itemID: becomesSecret.id!, with: otherSecret, now: afterCutoff) + try store.replaceContent(itemID: stopsBeingSecret.id!, with: "the key was rotated", now: afterCutoff) + + XCTAssertEqual(try store.pruneSecrets(olderThan: cutoff), 1) + XCTAssertEqual(try texts(in: store), ["the key was rotated"]) + } + + func testClassifiesAHistoryLargerThanOneBatch() throws { + let store = try makeTempStore() + let total = ItemStore.deleteChunkSize * 2 + 25 + for index in 0.. Date: Sat, 10 Oct 2026 17:18:57 +0530 Subject: [PATCH 2/2] chore: trim comments Signed-off-by: devansh nigam --- Copy/App/AppCoordinator.swift | 11 +--- Copy/App/DemoData.swift | 4 +- Copy/PasteStack/PasteStackModel.swift | 3 +- Copy/Settings/SettingsStore.swift | 19 ++---- Copy/Shelf/ItemCardView.swift | 21 ++---- Copy/Shelf/PreviewPane.swift | 9 ++- Copy/Shelf/ShelfViewModel.swift | 7 +- .../CopyCore/Models/ClipItem+Display.swift | 6 +- .../Sources/CopyCore/Models/Records.swift | 5 +- .../CopyCore/Rules/SecretDetector.swift | 66 +++++++------------ .../CopyCore/Storage/DatabaseManager.swift | 3 +- .../Sources/CopyCore/Storage/ItemStore.swift | 16 ++--- .../CopyCoreTests/SecretDetectorTests.swift | 9 +-- .../CopyCoreTests/SecretRetentionTests.swift | 3 +- 14 files changed, 62 insertions(+), 120 deletions(-) diff --git a/Copy/App/AppCoordinator.swift b/Copy/App/AppCoordinator.swift index 2be2dd2..1f5728a 100644 --- a/Copy/App/AppCoordinator.swift +++ b/Copy/App/AppCoordinator.swift @@ -491,8 +491,7 @@ final class AppCoordinator { } func start() { - // Secret deletion runs in demo mode too: it is off unless the user chose a - // lifetime, and the demo database is rebuilt on every launch anyway. + // Secret deletion also runs in demo mode; its database is rebuilt every launch. settings.onSecretLifetimeChange = { [weak self] _ in self?.scheduleSecretPrune() } scheduleSecretPrune() // Demo mode never captures the real clipboard (keeps the curated set pristine) and @@ -563,9 +562,7 @@ final class AppCoordinator { } } - /// Deletes items that contain a secret once they are older than - /// `SettingsStore.secretLifetime`, on the persist queue. Favorites and pinboard members - /// are spared (`ItemStore.pruneSecrets`). + /// Deletes secret items older than `secretLifetime`; favorites and pinboard items are kept. private func runSecretPrune() { guard let lifetime = settings.secretLifetime.interval else { return } let cutoff = Date().addingTimeInterval(-lifetime) @@ -582,9 +579,7 @@ final class AppCoordinator { } } - /// Applies the current `secretLifetime`: checks now, then again a few times within each - /// lifetime (every five minutes at most), so a secret outlives its limit only briefly. - /// With `.never` there is no timer at all. + /// Checks now, then every five minutes at most. No timer when the lifetime is `.never`. private func scheduleSecretPrune() { secretPruneTimer?.cancel() secretPruneTimer = nil diff --git a/Copy/App/DemoData.swift b/Copy/App/DemoData.swift index 9e02302..806f8a2 100644 --- a/Copy/App/DemoData.swift +++ b/Copy/App/DemoData.swift @@ -123,9 +123,7 @@ enum DemoData { from: notes, at: ago(28 * minute)) try store.setTitle(itemID: checklistID, "Launch checklist") - // Secrets, to show Hide Secrets (`SecretDetector`) at work: an .env file, a bare - // token, a card number in a sentence and a URL carrying a token. All fake, and - // assembled from pieces so no secret scanner mistakes this file for a leak. + // Fake secrets to show Hide Secrets, assembled from pieces so secret scanners ignore them. let fakeOpenAIKey = "sk" + "-proj-" + "FAKEfakeFAKEfake1234567890abcd" let fakeStripeKey = "sk" + "_test_" + "FAKE0000FAKE0000FAKE0000" let fakeGitHubToken = "gh" + "p_" + "abcdefghijklmnopqrstuvwxyz0123456789" diff --git a/Copy/PasteStack/PasteStackModel.swift b/Copy/PasteStack/PasteStackModel.swift index cbd2737..d7fc47a 100644 --- a/Copy/PasteStack/PasteStackModel.swift +++ b/Copy/PasteStack/PasteStackModel.swift @@ -25,8 +25,7 @@ final class PasteStackModel { let store: ItemStore - /// `SettingsStore.hideSecrets`, pushed by `AppCoordinator`: row titles mask API keys - /// and other secrets. A row has no reveal; the shelf card does. + /// Mirrors `SettingsStore.hideSecrets`: row titles mask secrets. var hideSecrets = true /// Bumped after an in-place edit so `PasteStackView` re-renders (the queue's uuids diff --git a/Copy/Settings/SettingsStore.swift b/Copy/Settings/SettingsStore.swift index fe1b21e..f979bd1 100644 --- a/Copy/Settings/SettingsStore.swift +++ b/Copy/Settings/SettingsStore.swift @@ -89,13 +89,12 @@ enum RetentionPeriod: String, CaseIterable { static let sliderOrder: [RetentionPeriod] = [.day, .week, .month, .threeMonths, .unlimited] } -/// How long an item that contains a secret (an API key, token, password or card number, -/// per `SecretDetector`) is kept after it is copied. Favorites and pinboard items are -/// never deleted, the same promise `RetentionPeriod` makes. +/// How long an item containing a secret is kept after it is copied. Favorites and +/// pinboard items are never deleted. enum SecretLifetime: String, CaseIterable, Identifiable { case never #if DEBUG - /// Debug builds only: short enough to watch a deletion happen while testing. + /// Debug only, for testing. case minute #endif case hour @@ -106,7 +105,7 @@ enum SecretLifetime: String, CaseIterable, Identifiable { var id: String { rawValue } - /// How long after it is copied a secret is deleted; `nil` keeps it. + /// `nil` keeps it forever. var interval: TimeInterval? { switch self { case .never: return nil @@ -226,10 +225,8 @@ final class SettingsStore { } @ObservationIgnored var onFloatingShelfChange: ((Bool) -> Void)? - /// Shows API keys, tokens, passwords and card numbers masked on cards, previews and - /// titles (see `SecretDetector`). On by default. Display only: the stored item and - /// what gets pasted are the real value. `onHideSecretsChange` pushes it to the paste - /// stack, which has no view of this store. + /// Masks secrets on cards, previews and titles (`SecretDetector`). Display only: the + /// stored and pasted text is unchanged. var hideSecrets: Bool { didSet { guard hideSecrets != oldValue else { return } @@ -239,9 +236,7 @@ final class SettingsStore { } @ObservationIgnored var onHideSecretsChange: ((Bool) -> Void)? - /// When items that contain a secret are deleted. `.never` by default: nothing is - /// removed unless the user asks for it. `onSecretLifetimeChange` lets the coordinator - /// apply a new choice at once instead of at the next scheduled check. + /// When items containing a secret are deleted. `.never` by default. var secretLifetime: SecretLifetime { didSet { guard secretLifetime != oldValue else { return } diff --git a/Copy/Shelf/ItemCardView.swift b/Copy/Shelf/ItemCardView.swift index a5840fc..2f78585 100644 --- a/Copy/Shelf/ItemCardView.swift +++ b/Copy/Shelf/ItemCardView.swift @@ -20,10 +20,8 @@ struct ItemCardView: View { var searchQuery: String = "" /// Briefly true after a "Show in History" jump lands on this card — draws an accent ring. var isFlashing: Bool = false - /// `SettingsStore.hideSecrets`: API keys and other secrets in the card's text are drawn - /// masked unless `secretsRevealed`. The pasted and dragged content is always the real text. + /// Mask secrets in the card's text unless `secretsRevealed`. Pasting uses the real text. var hideSecrets: Bool = false - /// The user asked to see this card's secrets (the hover eye button, `onToggleSecrets`). var secretsRevealed: Bool = false /// The card's ⌥-digit paste number (1-9), shown as a badge while ⌥ is held; `nil` hides it. var pasteNumber: Int? = nil @@ -75,17 +73,14 @@ struct ItemCardView: View { private var masksSecrets: Bool { hideSecrets && !secretsRevealed } - /// How much of the text a card body ever shows. private static let bodyCap = 1_500 - /// The text the card draws: the first `cap` characters, with secrets masked when - /// `masksSecrets`. Masking keeps the length, so highlight tokens still line up. + /// Masking keeps the length, so highlight tokens still line up. private func displayText(_ text: String, cap: Int) -> String { masksSecrets ? SecretDetector.maskedPrefix(text, count: cap) : String(text.prefix(cap)) } - /// Whether the card's visible text has a secret in it, which is what earns it the eye - /// button. Only asked while hovering. + /// Earns the card its eye button. Only evaluated while hovering. private var hasSecret: Bool { guard hideSecrets, isEditable, let text = item.plainText else { return false } return SecretDetector.maskedPrefix(text, count: Self.bodyCap) != String(text.prefix(Self.bodyCap)) @@ -375,7 +370,6 @@ struct ItemCardView: View { /// drawn on screen. @ViewBuilder private func codeAwareBody(text: String, cap: Int) -> some View { - // Tokens come from the real text; the masked text is the same length, so they fit. let highlight = CodeHighlightCache.shared.result(for: text, uuid: item.uuid, cap: cap) let display = Self.preservingIndentation(displayText(text, cap: cap)) if highlight.language != nil { @@ -677,15 +671,14 @@ private struct CardClickGesture: ViewModifier { /// The floating action pill shown on card hover (see `ItemCardView`'s top-trailing /// overlay). Surfaces favorite and delete, the two high-value actions otherwise hidden -/// in the right-click menu, plus unpin while viewing a pinboard and, on a card with a -/// masked secret, the eye that reveals it. Stays a quiet affordance rather than a -/// toolbar; the rest remains in the context menu and via drag. +/// in the right-click menu, plus unpin while viewing a pinboard and the eye that reveals +/// a masked secret. Stays a quiet affordance rather than a toolbar; the rest remains in +/// the context menu and via drag. private struct CardHoverActions: View { let favoritesEnabled: Bool let isFavorite: Bool let onToggleFavorite: () -> Void - /// Whether the card's secrets are showing; `nil` (and so no eye button) when the card - /// has none to hide. + /// `nil` hides the eye button: the card has no secret. var secretsRevealed: Bool? = nil var onToggleSecrets: () -> Void = {} /// Removes the card from the pinboard currently being viewed. `nil` (and so hidden) diff --git a/Copy/Shelf/PreviewPane.swift b/Copy/Shelf/PreviewPane.swift index 66fe571..51efeea 100644 --- a/Copy/Shelf/PreviewPane.swift +++ b/Copy/Shelf/PreviewPane.swift @@ -6,7 +6,7 @@ import UniformTypeIdentifiers struct PreviewPane: View { let item: ClipItem let store: ItemStore - /// `SettingsStore.hideSecrets`: secrets in the text are masked unless `secretsRevealed`. + /// Mask secrets in the text unless `secretsRevealed`. var hideSecrets: Bool = false var secretsRevealed: Bool = false var onToggleSecrets: () -> Void = {} @@ -38,10 +38,9 @@ struct PreviewPane: View { /// the paste's actual size. Anything beyond the cap still renders (up to the /// existing 200k display cap below), just without color. private let highlightCap = 10_000 - /// The most text the preview ever shows. private static let displayCap = 200_000 - /// Whether the previewed text has a secret in it, which is what earns it the eye button. + /// Earns the preview its eye button. private var hasSecret: Bool { guard hideSecrets, let text = item.plainText else { return false } return SecretDetector.maskedPrefix(text, count: Self.displayCap) != String(text.prefix(Self.displayCap)) @@ -53,7 +52,7 @@ struct PreviewPane: View { /// Any remainder past the highlighted portion still renders, just as plain mono /// text, exactly as the whole thing did before this feature existed. private func codeAwarePreviewText(_ text: String) -> Text { - // Masking keeps the length, so tokens from the real text still fit the masked one. + // Masking keeps the length, so highlight tokens still line up. let displayText = hideSecrets && !secretsRevealed ? SecretDetector.maskedPrefix(text, count: Self.displayCap) : String(text.prefix(Self.displayCap)) @@ -97,7 +96,7 @@ struct PreviewPane: View { } .overlay(alignment: .topTrailing) { if hasSecret { - // Same pill as a card's hover actions, so the text can scroll under it. + // Same pill as a card's hover actions. IconButton(systemName: secretsRevealed ? "eye.slash" : "eye", fontSize: 11, size: CGSize(width: 22, height: 22), help: secretsRevealed ? "Hide secrets" : "Show secrets", diff --git a/Copy/Shelf/ShelfViewModel.swift b/Copy/Shelf/ShelfViewModel.swift index df60405..3761119 100644 --- a/Copy/Shelf/ShelfViewModel.swift +++ b/Copy/Shelf/ShelfViewModel.swift @@ -101,9 +101,7 @@ final class ShelfViewModel { /// The uuid of the card the pointer is currently over, so a force-click can act on /// exactly the card under the cursor (set by `ItemCardView`'s hover callback). var hoveredItemID: String? - /// Cards whose secrets the user has chosen to see (by item uuid), while - /// `SettingsStore.hideSecrets` masks the rest. Forgotten when the shelf closes, so a - /// revealed key is hidden again the next time the shelf opens. + /// Cards (by uuid) whose secrets are shown. Cleared when the shelf closes. private(set) var revealedSecretIDs: Set = [] /// The pinboard tab a dragged tab would land beside, and on which side, so that tab /// shows its insertion edge. Driven by `ShelfHostingView` (see `shelfDragMoved`). @@ -683,12 +681,11 @@ final class ShelfViewModel { if !searchQuery.isEmpty { refresh() } } - /// Whether `item`'s secrets are currently drawn masked. func hidesSecrets(in item: ClipItem) -> Bool { settings.hideSecrets && !revealedSecretIDs.contains(item.uuid) } - /// The eye button on a card or its preview: shows or re-hides that card's secrets. + /// The eye button on a card or its preview. func toggleSecretsRevealed(_ item: ClipItem) { if revealedSecretIDs.contains(item.uuid) { revealedSecretIDs.remove(item.uuid) diff --git a/CopyCore/Sources/CopyCore/Models/ClipItem+Display.swift b/CopyCore/Sources/CopyCore/Models/ClipItem+Display.swift index 2f6abbc..156273e 100644 --- a/CopyCore/Sources/CopyCore/Models/ClipItem+Display.swift +++ b/CopyCore/Sources/CopyCore/Models/ClipItem+Display.swift @@ -7,8 +7,7 @@ public extension ClipItem { /// The user-assigned `title` when set, otherwise `menuTitle`. var displayTitle: String { displayTitle(hidingSecrets: false) } - /// `menuTitle`, with any API key or other secret in it masked when `hidingSecrets` - /// (see `SecretDetector`). + /// `menuTitle`, masking secrets when `hidingSecrets`. func menuTitle(hidingSecrets: Bool) -> String { switch kind { case .image: @@ -25,8 +24,7 @@ public extension ClipItem { } } - /// `displayTitle`, masking secrets in the derived title when `hidingSecrets`. A title - /// the user typed is shown as written. + /// `displayTitle`; a title the user typed is never masked. func displayTitle(hidingSecrets: Bool) -> String { title?.isEmpty == false ? title! : menuTitle(hidingSecrets: hidingSecrets) } diff --git a/CopyCore/Sources/CopyCore/Models/Records.swift b/CopyCore/Sources/CopyCore/Models/Records.swift index 2b1dc4e..adb016f 100644 --- a/CopyCore/Sources/CopyCore/Models/Records.swift +++ b/CopyCore/Sources/CopyCore/Models/Records.swift @@ -18,9 +18,8 @@ public struct ClipItem: Codable, Equatable, Identifiable, FetchableRecord, Mutab public var isFavorite: Bool public var title: String? public var recognizedText: String? - /// Whether `SecretDetector` found an API key or other secret in the text. `nil` until - /// the secret clean-up has looked at the item (`ItemStore.pruneSecrets`), and again - /// after an edit, so a new or changed item is always re-checked. + /// Whether the text holds a secret (`SecretDetector`). `nil` until + /// `ItemStore.pruneSecrets` has checked it, and again after an edit. public var containsSecret: Bool? public mutating func didInsert(_ inserted: InsertionSuccess) { diff --git a/CopyCore/Sources/CopyCore/Rules/SecretDetector.swift b/CopyCore/Sources/CopyCore/Rules/SecretDetector.swift index af03722..5272cd5 100644 --- a/CopyCore/Sources/CopyCore/Rules/SecretDetector.swift +++ b/CopyCore/Sources/CopyCore/Rules/SecretDetector.swift @@ -1,35 +1,22 @@ import Foundation -/// Finds secrets in copied text (API keys, tokens, private keys, card numbers) so the app -/// can show them masked. Masking is display-only: the stored item and what gets pasted are -/// untouched. -/// -/// Deliberately conservative, like `CodeDetector`: it prefers missing an unusual secret to -/// masking ordinary text. It recognizes provider key formats by their fixed prefixes, and a -/// generic `name = value` only where the shape is unambiguous: a quoted value, an -/// ALL-CAPS `.env`-style name, or a URL query parameter. An unquoted lowercase -/// `api_key=value` is skipped on purpose, because code (`login(password=user_password1)`) -/// looks exactly like it. +/// Finds secrets in copied text (API keys, tokens, private keys, card numbers) so they can +/// be shown masked. Conservative on purpose: it would rather miss a secret than mask +/// ordinary text, so an unquoted lowercase `api_key=value` is skipped (code looks the same). public enum SecretDetector { - /// The character a masked secret is drawn with. public static let maskCharacter: Character = "•" - /// Whether `text` contains anything `masked(_:)` would hide. public static func containsSecret(_ text: String) -> Bool { masked(text) != text } - /// `containsSecret` without the display cache, for scanning a whole history: caching - /// every item's text would only push out the entries the shelf is drawing. + /// `containsSecret` without the display cache, for scanning a whole history. public static func scan(_ text: String) -> Bool { computeMasked(text) != text } - /// `text` with the middle of each secret replaced by `maskCharacter`. - /// - /// The result always has the same length as `text`, in characters and in UTF-16 units: - /// every hidden character is ASCII and is swapped one-for-one. Syntax-highlight token - /// ranges computed against the original text therefore still line up with the masked one. + /// `text` with the middle of each secret replaced by `maskCharacter`. The length never + /// changes (characters and UTF-16), so highlight token ranges still line up. public static func masked(_ text: String) -> String { let key = text as NSString if let cached = cache.object(forKey: key) { return cached as String } @@ -38,21 +25,20 @@ public enum SecretDetector { return result } - /// The first `count` characters of `text`, masked. Scans a little past the cutoff, so a - /// secret the cutoff slices in half is still recognized instead of showing its first part. + /// The first `count` characters, masked. Scans past the cutoff so a secret it slices + /// through is still caught. public static func maskedPrefix(_ text: String, count: Int) -> String { String(masked(String(text.prefix(count + cutoffMargin))).prefix(count)) } // MARK: - Detection - /// How a matched secret is hidden. private enum Style { - /// Keep a few characters at each end, so a key is still recognizable. + /// Keep a few characters at each end. case token - /// Keep only the last four digits; separators stay. + /// Keep the last four digits. case card - /// Hide every character but keep the line breaks (a private key's body). + /// Hide everything but line breaks (a private key's body). case block } @@ -64,7 +50,7 @@ public enum SecretDetector { } private static let cutoffMargin = 4_096 - /// Bounds the work on a huge paste; text past this is left as it is. + /// Text past this is not scanned. private static let scanLimit = 250_000 private static let maskUnit = maskCharacter.utf16.first! @@ -74,13 +60,11 @@ public enum SecretDetector { return cache }() - /// Names that mark a value as a secret, for the generic `name = value` rules. + /// Names that mark a `name = value` as a secret. private static let secretName = "(?:api[_-]?key|secret|token|passw(?:or)?d)" - /// What a generic unquoted value may contain. No `.`, `(`, `&` or `$`: those are - /// property accesses, calls, the next URL parameter and variable references. + /// No `.`, `(`, `&` or `$`: property accesses, calls, URL separators, variables. private static let bareValue = "[A-Za-z0-9_+/=~!@#%^*-]" - /// A generic value must contain a letter and a digit, and not be a path, a variable - /// reference or a URL. `valueClass` is the character class the value is made of. + /// Needs a letter and a digit, and must not be a path, variable or URL. private static func looksLikeSecret(_ valueClass: String) -> String { "(?![/~.$]|https?:)(?=\(valueClass)*[A-Za-z])(?=\(valueClass)*[0-9])" } @@ -88,12 +72,12 @@ public enum SecretDetector { private static let rules: [Rule] = { func rule(_ pattern: String, group: Int = 0, style: Style = .token, options: NSRegularExpression.Options = []) -> Rule { - // The patterns are literals, so a failure here is a typo caught by the tests. + // Literal patterns: a failure is a typo the tests catch. Rule(regex: try! NSRegularExpression(pattern: pattern, options: options), group: group, style: style) } let quotedValue = "[\\x21\\x23-\\x26\\x28-\\x7E]" // printable ASCII except quotes return [ - // Provider formats, identified by their fixed prefix. + // Provider formats, by prefix. rule("\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b"), // AWS access key ID rule("\\bgh[pousr]_[A-Za-z0-9]{36,}\\b"), // GitHub token rule("\\bgithub_pat_[A-Za-z0-9_]{50,}\\b"), // GitHub fine-grained token @@ -107,16 +91,14 @@ public enum SecretDetector { rule("\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}"), // JWT rule("\\bbearer\\s+(?=[A-Za-z0-9._~+/=-]*[0-9])([A-Za-z0-9._~+/=-]{20,})", group: 1, options: [.caseInsensitive]), - // A private key: hide the body, keep the BEGIN/END lines. An unterminated block - // (the cutoff landed inside it) is hidden to the end. + // Private key: hide the body (to the end if unterminated), keep BEGIN/END. rule("-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----([\\s\\S]*?)(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|\\z)", group: 1, style: .block), - // name = "value", in any language or config format. + // name = "value" rule("\(secretName)[A-Za-z0-9_-]{0,40}[\"']?\\s{0,4}[:=]\\s{0,4}[\"']" + "\(looksLikeSecret(quotedValue))(\(quotedValue){12,})[\"']", group: 1, options: [.caseInsensitive]), - // NAME=value, the .env and shell form. Case-sensitive on purpose: see the type's - // doc comment. + // NAME=value (.env). Case-sensitive on purpose: lowercase is how code looks. rule("(? Bool { unit >= 48 && unit <= 57 } private static func isLineBreak(_ unit: unichar) -> Bool { unit == 10 || unit == 13 } - /// A payment card number: a known issuer prefix for its length, and a valid Luhn - /// checksum. Without the prefix check one random 16-digit number in ten would pass. + /// A known issuer prefix plus a valid Luhn checksum. private static func isCardNumber(_ units: [unichar]) -> Bool { let digits = units.map { Int($0) - 48 } let firstTwo = digits[0] * 10 + digits[1] diff --git a/CopyCore/Sources/CopyCore/Storage/DatabaseManager.swift b/CopyCore/Sources/CopyCore/Storage/DatabaseManager.swift index 40ebb2c..b5f1976 100644 --- a/CopyCore/Sources/CopyCore/Storage/DatabaseManager.swift +++ b/CopyCore/Sources/CopyCore/Storage/DatabaseManager.swift @@ -107,8 +107,7 @@ public final class DatabaseManager { } } migrator.registerMigration("v4") { db in - // NULL = not checked yet. Left empty here on purpose: `ItemStore.pruneSecrets` - // fills it in, so existing history is only scanned if secret deletion is used. + // NULL = not checked yet; `ItemStore.pruneSecrets` fills it in. try db.alter(table: "item") { t in t.add(column: "containsSecret", .boolean) } diff --git a/CopyCore/Sources/CopyCore/Storage/ItemStore.swift b/CopyCore/Sources/CopyCore/Storage/ItemStore.swift index 0d23705..eb75f3c 100644 --- a/CopyCore/Sources/CopyCore/Storage/ItemStore.swift +++ b/CopyCore/Sources/CopyCore/Storage/ItemStore.swift @@ -665,14 +665,9 @@ public struct ItemStore { } } - /// Deletes items that contain a secret (`SecretDetector`) and were copied before - /// `cutoff`, sparing favorites and pinboard members like `prune`. The clock is - /// `createdAt`, when the item first landed in the shelf: pasting it or copying it - /// again later does not extend it. - /// - /// Items nobody has checked yet (`containsSecret` is NULL: new copies, edits, and the - /// whole history the first time this runs) are classified first, in batches so a long - /// history never holds the database for long. + /// Deletes items that contain a secret and were first copied before `cutoff`, sparing + /// favorites and pinboard members. Unchecked items (`containsSecret` NULL) are + /// classified first. @discardableResult public func pruneSecrets(olderThan cutoff: Date) throws -> Int { try classifyUncheckedItems() @@ -688,8 +683,7 @@ public struct ItemStore { } } - /// Fills in `containsSecret` wherever it is still NULL. Only text can hold a secret the - /// detector reads, so every other kind is settled without looking at it. + /// Fills in `containsSecret` where it is NULL, in batches. Only text is scanned. private func classifyUncheckedItems() throws { let textKinds = [ItemKind.text, .richText, .link].map(\.rawValue) try writer.write { db in @@ -699,7 +693,7 @@ public struct ItemStore { """, arguments: StatementArguments(textKinds)) } while true { - // Read and write in one transaction, so an edit can't slip in between. + // One transaction per batch, so an edit can't slip in between. let classified = try writer.write { db -> Int in let rows = try Row.fetchAll(db, sql: """ SELECT id, plainText FROM item WHERE containsSecret IS NULL LIMIT ? diff --git a/CopyCore/Tests/CopyCoreTests/SecretDetectorTests.swift b/CopyCore/Tests/CopyCoreTests/SecretDetectorTests.swift index 201f43a..1443bfd 100644 --- a/CopyCore/Tests/CopyCoreTests/SecretDetectorTests.swift +++ b/CopyCore/Tests/CopyCoreTests/SecretDetectorTests.swift @@ -1,8 +1,7 @@ import XCTest @testable import CopyCore -/// Every key below is fake, and assembled from pieces so no secret scanner mistakes this -/// file for a leak. +/// Every key here is fake, assembled from pieces so secret scanners ignore it. final class SecretDetectorTests: XCTestCase { private let awsKey = "AKIA" + "IOSFODNN7EXAMPLE" private let githubToken = "gh" + "p_" + "abcdefghijklmnopqrstuvwxyz0123456789" @@ -118,8 +117,7 @@ final class SecretDetectorTests: XCTestCase { } } - /// Code that merely mentions a secret-ish name must not be masked: these are - /// identifiers, labels, paths and references, not secret values. + /// Identifiers, labels, paths and references are not secret values. func testCodeThatOnlyNamesASecretIsLeftAlone() { for text in [ #"let passwordLabel = "Enter your password""#, // quoted, but prose @@ -170,8 +168,7 @@ final class SecretDetectorTests: XCTestCase { XCTAssertEqual(SecretDetector.maskedPrefix("plain text", count: 5), "plain") } - /// A long unbroken run of key-like characters (a pasted base64 image, say) must not - /// send any rule into quadratic backtracking. Correct code takes milliseconds here. + /// Guards against quadratic backtracking on a long run of key-like characters. func testAHugeRunOfKeyLikeCharactersDoesNotHang() { let blob = String(repeating: "aGVsbG8gd29ybGQ", count: 20_000) // 300k characters let started = Date() diff --git a/CopyCore/Tests/CopyCoreTests/SecretRetentionTests.swift b/CopyCore/Tests/CopyCoreTests/SecretRetentionTests.swift index c92ad1a..51dae64 100644 --- a/CopyCore/Tests/CopyCoreTests/SecretRetentionTests.swift +++ b/CopyCore/Tests/CopyCoreTests/SecretRetentionTests.swift @@ -1,8 +1,7 @@ import XCTest @testable import CopyCore -/// `ItemStore.pruneSecrets`: items holding a secret are deleted once they are older than -/// the cutoff. Every key here is fake and assembled from pieces (see `SecretDetectorTests`). +/// `ItemStore.pruneSecrets`. Every key here is fake (see `SecretDetectorTests`). final class SecretRetentionTests: XCTestCase { private let secret = "API_KEY=" + "sk" + "-proj-" + "FAKEfakeFAKEfake1234567890abcd" private let otherSecret = "gh" + "p_" + "abcdefghijklmnopqrstuvwxyz0123456789"