supd.m   [plain text]

/*
* Copyright (c) 2017-2018 Apple Inc. All Rights Reserved.
*
* @APPLE_LICENSE_HEADER_START@
*
* This file contains Original Code and/or Modifications of Original Code
* as defined in and that are subject to the Apple Public Source License
* Version 2.0 (the 'License'). You may not use this file except in
* compliance with the License. Please obtain a copy of the License at
* http://www.opensource.apple.com/apsl/ and read it before using this
* file.
*
* The Original Code and all software distributed under the License are
* distributed on an 'AS IS' basis, WITHOUT WARRANTY OF ANY KIND, EITHER
* EXPRESS OR IMPLIED, AND APPLE HEREBY DISCLAIMS ALL SUCH WARRANTIES,
* INCLUDING WITHOUT LIMITATION, ANY WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE, QUIET ENJOYMENT OR NON-INFRINGEMENT.
* Please see the License for the specific language governing rights and
* limitations under the License.
*
* @APPLE_LICENSE_HEADER_END@
*/

#import "supd.h"
#import <Foundation/NSXPCConnection_Private.h>

#if !TARGET_OS_SIMULATOR

#import "SFAnalyticsDefines.h"
#import "SFAnalyticsSQLiteStore.h"
#import <Security/SFAnalytics.h>

#include <utilities/SecFileLocations.h>
#import "utilities/debugging.h"
#import <os/variant_private.h>
#import <xpc/xpc.h>
#include <notify.h>
#import "keychain/ckks/CKKSControl.h"
#import <zlib.h>

#import <AuthKit/AKAppleIDAuthenticationContext.h>
#import <AuthKit/AKAppleIDAuthenticationController.h>
#import <AuthKit/AKAppleIDAuthenticationController_Private.h>

#if TARGET_OS_OSX
#include "dirhelper_priv.h"
#include <membership.h>
#endif

#if TARGET_OS_OSX
#import <CrashReporterSupport/CrashReporterSupportPrivate.h>
#else
#import <CrashReporterSupport/CrashReporterSupport.h>
#endif

#import <Accounts/Accounts.h>
#import <Accounts/ACAccountStore_Private.h>
#import <Accounts/ACAccountType_Private.h>
#import <Accounts/ACAccountStore.h>
#import <AppleAccount/AppleAccount.h>
#import <AppleAccount/ACAccount+AppleAccount.h>
#import <AppleAccount/ACAccountStore+AppleAccount.h>

#import "utilities/simulatecrash_assert.h"
#import "trust/trustd/trustdFileHelper/trustdFileHelper.h"

NSString* const SFAnalyticsSplunkTopic = @"topic";
NSString* const SFAnalyticsClientId = @"clientId";
NSString* const SFAnalyticsInternal = @"internal";

NSString* const SFAnalyticsMetricsBase = @"metricsBase";
NSString* const SFAnalyticsDeviceID = @"ckdeviceID";
NSString* const SFAnalyticsAltDSID = @"altDSID";

NSString* const SFAnalyticsEventCorrelationID = @"eventLinkID";

NSString* const SFAnalyticsSecondsCustomerKey = @"SecondsBetweenUploadsCustomer";
NSString* const SFAnalyticsSecondsInternalKey = @"SecondsBetweenUploadsInternal";
NSString* const SFAnalyticsSecondsSeedKey = @"SecondsBetweenUploadsSeed";
NSString* const SFAnalyticsMaxEventsKey = @"NumberOfEvents";
NSString* const SFAnalyticsDevicePercentageCustomerKey = @"DevicePercentageCustomer";
NSString* const SFAnalyticsDevicePercentageInternalKey = @"DevicePercentageInternal";
NSString* const SFAnalyticsDevicePercentageSeedKey = @"DevicePercentageSeed";

NSString* const SupdErrorDomain = @"com.apple.security.supd";

#define SFANALYTICS_SPLUNK_DEV 0
#define OS_CRASH_TRACER_LOG_BUG_TYPE "226"

#if SFANALYTICS_SPLUNK_DEV
NSUInteger const secondsBetweenUploadsCustomer = 10;
NSUInteger const secondsBetweenUploadsInternal = 10;
NSUInteger const secondsBetweenUploadsSeed = 10;
#else // SFANALYTICS_SPLUNK_DEV
NSUInteger const secondsBetweenUploadsCustomer = (3 * (60 * 60 * 24));
NSUInteger const secondsBetweenUploadsInternal = (60 * 60 * 24);
NSUInteger const secondsBetweenUploadsSeed = (60 * 60 * 24);
#endif // SFANALYTICS_SPLUNK_DEV

@implementation SFAnalyticsReporter
- (BOOL)saveReport:(NSData *)reportData fileName:(NSString *)fileName
{
BOOL writtenToLog = NO;
#if TARGET_OS_OSX
NSDictionary *optionsDictionary = @{ (__bridge NSString *)kCRProblemReportSubmissionPolicyKey: (__bridge NSString *)kCRSubmissionPolicyAlternate };
#else // !TARGET_OS_OSX
NSDictionary *optionsDictionary = nil; // The keys above are not defined or required on iOS.
#endif // !TARGET_OS_OSX

secdebug("saveReport", "calling out to `OSAWriteLogForSubmission`");
writtenToLog = OSAWriteLogForSubmission(@OS_CRASH_TRACER_LOG_BUG_TYPE, fileName,
nil, optionsDictionary, ^(NSFileHandle *fileHandle) {
secnotice("OSAWriteLogForSubmission", "Writing log data to report: %@", fileName);
[fileHandle writeData:reportData];
});
return writtenToLog;
}
@end

#define DEFAULT_SPLUNK_MAX_EVENTS_TO_REPORT 1000
#define DEFAULT_SPLUNK_DEVICE_PERCENTAGE 100

BOOL runningTests = NO;
BOOL deviceAnalyticsOverride = NO;
BOOL deviceAnalyticsEnabled = NO;
BOOL iCloudAnalyticsOverride = NO;
BOOL iCloudAnalyticsEnabled = NO;

static BOOL
_isDeviceAnalyticsEnabled(void)
{
// This flag is only set during tests.
if (deviceAnalyticsOverride) {
return deviceAnalyticsEnabled;
}

static BOOL dataCollectionEnabled = NO;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
#if TARGET_OS_IPHONE
dataCollectionEnabled = DiagnosticLogSubmissionEnabled();
#elif TARGET_OS_OSX
dataCollectionEnabled = CRIsAutoSubmitEnabled();
#endif
});
return dataCollectionEnabled;
}

static NSString *
accountAltDSID(void)
{
ACAccountStore *accountStore = [[ACAccountStore alloc] init];
ACAccount *primaryAccount = [accountStore aa_primaryAppleAccount];
if (primaryAccount == nil) {
return nil;
}
return [primaryAccount aa_altDSID];
}

static NSString *const kAnalyticsiCloudIdMSKey = @"com.apple.idms.config.privacy.icloud.data";

static NSDictionary *
_getiCloudConfigurationInfoWithError(NSError **outError)
{
__block NSDictionary *outConfigurationInfo = nil;
__block NSError *localError = nil;

NSString *altDSID = accountAltDSID();
if (altDSID != nil) {
secnotice("_getiCloudConfigurationInfoWithError", "Fetching configuration info");

dispatch_semaphore_t sema = dispatch_semaphore_create(0);
AKAppleIDAuthenticationController *authController = [AKAppleIDAuthenticationController new];
[authController configurationInfoWithIdentifiers:@[kAnalyticsiCloudIdMSKey]
forAltDSID:altDSID
completion:^(NSDictionary<NSString *, id<NSSecureCoding>> *configurationInfo, NSError *error) {
if (error) {
secerror("_getiCloudConfigurationInfoWithError: Error fetching configurationInfo: %@", error);
localError = error;
} else if (![configurationInfo isKindOfClass:[NSDictionary class]]) {
secerror("_getiCloudConfigurationInfoWithError: configurationInfo dict was not a dict, it was a %{public}@", [configurationInfo class]);
localError = error;
configurationInfo = nil;
} else {
secnotice("_getiCloudConfigurationInfoWithError", "fetched configurationInfo %@", configurationInfo);
outConfigurationInfo = configurationInfo;
}
dispatch_semaphore_signal(sema);
}];
dispatch_semaphore_wait(sema, dispatch_time(DISPATCH_TIME_NOW, (uint64_t)(5 * NSEC_PER_SEC)));
} else {
secerror("_getiCloudConfigurationInfoWithError: Failed to fetch primary account info.");
}

if (localError && outError) {
*outError = localError;
}
return outConfigurationInfo;
}

static BOOL
_isiCloudAnalyticsEnabled()
{
// This flag is only set during tests.
if (iCloudAnalyticsOverride) {
return iCloudAnalyticsEnabled;
}

static bool cachedAllowsICloudAnalytics = false;

static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSError *error = nil;
NSDictionary *accountConfiguration = _getiCloudConfigurationInfoWithError(&error);
if (error == nil && accountConfiguration != nil) {
id iCloudAnalyticsOptIn = accountConfiguration[kAnalyticsiCloudIdMSKey];
if (iCloudAnalyticsOptIn != nil) {
BOOL iCloudAnalyticsOptInHasCorrectType = ([iCloudAnalyticsOptIn isKindOfClass:[NSNumber class]] || [iCloudAnalyticsOptIn isKindOfClass:[NSString class]]);
if (iCloudAnalyticsOptInHasCorrectType) {
NSNumber *iCloudAnalyticsOptInNumber = @([iCloudAnalyticsOptIn integerValue]);
cachedAllowsICloudAnalytics = ![iCloudAnalyticsOptInNumber isEqualToNumber:[NSNumber numberWithInteger:0]];
}
}
} else if (error != nil) {
secerror("_isiCloudAnalyticsEnabled: %@", error);
}
});

