diff --git a/changelog/unreleased/pr-26542.toml b/changelog/unreleased/pr-26542.toml new file mode 100644 index 000000000000..4a1763e8ce95 --- /dev/null +++ b/changelog/unreleased/pr-26542.toml @@ -0,0 +1,5 @@ +type = "a" +message = "Add macOS Unified Logging Collector source" + +pulls = ["26542"] + diff --git a/graylog2-server/src/main/java/org/graylog/collectors/CollectorsModule.java b/graylog2-server/src/main/java/org/graylog/collectors/CollectorsModule.java index efab68da0187..3722e2677737 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/CollectorsModule.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/CollectorsModule.java @@ -22,10 +22,12 @@ import org.graylog.collectors.cloud.CloudCollectorIngestService; import org.graylog.collectors.config.receiver.FilelogReceiverConfig; import org.graylog.collectors.config.receiver.JournaldReceiverConfig; +import org.graylog.collectors.config.receiver.MacOSUnifiedLoggingReceiverConfig; import org.graylog.collectors.config.receiver.WindowsEventLogReceiverConfig; import org.graylog.collectors.db.FileSourceConfig; import org.graylog.collectors.db.FleetDTO; import org.graylog.collectors.db.JournaldSourceConfig; +import org.graylog.collectors.db.MacOSUnifiedLoggingSourceConfig; import org.graylog.collectors.db.WindowsEventLogSourceConfig; import org.graylog.collectors.indexer.CollectorLogsIndexTemplateProvider; import org.graylog.collectors.input.CollectorIngestCodec; @@ -37,6 +39,7 @@ import org.graylog.collectors.input.processor.FilelogRecordProcessor; import org.graylog.collectors.input.processor.JournaldRecordProcessor; import org.graylog.collectors.input.processor.LogRecordProcessor; +import org.graylog.collectors.input.processor.MacOSUnifiedLoggingRecordProcessor; import org.graylog.collectors.input.processor.WindowsEventLogRecordProcessor; import org.graylog.collectors.input.transport.CollectorIngestHttpTransport; import org.graylog.collectors.opamp.OpAmpModule; @@ -97,6 +100,7 @@ protected void configure() { logRecordProcessorBinder.addBinding(FilelogReceiverConfig.RECEIVER_TYPE).to(FilelogRecordProcessor.class); logRecordProcessorBinder.addBinding(JournaldReceiverConfig.RECEIVER_TYPE).to(JournaldRecordProcessor.class); logRecordProcessorBinder.addBinding(WindowsEventLogReceiverConfig.RECEIVER_TYPE).to(WindowsEventLogRecordProcessor.class); + logRecordProcessorBinder.addBinding(MacOSUnifiedLoggingReceiverConfig.RECEIVER_TYPE).to(MacOSUnifiedLoggingRecordProcessor.class); logRecordProcessorBinder.addBinding(CollectorLogRecordProcessor.RECEIVER_TYPE).to(CollectorLogRecordProcessor.class); if (otlpDumpEnabled) { @@ -143,6 +147,7 @@ protected void configure() { registerJacksonSubtype(FileSourceConfig.class); registerJacksonSubtype(JournaldSourceConfig.class); registerJacksonSubtype(WindowsEventLogSourceConfig.class); + registerJacksonSubtype(MacOSUnifiedLoggingSourceConfig.class); final var indexTemplateProviderBinder = MapBinder.newMapBinder(binder(), String.class, IndexTemplateProvider.class); diff --git a/graylog2-server/src/main/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfig.java b/graylog2-server/src/main/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfig.java new file mode 100644 index 000000000000..04bd2974b9fa --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfig.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.collectors.config.receiver; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.auto.value.AutoValue; +import jakarta.annotation.Nullable; +import org.graylog.collectors.CollectorOSType; +import org.graylog.collectors.config.GoDurationSerializer; +import org.graylog.collectors.config.extension.FileStorageExtensionConfig; + +import java.time.Duration; +import java.util.EnumSet; + +import static org.graylog2.shared.utilities.StringUtils.f; + +/** + * OTel collector macOS Unified Logging Receiver configuration. + *

+ * Graylog only models the live-collection subset of the upstream receiver's options. The upstream + * {@code archive_path} (one-shot collection from a static {@code .logarchive}) and {@code end_time} + * (a bounded, finite collection window) options are intentionally omitted: the collector is meant + * for continuous live tailing, so those would only ever produce one-shot behavior. + * + * @see macOS Unified Logging Receiver + */ +@AutoValue +@JsonInclude(JsonInclude.Include.NON_NULL) +public abstract class MacOSUnifiedLoggingReceiverConfig implements CollectorReceiverConfig { + public static final String RECEIVER_TYPE = "macos_unified_logging"; + + public String type() { + return RECEIVER_TYPE; + } + + @Override + public EnumSet osSupport() { + return EnumSet.of(CollectorOSType.MACOS); + } + + @Nullable + @JsonProperty("predicate") + public abstract String predicate(); + + @JsonProperty("max_poll_interval") + @JsonSerialize(using = GoDurationSerializer.class) + public abstract Duration maxPollInterval(); + + @JsonProperty("max_log_age") + @JsonSerialize(using = GoDurationSerializer.class) + public abstract Duration maxLogAge(); + + @JsonProperty("format") + public String format() { + return "ndjson"; + } + + // Persists the live-mode forward cursor; required for cursor tracking and de-duplication. + @JsonProperty("storage") + public abstract String storage(); + + + public static Builder builder(String id) { + return new AutoValue_MacOSUnifiedLoggingReceiverConfig.Builder() + .name(f("macos_unified_logging/%s", id)) + .maxPollInterval(Duration.ofSeconds(30)) + .maxLogAge(Duration.ofHours(24)) + .storage(FileStorageExtensionConfig.defaultInstance().name()); + } + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder name(String name); + + public abstract Builder predicate(@Nullable String predicate); + + public abstract Builder maxPollInterval(Duration maxPollInterval); + + public abstract Builder maxLogAge(Duration maxLogAge); + + public abstract Builder storage(String storage); + + public abstract MacOSUnifiedLoggingReceiverConfig build(); + } +} diff --git a/graylog2-server/src/main/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfig.java b/graylog2-server/src/main/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfig.java new file mode 100644 index 000000000000..3412d968440b --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfig.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.collectors.db; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.google.auto.value.AutoValue; +import com.google.common.base.Strings; +import jakarta.annotation.Nullable; +import org.graylog.collectors.config.receiver.CollectorReceiverConfig; +import org.graylog.collectors.config.receiver.MacOSUnifiedLoggingReceiverConfig; + +import java.time.Duration; +import java.util.Optional; + +@AutoValue +@JsonTypeName(MacOSUnifiedLoggingSourceConfig.TYPE_NAME) +@JsonDeserialize(builder = MacOSUnifiedLoggingSourceConfig.Builder.class) +public abstract class MacOSUnifiedLoggingSourceConfig implements SourceConfig { + public static final String TYPE_NAME = "macos_unified_logging"; + + @Override + @JsonProperty(TYPE_FIELD) + public abstract String type(); + + @Nullable + @JsonProperty("predicate") + public abstract String predicate(); + + @JsonProperty("max_poll_interval") + public abstract Duration maxPollInterval(); + + @JsonProperty("max_log_age") + public abstract Duration maxLogAge(); + + public static Builder builder() { + return Builder.create(); + } + + @Override + public void validate() { + // empty predicates are valid + if (maxPollInterval().isZero() || maxPollInterval().isNegative()) { + throw new IllegalArgumentException("max_poll_interval must be positive"); + } + if (maxLogAge().isNegative()) { + throw new IllegalArgumentException("max_log_age must be zero or positive"); + } + } + + @Override + public Optional toReceiverConfig(String id) { + final var builder = MacOSUnifiedLoggingReceiverConfig.builder(id) + .maxLogAge(maxLogAge()) + .maxPollInterval(maxPollInterval()); + if (predicate() != null) { + builder.predicate(Strings.emptyToNull(predicate())); + } + return Optional.of(builder.build()); + } + + @AutoValue.Builder + public abstract static class Builder { + @JsonCreator + public static Builder create() { + return new AutoValue_MacOSUnifiedLoggingSourceConfig.Builder() + .type(TYPE_NAME); + } + + @JsonProperty(TYPE_FIELD) + public abstract Builder type(String type); + + @JsonProperty("predicate") + public abstract Builder predicate(@Nullable String predicate); + + @JsonProperty("max_poll_interval") + public abstract Builder maxPollInterval(Duration maxPollInterval); + + @JsonProperty("max_log_age") + public abstract Builder maxLogAge(Duration maxLogAge); + + public abstract MacOSUnifiedLoggingSourceConfig build(); + } +} diff --git a/graylog2-server/src/main/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessor.java b/graylog2-server/src/main/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessor.java new file mode 100644 index 000000000000..e5091deb92c0 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessor.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.collectors.input.processor; + +import com.google.common.base.Strings; +import org.apache.commons.lang.StringUtils; +import org.graylog.inputs.otel.OTelJournal; +import org.graylog.schema.EventFields; +import org.graylog.schema.ProcessFields; +import org.graylog.schema.UserFields; +import org.graylog.schema.VendorFields; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; + +/** + * Processes macOS unified logging records into GIM fields. + *

+ * The fixed receiver emits a plain-text body (the human-readable message), native OTel severity + * and timestamp — all handled by {@link org.graylog.collectors.input.CollectorIngestCodec} — plus + * structured {@code macos.*} attributes. This processor maps only those attributes: well-fitting + * ones to GIM fields, and macOS-specific identifiers (boot session, activity/trace IDs, sender + * image) preserved under a {@code macos_*} prefix. + * + * @see Apple Unified Logging + */ +public class MacOSUnifiedLoggingRecordProcessor implements LogRecordProcessor { + + @Override + public Map process(OTelJournal.Log log) { + final Map result = new HashMap<>(); + String processImagePath = null; + + for (final var attr : log.getLogRecord().getAttributesList()) { + final var value = attr.getValue(); + switch (attr.getKey()) { + // Mapped to GIM fields. + case "macos.subsystem" -> putStr(result, EventFields.EVENT_SOURCE, value.getStringValue()); + case "macos.category" -> putStr(result, VendorFields.VENDOR_SUBTYPE, value.getStringValue()); + case "macos.eventType" -> putStr(result, VendorFields.VENDOR_EVENT_TYPE, value.getStringValue()); + case "macos.formatString" -> putStr(result, VendorFields.VENDOR_EVENT_DESCRIPTION, value.getStringValue()); + case "macos.processImagePath" -> { + final var path = Strings.emptyToNull(value.getStringValue()); + if (path != null) { + processImagePath = path; + result.put(ProcessFields.PROCESS_PATH, path); + } + } + case "macos.processID" -> result.put(ProcessFields.PROCESS_ID, Long.toString(value.getIntValue())); + case "macos.userID" -> result.put(UserFields.USER_ID, Long.toString(value.getIntValue())); + // macOS-specific identifiers preserved under a macos_* prefix. + case "macos.threadID" -> result.put("macos_thread_id", value.getIntValue()); + case "macos.bootUUID" -> putStr(result, "macos_boot_uuid", value.getStringValue()); + case "macos.machTimestamp" -> result.put("macos_mach_timestamp", value.getIntValue()); + case "macos.traceID" -> result.put("macos_trace_id", value.getIntValue()); + case "macos.activityIdentifier" -> result.put("macos_activity_id", value.getIntValue()); + case "macos.parentActivityIdentifier" -> result.put("macos_parent_activity_id", value.getIntValue()); + case "macos.creatorActivityID" -> result.put("macos_creator_activity_id", value.getIntValue()); + case "macos.processImageUUID" -> putStr(result, "macos_process_image_uuid", value.getStringValue()); + case "macos.senderImagePath" -> putStr(result, "macos_sender_image_path", value.getStringValue()); + case "macos.senderImageUUID" -> putStr(result, "macos_sender_image_uuid", value.getStringValue()); + case "macos.senderProgramCounter" -> result.put("macos_sender_program_counter", value.getIntValue()); + default -> { + // Ignore non-macos attributes. + } + } + } + + // Derive the process name from the executable path. + if (processImagePath != null) { + Path path = Paths.get(processImagePath); + final var fileName = path.getFileName(); + if (fileName != null) { + result.put(ProcessFields.PROCESS_NAME, fileName.toString()); + } + } + + return result; + } + + private static void putStr(Map target, String field, String value) { + if (StringUtils.isNotBlank(value)) { + target.put(field, value); + } + } +} diff --git a/graylog2-server/src/test/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfigTest.java b/graylog2-server/src/test/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfigTest.java new file mode 100644 index 000000000000..665af888c81b --- /dev/null +++ b/graylog2-server/src/test/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfigTest.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.collectors.config.receiver; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.graylog.collectors.CollectorOSType; +import org.graylog2.shared.bindings.providers.ObjectMapperProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MacOSUnifiedLoggingReceiverConfigTest { + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapperProvider().get(); + } + + @Test + void defaultsAndSerialization() throws Exception { + final var config = MacOSUnifiedLoggingReceiverConfig.builder("source-1").build(); + + assertThat(config.type()).isEqualTo("macos_unified_logging"); + assertThat(config.name()).isEqualTo("macos_unified_logging/source-1"); + assertThat(config.osSupport()).containsExactly(CollectorOSType.MACOS); + + final var tree = objectMapper.readTree(objectMapper.writeValueAsString(config)); + assertThat(tree.get("storage").asText()).isEqualTo("file_storage/default"); + assertThat(tree.get("format").asText()).isEqualTo("ndjson"); + assertThat(tree.has("predicate")).isFalse(); + assertThat(tree.has("max_poll_interval")).isTrue(); + assertThat(tree.has("max_log_age")).isTrue(); + // @JsonIgnore fields must not leak into the rendered receiver block + assertThat(tree.has("type")).isFalse(); + assertThat(tree.has("name")).isFalse(); + } + + @Test + void overridesPredicate() throws Exception { + final var config = MacOSUnifiedLoggingReceiverConfig.builder("s") + .predicate("subsystem == 'com.apple.securityd'") + .build(); + final var tree = objectMapper.readTree(objectMapper.writeValueAsString(config)); + assertThat(tree.get("predicate").asText()).isEqualTo("subsystem == 'com.apple.securityd'"); + } +} diff --git a/graylog2-server/src/test/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfigTest.java b/graylog2-server/src/test/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfigTest.java new file mode 100644 index 000000000000..5216ace7a993 --- /dev/null +++ b/graylog2-server/src/test/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfigTest.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.collectors.db; + +import org.graylog.collectors.config.receiver.CollectorReceiverConfig; +import org.graylog.collectors.config.receiver.MacOSUnifiedLoggingReceiverConfig; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class MacOSUnifiedLoggingSourceConfigTest { + + @Test + void validate() { + final MacOSUnifiedLoggingSourceConfig zeroPollInterval = MacOSUnifiedLoggingSourceConfig.builder() + .maxPollInterval(Duration.ZERO) + .maxLogAge(Duration.ZERO) + .build(); + assertThatThrownBy(zeroPollInterval::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("max_poll_interval"); + + final MacOSUnifiedLoggingSourceConfig negativePollInterval = MacOSUnifiedLoggingSourceConfig.builder() + .maxPollInterval(Duration.ofSeconds(-1)) + .maxLogAge(Duration.ZERO) + .build(); + assertThatThrownBy(negativePollInterval::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("max_poll_interval"); + + + final MacOSUnifiedLoggingSourceConfig negativeLogAge = MacOSUnifiedLoggingSourceConfig.builder() + .maxPollInterval(Duration.ofSeconds(1)) + .maxLogAge(Duration.ofSeconds(-1)) + .build(); + assertThatThrownBy(negativeLogAge::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("max_log_age"); + + } + + @Test + void toReceiverConfig() { + final Optional receiverConfigOpt = MacOSUnifiedLoggingSourceConfig.builder() + .predicate("") + .maxLogAge(Duration.ZERO) + .maxPollInterval(Duration.ofSeconds(1)) + .build() + .toReceiverConfig("test-1"); + + assertThat(receiverConfigOpt).isNotEmpty(); + final CollectorReceiverConfig receiverConfig = receiverConfigOpt.get(); + assertThat(receiverConfig).isInstanceOf(MacOSUnifiedLoggingReceiverConfig.class); + final MacOSUnifiedLoggingReceiverConfig macConfig = (MacOSUnifiedLoggingReceiverConfig) receiverConfig; + assertThat(macConfig.maxLogAge()).isEqualTo(Duration.ZERO); + assertThat(macConfig.maxPollInterval()).isEqualTo(Duration.ofSeconds(1)); + assertThat(macConfig.predicate()).isNull(); + } +} diff --git a/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java b/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java index 217f410d2df3..15871941eb83 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java @@ -19,10 +19,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.jsontype.NamedType; import org.graylog.collectors.CollectorReadMode; +import org.graylog.collectors.config.receiver.MacOSUnifiedLoggingReceiverConfig; import org.graylog2.shared.bindings.providers.ObjectMapperProvider; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.time.Duration; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -38,7 +40,8 @@ void setUp() { objectMapper.registerSubtypes( new NamedType(FileSourceConfig.class, FileSourceConfig.TYPE_NAME), new NamedType(JournaldSourceConfig.class, JournaldSourceConfig.TYPE_NAME), - new NamedType(WindowsEventLogSourceConfig.class, WindowsEventLogSourceConfig.TYPE_NAME) + new NamedType(WindowsEventLogSourceConfig.class, WindowsEventLogSourceConfig.TYPE_NAME), + new NamedType(MacOSUnifiedLoggingSourceConfig.class, MacOSUnifiedLoggingSourceConfig.TYPE_NAME) ); } @@ -130,4 +133,81 @@ void serializationIncludesTypeField() throws Exception { assertThat(tree.get("type").asText()).isEqualTo("file"); } + @Test + void macosRoundTripAndReceiverConfig() throws Exception { + final var original = MacOSUnifiedLoggingSourceConfig.builder() + .predicate("subsystem == 'com.apple.securityd'") + .maxPollInterval(Duration.ofSeconds(1)) + .maxLogAge(Duration.ofSeconds(1)) + .build(); + + final var json = objectMapper.writeValueAsString(original); + final var deserialized = objectMapper.readValue(json, SourceConfig.class); + assertThat(deserialized).isEqualTo(original); + + final var receiver = original.toReceiverConfig("src-1").orElseThrow(); + assertThat(receiver.type()).isEqualTo("macos_unified_logging"); + assertThat(receiver.name()).isEqualTo("macos_unified_logging/src-1"); + assertThat(receiver).isInstanceOf(MacOSUnifiedLoggingReceiverConfig.class); + final var macReceiver = (MacOSUnifiedLoggingReceiverConfig) receiver; + assertThat(macReceiver.predicate()).isEqualTo("subsystem == 'com.apple.securityd'"); + assertThat(macReceiver.storage()).isEqualTo("file_storage/default"); + } + + @Test + void macosAllowsEmptyPredicate() { + final var config = MacOSUnifiedLoggingSourceConfig.builder() + .maxPollInterval(Duration.ofSeconds(1)) + .maxLogAge(Duration.ofSeconds(1)) + .build(); + final var macReceiver = + (MacOSUnifiedLoggingReceiverConfig) config.toReceiverConfig("s").orElseThrow(); + assertThat(macReceiver.predicate()).isNull(); + } + + @Test + void macosForwardsStartTimeAndDurationsToReceiverConfig() { + final var config = MacOSUnifiedLoggingSourceConfig.builder() + .maxPollInterval(Duration.ofSeconds(15)) + .maxLogAge(Duration.ofHours(6)) + .build(); + + final var macReceiver = + (MacOSUnifiedLoggingReceiverConfig) config.toReceiverConfig("src-1").orElseThrow(); + + assertThat(macReceiver.maxPollInterval()).isEqualTo(Duration.ofSeconds(15)); + assertThat(macReceiver.maxLogAge()).isEqualTo(Duration.ofHours(6)); + } + + @Test + void macosLeavesReceiverDefaultsWhenOptionalFieldsUnset() { + final var config = MacOSUnifiedLoggingSourceConfig.builder() + .maxPollInterval(Duration.ofSeconds(30)) + .maxLogAge(Duration.ofHours(24)) + .build(); + + final var macReceiver = + (MacOSUnifiedLoggingReceiverConfig) config.toReceiverConfig("s").orElseThrow(); + + assertThat(macReceiver.maxPollInterval()).isEqualTo(Duration.ofSeconds(30)); + assertThat(macReceiver.maxLogAge()).isEqualTo(Duration.ofHours(24)); + } + + @Test + void macosRoundTripSerializesNewFields() throws Exception { + final var original = MacOSUnifiedLoggingSourceConfig.builder() + .predicate("subsystem == 'com.apple.securityd'") + .maxPollInterval(Duration.ofSeconds(30)) + .maxLogAge(Duration.ofHours(24)) + .build(); + + final var json = objectMapper.writeValueAsString(original); + final var tree = objectMapper.readTree(json); + assertThat(tree.get("max_poll_interval").asText()).isEqualTo("PT30S"); + assertThat(tree.get("max_log_age").asText()).isEqualTo("PT24H"); + + final var deserialized = objectMapper.readValue(json, SourceConfig.class); + assertThat(deserialized).isEqualTo(original); + } + } diff --git a/graylog2-server/src/test/java/org/graylog/collectors/db/SourceDTOTest.java b/graylog2-server/src/test/java/org/graylog/collectors/db/SourceDTOTest.java index e2db7110de87..666bef54f498 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/db/SourceDTOTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/db/SourceDTOTest.java @@ -36,7 +36,8 @@ void setUp() { objectMapper.registerSubtypes( new NamedType(FileSourceConfig.class, FileSourceConfig.TYPE_NAME), new NamedType(JournaldSourceConfig.class, JournaldSourceConfig.TYPE_NAME), - new NamedType(WindowsEventLogSourceConfig.class, WindowsEventLogSourceConfig.TYPE_NAME) + new NamedType(WindowsEventLogSourceConfig.class, WindowsEventLogSourceConfig.TYPE_NAME), + new NamedType(MacOSUnifiedLoggingSourceConfig.class, MacOSUnifiedLoggingSourceConfig.TYPE_NAME) ); } diff --git a/graylog2-server/src/test/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessorTest.java b/graylog2-server/src/test/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessorTest.java new file mode 100644 index 000000000000..2acb3dbbe961 --- /dev/null +++ b/graylog2-server/src/test/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessorTest.java @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.collectors.input.processor; + +import com.google.common.io.Resources; +import com.google.protobuf.util.JsonFormat; +import io.opentelemetry.proto.common.v1.AnyValue; +import io.opentelemetry.proto.common.v1.KeyValue; +import io.opentelemetry.proto.logs.v1.LogRecord; +import org.graylog.collectors.CollectorJournal; +import org.graylog.inputs.otel.OTelJournal; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class MacOSUnifiedLoggingRecordProcessorTest { + + private MacOSUnifiedLoggingRecordProcessor processor; + + @BeforeEach + void setUp() { + processor = new MacOSUnifiedLoggingRecordProcessor(); + } + + @Test + void mapsMacosAttributesToFields() { + final var logRecord = LogRecord.newBuilder() + .setBody(AnyValue.newBuilder().setStringValue("hello world")) + .addAttributes(strAttr("macos.subsystem", "com.apple.bluetooth")) + .addAttributes(strAttr("macos.category", "Server.LE.Scan")) + .addAttributes(strAttr("macos.eventType", "logEvent")) + .addAttributes(strAttr("macos.formatString", "fmt %@")) + .addAttributes(strAttr("macos.processImagePath", "/usr/sbin/bluetoothd")) + .addAttributes(intAttr("macos.processID", 401)) + .addAttributes(intAttr("macos.userID", 205)) + .addAttributes(intAttr("macos.threadID", 11537025L)) + .addAttributes(strAttr("macos.bootUUID", "BOOT-A")) + .addAttributes(intAttr("macos.machTimestamp", 12868010147176L)) + .addAttributes(intAttr("macos.traceID", 45473881108119556L)) + .addAttributes(intAttr("macos.activityIdentifier", 0)) + .addAttributes(intAttr("macos.parentActivityIdentifier", 0)) + .addAttributes(intAttr("macos.creatorActivityID", 0)) + .addAttributes(strAttr("macos.processImageUUID", "PUUID")) + .addAttributes(strAttr("macos.senderImagePath", "/usr/sbin/bluetoothd")) + .addAttributes(strAttr("macos.senderImageUUID", "SUUID")) + .addAttributes(intAttr("macos.senderProgramCounter", 7787736L)) + .build(); + + final var result = processor.process(wrap(logRecord)); + + assertThat(result).containsExactlyInAnyOrderEntriesOf(Map.ofEntries( + Map.entry("event_source", "com.apple.bluetooth"), + Map.entry("vendor_subtype", "Server.LE.Scan"), + Map.entry("vendor_event_type", "logEvent"), + Map.entry("vendor_event_description", "fmt %@"), + Map.entry("process_path", "/usr/sbin/bluetoothd"), + Map.entry("process_name", "bluetoothd"), + Map.entry("process_id", "401"), + Map.entry("user_id", "205"), + Map.entry("macos_thread_id", 11537025L), + Map.entry("macos_boot_uuid", "BOOT-A"), + Map.entry("macos_mach_timestamp", 12868010147176L), + Map.entry("macos_trace_id", 45473881108119556L), + Map.entry("macos_activity_id", 0L), + Map.entry("macos_parent_activity_id", 0L), + Map.entry("macos_creator_activity_id", 0L), + Map.entry("macos_process_image_uuid", "PUUID"), + Map.entry("macos_sender_image_path", "/usr/sbin/bluetoothd"), + Map.entry("macos_sender_image_uuid", "SUUID"), + Map.entry("macos_sender_program_counter", 7787736L) + )); + } + + @Test + void ignoresNonMacosAttributesAndEmptyStrings() { + final var logRecord = LogRecord.newBuilder() + .addAttributes(strAttr("macos.subsystem", "")) + .addAttributes(strAttr("other.key", "value")) + .addAttributes(strAttr("macos.eventType", "logEvent")) + .build(); + + final var result = processor.process(wrap(logRecord)); + + assertThat(result).containsExactlyInAnyOrderEntriesOf(Map.of("vendor_event_type", "logEvent")); + } + + @Test + void derivesProcessNameFromPathWithoutSlash() { + final var logRecord = LogRecord.newBuilder() + .addAttributes(strAttr("macos.processImagePath", "standalone-binary")) + .build(); + + final var result = processor.process(wrap(logRecord)); + + assertThat(result).containsEntry("process_path", "standalone-binary"); + assertThat(result).containsEntry("process_name", "standalone-binary"); + } + + @Test + void mapsFieldsFromFixture() throws IOException { + final var logRecord = parseFixture("macos-unified-log-record.json"); + + final var result = processor.process(wrap(logRecord)); + + assertThat(result).containsEntry("event_source", "com.example.network"); + assertThat(result).containsEntry("vendor_subtype", "connection"); + assertThat(result).containsEntry("process_name", "example-daemon"); + assertThat(result).containsEntry("user_id", "501"); + assertThat(result).containsEntry("macos_boot_uuid", "FFEEDDCC-BBAA-9988-7766-554433221100"); + assertThat(result).containsEntry("macos_trace_id", 45473881108119556L); + } + + private static OTelJournal.Log wrap(LogRecord logRecord) { + return OTelJournal.Log.newBuilder().setLogRecord(logRecord).build(); + } + + private static KeyValue strAttr(String key, String value) { + return KeyValue.newBuilder().setKey(key) + .setValue(AnyValue.newBuilder().setStringValue(value)).build(); + } + + private static KeyValue intAttr(String key, long value) { + return KeyValue.newBuilder().setKey(key) + .setValue(AnyValue.newBuilder().setIntValue(value)).build(); + } + + private static LogRecord parseFixture(String filename) throws IOException { + final var builder = CollectorJournal.Record.newBuilder(); + final var json = Resources.toString( + Resources.getResource(MacOSUnifiedLoggingRecordProcessorTest.class, filename), + StandardCharsets.UTF_8); + JsonFormat.parser().merge(json, builder); + return builder.build().getOtelRecord().getLog().getLogRecord(); + } +} diff --git a/graylog2-server/src/test/resources/org/graylog/collectors/input/processor/macos-unified-log-record.json b/graylog2-server/src/test/resources/org/graylog/collectors/input/processor/macos-unified-log-record.json new file mode 100644 index 000000000000..bd38a97a536c --- /dev/null +++ b/graylog2-server/src/test/resources/org/graylog/collectors/input/processor/macos-unified-log-record.json @@ -0,0 +1,34 @@ +{ + "otelRecord": { + "log": { + "resource": { + "attributes": [ + { "key": "collector.receiver.type", "value": { "stringValue": "macos_unified_logging" } } + ] + }, + "scope": {}, + "logRecord": { + "timeUnixNano": "1709000000000000000", + "observedTimeUnixNano": "1709000001000000000", + "severityText": "Default", + "body": { "stringValue": "Connection established to endpoint 10.0.1.50:443" }, + "attributes": [ + { "key": "macos.subsystem", "value": { "stringValue": "com.example.network" } }, + { "key": "macos.category", "value": { "stringValue": "connection" } }, + { "key": "macos.eventType", "value": { "stringValue": "logEvent" } }, + { "key": "macos.formatString", "value": { "stringValue": "%{public}s connection to %{public}s:%{public}d" } }, + { "key": "macos.processImagePath", "value": { "stringValue": "/usr/libexec/example-daemon" } }, + { "key": "macos.processID", "value": { "intValue": "1234" } }, + { "key": "macos.userID", "value": { "intValue": "501" } }, + { "key": "macos.threadID", "value": { "intValue": "7890" } }, + { "key": "macos.bootUUID", "value": { "stringValue": "FFEEDDCC-BBAA-9988-7766-554433221100" } }, + { "key": "macos.machTimestamp", "value": { "intValue": "1234567890" } }, + { "key": "macos.traceID", "value": { "intValue": "45473881108119556" } }, + { "key": "macos.senderImagePath", "value": { "stringValue": "/usr/lib/libexample.dylib" } } + ] + } + } + }, + "collectorInstanceUid": "test-agent-001", + "collectorReceiverType": "macos_unified_logging" +} diff --git a/graylog2-web-interface/src/components/collectors/overview/FirstOnboarding.test.tsx b/graylog2-web-interface/src/components/collectors/overview/FirstOnboarding.test.tsx index 83eba592b500..26ac108951c8 100644 --- a/graylog2-web-interface/src/components/collectors/overview/FirstOnboarding.test.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/FirstOnboarding.test.tsx @@ -200,7 +200,7 @@ describe('FirstOnboarding', () => { ); }); - expect(createSource).toHaveBeenCalledTimes(3); + expect(createSource).toHaveBeenCalledTimes(4); await waitFor(() => { expect(createEnrollmentToken).toHaveBeenCalledWith({ @@ -249,7 +249,7 @@ describe('FirstOnboarding', () => { ); }); - expect(createSource).toHaveBeenCalledTimes(3); + expect(createSource).toHaveBeenCalledTimes(4); await waitFor(() => { expect(createEnrollmentToken).toHaveBeenCalledWith({ diff --git a/graylog2-web-interface/src/components/collectors/overview/FleetCardsGrid.tsx b/graylog2-web-interface/src/components/collectors/overview/FleetCardsGrid.tsx index 0fa156c87330..b317745a78de 100644 --- a/graylog2-web-interface/src/components/collectors/overview/FleetCardsGrid.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/FleetCardsGrid.tsx @@ -32,7 +32,7 @@ import type { FleetStatsSummary } from '../types'; const Grid = styled.div( ({ theme }) => css` display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(200px, 300px)); gap: ${theme.spacings.sm}; `, ); diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/defaultSources.ts b/graylog2-web-interface/src/components/collectors/overview/onboarding/defaultSources.ts index d62921b8345d..1a9fb1a41d71 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/defaultSources.ts +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/defaultSources.ts @@ -39,6 +39,30 @@ const DEFAULT_SOURCES: NewSource[] = [ read_mode: 'end', }, }, + { + name: 'macOS Unified Logs', + description: 'Collects common logs from macOS unified logs', + enabled: true, + type: 'macos_unified_logging', + config: { + predicate: 'subsystem IN {\n' + + '\'com.apple.opendirectoryd\',\n' + + '\'com.apple.authorization\',\n' + + '\'com.apple.loginwindow\',\n' + + '\'com.apple.securityd\',\n' + + '\'com.apple.TCC\',\n' + + '\'com.apple.alf\',\n' + + '\'com.apple.networkextension\',\n' + + '\'com.apple.DiskManagement\',\n' + + '\'com.apple.CoreStorage\',\n' + + '\'com.apple.endpointsecurity\',\n' + + '\'com.apple.syspolicyd\',\n' + + '\'com.apple.launchd\'\n' + + '} AND messageType >= error', + max_log_age: 'PT24H', + max_poll_interval: 'PT30S', + }, + }, { name: 'Windows Event Log', description: 'Collects Windows event logs from default channels', diff --git a/graylog2-web-interface/src/components/collectors/sources/ColumnRenderers.tsx b/graylog2-web-interface/src/components/collectors/sources/ColumnRenderers.tsx index be5d4ab7bacc..df6dd00f4640 100644 --- a/graylog2-web-interface/src/components/collectors/sources/ColumnRenderers.tsx +++ b/graylog2-web-interface/src/components/collectors/sources/ColumnRenderers.tsx @@ -31,7 +31,7 @@ const customColumnRenderers = (): ColumnRenderers => ({ }, type: { renderCell: (type: string) => , - staticWidth: 140, + staticWidth: 190, }, enabled: { renderCell: (_enabled: boolean, source: Source) => ( diff --git a/graylog2-web-interface/src/components/collectors/sources/Constants.ts b/graylog2-web-interface/src/components/collectors/sources/Constants.ts index 0084eeeb950f..b8d25a9433a5 100644 --- a/graylog2-web-interface/src/components/collectors/sources/Constants.ts +++ b/graylog2-web-interface/src/components/collectors/sources/Constants.ts @@ -32,4 +32,5 @@ export const SOURCE_TYPE_LABELS: Record = { file: 'File', journald: 'Journald', windows_event_log: 'Windows Event Log', + macos_unified_logging: 'macOS Unified Logging', }; diff --git a/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.test.tsx b/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.test.tsx index 9035e06a7e92..862e561587ac 100644 --- a/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.test.tsx +++ b/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.test.tsx @@ -62,6 +62,25 @@ describe('SourceFormModal', () => { await screen.findByLabelText(/file path/i); }); + + it('renders macOS predicate field when macos source type selected', async () => { + render(); + + await screen.findByText('macOS Unified Logging'); + await userEvent.selectOptions(screen.getByLabelText(/Source Type/i), 'macos_unified_logging'); + + await screen.findByLabelText(/Predicate/i); + }); + + it('renders macOS poll interval and log age fields when macos source type selected', async () => { + render(); + + await userEvent.selectOptions(screen.getByLabelText(/Source Type/i), 'macos_unified_logging'); + + await screen.findByLabelText(/Max poll interval/i); + await screen.findByLabelText(/Max log age/i); + }); + }); describe('splitToList', () => { diff --git a/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx b/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx index d6e60ff938c4..6ec44a0f402f 100644 --- a/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx +++ b/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx @@ -15,18 +15,20 @@ * . */ import * as React from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { FormikTouched } from 'formik'; -import { Formik, Form } from 'formik'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {FormikTouched, FormikErrors} from 'formik'; +import {Formik, Form} from 'formik'; import isEqual from 'lodash/isEqual'; +import moment from 'moment'; -import { HelpBlock, Input } from 'components/bootstrap'; +import {HelpBlock, Input} from 'components/bootstrap'; import Modal from 'components/bootstrap/Modal'; -import { FormikInput } from 'components/common'; +import {FormikInput, InputDescription} from 'components/common'; +import TimeUnitInput, {extractDurationAndUnit} from 'components/common/TimeUnitInput'; import ModalSubmit from 'components/common/ModalSubmit'; -import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; +import {TELEMETRY_EVENT_TYPE} from 'logic/telemetry/Constants'; -import { SOURCE_TYPE_LABELS } from './Constants'; +import {SOURCE_TYPE_LABELS} from './Constants'; import useSendCollectorsTelemetry from '../hooks/useSendCollectorsTelemetry'; import type { @@ -36,6 +38,7 @@ import type { JournaldSourceConfig, JournaldPriority, WindowsEventLogSourceConfig, + MacOSUnifiedLoggingSourceConfig, } from '../types'; type Props = { @@ -45,10 +48,31 @@ type Props = { onSave: (source: Omit) => Promise<{ id?: string } | void>; }; -const defaultConfigs: Record = { +const defaultConfigs: Record< + SourceType, + FileSourceConfig | JournaldSourceConfig | WindowsEventLogSourceConfig | MacOSUnifiedLoggingSourceConfig +> = { file: { paths: [''], read_mode: 'end' }, journald: { read_mode: 'end', priority: 'info' }, windows_event_log: { channels: [], include_default_channels: true, read_mode: 'end' }, + macos_unified_logging: { + predicate: 'subsystem IN {\n' + + '\'com.apple.opendirectoryd\',\n' + + '\'com.apple.authorization\',\n' + + '\'com.apple.loginwindow\',\n' + + '\'com.apple.securityd\',\n' + + '\'com.apple.TCC\',\n' + + '\'com.apple.alf\',\n' + + '\'com.apple.networkextension\',\n' + + '\'com.apple.DiskManagement\',\n' + + '\'com.apple.CoreStorage\',\n' + + '\'com.apple.endpointsecurity\',\n' + + '\'com.apple.syspolicyd\',\n' + + '\'com.apple.launchd\'\n' + + '} AND messageType >= error', + max_log_age: 'PT24H', + max_poll_interval: 'PT30S', + }, }; type FormValues = { @@ -56,16 +80,41 @@ type FormValues = { name: string; description: string; enabled: boolean; - config: FileSourceConfig | JournaldSourceConfig | WindowsEventLogSourceConfig; + config: FileSourceConfig | JournaldSourceConfig | WindowsEventLogSourceConfig | MacOSUnifiedLoggingSourceConfig; +}; + +// TimeUnitInput is not a formik field, so we need to validate against the entire form +const validateMacOSUnifiedLogging = ( + config: MacOSUnifiedLoggingSourceConfig, +): FormikErrors => { + const errors: FormikErrors = {}; + + if (moment.duration(config.max_poll_interval).asSeconds() <= 0) { + errors.max_poll_interval = 'Max poll interval must be at least 1 seconds.'; + } + + if (moment.duration(config.max_log_age).asSeconds() < 0) { + errors.max_log_age = 'Max log age must be 0 or a positive value.'; + } + + return errors; }; const validate = (values: FormValues) => { - const errors: Partial> = {}; + const errors: FormikErrors = {}; if (!values.name) { errors.name = 'Name is required'; } + if (values.source_type === 'macos_unified_logging') { + const configErrors = validateMacOSUnifiedLogging(values.config as MacOSUnifiedLoggingSourceConfig); + + if (Object.keys(configErrors).length > 0) { + errors.config = configErrors; + } + } + return errors; }; @@ -210,6 +259,64 @@ const WindowsEventLogConfigFields = ({ ); }; +// Ordered largest-first so extractDurationAndUnit renders the most readable unit (e.g. PT24H -> "1 days"). +const POLL_INTERVAL_UNITS = ['HOURS', 'MINUTES', 'SECONDS']; +const LOG_AGE_UNITS = ['DAYS', 'HOURS', 'MINUTES', 'SECONDS']; + +const MacOSUnifiedLoggingConfigFields = ({ + config, + setFieldValue, + errors = undefined, +}: { + config: MacOSUnifiedLoggingSourceConfig; + setFieldValue: (field: string, value: unknown) => void; + errors?: FormikErrors; +}) => { + const pollInterval = extractDurationAndUnit(config.max_poll_interval, POLL_INTERVAL_UNITS); + const logAge = extractDurationAndUnit(config.max_log_age, LOG_AGE_UNITS); + + // TimeUnitInput reports (value, unit, checked). Unchecked clears the field so the backend default applies. + const updateDuration = + (field: 'max_poll_interval' | 'max_log_age') => + (value: number, unit: string) => { + setFieldValue('config', { + ...config, + [field]: moment.duration(value, unit as moment.unitOfTime.DurationConstructor).toISOString(), + }); + }; + + return ( + <> + setFieldValue('config', { ...config, predicate: e.target.value || undefined })} + /> + + + + + + ); +}; + const SourceFormModal = ({ fleetId, source = undefined, onClose, onSave }: Props) => { const isEdit = !!source; const sendTelemetry = useSendCollectorsTelemetry(); @@ -302,7 +409,7 @@ const SourceFormModal = ({ fleetId, source = undefined, onClose, onSave }: Props return ( initialValues={initialValues} onSubmit={handleSubmit} validate={validate}> - {({ isSubmitting, isValidating, values, setFieldValue, dirty, touched }) => { + {({ isSubmitting, isValidating, isValid, values, setFieldValue, dirty, touched, errors }) => { formStateRef.current = { dirty, touched, values }; return ( @@ -344,6 +451,9 @@ const SourceFormModal = ({ fleetId, source = undefined, onClose, onSave }: Props {values.source_type === 'windows_event_log' && ( Collect events from Windows Event Viewer channels. )} + {values.source_type === 'macos_unified_logging' && ( + Collect from the macOS unified logging system via the `log` command (macOS only). + )} )} + {values.source_type === 'macos_unified_logging' && ( + } + /> + )} diff --git a/graylog2-web-interface/src/components/collectors/types.ts b/graylog2-web-interface/src/components/collectors/types.ts index 43e1bc985c0e..fa59ad6268f3 100644 --- a/graylog2-web-interface/src/components/collectors/types.ts +++ b/graylog2-web-interface/src/components/collectors/types.ts @@ -45,7 +45,7 @@ export type CollectorInstanceView = { has_pending_changes: boolean; }; -export type SourceType = 'file' | 'journald' | 'windows_event_log'; +export type SourceType = 'file' | 'journald' | 'windows_event_log' | 'macos_unified_logging'; export type SourceBase = { id: string; @@ -76,10 +76,21 @@ export type WindowsEventLogSourceConfig = { read_mode: 'beginning' | 'end'; }; +export type MacOSUnifiedLoggingSourceConfig = { + predicate?: string; + // ISO-8601 durations (e.g. 'PT30S', 'PT24H') + max_poll_interval: string; + max_log_age: string; +}; + export type FileSource = SourceBase & { type: 'file'; config: FileSourceConfig }; export type JournaldSource = SourceBase & { type: 'journald'; config: JournaldSourceConfig }; export type WindowsEventLogSource = SourceBase & { type: 'windows_event_log'; config: WindowsEventLogSourceConfig }; -export type Source = FileSource | JournaldSource | WindowsEventLogSource; +export type MacOSUnifiedLoggingSource = SourceBase & { + type: 'macos_unified_logging'; + config: MacOSUnifiedLoggingSourceConfig; +}; +export type Source = FileSource | JournaldSource | WindowsEventLogSource | MacOSUnifiedLoggingSource; export type EnrollmentTokenCreator = { user_id: string;