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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,11 @@ jobs:
- name: Generate the project and reject drift
run: scripts/check_xcode_project.sh

- name: Check bounded notarization retries and warning gate
- name: Check bounded notarization retries, catalog policy, and warning gate
if: matrix.xcode == 'newest'
run: |
scripts/test-notarize.sh
scripts/test-rule-pack-policy.sh
scripts/test-swift-warnings.sh

# This compiles a different product graph and activation path from the Developer ID build. The build clears
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ jobs:
curl --fail --silent --show-error --connect-timeout 10 --max-time 30 --max-filesize 8388608 \
--output "$PREVIOUS_CATALOG" "$LIVE_CATALOG_URL"
fi
python3 -I scripts/rule_pack_catalog.py verify "$PREVIOUS_CATALOG" \
python3 -I scripts/rule_pack_catalog.py verify "$PREVIOUS_CATALOG" --historical \
--public-key Flowlight/Resources/RulePacks/official-rule-packs-v1-public.pem \
--expected-key-id flowlight-rule-packs-2026-01
python3 -I scripts/rule_pack_catalog.py compare-previous site/packs/v1/official.json \
Expand Down
12 changes: 6 additions & 6 deletions Flowlight.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2184,7 +2184,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 57;
CURRENT_PROJECT_VERSION = 58;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES;
Expand All @@ -2199,7 +2199,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.15.10;
MARKETING_VERSION = 0.15.11;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand Down Expand Up @@ -2245,7 +2245,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 57;
CURRENT_PROJECT_VERSION = 58;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 38RJUJHKZS;
ENABLE_HARDENED_RUNTIME = YES;
Expand All @@ -2260,7 +2260,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.15.10;
MARKETING_VERSION = 0.15.11;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand Down Expand Up @@ -2325,7 +2325,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 57;
CURRENT_PROJECT_VERSION = 58;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES;
Expand All @@ -2346,7 +2346,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.15.10;
MARKETING_VERSION = 0.15.11;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
Expand Down
67 changes: 62 additions & 5 deletions Flowlight/Analysis/BuiltInRulePacks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,56 @@ enum BuiltInRulePacks {

static let tracking = definition(
id: "official.tracking",
version: "2",
generatedAt: 1_793_253_368,
title: "Cross-site tracking",
summary: "Common analytics, session replay, and cross-site measurement hosts.",
caveat: "Some apps use the same providers for feature flags. They may fall back to built-in defaults when a host is unavailable.",
category: .tracking,
hosts: RuleTemplate.analyticsHosts)
summary: "Dedicated analytics collection, session replay, and tracker bootstrap hosts.",
caveat: "Product dashboards stay reachable. Shared-host collection paths and first-party tracking proxies cannot be separated by hostname blocking.",
entries: [
entry(1, "api-eu.mixpanel.com"),
entry(2, "api-in.mixpanel.com"),
entry(3, "api-js.mixpanel.com"),
entry(4, "api.eu.amplitude.com"),
entry(5, "api.mixpanel.com"),
entry(6, "api.segment.io"),
entry(7, "api2.amplitude.com"),
entry(8, "browser-intake-ap1-datadoghq.com"),
entry(9, "browser-intake-ap2-datadoghq.com"),
entry(10, "browser-intake-datadoghq.com", match: .suffix),
entry(11, "browser-intake-datadoghq.eu"),
entry(12, "browser-intake-ddog-gov.com"),
entry(13, "browser-intake-uk1-datadoghq.com"),
entry(14, "browser-intake-us2-ddog-gov.com"),
entry(15, "browser-intake-us3-datadoghq.com"),
entry(16, "browser-intake-us5-datadoghq.com"),
entry(17, "c.eu.heap-api.com"),
entry(18, "c.us.heap-api.com"),
entry(19, "cdn.eu.heap-api.com"),
entry(20, "cdn.heapanalytics.com"),
entry(21, "cdn.matomo.cloud"),
entry(22, "cdn.mxpnl.com"),
entry(23, "cdn.us.heap-api.com"),
entry(24, "clarity.ms", match: .suffix),
entry(25, "edge.eu1.fullstory.com"),
entry(26, "edge.fullstory.com"),
entry(27, "eu-assets.i.posthog.com"),
entry(28, "eu.i.posthog.com"),
entry(29, "events.eu1.segmentapis.com"),
entry(30, "events.statsigapi.net"),
entry(31, "google-analytics.com", match: .suffix),
entry(32, "googletagmanager.com", match: .suffix),
entry(33, "lr-ingest.com", match: .suffix),
entry(34, "lr-ingest.io", match: .suffix),
entry(35, "lr-intake.com", match: .suffix),
entry(36, "lrkt-in.com", match: .suffix),
entry(37, "prodregistryv2.org", match: .suffix),
entry(38, "r.logrocket.com"),
entry(39, "rs.eu1.fullstory.com"),
entry(40, "rs.fullstory.com"),
entry(41, "services.fullstory.com"),
entry(42, "us-assets.i.posthog.com"),
entry(43, "us.i.posthog.com"),
])