return cachedAllowsICloudAnalytics;
}

/* NSData GZip category based on GeoKit's implementation */
@interface NSData (GZip)
- (NSData *)supd_gzipDeflate;
@end

#define GZIP_OFFSET 16
#define GZIP_STRIDE_LEN 16384

@implementation NSData (Gzip)
- (NSData *)supd_gzipDeflate
{
if ([self length] == 0) {
return self;
}

z_stream strm;
memset(&strm, 0, sizeof(strm));
strm.next_in=(uint8_t *)[self bytes];
strm.avail_in = (unsigned int)[self length];

if (Z_OK != deflateInit2(&strm, Z_BEST_COMPRESSION, Z_DEFLATED,
MAX_WBITS + GZIP_OFFSET, MAX_MEM_LEVEL, Z_DEFAULT_STRATEGY)) {
return nil;
}

NSMutableData *compressed = [NSMutableData dataWithLength:GZIP_STRIDE_LEN];

do {
if (strm.total_out >= [compressed length]) {
[compressed increaseLengthBy: 16384];
}

strm.next_out = [compressed mutableBytes] + strm.total_out;
strm.avail_out = (int)[compressed length] - (int)strm.total_out;

deflate(&strm, Z_FINISH);

} while (strm.avail_out == 0);

deflateEnd(&strm);

[compressed setLength: strm.total_out];
if (strm.avail_in == 0) {
return [NSData dataWithData:compressed];
} else {
return nil;
}
}
@end

@implementation SFAnalyticsClient {
NSString* _path;
NSString* _name;
BOOL _requireDeviceAnalytics;
BOOL _requireiCloudAnalytics;
}

@synthesize storePath = _path;
@synthesize name = _name;

- (instancetype)initWithStorePath:(NSString*)path name:(NSString*)name
deviceAnalytics:(BOOL)deviceAnalytics iCloudAnalytics:(BOOL)iCloudAnalytics {
if (self = [super init]) {
_path = path;
_name = name;
_requireDeviceAnalytics = deviceAnalytics;
_requireiCloudAnalytics = iCloudAnalytics;
}
return self;
}

@end

@interface SFAnalyticsTopic ()
@property NSURL* _splunkUploadURL;

@property BOOL allowInsecureSplunkCert;
@property BOOL ignoreServersMessagesTellingUsToGoAway;
@property BOOL disableUploads;
@property BOOL disableClientId;

@property NSUInteger secondsBetweenUploads;
@property NSUInteger maxEventsToReport;
@property float devicePercentage; // for sampling reporting devices

@property NSDictionary* metricsBase; // data the server provides and wants us to send back
@property NSArray* blacklistedFields;
@property NSArray* blacklistedEvents;
@end

@implementation SFAnalyticsTopic

- (void)setupClientsForTopic:(NSString *)topicName
{
NSMutableArray<SFAnalyticsClient*>* clients = [NSMutableArray<SFAnalyticsClient*> new];
if ([topicName isEqualToString:SFAnalyticsTopicKeySync]) {
[clients addObject:[[SFAnalyticsClient alloc] initWithStorePath:[self.class databasePathForCKKS]
name:@"ckks" deviceAnalytics:NO iCloudAnalytics:YES]];
[clients addObject:[[SFAnalyticsClient alloc] initWithStorePath:[self.class databasePathForSOS]
name:@"sos" deviceAnalytics:NO iCloudAnalytics:YES]];
[clients addObject:[[SFAnalyticsClient alloc] initWithStorePath:[self.class databasePathForPCS]
name:@"pcs" deviceAnalytics:NO iCloudAnalytics:YES]];
[clients addObject:[[SFAnalyticsClient alloc] initWithStorePath:[self.class databasePathForSignIn]
name:@"signins" deviceAnalytics:NO iCloudAnalytics:YES]];
[clients addObject:[[SFAnalyticsClient alloc] initWithStorePath:[self.class databasePathForLocal]
name:@"local" deviceAnalytics:YES iCloudAnalytics:NO]];
} else if ([topicName isEqualToString:SFAnalyticsTopicCloudServices]) {
[clients addObject:[[SFAnalyticsClient alloc] initWithStorePath:[self.class databasePathForCloudServices]
name:@"CloudServices"
deviceAnalytics:YES
iCloudAnalytics:NO]];
} else if ([topicName isEqualToString:SFAnalyticsTopicTrust]) {
[clients addObject:[[SFAnalyticsClient alloc] initWithStorePath:[self.class databasePathForTrust]
name:@"trust" deviceAnalytics:YES iCloudAnalytics:NO]];
#if TARGET_OS_OSX
[clients addObject:[[SFAnalyticsClient alloc] initWithStorePath:[self.class databasePathForRootTrust]
name:@"rootTrust" deviceAnalytics:YES iCloudAnalytics:NO]];
#endif
} else if ([topicName isEqualToString:SFAnalyticsTopicNetworking]) {
[clients addObject:[[SFAnalyticsClient alloc] initWithStorePath:[self.class databasePathForNetworking]
name:@"networking" deviceAnalytics:YES iCloudAnalytics:NO]];
#if TARGET_OS_OSX
[clients addObject:[[SFAnalyticsClient alloc] initWithStorePath:[self.class databasePathForRootNetworking]
name:@"rootNetworking" deviceAnalytics:YES iCloudAnalytics:NO]];
#endif
} else if ([topicName isEqualToString:SFAnalyticsTopicTransparency]) {
[clients addObject:[[SFAnalyticsClient alloc] initWithStorePath:[self.class databasePathForTransparency]
name:@"transparency" deviceAnalytics:NO iCloudAnalytics:YES]];
}

_topicClients = clients;
}

- (instancetype)initWithDictionary:(NSDictionary *)dictionary name:(NSString *)topicName samplingRates:(NSDictionary *)rates {
if (self = [super init]) {
_internalTopicName = topicName;
[self setupClientsForTopic:topicName];
_splunkTopicName = dictionary[@"splunk_topic"];
__splunkUploadURL = [NSURL URLWithString:dictionary[@"splunk_uploadURL"]];
_splunkBagURL = [NSURL URLWithString:dictionary[@"splunk_bagURL"]];
_allowInsecureSplunkCert = [[dictionary valueForKey:@"splunk_allowInsecureCertificate"] boolValue];
_uploadSizeLimit = [[dictionary valueForKey:@"uploadSizeLimit"] unsignedIntegerValue];

NSString* splunkEndpoint = dictionary[@"splunk_endpointDomain"];
if (dictionary[@"disableClientId"]) {
_disableClientId = YES;
}

NSUserDefaults* defaults = [[NSUserDefaults alloc] initWithSuiteName:SFAnalyticsUserDefaultsSuite];
NSString* userDefaultsSplunkTopic = [defaults stringForKey:@"splunk_topic"];
if (userDefaultsSplunkTopic) {
_splunkTopicName = userDefaultsSplunkTopic;
}

NSURL* userDefaultsSplunkUploadURL = [NSURL URLWithString:[defaults stringForKey:@"splunk_uploadURL"]];
if (userDefaultsSplunkUploadURL) {
__splunkUploadURL = userDefaultsSplunkUploadURL;
}

NSURL* userDefaultsSplunkBagURL = [NSURL URLWithString:[defaults stringForKey:@"splunk_bagURL"]];
if (userDefaultsSplunkBagURL) {
_splunkBagURL = userDefaultsSplunkBagURL;
}

NSInteger userDefaultsUploadSizeLimit = [defaults integerForKey:@"uploadSizeLimit"];
if (userDefaultsUploadSizeLimit > 0) {
_uploadSizeLimit = userDefaultsUploadSizeLimit;
}

BOOL userDefaultsAllowInsecureSplunkCert = [defaults boolForKey:@"splunk_allowInsecureCertificate"];
_allowInsecureSplunkCert |= userDefaultsAllowInsecureSplunkCert;

NSString* userDefaultsSplunkEndpoint = [defaults stringForKey:@"splunk_endpointDomain"];
if (userDefaultsSplunkEndpoint) {
splunkEndpoint = userDefaultsSplunkEndpoint;
}

#if SFANALYTICS_SPLUNK_DEV
_secondsBetweenUploads = secondsBetweenUploadsInternal;
_maxEventsToReport = SFAnalyticsMaxEventsToReport;
_devicePercentage = DEFAULT_SPLUNK_DEVICE_PERCENTAGE;
#else
bool internal = os_variant_has_internal_diagnostics("com.apple.security");
if (rates) {
#if RC_SEED_BUILD
NSNumber *secondsNum = internal ? rates[SFAnalyticsSecondsInternalKey] : rates[SFAnalyticsSecondsSeedKey];
NSNumber *percentageNum = internal ? rates[SFAnalyticsDevicePercentageInternalKey] : rates[SFAnalyticsDevicePercentageSeedKey];
#else
NSNumber *secondsNum = internal ? rates[SFAnalyticsSecondsInternalKey] : rates[SFAnalyticsSecondsCustomerKey];
NSNumber *percentageNum = internal ? rates[SFAnalyticsDevicePercentageInternalKey] : rates[SFAnalyticsDevicePercentageCustomerKey];
#endif
_secondsBetweenUploads = [secondsNum integerValue];
_maxEventsToReport = [rates[SFAnalyticsMaxEventsKey] unsignedIntegerValue];
_devicePercentage = [percentageNum floatValue];
} else {
#if RC_SEED_BUILD
_secondsBetweenUploads = internal ? secondsBetweenUploadsInternal : secondsBetweenUploadsSeed;
#else
_secondsBetweenUploads = internal ? secondsBetweenUploadsInternal : secondsBetweenUploadsCustomer;
#endif
_maxEventsToReport = SFAnalyticsMaxEventsToReport;
_devicePercentage = DEFAULT_SPLUNK_DEVICE_PERCENTAGE;
}
#endif
secnotice("supd", "created %@ with %lu seconds between uploads, %lu max events, %f percent of uploads",
_internalTopicName, (unsigned long)_secondsBetweenUploads, (unsigned long)_maxEventsToReport, _devicePercentage);

#if SFANALYTICS_SPLUNK_DEV
_ignoreServersMessagesTellingUsToGoAway = YES;

if (!_splunkUploadURL && splunkEndpoint) {
NSString* urlString = [NSString stringWithFormat:@"https://%@/report/2/%@", splunkEndpoint, _splunkTopicName];
_splunkUploadURL = [NSURL URLWithString:urlString];
}
#else
(void)splunkEndpoint;
#endif
}
return self;
}

