Skip to content

Commit eed84e5

Browse files
authored
Merge pull request #1047 from DependencyTrack/file-storage-plugin
Implement file storage plugin
2 parents f5ef5e3 + ed7a392 commit eed84e5

25 files changed

+2139
-116
lines changed

pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,12 @@
543543
<version>${lib.testcontainers.version}</version>
544544
<scope>test</scope>
545545
</dependency>
546+
<dependency>
547+
<groupId>org.testcontainers</groupId>
548+
<artifactId>minio</artifactId>
549+
<version>${lib.testcontainers.version}</version>
550+
<scope>test</scope>
551+
</dependency>
546552
<dependency>
547553
<groupId>org.testcontainers</groupId>
548554
<artifactId>postgresql</artifactId>

src/main/java/org/dependencytrack/event/BomUploadEvent.java

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@
2020

2121
import alpine.event.framework.AbstractChainableEvent;
2222
import org.dependencytrack.model.Project;
23-
24-
import java.io.File;
23+
import org.dependencytrack.proto.storage.v1alpha1.FileMetadata;
2524

2625
/**
2726
* Defines an event triggered when a bill-of-material (bom) document is submitted.
@@ -32,18 +31,18 @@
3231
public class BomUploadEvent extends AbstractChainableEvent {
3332

3433
private final Project project;
35-
private final File file;
34+
private final FileMetadata fileMetadata;
3635

37-
public BomUploadEvent(final Project project, final File file) {
36+
public BomUploadEvent(final Project project, final FileMetadata fileMetadata) {
3837
this.project = project;
39-
this.file = file;
38+
this.fileMetadata = fileMetadata;
4039
}
4140

4241
public Project getProject() {
4342
return project;
4443
}
4544

46-
public File getFile() {
47-
return file;
45+
public FileMetadata getFileMetadata() {
46+
return fileMetadata;
4847
}
4948
}

src/main/java/org/dependencytrack/resources/v1/BomResource.java

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,13 @@
5454
import org.dependencytrack.parser.cyclonedx.CycloneDxValidator;
5555
import org.dependencytrack.parser.cyclonedx.InvalidBomException;
5656
import org.dependencytrack.persistence.QueryManager;
57+
import org.dependencytrack.plugin.PluginManager;
58+
import org.dependencytrack.proto.storage.v1alpha1.FileMetadata;
5759
import org.dependencytrack.resources.v1.problems.InvalidBomProblemDetails;
5860
import org.dependencytrack.resources.v1.problems.ProblemDetails;
5961
import org.dependencytrack.resources.v1.vo.BomSubmitRequest;
6062
import org.dependencytrack.resources.v1.vo.BomUploadResponse;
63+
import org.dependencytrack.storage.FileStorage;
6164
import org.glassfish.jersey.media.multipart.BodyPartEntity;
6265
import org.glassfish.jersey.media.multipart.FormDataBodyPart;
6366
import org.glassfish.jersey.media.multipart.FormDataParam;
@@ -80,13 +83,11 @@
8083
import jakarta.ws.rs.core.MediaType;
8184
import jakarta.ws.rs.core.Response;
8285
import java.io.ByteArrayInputStream;
83-
import java.io.File;
8486
import java.io.IOException;
8587
import java.io.StringReader;
8688
import java.nio.charset.StandardCharsets;
87-
import java.nio.file.Files;
88-
import java.nio.file.StandardOpenOption;
8989
import java.security.Principal;
90+
import java.time.Instant;
9091
import java.util.Arrays;
9192
import java.util.Base64;
9293
import java.util.List;
@@ -465,17 +466,17 @@ private Response process(QueryManager qm, Project project, String encodedBomData
465466
if (project != null) {
466467
requireAccess(qm, project);
467468

468-
final File bomFile;
469+
final FileMetadata bomFileMetadata;
469470
try (final var encodedInputStream = new ByteArrayInputStream(encodedBomData.getBytes(StandardCharsets.UTF_8));
470471
final var decodedInputStream = Base64.getDecoder().wrap(encodedInputStream);
471472
final var byteOrderMarkInputStream = new BOMInputStream(decodedInputStream)) {
472-
bomFile = validateAndStoreBom(IOUtils.toByteArray(byteOrderMarkInputStream), project);
473+
bomFileMetadata = validateAndStoreBom(IOUtils.toByteArray(byteOrderMarkInputStream), project);
473474
} catch (IOException e) {
474475
LOGGER.error("An unexpected error occurred while validating or storing a BOM uploaded to project: " + project.getUuid(), e);
475476
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
476477
}
477478

478-
final BomUploadEvent bomUploadEvent = new BomUploadEvent(qm.detach(Project.class, project.getId()), bomFile);
479+
final BomUploadEvent bomUploadEvent = new BomUploadEvent(qm.detach(Project.class, project.getId()), bomFileMetadata);
479480
qm.createWorkflowSteps(bomUploadEvent.getChainIdentifier());
480481
Event.dispatch(bomUploadEvent);
481482

@@ -496,18 +497,18 @@ private Response process(QueryManager qm, Project project, List<FormDataBodyPart
496497
if (project != null) {
497498
requireAccess(qm, project);
498499

499-
final File bomFile;
500+
final FileMetadata bomFileMetadata;
500501
try (final var inputStream = bodyPartEntity.getInputStream();
501502
final var byteOrderMarkInputStream = new BOMInputStream(inputStream)) {
502-
bomFile = validateAndStoreBom(IOUtils.toByteArray(byteOrderMarkInputStream), project, artifactPart.getMediaType());
503+
bomFileMetadata = validateAndStoreBom(IOUtils.toByteArray(byteOrderMarkInputStream), project, artifactPart.getMediaType());
503504
} catch (IOException e) {
504505
LOGGER.error("An unexpected error occurred while validating or storing a BOM uploaded to project: " + project.getUuid(), e);
505506
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
506507
}
507508

508509
// todo: make option to combine all the bom data so components are reconciled in a single pass.
509510
// todo: https://github.com/DependencyTrack/dependency-track/issues/130
510-
final BomUploadEvent bomUploadEvent = new BomUploadEvent(qm.detach(Project.class, project.getId()), bomFile);
511+
final BomUploadEvent bomUploadEvent = new BomUploadEvent(qm.detach(Project.class, project.getId()), bomFileMetadata);
511512

512513
qm.createWorkflowSteps(bomUploadEvent.getChainIdentifier());
513514
Event.dispatch(bomUploadEvent);
@@ -522,25 +523,22 @@ private Response process(QueryManager qm, Project project, List<FormDataBodyPart
522523
return Response.ok().build();
523524
}
524525

525-
private File validateAndStoreBom(final byte[] bomBytes, final Project project) throws IOException {
526+
private FileMetadata validateAndStoreBom(final byte[] bomBytes, final Project project) throws IOException {
526527
return validateAndStoreBom(bomBytes, project, null);
527528
}
528529

529-
private File validateAndStoreBom(final byte[] bomBytes, final Project project, MediaType mediaType) throws IOException {
530+
private FileMetadata validateAndStoreBom(final byte[] bomBytes, final Project project, MediaType mediaType) throws IOException {
530531
validate(bomBytes, project, mediaType);
531532

532-
// TODO: Store externally so other instances of the API server can pick it up.
533-
// https://github.com/CycloneDX/cyclonedx-bom-repo-server
534-
final java.nio.file.Path tmpPath = Files.createTempFile("dtrack-bom-%s".formatted(project.getUuid()), null);
535-
final File tmpFile = tmpPath.toFile();
536-
tmpFile.deleteOnExit();
537-
538-
LOGGER.debug("Writing BOM for project %s to %s".formatted(project.getUuid(), tmpPath));
539-
try (final var tmpOutputStream = Files.newOutputStream(tmpPath, StandardOpenOption.WRITE)) {
540-
tmpOutputStream.write(bomBytes);
533+
// TODO: Provide mediaType to FileStorage#store. Should be any of:
534+
// * application/vnd.cyclonedx+json
535+
// * application/vnd.cyclonedx+xml
536+
// * application/x.vnd.cyclonedx+protobuf
537+
// Consider also attaching the detected version, i.e. application/vnd.cyclonedx+xml; version=1.6
538+
// See https://cyclonedx.org/specification/overview/ -> Media Types.
539+
try (final var fileStorage = PluginManager.getInstance().getExtension(FileStorage.class)) {
540+
return fileStorage.store("bom-upload/%s_%s".formatted(Instant.now().toEpochMilli(), project.getUuid()), bomBytes);
541541
}
542-
543-
return tmpFile;
544542
}
545543

546544
static void validate(final byte[] bomBytes, final Project project) {
@@ -636,7 +634,7 @@ private static boolean shouldValidate(final Project project) {
636634
.map(org.dependencytrack.model.Tag::getName)
637635
.anyMatch(validationModeTags::contains);
638636
return (validationMode == BomValidationMode.ENABLED_FOR_TAGS && doTagsMatch)
639-
|| (validationMode == BomValidationMode.DISABLED_FOR_TAGS && !doTagsMatch);
637+
|| (validationMode == BomValidationMode.DISABLED_FOR_TAGS && !doTagsMatch);
640638
}
641639
}
642640
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* This file is part of Dependency-Track.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* Copyright (c) OWASP Foundation. All Rights Reserved.
18+
*/
19+
package org.dependencytrack.storage;
20+
21+
import org.dependencytrack.plugin.api.ExtensionPoint;
22+
import org.dependencytrack.proto.storage.v1alpha1.FileMetadata;
23+
24+
import java.io.FileNotFoundException;
25+
import java.io.IOException;
26+
import java.util.regex.Pattern;
27+
28+
import static java.util.Objects.requireNonNull;
29+
30+
/**
31+
* @since 5.6.0
32+
*/
33+
public interface FileStorage extends ExtensionPoint {
34+
35+
Pattern VALID_NAME_PATTERN = Pattern.compile("[a-zA-Z0-9_/\\-.]+");
36+
37+
/**
38+
* Persist data to a file in storage.
39+
* <p>
40+
* Storage providers may transparently perform additional steps,
41+
* such as encryption and compression.
42+
*
43+
* @param fileName Name of the file. This fileName is not guaranteed to be reflected
44+
* in storage as-is. It may be modified or changed entirely.
45+
* @param mediaType Media type of the file.
46+
* @param content Data to store.
47+
* @return Metadata of the stored file.
48+
* @throws IOException When storing the file failed.
49+
* @see <a href="https://www.iana.org/assignments/media-types/media-types.xhtml">IANA Media Types</a>
50+
*/
51+
FileMetadata store(final String fileName, final String mediaType, final byte[] content) throws IOException;
52+
53+
/**
54+
* Persist data to a file in storage, assuming the media type to be {@code application/octet-stream}.
55+
*
56+
* @see #store(String, String, byte[])
57+
*/
58+
default FileMetadata store(final String fileName, final byte[] content) throws IOException {
59+
return store(fileName, "application/octet-stream", content);
60+
}
61+
62+
/**
63+
* Retrieves a file from storage.
64+
* <p>
65+
* Storage providers may transparently perform additional steps,
66+
* such as integrity verification, decryption and decompression.
67+
* <p>
68+
* Trying to retrieve a file from a different storage provider
69+
* is an illegal operation and yields an exception.
70+
*
71+
* @param fileMetadata Metadata of the file to retrieve.
72+
* @return The file's content.
73+
* @throws IOException When retrieving the file failed.
74+
* @throws FileNotFoundException When the requested file was not found.
75+
*/
76+
byte[] get(final FileMetadata fileMetadata) throws IOException;
77+
78+
/**
79+
* Deletes a file from storage.
80+
* <p>
81+
* Trying to delete a file from a different storage provider
82+
* is an illegal operation and yields an exception.
83+
*
84+
* @param fileMetadata Metadata of the file to delete.
85+
* @return {@code true} when the file was deleted, otherwise {@code false}.
86+
* @throws IOException When deleting the file failed.
87+
*/
88+
boolean delete(final FileMetadata fileMetadata) throws IOException;
89+
90+
// TODO: deleteMany. Some remote storage backends support batch deletes.
91+
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html
92+
93+
static void requireValidFileName(final String fileName) {
94+
requireNonNull(fileName, "fileName must not be null");
95+
96+
if (!VALID_NAME_PATTERN.matcher(fileName).matches()) {
97+
throw new IllegalArgumentException("fileName must match pattern: " + VALID_NAME_PATTERN.pattern());
98+
}
99+
}
100+
101+
class ExtensionPointMetadata implements org.dependencytrack.plugin.api.ExtensionPointMetadata<FileStorage> {
102+
103+
@Override
104+
public String name() {
105+
return "file.storage";
106+
}
107+
108+
@Override
109+
public boolean required() {
110+
return true;
111+
}
112+
113+
@Override
114+
public Class<FileStorage> extensionPointClass() {
115+
return FileStorage.class;
116+
}
117+
118+
}
119+
120+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* This file is part of Dependency-Track.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* Copyright (c) OWASP Foundation. All Rights Reserved.
18+
*/
19+
package org.dependencytrack.storage;
20+
21+
import org.dependencytrack.plugin.api.ExtensionFactory;
22+
import org.dependencytrack.plugin.api.ExtensionPoint;
23+
import org.dependencytrack.plugin.api.Plugin;
24+
25+
import java.util.Collection;
26+
import java.util.List;
27+
28+
/**
29+
* @since 5.6.0
30+
*/
31+
public final class FileStoragePlugin implements Plugin {
32+
33+
@Override
34+
public Collection<? extends ExtensionFactory<? extends ExtensionPoint>> extensionFactories() {
35+
return List.of(
36+
new LocalFileStorageFactory(),
37+
new MemoryFileStorageFactory(),
38+
new S3FileStorageFactory());
39+
}
40+
41+
}

0 commit comments

Comments
 (0)