Skip to content

Commit

Permalink
Add support for was_mmaped_writeable to file write monitoring when us…
Browse files Browse the repository at this point in the history
…ing macOS 13+ (google#1148)

Add support for was_mmaped_writeable to file write monitoring when using macOS 13

In macOS 13 close events now have a new field was_mapped_writable that lets us
track if the file was mmaped writable.  Often developer tools use mmap to
avoid large numbers of write syscalls (e.g. the go toolchain) and this improves
transitive allow listing with those tools.
  • Loading branch information
pmarkowsky authored Aug 14, 2023
1 parent 6588c23 commit 72e292d
Show file tree
Hide file tree
Showing 2 changed files with 166 additions and 52 deletions.
21 changes: 17 additions & 4 deletions Source/santad/EventProviders/SNTEndpointSecurityRecorder.mm
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,23 @@ - (void)handleMessage:(Message &&)esMsg
// Pre-enrichment processing
switch (esMsg->event_type) {
case ES_EVENT_TYPE_NOTIFY_CLOSE: {
// TODO(mlw): Once we move to building with the macOS 13 SDK, we should also check
// the `was_mapped_writable` field
if (esMsg->event.close.modified == false) {
BOOL shouldLogClose = esMsg->event.close.modified;

#if HAVE_MACOS_13
if (@available(macOS 13.5, *)) {
// As of macSO 13.0 we have a new field for if a file was mmaped with
// write permissions on close events. However it did not work until
// 13.5.
//
// If something was mmaped writable it was probably written to. Often
// developer tools do this to avoid lots of write syscalls, e.g. go's
// tool chain. We log this so the compiler controller can take that into
// account.
shouldLogClose |= esMsg->event.close.was_mapped_writable;
}
#endif

if (!shouldLogClose) {
// Ignore unmodified files
// Note: Do not record metrics in this case. These are not considered "drops"
// because this is not a failure case. Ideally we would tell ES to not send
Expand All @@ -115,7 +129,6 @@ - (void)handleMessage:(Message &&)esMsg

break;
}

default: break;
}

Expand Down
197 changes: 149 additions & 48 deletions Source/santad/EventProviders/SNTEndpointSecurityRecorderTest.mm
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#import <XCTest/XCTest.h>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <objc/NSObjCRuntime.h>
#include <cstddef>

#include <memory>
Expand Down Expand Up @@ -102,12 +103,21 @@ - (void)testEnable {
XCTBubbleMockVerifyAndClearExpectations(mockESApi.get());
}

- (void)testHandleMessage {
typedef void (^testHelperBlock)(es_message_t *message,
std::shared_ptr<MockEndpointSecurityAPI> mockESApi, id mockCC,
SNTEndpointSecurityRecorder *recorderClient,
std::shared_ptr<PrefixTree<Unit>> prefixTree,
dispatch_semaphore_t *sema, dispatch_semaphore_t *semaMetrics);

es_file_t targetFileMatchesRegex = MakeESFile("/foo/matches");
es_file_t targetFileMissesRegex = MakeESFile("/foo/misses");

- (void)handleMessageWithMatchCalls:(BOOL)regexMatchCalls
withMissCalls:(BOOL)regexFailsMatchCalls
withBlock:(testHelperBlock)testBlock {
es_file_t file = MakeESFile("foo");
es_process_t proc = MakeESProcess(&file);
es_message_t esMsg = MakeESMessage(ES_EVENT_TYPE_NOTIFY_CLOSE, &proc, ActionType::Auth);
es_file_t targetFileMatchesRegex = MakeESFile("/foo/matches");
es_file_t targetFileMissesRegex = MakeESFile("/foo/misses");

auto mockESApi = std::make_shared<MockEndpointSecurityAPI>();
mockESApi->SetExpectationsESNewClient();
Expand All @@ -116,23 +126,29 @@ - (void)testHandleMessage {
std::unique_ptr<EnrichedMessage> enrichedMsg = std::unique_ptr<EnrichedMessage>(nullptr);

auto mockEnricher = std::make_shared<MockEnricher>();
EXPECT_CALL(*mockEnricher, Enrich).WillOnce(testing::Return(std::move(enrichedMsg)));

if (regexMatchCalls) {
EXPECT_CALL(*mockEnricher, Enrich).WillOnce(testing::Return(std::move(enrichedMsg)));
}

auto mockAuthCache = std::make_shared<MockAuthResultCache>(nullptr, nil);
EXPECT_CALL(*mockAuthCache, RemoveFromCache(&targetFileMatchesRegex)).Times(1);
EXPECT_CALL(*mockAuthCache, RemoveFromCache(&targetFileMissesRegex)).Times(1);
EXPECT_CALL(*mockAuthCache, RemoveFromCache(&targetFileMatchesRegex)).Times((int)regexMatchCalls);
EXPECT_CALL(*mockAuthCache, RemoveFromCache(&targetFileMissesRegex))
.Times((int)regexFailsMatchCalls);

dispatch_semaphore_t semaMetrics = dispatch_semaphore_create(0);

// NOTE: Currently unable to create a partial mock of the
// `SNTEndpointSecurityRecorder` object. There is a bug in OCMock that doesn't
// properly handle the `processEnrichedMessage:handler:` block. Instead this
// test will mock the `Log` method that is called in the handler block.
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
__block dispatch_semaphore_t sema = dispatch_semaphore_create(0);
auto mockLogger = std::make_shared<MockLogger>(nullptr, nullptr);
EXPECT_CALL(*mockLogger, Log).WillOnce(testing::InvokeWithoutArgs(^() {
dispatch_semaphore_signal(sema);
}));
if (regexMatchCalls) {
EXPECT_CALL(*mockLogger, Log).WillOnce(testing::InvokeWithoutArgs(^() {
dispatch_semaphore_signal(sema);
}));
}

auto prefixTree = std::make_shared<PrefixTree<Unit>>();

Expand All @@ -147,75 +163,160 @@ - (void)testHandleMessage {
authResultCache:mockAuthCache
prefixTree:prefixTree];

// CLOSE not modified, bail early
{
esMsg.event_type = ES_EVENT_TYPE_NOTIFY_CLOSE;
esMsg.event.close.modified = false;
esMsg.event.close.target = NULL;
testBlock(&esMsg, mockESApi, mockCC, recorderClient, prefixTree, &sema, &semaMetrics);

XCTAssertTrue(OCMVerifyAll(mockCC));

XCTBubbleMockVerifyAndClearExpectations(mockAuthCache.get());
XCTBubbleMockVerifyAndClearExpectations(mockEnricher.get());
XCTBubbleMockVerifyAndClearExpectations(mockESApi.get());
XCTBubbleMockVerifyAndClearExpectations(mockLogger.get());

[mockCC stopMocking];
}

- (void)testHandleMessageWithCloseMappedWriteable {
#if HAVE_MACOS_13
if (@available(macOS 13.0, *)) {
// CLOSE not modified, but was_mapped_writable, should remove from cache,
// and matches fileChangesRegex
testHelperBlock testBlock =
^(es_message_t *esMsg, std::shared_ptr<MockEndpointSecurityAPI> mockESApi, id mockCC,
SNTEndpointSecurityRecorder *recorderClient, std::shared_ptr<PrefixTree<Unit>> prefixTree,
__autoreleasing dispatch_semaphore_t *sema,
__autoreleasing dispatch_semaphore_t *semaMetrics) {
esMsg->event_type = ES_EVENT_TYPE_NOTIFY_CLOSE;
esMsg->event.close.modified = false;
esMsg->event.close.was_mapped_writable = true;
esMsg->event.close.target = &targetFileMatchesRegex;
Message msg(mockESApi, esMsg);

OCMExpect([mockCC handleEvent:msg withLogger:nullptr]).ignoringNonObjectArgs();

XCTAssertNoThrow([recorderClient handleMessage:Message(mockESApi, esMsg)
recordEventMetrics:^(EventDisposition d) {
XCTAssertEqual(d, EventDisposition::kProcessed);
dispatch_semaphore_signal(*semaMetrics);
}]);
XCTAssertSemaTrue(*semaMetrics, 5, "Metrics not recorded within expected window");
XCTAssertSemaTrue(*sema, 5, "Log wasn't called within expected time window");
};

[self handleMessageWithMatchCalls:YES withMissCalls:NO withBlock:testBlock];
}
#endif
}

- (void)testHandleEventCloseNotModifiedWithWasMappedWritable {
#if HAVE_MACOS_13
if (@available(macOS 13.0, *)) {
// CLOSE not modified, but was_mapped_writable, remove from cache, and does not match
// fileChangesRegex
testHelperBlock testBlock =
^(es_message_t *esMsg, std::shared_ptr<MockEndpointSecurityAPI> mockESApi, id mockCC,
SNTEndpointSecurityRecorder *recorderClient, std::shared_ptr<PrefixTree<Unit>> prefixTree,
__autoreleasing dispatch_semaphore_t *sema,
__autoreleasing dispatch_semaphore_t *semaMetrics) {
esMsg->event_type = ES_EVENT_TYPE_NOTIFY_CLOSE;
esMsg->event.close.modified = false;
esMsg->event.close.was_mapped_writable = true;
esMsg->event.close.target = &targetFileMissesRegex;
Message msg(mockESApi, esMsg);

XCTAssertNoThrow([recorderClient handleMessage:Message(mockESApi, esMsg)
recordEventMetrics:^(EventDisposition d) {
XCTFail("Metrics record callback should not be called here");
}]);
};

[self handleMessageWithMatchCalls:NO withMissCalls:YES withBlock:testBlock];
}
#endif
}

XCTAssertNoThrow([recorderClient handleMessage:Message(mockESApi, &esMsg)
- (void)testHandleMessage {
// CLOSE not modified, bail early
testHelperBlock testBlock = ^(
es_message_t *esMsg, std::shared_ptr<MockEndpointSecurityAPI> mockESApi, id mockCC,
SNTEndpointSecurityRecorder *recorderClient, std::shared_ptr<PrefixTree<Unit>> prefixTree,
__autoreleasing dispatch_semaphore_t *sema, __autoreleasing dispatch_semaphore_t *semaMetrics) {
esMsg->event_type = ES_EVENT_TYPE_NOTIFY_CLOSE;
esMsg->event.close.modified = false;
esMsg->event.close.target = NULL;

XCTAssertNoThrow([recorderClient handleMessage:Message(mockESApi, esMsg)
recordEventMetrics:^(EventDisposition d) {
XCTFail("Metrics record callback should not be called here");
}]);
}
};

[self handleMessageWithMatchCalls:NO withMissCalls:NO withBlock:testBlock];

// CLOSE modified, remove from cache, and matches fileChangesRegex
{
esMsg.event_type = ES_EVENT_TYPE_NOTIFY_CLOSE;
esMsg.event.close.modified = true;
esMsg.event.close.target = &targetFileMatchesRegex;
Message msg(mockESApi, &esMsg);
testBlock = ^(
es_message_t *esMsg, std::shared_ptr<MockEndpointSecurityAPI> mockESApi, id mockCC,
SNTEndpointSecurityRecorder *recorderClient, std::shared_ptr<PrefixTree<Unit>> prefixTree,
__autoreleasing dispatch_semaphore_t *sema, __autoreleasing dispatch_semaphore_t *semaMetrics) {
esMsg->event_type = ES_EVENT_TYPE_NOTIFY_CLOSE;
esMsg->event.close.modified = true;
esMsg->event.close.target = &targetFileMatchesRegex;
Message msg(mockESApi, esMsg);

OCMExpect([mockCC handleEvent:msg withLogger:nullptr]).ignoringNonObjectArgs();

[recorderClient handleMessage:std::move(msg)
recordEventMetrics:^(EventDisposition d) {
XCTAssertEqual(d, EventDisposition::kProcessed);
dispatch_semaphore_signal(semaMetrics);
dispatch_semaphore_signal(*semaMetrics);
}];

XCTAssertSemaTrue(semaMetrics, 5, "Metrics not recorded within expected window");
XCTAssertSemaTrue(sema, 5, "Log wasn't called within expected time window");
}
XCTAssertSemaTrue(*semaMetrics, 5, "Metrics not recorded within expected window");
XCTAssertSemaTrue(*sema, 5, "Log wasn't called within expected time window");
};

[self handleMessageWithMatchCalls:YES withMissCalls:NO withBlock:testBlock];

// CLOSE modified, remove from cache, but doesn't match fileChangesRegex
{
esMsg.event_type = ES_EVENT_TYPE_NOTIFY_CLOSE;
esMsg.event.close.modified = true;
esMsg.event.close.target = &targetFileMissesRegex;
Message msg(mockESApi, &esMsg);
XCTAssertNoThrow([recorderClient handleMessage:Message(mockESApi, &esMsg)
testBlock = ^(
es_message_t *esMsg, std::shared_ptr<MockEndpointSecurityAPI> mockESApi, id mockCC,
SNTEndpointSecurityRecorder *recorderClient, std::shared_ptr<PrefixTree<Unit>> prefixTree,
__autoreleasing dispatch_semaphore_t *sema, __autoreleasing dispatch_semaphore_t *semaMetrics) {
esMsg->event_type = ES_EVENT_TYPE_NOTIFY_CLOSE;
esMsg->event.close.modified = true;
esMsg->event.close.target = &targetFileMissesRegex;
Message msg(mockESApi, esMsg);
XCTAssertNoThrow([recorderClient handleMessage:Message(mockESApi, esMsg)
recordEventMetrics:^(EventDisposition d) {
XCTFail("Metrics record callback should not be called here");
}]);
}
};

[self handleMessageWithMatchCalls:NO withMissCalls:YES withBlock:testBlock];

// LINK, Prefix match, bail early
testBlock =
^(es_message_t *esMsg, std::shared_ptr<MockEndpointSecurityAPI> mockESApi, id mockCC,
SNTEndpointSecurityRecorder *recorderClient, std::shared_ptr<PrefixTree<Unit>> prefixTree,
__autoreleasing dispatch_semaphore_t *sema, __autoreleasing dispatch_semaphore_t *semaMetrics)

{
esMsg.event_type = ES_EVENT_TYPE_NOTIFY_LINK;
esMsg.event.link.source = &targetFileMatchesRegex;
prefixTree->InsertPrefix(esMsg.event.link.source->path.data, Unit{});
Message msg(mockESApi, &esMsg);
esMsg->event_type = ES_EVENT_TYPE_NOTIFY_LINK;
esMsg->event.link.source = &targetFileMatchesRegex;
prefixTree->InsertPrefix(esMsg->event.link.source->path.data, Unit{});
Message msg(mockESApi, esMsg);

OCMExpect([mockCC handleEvent:msg withLogger:nullptr]).ignoringNonObjectArgs();

[recorderClient handleMessage:std::move(msg)
recordEventMetrics:^(EventDisposition d) {
XCTAssertEqual(d, EventDisposition::kDropped);
dispatch_semaphore_signal(semaMetrics);
dispatch_semaphore_signal(*semaMetrics);
}];

XCTAssertSemaTrue(semaMetrics, 5, "Metrics not recorded within expected window");
}

XCTAssertTrue(OCMVerifyAll(mockCC));

XCTBubbleMockVerifyAndClearExpectations(mockAuthCache.get());
XCTBubbleMockVerifyAndClearExpectations(mockEnricher.get());
XCTBubbleMockVerifyAndClearExpectations(mockESApi.get());
XCTBubbleMockVerifyAndClearExpectations(mockLogger.get());
XCTAssertSemaTrue(*semaMetrics, 5, "Metrics not recorded within expected window");
};

[mockCC stopMocking];
[self handleMessageWithMatchCalls:NO withMissCalls:NO withBlock:testBlock];
}

- (void)testGetTargetFileForPrefixTree {
Expand Down

0 comments on commit 72e292d

Please sign in to comment.