- (BOOL)isSampledUpload
{
uint32_t sample = arc4random();
if ((double)_devicePercentage < ((double)1 / UINT32_MAX) * 100) {
/* Requested percentage is smaller than we can sample. just do 1 out of UINT32_MAX */
if (sample == 0) {
return YES;
}
} else {
if ((double)sample <= (double)UINT32_MAX * ((double)_devicePercentage / 100)) {
return YES;
}
}
return NO;
}

- (BOOL)postJSON:(NSData*)json toEndpoint:(NSURL*)endpoint error:(NSError**)error
{
if (!endpoint) {
if (error) {
NSString *description = [NSString stringWithFormat:@"No endpoint for %@", _internalTopicName];
*error = [NSError errorWithDomain:@"SupdUploadErrorDomain"
code:-10
userInfo:@{NSLocalizedDescriptionKey : description}];
}
return false;
}
/*
* Create the NSURLSession
* We use the ephemeral session config because we don't need cookies or cache
*/
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration ephemeralSessionConfiguration];

configuration.HTTPAdditionalHeaders = @{ @"User-Agent" : [NSString stringWithFormat:@"securityd/%s", SECURITY_BUILD_VERSION]};

NSURLSession* postSession = [NSURLSession sessionWithConfiguration:configuration
delegate:self
delegateQueue:nil];

NSMutableURLRequest* postRequest = [[NSMutableURLRequest alloc] init];
postRequest.URL = endpoint;
postRequest.HTTPMethod = @"POST";
postRequest.HTTPBody = [json supd_gzipDeflate];
[postRequest setValue:@"gzip" forHTTPHeaderField:@"Content-Encoding"];

/*
* Create the upload task.
*/
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
__block BOOL uploadSuccess = NO;
NSURLSessionDataTask* uploadTask = [postSession dataTaskWithRequest:postRequest
completionHandler:^(NSData * _Nullable __unused data, NSURLResponse * _Nullable response, NSError * _Nullable requestError) {
if (requestError) {
secerror("Error in uploading the events to splunk for %@: %@", self->_internalTopicName, requestError);
} else if (![response isKindOfClass:NSHTTPURLResponse.class]){
Class class = response.class;
secerror("Received the wrong kind of response for %@: %@", self->_internalTopicName, NSStringFromClass(class));
} else {
NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)response;
if(httpResponse.statusCode >= 200 && httpResponse.statusCode < 300) {
/* Success */
uploadSuccess = YES;
secnotice("upload", "Splunk upload success for %@", self->_internalTopicName);
} else {
secnotice("upload", "Splunk upload for %@ unexpected status to URL: %@ -- status: %d",
self->_internalTopicName, endpoint, (int)(httpResponse.statusCode));
}
}
dispatch_semaphore_signal(sem);
}];
secnotice("upload", "Splunk upload start for %@", self->_internalTopicName);
[uploadTask resume];
dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (uint64_t)(5 * 60 * NSEC_PER_SEC)));
return uploadSuccess;
}

- (BOOL)eventIsBlacklisted:(NSMutableDictionary*)event {
return _blacklistedEvents ? [_blacklistedEvents containsObject:event[SFAnalyticsEventType]] : NO;
}

- (void)removeBlacklistedFieldsFromEvent:(NSMutableDictionary*)event {
for (NSString* badField in self->_blacklistedFields) {
[event removeObjectForKey:badField];
}
}

- (void)addRequiredFieldsToEvent:(NSMutableDictionary*)event {
[_metricsBase enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
if (!event[key]) {
event[key] = obj;
}
}];
}

- (BOOL)prepareEventForUpload:(NSMutableDictionary*)event
linkedUUID:(NSUUID *)linkedUUID {
if ([self eventIsBlacklisted:event]) {
return NO;
}

[self removeBlacklistedFieldsFromEvent:event];
[self addRequiredFieldsToEvent:event];
if (_disableClientId) {
event[SFAnalyticsClientId] = @(0);
}
event[SFAnalyticsSplunkTopic] = self->_splunkTopicName ?: [NSNull null];
if (linkedUUID) {
event[SFAnalyticsEventCorrelationID] = [linkedUUID UUIDString];
}
return YES;
}

- (void)addFailures:(NSMutableArray<NSArray*>*)failures toUploadRecords:(NSMutableArray*)records threshold:(NSUInteger)threshold linkedUUID:(NSUUID *)linkedUUID
{
// The first 0 through 'threshold' items are getting uploaded in any case (which might be 0 for lower priority data)

for (NSArray* client in failures) {
[client enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
NSMutableDictionary* event = (NSMutableDictionary*)obj;
if (idx >= threshold) {
*stop = YES;
return;
}
if ([self prepareEventForUpload:event linkedUUID:linkedUUID]) {
if ([NSJSONSerialization isValidJSONObject:event]) {
[records addObject:event];
} else {
secerror("supd: Replacing event with errorEvent because invalid JSON: %@", event);
NSString* originalType = event[SFAnalyticsEventType];
NSDictionary* errorEvent = @{ SFAnalyticsEventType : SFAnalyticsEventTypeErrorEvent,
SFAnalyticsEventErrorDestription : [NSString stringWithFormat:@"JSON:%@", originalType]};
[records addObject:errorEvent];
}
}
}];
}

// Are there more items than we shoved into the upload records?
NSInteger excessItems = 0;
for (NSArray* client in failures) {
NSInteger localExcess = client.count - threshold;
excessItems += localExcess > 0 ? localExcess : 0;
}

// Then, if we have space and items left, apply a scaling factor to distribute events across clients to fill upload buffer
if (records.count < _maxEventsToReport && excessItems > 0) {
double scale = (_maxEventsToReport - records.count) / (double)excessItems;
if (scale > 1) {
scale = 1;
}

for (NSArray* client in failures) {
if (client.count > threshold) {
NSRange range = NSMakeRange(threshold, (client.count - threshold) * scale);
NSArray* sub = [client subarrayWithRange:range];
[sub enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
if ([self prepareEventForUpload:obj linkedUUID:linkedUUID]) {
[records addObject:obj];
}
}];
}
}
}
}

