From b8d7ed0c07342212ed63dfecb1111f21b546e471 Mon Sep 17 00:00:00 2001 From: Pete Markowsky Date: Wed, 13 Sep 2023 17:46:49 -0400 Subject: [PATCH] Add basic support for importing and exporting rules to/from JSON (#1170) * Add basic support for importing and exporting rules to/from JSON. --- Source/common/SNTRule.h | 5 + Source/common/SNTRule.m | 37 +++++ Source/common/SNTRuleTest.m | 62 ++++++++ Source/common/SNTXPCControlInterface.h | 1 + Source/common/SNTXPCControlInterface.m | 5 + Source/santactl/Commands/SNTCommandRule.m | 156 +++++++++++++++++++- Source/santad/DataLayer/SNTRuleTable.h | 5 + Source/santad/DataLayer/SNTRuleTable.m | 15 ++ Source/santad/DataLayer/SNTRuleTableTest.m | 21 +++ Source/santad/SNTDaemonControlController.mm | 15 ++ 10 files changed, 321 insertions(+), 1 deletion(-) diff --git a/Source/common/SNTRule.h b/Source/common/SNTRule.h index 340e8005b..b18f7ef31 100644 --- a/Source/common/SNTRule.h +++ b/Source/common/SNTRule.h @@ -79,4 +79,9 @@ /// - (void)resetTimestamp; +/// +/// Returns a dictionary representation of the rule. +/// +- (NSDictionary *)dictionaryRepresentation; + @end diff --git a/Source/common/SNTRule.m b/Source/common/SNTRule.m index 43b00a8bd..89aebd7aa 100644 --- a/Source/common/SNTRule.m +++ b/Source/common/SNTRule.m @@ -226,6 +226,43 @@ - (instancetype)initWithCoder:(NSCoder *)decoder { return self; } +- (NSString *)ruleStateToPolicyString:(SNTRuleState)state { + switch (state) { + case SNTRuleStateAllow: return kRulePolicyAllowlist; + case SNTRuleStateAllowCompiler: return kRulePolicyAllowlistCompiler; + case SNTRuleStateBlock: return kRulePolicyBlocklist; + case SNTRuleStateSilentBlock: return kRulePolicySilentBlocklist; + case SNTRuleStateRemove: return kRulePolicyRemove; + case SNTRuleStateAllowTransitive: return @"AllowTransitive"; + // This should never be hit. But is here for completion. + default: return @"Unknown"; + } +} + +- (NSString *)ruleTypeToString:(SNTRuleType)ruleType { + switch (ruleType) { + case SNTRuleTypeBinary: return kRuleTypeBinary; + case SNTRuleTypeCertificate: return kRuleTypeCertificate; + case SNTRuleTypeTeamID: return kRuleTypeTeamID; + case SNTRuleTypeSigningID: return kRuleTypeSigningID; + // This should never be hit. If we have rule types of Unknown then there's a + // coding error somewhere. + default: return @"Unknown"; + } +} + +// Returns an NSDictionary representation of the rule. Primarily use for +// exporting rules. +- (NSDictionary *)dictionaryRepresentation { + return @{ + kRuleIdentifier : self.identifier, + kRulePolicy : [self ruleStateToPolicyString:self.state], + kRuleType : [self ruleTypeToString:self.type], + kRuleCustomMsg : self.customMsg ?: @"", + kRuleCustomURL : self.customURL ?: @"" + }; +} + #undef DECODE #undef ENCODE #pragma clang diagnostic pop diff --git a/Source/common/SNTRuleTest.m b/Source/common/SNTRuleTest.m index 2a03edb70..28742122d 100644 --- a/Source/common/SNTRuleTest.m +++ b/Source/common/SNTRuleTest.m @@ -14,6 +14,7 @@ #import #include "Source/common/SNTCommonEnums.h" +#import "Source/common/SNTSyncConstants.h" #import "Source/common/SNTRule.h" @@ -95,12 +96,14 @@ - (void)testInitWithDictionaryValid { @"policy" : @"ALLOWLIST", @"rule_type" : @"TEAMID", @"custom_msg" : @"A custom block message", + @"custom_url" : @"https://example.com", }]; XCTAssertNotNil(sut); XCTAssertEqualObjects(sut.identifier, @"ABCDEFGHIJ"); XCTAssertEqual(sut.type, SNTRuleTypeTeamID); XCTAssertEqual(sut.state, SNTRuleStateAllow); XCTAssertEqualObjects(sut.customMsg, @"A custom block message"); + XCTAssertEqualObjects(sut.customURL, @"https://example.com"); // TeamIDs must be 10 chars in length sut = [[SNTRule alloc] initWithDictionary:@{ @@ -222,4 +225,63 @@ - (void)testInitWithDictionaryInvalid { XCTAssertNil(sut); } +- (void)testRuleDictionaryRepresentation { + NSDictionary *expectedTeamID = @{ + @"identifier" : @"ABCDEFGHIJ", + @"policy" : @"ALLOWLIST", + @"rule_type" : @"TEAMID", + @"custom_msg" : @"A custom block message", + @"custom_url" : @"https://example.com", + }; + + SNTRule *sut = [[SNTRule alloc] initWithDictionary:expectedTeamID]; + NSDictionary *dict = [sut dictionaryRepresentation]; + XCTAssertEqualObjects(expectedTeamID, dict); + + NSDictionary *expectedBinary = @{ + @"identifier" : @"84de9c61777ca36b13228e2446d53e966096e78db7a72c632b5c185b2ffe68a6", + @"policy" : @"BLOCKLIST", + @"rule_type" : @"BINARY", + @"custom_msg" : @"", + @"custom_url" : @"", + }; + + sut = [[SNTRule alloc] initWithDictionary:expectedBinary]; + dict = [sut dictionaryRepresentation]; + + XCTAssertEqualObjects(expectedBinary, dict); +} + +- (void)testRuleStateToPolicyString { + NSDictionary *expected = @{ + @"identifier" : @"84de9c61777ca36b13228e2446d53e966096e78db7a72c632b5c185b2ffe68a6", + @"policy" : @"ALLOWLIST", + @"rule_type" : @"BINARY", + @"custom_msg" : @"A custom block message", + @"custom_url" : @"https://example.com", + }; + + SNTRule *sut = [[SNTRule alloc] initWithDictionary:expected]; + sut.state = SNTRuleStateBlock; + XCTAssertEqualObjects(kRulePolicyBlocklist, [sut dictionaryRepresentation][kRulePolicy]); + sut.state = SNTRuleStateSilentBlock; + XCTAssertEqualObjects(kRulePolicySilentBlocklist, [sut dictionaryRepresentation][kRulePolicy]); + sut.state = SNTRuleStateAllow; + XCTAssertEqualObjects(kRulePolicyAllowlist, [sut dictionaryRepresentation][kRulePolicy]); + sut.state = SNTRuleStateAllowCompiler; + XCTAssertEqualObjects(kRulePolicyAllowlistCompiler, [sut dictionaryRepresentation][kRulePolicy]); + // Invalid states + sut.state = SNTRuleStateRemove; + XCTAssertEqualObjects(kRulePolicyRemove, [sut dictionaryRepresentation][kRulePolicy]); +} + +/* +- (void)testRuleTypeToString { + SNTRule *sut = [[SNTRule alloc] init]; + XCTAssertEqual(kRuleTypeBinary, [sut ruleTypeToString:@""]);//SNTRuleTypeBinary]); + XCTAssertEqual(kRuleTypeCertificate,[sut ruleTypeToString:SNTRuleTypeCertificate]); + XCTAssertEqual(kRuleTypeTeamID, [sut ruleTypeToString:SNTRuleTypeTeamID]); + XCTAssertEqual(kRuleTypeSigningID,[sut ruleTypeToString:SNTRuleTypeSigningID]); +}*/ + @end diff --git a/Source/common/SNTXPCControlInterface.h b/Source/common/SNTXPCControlInterface.h index 312e43ec2..d3a5cc03d 100644 --- a/Source/common/SNTXPCControlInterface.h +++ b/Source/common/SNTXPCControlInterface.h @@ -37,6 +37,7 @@ teamID:(NSString *)teamID signingID:(NSString *)signingID reply:(void (^)(SNTRule *))reply; +- (void)retrieveAllRules:(void (^)(NSArray *rules, NSError *error))reply; /// /// Config ops diff --git a/Source/common/SNTXPCControlInterface.m b/Source/common/SNTXPCControlInterface.m index 5d6929490..eded7fc52 100644 --- a/Source/common/SNTXPCControlInterface.m +++ b/Source/common/SNTXPCControlInterface.m @@ -53,6 +53,11 @@ + (void)initializeControlInterface:(NSXPCInterface *)r { forSelector:@selector(databaseRuleAddRules:cleanSlate:reply:) argumentIndex:0 ofReply:NO]; + + [r setClasses:[NSSet setWithObjects:[NSArray class], [SNTRule class], nil] + forSelector:@selector(retrieveAllRules:) + argumentIndex:0 + ofReply:YES]; } + (NSXPCInterface *)controlInterface { diff --git a/Source/santactl/Commands/SNTCommandRule.m b/Source/santactl/Commands/SNTCommandRule.m index 2f5078862..783feaaab 100644 --- a/Source/santactl/Commands/SNTCommandRule.m +++ b/Source/santactl/Commands/SNTCommandRule.m @@ -54,6 +54,8 @@ + (NSString *)longHelpText { @" --compiler: allow and mark as a compiler\n" @" --remove: remove existing rule\n" @" --check: check for an existing rule\n" + @" --import: import rules from a JSON file\n" + @" --export: export rules to a JSON file\n" @"\n" @" One of:\n" @" --path {path}: path of binary/bundle to add/remove.\n" @@ -62,6 +64,7 @@ + (NSString *)longHelpText { @" the rule state of a file.\n" @" --identifier {sha256|teamID|signingID}: identifier to add/remove/check\n" @" --sha256 {sha256}: hash to add/remove/check [deprecated]\n" + @" --json {path}: path to a JSON file containing a list of rules to add/remove\n" @"\n" @" Optionally:\n" @" --teamid: add or check a team ID rule instead of binary\n" @@ -81,7 +84,21 @@ + (NSString *)longHelpText { @" that the signing ID is properly scoped to a developer. For the special\n" @" case of platform binaries, `TeamID` should be replaced with the string\n" @" \"platform\" (e.g. `platform:SigningID`). This allows for rules\n" - @" targeting Apple-signed binaries that do not have a team ID.\n"); + @" targeting Apple-signed binaries that do not have a team ID.\n" + @"\n" + @" Importing / Exporting Rules:\n" + @" If santa is not configured to use a sync server one can export\n" + @" & import its non-static rules to and from JSON files using the \n" + @" --export/--import flags. These files have the following form:\n" + @"\n" + @" {\"rules\": [{rule-dictionaries}]}\n" + @" e.g. {\"rules\": [\n" + @" {\"policy\": \"BLOCKLIST\",\n" + @" \"identifier\": " + @"\"84de9c61777ca36b13228e2446d53e966096e78db7a72c632b5c185b2ffe68a6\"\n" + @" \"custom_url\" : \"\",\n" + @" \"custom_msg\": \"/bin/ls block for demo\"}\n" + @" ]}\n"); } - (void)runWithArguments:(NSArray *)arguments { @@ -103,7 +120,10 @@ - (void)runWithArguments:(NSArray *)arguments { newRule.type = SNTRuleTypeBinary; NSString *path; + NSString *jsonFilePath; BOOL check = NO; + BOOL importRules = NO; + BOOL exportRules = NO; // Parse arguments for (NSUInteger i = 0; i < arguments.count; ++i) { @@ -154,6 +174,33 @@ - (void)runWithArguments:(NSArray *)arguments { } else if ([arg caseInsensitiveCompare:@"--force"] == NSOrderedSame) { // Don't do anything special. #endif + } else if ([arg caseInsensitiveCompare:@"--json"] == NSOrderedSame) { + if (++i > arguments.count - 1) { + [self printErrorUsageAndExit:@"--json requires an argument"]; + } + jsonFilePath = arguments[i]; + } else if ([arg caseInsensitiveCompare:@"--import"] == NSOrderedSame) { + if (exportRules) { + [self printErrorUsageAndExit:@"--import and --export are mutually exclusive"]; + } + importRules = YES; + if (++i > arguments.count - 1) { + [self printErrorUsageAndExit:@"--import requires an argument"]; + } + jsonFilePath = arguments[i]; + } else if ([arg caseInsensitiveCompare:@"--export"] == NSOrderedSame) { + if (importRules) { + [self printErrorUsageAndExit:@"--import and --export are mutually exclusive"]; + } + exportRules = YES; + if (++i > arguments.count - 1) { + [self printErrorUsageAndExit:@"--export requires an argument"]; + } + jsonFilePath = arguments[i]; + } else if ([arg caseInsensitiveCompare:@"--help"] == NSOrderedSame || + [arg caseInsensitiveCompare:@"-h"] == NSOrderedSame) { + printf("%s\n", self.class.longHelpText.UTF8String); + exit(0); } else { [self printErrorUsageAndExit:[@"Unknown argument: " stringByAppendingString:arg]]; } @@ -189,6 +236,16 @@ - (void)runWithArguments:(NSArray *)arguments { return [self printStateOfRule:newRule daemonConnection:self.daemonConn]; } + // Note this block needs to come after the check block above. + if (jsonFilePath.length > 0) { + if (importRules) { + [self importJSONFile:jsonFilePath]; + } else if (exportRules) { + [self exportJSONFile:jsonFilePath]; + } + return; + } + if (newRule.state == SNTRuleStateUnknown) { [self printErrorUsageAndExit:@"No state specified"]; } else if (!newRule.identifier) { @@ -302,4 +359,101 @@ - (void)printStateOfRule:(SNTRule *)rule daemonConnection:(MOLXPCConnection *)da exit(0); } +- (void)importJSONFile:(NSString *)jsonFilePath { + // If the file exists parse it and then add the rules one at a time. + NSError *error; + NSData *data = [NSData dataWithContentsOfFile:jsonFilePath options:0 error:&error]; + if (error) { + [self printErrorUsageAndExit:[NSString stringWithFormat:@"Failed to read %@: %@", jsonFilePath, + error.localizedDescription]]; + } + + // We expect a JSON object with one key "rules". This is an array of rule + // objects. + // e.g. + // {"rules": [{ + // "policy" : "BLOCKLIST", + // "rule_type" : "BINARY", + // "identifier" : "84de9c61777ca36b13228e2446d53e966096e78db7a72c632b5c185b2ffe68a6" + // "custom_url" : "", + // "custom_msg" : "/bin/ls block for demo" + // }]} + NSDictionary *rules = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + if (error) { + [self printErrorUsageAndExit:[NSString stringWithFormat:@"Failed to parse %@: %@", jsonFilePath, + error.localizedDescription]]; + } + + NSMutableArray *parsedRules = [[NSMutableArray alloc] init]; + + for (NSDictionary *jsonRule in rules[@"rules"]) { + SNTRule *rule = [[SNTRule alloc] initWithDictionary:jsonRule]; + if (!rule) { + [self printErrorUsageAndExit:[NSString stringWithFormat:@"Invalid rule: %@", jsonRule]]; + } + [parsedRules addObject:rule]; + } + + [[self.daemonConn remoteObjectProxy] + databaseRuleAddRules:parsedRules + cleanSlate:NO + reply:^(NSError *error) { + if (error) { + printf("Failed to modify rules: %s", + [error.localizedDescription UTF8String]); + LOGD(@"Failure reason: %@", error.localizedFailureReason); + exit(1); + } + exit(0); + }]; +} + +- (void)exportJSONFile:(NSString *)jsonFilePath { + // Get the rules from the daemon and then write them to the file. + id rop = [self.daemonConn synchronousRemoteObjectProxy]; + [rop retrieveAllRules:^(NSArray *rules, NSError *error) { + if (error) { + printf("Failed to get rules: %s", [error.localizedDescription UTF8String]); + LOGD(@"Failure reason: %@", error.localizedFailureReason); + exit(1); + } + + if (rules.count == 0) { + printf("No rules to export.\n"); + exit(1); + } + // Convert Rules to an NSDictionary. + NSMutableArray *rulesAsDicts = [[NSMutableArray alloc] init]; + + for (SNTRule *rule in rules) { + // Omit transitive and remove rules as they're not relevan. + if (rule.state == SNTRuleStateAllowTransitive || rule.state == SNTRuleStateRemove) { + continue; + } + + [rulesAsDicts addObject:[rule dictionaryRepresentation]]; + } + + NSOutputStream *outputStream = [[NSOutputStream alloc] initToFileAtPath:jsonFilePath append:NO]; + [outputStream open]; + + // Write the rules to the file. + // File should look like the following JSON: + // {"rules": [{"policy": "ALLOWLIST", "identifier": hash, "rule_type: "BINARY"},}]} + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:@{@"rules" : rulesAsDicts} + options:NSJSONWritingPrettyPrinted + error:&error]; + // Print error + if (error) { + printf("Failed to jsonify rules: %s", [error.localizedDescription UTF8String]); + LOGD(@"Failure reason: %@", error.localizedFailureReason); + exit(1); + } + // Write jsonData to the file + [outputStream write:jsonData.bytes maxLength:jsonData.length]; + [outputStream close]; + exit(0); + }]; +} + @end diff --git a/Source/santad/DataLayer/SNTRuleTable.h b/Source/santad/DataLayer/SNTRuleTable.h index 2cbf622c4..6d7cb5389 100644 --- a/Source/santad/DataLayer/SNTRuleTable.h +++ b/Source/santad/DataLayer/SNTRuleTable.h @@ -101,6 +101,11 @@ /// - (void)removeOutdatedTransitiveRules; +/// +/// Retrieve all rules from the database for export. +/// +- (NSArray *)retrieveAllRules; + /// /// A map of a file hashes to cached decisions. This is used to pre-validate and whitelist /// certain critical system binaries that are integral to Santa's functionality. diff --git a/Source/santad/DataLayer/SNTRuleTable.m b/Source/santad/DataLayer/SNTRuleTable.m index efbe75242..5c3fe3670 100644 --- a/Source/santad/DataLayer/SNTRuleTable.m +++ b/Source/santad/DataLayer/SNTRuleTable.m @@ -539,4 +539,19 @@ - (BOOL)fillError:(NSError **)error code:(SNTRuleTableError)code message:(NSStri return YES; } +#pragma mark Querying + +// Retrieve all rules from the Database +- (NSArray *)retrieveAllRules { + NSMutableArray *rules = [NSMutableArray array]; + [self inDatabase:^(FMDatabase *db) { + FMResultSet *rs = [db executeQuery:@"SELECT * FROM rules"]; + while ([rs next]) { + [rules addObject:[self ruleFromResultSet:rs]]; + } + [rs close]; + }]; + return rules; +} + @end diff --git a/Source/santad/DataLayer/SNTRuleTableTest.m b/Source/santad/DataLayer/SNTRuleTableTest.m index 256dcdcb3..0249c7521 100644 --- a/Source/santad/DataLayer/SNTRuleTableTest.m +++ b/Source/santad/DataLayer/SNTRuleTableTest.m @@ -301,4 +301,25 @@ - (void)testBadDatabase { [[NSFileManager defaultManager] removeItemAtPath:dbPath error:NULL]; } +- (void)testRetrieveAllRulesWithEmptyDatabase { + NSArray *rules = [self.sut retrieveAllRules]; + XCTAssertEqual(rules.count, 0); +} + +- (void)testRetrieveAllRulesWithMultipleRules { + [self.sut addRules:@[ + [self _exampleCertRule], [self _exampleBinaryRule], [self _exampleTeamIDRule], + [self _exampleSigningIDRuleIsPlatform:NO] + ] + cleanSlate:NO + error:nil]; + + NSArray *rules = [self.sut retrieveAllRules]; + XCTAssertEqual(rules.count, 4); + XCTAssertEqualObjects(rules[0], [self _exampleCertRule]); + XCTAssertEqualObjects(rules[1], [self _exampleBinaryRule]); + XCTAssertEqualObjects(rules[2], [self _exampleTeamIDRule]); + XCTAssertEqualObjects(rules[3], [self _exampleSigningIDRuleIsPlatform:NO]); +} + @end diff --git a/Source/santad/SNTDaemonControlController.mm b/Source/santad/SNTDaemonControlController.mm index 6528eae97..31f637f81 100644 --- a/Source/santad/SNTDaemonControlController.mm +++ b/Source/santad/SNTDaemonControlController.mm @@ -156,6 +156,21 @@ - (void)staticRuleCount:(void (^)(int64_t count))reply { reply([SNTConfigurator configurator].staticRules.count); } +- (void)retrieveAllRules:(void (^)(NSArray *, NSError *))reply { + SNTConfigurator *config = [SNTConfigurator configurator]; + + // Do not return any rules if syncBaseURL is set and return an error. + if (config.syncBaseURL) { + reply(@[], [NSError errorWithDomain:@"com.google.santad" + code:403 // (TODO) define error code + userInfo:@{NSLocalizedDescriptionKey : @"SyncBaseURL is set"}]); + return; + } + + NSArray *rules = [[SNTDatabaseController ruleTable] retrieveAllRules]; + reply(rules, nil); +} + #pragma mark Decision Ops - (void)decisionForFilePath:(NSString *)filePath