diff options
| author | Christian Cleberg <[email protected]> | 2026-07-16 23:48:10 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-16 23:48:10 -0500 |
| commit | d8195992e519343d6d8ff7fb17aa98358800d913 (patch) | |
| tree | 09ef08c1c06ac55c84299d5fd1251755fe0cac4f | |
| parent | 1edf5bc83c0dcc07246908af341f37e2c871edd7 (diff) | |
| download | domain-dig-4.4.1.tar.gz domain-dig-4.4.1.tar.bz2 domain-dig-4.4.1.zip | |
DomainDig v4.4.1: Consolidate Audit Mode and remove the CLI targetv4.4.1
- Make DomainDig/DomainDig/Audit* the single active Audit Mode implementation
(models, views, exporter) with an Audit tab and session/export UI
- Include audit sessions in backup/restore lifecycle counts, summaries, and
merge behavior via DomainDataPortabilityService
- Remove the DomainDigCLI target, source file, scheme, and all project
references; keep the shared inspection/report pipeline for the app
- Align AppVersion.current to 4.4.1 and refresh README/architecture docs
- Add RELEASE_ROADMAP.md
| -rw-r--r-- | Docs/ARCHITECTURE.md | 101 | ||||
| -rw-r--r-- | DomainDataPortabilityService.swift | 87 | ||||
| -rw-r--r-- | DomainDig.xcodeproj/project.pbxproj | 114 | ||||
| -rw-r--r-- | DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist | 5 | ||||
| -rw-r--r-- | DomainDig/AppVersion.swift | 2 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 24 | ||||
| -rw-r--r-- | DomainDig/DomainDig/AuditExporter.swift | 157 | ||||
| -rw-r--r-- | DomainDig/DomainDig/AuditModels.swift | 279 | ||||
| -rw-r--r-- | DomainDig/DomainDig/AuditViews.swift | 571 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 193 | ||||
| -rw-r--r-- | DomainDig/RootTabView.swift | 18 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 28 | ||||
| -rw-r--r-- | DomainDigCLI.swift | 525 | ||||
| -rw-r--r-- | README.md | 68 | ||||
| -rw-r--r-- | RELEASE_ROADMAP.md | 90 |
15 files changed, 1559 insertions, 703 deletions
diff --git a/Docs/ARCHITECTURE.md b/Docs/ARCHITECTURE.md index 0d364d6..c006b40 100644 --- a/Docs/ARCHITECTURE.md +++ b/Docs/ARCHITECTURE.md @@ -1,48 +1,93 @@ -# DomainDig v3.0.0 Architecture +# DomainDig v4.4.1 Architecture ## Overview -DomainDig is a local-first inspection platform built around one canonical output model: `DomainReport`. +DomainDig is a local-first inspection and audit app built around one canonical output model: `DomainReport`. Inspection flow: -1. `DomainInspectionService.inspect(domain:)` gathers live and cached section data into `LookupSnapshot`. -2. `DomainReportBuilder` converts the snapshot into a canonical `DomainReport`. -3. UI, exports, and CLI rendering derive from `DomainReport`. +1. `LookupRuntime` coordinates the section services that gather DNS, web, TLS, ownership, reachability, redirect, email, port, and enrichment data. +2. `DomainInspectionService` normalizes live and cached results into `LookupSnapshot`. +3. `DomainReportBuilder` converts each snapshot into the canonical `DomainReport`. +4. SwiftUI screens, exports, and the local API render from `DomainReport` or data derived from it. -`LookupSnapshot` remains an internal collection and persistence shape. `DomainReport` is the stable presentation and export contract. +`LookupSnapshot` remains the internal persistence shape for raw inspection state. `DomainReport` is the stable presentation/export contract. -## Canonical Report Lifecycle +## App Layers -- `LookupRuntime` coordinates section services. -- `DomainInspectionService` normalizes failures, provenance, cache state, and section metadata. -- `DomainReportBuilder` adds summaries, insights, risk scoring, change analysis, workflow context, and report metadata. -- `DomainReportExporter` renders TXT, CSV, and JSON from the same report payload. -- `DomainDigCLI` prints exporter output directly so CLI output matches the app. +- Section services: network collection and local normalization only. +- `LookupRuntime`: orchestrates section services for a single inspection. +- `DomainInspectionService`: builds inspection snapshots with provenance, cache state, and failure metadata. +- `DomainReportBuilder`: assembles summaries, insights, risk scoring, workflow context, and report metadata. +- `DomainReportExporter`: renders TXT, CSV, and JSON output for app and local API use. +- `DomainViewModel`: coordinates SwiftUI state, persistence, audit sessions, monitoring, workflows, batch operations, imports, and exports. +- SwiftUI views: render screens and invoke view-model actions. + +## Audit Mode + +The app has one active Audit Mode implementation: + +- Models live in `DomainDig/DomainDig/AuditModels.swift`. +- UI lives in `DomainDig/DomainDig/AuditViews.swift`. +- Export rendering lives in `DomainDig/DomainDig/AuditExporter.swift`. +- Persistence is owned by `DomainViewModel` through `DomainDataPortabilityService`. + +An audit session captures: + +- Domain and reviewer metadata +- Session status +- Point-in-time `HistoryEntry` and `DomainReport` +- Historical snapshot context +- Evidence asset references +- Checklist progress +- Findings with severity, status, evidence references, notes, and checklist areas +- Reviewer notes + +Audit sessions are stored under the same local portability service as the rest of app data and are included in full backup/restore flows. + +The older standalone prototype files, `DomainDig/AuditMode.swift` and `DomainDig/AuditModeView.swift`, are preserved in the repository for reference but excluded from synchronized target membership. They are not the release audit path. + +## Data Portability + +`DomainDataPortabilityService` owns backup, import, validation, lifecycle counts, and merge/replace behavior for: + +- Tracked domains +- History snapshots +- Audit sessions +- Workflows +- Monitoring settings and logs +- App settings +- Local feature metadata + +Backup imports support merge and replace modes. Merge mode deduplicates by stable IDs or normalized domain keys, keeps local data where appropriate, and merges audit-session reviewer notes when the same audit session appears in multiple backups. ## Feature Tiers -The app now uses `FeatureAccessService` as the single feature gating surface. +`FeatureAccessService`, `PremiumAccessService`, `PurchaseService`, and `UsageCreditService` provide the app's feature-gating surfaces. + +The app remains local-first. Purchase and entitlement code is local app infrastructure and does not introduce a hosted DomainDig backend. + +## Local API + +`LocalAPIService` is an automation surface over the same inspection/reporting pipeline: -- `Free`: single lookup, basic history, limited tracking -- `Pro`: workflows, batch operations, advanced exports -- `Data+`: future historical datasets and extended enrichment +- `DomainInspectionService` +- `DomainReportBuilder` +- `DomainReportExporter` +- `LocalAPIModels` -Current release behavior is static scaffolding only. There are no purchases, backend checks, or remote entitlements. +The major-version roadmap calls for a stronger compatibility promise around this local API contract in `v5.0.0`. -## Data Boundaries +## Xcode Project Structure -- Inspection services: network collection only -- `DomainReportBuilder`: canonical model assembly -- `FeatureAccessService`: tier and capability checks -- `DomainViewModel`: UI orchestration, persistence, batch coordination -- Views: rendering and interaction only +`DomainDig.xcodeproj` uses filesystem-synchronized groups for the `DomainDig` folder. Target membership exclusions are therefore important release metadata. Files that should remain in the tree but not compile, such as retired prototypes, must be listed in the appropriate synchronized build file exception set. -## Adding a New Data Source +## Adding A New Data Source -1. Add the raw collection call to `LookupRuntime`. +1. Add the raw collection call to `LookupRuntime` or an existing section service. 2. Integrate it in `DomainInspectionService` with provenance, cache source, and normalized failures. 3. Extend `LookupSnapshot` only if the raw result must persist. -4. Add the summarized representation to `DomainReportBuilder`. -5. Expose it through `DomainReportExporter` if it should appear in TXT, CSV, JSON, or CLI. -6. Render the new summary in SwiftUI using `DomainReport` fields. +4. Add summarized representation to `DomainReportBuilder`. +5. Expose it through `DomainReportExporter` or `LocalAPIModels` when it is part of the external contract. +6. Render it in SwiftUI from `DomainReport` fields or view-model state. +7. Update backup/restore only when the data is user-authored state or long-lived app state. diff --git a/DomainDataPortabilityService.swift b/DomainDataPortabilityService.swift index ab0bc0b..b6f9eee 100644 --- a/DomainDataPortabilityService.swift +++ b/DomainDataPortabilityService.swift @@ -9,6 +9,7 @@ struct DomainDigBackup: Codable { let appVersion: String let trackedDomains: [TrackedDomain] let historyEntries: [HistoryEntry] + let auditSessions: [AuditSession]? let workflows: [DomainWorkflow] let monitoringSettings: MonitoringSettings? let monitoringLogs: [MonitoringLog] @@ -88,6 +89,7 @@ enum DataPortabilityImportKind: String { struct DataLifecycleSummary: Equatable { let trackedDomains: Int let historySnapshots: Int + let auditSessions: Int let workflows: Int let cachedItems: Int let monitoringLogs: Int @@ -352,6 +354,9 @@ enum DataMigrationService { let historyEntries = DomainDataPortabilityService.loadHistoryEntries(defaults: defaults) DomainDataPortabilityService.saveHistoryEntries(historyEntries, defaults: defaults) + let auditSessions = DomainDataPortabilityService.loadAuditSessions(defaults: defaults) + DomainDataPortabilityService.saveAuditSessions(auditSessions, defaults: defaults) + let workflows = DomainDataPortabilityService.loadWorkflows(defaults: defaults) DomainDataPortabilityService.saveWorkflows(workflows, defaults: defaults) @@ -378,6 +383,7 @@ enum DataValidationService { messages.append(contentsOf: validateTrackedDomains(backup.trackedDomains)) messages.append(contentsOf: validateHistoryEntries(backup.historyEntries)) + messages.append(contentsOf: validateAuditSessions(backup.auditSessions ?? [])) messages.append(contentsOf: validateWorkflows(backup.workflows)) if backup.appSettings.resolverURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -430,6 +436,23 @@ enum DataValidationService { return messages } + private static func validateAuditSessions(_ auditSessions: [AuditSession]) -> [DataValidationMessage] { + var messages: [DataValidationMessage] = [] + var seen = Set<UUID>() + + for session in auditSessions { + if normalizeDomain(session.domain).isEmpty { + messages.append(.init(text: "An audit session is missing its domain name.", isError: true)) + } + + if !seen.insert(session.id).inserted { + messages.append(.init(text: "Duplicate audit session found for \(session.domain). Merge rules will consolidate it.", isError: false)) + } + } + + return messages + } + private static func validateWorkflows(_ workflows: [DomainWorkflow]) -> [DataValidationMessage] { var messages: [DataValidationMessage] = [] for workflow in workflows { @@ -479,6 +502,7 @@ enum DomainDataPortabilityService { static let trackedDomains = "trackedDomains" static let legacyWatchedDomains = "watchedDomains" static let history = "lookupHistory" + static let audits = "domainAudits" static let workflows = "domainWorkflows" static let monitoringSettings = "monitoring.settings" static let monitoringLogs = "monitoring.logs" @@ -565,6 +589,21 @@ enum DomainDataPortabilityService { } } + static func loadAuditSessions(defaults: UserDefaults = .standard) -> [AuditSession] { + guard let data = defaults.data(forKey: StorageKey.audits), + let sessions = try? JSONDecoder().decode([AuditSession].self, from: data) else { + return [] + } + return sessions.sorted { $0.createdAt > $1.createdAt } + } + + static func saveAuditSessions(_ auditSessions: [AuditSession], defaults: UserDefaults = .standard) { + let sessions = auditSessions.sorted { $0.createdAt > $1.createdAt } + if let data = try? JSONEncoder().encode(sessions) { + defaults.set(data, forKey: StorageKey.audits) + } + } + static func loadWorkflows(defaults: UserDefaults = .standard) -> [DomainWorkflow] { guard let data = defaults.data(forKey: StorageKey.workflows), let workflows = try? JSONDecoder().decode([DomainWorkflow].self, from: data) else { @@ -662,6 +701,7 @@ enum DomainDataPortabilityService { appVersion: AppVersion.current, trackedDomains: loadTrackedDomains(defaults: defaults), historyEntries: loadHistoryEntries(defaults: defaults), + auditSessions: loadAuditSessions(defaults: defaults), workflows: loadWorkflows(defaults: defaults), monitoringSettings: loadMonitoringSettings(defaults: defaults), monitoringLogs: loadMonitoringLogs(defaults: defaults), @@ -750,7 +790,7 @@ enum DomainDataPortabilityService { return DataImportResult( kind: .backup, mode: mode, - summary: "Imported backup with \(backup.trackedDomains.count) tracked domains, \(backup.historyEntries.count) history snapshots, and \(backup.workflows.count) workflows.", + summary: "Imported backup with \(backup.trackedDomains.count) tracked domains, \(backup.historyEntries.count) history snapshots, \(backup.auditSessions?.count ?? 0) audit sessions, and \(backup.workflows.count) workflows.", warnings: report.warnings ) case .trackedDomains(let trackedDomains, let report): @@ -794,6 +834,7 @@ enum DomainDataPortabilityService { return DataLifecycleSummary( trackedDomains: loadTrackedDomains(defaults: defaults).count, historySnapshots: loadHistoryEntries(defaults: defaults).count, + auditSessions: loadAuditSessions(defaults: defaults).count, workflows: loadWorkflows(defaults: defaults).count, cachedItems: cachedItems, monitoringLogs: loadMonitoringLogs(defaults: defaults).count @@ -897,6 +938,7 @@ enum DomainDataPortabilityService { appVersion: AppVersion.current, trackedDomains: legacy.trackedDomains ?? [], historyEntries: legacy.historyEntries ?? [], + auditSessions: nil, workflows: legacy.workflows ?? [], monitoringSettings: legacy.monitoringSettings, monitoringLogs: legacy.monitoringLogs ?? [], @@ -918,6 +960,7 @@ enum DomainDataPortabilityService { if mode == .replace { saveTrackedDomains(deduplicatedTrackedDomains(backup.trackedDomains), defaults: defaults) saveHistoryEntries(deduplicatedHistoryEntries(backup.historyEntries), defaults: defaults) + saveAuditSessions(backup.auditSessions ?? [], defaults: defaults) saveWorkflows(deduplicatedWorkflows(backup.workflows), defaults: defaults) saveMonitoringSettings( MonitoringStorage.sanitizeSettings( @@ -943,6 +986,10 @@ enum DomainDataPortabilityService { defaults: defaults ) + let existingAuditSessions = loadAuditSessions(defaults: defaults) + let mergedAuditSessions = mergeAuditSessions(existing: existingAuditSessions, incoming: backup.auditSessions ?? []) + saveAuditSessions(mergedAuditSessions, defaults: defaults) + let existingWorkflows = loadWorkflows(defaults: defaults) saveWorkflows(mergeWorkflows(existing: existingWorkflows, incoming: backup.workflows), defaults: defaults) @@ -1012,6 +1059,7 @@ enum DomainDataPortabilityService { return DataLifecycleSummary( trackedDomains: backup.trackedDomains.count, historySnapshots: backup.historyEntries.count, + auditSessions: backup.auditSessions?.count ?? 0, workflows: backup.workflows.count, cachedItems: backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count + ((backup.featureMetadata?.cachedEntitlement == nil ? 0 : 1) + (backup.featureMetadata?.usageCredits == nil ? 0 : 1)), monitoringLogs: backup.monitoringLogs.count @@ -1021,6 +1069,7 @@ enum DomainDataPortabilityService { return DataLifecycleSummary( trackedDomains: mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: backup.trackedDomains).domains.count, historySnapshots: mergeHistoryEntries(existing: loadHistoryEntries(defaults: defaults), incoming: backup.historyEntries).count, + auditSessions: mergeAuditSessions(existing: loadAuditSessions(defaults: defaults), incoming: backup.auditSessions ?? []).count, workflows: mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: backup.workflows).count, cachedItems: max(current.cachedItems, backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count), monitoringLogs: mergeMonitoringLogs(existing: loadMonitoringLogs(defaults: defaults), incoming: backup.monitoringLogs).count @@ -1032,6 +1081,7 @@ enum DomainDataPortabilityService { return DataLifecycleSummary( trackedDomains: total, historySnapshots: current.historySnapshots, + auditSessions: current.auditSessions, workflows: current.workflows, cachedItems: current.cachedItems, monitoringLogs: current.monitoringLogs @@ -1043,6 +1093,7 @@ enum DomainDataPortabilityService { return DataLifecycleSummary( trackedDomains: current.trackedDomains, historySnapshots: current.historySnapshots, + auditSessions: current.auditSessions, workflows: total, cachedItems: current.cachedItems, monitoringLogs: current.monitoringLogs @@ -1132,6 +1183,39 @@ enum DomainDataPortabilityService { } } + private static func mergeAuditSessions(existing: [AuditSession], incoming: [AuditSession]) -> [AuditSession] { + var merged = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, $0) }) + + for session in incoming { + if let existingSession = merged[session.id] { + let winner = existingSession.findings.count >= session.findings.count ? existingSession : session + let candidateNotes = [existingSession.notes, session.notes] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + let notes = candidateNotes.reduce(into: [String]()) { result, note in + if !result.contains(note) { + result.append(note) + } + }.joined(separator: "\n\n") + merged[session.id] = AuditSession( + id: existingSession.id, + domain: existingSession.domain, + createdAt: min(existingSession.createdAt, session.createdAt), + reviewer: winner.reviewer, + status: winner.status, + evidence: winner.evidence, + findings: winner.findings, + notes: notes, + checklist: winner.checklist + ) + } else { + merged[session.id] = session + } + } + + return merged.values.sorted { $0.createdAt > $1.createdAt } + } + private static func mergeWorkflows(existing: [DomainWorkflow], incoming: [DomainWorkflow]) -> [DomainWorkflow] { var merged = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, normalizedWorkflow($0)) }) @@ -1394,6 +1478,7 @@ private enum ImportPayload { "Full backup import", "\(backup.trackedDomains.count) tracked domains", "\(backup.historyEntries.count) history snapshots", + "\(backup.auditSessions?.count ?? 0) audit sessions", "\(backup.workflows.count) workflows", "\(backup.monitoringLogs.count) monitoring logs", mode == .replace ? "Replace mode will overwrite local backupable data." : "Merge mode will keep local data and consolidate duplicates." diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj index 29813b0..1a97a4a 100644 --- a/DomainDig.xcodeproj/project.pbxproj +++ b/DomainDig.xcodeproj/project.pbxproj @@ -7,33 +7,23 @@ objects = { /* Begin PBXBuildFile section */ - 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */; }; 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; }; 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; }; 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; }; 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; }; - 8BBFEFCA2F987E8700E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; }; - 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; }; - 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; }; - 8BBFEFCD2F987E8700E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; }; - 8BCA3CBE2F9C8D57004B742C /* LocalAPIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */; }; - 8BCA3CBF2F9C8D57004B742C /* LocalAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */; }; 8BCA3CC02F9C8D57004B742C /* LocalAPIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */; }; 8BCA3CC12F9C8D57004B742C /* LocalAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */; }; - 8BF9DA862F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; }; 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDigCLI.swift; sourceTree = "<group>"; }; 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; }; 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportBuilder.swift; sourceTree = "<group>"; }; 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; }; 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; }; 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIModels.swift; sourceTree = "<group>"; }; 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIService.swift; sourceTree = "<group>"; }; - 8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; }; 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ @@ -41,27 +31,12 @@ 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - DomainDigCLI.swift, + AuditMode.swift, + AuditModeView.swift, Info.plist, ); target = 8B7800682F6090E300933221 /* DomainDig */; }; - 8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - BatchResultsView.swift, - BatchSweepSummaryView.swift, - ContentView.swift, - DomainDigApp.swift, - DomainViewModel.swift, - ExportPresenter.swift, - HistoryView.swift, - LocalNotificationService.swift, - SavedDomainsView.swift, - WatchlistView.swift, - ); - target = 8BF124FB2F70000100933221 /* DomainDigCLI */; - }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -69,7 +44,6 @@ isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */, - 8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */, ); path = DomainDig; sourceTree = "<group>"; @@ -84,13 +58,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 8BF124FD2F70000100933221 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -103,7 +70,6 @@ 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */, 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */, 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */, - 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */, 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */, 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */, 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */, @@ -114,7 +80,6 @@ isa = PBXGroup; children = ( 8B7800692F6090E300933221 /* DomainDig.app */, - 8BF124F92F70000100933221 /* domaindig */, ); name = Products; sourceTree = "<group>"; @@ -144,27 +109,6 @@ productReference = 8B7800692F6090E300933221 /* DomainDig.app */; productType = "com.apple.product-type.application"; }; - 8BF124FB2F70000100933221 /* DomainDigCLI */ = { - isa = PBXNativeTarget; - buildConfigurationList = 8BBFF0292F987EF700E8E144 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */; - buildPhases = ( - 8BF124FC2F70000100933221 /* Sources */, - 8BF124FD2F70000100933221 /* Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - 8B78006B2F6090E300933221 /* DomainDig */, - ); - name = DomainDigCLI; - packageProductDependencies = ( - ); - productName = domaindig; - productReference = 8BF124F92F70000100933221 /* domaindig */; - productType = "com.apple.product-type.tool"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -200,7 +144,6 @@ projectRoot = ""; targets = ( 8B7800682F6090E300933221 /* DomainDig */, - 8BF124FB2F70000100933221 /* DomainDigCLI */, ); }; /* End PBXProject section */ @@ -230,21 +173,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 8BF124FC2F70000100933221 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */, - 8BF9DA862F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */, - 8BBFEFCA2F987E8700E8E144 /* DomainInspectionService.swift in Sources */, - 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */, - 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */, - 8BBFEFCD2F987E8700E8E144 /* LookupSnapshot.swift in Sources */, - 8BCA3CBE2F9C8D57004B742C /* LocalAPIModels.swift in Sources */, - 8BCA3CBF2F9C8D57004B742C /* LocalAPIService.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ @@ -378,7 +306,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 36; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -395,7 +323,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.3.0; + MARKETING_VERSION = 4.4.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -415,7 +343,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 36; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -432,7 +360,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.3.0; + MARKETING_VERSION = 4.4.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -445,27 +373,6 @@ }; name = Release; }; - 8BBFF0272F987E8700E8E144 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - COPY_PHASE_STRIP = NO; - GCC_DYNAMIC_NO_PIC = NO; - GCC_OPTIMIZATION_LEVEL = 0; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; - PRODUCT_NAME = domaindig; - }; - name = Debug; - }; - 8BBFF0282F987E8700E8E144 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - COPY_PHASE_STRIP = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; - PRODUCT_NAME = domaindig; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -487,15 +394,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 8BBFF0292F987EF700E8E144 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 8BBFF0272F987E8700E8E144 /* Debug */, - 8BBFF0282F987E8700E8E144 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ }; rootObject = 8B7800612F6090E300933221 /* Project object */; diff --git a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist index fbb9f70..8fd771d 100644 --- a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,11 +9,6 @@ <key>orderHint</key> <integer>0</integer> </dict> - <key>DomainDigCLI.xcscheme_^#shared#^_</key> - <dict> - <key>orderHint</key> - <integer>1</integer> - </dict> </dict> <key>SuppressBuildableAutocreation</key> <dict> diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift index cc675b0..07d3664 100644 --- a/DomainDig/AppVersion.swift +++ b/DomainDig/AppVersion.swift @@ -2,6 +2,6 @@ import Foundation enum AppVersion { nonisolated static var current: String { - "3.5.0" + "4.4.1" } } diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index 5a469e1..30d693e 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -44,6 +44,8 @@ struct ContentView: View { @State private var showingCurrentDomainWorkflowSheet = false @State private var showingBatchWorkflowSheet = false @State private var showingTimeline = false + @State private var showingAuditTimeline = false + @State private var auditStartInFlight = false var body: some View { let _ = purchaseService.currentTier @@ -274,6 +276,11 @@ struct ContentView: View { TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain) } } + .sheet(isPresented: $showingAuditTimeline) { + NavigationStack { + AuditDomainTimelineView(viewModel: viewModel, domain: viewModel.searchedDomain) + } + } } private var manualBatchSummaryBinding: Binding<BatchSweepSummary?> { @@ -508,6 +515,21 @@ struct ContentView: View { Button("Add to workflow") { showingCurrentDomainWorkflowSheet = true } + Button(auditStartInFlight ? "Starting audit…" : "Start audit") { + Task { + auditStartInFlight = true + if await viewModel.startAudit(for: viewModel.searchedDomain) != nil { + showingAuditTimeline = true + } + auditStartInFlight = false + } + } + .disabled(auditStartInFlight) + if !viewModel.audits(for: viewModel.searchedDomain).isEmpty { + Button("View audits") { + showingAuditTimeline = true + } + } if !viewModel.historyEntries(for: viewModel.searchedDomain).isEmpty { Button("Open timeline") { showingTimeline = true @@ -3301,6 +3323,7 @@ private struct DataPortabilitySettingsView: View { Section("Local Data") { LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)") LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)") + LabeledContent("Audit Sessions", value: "\(viewModel.dataLifecycleSummary.auditSessions)") LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)") LabeledContent("Cached Items", value: "\(viewModel.dataLifecycleSummary.cachedItems)") LabeledContent("Monitoring Logs", value: "\(viewModel.dataLifecycleSummary.monitoringLogs)") @@ -3702,6 +3725,7 @@ private struct DataImportPreviewSheet: View { Section("Projected Counts") { LabeledContent("Tracked Domains", value: "\(preview.projectedCounts.trackedDomains)") LabeledContent("History Snapshots", value: "\(preview.projectedCounts.historySnapshots)") + LabeledContent("Audit Sessions", value: "\(preview.projectedCounts.auditSessions)") LabeledContent("Workflows", value: "\(preview.projectedCounts.workflows)") LabeledContent("Cached Items", value: "\(preview.projectedCounts.cachedItems)") LabeledContent("Monitoring Logs", value: "\(preview.projectedCounts.monitoringLogs)") diff --git a/DomainDig/DomainDig/AuditExporter.swift b/DomainDig/DomainDig/AuditExporter.swift new file mode 100644 index 0000000..0d9a098 --- /dev/null +++ b/DomainDig/DomainDig/AuditExporter.swift @@ -0,0 +1,157 @@ +import Foundation + +#if canImport(UIKit) +import UIKit +#endif + +enum AuditExportFormat: String, CaseIterable, Identifiable { + case pdf + case json + case markdown + + var id: String { rawValue } + + var fileExtension: String { rawValue == "markdown" ? "md" : rawValue } +} + +enum AuditExporter { + static func data(for session: AuditSession, format: AuditExportFormat) throws -> Data { + switch format { + case .json: + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return try encoder.encode(session) + case .markdown: + return Data(markdown(for: session).utf8) + case .pdf: + return try pdfData(for: session) + } + } + + static func markdown(for session: AuditSession) -> String { + var lines: [String] = [ + "# DomainDig Audit", + "", + "- Domain: \(session.domain)", + "- Review Date: \(session.createdAt.formatted(date: .abbreviated, time: .shortened))", + "- Reviewer: \(session.reviewer)", + "- Status: \(session.status.title)", + "- Evidence Captured: \(session.evidence.capturedAt.formatted(date: .abbreviated, time: .shortened))", + "- Checklist Progress: \(session.completedChecklistCount)/\(session.checklist.count)", + "" + ] + + if !session.notes.isEmpty { + lines.append("## Summary Notes") + lines.append(session.notes) + lines.append("") + } + + lines.append("## Findings Summary") + if session.findings.isEmpty { + lines.append("No findings recorded.") + } else { + for finding in session.findings { + lines.append("- [\(finding.severity.title)] \(finding.title) (\(finding.status.title))") + } + } + lines.append("") + + lines.append("## Key Risks") + if session.keyRisks.isEmpty { + lines.append("No medium or high-severity risks recorded.") + } else { + for finding in session.keyRisks { + lines.append("- \(finding.title): \(finding.summary)") + } + } + lines.append("") + + lines.append("## Checklist") + for item in session.checklist { + lines.append("- [\(item.isComplete ? "x" : " ")] \(item.title): \(item.detail)") + } + lines.append("") + + lines.append("## Findings") + if session.findings.isEmpty { + lines.append("No findings recorded.") + } else { + for finding in session.findings { + lines.append("### \(finding.title)") + lines.append("- Severity: \(finding.severity.title)") + lines.append("- Status: \(finding.status.title)") + if !finding.checklistAreas.isEmpty { + lines.append("- Checklist Areas: \(finding.checklistAreas.map(\.title).joined(separator: ", "))") + } + lines.append("- Summary: \(finding.summary)") + if !finding.evidenceReferences.isEmpty { + lines.append("- Evidence: \(finding.evidenceReferences.joined(separator: " | "))") + } + if !finding.notes.isEmpty { + lines.append("- Notes: \(finding.notes)") + } + lines.append("") + } + } + + lines.append("## Evidence Snapshot") + lines.append("- Availability: \(availabilityTitle(session.evidence.report.availability))") + lines.append("- Risk Score: \(session.evidence.report.riskAssessment.score) (\(session.evidence.report.riskAssessment.level.title))") + lines.append("- TLS Status: \(session.evidence.report.web.tlsStatus)") + lines.append("- Final URL: \(session.evidence.report.web.finalURL ?? "Unavailable")") + lines.append("- Primary IP: \(session.evidence.report.dns.primaryIP ?? "Unavailable")") + lines.append("- Reachability: \(session.evidence.report.network.reachabilitySummary)") + lines.append("- Ownership: \(session.evidence.report.ownership?.registrar ?? "Unavailable")") + lines.append("- Historical Context Snapshots: \(session.evidence.historicalContext.count)") + + return lines.joined(separator: "\n") + } + + private static func availabilityTitle(_ status: DomainAvailabilityStatus) -> String { + switch status { + case .available: + return "Available" + case .registered: + return "Registered" + case .unknown: + return "Unknown" + } + } + + private static func pdfData(for session: AuditSession) throws -> Data { + let markdown = markdown(for: session) + #if canImport(UIKit) + let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 612, height: 792)) + return renderer.pdfData { context in + let lines = markdown.components(separatedBy: .newlines) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineBreakMode = .byWordWrapping + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.monospacedSystemFont(ofSize: 11, weight: .regular), + .paragraphStyle: paragraphStyle + ] + + var yOffset: CGFloat = 36 + context.beginPage() + + for line in lines { + if yOffset > 744 { + context.beginPage() + yOffset = 36 + } + + let renderedLine = NSString(string: line.isEmpty ? " " : line) + renderedLine.draw( + in: CGRect(x: 36, y: yOffset, width: 540, height: 22), + withAttributes: attributes + ) + yOffset += 16 + } + } + #else + return Data(markdown.utf8) + #endif + } +} diff --git a/DomainDig/DomainDig/AuditModels.swift b/DomainDig/DomainDig/AuditModels.swift new file mode 100644 index 0000000..28eb9c9 --- /dev/null +++ b/DomainDig/DomainDig/AuditModels.swift @@ -0,0 +1,279 @@ +import Foundation + +enum AuditStatus: String, Codable, Sendable, CaseIterable, Identifiable { + case draft + case inReview = "in_review" + case complete + + var id: String { rawValue } + + var title: String { + switch self { + case .draft: + return "Draft" + case .inReview: + return "In Review" + case .complete: + return "Complete" + } + } +} + +enum AuditFindingSeverity: String, Codable, Sendable, CaseIterable, Identifiable { + case informational + case low + case medium + case high + + var id: String { rawValue } + + var title: String { rawValue.capitalized } +} + +enum AuditFindingStatus: String, Codable, Sendable, CaseIterable, Identifiable { + case open + case reviewing + case resolved + + var id: String { rawValue } + + var title: String { rawValue.capitalized } +} + +enum AuditChecklistArea: String, Codable, Sendable, CaseIterable, Identifiable { + case dnsReview + case certificateReview + case redirectReview + case headerReview + case ownershipReview + case infrastructureReview + case monitoringHistoryReview + + var id: String { rawValue } + + var title: String { + switch self { + case .dnsReview: + return "DNS Review" + case .certificateReview: + return "Certificate Review" + case .redirectReview: + return "Redirect Review" + case .headerReview: + return "Header Review" + case .ownershipReview: + return "Ownership Review" + case .infrastructureReview: + return "Infrastructure Review" + case .monitoringHistoryReview: + return "Monitoring History Review" + } + } + + var prompt: String { + switch self { + case .dnsReview: + return "Validate authoritative records, nameservers, and DNSSEC posture." + case .certificateReview: + return "Review certificate validity, expiry, issuer, and transport security." + case .redirectReview: + return "Confirm redirect targets, hop count, and protocol transitions." + case .headerReview: + return "Check security headers and HTTP response posture." + case .ownershipReview: + return "Review registration, registrar, and ownership evidence." + case .infrastructureReview: + return "Assess reachability, exposed services, and network context." + case .monitoringHistoryReview: + return "Compare this audit with prior snapshots and repeated issues." + } + } +} + +struct AuditChecklistItem: Identifiable, Codable, Sendable, Equatable { + let id: UUID + let area: AuditChecklistArea + var title: String + var detail: String + var isComplete: Bool + var completedAt: Date? + + init( + id: UUID = UUID(), + area: AuditChecklistArea, + title: String, + detail: String, + isComplete: Bool = false, + completedAt: Date? = nil + ) { + self.id = id + self.area = area + self.title = title + self.detail = detail + self.isComplete = isComplete + self.completedAt = completedAt + } +} + +enum AuditEvidenceAssetKind: String, Codable, Sendable { + case screenshot + case document + case note +} + +struct AuditEvidenceAsset: Identifiable, Codable, Sendable, Equatable { + let id: UUID + var title: String + var kind: AuditEvidenceAssetKind + var reference: String + + init(id: UUID = UUID(), title: String, kind: AuditEvidenceAssetKind, reference: String) { + self.id = id + self.title = title + self.kind = kind + self.reference = reference + } +} + +struct AuditEvidenceSnapshot: Codable { + var capturedAt: Date + var lookup: HistoryEntry + var report: DomainReport + var historicalContext: [SnapshotSummary] + var screenshots: [AuditEvidenceAsset] +} + +struct AuditFinding: Identifiable, Codable, Sendable, Equatable { + var id: UUID + var title: String + var severity: AuditFindingSeverity + var summary: String + var evidenceReferences: [String] + var notes: String + var status: AuditFindingStatus + var checklistAreas: [AuditChecklistArea] + var createdAt: Date + var updatedAt: Date + + init( + id: UUID = UUID(), + title: String, + severity: AuditFindingSeverity, + summary: String, + evidenceReferences: [String], + notes: String, + status: AuditFindingStatus = .open, + checklistAreas: [AuditChecklistArea] = [], + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.title = title + self.severity = severity + self.summary = summary + self.evidenceReferences = evidenceReferences + self.notes = notes + self.status = status + self.checklistAreas = checklistAreas + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +struct AuditSession: Identifiable, Codable { + var id: UUID + var domain: String + var createdAt: Date + var reviewer: String + var status: AuditStatus + var evidence: AuditEvidenceSnapshot + var findings: [AuditFinding] + var notes: String + var checklist: [AuditChecklistItem] + + init( + id: UUID = UUID(), + domain: String, + createdAt: Date = Date(), + reviewer: String, + status: AuditStatus, + evidence: AuditEvidenceSnapshot, + findings: [AuditFinding] = [], + notes: String = "", + checklist: [AuditChecklistItem] = AuditChecklistArea.defaultItems + ) { + self.id = id + self.domain = domain + self.createdAt = createdAt + self.reviewer = reviewer + self.status = status + self.evidence = evidence + self.findings = findings + self.notes = notes + self.checklist = checklist + } + + var completedChecklistCount: Int { + checklist.filter(\.isComplete).count + } + + var checklistProgress: Double { + guard !checklist.isEmpty else { return 0 } + return Double(completedChecklistCount) / Double(checklist.count) + } + + var highestSeverity: AuditFindingSeverity? { + findings.max { lhs, rhs in + lhs.severity.sortOrder < rhs.severity.sortOrder + }?.severity + } + + var keyRisks: [AuditFinding] { + findings + .filter { $0.severity == .high || $0.severity == .medium } + .sorted { lhs, rhs in + if lhs.severity.sortOrder == rhs.severity.sortOrder { + return lhs.updatedAt > rhs.updatedAt + } + return lhs.severity.sortOrder > rhs.severity.sortOrder + } + } +} + +extension AuditFindingSeverity { + var sortOrder: Int { + switch self { + case .informational: + return 0 + case .low: + return 1 + case .medium: + return 2 + case .high: + return 3 + } + } +} + +extension AuditChecklistArea { + static var defaultItems: [AuditChecklistItem] { + allCases.map { area in + AuditChecklistItem( + area: area, + title: area.title, + detail: area.prompt + ) + } + } +} + +struct AuditTimelinePoint: Identifiable, Equatable { + let id: UUID + let sessionID: UUID + let domain: String + let createdAt: Date + let status: AuditStatus + let findingCount: Int + let openHighSeverityCount: Int + let repeatedIssueCount: Int +} diff --git a/DomainDig/DomainDig/AuditViews.swift b/DomainDig/DomainDig/AuditViews.swift new file mode 100644 index 0000000..8602bb3 --- /dev/null +++ b/DomainDig/DomainDig/AuditViews.swift @@ -0,0 +1,571 @@ +import SwiftUI + +struct AuditListView: View { + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + @State private var auditStartInFlight = false + + private var groupedSessions: [(domain: String, sessions: [AuditSession])] { + Dictionary(grouping: viewModel.auditSessions) { $0.domain.lowercased() } + .values + .map { sessions in + let sorted = sessions.sorted { $0.createdAt > $1.createdAt } + return (domain: sorted.first?.domain ?? "unknown", sessions: sorted) + } + .sorted { $0.domain < $1.domain } + } + + var body: some View { + List { + if groupedSessions.isEmpty { + Section { + EmptyStateCardView( + title: "No Audits Yet", + message: "Audit Mode captures evidence, findings, checklist progress, and point-in-time review output for each domain assessment.", + suggestion: "Open Inspect for a domain and start an audit to create your first review session.", + systemImage: "checklist.unchecked", + showsCardBackground: false + ) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } else { + Section("Audit Domains") { + ForEach(groupedSessions, id: \.domain) { group in + NavigationLink { + AuditDomainTimelineView(viewModel: viewModel, domain: group.domain) + } label: { + VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 2) { + HStack { + Text(group.domain) + .font(appDensity.font(.callout, design: .default, weight: .semibold)) + Spacer() + Text("\(group.sessions.count) audit\(group.sessions.count == 1 ? "" : "s")") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + + if let latest = group.sessions.first { + Text(latest.findings.isEmpty ? "No findings recorded yet" : latest.findings.map(\.title).prefix(2).joined(separator: " • ")) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + .lineLimit(2) + + HStack(spacing: 8) { + auditStatusBadge(latest.status) + if let severity = latest.highestSeverity { + findingSeverityBadge(severity) + } + Text("Checklist \(latest.completedChecklistCount)/\(latest.checklist.count)") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 4) + } + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle("Audit Mode") + .toolbar { + if !viewModel.searchedDomain.isEmpty { + ToolbarItem(placement: .topBarTrailing) { + Button(auditStartInFlight ? "Auditing…" : "Start Audit") { + Task { + auditStartInFlight = true + _ = await viewModel.startAudit(for: viewModel.searchedDomain) + auditStartInFlight = false + } + } + .disabled(auditStartInFlight) + } + } + } + .preferredColorScheme(.dark) + } +} + +struct AuditDomainTimelineView: View { + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + let domain: String + @State private var auditStartInFlight = false + + private var sessions: [AuditSession] { + viewModel.audits(for: domain) + } + + var body: some View { + List { + Section("Sessions") { + ForEach(sessions) { session in + NavigationLink { + AuditSessionDetailView(viewModel: viewModel, sessionID: session.id) + } label: { + VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 2) { + HStack { + Text(session.createdAt.formatted(date: .abbreviated, time: .shortened)) + .font(appDensity.font(.callout)) + Spacer() + auditStatusBadge(session.status) + } + + Text("Reviewer: \(session.reviewer)") + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + Text("\(session.findings.count) findings") + Text("Checklist \(session.completedChecklistCount)/\(session.checklist.count)") + if let severity = session.highestSeverity { + Text("Top \(severity.title)") + } + } + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + Section("Trend") { + ForEach(viewModel.auditTimeline(for: domain)) { point in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(point.createdAt.formatted(date: .abbreviated, time: .shortened)) + .font(appDensity.font(.caption, weight: .semibold)) + Spacer() + Text("\(point.findingCount) findings") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + Text("Open high severity: \(point.openHighSeverityCount) • Repeated issues: \(point.repeatedIssueCount)") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle(domain) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(auditStartInFlight ? "Auditing…" : "New Audit") { + Task { + auditStartInFlight = true + _ = await viewModel.startAudit(for: domain) + auditStartInFlight = false + } + } + .disabled(auditStartInFlight) + } + } + } +} + +struct AuditSessionDetailView: View { + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + let sessionID: UUID + + @State private var notesDraft = "" + @State private var showingFindingEditor = false + @State private var editingFinding: AuditFinding? + + private var session: AuditSession? { + viewModel.auditSession(withID: sessionID) + } + + var body: some View { + Group { + if let session { + List { + Section { + VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(session.domain) + .font(appDensity.font(.headline, design: .default, weight: .semibold)) + Text("Reviewer: \(session.reviewer)") + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } + Spacer() + auditStatusBadge(session.status) + } + + Picker("Status", selection: Binding( + get: { session.status }, + set: { viewModel.updateAuditStatus($0, sessionID: session.id) } + )) { + ForEach(AuditStatus.allCases) { status in + Text(status.title).tag(status) + } + } + .pickerStyle(.segmented) + + Text("Captured \(session.evidence.capturedAt.formatted(date: .abbreviated, time: .shortened))") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + Section("Audit Summary") { + LabeledContent("Findings", value: "\(session.findings.count)") + LabeledContent("Checklist", value: "\(session.completedChecklistCount)/\(session.checklist.count)") + LabeledContent("Risk Score", value: "\(session.evidence.report.riskAssessment.score)") + if let severity = session.highestSeverity { + LabeledContent("Highest Severity", value: severity.title) + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + Section("Reviewer Notes") { + TextEditor(text: $notesDraft) + .frame(minHeight: 120) + .scrollContentBackground(.hidden) + .background(Color.clear) + + Button("Save Notes") { + viewModel.updateAuditNotes(notesDraft, sessionID: session.id) + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + Section("Checklist") { + ForEach(session.checklist) { item in + Button { + viewModel.toggleAuditChecklistItem(sessionID: session.id, itemID: item.id) + } label: { + HStack(alignment: .top, spacing: 10) { + Image(systemName: item.isComplete ? "checkmark.circle.fill" : "circle") + .foregroundStyle(item.isComplete ? Color.green : .secondary) + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .font(appDensity.font(.callout)) + .foregroundStyle(.primary) + Text(item.detail) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } + Spacer() + } + } + .buttonStyle(.plain) + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + Section("Findings") { + if session.findings.isEmpty { + Text("No findings recorded.") + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } else { + ForEach(session.findings) { finding in + Button { + editingFinding = finding + } label: { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(finding.title) + .font(appDensity.font(.callout, design: .default, weight: .semibold)) + .foregroundStyle(.primary) + Spacer() + findingSeverityBadge(finding.severity) + } + Text(finding.summary) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + .lineLimit(2) + Text("\(finding.status.title) • \(finding.evidenceReferences.count) evidence refs") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } + .buttonStyle(.plain) + } + .onDelete { offsets in + viewModel.removeAuditFindings(at: offsets, sessionID: session.id) + } + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + Section("Evidence Snapshot") { + LabeledContent("Availability", value: availabilityTitle(session.evidence.report.availability)) + LabeledContent("Primary IP", value: session.evidence.report.dns.primaryIP ?? "Unavailable") + LabeledContent("TLS", value: session.evidence.report.web.tlsStatus) + LabeledContent("Final URL", value: session.evidence.report.web.finalURL ?? "Unavailable") + LabeledContent("Reachability", value: session.evidence.report.network.reachabilitySummary) + LabeledContent("Registrar", value: session.evidence.report.ownership?.registrar ?? "Unavailable") + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + if !session.evidence.historicalContext.isEmpty { + Section("Historical Context") { + ForEach(session.evidence.historicalContext) { entry in + VStack(alignment: .leading, spacing: 4) { + Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened)) + .font(appDensity.font(.caption, weight: .semibold)) + Text(entry.changeSummaryMessage ?? "No change summary") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + + Section("Audit Timeline") { + ForEach(viewModel.auditTimeline(for: session.domain)) { point in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(point.createdAt.formatted(date: .abbreviated, time: .shortened)) + .font(appDensity.font(.caption, weight: .semibold)) + Spacer() + Text(point.status.title) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + Text("Findings \(point.findingCount) • Open high \(point.openHighSeverityCount) • Repeated \(point.repeatedIssueCount)") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle("Audit Session") + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button("Add Finding") { + editingFinding = nil + showingFindingEditor = true + } + Menu("Export") { + ForEach(AuditExportFormat.allCases) { format in + Button("Export \(format.fileExtension.uppercased())") { + guard let data = viewModel.exportAuditData(sessionID: session.id, format: format) else { return } + ExportPresenter.share( + filename: "\(session.domain)-audit-\(session.createdAt.ISO8601Format()).\(format.fileExtension)", + data: data + ) + } + } + } + } + } + .onAppear { + notesDraft = session.notes + } + .onChange(of: session.notes) { _, newValue in + notesDraft = newValue + } + .sheet(isPresented: $showingFindingEditor) { + AuditFindingEditorView( + existingFinding: nil, + session: session + ) { draft in + viewModel.addAuditFinding( + sessionID: session.id, + title: draft.title, + severity: draft.severity, + summary: draft.summary, + evidenceReferences: draft.evidenceReferences, + notes: draft.notes, + checklistAreas: draft.checklistAreas + ) + } + } + .sheet(item: $editingFinding) { finding in + AuditFindingEditorView(existingFinding: finding, session: session) { updated in + viewModel.updateAuditFinding(updated, sessionID: session.id) + } + } + } else { + ContentUnavailableView("Audit Session Missing", systemImage: "exclamationmark.triangle") + .background(Color.black) + } + } + .preferredColorScheme(.dark) + } +} + +private struct AuditFindingEditorView: View { + @Environment(\.dismiss) private var dismiss + + let existingFinding: AuditFinding? + let session: AuditSession + let onSave: (AuditFinding) -> Void + + @State private var title: String + @State private var severity: AuditFindingSeverity + @State private var summary: String + @State private var evidenceReferencesText: String + @State private var notes: String + @State private var status: AuditFindingStatus + @State private var selectedAreas: Set<AuditChecklistArea> + + init(existingFinding: AuditFinding?, session: AuditSession, onSave: @escaping (AuditFinding) -> Void) { + self.existingFinding = existingFinding + self.session = session + self.onSave = onSave + _title = State(initialValue: existingFinding?.title ?? "") + _severity = State(initialValue: existingFinding?.severity ?? .medium) + _summary = State(initialValue: existingFinding?.summary ?? "") + _evidenceReferencesText = State(initialValue: existingFinding?.evidenceReferences.joined(separator: "\n") ?? "") + _notes = State(initialValue: existingFinding?.notes ?? "") + _status = State(initialValue: existingFinding?.status ?? .open) + _selectedAreas = State(initialValue: Set(existingFinding?.checklistAreas ?? [])) + } + + var body: some View { + NavigationStack { + Form { + Section("Finding") { + TextField("Title", text: $title) + Picker("Severity", selection: $severity) { + ForEach(AuditFindingSeverity.allCases) { severity in + Text(severity.title).tag(severity) + } + } + Picker("Status", selection: $status) { + ForEach(AuditFindingStatus.allCases) { status in + Text(status.title).tag(status) + } + } + TextField("Summary", text: $summary, axis: .vertical) + .lineLimit(3...6) + } + + Section("Evidence References") { + TextField("One reference per line", text: $evidenceReferencesText, axis: .vertical) + .lineLimit(4...8) + if !session.evidence.screenshots.isEmpty { + ForEach(session.evidence.screenshots) { asset in + Text("\(asset.title): \(asset.reference)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + Section("Checklist Areas") { + ForEach(AuditChecklistArea.allCases) { area in + Button { + if selectedAreas.contains(area) { + selectedAreas.remove(area) + } else { + selectedAreas.insert(area) + } + } label: { + HStack { + Text(area.title) + Spacer() + Image(systemName: selectedAreas.contains(area) ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selectedAreas.contains(area) ? Color.green : .secondary) + } + } + .buttonStyle(.plain) + } + } + + Section("Notes") { + TextField("Optional notes", text: $notes, axis: .vertical) + .lineLimit(4...8) + } + } + .navigationTitle(existingFinding == nil ? "New Finding" : "Edit Finding") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + onSave( + AuditFinding( + id: existingFinding?.id ?? UUID(), + title: title.trimmingCharacters(in: .whitespacesAndNewlines), + severity: severity, + summary: summary.trimmingCharacters(in: .whitespacesAndNewlines), + evidenceReferences: evidenceReferencesText + .split(separator: "\n") + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty }, + notes: notes.trimmingCharacters(in: .whitespacesAndNewlines), + status: status, + checklistAreas: Array(selectedAreas).sorted { $0.title < $1.title }, + createdAt: existingFinding?.createdAt ?? Date(), + updatedAt: Date() + ) + ) + dismiss() + } + .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } +} + +private func auditStatusBadge(_ status: AuditStatus) -> some View { + let model: AppStatusBadgeModel + switch status { + case .draft: + model = .init(title: "Draft", systemImage: "square.and.pencil", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16)) + case .inReview: + model = .init(title: "In Review", systemImage: "doc.text.magnifyingglass", foregroundColor: .cyan, backgroundColor: .cyan.opacity(0.16)) + case .complete: + model = .init(title: "Complete", systemImage: "checkmark.seal.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16)) + } + return AppStatusBadgeView(model: model) +} + +private func findingSeverityBadge(_ severity: AuditFindingSeverity) -> some View { + let color: Color + switch severity { + case .informational: + color = .secondary + case .low: + color = .green + case .medium: + color = .yellow + case .high: + color = .red + } + return AppStatusBadgeView( + model: .init( + title: severity.title, + systemImage: "exclamationmark.circle.fill", + foregroundColor: color, + backgroundColor: color.opacity(0.16) + ) + ) +} + +private func availabilityTitle(_ status: DomainAvailabilityStatus) -> String { + switch status { + case .available: + return "Available" + case .registered: + return "Registered" + case .unknown: + return "Unknown" + } +} diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 88e2ec6..f0c4411 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -297,6 +297,7 @@ final class DomainViewModel { private static let historyKey = "lookupHistory" private static let maxHistory = 250 var history: [HistoryEntry] = DomainViewModel.loadHistoryEntries() + var auditSessions: [AuditSession] = DomainDataPortabilityService.loadAuditSessions() private static let workflowsKey = "domainWorkflows" var workflows: [DomainWorkflow] = DomainViewModel.loadWorkflows() var historySearchText = "" @@ -952,6 +953,7 @@ final class DomainViewModel { savedDomains = DomainDataPortabilityService.loadSavedDomains() trackedDomains = Self.loadTrackedDomains() history = Self.loadHistoryEntries() + auditSessions = DomainDataPortabilityService.loadAuditSessions() workflows = Self.loadWorkflows() monitoringSettings = MonitoringStorage.sanitizeSettings( MonitoringStorage.loadSettings(), @@ -970,6 +972,7 @@ final class DomainViewModel { savedDomains = [] trackedDomains = [] history = [] + auditSessions = [] workflows = [] historySearchText = "" historyDateFilter = .all @@ -1464,6 +1467,154 @@ final class DomainViewModel { return String(data: data, encoding: .utf8) } + func audits(for domain: String) -> [AuditSession] { + auditSessions + .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } + .sorted { $0.createdAt > $1.createdAt } + } + + func auditSession(withID id: UUID) -> AuditSession? { + auditSessions.first(where: { $0.id == id }) + } + + func auditTimeline(for domain: String) -> [AuditTimelinePoint] { + let sessions = audits(for: domain).sorted { $0.createdAt > $1.createdAt } + return sessions.map { session in + let repeatedIssues = sessions + .filter { $0.id != session.id } + .flatMap(\.findings) + .map { $0.title.lowercased() } + let repeatedIssueCount = session.findings.filter { + repeatedIssues.contains($0.title.lowercased()) + }.count + + return AuditTimelinePoint( + id: session.id, + sessionID: session.id, + domain: session.domain, + createdAt: session.createdAt, + status: session.status, + findingCount: session.findings.count, + openHighSeverityCount: session.findings.filter { $0.severity == .high && $0.status != .resolved }.count, + repeatedIssueCount: repeatedIssueCount + ) + } + } + + @discardableResult + func startAudit(for domain: String, reviewer: String? = nil) async -> AuditSession? { + let normalizedDomain = domain + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + .components(separatedBy: "/").first? + .lowercased() ?? domain.lowercased() + guard !normalizedDomain.isEmpty else { return nil } + + let previous = historyEntries(for: normalizedDomain).first?.snapshot + let snapshot = await inspectionService.inspectSnapshot(domain: normalizedDomain, previousSnapshot: previous) + guard let entry = saveHistoryEntry( + from: snapshot, + replaceLatest: false, + updateCurrentState: searchedDomain.caseInsensitiveCompare(normalizedDomain) == .orderedSame + ) else { + return nil + } + + let report = report(for: entry) + let historicalContext = Array(historyEntries(for: normalizedDomain).dropFirst().prefix(6)).map(\.snapshotSummary) + let screenshots = snapshotEvidenceAssets(from: report) + let session = AuditSession( + domain: normalizedDomain, + createdAt: entry.timestamp, + reviewer: (reviewer?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty) ?? Self.defaultAuditReviewer, + status: .draft, + evidence: AuditEvidenceSnapshot( + capturedAt: entry.timestamp, + lookup: entry, + report: report, + historicalContext: historicalContext, + screenshots: screenshots + ), + findings: [], + notes: "", + checklist: AuditChecklistArea.defaultItems + ) + + auditSessions.insert(session, at: 0) + persistAuditSessions() + return session + } + + func updateAuditStatus(_ status: AuditStatus, sessionID: UUID) { + guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return } + auditSessions[index].status = status + persistAuditSessions() + } + + func updateAuditNotes(_ notes: String, sessionID: UUID) { + guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return } + auditSessions[index].notes = notes.trimmingCharacters(in: .whitespacesAndNewlines) + persistAuditSessions() + } + + func toggleAuditChecklistItem(sessionID: UUID, itemID: UUID) { + guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }), + let itemIndex = auditSessions[sessionIndex].checklist.firstIndex(where: { $0.id == itemID }) else { + return + } + + auditSessions[sessionIndex].checklist[itemIndex].isComplete.toggle() + auditSessions[sessionIndex].checklist[itemIndex].completedAt = auditSessions[sessionIndex].checklist[itemIndex].isComplete ? Date() : nil + persistAuditSessions() + } + + func addAuditFinding( + sessionID: UUID, + title: String, + severity: AuditFindingSeverity, + summary: String, + evidenceReferences: [String], + notes: String, + checklistAreas: [AuditChecklistArea] + ) { + guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return } + let finding = AuditFinding( + title: title, + severity: severity, + summary: summary, + evidenceReferences: evidenceReferences, + notes: notes, + status: .open, + checklistAreas: checklistAreas + ) + auditSessions[index].findings.insert(finding, at: 0) + persistAuditSessions() + } + + func updateAuditFinding(_ finding: AuditFinding, sessionID: UUID) { + guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }), + let findingIndex = auditSessions[sessionIndex].findings.firstIndex(where: { $0.id == finding.id }) else { + return + } + + var updatedFinding = finding + updatedFinding.updatedAt = Date() + auditSessions[sessionIndex].findings[findingIndex] = updatedFinding + persistAuditSessions() + } + + func removeAuditFindings(at offsets: IndexSet, sessionID: UUID) { + guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return } + auditSessions[sessionIndex].findings.remove(atOffsets: offsets) + persistAuditSessions() + } + + func exportAuditData(sessionID: UUID, format: AuditExportFormat) -> Data? { + guard let session = auditSession(withID: sessionID) else { return nil } + return try? AuditExporter.data(for: session, format: format) + } + func loadOwnershipHistory() async { guard !searchedDomain.isEmpty else { return } guard DataAccessService.hasAccess(to: .ownershipHistory) else { @@ -2405,6 +2556,11 @@ final class DomainViewModel { DomainDebugLog.signpostEnd("DomainViewModel.persistHistory", start: persistStartedAt, extra: "count=\(history.count)") } + private func persistAuditSessions() { + DomainDataPortabilityService.saveAuditSessions(auditSessions) + refreshDataLifecycleSummary() + } + func setHistoryAutoPruneOption(_ option: HistoryAutoPruneOption) { historyAutoPruneOption = option UserDefaults.standard.set(option.rawValue, forKey: Self.historyAutoPruneKey) @@ -3565,6 +3721,43 @@ final class DomainViewModel { ) } + private static var defaultAuditReviewer: String { + let reviewer = NSFullUserName().trimmingCharacters(in: .whitespacesAndNewlines) + return reviewer.isEmpty ? "Local Reviewer" : reviewer + } + + private func snapshotEvidenceAssets(from report: DomainReport) -> [AuditEvidenceAsset] { + var assets: [AuditEvidenceAsset] = [] + if let finalURL = report.web.finalURL { + assets.append(AuditEvidenceAsset(title: "Final URL", kind: .document, reference: finalURL)) + } + if let registrar = report.ownership?.registrar { + assets.append(AuditEvidenceAsset(title: "Registrar", kind: .document, reference: registrar)) + } + if let primaryIP = report.dns.primaryIP { + assets.append(AuditEvidenceAsset(title: "Primary IP", kind: .document, reference: primaryIP)) + } + if !report.web.redirectChain.isEmpty { + assets.append( + AuditEvidenceAsset( + title: "Redirect Chain", + kind: .document, + reference: report.web.redirectChain.map { "\($0.statusCode) \($0.url)" }.joined(separator: " | ") + ) + ) + } + if !report.web.headers.isEmpty { + assets.append( + AuditEvidenceAsset( + title: "Observed Headers", + kind: .document, + reference: report.web.headers.prefix(6).map { "\($0.name): \($0.value)" }.joined(separator: " | ") + ) + ) + } + return assets + } + private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot { LookupSnapshot( historyEntryID: trackedDomain.lastSnapshotID, diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift index 524e8e9..f2c9f6c 100644 --- a/DomainDig/RootTabView.swift +++ b/DomainDig/RootTabView.swift @@ -2,9 +2,9 @@ import SwiftUI private enum RootTab: Hashable { case dashboard + case audit case history case inspect - case audit case settings } @@ -26,6 +26,14 @@ struct RootTabView: View { .tag(RootTab.dashboard) NavigationStack { + AuditListView(viewModel: viewModel) + } + .tabItem { + Label("Audit", systemImage: "checklist") + } + .tag(RootTab.audit) + + NavigationStack { HistoryView(viewModel: viewModel) } .tabItem { @@ -40,14 +48,6 @@ struct RootTabView: View { .tag(RootTab.inspect) NavigationStack { - AuditModeView(viewModel: viewModel) - } - .tabItem { - Label("Audit", systemImage: "checklist") - } - .tag(RootTab.audit) - - NavigationStack { SettingsView(viewModel: viewModel) } .tabItem { diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index d092f65..d91dd5d 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -476,6 +476,8 @@ struct TrackedDomainDetailView: View { @State private var isEditingNote = false @State private var showRerunOptions = false @State private var shareEntity: ShareableEntity? + @State private var showingAuditTimeline = false + @State private var auditStartInFlight = false private var liveTrackedDomain: TrackedDomain { viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain @@ -513,6 +515,27 @@ struct TrackedDomainDetailView: View { } Button { + Task { + auditStartInFlight = true + if await viewModel.startAudit(for: liveTrackedDomain.domain) != nil { + showingAuditTimeline = true + } + auditStartInFlight = false + } + } label: { + Label(auditStartInFlight ? "Starting Audit…" : "Start Audit", systemImage: "checklist") + } + .disabled(auditStartInFlight) + + if !viewModel.audits(for: liveTrackedDomain.domain).isEmpty { + Button { + showingAuditTimeline = true + } label: { + Label("View Audits", systemImage: "clock.badge.checkmark") + } + } + + Button { viewModel.togglePinned(for: liveTrackedDomain) } label: { Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin") @@ -651,5 +674,10 @@ struct TrackedDomainDetailView: View { .sheet(item: $shareEntity) { entity in CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain) } + .sheet(isPresented: $showingAuditTimeline) { + NavigationStack { + AuditDomainTimelineView(viewModel: viewModel, domain: liveTrackedDomain.domain) + } + } } } diff --git a/DomainDigCLI.swift b/DomainDigCLI.swift deleted file mode 100644 index 00713fd..0000000 --- a/DomainDigCLI.swift +++ /dev/null @@ -1,525 +0,0 @@ -import Foundation - -@main -struct DomainDigCLI { - static func main() async { - let arguments = Array(CommandLine.arguments.dropFirst()) - let wantsJSON = arguments.contains("--json") || arguments.contains("-j") - - guard let command = CommandLine.arguments.first else { - fputs(usageText, stderr) - Foundation.exit(1) - } - _ = command - - if arguments.first == "backup" { - runBackupCommand(arguments: Array(arguments.dropFirst()), wantsJSON: wantsJSON) - return - } - - if arguments.first == "history" { - runHistoryCommand(arguments: Array(arguments.dropFirst()), wantsJSON: wantsJSON) - return - } - - if arguments.first == "diff" { - runDiffCommand(arguments: Array(arguments.dropFirst()), wantsJSON: wantsJSON) - return - } - - if arguments.first == "monitor" { - await runMonitorCommand(wantsJSON: wantsJSON) - return - } - - let wantsOwnershipHistory = arguments.contains("--ownership-history") - let wantsDNSHistory = arguments.contains("--dns-history") - let wantsExtendedSubdomains = arguments.contains("--extended-subdomains") - let wantsPricing = arguments.contains("--pricing") - let domains = arguments.filter { !$0.hasPrefix("-") } - - let requestedDomains = domains - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - - guard !requestedDomains.isEmpty else { - fputs(usageText, stderr) - Foundation.exit(1) - } - - let inspectionService = DomainInspectionService() - let reportBuilder = DomainReportBuilder() - var reports: [DomainReport] = [] - var seen = Set<String>() - for domain in requestedDomains { - let normalizedDomain = domain.lowercased() - guard seen.insert(normalizedDomain).inserted else { continue } - let snapshot = await inspectionService.inspectSnapshot(domain: domain) - let enrichedSnapshot = await enrichSnapshot( - snapshot, - wantsOwnershipHistory: wantsOwnershipHistory, - wantsDNSHistory: wantsDNSHistory, - wantsExtendedSubdomains: wantsExtendedSubdomains, - wantsPricing: wantsPricing - ) - reports.append(reportBuilder.build(from: enrichedSnapshot)) - } - - do { - let data: Data - if reports.count == 1, let report = reports.first { - data = try DomainReportExporter.data( - for: report, - format: wantsJSON ? .json : .text - ) - } else { - data = try DomainReportExporter.data( - for: reports, - format: wantsJSON ? .json : .text, - title: "DomainDig Batch Report" - ) - } - FileHandle.standardOutput.write(data) - if data.last != 0x0A { - FileHandle.standardOutput.write(Data([0x0A])) - } - } catch { - fputs("domaindig: \(error.localizedDescription)\n", stderr) - Foundation.exit(1) - } - } - - private static func enrichSnapshot( - _ snapshot: LookupSnapshot, - wantsOwnershipHistory: Bool, - wantsDNSHistory: Bool, - wantsExtendedSubdomains: Bool, - wantsPricing: Bool - ) async -> LookupSnapshot { - guard FeatureAccessService.currentTier == .proPlus else { - return snapshot - } - - let historyEntries = loadHistoryEntries() - var ownershipHistory = snapshot.ownershipHistory - var ownershipHistoryError = snapshot.ownershipHistoryError - var dnsHistory = snapshot.dnsHistory - var dnsHistoryError = snapshot.dnsHistoryError - var extendedSubdomains = snapshot.extendedSubdomains - var extendedSubdomainsError = snapshot.extendedSubdomainsError - var domainPricing = snapshot.domainPricing - var domainPricingError = snapshot.domainPricingError - - if wantsOwnershipHistory { - let outcome = await ExternalDataService.shared.ownershipHistory( - domain: snapshot.domain, - currentOwnership: snapshot.ownership, - historyEntries: historyEntries - ) - switch outcome.value { - case let .success(events): - ownershipHistory = events - ownershipHistoryError = nil - case let .empty(message): - ownershipHistoryError = message - case let .error(message): - ownershipHistoryError = message - } - } - - if wantsDNSHistory { - let outcome = await ExternalDataService.shared.dnsHistory( - domain: snapshot.domain, - dnsSections: snapshot.dnsSections, - historyEntries: historyEntries - ) - switch outcome.value { - case let .success(events): - dnsHistory = events - dnsHistoryError = nil - case let .empty(message): - dnsHistoryError = message - case let .error(message): - dnsHistoryError = message - } - } - - if wantsExtendedSubdomains { - let outcome = await ExternalDataService.shared.extendedSubdomains( - domain: snapshot.domain, - existing: snapshot.subdomains - ) - switch outcome.value { - case let .success(results): - extendedSubdomains = results - extendedSubdomainsError = nil - case let .empty(message): - extendedSubdomainsError = message - case let .error(message): - extendedSubdomainsError = message - } - } - - if wantsPricing { - let outcome = await ExternalDataService.shared.pricing(domain: snapshot.domain) - switch outcome.value { - case let .success(pricing): - domainPricing = pricing - domainPricingError = nil - case let .empty(message), let .error(message): - domainPricingError = message - } - } - - return LookupSnapshot( - historyEntryID: snapshot.historyEntryID, - domain: snapshot.domain, - timestamp: snapshot.timestamp, - trackedDomainID: snapshot.trackedDomainID, - note: snapshot.note, - appVersion: snapshot.appVersion, - resolverDisplayName: snapshot.resolverDisplayName, - resolverURLString: snapshot.resolverURLString, - dataSources: snapshot.dataSources, - provenanceBySection: snapshot.provenanceBySection, - availabilityConfidence: snapshot.availabilityConfidence, - ownershipConfidence: snapshot.ownershipConfidence, - subdomainConfidence: snapshot.subdomainConfidence, - emailSecurityConfidence: snapshot.emailSecurityConfidence, - geolocationConfidence: snapshot.geolocationConfidence, - errorDetails: snapshot.errorDetails, - isPartialSnapshot: snapshot.isPartialSnapshot, - validationIssues: snapshot.validationIssues, - totalLookupDurationMs: snapshot.totalLookupDurationMs, - snapshotIndex: snapshot.snapshotIndex, - previousSnapshotID: snapshot.previousSnapshotID, - changeCount: snapshot.changeCount, - severitySummary: snapshot.severitySummary, - dnsSections: snapshot.dnsSections, - dnsError: snapshot.dnsError, - availabilityResult: snapshot.availabilityResult, - suggestions: snapshot.suggestions, - sslInfo: snapshot.sslInfo, - sslError: snapshot.sslError, - hstsPreloaded: snapshot.hstsPreloaded, - httpHeaders: snapshot.httpHeaders, - httpSecurityGrade: snapshot.httpSecurityGrade, - httpStatusCode: snapshot.httpStatusCode, - httpResponseTimeMs: snapshot.httpResponseTimeMs, - httpProtocol: snapshot.httpProtocol, - http3Advertised: snapshot.http3Advertised, - httpHeadersError: snapshot.httpHeadersError, - reachabilityResults: snapshot.reachabilityResults, - reachabilityError: snapshot.reachabilityError, - ipGeolocation: snapshot.ipGeolocation, - ipGeolocationError: snapshot.ipGeolocationError, - emailSecurity: snapshot.emailSecurity, - emailSecurityError: snapshot.emailSecurityError, - ownership: snapshot.ownership, - ownershipError: snapshot.ownershipError, - ownershipHistory: ownershipHistory, - ownershipHistoryError: ownershipHistoryError, - inferredProvider: snapshot.inferredProvider, - priorProviders: snapshot.priorProviders, - domainClassification: snapshot.domainClassification, - ownershipTransitions: snapshot.ownershipTransitions, - hostingTransitions: snapshot.hostingTransitions, - subdomainHistory: snapshot.subdomainHistory, - riskSignals: snapshot.riskSignals, - intelligenceTimeline: snapshot.intelligenceTimeline, - ptrRecord: snapshot.ptrRecord, - ptrError: snapshot.ptrError, - redirectChain: snapshot.redirectChain, - redirectChainError: snapshot.redirectChainError, - subdomains: snapshot.subdomains, - subdomainsError: snapshot.subdomainsError, - extendedSubdomains: extendedSubdomains, - extendedSubdomainsError: extendedSubdomainsError, - dnsHistory: dnsHistory, - dnsHistoryError: dnsHistoryError, - domainPricing: domainPricing, - domainPricingError: domainPricingError, - portScanResults: snapshot.portScanResults, - portScanError: snapshot.portScanError, - changeSummary: snapshot.changeSummary, - resultSource: snapshot.resultSource, - cachedSections: snapshot.cachedSections, - statusMessage: snapshot.statusMessage - ) - } - - private static func loadHistoryEntries() -> [HistoryEntry] { - DomainDataPortabilityService.loadHistoryEntries() - } - - private static func runHistoryCommand(arguments: [String], wantsJSON: Bool) { - guard let domain = arguments.first(where: { !$0.hasPrefix("-") }) else { - fputs("usage: domaindig history <domain> [--json]\n", stderr) - Foundation.exit(1) - } - - let entries = loadHistoryEntries() - .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } - .sorted { $0.timestamp > $1.timestamp } - - if wantsJSON { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.dateEncodingStrategy = .iso8601 - let payload = entries.map { entry in - [ - "id": entry.id.uuidString, - "timestamp": ISO8601DateFormatter().string(from: entry.timestamp), - "changeSummary": entry.changeSummary?.message ?? "No change summary", - "changeCount": "\(entry.changeCount)", - "severity": entry.severitySummary?.title ?? "N/A" - ] - } - if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) { - FileHandle.standardOutput.write(data) - FileHandle.standardOutput.write(Data([0x0A])) - return - } - } - - let lines = entries.map { entry in - [ - entry.id.uuidString, - entry.timestamp.formatted(date: .abbreviated, time: .shortened), - entry.changeSummary?.message ?? "No change summary" - ].joined(separator: " | ") - } - - FileHandle.standardOutput.write(Data((lines.isEmpty ? "No history found.\n" : lines.joined(separator: "\n") + "\n").utf8)) - } - - private static func runDiffCommand(arguments: [String], wantsJSON: Bool) { - guard let domain = arguments.first(where: { !$0.hasPrefix("-") }) else { - fputs("usage: domaindig diff <domain> --from <id> --to <id> [--json]\n", stderr) - Foundation.exit(1) - } - - guard let fromID = optionValue(named: "--from", in: arguments), - let toID = optionValue(named: "--to", in: arguments), - let fromUUID = UUID(uuidString: fromID), - let toUUID = UUID(uuidString: toID) else { - fputs("domaindig diff: --from and --to must be valid snapshot IDs\n", stderr) - Foundation.exit(1) - } - - let entries = loadHistoryEntries().filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } - guard let fromEntry = entries.first(where: { $0.id == fromUUID }), - let toEntry = entries.first(where: { $0.id == toUUID }) else { - fputs("domaindig diff: snapshots not found for domain\n", stderr) - Foundation.exit(1) - } - - let orderedEntries = [fromEntry, toEntry].sorted { $0.timestamp < $1.timestamp } - let diff = DiffService.compare(from: orderedEntries[0].snapshot, to: orderedEntries[1].snapshot) - - if wantsJSON { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.dateEncodingStrategy = .iso8601 - if let data = try? encoder.encode(diff) { - FileHandle.standardOutput.write(data) - FileHandle.standardOutput.write(Data([0x0A])) - return - } - } - - var lines = [ - "DomainDig Diff", - "Domain: \(domain)", - "From: \(orderedEntries[0].id.uuidString)", - "To: \(orderedEntries[1].id.uuidString)" - ] - - for section in diff.sections where section.hasChanges { - lines.append("") - lines.append(section.title) - for item in section.items where item.hasChanges { - lines.append("\(item.changeType.marker) \(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")") - } - } - - FileHandle.standardOutput.write(Data((lines.joined(separator: "\n") + "\n").utf8)) - } - - private static func optionValue(named name: String, in arguments: [String]) -> String? { - guard let index = arguments.firstIndex(of: name), arguments.indices.contains(index + 1) else { - return nil - } - return arguments[index + 1] - } - - private static func runBackupCommand(arguments: [String], wantsJSON: Bool) { - guard let subcommand = arguments.first else { - fputs(usageText, stderr) - Foundation.exit(1) - } - - switch subcommand { - case "export": - do { - let data = try DomainDataPortabilityService.backupData() - let outputPath = arguments.dropFirst().first(where: { !$0.hasPrefix("-") }) - if let outputPath { - try data.write(to: URL(fileURLWithPath: outputPath), options: .atomic) - } else { - FileHandle.standardOutput.write(data) - if data.last != 0x0A { - FileHandle.standardOutput.write(Data([0x0A])) - } - } - } catch { - fputs("domaindig backup export: \(error.localizedDescription)\n", stderr) - Foundation.exit(1) - } - case "validate": - guard let path = arguments.dropFirst().first(where: { !$0.hasPrefix("-") }) else { - fputs("usage: domaindig backup validate <path>\n", stderr) - Foundation.exit(1) - } - - do { - let data = try Data(contentsOf: URL(fileURLWithPath: path)) - let report = try DomainDataPortabilityService.validateBackup(data: data, fileName: URL(fileURLWithPath: path).lastPathComponent) - if wantsJSON { - let payload = [ - "warnings": report.warnings, - "errors": report.errors - ] - let encoded = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) - FileHandle.standardOutput.write(encoded) - } else { - let lines = [ - "Warnings: \(report.warnings.count)", - "Errors: \(report.errors.count)" - ] + report.warnings.map { "warning: \($0)" } + report.errors.map { "error: \($0)" } - FileHandle.standardOutput.write(Data(lines.joined(separator: "\n").utf8)) - } - FileHandle.standardOutput.write(Data([0x0A])) - if !report.errors.isEmpty { - Foundation.exit(1) - } - } catch { - fputs("domaindig backup validate: \(error.localizedDescription)\n", stderr) - Foundation.exit(1) - } - case "import": - guard let path = arguments.dropFirst().first(where: { !$0.hasPrefix("-") }) else { - fputs("usage: domaindig backup import <path> [--replace]\n", stderr) - Foundation.exit(1) - } - - let mode: DataPortabilityImportMode = arguments.contains("--replace") ? .replace : .merge - - do { - let fileURL = URL(fileURLWithPath: path) - let data = try Data(contentsOf: fileURL) - let preview = try DomainDataPortabilityService.prepareImport( - data: data, - fileName: fileURL.lastPathComponent, - mode: mode - ) - guard preview.kind == .backup else { - fputs("domaindig backup import: expected a full backup file\n", stderr) - Foundation.exit(1) - } - let result = try DomainDataPortabilityService.applyImport(preview, mode: mode) - let lines = [result.summary] + result.warnings.map { "warning: \($0)" } - FileHandle.standardOutput.write(Data(lines.joined(separator: "\n").utf8)) - FileHandle.standardOutput.write(Data([0x0A])) - } catch { - fputs("domaindig backup import: \(error.localizedDescription)\n", stderr) - Foundation.exit(1) - } - default: - fputs(usageText, stderr) - Foundation.exit(1) - } - } - - private static func runMonitorCommand(wantsJSON: Bool) async { - guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { - fputs("domaindig monitor: monitoring requires Pro\n", stderr) - Foundation.exit(1) - } - - let outcome = await DomainMonitoringService.shared.performMonitoring( - trigger: .cli, - requireEnabledSetting: false - ) - - guard let log = outcome.log else { - fputs("domaindig monitor: \(outcome.message)\n", stderr) - Foundation.exit(1) - } - - do { - let output: Data - if wantsJSON { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - output = try encoder.encode(log) - } else { - output = Data(monitoringTextSummary(for: log).utf8) - } - FileHandle.standardOutput.write(output) - if output.last != 0x0A { - FileHandle.standardOutput.write(Data([0x0A])) - } - if !outcome.success { - Foundation.exit(1) - } - } catch { - fputs("domaindig monitor: \(error.localizedDescription)\n", stderr) - Foundation.exit(1) - } - } - - private static func monitoringTextSummary(for log: MonitoringLog) -> String { - var lines = [ - "DomainDig Monitoring", - "====================", - "Trigger: \(log.trigger.title)", - "Timestamp: \(log.timestamp.formatted(date: .abbreviated, time: .shortened))", - "Checked: \(log.domainsChecked)", - "Changes: \(log.changesFound)", - "Alerts: \(log.alertsTriggered)", - "" - ] - - if log.checkedDomains.isEmpty { - lines.append("No domains were checked.") - } else { - for result in log.checkedDomains { - let severity = result.alertSeverity?.title ?? "None" - lines.append("\(result.domain): \(result.summaryMessage) [alert: \(severity)]") - } - } - - if !log.errors.isEmpty { - lines.append("") - lines.append("Errors:") - lines.append(contentsOf: log.errors.map { "- \($0)" }) - } - - return lines.joined(separator: "\n") - } - - private static var usageText: String { - """ - usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] - domaindig history <domain> [--json] - domaindig diff <domain> --from <id> --to <id> [--json] - domaindig monitor [--json] - domaindig backup export [path] - domaindig backup import <path> [--replace] - domaindig backup validate <path> [--json] - - note: the CLI remains local-only and does not sync with iCloud yet. - """ - } -} @@ -1,34 +1,32 @@ -# domain-dig +# DomainDig [](https://sonarcloud.io/summary/new_code?id=zerolabsco_domain-dig) [](https://sonarcloud.io/summary/new_code?id=zerolabsco_domain-dig) -Domain Dig is a minimal iOS to perform DNS lookups. +DomainDig is a local-first iOS domain inspection toolkit for DNS, web, ownership, monitoring, reporting, and audit workflows. The app gathers a point-in-time domain snapshot, normalizes it into a canonical `DomainReport`, and keeps user data on device unless the user exports, shares, or syncs it. -## Features +## Current Release Target -Data provided by a Domain Dig search: +Immediate release target: `v4.4.1`. -- Reachability -- Redirect Chain -- DNS Records -- Email Security -- SSL/TLS Certificate -- HTTP Headers - - "Grade" for header security -- IP Location -- Open Ports - - Custom port checks +This patch release focuses on release readiness: resolving duplicate Audit Mode implementations, aligning app/project version metadata, preserving audit persistence and backup/restore coverage, and refreshing docs for the current app surface. -Extra features: +## Features -- Save favorite domains for quick reuse -- Check historical log of searches and their results -- Change DNS resolver to Cloudflare, Google, Quad9, or a custom resolver +- Domain inspection for DNS records, email security, TLS certificates, HTTP headers, redirects, IP geolocation, reachability, open ports, RDAP, ownership, subdomain discovery, and availability. +- Canonical `DomainReport` output used by the app UI and exports. +- History snapshots with change summaries, risk scoring, notes, and saved domain context. +- Dashboard, watchlist, monitoring, workflows, batch results, integrations, and data portability screens. +- Audit Mode with sessions, checklist progress, reviewer notes, findings, evidence snapshots, audit timelines, and markdown/json/pdf export. +- Backup and restore for tracked domains, history, audit sessions, workflows, monitoring settings/logs, app settings, and local feature metadata. +- Local-first operation with no required backend. +- Optional local API surface for automation-compatible report output. -## Contributing +## Data And Privacy -Contributions are welcome. Keep changes scoped, include tests when behavior changes, and open a pull request with a clear summary of the user-facing impact. +DomainDig stores local app data in on-device persistence. Backup exports can include tracked domains, lookup history, audit sessions, workflow definitions, monitoring configuration, monitoring logs, app settings, and cached feature metadata. Imports are processed on device. + +Network inspection requests are made only to perform the requested domain checks or configured resolver lookups. The app does not require a hosted DomainDig backend. ## Development @@ -36,23 +34,41 @@ Contributions are welcome. Keep changes scoped, include tests when behavior chan - Xcode with current iOS SDK support - iOS Simulator or physical iOS device +- Swift/Xcode support for filesystem-synchronized groups used by the project ### Getting Started -1. Clone the repository: +1. Clone the repository. ```sh - git clone https://git.sr.ht/~ccleberg/DomainDig + git clone https://github.com/zerolabsco/domain-dig.git ``` -2. Open the project in Xcode. -3. Build and run the app on a simulator or device. +2. Open `DomainDig.xcodeproj` in Xcode. +3. Select the `DomainDig` scheme. +4. Build and run on a simulator or device. + +### Useful Checks + +```sh +xcodebuild -project DomainDig.xcodeproj -scheme DomainDig -destination 'platform=iOS Simulator,name=iPhone 16' build +``` + +The app and local API share the canonical report pipeline through `DomainInspectionService`, `DomainReportBuilder`, and `DomainReportExporter`. + +## Release Planning + +See `RELEASE_ROADMAP.md` for the semver release plan from `v4.4.1` through the planned `v5.0.0` stabilization milestone. + +## Contributing + +Contributions are welcome. Keep changes scoped, include tests or build verification when behavior changes, and open a pull request with a clear summary of user-facing impact. ## Security -If you discover a security issue, see SECURITY.md. +If you discover a security issue, see `SECURITY.md`. ## License -This project is licensed under the GPL 3.0 or later. See LICENSE. +This project is licensed under GPL 3.0 or later. See `LICENSE`. ## Contact diff --git a/RELEASE_ROADMAP.md b/RELEASE_ROADMAP.md new file mode 100644 index 0000000..42600f2 --- /dev/null +++ b/RELEASE_ROADMAP.md @@ -0,0 +1,90 @@ +# DomainDig Release Roadmap + +Priority lens: **new user-facing features.** The inspection engine is already +deep (DNS, DNSSEC, CAA, TLS, TLSA/DANE, email security incl. BIMI/MTA-STS, RDAP, +ports, geolocation, subdomains, availability). The next several releases invest +in *reach and surfacing* — getting that data onto more iOS surfaces and into more +workflows — rather than adding raw protocol checks. + +Current version: `v4.4.1`. + +## v4.4.1 Patch: Release Readiness (in progress) + +Finish the audit-mode consolidation already on the working tree, then ship a +clean release candidate. + +- Resolve duplicate Audit Mode implementations; retire the prototype + `AuditMode.swift` / `AuditModeView.swift` and keep the `DomainDig/DomainDig/Audit*` + path as the single active implementation. +- Align `AppVersion.current`, Xcode marketing version, and build number. +- Confirm audit sessions are included in backup/restore counts, summaries, and + merge behavior. +- Refresh README and architecture docs to match the shipping surface. + +Gate: clean Xcode build/archive before tagging. + +## v4.5.0 Minor: Home Screen & Shortcuts Reach + +Goal: put DomainDig data and actions where the user already is. + +- **WidgetKit widgets** (Home Screen + Lock Screen) for pinned/watchlist domains: + certificate expiry countdown, monitoring status, last-change indicator. +- **App Intents / Shortcuts**: "Inspect domain", "Add to watchlist", "Run sweep" + as intents usable from Shortcuts, Spotlight, and the Action button. +- Deep links from widgets and intents into the relevant domain detail screen. +- Polished audit-mode ergonomics carried over from the prior roadmap (timeline + presentation, checklist/finding editing, markdown/json/pdf export affordances). + +## v4.6.0 Minor: Alerts, Glances & iPad + +Goal: make monitoring and results feel first-class across contexts. + +- **Live Activities** for in-flight sweeps and active monitoring alerts + (cert-expiry and change events at a glance). +- **Share extension**: "Dig this domain" from Safari and the system share sheet. +- **iPad-optimized layout** using `NavigationSplitView` (the app currently ships + an iPhone-style stack on iPad); adapt watchlist/detail as a two-column layout. +- Richer, actionable notification content building on the existing + `LocalNotificationService` triggers. + +## v4.7.0 Minor: Intelligence & Comparison + +Goal: help users interpret and organize, not just collect. + +- **Domain-vs-domain comparison** (side-by-side), extending the existing + time-based `DiffService` to compare two distinct domains. +- **Reputation / blocklist signals** as a new optional data source (currently + absent); surfaced in the report and available to monitoring alerts. +- **Tags / folders and saved views** for the watchlist to organize large sets. + +## v4.8.0 Minor: Reporting & Sharing + +Goal: turn point-in-time snapshots into shareable, scheduled deliverables. + +- Scheduled report generation (markdown/json/pdf) for tracked domains. +- Stronger share affordances for reports and audit evidence. +- Export polish and consistency across app and local API output. + +## v5.0.0 Major: Contract Stabilization & Engineering Health + +Goal: earn long-term compatibility promises — and pay down the debt that the +feature releases above will accumulate. + +- Define migration policy for persisted snapshots, backups, audits, workflows, + and settings. +- Stabilize the public local API response contract; document compatibility + guarantees and planned deprecations. +- **Establish a test target.** The project currently has no XCTest target and no + tests; add one and cover the deterministic core first — `DomainReportBuilder`, + `DomainReportExporter`, `DomainDataPortabilityService` (merge/replace dedup), + and `DiffService` — before locking down external contracts. +- **Decompose the god-files** behind that test net: `DomainViewModel.swift` + (~4.7k lines) and `ContentView.swift` (~3.8k lines) into focused units + (audit, monitoring, workflows, portability). + +## Cross-cutting note + +New feature surfaces (widgets, intents, extensions) each add a target and a +persistence/entitlement seam. Add at least characterization tests for +`DomainDataPortabilityService` and the report builders **before** v4.7.0, so the +v5.0.0 contract and refactor work has a safety net rather than starting from zero. |