- (NSMutableDictionary*)sampleStatisticsForSamples:(NSArray*)samples withName:(NSString*)name
{
NSMutableDictionary* statistics = [NSMutableDictionary dictionary];
NSUInteger count = samples.count;
NSArray* sortedSamples = [samples sortedArrayUsingSelector:@selector(compare:)];
NSArray* samplesAsExpressionArray = @[[NSExpression expressionForConstantValue:sortedSamples]];

if (count == 1) {
statistics[name] = samples[0];
} else {
// NSExpression takes population standard deviation. Our data is a sample of whatever we sampled over time,
// but the difference between the two is fairly minor (divide by N before taking sqrt versus divide by N-1).
statistics[[NSString stringWithFormat:@"%@-dev", name]] = [[NSExpression expressionForFunction:@"stddev:" arguments:samplesAsExpressionArray] expressionValueWithObject:nil context:nil];

statistics[[NSString stringWithFormat:@"%@-min", name]] = [[NSExpression expressionForFunction:@"min:" arguments:samplesAsExpressionArray] expressionValueWithObject:nil context:nil];
statistics[[NSString stringWithFormat:@"%@-max", name]] = [[NSExpression expressionForFunction:@"max:" arguments:samplesAsExpressionArray] expressionValueWithObject:nil context:nil];
statistics[[NSString stringWithFormat:@"%@-avg", name]] = [[NSExpression expressionForFunction:@"average:" arguments:samplesAsExpressionArray] expressionValueWithObject:nil context:nil];
statistics[[NSString stringWithFormat:@"%@-med", name]] = [[NSExpression expressionForFunction:@"median:" arguments:samplesAsExpressionArray] expressionValueWithObject:nil context:nil];
}

if (count > 3) {
NSString* q1 = [NSString stringWithFormat:@"%@-1q", name];
NSString* q3 = [NSString stringWithFormat:@"%@-3q", name];
// From Wikipedia, which is never wrong
if (count % 2 == 0) {
// The lower quartile value is the median of the lower half of the data. The upper quartile value is the median of the upper half of the data.
statistics[q1] = [[NSExpression expressionForFunction:@"median:" arguments:@[[NSExpression expressionForConstantValue:[sortedSamples subarrayWithRange:NSMakeRange(0, count / 2)]]]] expressionValueWithObject:nil context:nil];
statistics[q3] = [[NSExpression expressionForFunction:@"median:" arguments:@[[NSExpression expressionForConstantValue:[sortedSamples subarrayWithRange:NSMakeRange((count / 2), count / 2)]]]] expressionValueWithObject:nil context:nil];
} else if (count % 4 == 1) {
// If there are (4n+1) data points, then the lower quartile is 25% of the nth data value plus 75% of the (n+1)th data value;
// the upper quartile is 75% of the (3n+1)th data point plus 25% of the (3n+2)th data point.
// (offset n by -1 since we count from 0)
NSUInteger n = count / 4;
statistics[q1] = @(([sortedSamples[n - 1] doubleValue] + [sortedSamples[n] doubleValue] * 3.0) / 4.0);
statistics[q3] = @(([sortedSamples[(3 * n)] doubleValue] * 3.0 + [sortedSamples[(3 * n) + 1] doubleValue]) / 4.0);
} else if (count % 4 == 3){
// If there are (4n+3) data points, then the lower quartile is 75% of the (n+1)th data value plus 25% of the (n+2)th data value;
// the upper quartile is 25% of the (3n+2)th data point plus 75% of the (3n+3)th data point.
// (offset n by -1 since we count from 0)
NSUInteger n = count / 4;
statistics[q1] = @(([sortedSamples[n] doubleValue] * 3.0 + [sortedSamples[n + 1] doubleValue]) / 4.0);
statistics[q3] = @(([sortedSamples[(3 * n) + 1] doubleValue] + [sortedSamples[(3 * n) + 2] doubleValue] * 3.0) / 4.0);
}
}

return statistics;
}

- (NSMutableDictionary*)healthSummaryWithName:(NSString*)name store:(SFAnalyticsSQLiteStore*)store uuid:(NSUUID *)uuid
{
__block NSMutableDictionary* summary = [NSMutableDictionary new];

// Add some events of our own before pulling in data
summary[SFAnalyticsEventType] = [NSString stringWithFormat:@"%@HealthSummary", name];
if ([self eventIsBlacklisted:summary]) {
return nil;
}
summary[SFAnalyticsEventTime] = @([[NSDate date] timeIntervalSince1970] * 1000); // Splunk wants milliseconds
[SFAnalytics addOSVersionToEvent:summary];
if (store.uploadDate) {
summary[SFAnalyticsAttributeLastUploadTime] = @([store.uploadDate timeIntervalSince1970] * 1000);
} else {
summary[SFAnalyticsAttributeLastUploadTime] = @(0);
}

// Process counters
NSDictionary* successCounts = store.summaryCounts;
__block NSInteger totalSuccessCount = 0;
__block NSInteger totalHardFailureCount = 0;
__block NSInteger totalSoftFailureCount = 0;
[successCounts enumerateKeysAndObjectsUsingBlock:^(NSString* _Nonnull eventType, NSDictionary* _Nonnull counts, BOOL* _Nonnull stop) {
summary[[NSString stringWithFormat:@"%@-success", eventType]] = counts[SFAnalyticsColumnSuccessCount];
summary[[NSString stringWithFormat:@"%@-hardfail", eventType]] = counts[SFAnalyticsColumnHardFailureCount];
summary[[NSString stringWithFormat:@"%@-softfail", eventType]] = counts[SFAnalyticsColumnSoftFailureCount];
totalSuccessCount += [counts[SFAnalyticsColumnSuccessCount] integerValue];
totalHardFailureCount += [counts[SFAnalyticsColumnHardFailureCount] integerValue];
totalSoftFailureCount += [counts[SFAnalyticsColumnSoftFailureCount] integerValue];
}];

summary[SFAnalyticsColumnSuccessCount] = @(totalSuccessCount);
summary[SFAnalyticsColumnHardFailureCount] = @(totalHardFailureCount);
summary[SFAnalyticsColumnSoftFailureCount] = @(totalSoftFailureCount);
if (os_variant_has_internal_diagnostics("com.apple.security")) {
summary[SFAnalyticsInternal] = @YES;
}

// Process samples
NSMutableDictionary<NSString*,NSMutableArray*>* samplesBySampler = [NSMutableDictionary<NSString*,NSMutableArray*> dictionary];
for (NSDictionary* sample in [store samples]) {
if (!samplesBySampler[sample[SFAnalyticsColumnSampleName]]) {
samplesBySampler[sample[SFAnalyticsColumnSampleName]] = [NSMutableArray array];
}
[samplesBySampler[sample[SFAnalyticsColumnSampleName]] addObject:sample[SFAnalyticsColumnSampleValue]];
}
[samplesBySampler enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, NSMutableArray * _Nonnull obj, BOOL * _Nonnull stop) {
NSMutableDictionary* event = [self sampleStatisticsForSamples:obj withName:key];
[summary addEntriesFromDictionary:event];
}];

// Should always return yes because we already checked for event blacklisting specifically (unless summary itself is blacklisted)
if (![self prepareEventForUpload:summary linkedUUID:uuid]) {
secwarning("supd: health summary for %@ blacklisted", name);
return nil;
}

// Seems unlikely because we only insert strings, samplers only take NSNumbers and frankly, sampleStatisticsForSamples probably would have crashed
if (![NSJSONSerialization isValidJSONObject:summary]) {
secerror("json: health summary for client %@ is invalid JSON: %@", name, summary);
return [@{ SFAnalyticsEventType : SFAnalyticsEventTypeErrorEvent,
SFAnalyticsEventErrorDestription : [NSString stringWithFormat:@"JSON:%@HealthSummary", name]} mutableCopy];
}

return summary;
}

- (void)updateUploadDateForClients:(NSArray<SFAnalyticsClient*>*)clients date:(NSDate *)date clearData:(BOOL)clearData
{
for (SFAnalyticsClient* client in clients) {
SFAnalyticsSQLiteStore* store = [SFAnalyticsSQLiteStore storeWithPath:client.storePath schema:SFAnalyticsTableSchema];
secnotice("postprocess", "Setting upload date (%@) for client: %@", date, client.name);
store.uploadDate = date;
if (clearData) {
secnotice("postprocess", "Clearing collected data for client: %@", client.name);
[store clearAllData];
}
}
}

- (size_t)serializedEventSize:(NSObject *)event
error:(NSError**)error
{
if (![NSJSONSerialization isValidJSONObject:event]) {
secnotice("serializedEventSize", "invalid JSON object");
return 0;
}

NSData *json = [NSJSONSerialization dataWithJSONObject:event
options:0
error:error];
if (json) {
return [json length];
} else {
secnotice("serializedEventSize", "failed to serialize event");
return 0;
}
}

- (NSArray<NSArray *> *)chunkFailureSet:(size_t)sizeCapacity
events:(NSArray<NSDictionary *> *)events
error:(NSError **)error
{
const size_t postBodyLimit = 1000; // 1000 events in a single upload
size_t currentSize = 0;
size_t currentEventCount = 0;

NSMutableArray<NSArray<NSDictionary *> *> *eventChunks = [[NSMutableArray<NSArray<NSDictionary *> *> alloc] init];
NSMutableArray<NSDictionary *> *currentEventChunk = [[NSMutableArray<NSDictionary *> alloc] init];
for (NSDictionary *event in events) {
NSError *localError = nil;
size_t eventSize = [self serializedEventSize:event error:&localError];
if (localError != nil) {
if (error) {
*error = localError;
}
secemergency("Unable to serialize event JSON: %@", [localError localizedDescription]);
return nil;
}

BOOL countLessThanLimit = currentEventCount < postBodyLimit;
BOOL sizeLessThanCapacity = (currentSize + eventSize) <= sizeCapacity;
if (!countLessThanLimit || !sizeLessThanCapacity) {
[eventChunks addObject:currentEventChunk];
currentEventChunk = [[NSMutableArray<NSDictionary *> alloc] init];
currentEventCount = 0;
currentSize = 0;
}

[currentEventChunk addObject:event];
currentEventCount++;
currentSize += eventSize;
}

if ([currentEventChunk count] > 0) {
[eventChunks addObject:currentEventChunk];
}

return eventChunks;
}