static let telemetry = definition(
id: "official.telemetry",
Expand All @@ -39,11 +84,23 @@ enum BuiltInRulePacks {
let entries = canonical.enumerated().map {
RulePackEntry(id: UInt32($0.offset + 1), host: $0.element, match: .suffix, category: category)
}
return definition(id: id, version: "1", generatedAt: 1_792_220_400,
title: title, summary: summary, caveat: caveat, entries: entries)
}

private static func definition(id: String, version: String, generatedAt: TimeInterval,
title: String, summary: String, caveat: String,
entries: [RulePackEntry]) -> RulePackDefinition {
let manifest = RulePackManifest(
id: id, version: "1", generatedAt: Date(timeIntervalSince1970: 1_792_220_400),
id: id, version: version, generatedAt: Date(timeIntervalSince1970: generatedAt),
entryCount: entries.count, contentSHA256: RulePackValidator.digest(entries),
titleKey: title, summaryKey: summary, caveatKey: caveat,
source: "Flowlight curated built-in list", license: "GPL-3.0")
return RulePackDefinition(manifest: manifest, entries: entries)
}

private static func entry(_ id: UInt32, _ host: String,
match: RulePackEntry.Match = .exact) -> RulePackEntry {
RulePackEntry(id: id, host: host, match: match, category: .tracking)
}
}
2 changes: 2 additions & 0 deletions Flowlight/Analysis/RuleStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ final class RuleStore: ObservableObject {

func save(_ rule: Rule) {
var rule = rule
let enteredWildcard = rule.destination.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("*.")
rule.destination = Self.cleaned(rule.destination)
if enteredWildcard { rule.destinationMatch = .suffix }
if let index = rules.firstIndex(where: { $0.id == rule.id }) {
rules[index] = rule
} else {
Expand Down
89 changes: 59 additions & 30 deletions Flowlight/Analysis/RuleTemplates.swift
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,8 @@ extension RuleTemplate {
guard !destination.isEmpty else { return false }
let isAddress = AgentPolicy.isIPAddress(destination)
return rules().contains {
AgentPolicy.matches($0.destination, host: isAddress ? "" : destination,
ip: isAddress ? destination : "")
$0.destinationMatches(host: isAddress ? "" : destination,
ip: isAddress ? destination : "")
}
}

Expand All @@ -220,10 +220,13 @@ extension RuleTemplate {
/// One block per destination, all sharing a name so the Rules screen reads as a set rather than as nine
/// unrelated rules someone wrote on the same afternoon.
private static func blocks(_ destinations: [String], app: Subject, label: String,
schedule: Rule.Schedule = Rule.Schedule()) -> [Rule] {
schedule: Rule.Schedule = Rule.Schedule(),
destinationMatch: Rule.DestinationMatch = .suffix) -> [Rule] {
destinations.map { destination in
var rule = Rule(action: .block, app: app.app, destination: destination, origin: .preset)
var rule = Rule(action: .block, app: app.app, destination: destination,
destinationMatch: destinationMatch, origin: .preset)
rule.schedule = schedule
if label == "no product analytics" { rule.presetID = "telemetry.product-analytics" }
rule.name = app.hasApp ? "\(app.appName): \(label)" : label
return rule
}
Expand Down Expand Up @@ -570,12 +573,15 @@ extension RuleTemplate {
summary: "The usage-measurement services Mac apps and developer tools send events to. Refusing them "
+ "costs you nothing the app does for you.",
category: .telemetry, needs: .nothing,
caveat: "Statsig serves feature flags from the same domains as its analytics, so an app that uses it "
+ "may fall back to its built-in defaults rather than simply measuring less. And some products "
+ "route analytics through their own domain, which is indistinguishable from the rest of what "
+ "they do and so can't be on a list like this at all.",
caveat: "Provider dashboards and shared feature-configuration hosts stay reachable. Collection paths "
+ "that share a product hostname, plus first-party tracking proxies, are indistinguishable at the "
+ "connection layer and cannot be on a hostname list like this.",
scopesToApp: true,
build: { subject in blocks(analyticsHosts, app: subject, label: "no product analytics") }),
build: { subject in
blocks(analyticsHosts, app: subject, label: "no product analytics")
+ blocks(analyticsExactHosts, app: subject, label: "no product analytics",
destinationMatch: .exact)
}),

RuleTemplate(
id: "telemetry.crash-reporting",
Expand Down Expand Up @@ -616,32 +622,55 @@ extension RuleTemplate {
build: { subject in blocks(attributionHosts, app: subject, label: "no install attribution") }),
]

/// Statsig is the reason a list like this is worth having at all: five of its ingestion domains are named
/// after nothing, so nobody scanning Live would ever pick them out.
/// Dedicated families where every subdomain is part of collection rather than the provider's product surface.
static let analyticsHosts = [
"google-analytics.com",
"analytics.google.com",
"googletagmanager.com",
"segment.io", // api.segment.io
"segment.com",
"segmentapis.com", // the EU workspaces
"mixpanel.com",
"mxpnl.com",
"amplitude.com", // api2.amplitude.com is the ingest host
"posthog.com",
"heapanalytics.com",
"fullstory.com",
"lr-ingest.io", // LogRocket
"matomo.cloud", // the hosted one; self-hosted is any host at all
"clarity.ms",
"browser-intake-datadoghq.com",
"statsig.com",
"statsigapi.net",
"featuregates.org",
"featureassets.org",
"clarity.ms",
"lr-ingest.com",
"lr-ingest.io",
"lr-intake.com",
"lrkt-in.com",
"prodregistryv2.org",
"assetsconfigcdn.org",
"beyondwickedmapping.org",
]

/// Reviewed collector and tracker-bootstrap names that cannot safely widen to their neighboring product hosts.
static let analyticsExactHosts = [
"api-eu.mixpanel.com",
"api-in.mixpanel.com",
"api-js.mixpanel.com",
"api.eu.amplitude.com",
"api.mixpanel.com",
"api.segment.io",
"api2.amplitude.com",
"browser-intake-ap1-datadoghq.com",
"browser-intake-ap2-datadoghq.com",
"browser-intake-datadoghq.eu",
"browser-intake-ddog-gov.com",
"browser-intake-uk1-datadoghq.com",
"browser-intake-us2-ddog-gov.com",
"browser-intake-us3-datadoghq.com",
"browser-intake-us5-datadoghq.com",
"c.eu.heap-api.com",
"c.us.heap-api.com",
"cdn.eu.heap-api.com",
"cdn.heapanalytics.com",
"cdn.matomo.cloud",
"cdn.mxpnl.com",
"cdn.us.heap-api.com",
"edge.eu1.fullstory.com",
"edge.fullstory.com",
"eu-assets.i.posthog.com",
"eu.i.posthog.com",
"events.eu1.segmentapis.com",
"events.statsigapi.net",
"r.logrocket.com",
"rs.eu1.fullstory.com",
"rs.fullstory.com",
"services.fullstory.com",
"us-assets.i.posthog.com",
"us.i.posthog.com",
]

static let crashReportingHosts = [
Expand Down
14 changes: 14 additions & 0 deletions Flowlight/Capture/ExtensionTrafficSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ struct RulePackPushGate {

/// Receives per-second batches from the system extension over XPC.
final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @unchecked Sendable {
static func acceptsRegisteredVersion(_ actual: String, expected: String) -> Bool {
!expected.isEmpty && actual == expected
}
var displayName: String { L("Network Extension") }
private var connection: NSXPCConnection?
private var sink: (([TrafficBatch]) -> Void)?
Expand Down Expand Up @@ -136,6 +139,17 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u
// A refusal used to end here, with a line of status text and no timer: connected, registered at
// nothing, and never going to try again. It is a failure like any other, so it goes on the ladder.
guard ok else { self.scheduleReconnect(L("Extension refused registration"), from: connection); return }
// Exact ordinary rules are encoded as an additive Rule field. An older filter would ignore that field
// and widen a collector host to its whole product domain, so publish nothing until the full embedded
// version/build is running. Registration reset the old filter to fail-open before this check.
let expected = ExtensionManager.expectedExtensionVersion
guard Self.acceptsRegisteredVersion(version, expected: expected) else {
self.status?(L("The installed filter extension is %@ and this app is %@ — macOS is replacing it. Capture resumes once it has.",
version, expected))
self.onRepairRequested?()
self.scheduleReconnect(L("Trying the filter extension again…"), from: connection)
return
}
self.recovery.succeeded()
self.status?(self.sawTraffic ? L("Connected to filter extension %@", version)
: L("Connected to filter extension %@ — no traffic from it yet", version))
Expand Down
2 changes: 1 addition & 1 deletion Flowlight/Inspection/InspectionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ final class InspectionController: ObservableObject {
let destination = Self.proxyDestination(host)
var refusals = (self?.requestRules() ?? []).filter {
$0.action == .block && ($0.destination.isEmpty
|| AgentPolicy.matches($0.destination, host: destination.host, ip: destination.ip))
|| $0.destinationMatches(host: destination.host, ip: destination.ip))
}
if refusals.contains(where: { !$0.app.isEmpty }) {
// Only then is it worth asking who opened this connection: naming an app is the uncommon case,
Expand Down
Loading
Loading