diff --git a/pom.xml b/pom.xml index 5936da5c..541441bd 100644 --- a/pom.xml +++ b/pom.xml @@ -60,7 +60,7 @@ 3.2.0 - 2.1.5.RELEASE + 2.1.8.RELEASE 2.9.0 UTF-8 diff --git a/scb-engine/pom.xml b/scb-engine/pom.xml index a7c8bce5..c01a6212 100644 --- a/scb-engine/pom.xml +++ b/scb-engine/pom.xml @@ -95,6 +95,11 @@ camunda-bpm-spring-boot-starter-test test + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + diff --git a/scb-engine/src/main/java/io/securecodebox/engine/execution/DefaultScanProcessExecution.java b/scb-engine/src/main/java/io/securecodebox/engine/execution/DefaultScanProcessExecution.java index ce6be858..518f0b57 100644 --- a/scb-engine/src/main/java/io/securecodebox/engine/execution/DefaultScanProcessExecution.java +++ b/scb-engine/src/main/java/io/securecodebox/engine/execution/DefaultScanProcessExecution.java @@ -20,16 +20,13 @@ package io.securecodebox.engine.execution; import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import io.securecodebox.constants.DefaultFields; -import io.securecodebox.model.rest.Report; +import io.securecodebox.engine.service.ExecutionTimeService; import io.securecodebox.model.execution.ScanProcessExecution; import io.securecodebox.model.execution.Scanner; import io.securecodebox.model.execution.Target; import io.securecodebox.model.findings.Finding; import io.securecodebox.scanprocess.ProcessVariableHelper; -import java.util.Map; import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.variable.value.BooleanValue; import org.camunda.bpm.engine.variable.value.StringValue; @@ -37,8 +34,11 @@ import org.springframework.util.StringUtils; import java.util.Collections; +import java.util.Date; import java.util.LinkedList; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.UUID; /** @@ -51,8 +51,12 @@ public class DefaultScanProcessExecution implements ScanProcessExecution { @JsonIgnore protected DelegateExecution execution; + @JsonIgnore + public ExecutionTimeService executionTimeService; + public DefaultScanProcessExecution(DelegateExecution execution) { this.execution = execution; + this.executionTimeService = new ExecutionTimeService(execution); } @Override @@ -166,7 +170,7 @@ public boolean isAutomated() { } @Override - public String getScannerType(){ + public String getScannerType() { return (String) execution.getVariable(DefaultFields.PROCESS_SCANNER_TYPE.name()); } @@ -175,7 +179,7 @@ public String getScannerType(){ * Same as the Name of the securityTest. e.g. nmap */ @Override - public String getName(){ + public String getName() { return (String) execution.getVariable(DefaultFields.PROCESS_NAME.name()); } @@ -189,7 +193,28 @@ public void setName(String name) { } @Override - public Map getMetaData(){ + public Map getMetaData() { return (Map) execution.getVariable(DefaultFields.PROCESS_META_DATA.name()); } + + @Override + public Date getStartDate(){ + return executionTimeService.getStartDate(); + } + + @Override + public Optional getEndDate(){ + return executionTimeService.getEndDate(); + } + + @Override + public Long getDurationInMilliSeconds() { + Date startTime = getStartDate(); + + if(startTime == null){ + return null; + } + + return getEndDate().orElseGet(Date::new).getTime() - startTime.getTime(); + } } diff --git a/scb-engine/src/main/java/io/securecodebox/engine/service/ExecutionTimeService.java b/scb-engine/src/main/java/io/securecodebox/engine/service/ExecutionTimeService.java new file mode 100644 index 00000000..f6118e32 --- /dev/null +++ b/scb-engine/src/main/java/io/securecodebox/engine/service/ExecutionTimeService.java @@ -0,0 +1,40 @@ +package io.securecodebox.engine.service; + +import org.camunda.bpm.engine.delegate.DelegateExecution; +import org.camunda.bpm.engine.history.HistoricProcessInstance; + +import java.util.Date; +import java.util.Optional; + +public class ExecutionTimeService { + + private DelegateExecution execution; + + public ExecutionTimeService(DelegateExecution execution){ + this.execution = execution; + } + + private Optional getHistoricProcessInstance(){ + return execution.getProcessEngineServices() + .getHistoryService() + .createHistoricProcessInstanceQuery() + .processInstanceId(execution.getProcessInstanceId()) + .list() + .stream() + .findFirst(); + } + + public Date getStartDate(){ + return getHistoricProcessInstance() + .orElseThrow(() -> new RuntimeException("Failed to finding process")) + .getStartTime(); + } + + public Optional getEndDate(){ + return Optional.ofNullable( + getHistoricProcessInstance() + .orElseThrow(() -> new RuntimeException("Failed to finding process")) + .getEndTime() + ); + } +} diff --git a/scb-engine/src/main/java/io/securecodebox/engine/service/SecurityTestService.java b/scb-engine/src/main/java/io/securecodebox/engine/service/SecurityTestService.java index 37424a15..cc5d21e0 100644 --- a/scb-engine/src/main/java/io/securecodebox/engine/service/SecurityTestService.java +++ b/scb-engine/src/main/java/io/securecodebox/engine/service/SecurityTestService.java @@ -39,6 +39,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; @@ -143,7 +144,7 @@ public SecurityTest getCompletedSecurityTest(UUID id) throws SecurityTestNotFoun List targets = getListValue(variables, DefaultFields.PROCESS_TARGETS, Target.class); Map metaData = (Map) variables.get(DefaultFields.PROCESS_META_DATA.name()).getValue(); - return new SecurityTest(id, context, name, targets.get(0), report, metaData, tenant); + return new SecurityTest(id, context, name, targets.get(0), report, metaData, tenant, process.getStartTime(), Optional.ofNullable(process.getEndTime())); } private List getListValue(Map variables, DefaultFields name, Class type) { diff --git a/scb-engine/src/test/java/io/securecodebox/engine/execution/DefaultScanProcessExecutionTest.java b/scb-engine/src/test/java/io/securecodebox/engine/execution/DefaultScanProcessExecutionTest.java index 49208255..a375380f 100644 --- a/scb-engine/src/test/java/io/securecodebox/engine/execution/DefaultScanProcessExecutionTest.java +++ b/scb-engine/src/test/java/io/securecodebox/engine/execution/DefaultScanProcessExecutionTest.java @@ -20,8 +20,10 @@ package io.securecodebox.engine.execution; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; import io.securecodebox.TestHelper; import io.securecodebox.constants.DefaultFields; +import io.securecodebox.engine.service.ExecutionTimeService; import io.securecodebox.model.execution.ScanProcessExecution; import io.securecodebox.model.execution.ScanProcessExecutionFactory; import io.securecodebox.model.findings.OsiLayer; @@ -36,12 +38,15 @@ import org.mockito.MockitoAnnotations; import org.mockito.stubbing.Answer; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Date; +import java.util.Optional; import java.util.UUID; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anyString; import static org.mockito.Matchers.eq; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doAnswer; @@ -55,7 +60,7 @@ */ public class DefaultScanProcessExecutionTest { - private static final String DEFAULT_EXECUTION = "{\"id\":\"5a4e9d37-09b0-4109-badd-d79dfa8fce2a\",\"context\":\"TEST_CONTEXT\",\"automated\":false,\"scanners\":[{\"id\":\"62fa8ffb-e3bc-433e-b322-9c02108c5171\",\"type\":\"Test_SCANNER\",\"findings\":[{\"id\":\"49bf7fd3-8512-4d73-a28f-608e493cd726\",\"name\":\"BAD_TEST_FINDIG\",\"description\":\"Some coder has tested this!\",\"category\":\"COOL_TEST_STUFF\",\"osi_layer\":\"NOT_APPLICABLE\",\"severity\":\"HIGH\",\"reference\":{\"id\":\"UNI_CODE_STUFF\",\"source\":\"RISCOOL\"},\"hint\":\"You might wan't to blame Rüdiger!\",\"attributes\":{\"TEST\":\"Kekse\",\"HORRIBLE\":\"Coke\"},\"location\":\"mett.brot.securecodebox.io\",\"false_positive\":false}],\"rawFindings\":\"[{\\\"pudding\\\":\\\"Bier\\\"}]\"}]}"; + private static final String DEFAULT_EXECUTION = "{\"id\":\"5a4e9d37-09b0-4109-badd-d79dfa8fce2a\",\"context\":\"TEST_CONTEXT\",\"automated\":false,\"scanners\":[{\"id\":\"62fa8ffb-e3bc-433e-b322-9c02108c5171\",\"type\":\"Test_SCANNER\",\"findings\":[{\"id\":\"49bf7fd3-8512-4d73-a28f-608e493cd726\",\"name\":\"BAD_TEST_FINDIG\",\"description\":\"Some coder has tested this!\",\"category\":\"COOL_TEST_STUFF\",\"osi_layer\":\"NOT_APPLICABLE\",\"severity\":\"HIGH\",\"reference\":{\"id\":\"UNI_CODE_STUFF\",\"source\":\"RISCOOL\"},\"hint\":\"You might wan't to blame Rüdiger!\",\"attributes\":{\"TEST\":\"Kekse\",\"HORRIBLE\":\"Coke\"},\"location\":\"mett.brot.securecodebox.io\",\"false_positive\":false}],\"rawFindings\":\"[{\\\"pudding\\\":\\\"Bier\\\"}]\"}],\"startDate\":504295320000,\"endDate\":504295620000,\"durationInMilliSeconds\":300000}"; public static final String SCANNER_SERIALIZE_RESULT = "{\"id\":\"62fa8ffb-e3bc-433e-b322-9c02108c5171\",\"type\":\"Test_SCANNER\",\"findings\":[{\"id\":\"49bf7fd3-8512-4d73-a28f-608e493cd726\",\"name\":\"BAD_TEST_FINDIG\",\"description\":\"Some coder has tested this!\",\"category\":\"COOL_TEST_STUFF\",\"osi_layer\":\"NOT_APPLICABLE\",\"severity\":\"HIGH\",\"reference\":{\"id\":\"UNI_CODE_STUFF\",\"source\":\"RISCOOL\"},\"hint\":\"You might wan't to blame Rüdiger!\",\"attributes\":{\"TEST\":\"Kekse\",\"HORRIBLE\":\"Coke\"},\"location\":\"mett.brot.securecodebox.io\",\"false_positive\":false}],\"rawFindings\":\"[{\\\"pudding\\\":\\\"Bier\\\"}]\"}"; String findingCache = ""; @@ -66,37 +71,51 @@ public class DefaultScanProcessExecutionTest { @Mock ScanProcessExecutionFactory processExecutionFactory; @Mock - DelegateExecution executionMock; + DelegateExecution execution; + @Mock + ExecutionTimeService executionTimeService; DefaultScanProcessExecution underTest; @Before public void setUp() { MockitoAnnotations.initMocks(this); - underTest = new DefaultScanProcessExecution(executionMock); + underTest = new DefaultScanProcessExecution(execution); + + objectMapper.registerModule(new Jdk8Module()); - when(processExecutionFactory.get(executionMock)).thenReturn(underTest); - when(executionMock.hasVariable(eq(DefaultFields.PROCESS_FINDINGS.name()))).thenReturn(true); - when(executionMock.getVariable(eq(DefaultFields.PROCESS_FINDINGS.name()))).thenAnswer((answer) -> findingCache); + when(executionTimeService.getStartDate()).thenReturn( + Date.from(LocalDateTime.of(1985, 12, 24, 18, 2).toInstant(ZoneOffset.UTC)) + ); + when(executionTimeService.getEndDate()).thenReturn(Optional.of( + Date.from(LocalDateTime.of(1985, 12, 24, 18, 7).toInstant(ZoneOffset.UTC)) + )); + underTest.executionTimeService = executionTimeService; + + when(processExecutionFactory.get(execution)).thenReturn(underTest); + when(execution.hasVariable(eq(DefaultFields.PROCESS_FINDINGS.name()))).thenReturn(true); + when(execution.getVariable(eq(DefaultFields.PROCESS_FINDINGS.name()))).thenAnswer((answer) -> findingCache); doAnswer((Answer) invocation -> { findingCache = (String) ((ObjectValueImpl)invocation.getArgument(1)).getValue(); return Void.TYPE; - }).when(executionMock).setVariable(eq(DefaultFields.PROCESS_FINDINGS.name()), any()); + }).when(execution).setVariable(eq(DefaultFields.PROCESS_FINDINGS.name()), any()); - when(executionMock.hasVariable(eq(DefaultFields.PROCESS_TARGETS.name()))).thenReturn(true); - when(executionMock.getVariable(eq(DefaultFields.PROCESS_TARGETS.name()))).thenAnswer((answer) -> targetCache); + when(execution.hasVariable(eq(DefaultFields.PROCESS_TARGETS.name()))).thenReturn(true); + when(execution.getVariable(eq(DefaultFields.PROCESS_TARGETS.name()))).thenAnswer((answer) -> targetCache); doAnswer((Answer) invocation -> { targetCache = (String) ((ObjectValueImpl)invocation.getArgument(1)).getValue(); return Void.TYPE; - }).when(executionMock).setVariable(eq(DefaultFields.PROCESS_TARGETS.name()), any()); + }).when(execution).setVariable(eq(DefaultFields.PROCESS_TARGETS.name()), any()); } @Test public void testSerialize() throws Exception { DelegateExecution process = mockDelegateExcecution(); - ScanProcessExecution execution = new DefaultScanProcessExecution(process); - String s = objectMapper.writeValueAsString(execution); + DefaultScanProcessExecution execution = new DefaultScanProcessExecution(process); + + execution.executionTimeService = executionTimeService; + String s = objectMapper.writeValueAsString((ScanProcessExecution) execution); System.out.println(s); assertEquals(DEFAULT_EXECUTION, s); @@ -126,9 +145,9 @@ public void testAppendAndClearFindings() throws Exception { underTest.appendFinding(TestHelper.createBasicFinding(finding1Id)); underTest.appendFinding(TestHelper.createBasicFindingDifferent(finding2Id)); - Mockito.verify(executionMock, times(2)).setVariable(eq(DefaultFields.PROCESS_FINDINGS.name()), any()); + Mockito.verify(execution, times(2)).setVariable(eq(DefaultFields.PROCESS_FINDINGS.name()), any()); - ScanProcessExecution processExecution = processExecutionFactory.get(executionMock); + ScanProcessExecution processExecution = processExecutionFactory.get(execution); assertEquals(2, processExecution.getFindings().size()); @@ -163,9 +182,9 @@ public void testAppendAndClearFindings() throws Exception { // underTest.clearFindings(); - Mockito.verify(executionMock, atLeastOnce()).getVariable(eq(DefaultFields.PROCESS_FINDINGS.name())); - Mockito.verify(executionMock, times(3)).setVariable(eq(DefaultFields.PROCESS_FINDINGS.name()), any()); - Mockito.verifyNoMoreInteractions(executionMock); + Mockito.verify(execution, atLeastOnce()).getVariable(eq(DefaultFields.PROCESS_FINDINGS.name())); + Mockito.verify(execution, times(3)).setVariable(eq(DefaultFields.PROCESS_FINDINGS.name()), any()); + Mockito.verifyNoMoreInteractions(execution); assertEquals(0, processExecution.getFindings().size()); } @@ -177,9 +196,9 @@ public void testAppendAndClearTargets() throws Exception { underTest.appendTarget(TestHelper.createBaiscTarget()); underTest.appendTarget(TestHelper.createTarget("http://w1.w2.www", "some wired")); - Mockito.verify(executionMock, times(2)).setVariable(eq(DefaultFields.PROCESS_TARGETS.name()), any()); + Mockito.verify(execution, times(2)).setVariable(eq(DefaultFields.PROCESS_TARGETS.name()), any()); - ScanProcessExecution processExecution = processExecutionFactory.get(executionMock); + ScanProcessExecution processExecution = processExecutionFactory.get(execution); assertEquals(2, processExecution.getTargets().size()); @@ -201,9 +220,9 @@ public void testAppendAndClearTargets() throws Exception { // Clear targets // underTest.clearTargets(); - Mockito.verify(executionMock, atLeastOnce()).getVariable(eq(DefaultFields.PROCESS_TARGETS.name())); - Mockito.verify(executionMock, times(3)).setVariable(eq(DefaultFields.PROCESS_TARGETS.name()), any()); - Mockito.verifyNoMoreInteractions(executionMock); + Mockito.verify(execution, atLeastOnce()).getVariable(eq(DefaultFields.PROCESS_TARGETS.name())); + Mockito.verify(execution, times(3)).setVariable(eq(DefaultFields.PROCESS_TARGETS.name()), any()); + Mockito.verifyNoMoreInteractions(execution); assertEquals(0, processExecution.getTargets().size()); } diff --git a/scb-persistenceproviders/elasticsearch-persistenceprovider/pom.xml b/scb-persistenceproviders/elasticsearch-persistenceprovider/pom.xml index f02b6182..ae7c9a4d 100644 --- a/scb-persistenceproviders/elasticsearch-persistenceprovider/pom.xml +++ b/scb-persistenceproviders/elasticsearch-persistenceprovider/pom.xml @@ -71,6 +71,10 @@ 1.2.1 test + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + diff --git a/scb-persistenceproviders/elasticsearch-persistenceprovider/src/main/java/io/securecodebox/persistence/elasticsearch/ElasticSearchPersistenceProvider.java b/scb-persistenceproviders/elasticsearch-persistenceprovider/src/main/java/io/securecodebox/persistence/elasticsearch/ElasticSearchPersistenceProvider.java index f0d9eab2..3b8a000f 100644 --- a/scb-persistenceproviders/elasticsearch-persistenceprovider/src/main/java/io/securecodebox/persistence/elasticsearch/ElasticSearchPersistenceProvider.java +++ b/scb-persistenceproviders/elasticsearch-persistenceprovider/src/main/java/io/securecodebox/persistence/elasticsearch/ElasticSearchPersistenceProvider.java @@ -22,6 +22,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; import io.securecodebox.model.findings.Finding; import io.securecodebox.model.securitytest.SecurityTest; import io.securecodebox.persistence.PersistenceException; @@ -168,6 +169,7 @@ public void persist(SecurityTest securityTest) throws PersistenceException{ } ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new Jdk8Module()); try { checkForSecurityTestIdExistence(securityTest); @@ -336,6 +338,7 @@ private String readFileResource(String file) { private Map serializeAndRemove(Object object, String... toRemove) { ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new Jdk8Module()); try { String jsonString = objectMapper.writeValueAsString(object); Map result = objectMapper.readValue(jsonString, new TypeReference>() { @@ -402,6 +405,7 @@ private void initializeKibana() throws IOException { // The index-pattern "securecodebox*" doesn't exist, we need to create it along with the import objects ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new Jdk8Module()); String kibanaFile = readFileResource("kibana-imports.json"); List dataElements = objectMapper.readValue(kibanaFile, objectMapper.getTypeFactory().constructCollectionType(List.class, KibanaData.class)); diff --git a/scb-persistenceproviders/s3-persistenceprovider/pom.xml b/scb-persistenceproviders/s3-persistenceprovider/pom.xml index b228bd9c..b3a1182d 100644 --- a/scb-persistenceproviders/s3-persistenceprovider/pom.xml +++ b/scb-persistenceproviders/s3-persistenceprovider/pom.xml @@ -66,7 +66,7 @@ commons-io commons-io - RELEASE + 2.6 diff --git a/scb-sdk/src/main/java/io/securecodebox/model/execution/ScanProcessExecution.java b/scb-sdk/src/main/java/io/securecodebox/model/execution/ScanProcessExecution.java index ed1848b7..ad73c42f 100644 --- a/scb-sdk/src/main/java/io/securecodebox/model/execution/ScanProcessExecution.java +++ b/scb-sdk/src/main/java/io/securecodebox/model/execution/ScanProcessExecution.java @@ -25,15 +25,17 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder; import io.securecodebox.model.findings.Finding; +import java.util.Date; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; /** * @author Rüdiger Heins - iteratec GmbH * @since 08.03.18 */ -@JsonPropertyOrder({ "id", "context", "automated", "scanners", "scanner_type", "tenant_id" }) +@JsonPropertyOrder({ "id", "context", "automated", "scanners", "scanner_type", "tenant_id", "startDate", "endDate", "durationInMilliSeconds" }) @JsonInclude(JsonInclude.Include.NON_EMPTY) public interface ScanProcessExecution { @@ -109,4 +111,13 @@ public interface ScanProcessExecution { @JsonProperty("name") void setName(String name); + + @JsonProperty("durationInMilliSeconds") + Long getDurationInMilliSeconds(); + + @JsonProperty("startDate") + Date getStartDate(); + + @JsonProperty("endDate") + Optional getEndDate(); } diff --git a/scb-sdk/src/main/java/io/securecodebox/model/securitytest/SecurityTest.java b/scb-sdk/src/main/java/io/securecodebox/model/securitytest/SecurityTest.java index c6f159d6..e1b45547 100644 --- a/scb-sdk/src/main/java/io/securecodebox/model/securitytest/SecurityTest.java +++ b/scb-sdk/src/main/java/io/securecodebox/model/securitytest/SecurityTest.java @@ -24,7 +24,9 @@ import io.securecodebox.model.rest.Report; import io.swagger.annotations.ApiModelProperty; +import java.util.Date; import java.util.Map; +import java.util.Optional; import java.util.UUID; public class SecurityTest extends AbstractSecurityTest { @@ -41,6 +43,10 @@ public SecurityTest(UUID id, String context, String name, Target target, Report } public SecurityTest(UUID id, String context, String name, Target target, Report report, Map metaData, String tenant) { + this(id, context, name, target, report, metaData, tenant, null, Optional.empty()); + } + + public SecurityTest(UUID id, String context, String name, Target target, Report report, Map metaData, String tenant, Date startedAt, Optional endedAt) { this.id = id; this.context = context; this.name = name; @@ -48,6 +54,8 @@ public SecurityTest(UUID id, String context, String name, Target target, Report this.report = report; this.tenant = tenant; this.setMetaData(metaData); + this.startedAt = startedAt; + this.endedAt = endedAt; } public SecurityTest(ScanProcessExecution execution){ @@ -61,6 +69,8 @@ public SecurityTest(ScanProcessExecution execution){ this.target = execution.getTargets().get(0); } this.report = new Report(execution); + this.startedAt = execution.getStartDate(); + this.endedAt = execution.getEndDate(); } public Report getReport() { @@ -87,4 +97,44 @@ public void setId(UUID id) { public boolean isFinished(){ return this.report != null; } + + @JsonProperty("durationInMilliSeconds") + @ApiModelProperty( + value = "Shows the current runtime duration or the time to completion in milli seconds.", + example = "42" + ) + public Long getDurationInMilliSeconds() { + if(startedAt == null){ + return null; + } + return endedAt.orElseGet(Date::new).getTime() - startedAt.getTime(); + } + + @JsonProperty("startedAt") + @ApiModelProperty( + value = "Timestamp of when the security test was started.", + example = "42" + ) + protected Date startedAt; + public Date startedAt() { + return startedAt; + } + + public void setStartedAt(Date startedAt) { + this.startedAt = startedAt; + } + + @JsonProperty("endedAt") + @ApiModelProperty( + value = "Timestamp of when the security test was ended. Null if still running, see finished attributes", + example = "42" + ) + protected Optional endedAt; + public Optional getEndedAt() { + return endedAt; + } + + public void setEndedAt(Optional endedAt) { + this.endedAt = endedAt; + } }