- (NSDictionary *)createEventDictionary:(NSArray *)healthSummaries
failures:(NSArray<NSDictionary *> *)failures
error:(NSError **)error
{
NSMutableArray *events = [[NSMutableArray alloc] init];
[events addObjectsFromArray:healthSummaries];
if (failures) {
[events addObjectsFromArray:failures];
}

NSDictionary *eventDictionary = @{
SFAnalyticsPostTime : @([[NSDate date] timeIntervalSince1970] * 1000),
@"events" : events,
};

if (![NSJSONSerialization isValidJSONObject:eventDictionary]) {
secemergency("json: final dictionary invalid JSON.");
if (error) {
*error = [NSError errorWithDomain:SupdErrorDomain code:SupdInvalidJSONError
userInfo:@{NSLocalizedDescriptionKey : [NSString localizedStringWithFormat:@"Final dictionary for upload is invalid JSON: %@", eventDictionary]}];
}
return nil;
}

return eventDictionary;
}

- (NSArray<NSDictionary *> *)createChunkedLoggingJSON:(NSArray<NSDictionary *> *)healthSummaries
failures:(NSArray<NSDictionary *> *)failures
error:(NSError **)error
{
NSError *localError = nil;
size_t baseSize = [self serializedEventSize:healthSummaries error:&localError];
if (localError != nil) {
secemergency("Unable to serialize health summary JSON");
if (error) {
*error = localError;
}
return nil;
}

NSArray<NSArray *> *chunkedEvents = [self chunkFailureSet:(self.uploadSizeLimit - baseSize) events:failures error:&localError];

NSMutableArray<NSDictionary *> *jsonResults = [[NSMutableArray<NSDictionary *> alloc] init];
for (NSArray<NSDictionary *> *failureSet in chunkedEvents) {
NSDictionary *eventDictionary = [self createEventDictionary:healthSummaries failures:failureSet error:error];
if (eventDictionary) {
[jsonResults addObject:eventDictionary];
} else {
return nil;
}
}

if ([jsonResults count] == 0) {
NSDictionary *eventDictionary = [self createEventDictionary:healthSummaries failures:nil error:error];
if (eventDictionary) {
[jsonResults addObject:eventDictionary];
} else {
return nil;
}
}

return jsonResults;
}

- (BOOL)copyEvents:(NSMutableArray<NSDictionary *> **)healthSummaries
failures:(NSMutableArray<NSDictionary *> **)failures
forUpload:(BOOL)upload
participatingClients:(NSMutableArray<SFAnalyticsClient*>**)clients
force:(BOOL)force
linkedUUID:(NSUUID *)linkedUUID
error:(NSError**)error
{
NSMutableArray<SFAnalyticsClient*> *localClients = [[NSMutableArray alloc] init];
NSMutableArray<NSDictionary *> *localHealthSummaries = [[NSMutableArray<NSDictionary *> alloc] init];
NSMutableArray<NSDictionary *> *localFailures = [[NSMutableArray<NSDictionary *> alloc] init];
NSMutableArray<NSArray*> *hardFailures = [[NSMutableArray alloc] init];
NSMutableArray<NSArray*> *softFailures = [[NSMutableArray alloc] init];
NSString *ckdeviceID = nil;
NSString *accountID = nil;

if (os_variant_has_internal_diagnostics("com.apple.security") && [_internalTopicName isEqualToString:SFAnalyticsTopicKeySync]) {
ckdeviceID = [self askSecurityForCKDeviceID];
accountID = accountAltDSID();
}
for (SFAnalyticsClient* client in self->_topicClients) {
@autoreleasepool {
if (!force && [client requireDeviceAnalytics] && !_isDeviceAnalyticsEnabled()) {
// Client required device analytics, yet the user did not opt in.
secnotice("getLoggingJSON", "Client '%@' requires device analytics yet user did not opt in.", [client name]);
continue;
}
if (!force && [client requireiCloudAnalytics] && !_isiCloudAnalyticsEnabled()) {
// Client required iCloud analytics, yet the user did not opt in.
secnotice("getLoggingJSON", "Client '%@' requires iCloud analytics yet user did not opt in.", [client name]);
continue;
}

SFAnalyticsSQLiteStore* store = [SFAnalyticsSQLiteStore storeWithPath:client.storePath schema:SFAnalyticsTableSchema];

if (upload) {
NSDate* uploadDate = store.uploadDate;
if (!force && uploadDate && [[NSDate date] timeIntervalSinceDate:uploadDate] < _secondsBetweenUploads) {
secnotice("json", "ignoring client '%@' for %@ because last upload too recent: %@",
client.name, _internalTopicName, uploadDate);
continue;
}

if (force) {
secnotice("json", "client '%@' for topic '%@' force-included", client.name, _internalTopicName);
} else {
secnotice("json", "including client '%@' for topic '%@' for upload", client.name, _internalTopicName);
}
[localClients addObject:client];
}

NSMutableDictionary* healthSummary = [self healthSummaryWithName:client.name store:store uuid:linkedUUID];
if (healthSummary) {
if (ckdeviceID) {
healthSummary[SFAnalyticsDeviceID] = ckdeviceID;
}
if (accountID) {
healthSummary[SFAnalyticsAltDSID] = accountID;
}
[localHealthSummaries addObject:healthSummary];
}

[hardFailures addObject:store.hardFailures];
[softFailures addObject:store.softFailures];
}
}

if (upload && [localClients count] == 0) {
if (error) {
NSString *description = [NSString stringWithFormat:@"Upload too recent for all clients for %@", _internalTopicName];
*error = [NSError errorWithDomain:@"SupdUploadErrorDomain"
code:-10
userInfo:@{NSLocalizedDescriptionKey : description}];
}
return NO;
}

if (clients) {
*clients = localClients;
}

if (failures) {
[self addFailures:hardFailures toUploadRecords:localFailures threshold:_maxEventsToReport/10 linkedUUID:linkedUUID];
[self addFailures:softFailures toUploadRecords:localFailures threshold:0 linkedUUID:linkedUUID];
[*failures addObjectsFromArray:localFailures];
}

if (healthSummaries) {
[*healthSummaries addObjectsFromArray:localHealthSummaries];
}

return YES;
}

- (NSArray<NSDictionary *> *)createChunkedLoggingJSON:(bool)pretty
forUpload:(BOOL)upload
participatingClients:(NSMutableArray<SFAnalyticsClient*>**)clients
force:(BOOL)force // supdctl uploads ignore privacy settings and recency
error:(NSError**)error
{
NSUUID *linkedUUID = [NSUUID UUID];
NSError *localError = nil;
NSMutableArray *failures = [[NSMutableArray alloc] init];
NSMutableArray *healthSummaries = [[NSMutableArray alloc] init];
BOOL copied = [self copyEvents:&healthSummaries
failures:&failures
forUpload:upload
participatingClients:clients
force:force
linkedUUID:linkedUUID
error:&localError];
if (!copied || localError) {
if (error) {
*error = localError;
}
return nil;
}

// Trim failures to the max count, based on health summary count
if ([failures count] > (_maxEventsToReport - [healthSummaries count])) {
NSRange range;
range.location = 0;
range.length = _maxEventsToReport - [healthSummaries count];
failures = [[failures subarrayWithRange:range] mutableCopy];
}

return [self createChunkedLoggingJSON:healthSummaries failures:failures error:error];
}

- (NSDictionary *)createLoggingJSON:(bool)pretty
forUpload:(BOOL)upload
participatingClients:(NSMutableArray<SFAnalyticsClient*>**)clients
force:(BOOL)force // supdctl uploads ignore privacy settings and recency
error:(NSError**)error
{
NSError *localError = nil;
NSMutableArray *failures = [[NSMutableArray alloc] init];
NSMutableArray *healthSummaries = [[NSMutableArray alloc] init];
BOOL copied = [self copyEvents:&healthSummaries
failures:&failures
forUpload:upload
participatingClients:clients
force:force
linkedUUID:nil
error:&localError];
if (!copied || localError) {
if (error) {
*error = localError;
}
return nil;
}

// Trim failures to the max count, based on health summary count
if ([failures count] > (_maxEventsToReport - [healthSummaries count])) {
NSRange range;
range.location = 0;
range.length = _maxEventsToReport - [healthSummaries count];
failures = [[failures subarrayWithRange:range] mutableCopy];
}

return [self createEventDictionary:healthSummaries failures:failures error:error];
}

// Is at least one client eligible for data collection based on user consent? Otherwise callers should NOT reach off-device.
- (BOOL)haveEligibleClients {
for (SFAnalyticsClient* client in self.topicClients) {
if ((!client.requireDeviceAnalytics || _isDeviceAnalyticsEnabled()) &&
(!client.requireiCloudAnalytics || _isiCloudAnalyticsEnabled())) {
return YES;
}
}
return NO;
}

- (NSString*)askSecurityForCKDeviceID
{
NSError* error = nil;
CKKSControl* rpc = [CKKSControl controlObject:&error];
if(error || !rpc) {
secerror("unable to obtain CKKS endpoint: %@", error);
return nil;
}

__block NSString* localCKDeviceID;
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
[rpc rpcGetCKDeviceIDWithReply:^(NSString* ckdeviceID) {
localCKDeviceID = ckdeviceID;
dispatch_semaphore_signal(sema);
}];

if (dispatch_semaphore_wait(sema, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC * 10)) != 0) {
secerror("timed out waiting for a response from security");
return nil;
}

