diff --git a/pom.xml b/pom.xml index 8e79143ef..e67350079 100644 --- a/pom.xml +++ b/pom.xml @@ -88,19 +88,17 @@ provided - com.google.auto.value - auto-value - ${auto-value.version} + org.graylog.autovalue + auto-value-javabean + ${auto-value-javabean.version} provided - com.google.auto.service auto-service ${auto-service.version} provided - junit junit @@ -113,6 +111,12 @@ ${assertj-core.version} test + + org.mockito + mockito-core + ${mockito.version} + test + diff --git a/src/main/java/org/graylog/integrations/IntegrationsModule.java b/src/main/java/org/graylog/integrations/IntegrationsModule.java index 9a55f649a..64a9de887 100644 --- a/src/main/java/org/graylog/integrations/IntegrationsModule.java +++ b/src/main/java/org/graylog/integrations/IntegrationsModule.java @@ -16,12 +16,18 @@ */ package org.graylog.integrations; +import org.graylog.integrations.aws.resources.AWSResource; import org.graylog.integrations.inputs.paloalto.PaloAltoCodec; import org.graylog.integrations.inputs.paloalto.PaloAltoTCPInput; import org.graylog2.plugin.PluginConfigBean; import org.graylog2.plugin.PluginModule; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient; +import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClientBuilder; +import software.amazon.awssdk.services.kinesis.KinesisClient; +import software.amazon.awssdk.services.kinesis.KinesisClientBuilder; import java.util.Collections; import java.util.Set; @@ -30,7 +36,8 @@ * Extend the PluginModule abstract class here to add you plugin to the system. */ public class IntegrationsModule extends PluginModule { - private static final Logger LOG = LoggerFactory.getLogger(IntegrationsModule.class); + + private static final Logger LOG = LoggerFactory.getLogger(IntegrationsModule.class); /** * Returns all configuration beans required by this plugin. * @@ -65,5 +72,10 @@ protected void configure() { LOG.debug("Registering message input: {}", PaloAltoTCPInput.NAME); addMessageInput(PaloAltoTCPInput.class); addCodec(PaloAltoCodec.NAME, PaloAltoCodec.class); + + addRestResource(AWSResource.class); + + bind(CloudWatchLogsClientBuilder.class).toProvider(CloudWatchLogsClient::builder); + bind(KinesisClientBuilder.class).toProvider(KinesisClient::builder); } -} +} \ No newline at end of file diff --git a/src/main/java/org/graylog/integrations/aws/AWSService.java b/src/main/java/org/graylog/integrations/aws/AWSService.java deleted file mode 100644 index e0790b54e..000000000 --- a/src/main/java/org/graylog/integrations/aws/AWSService.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.graylog.integrations.aws; - -/** - * Service class that handles all general AWS service operations and business logic. - */ -public class AWSService { - -} diff --git a/src/main/java/org/graylog/integrations/aws/CloudWatchService.java b/src/main/java/org/graylog/integrations/aws/CloudWatchService.java new file mode 100644 index 000000000..f3c43daa3 --- /dev/null +++ b/src/main/java/org/graylog/integrations/aws/CloudWatchService.java @@ -0,0 +1,42 @@ +package org.graylog.integrations.aws; + +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient; +import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClientBuilder; +import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogGroupsRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogGroupsResponse; +import software.amazon.awssdk.services.cloudwatchlogs.paginators.DescribeLogGroupsIterable; + +import javax.inject.Inject; +import java.util.ArrayList; + +public class CloudWatchService { + + private CloudWatchLogsClientBuilder logsClientBuilder; + + @Inject + public CloudWatchService(CloudWatchLogsClientBuilder logsClientBuilder) { + this.logsClientBuilder = logsClientBuilder; + } + + /** + * Returns a list of log groups that exist in CloudWatch. + * + * @param region The AWS region + * @return A list of log groups in alphabetical order. + */ + public ArrayList getLogGroupNames(String region) { + + final CloudWatchLogsClient cloudWatchLogsClient = logsClientBuilder.region(Region.of(region)).build(); + final DescribeLogGroupsRequest describeLogGroupsRequest = DescribeLogGroupsRequest.builder().build(); + final DescribeLogGroupsIterable responses = cloudWatchLogsClient.describeLogGroupsPaginator(describeLogGroupsRequest); + + final ArrayList groupNameList = new ArrayList<>(); + for (DescribeLogGroupsResponse response : responses) { + for (int c = 0; c < response.logGroups().size(); c++) { + groupNameList.add(response.logGroups().get(c).logGroupName()); + } + } + return groupNameList; + } +} diff --git a/src/main/java/org/graylog/integrations/aws/KinesisDTO.java b/src/main/java/org/graylog/integrations/aws/KinesisDTO.java new file mode 100644 index 000000000..42664ca9b --- /dev/null +++ b/src/main/java/org/graylog/integrations/aws/KinesisDTO.java @@ -0,0 +1,108 @@ +package org.graylog.integrations.aws; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableSet; +import org.graylog.autovalue.WithBeanGetter; +import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; +import org.mongojack.Id; +import org.mongojack.ObjectId; + +import javax.annotation.Nullable; +import javax.validation.constraints.NotBlank; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * TODO: This class has not been used yet. We will delete it if we decide to store data directly in the input. + * I am currently feeling like we will likely store data in the input. + */ +@AutoValue +@JsonDeserialize(builder = KinesisDTO.Builder.class) +@WithBeanGetter +public abstract class KinesisDTO { + + + public static final String FIELD_ID = "id"; + + // TODO: Do we really need a title, summary, and description + public static final String FIELD_TITLE = "title"; + public static final String FIELD_SUMMARY = "summary"; + public static final String FIELD_DESCRIPTION = "description"; + + public static final String FIELD_AWS_ACCESS_KEY_ID = "aws_access_key_id"; + public static final String FIELD_AWS_SECRET_ACCESS_KEY = "aws_secret_access_key"; + public static final String FIELD_AWS_KINESIS_STREAM_NAME = "aws_kinesis_stream_name"; + public static final String FIELD_AWS_LOG_TYPE = "aws_log_type"; + public static final String FIELD_AWS_KINESIS_SUBSCRIPTION_ID = "kinesis_subscription_id"; + public static final String FIELD_CREATED_AT = "created_at"; + + public static final ImmutableSet SORT_FIELDS = ImmutableSet.of(FIELD_ID, FIELD_TITLE, FIELD_CREATED_AT ); + + @ObjectId + @Id + @Nullable + @JsonProperty(FIELD_ID) + public abstract String id(); + + @JsonProperty(FIELD_TITLE) + @NotBlank + public abstract String title(); + + // A short, one sentence description of the integration + @JsonProperty(FIELD_SUMMARY) + public abstract String summary(); + + // A longer description of the integration + @JsonProperty(FIELD_DESCRIPTION) + public abstract String description(); + + // Must be encrypted before being stored. + @JsonProperty(FIELD_AWS_ACCESS_KEY_ID) + public abstract String awsAccessKeyId(); + + // Must be encrypted before being stored. + @JsonProperty(FIELD_AWS_SECRET_ACCESS_KEY) + public abstract String awsSecretAccessKey(); + + @JsonProperty(FIELD_AWS_KINESIS_STREAM_NAME) + public abstract String awsKinesisStreamName(); + + @JsonProperty(FIELD_AWS_LOG_TYPE) + public abstract String awsLogType(); + + @JsonProperty(FIELD_AWS_KINESIS_SUBSCRIPTION_ID) + public abstract String awsKinesisSubscriptionId(); + + @JsonProperty(FIELD_CREATED_AT) + public abstract DateTime createdAt(); + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder id(String id); + + public abstract Builder title(@NotBlank String title); + + public abstract Builder summary(String summary); + + public abstract Builder description(String description); + + public abstract Builder awsAccessKeyId(String awsAccessKeyId); + + public abstract Builder awsSecretAccessKey(String awsSecretAccessKey); + + public abstract Builder awsKinesisStreamName(String awsKinesisStreamName); + + public abstract Builder awsLogType(String awsLogType); + + public abstract Builder awsKinesisSubscriptionId(String awsKinesisSubscriptionId); + + public abstract Builder createdAt(DateTime createdAt); + + public abstract KinesisDTO build(); + } +} \ No newline at end of file diff --git a/src/main/java/org/graylog/integrations/aws/KinesisService.java b/src/main/java/org/graylog/integrations/aws/KinesisService.java new file mode 100644 index 000000000..73b72ff54 --- /dev/null +++ b/src/main/java/org/graylog/integrations/aws/KinesisService.java @@ -0,0 +1,122 @@ +package org.graylog.integrations.aws; + +import com.github.rholder.retry.RetryException; +import com.github.rholder.retry.Retryer; +import com.github.rholder.retry.RetryerBuilder; +import com.github.rholder.retry.StopStrategies; +import org.apache.commons.lang3.StringUtils; +import org.graylog.integrations.aws.resources.requests.KinesisHealthCheckRequest; +import org.graylog.integrations.aws.resources.responses.KinesisHealthCheckResponse; +import org.graylog.integrations.aws.service.AWSLogMessage; +import org.graylog.integrations.aws.service.AWSService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.kinesis.KinesisClient; +import software.amazon.awssdk.services.kinesis.KinesisClientBuilder; +import software.amazon.awssdk.services.kinesis.model.ListStreamsRequest; +import software.amazon.awssdk.services.kinesis.model.ListStreamsResponse; + +import javax.inject.Inject; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutionException; + +public class KinesisService { + + private static final Logger LOG = LoggerFactory.getLogger(AWSService.class); + private static final int KINESIS_LIST_STREAMS_MAX_ATTEMPTS = 1000; + private static final int KINESIS_LIST_STREAMS_LIMIT = 30; + + private final KinesisClientBuilder kinesisClientBuilder; + + @Inject + public KinesisService(KinesisClientBuilder kinesisClientBuilder) { + + this.kinesisClientBuilder = kinesisClientBuilder; + } + + public KinesisHealthCheckResponse healthCheck(KinesisHealthCheckRequest heathCheckRequest) { + + // TODO: Read a log message from Kinesis. + + // Detect the log message format + + // TODO: Replace with actual log message received from Kinesis stream. + String message = "2 123456789010 eni-abc123de 172.31.16.139 172.31.16.21 20641 22 6 20 4249 1418530010 1418530070 ACCEPT OK"; + AWSLogMessage awsLogMessage = new AWSLogMessage(message); + + return KinesisHealthCheckResponse.create(true, + awsLogMessage.messageType().toString(), + "Success! The message is an AWS FlowLog!"); + } + + /** + * Get a list of Kinesis stream names. All available streams will be returned. + * + * @param regionName The AWS region to query Kinesis stream names from. + * @return A list of all available Kinesis streams in the supplied region. + */ + public List getKinesisStreams(String regionName, String accessKeyId, String secretAccessKey) throws ExecutionException { + + LOG.debug("List Kinesis streams for region [{}]", regionName); + + // Only explicitly provide credentials if key/secret are provided. + // TODO: Remove this IF check and always provided the string credentials. This will prevent the AWS SDK + // from reading credentials from environment variables, which we definitely do not want to do. + if (StringUtils.isNotBlank(accessKeyId) && StringUtils.isNotBlank(secretAccessKey)) { + StaticCredentialsProvider credentialsProvider = + StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKeyId, secretAccessKey)); + kinesisClientBuilder.credentialsProvider(credentialsProvider); + } + + final KinesisClient kinesisClient = + kinesisClientBuilder.region(Region.of(regionName)).build(); + + // KinesisClient.listStreams() is paginated. Use a retryer to loop and stream names (while ListStreamsResponse.hasMoreStreams() is true). + // The stopAfterAttempt retryer option is an emergency brake to prevent infinite loops + // if AWS API always returns true for hasMoreStreamNames. + final Retryer retryer = RetryerBuilder.newBuilder() + .retryIfResult(b -> Objects.equals(b, Boolean.TRUE)) + .withStopStrategy(StopStrategies.stopAfterAttempt(KINESIS_LIST_STREAMS_MAX_ATTEMPTS)) + .build(); + + ListStreamsRequest streamsRequest = ListStreamsRequest.builder().limit(KINESIS_LIST_STREAMS_LIMIT).build(); + final ListStreamsResponse listStreamsResponse = kinesisClient.listStreams(streamsRequest); + final List streamNames = new ArrayList<>(listStreamsResponse.streamNames()); + + if (listStreamsResponse.hasMoreStreams()) { + try { + retryer.call(() -> { + final String lastStreamName = streamNames.get(streamNames.size() - 1); + final ListStreamsRequest moreStreamsRequest = ListStreamsRequest.builder() + .exclusiveStartStreamName(lastStreamName) + .limit(KINESIS_LIST_STREAMS_LIMIT).build(); + final ListStreamsResponse moreSteamsResponse = kinesisClient.listStreams(moreStreamsRequest); + streamNames.addAll(moreSteamsResponse.streamNames()); + + // If more streams, then this will execute again. + return moreSteamsResponse.hasMoreStreams(); + }); + // Only catch the RetryException, which occurs after too many attempts. When this happens, we still want + // to the return the response with any streams obtained. + // All other exceptions will be bubbled up to the client caller. + } catch (RetryException e) { + LOG.error("Failed to get all stream names after {} attempts. Proceeding to return currently obtained streams.", KINESIS_LIST_STREAMS_MAX_ATTEMPTS); + } + } + + LOG.debug("Kinesis streams queried: [{}]", streamNames); + + return streamNames; + } + + // TODO Create Kinesis Stream + + // TODO Subscribe to Kinesis Stream + + // TODO getRecord +} \ No newline at end of file diff --git a/src/main/java/org/graylog/integrations/aws/resources/AWSResource.java b/src/main/java/org/graylog/integrations/aws/resources/AWSResource.java new file mode 100644 index 000000000..9b9b4e59a --- /dev/null +++ b/src/main/java/org/graylog/integrations/aws/resources/AWSResource.java @@ -0,0 +1,122 @@ +package org.graylog.integrations.aws.resources; + +import com.codahale.metrics.annotation.Timed; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import org.apache.shiro.authz.annotation.RequiresAuthentication; +import org.graylog.integrations.aws.CloudWatchService; +import org.graylog.integrations.aws.KinesisService; +import org.graylog.integrations.aws.resources.requests.KinesisHealthCheckRequest; +import org.graylog.integrations.aws.resources.responses.AvailableAWSServiceSummmary; +import org.graylog.integrations.aws.resources.responses.KinesisHealthCheckResponse; +import org.graylog.integrations.aws.resources.responses.RegionResponse; +import org.graylog.integrations.aws.service.AWSService; +import org.graylog2.plugin.rest.PluginRestResource; + +import javax.inject.Inject; +import javax.validation.Valid; +import javax.validation.constraints.NotNull; +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.util.List; +import java.util.concurrent.ExecutionException; + +/** + * Web endpoints for the AWS integration. + */ + +@RequiresAuthentication +@Api(value = "AWS", description = "AWS integrations") +@Path("/aws") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class AWSResource implements PluginRestResource { + + private AWSService awsService; + private KinesisService kinesisService; + private CloudWatchService cloudWatchService; + + @Inject + public AWSResource(AWSService awsService, KinesisService kinesisService, CloudWatchService cloudWatchService) { + this.awsService = awsService; + this.kinesisService = kinesisService; + this.cloudWatchService = cloudWatchService; + } + + // GET AWS regions + @GET + @Timed + @Path("/regions") + @ApiOperation(value = "Get all available AWS regions") + public List getAwsRegions() { + return awsService.getAvailableRegions(); + } + + /** + * Performs an AWS HealthCheck + * + * Sample CURL command for executing this method. Use this to model the UI request. + * Note the --data-binary param that includes the put body JSON with region and AWS credentials. + * + * curl http://someuser:somepass@localhost:9000/api/plugins/org.graylog.integrations/aws/availableServices + */ + @GET + @Timed + @Path("/availableServices") + @ApiOperation(value = "Get all available AWS services") + public AvailableAWSServiceSummmary getAvailableServices() { + + return awsService.getAvailableServices(); + } + + // GET CloudWatch log group names + @GET + @Timed + @Path("/cloudWatch/logGroups/{regionName}") + @ApiOperation(value = "Get all available AWS CloudWatch log groups names for the specified region") + public List getLogGroupNames(@ApiParam(name = "regionName", required = true) + @PathParam("regionName") String regionName) { + + return cloudWatchService.getLogGroupNames(regionName); + } + + // GET Kinesis Streams + // TODO: Rework to accept a form post body with credentials + @GET + @Timed + @Path("/kinesis/streams/{regionName}") + @ApiOperation(value = "Get all available AWS Kinesis streams for the specified region") + public List getKinesisStreams(@ApiParam(name = "regionName", required = true) + @PathParam("regionName") String regionName) throws ExecutionException { + + return kinesisService.getKinesisStreams(regionName, null, null); + } + + // PUT Kinesis Health Check + @PUT + @Timed + @Path("/kinesis/healthCheck") + @ApiOperation( + value = "Attempt to retrieve logs from the indicated AWS log group with the specified credentials.", + response = KinesisHealthCheckResponse.class + ) + public Response kinesisHealthCheck(@ApiParam(name = "JSON body", required = true) @Valid @NotNull KinesisHealthCheckRequest heathCheckRequest) { + + // TODO: Check permissions? + + // Call into service layer to handle business logic. + KinesisHealthCheckResponse response = kinesisService.healthCheck(heathCheckRequest); + + return Response.accepted().entity(response).build(); + } + + // TODO GET kinesisAutomatedSetup + // getRegion, getlogGroupNames, subscribeToStream +} \ No newline at end of file diff --git a/src/main/java/org/graylog/integrations/aws/resources/requests/KinesisHealthCheckRequest.java b/src/main/java/org/graylog/integrations/aws/resources/requests/KinesisHealthCheckRequest.java new file mode 100644 index 000000000..fa41fc2c4 --- /dev/null +++ b/src/main/java/org/graylog/integrations/aws/resources/requests/KinesisHealthCheckRequest.java @@ -0,0 +1,31 @@ +package org.graylog.integrations.aws.resources.requests; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.auto.value.AutoValue; +import org.graylog.autovalue.WithBeanGetter; + +@JsonAutoDetect +@AutoValue +@WithBeanGetter +public abstract class KinesisHealthCheckRequest { + + @JsonProperty + public abstract String region(); + + @JsonProperty + public abstract String logGroupName(); + + @JsonProperty + public abstract String awsAccessKeyId(); + + @JsonProperty + public abstract String awsSecretAccessKey(); + + public static KinesisHealthCheckRequest create(@JsonProperty("region") String region, + @JsonProperty("log_group_name") String logGroupName, + @JsonProperty("aws_access_key_id") String awsAccessKeyId, + @JsonProperty("aws_secret_access_key") String awsSecretAccessKey) { + return new AutoValue_KinesisHealthCheckRequest(region, logGroupName, awsAccessKeyId, awsSecretAccessKey); + } +} \ No newline at end of file diff --git a/src/main/java/org/graylog/integrations/aws/resources/responses/AvailableAWSService.java b/src/main/java/org/graylog/integrations/aws/resources/responses/AvailableAWSService.java new file mode 100644 index 000000000..597401fbd --- /dev/null +++ b/src/main/java/org/graylog/integrations/aws/resources/responses/AvailableAWSService.java @@ -0,0 +1,41 @@ +package org.graylog.integrations.aws.resources.responses; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.auto.value.AutoValue; +import org.graylog.autovalue.WithBeanGetter; + +@JsonAutoDetect +@AutoValue +@WithBeanGetter +public abstract class AvailableAWSService { + + private static final String NAME = "name"; + private static final String DESCRIPTION = "description"; + private static final String POLICY = "policy"; + private static final String HELPER_TEXT = "helper_text"; + private static final String LEARN_MORE_LINK = "learn_more_link"; + + @JsonProperty(NAME) + public abstract String name(); + + @JsonProperty(DESCRIPTION) + public abstract String description(); + + @JsonProperty(POLICY) + public abstract String policy(); + + @JsonProperty(HELPER_TEXT) + public abstract String helperText(); + + @JsonProperty(LEARN_MORE_LINK) + public abstract String LearnMoreLink(); + + public static AvailableAWSService create(@JsonProperty(NAME) String name, + @JsonProperty(DESCRIPTION) String description, + @JsonProperty(POLICY) String policy, + @JsonProperty(HELPER_TEXT) String helperText, + @JsonProperty(LEARN_MORE_LINK) String LearnMoreLink) { + return new AutoValue_AvailableAWSService(name, description, policy, helperText, LearnMoreLink); + } +} \ No newline at end of file diff --git a/src/main/java/org/graylog/integrations/aws/resources/responses/AvailableAWSServiceSummmary.java b/src/main/java/org/graylog/integrations/aws/resources/responses/AvailableAWSServiceSummmary.java new file mode 100644 index 000000000..3bdbc23a3 --- /dev/null +++ b/src/main/java/org/graylog/integrations/aws/resources/responses/AvailableAWSServiceSummmary.java @@ -0,0 +1,28 @@ +package org.graylog.integrations.aws.resources.responses; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.auto.value.AutoValue; +import org.graylog.autovalue.WithBeanGetter; + +import java.util.List; + +@JsonAutoDetect +@AutoValue +@WithBeanGetter +public abstract class AvailableAWSServiceSummmary { + + private static final String SERVICES = "services"; + private static final String TOTAL = "total"; + + @JsonProperty(SERVICES) + public abstract List services(); + + @JsonProperty(TOTAL) + public abstract long total(); + + public static AvailableAWSServiceSummmary create(@JsonProperty(SERVICES) List services, + @JsonProperty(TOTAL) long total) { + return new AutoValue_AvailableAWSServiceSummmary(services, total); + } +} \ No newline at end of file diff --git a/src/main/java/org/graylog/integrations/aws/resources/responses/KinesisHealthCheckResponse.java b/src/main/java/org/graylog/integrations/aws/resources/responses/KinesisHealthCheckResponse.java new file mode 100644 index 000000000..0d358b869 --- /dev/null +++ b/src/main/java/org/graylog/integrations/aws/resources/responses/KinesisHealthCheckResponse.java @@ -0,0 +1,30 @@ +package org.graylog.integrations.aws.resources.responses; + + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.auto.value.AutoValue; +import org.graylog.autovalue.WithBeanGetter; + +@JsonAutoDetect +@AutoValue +@WithBeanGetter +public abstract class KinesisHealthCheckResponse { + + @JsonProperty + public abstract boolean success(); + + // Eg. CloudWatch, other. + @JsonProperty + public abstract String logType(); + + // Some specific success or error message from AWS SDK. + @JsonProperty + public abstract String message(); + + public static KinesisHealthCheckResponse create(@JsonProperty("success") boolean success, + @JsonProperty("log_type") String logType, + @JsonProperty("message") String message) { + return new AutoValue_KinesisHealthCheckResponse(success, logType, message); + } +} \ No newline at end of file diff --git a/src/main/java/org/graylog/integrations/aws/resources/responses/RegionResponse.java b/src/main/java/org/graylog/integrations/aws/resources/responses/RegionResponse.java new file mode 100644 index 000000000..1fea660ab --- /dev/null +++ b/src/main/java/org/graylog/integrations/aws/resources/responses/RegionResponse.java @@ -0,0 +1,31 @@ +package org.graylog.integrations.aws.resources.responses; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.auto.value.AutoValue; +import org.graylog.autovalue.WithBeanGetter; + +@JsonAutoDetect +@AutoValue +@WithBeanGetter +public abstract class RegionResponse { + + // eu-west-2 + @JsonProperty + public abstract String regionId(); + + // EU (London) + @JsonProperty + public abstract String regionDescription(); + + // The combination of both the name and description for display in the UI: + // EU (London): eu-west-2 + @JsonProperty + public abstract String displayValue(); + + public static RegionResponse create(@JsonProperty("region_id") String regionId, + @JsonProperty("region_description") String regionDescription, + @JsonProperty("display_value") String displayValue ) { + return new AutoValue_RegionResponse(regionId, regionDescription, displayValue); + } +} \ No newline at end of file diff --git a/src/main/java/org/graylog/integrations/aws/service/AWSLogMessage.java b/src/main/java/org/graylog/integrations/aws/service/AWSLogMessage.java new file mode 100644 index 000000000..25e0de184 --- /dev/null +++ b/src/main/java/org/graylog/integrations/aws/service/AWSLogMessage.java @@ -0,0 +1,45 @@ +package org.graylog.integrations.aws.service; + +/** + * Supports the ability to automatically parse + */ +public class AWSLogMessage { + + private static final String ACTION_ACCEPT = "ACCEPT"; + private static final String ACTION_REJECT = "REJECT"; + + private String logMessage; + + public AWSLogMessage(String logMessage) { + this.logMessage = logMessage; + } + + /** + * Detects the type of log message. + * + * @return + */ + public Type messageType() { + + // AWS Flow Logs + // 2 123456789010 eni-abc123de 172.31.16.139 172.31.16.21 20641 22 6 20 4249 1418530010 1418530070 ACCEPT OK + + // Not using a regex here, because it would be quite complicated and hard to maintain. + // Performance should not be an issue here, because this will only be executed once when detecting a log message. + if ((logMessage.contains(ACTION_ACCEPT) || logMessage.contains(ACTION_REJECT)) && + logMessage.chars().filter(Character::isSpaceChar).count() == 13) { + return Type.FLOW_LOGS; + } + + // Add more log message types here as needed + + return Type.UNKNOWN; + } + + // One enum value should be added for each type of log message that auto-detect is supported for. + public enum Type { + + FLOW_LOGS, // See https://docs.aws.amazon.com/vpc/latest/userguide/flow-logs.html + UNKNOWN + } +} diff --git a/src/main/java/org/graylog/integrations/aws/service/AWSService.java b/src/main/java/org/graylog/integrations/aws/service/AWSService.java new file mode 100644 index 000000000..ac5b98751 --- /dev/null +++ b/src/main/java/org/graylog/integrations/aws/service/AWSService.java @@ -0,0 +1,92 @@ +package org.graylog.integrations.aws.service; + +import org.graylog.integrations.aws.resources.responses.AvailableAWSService; +import org.graylog.integrations.aws.resources.responses.AvailableAWSServiceSummmary; +import org.graylog.integrations.aws.resources.responses.RegionResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.regions.RegionMetadata; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Service for all AWS CloudWatch business logic. + *

+ * This layer should not directly use the AWS SDK. All SDK operations should be performed in AWSClient. + */ +public class AWSService { + + private static final Logger LOG = LoggerFactory.getLogger(AWSService.class); + + /** + * @return A list of all available regions. + */ + public List getAvailableRegions() { + + // This stream operation is just a way to convert a list of regions to the RegionResponse object. + return Region.regions().stream() + .filter(r -> !r.isGlobalRegion()) // Ignore the global region. We don't need it. + .map(r -> { + // Build a single AWSRegionResponse with id, description, and displayValue. + RegionMetadata regionMetadata = r.metadata(); + String displayValue = String.format("%s: %s", regionMetadata.description(), regionMetadata.id()); + return RegionResponse.create(regionMetadata.id(), regionMetadata.description(), displayValue); + }).collect(Collectors.toList()); + } + + /** + * @return A list of available AWS services supported by the AWS Graylog AWS integration. + */ + public AvailableAWSServiceSummmary getAvailableServices() { + + ArrayList services = new ArrayList<>(); + AvailableAWSService cloudWatchService = + AvailableAWSService.create("CloudWatch", + "Retrieve CloudWatch logs via Kinesis. Kinesis allows streaming of the logs" + + "in real time. Amazon CloudWatch is a monitoring and management service built" + + "for developers, system operators, site reliability engineers (SRE), " + + "and IT managers.", + "{\n" + + " \"Version\": \"2012-10-17\",\n" + + " \"Statement\": [\n" + + " {\n" + + " \"Sid\": \"VisualEditor0\",\n" + + " \"Effect\": \"Allow\",\n" + + " \"Action\": [\n" + + " \"cloudwatch:PutMetricData\",\n" + + " \"dynamodb:CreateTable\",\n" + + " \"dynamodb:DescribeTable\",\n" + + " \"dynamodb:GetItem\",\n" + + " \"dynamodb:PutItem\",\n" + + " \"dynamodb:Scan\",\n" + + " \"dynamodb:UpdateItem\",\n" + + " \"ec2:DescribeInstances\",\n" + + " \"ec2:DescribeNetworkInterfaceAttribute\",\n" + + " \"ec2:DescribeNetworkInterfaces\",\n" + + " \"elasticloadbalancing:DescribeLoadBalancerAttributes\",\n" + + " \"elasticloadbalancing:DescribeLoadBalancers\",\n" + + " \"kinesis:GetRecords\",\n" + + " \"kinesis:GetShardIterator\",\n" + + " \"kinesis:ListShards\"\n" + + " ],\n" + + " \"Resource\": \"*\"\n" + + " }\n" + + " ]\n" + + "}", + "Requires Kinesis", + "https://aws.amazon.com/cloudwatch/" + ); + services.add(cloudWatchService); + + return AvailableAWSServiceSummmary.create(services, services.size()); + } + + //TODO Add getAWSServices List + //List that contains all the supported AWS services (i.e. Cloudwatch, Kinesis) + + // TODO GET getUserCredentials + +} \ No newline at end of file diff --git a/src/test/java/org.graylog.integrations/aws/AWSServiceTest.java b/src/test/java/org.graylog.integrations/aws/AWSServiceTest.java index 6ed8e55de..172a0286c 100644 --- a/src/test/java/org.graylog.integrations/aws/AWSServiceTest.java +++ b/src/test/java/org.graylog.integrations/aws/AWSServiceTest.java @@ -1,15 +1,58 @@ package org.graylog.integrations.aws; +import org.graylog.integrations.aws.resources.responses.AvailableAWSServiceSummmary; +import org.graylog.integrations.aws.resources.responses.RegionResponse; +import org.graylog.integrations.aws.service.AWSService; +import org.junit.Before; import org.junit.Test; -/** - * Unit test for the AWSService class. - */ +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + public class AWSServiceTest { + private AWSService awsService; + + @Before + public void setUp() { + awsService = new AWSService(); + } @Test - public void name() { + public void regionTest() { + + List availableRegions = awsService.getAvailableRegions(); + + // Use a loop presence check. + // Check format of random region. + boolean foundEuWestRegion = false; + for (RegionResponse availableRegion : availableRegions) { + + if (availableRegion.regionId().equals("eu-west-2")) { + foundEuWestRegion = true; + } + } + assertTrue(foundEuWestRegion); + + // Use one liner presence checks. + assertTrue(availableRegions.stream().anyMatch(r -> r.regionDescription().equals("EU (Stockholm)"))); + assertTrue(availableRegions.stream().anyMatch(r -> r.displayValue().equals("EU (Stockholm): eu-north-1"))); + assertEquals("There should be 20 total regions. This will change in future versions of the AWS SDK", + 20, availableRegions.size()); + } + + @Test + public void testAvailableServices() { + + AvailableAWSServiceSummmary services = awsService.getAvailableServices(); + + // There should be one service. + assertEquals(1, services.total()); + assertEquals(1, services.services().size()); + // CloudWatch should be in the list of available services. + assertTrue(services.services().stream().anyMatch(s -> s.name().equals("CloudWatch"))); } -} +} \ No newline at end of file diff --git a/src/test/java/org.graylog.integrations/aws/CloudWatchServiceTest.java b/src/test/java/org.graylog.integrations/aws/CloudWatchServiceTest.java new file mode 100644 index 000000000..d51df5ddd --- /dev/null +++ b/src/test/java/org.graylog.integrations/aws/CloudWatchServiceTest.java @@ -0,0 +1,87 @@ +package org.graylog.integrations.aws; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient; +import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClientBuilder; +import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogGroupsRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogGroupsResponse; +import software.amazon.awssdk.services.cloudwatchlogs.model.LogGroup; +import software.amazon.awssdk.services.cloudwatchlogs.paginators.DescribeLogGroupsIterable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.when; + +public class CloudWatchServiceTest { + + @Rule + public MockitoRule mockitoRule = MockitoJUnit.rule(); + + @Mock + private CloudWatchLogsClientBuilder logsClientBuilder; + + @Mock + private CloudWatchLogsClient cloudWatchLogsClient; + + @Mock + DescribeLogGroupsIterable logGroupsIterable; + + private CloudWatchService cloudWatchService; + + @Before + public void setUp() { + + cloudWatchService = new CloudWatchService(logsClientBuilder); + } + + @Test + public void testLogGroupNames() { + + // Perform test setup. Return the builder and client when appropriate. + when(logsClientBuilder.region(isA(Region.class))).thenReturn(logsClientBuilder); + when(logsClientBuilder.build()).thenReturn(cloudWatchLogsClient); + + // Create a fake response that contains three log groups. + DescribeLogGroupsResponse fakeLogGroupResponse = DescribeLogGroupsResponse + .builder() + .logGroups(LogGroup.builder().logGroupName("group-1").build(), + LogGroup.builder().logGroupName("group-2").build(), + LogGroup.builder().logGroupName("group-3").build()) + .build(); + + // Mock out the response. When CloudWatchLogsClient.describeLogGroupsPaginator() is called, + // return two responses with six messages total. + List responses = Arrays.asList(fakeLogGroupResponse, fakeLogGroupResponse); + when(logGroupsIterable.iterator()).thenReturn(responses.iterator()); + when(cloudWatchLogsClient.describeLogGroupsPaginator(isA(DescribeLogGroupsRequest.class))).thenReturn(logGroupsIterable); + + ArrayList logGroupNames = cloudWatchService.getLogGroupNames("us-east-1"); + + // Inspect the log groups returned and verify the contents and size. + Assert.assertEquals("The number of groups should be because the two responses " + + "with 3 groups each were provided.", 6, logGroupNames.size()); + + // Loop example to verify presence of a specific log group. + boolean foundGroup = false; + for (String logGroupName : logGroupNames) { + if (logGroupName.equals("group-1")) { + foundGroup = true; + } + } + assertTrue(foundGroup); + + // One line with stream. + assertTrue(logGroupNames.stream().anyMatch(logGroupName -> logGroupName.equals("group-2"))); + } +} \ No newline at end of file diff --git a/src/test/java/org.graylog.integrations/aws/KinesisServiceTest.java b/src/test/java/org.graylog.integrations/aws/KinesisServiceTest.java new file mode 100644 index 000000000..4e8294ef9 --- /dev/null +++ b/src/test/java/org.graylog.integrations/aws/KinesisServiceTest.java @@ -0,0 +1,110 @@ +package org.graylog.integrations.aws; + +import org.graylog.integrations.aws.resources.requests.KinesisHealthCheckRequest; +import org.graylog.integrations.aws.resources.responses.KinesisHealthCheckResponse; +import org.graylog.integrations.aws.service.AWSLogMessage; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.kinesis.KinesisClient; +import software.amazon.awssdk.services.kinesis.KinesisClientBuilder; +import software.amazon.awssdk.services.kinesis.model.ListStreamsRequest; +import software.amazon.awssdk.services.kinesis.model.ListStreamsResponse; + +import java.util.List; +import java.util.concurrent.ExecutionException; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.when; + +public class KinesisServiceTest { + + private static final String[] TWO_TEST_STREAMS = {"test-stream-1", "test-stream-2"}; + private static final String TEST_REGION = Region.EU_WEST_1.id(); + + @Rule + public MockitoRule mockitoRule = MockitoJUnit.rule(); + + @Mock + private KinesisClientBuilder kinesisClientBuilder; + + @Mock + private KinesisClient kinesisClient; + + private KinesisService kinesisService; + + @Before + public void setUp() { + + // Create an AWS client with a mock KinesisClientBuilder + kinesisService = new KinesisService(kinesisClientBuilder); + } + + @Test + public void testLogIdentification() { + + // Verify that an ACCEPT flow log us detected as a flow log. + AWSLogMessage logMessage = new AWSLogMessage("2 123456789010 eni-abc123de 172.31.16.139 172.31.16.21 20641 22 6 20 4249 1418530010 1418530070 ACCEPT OK"); + assertEquals(AWSLogMessage.Type.FLOW_LOGS, logMessage.messageType()); + + // Verify that an ACCEPT flow log us detected as a flow log. + logMessage = new AWSLogMessage("2 123456789010 eni-abc123de 172.31.16.139 172.31.16.21 20641 22 6 20 4249 1418530010 1418530070 REJECT OK"); + assertEquals(AWSLogMessage.Type.FLOW_LOGS, logMessage.messageType()); + + // Verify that it's detected as unknown + logMessage = new AWSLogMessage("haha this is not a real log message"); + assertEquals(AWSLogMessage.Type.UNKNOWN, logMessage.messageType()); + } + + @Test + public void healthCheck() { + + KinesisHealthCheckRequest request = KinesisHealthCheckRequest.create("us-east-1", "some-group", "", ""); + KinesisHealthCheckResponse healthCheckResponse = kinesisService.healthCheck(request); + + // Hard-coded to flow logs for now. This will be mocked out with a real message at some point + assertEquals(AWSLogMessage.Type.FLOW_LOGS.toString(), healthCheckResponse.logType()); + } + + @Test + public void testGetStreams() throws ExecutionException { + + // Test with two streams and one page. This is the most common case for most AWS accounts. + when(kinesisClientBuilder.region(Region.EU_WEST_1)).thenReturn(kinesisClientBuilder); + when(kinesisClientBuilder.build()).thenReturn(kinesisClient); + + when(kinesisClient.listStreams(isA(ListStreamsRequest.class))) + .thenReturn(ListStreamsResponse.builder() + .streamNames(TWO_TEST_STREAMS) + .hasMoreStreams(false).build()); + + + List kinesisStreams = kinesisService.getKinesisStreams(TEST_REGION, null, null); + assertEquals(2, kinesisStreams.size()); + + // Test with stream paging functionality. This will be the case when a large number of Kinesis streams + // are present on a particular AWS account. + when(kinesisClientBuilder.region(Region.EU_WEST_1)).thenReturn(kinesisClientBuilder); + when(kinesisClientBuilder.build()).thenReturn(kinesisClient); + + when(kinesisClient.listStreams(isA(ListStreamsRequest.class))) + // First return a response with two streams indicating that there are more. + .thenReturn(ListStreamsResponse.builder() + .streamNames(TWO_TEST_STREAMS) + .hasMoreStreams(true).build()) + // Then return a response with two streams and indicate that all have been retrieved. + .thenReturn(ListStreamsResponse.builder() + .streamNames(TWO_TEST_STREAMS) + .hasMoreStreams(false).build()); // Indicate no more streams. + + kinesisStreams = kinesisService.getKinesisStreams(TEST_REGION, null, null); + + // There should be 4 total streams (two from each page). + assertEquals(4, kinesisStreams.size()); + } +} \ No newline at end of file diff --git a/src/test/java/org.graylog.integrations/aws/resources/AWSResourceTest.java b/src/test/java/org.graylog.integrations/aws/resources/AWSResourceTest.java new file mode 100644 index 000000000..8abd8e2d6 --- /dev/null +++ b/src/test/java/org.graylog.integrations/aws/resources/AWSResourceTest.java @@ -0,0 +1,44 @@ +package org.graylog.integrations.aws.resources; + +import org.graylog.integrations.aws.CloudWatchService; +import org.graylog.integrations.aws.KinesisService; +import org.graylog.integrations.aws.service.AWSService; +import org.junit.Before; +import org.junit.Rule; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClientBuilder; +import software.amazon.awssdk.services.kinesis.KinesisClient; +import software.amazon.awssdk.services.kinesis.KinesisClientBuilder; + +import static org.mockito.Mockito.when; + +public class AWSResourceTest { + + @Rule + public MockitoRule mockitoRule = MockitoJUnit.rule(); + + private AWSResource awsResource; + + @Mock + private KinesisClientBuilder kinesisClientBuilder; + + @Mock + private CloudWatchLogsClientBuilder logsClientBuilder; + + @Mock + KinesisClient kinesisClient; + + @Before + public void setUp() { + + // Make sure that the actual KinesisClient is not used. + when(kinesisClientBuilder.region(Region.EU_WEST_1)).thenReturn(kinesisClientBuilder); + when(kinesisClientBuilder.build()).thenReturn(kinesisClient); + + // Set up the chain of mocks. + awsResource = new AWSResource(new AWSService(), new KinesisService(kinesisClientBuilder), new CloudWatchService(logsClientBuilder)); + } +} \ No newline at end of file