return localCKDeviceID;
}

// this method is kind of evil for the fact that it has side-effects in pulling other things besides the metricsURL from the server, and as such should NOT be memoized.
// TODO redo this, probably to return a dictionary.
- (NSURL*)splunkUploadURL:(BOOL)force
{
if (!force && ![self haveEligibleClients]) { // force is true IFF called from supdctl. Customers don't have it and internal audiences must call it explicitly.
secnotice("getURL", "Not going to talk to server for topic %@ because no eligible clients", [self internalTopicName]);
return nil;
}

if (__splunkUploadURL) {
return __splunkUploadURL;
}

secnotice("getURL", "Asking server for endpoint and config data for topic %@", [self internalTopicName]);

__weak __typeof(self) weakSelf = self;
dispatch_semaphore_t sem = dispatch_semaphore_create(0);

__block NSError* error = nil;
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration ephemeralSessionConfiguration];
NSURLSession* storeBagSession = [NSURLSession sessionWithConfiguration:configuration
delegate:self
delegateQueue:nil];

NSURL* requestEndpoint = _splunkBagURL;
__block NSURL* result = nil;
NSURLSessionDataTask* storeBagTask = [storeBagSession dataTaskWithURL:requestEndpoint completionHandler:^(NSData * _Nullable data,
NSURLResponse * _Nullable __unused response,
NSError * _Nullable responseError) {

__strong __typeof(self) strongSelf = weakSelf;
if (!strongSelf) {
return;
}

if (data && !responseError) {
NSData *responseData = data; // shut up compiler
NSDictionary* responseDict = [NSJSONSerialization JSONObjectWithData:responseData options:0 error:&error];
if([responseDict isKindOfClass:NSDictionary.class] && !error) {
if (!self->_ignoreServersMessagesTellingUsToGoAway) {
self->_disableUploads = [[responseDict valueForKey:@"sendDisabled"] boolValue];
if (self->_disableUploads) {
// then don't upload anything right now
secerror("not returning a splunk URL because uploads are disabled for %@", self->_internalTopicName);
dispatch_semaphore_signal(sem);
return;
}

// backend works with milliseconds
NSUInteger secondsBetweenUploads = [[responseDict valueForKey:@"postFrequency"] unsignedIntegerValue] / 1000;
if (secondsBetweenUploads > 0) {
if (os_variant_has_internal_diagnostics("com.apple.security") &&
self->_secondsBetweenUploads < secondsBetweenUploads) {
secnotice("getURL", "Overriding server-sent post frequency because device is internal (%lu -> %lu)", (unsigned long)secondsBetweenUploads, (unsigned long)self->_secondsBetweenUploads);
} else {
strongSelf->_secondsBetweenUploads = secondsBetweenUploads;
}
}

strongSelf->_blacklistedEvents = responseDict[@"blacklistedEvents"];
strongSelf->_blacklistedFields = responseDict[@"blacklistedFields"];
}

strongSelf->_metricsBase = responseDict[@"metricsBase"];

NSString* metricsEndpoint = responseDict[@"metricsUrl"];
if([metricsEndpoint isKindOfClass:NSString.class]) {
/* Lives our URL */
NSString* endpoint = [metricsEndpoint stringByAppendingFormat:@"/2/%@", strongSelf->_splunkTopicName];
secnotice("upload", "got metrics endpoint %@ for %@", endpoint, self->_internalTopicName);
NSURL* endpointURL = [NSURL URLWithString:endpoint];
if([endpointURL.scheme isEqualToString:@"https"]) {
result = endpointURL;
}
}
}
}
else {
error = responseError;
}
if (error) {
secnotice("upload", "Unable to fetch splunk endpoint at URL for %@: %@ -- error: %@",
self->_internalTopicName, requestEndpoint, error.description);
}
else if (!result) {
secnotice("upload", "Malformed iTunes config payload for %@!", self->_internalTopicName);
}

dispatch_semaphore_signal(sem);
}];

[storeBagTask resume];
dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (uint64_t)(60 * NSEC_PER_SEC)));

return result;
}

- (void)URLSession:(NSURLSession *)session didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential *))completionHandler {
assert(completionHandler);
(void)session;
secnotice("upload", "Splunk upload challenge for %@", _internalTopicName);
NSURLCredential *cred = nil;

if ([challenge previousFailureCount] > 0) {
// Previous failures occurred, bail
completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil);

} else if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
/*
* Evaluate trust for the certificate
*/

SecTrustRef serverTrust = challenge.protectionSpace.serverTrust;
// Coverity gets upset if we don't check status even though result is all we need.
bool trustResult = SecTrustEvaluateWithError(serverTrust, NULL);
if (_allowInsecureSplunkCert || trustResult) {
/*
* All is well, accept the credentials
*/
if(_allowInsecureSplunkCert) {
secnotice("upload", "Force Accepting Splunk Credential for %@", _internalTopicName);
}
cred = [NSURLCredential credentialForTrust:serverTrust];
completionHandler(NSURLSessionAuthChallengeUseCredential, cred);

} else {
/*
* An error occurred in evaluating trust, bail
*/
completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil);
}
} else {
/*
* Just perform the default handling
*/
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil);
}
}

- (NSDictionary*)eventDictWithBlacklistedFieldsStrippedFrom:(NSDictionary*)eventDict
{
NSMutableDictionary* strippedDict = eventDict.mutableCopy;
for (NSString* blacklistedField in _blacklistedFields) {
[strippedDict removeObjectForKey:blacklistedField];
}
return strippedDict;
}

// MARK: Database path retrieval

+ (NSString*)databasePathForCKKS
{
return [(__bridge_transfer NSURL*)SecCopyURLForFileInKeychainDirectory((__bridge CFStringRef)@"Analytics/ckks_analytics.db") path];
}

+ (NSString*)databasePathForSOS
{
return [(__bridge_transfer NSURL*)SecCopyURLForFileInKeychainDirectory((__bridge CFStringRef)@"Analytics/sos_analytics.db") path];
}

+ (NSString*)AppSupportPath
{
#if TARGET_OS_IPHONE && !TARGET_OS_SIMULATOR
return @"/var/mobile/Library/Application Support";
#else
NSArray<NSString *>*paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true);
if ([paths count] < 1) {
return nil;
}
return [NSString stringWithString: paths[0]];
#endif /* TARGET_OS_IPHONE && !TARGET_OS_SIMULATOR */
}

+ (NSString*)databasePathForPCS
{
NSString *appSup = [self AppSupportPath];
if (!appSup) {
return nil;
}
NSString *dbpath = [NSString stringWithFormat:@"%@/com.apple.ProtectedCloudStorage/PCSAnalytics.db", appSup];
secnotice("supd", "PCS Database path (%@)", dbpath);
return dbpath;
}

+ (NSString*)databasePathForLocal
{
return [(__bridge_transfer NSURL*)SecCopyURLForFileInKeychainDirectory((__bridge CFStringRef)@"Analytics/localkeychain.db") path];
}

+ (NSString*)databasePathForTrust
{
#if TARGET_OS_IPHONE
return [(__bridge_transfer NSURL*)SecCopyURLForFileInKeychainDirectory(CFSTR("Analytics/trust_analytics.db")) path];
#else
return [SFAnalytics defaultProtectedAnalyticsDatabasePath:@"trust_analytics"];
#endif
}

#if TARGET_OS_OSX
#define TRUSTD_ROLE_ACCOUNT 282

+ (NSUUID *)trustdUUID
{
uuid_t rootUuid;
int ret = mbr_uid_to_uuid(282, rootUuid);
if (ret != 0) {
return nil;
}
return [[NSUUID alloc] initWithUUIDBytes:rootUuid];
}
#endif

#if TARGET_OS_OSX
+ (NSString*)databasePathForRootTrust
{
return [SFAnalytics defaultProtectedAnalyticsDatabasePath:@"trust_analytics" uuid:[SFAnalyticsTopic trustdUUID]];
}
#endif

+ (NSString*)databasePathForNetworking
{
#if TARGET_OS_IPHONE
return [(__bridge_transfer NSURL*)SecCopyURLForFileInKeychainDirectory(CFSTR("Analytics/networking_analytics.db")) path];
#else
return [SFAnalytics defaultProtectedAnalyticsDatabasePath:@"networking_analytics"];
#endif
}

#if TARGET_OS_OSX
+ (NSString*)databasePathForRootNetworking
{
return [SFAnalytics defaultProtectedAnalyticsDatabasePath:@"networking_analytics" uuid:[SFAnalyticsTopic trustdUUID]];
}
#endif

+ (NSString*)databasePathForSignIn
{
return [(__bridge_transfer NSURL*)SecCopyURLForFileInKeychainDirectory(CFSTR("Analytics/signin_metrics.db")) path];
}

+ (NSString*)databasePathForCloudServices
{
return [(__bridge_transfer NSURL*)SecCopyURLForFileInKeychainDirectory(CFSTR("Analytics/CloudServicesAnalytics.db")) path];
}

+ (NSString*)databasePathForTransparency
{
return [(__bridge_transfer NSURL*)SecCopyURLForFileInKeychainDirectory((__bridge CFStringRef)@"Analytics/TransparencyAnalytics.db") path];
}

@end

@interface supd ()
@property NSDictionary *topicsSamplingRates;
@property NSXPCConnection *connection;
@end

@implementation supd
- (void)setupTopics
{
NSDictionary* systemDefaultValues = [NSDictionary dictionaryWithContentsOfFile:[[NSBundle bundleWithPath:@"/System/Library/Frameworks/Security.framework"] pathForResource:@"SFAnalytics" ofType:@"plist"]];
NSMutableArray <SFAnalyticsTopic*>* topics = [NSMutableArray array];
for (NSString *topicKey in systemDefaultValues) {
NSDictionary *topicSamplingRates = _topicsSamplingRates[topicKey];
SFAnalyticsTopic *topic = [[SFAnalyticsTopic alloc] initWithDictionary:systemDefaultValues[topicKey] name:topicKey samplingRates:topicSamplingRates];
[topics addObject:topic];
}
_analyticsTopics = [NSArray arrayWithArray:topics];
}

static NSString *SystemTrustStorePath = @"/System/Library/Security/Certificates.bundle";
static NSString *AnalyticsSamplingRatesFilename = @"AnalyticsSamplingRates";
static NSString *ContentVersionKey = @"MobileAssetContentVersion";
static NSString *AssetContextFilename = @"OTAPKIContext.plist";

static NSNumber *getSystemVersion(NSBundle *trustStoreBundle) {
NSDictionary *systemVersionPlist = [NSDictionary dictionaryWithContentsOfURL:[trustStoreBundle URLForResource:@"AssetVersion"
withExtension:@"plist"]];
if (!systemVersionPlist || ![systemVersionPlist isKindOfClass:[NSDictionary class]]) {
return nil;
}
NSNumber *systemVersion = systemVersionPlist[ContentVersionKey];
if (systemVersion == nil || ![systemVersion isKindOfClass:[NSNumber class]]) {
return nil;
}
return systemVersion;
}

static NSNumber *getAssetVersion(NSURL *directory) {
NSDictionary *assetContextPlist = [NSDictionary dictionaryWithContentsOfURL:[directory URLByAppendingPathComponent:AssetContextFilename]];
if (!assetContextPlist || ![assetContextPlist isKindOfClass:[NSDictionary class]]) {
return nil;
}
NSNumber *assetVersion = assetContextPlist[ContentVersionKey];
if (assetVersion == nil || ![assetVersion isKindOfClass:[NSNumber class]]) {
return nil;
}
return assetVersion;
}

static bool ShouldInitializeWithAsset(NSBundle *trustStoreBundle, NSURL *directory) {
NSNumber *systemVersion = getSystemVersion(trustStoreBundle);
NSNumber *assetVersion = getAssetVersion(directory);

if (assetVersion == nil || systemVersion == nil) {
return false;
}
if ([assetVersion compare:systemVersion] == NSOrderedDescending) {
return true;
}
return false;
}

- (void)setupSamplingRates {
NSBundle *trustStoreBundle = [NSBundle bundleWithPath:SystemTrustStorePath];

NSURL *protectedDirectory = CFBridgingRelease(SecCopyURLForFileInProtectedDirectory(CFSTR("trustd/")));
NSURL *directory = [protectedDirectory URLByAppendingPathComponent:@"SupplementalsAssets/" isDirectory:YES];

NSDictionary *analyticsSamplingRates = nil;
if (ShouldInitializeWithAsset(trustStoreBundle, directory)) {
/* Try to get the asset version of the sampling rates */
NSURL *analyticsSamplingRateURL = [directory URLByAppendingPathComponent:[NSString stringWithFormat:@"%@.plist", AnalyticsSamplingRatesFilename]];
analyticsSamplingRates = [NSDictionary dictionaryWithContentsOfURL:analyticsSamplingRateURL];
secnotice("supd", "read sampling rates from SupplementalsAssets dir");
if (!analyticsSamplingRates || ![analyticsSamplingRates isKindOfClass:[NSDictionary class]]) {
analyticsSamplingRates = nil;
}
}
if (!analyticsSamplingRates) {
analyticsSamplingRates = [NSDictionary dictionaryWithContentsOfURL: [trustStoreBundle URLForResource:AnalyticsSamplingRatesFilename
withExtension:@"plist"]];
}
if (analyticsSamplingRates && [analyticsSamplingRates isKindOfClass:[NSDictionary class]]) {
_topicsSamplingRates = analyticsSamplingRates[@"Topics"];
if (!_topicsSamplingRates || ![analyticsSamplingRates isKindOfClass:[NSDictionary class]]) {
_topicsSamplingRates = nil; // Something has gone terribly wrong, so we'll use the hardcoded defaults in this case
}
}
}

- (instancetype)initWithConnection:(NSXPCConnection *)connection reporter:(SFAnalyticsReporter *)reporter
{
if ((self = [super init])) {
_connection = connection;
_reporter = reporter;
[self setupSamplingRates];
[self setupTopics];

static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
xpc_activity_register("com.apple.securityuploadd.triggerupload", XPC_ACTIVITY_CHECK_IN, ^(xpc_activity_t activity) {
xpc_activity_state_t activityState = xpc_activity_get_state(activity);
secnotice("supd", "hit xpc activity trigger, state: %ld", activityState);
if (activityState == XPC_ACTIVITY_STATE_RUN) {
// Run our regularly scheduled scan
[self performRegularlyScheduledUpload];
}
});
});
}
return self;
}

- (instancetype)initWithConnection:(NSXPCConnection *)connection {
SFAnalyticsReporter *reporter = [[SFAnalyticsReporter alloc] init];
return [self initWithConnection:connection reporter:reporter];
}

- (void)sendNotificationForOncePerReportSamplers
{
notify_post(SFAnalyticsFireSamplersNotification);
[NSThread sleepForTimeInterval:3.0];
}

- (void)performRegularlyScheduledUpload {
secnotice("upload", "Starting uploads in response to regular trigger");
NSError *error = nil;
if ([self uploadAnalyticsWithError:&error force:NO]) {
secnotice("upload", "Regularly scheduled upload successful");
} else {
secerror("upload: Failed to complete regularly scheduled upload: %@", error);
}
}

- (NSArray<NSData *> *)serializeLoggingEvents:(NSArray<NSDictionary *> *)events
error:(NSError **)error
{
if (!events) {
return nil;
}

NSMutableArray<NSData *> *serializedEvents = [[NSMutableArray<NSData *> alloc] init];
for (NSDictionary *event in events) {
NSError *serializationError = nil;
NSData* serializedEvent = [NSJSONSerialization dataWithJSONObject:event
options:0
error:&serializationError];
if (serializedEvent && !serializationError) {
[serializedEvents addObject:serializedEvent];
} else if (error) {
*error = serializationError;
return nil;
}
}

return serializedEvents;
}

- (BOOL)uploadAnalyticsWithError:(NSError**)error force:(BOOL)force {
[self sendNotificationForOncePerReportSamplers];

BOOL result = NO;
NSError* localError = nil;
for (SFAnalyticsTopic *topic in _analyticsTopics) {
@autoreleasepool { // The logging JSONs get quite large. Ensure they're deallocated between topics.
__block NSURL* endpoint = [topic splunkUploadURL:force]; // has side effects!

if (!endpoint) {
secnotice("upload", "Skipping upload for %@ because no endpoint", [topic internalTopicName]);
continue;
}

if ([topic disableUploads]) {
secnotice("upload", "Aborting upload task for %@ because uploads are disabled", [topic internalTopicName]);
continue;
}

NSMutableArray<SFAnalyticsClient*>* clients = [NSMutableArray new];
NSArray<NSDictionary *> *jsonEvents = [topic createChunkedLoggingJSON:false forUpload:YES participatingClients:&clients force:force error:&localError];
if (!jsonEvents || localError) {
if ([[localError domain] isEqualToString:SupdErrorDomain] && [localError code] == SupdInvalidJSONError) {
// Pretend this was a success because at least we'll get rid of bad data.
// If someone keeps logging bad data and we only catch it here then
// this causes sustained data loss for the entire topic.
[topic updateUploadDateForClients:clients date:[NSDate date] clearData:YES];
}
secerror("upload: failed to create chunked log events for logging topic %@: %@", [topic internalTopicName], localError);
continue;
}

NSArray<NSData *> *serializedEvents = [self serializeLoggingEvents:jsonEvents error:&localError];
if (!serializedEvents || localError) {
if ([[localError domain] isEqualToString:SupdErrorDomain] && [localError code] == SupdInvalidJSONError) {
// Pretend this was a success because at least we'll get rid of bad data.
// If someone keeps logging bad data and we only catch it here then
// this causes sustained data loss for the entire topic.
[topic updateUploadDateForClients:clients date:[NSDate date] clearData:YES];
}
secerror("upload: failed to serialized chunked log events for logging topic %@: %@", [topic internalTopicName], localError);
continue;
}

if ([topic isSampledUpload]) {
for (NSData *json in serializedEvents) {
if (![self->_reporter saveReport:json fileName:[topic internalTopicName]]) {
secerror("upload: failed to write analytics data to log");
}
if ([topic postJSON:json toEndpoint:endpoint error:&localError]) {
secnotice("upload", "Successfully posted JSON for %@", [topic internalTopicName]);
result = YES;
[topic updateUploadDateForClients:clients date:[NSDate date] clearData:YES];
} else {
secerror("upload: Failed to post JSON for %@: %@", [topic internalTopicName], localError);
}
}
} else {
/* If we didn't sample this report, update date to prevent trying to upload again sooner
* than we should. Clear data so that per-day calculations remain consistent. */
secnotice("upload", "skipping unsampled upload for %@ and clearing data", [topic internalTopicName]);
[topic updateUploadDateForClients:clients date:[NSDate date] clearData:YES];
}
}
if (error && localError) {
*error = localError;
}
}
return result;
}

- (NSString*)sysdiagnoseStringForEventRecord:(NSDictionary*)eventRecord
{
NSMutableDictionary* mutableEventRecord = eventRecord.mutableCopy;
[mutableEventRecord removeObjectForKey:SFAnalyticsSplunkTopic];

NSDate* eventDate = [NSDate dateWithTimeIntervalSince1970:[[eventRecord valueForKey:SFAnalyticsEventTime] doubleValue] / 1000];
[mutableEventRecord removeObjectForKey:SFAnalyticsEventTime];

NSString* eventName = eventRecord[SFAnalyticsEventType];
[mutableEventRecord removeObjectForKey:SFAnalyticsEventType];

SFAnalyticsEventClass eventClass = [[eventRecord valueForKey:SFAnalyticsEventClassKey] integerValue];
NSString* eventClassString = [self stringForEventClass:eventClass];
[mutableEventRecord removeObjectForKey:SFAnalyticsEventClassKey];

NSMutableString* additionalAttributesString = [NSMutableString string];
if (mutableEventRecord.count > 0) {
[additionalAttributesString appendString:@" - Attributes: {" ];
__block BOOL firstAttribute = YES;
[mutableEventRecord enumerateKeysAndObjectsUsingBlock:^(NSString* key, id object, BOOL* stop) {
NSString* openingString = firstAttribute ? @"" : @", ";
[additionalAttributesString appendString:[NSString stringWithFormat:@"%@%@ : %@", openingString, key, object]];
firstAttribute = NO;
}];
[additionalAttributesString appendString:@" }"];
}

return [NSString stringWithFormat:@"%@ %@: %@%@", eventDate, eventClassString, eventName, additionalAttributesString];
}

- (NSString*)getSysdiagnoseDump
{
NSMutableString* sysdiagnose = [[NSMutableString alloc] init];

for (SFAnalyticsTopic* topic in _analyticsTopics) {
for (SFAnalyticsClient* client in topic.topicClients) {
[sysdiagnose appendString:[NSString stringWithFormat:@"Client: %@\n", client.name]];
SFAnalyticsSQLiteStore* store = [SFAnalyticsSQLiteStore storeWithPath:client.storePath schema:SFAnalyticsTableSchema];
NSArray* allEvents = store.allEvents;
for (NSDictionary* eventRecord in allEvents) {
[sysdiagnose appendFormat:@"%@\n", [self sysdiagnoseStringForEventRecord:eventRecord]];
}
if (allEvents.count == 0) {
[sysdiagnose appendString:@"No data to report for this client\n"];
}
}
}
return sysdiagnose;
}

- (NSString*)stringForEventClass:(SFAnalyticsEventClass)eventClass
{
if (eventClass == SFAnalyticsEventClassNote) {
return @"EventNote";
}
else if (eventClass == SFAnalyticsEventClassSuccess) {
return @"EventSuccess";
}
else if (eventClass == SFAnalyticsEventClassHardFailure) {
return @"EventHardFailure";
}
else if (eventClass == SFAnalyticsEventClassSoftFailure) {
return @"EventSoftFailure";
}
else {
return @"EventUnknown";
}
}

// MARK: XPC Procotol Handlers
- (BOOL)checkSupdEntitlement {
NSNumber *supdEntitlement = [self.connection valueForEntitlement:@"com.apple.private.securityuploadd"];
if (![supdEntitlement isKindOfClass:[NSNumber class]] || ![supdEntitlement boolValue]) {
return NO;
}
return YES;
}

- (void)getSysdiagnoseDumpWithReply:(void (^)(NSString*))reply {
if ([self checkSupdEntitlement]) {
reply([self getSysdiagnoseDump]);
} else {
reply(@"client not entitled");
}
}

- (void)createLoggingJSON:(bool)pretty topic:(NSString *)topicName reply:(void (^)(NSData *, NSError*))reply {
if (![self checkSupdEntitlement]) {
NSError *error = [NSError errorWithDomain:NSOSStatusErrorDomain code:errSecMissingEntitlement userInfo:nil];
reply(nil, error);
return;
}

secnotice("rpcCreateLoggingJSON", "Building a JSON blob resembling the one we would have uploaded");
NSError* error = nil;
[self sendNotificationForOncePerReportSamplers];
NSDictionary *eventDictionary = nil;
for (SFAnalyticsTopic* topic in self->_analyticsTopics) {
if ([topic.internalTopicName isEqualToString:topicName]) {
eventDictionary = [topic createLoggingJSON:pretty forUpload:NO participatingClients:nil force:!runningTests error:&error];
}
}

NSData *data = nil;
if (!eventDictionary) {
secerror("Unable to obtain JSON: %@", error);
} else {
data = [NSJSONSerialization dataWithJSONObject:eventDictionary
options:(pretty ? NSJSONWritingPrettyPrinted : 0)
error:&error];
}

reply(data, error);
}

- (void)createChunkedLoggingJSON:(bool)pretty topic:(NSString *)topicName reply:(void (^)(NSData *, NSError*))reply
{
if (![self checkSupdEntitlement]) {
NSError *error = [NSError errorWithDomain:NSOSStatusErrorDomain code:errSecMissingEntitlement userInfo:nil];
reply(nil, error);
return;
}

secnotice("rpcCreateChunkedLoggingJSON", "Building an array of JSON blobs resembling the one we would have uploaded");
NSError* error = nil;
[self sendNotificationForOncePerReportSamplers];
NSArray<NSDictionary *> *events = nil;
for (SFAnalyticsTopic* topic in self->_analyticsTopics) {
if ([topic.internalTopicName isEqualToString:topicName]) {
events = [topic createChunkedLoggingJSON:pretty forUpload:NO participatingClients:nil force:!runningTests error:&error];
}
}

NSData *data = nil;
if (!events) {
secerror("Unable to obtain JSON: %@", error);
} else {
data = [NSJSONSerialization dataWithJSONObject:events
options:(pretty ? NSJSONWritingPrettyPrinted : 0)
error:&error];
}

reply(data, error);
}

- (void)forceUploadWithReply:(void (^)(BOOL, NSError*))reply {
if (![self checkSupdEntitlement]) {
NSError *error = [NSError errorWithDomain:NSOSStatusErrorDomain code:errSecMissingEntitlement userInfo:nil];
reply(NO, error);
return;
}

secnotice("upload", "Performing upload in response to rpc message");
NSError* error = nil;
BOOL result = [self uploadAnalyticsWithError:&error force:YES];
secnotice("upload", "Result of manually triggered upload: %@, error: %@", result ? @"success" : @"failure", error);
reply(result, error);
}

- (void)setUploadDateWith:(NSDate *)date reply:(void (^)(BOOL, NSError*))reply
{
if (![self checkSupdEntitlement]) {
NSError *error = [NSError errorWithDomain:NSOSStatusErrorDomain code:errSecMissingEntitlement userInfo:nil];
reply(NO, error);
return;
}

for (SFAnalyticsTopic* topic in _analyticsTopics) {
[topic updateUploadDateForClients:topic.topicClients date:date clearData:NO];
}
reply(YES, nil);
}

- (void)clientStatus:(void (^)(NSDictionary<NSString *, id> *, NSError *))reply
{
if (![self checkSupdEntitlement]) {
NSError *error = [NSError errorWithDomain:NSOSStatusErrorDomain code:errSecMissingEntitlement userInfo:nil];
reply(nil, error);
return;
}

NSMutableDictionary *info = [NSMutableDictionary dictionary];
for (SFAnalyticsTopic* topic in _analyticsTopics) {
for (SFAnalyticsClient *client in topic.topicClients) {
SFAnalyticsSQLiteStore* store = [SFAnalyticsSQLiteStore storeWithPath:client.storePath schema:SFAnalyticsTableSchema];

NSMutableDictionary *clientInfo = [NSMutableDictionary dictionary];
clientInfo[@"uploadDate"] = store.uploadDate;
info[client.name] = clientInfo;
}
}

reply(info, nil);
}

- (void)fixFiles:(void (^)(BOOL, NSError*))reply
{
if (![[self.connection valueForEntitlement:@"com.apple.private.trustd.FileHelp"] boolValue]) {
reply(NO, [NSError errorWithDomain:NSOSStatusErrorDomain code:errSecMissingEntitlement userInfo:nil]);
}
#if TARGET_OS_IPHONE
TrustdFileHelper *helper = [[TrustdFileHelper alloc] init];
[helper fixFiles:reply];
#else
reply(NO, [NSError errorWithDomain:NSOSStatusErrorDomain code:errSecUnimplemented userInfo:nil]);
#endif
}

@end

#endif // !TARGET_OS_SIMULATOR