Skip to content
Open
6 changes: 6 additions & 0 deletions src/main/java/io/github/guacsec/trustifyda/Provider.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,16 @@ public abstract class Provider {
public static class Content {
public final byte[] buffer;
public final String type;
public final boolean batch;

public Content(byte[] buffer, String type) {
this(buffer, type, false);
}

public Content(byte[] buffer, String type, boolean batch) {
this.buffer = buffer;
this.type = type;
this.batch = batch;
}
}

Expand Down
23 changes: 21 additions & 2 deletions src/main/java/io/github/guacsec/trustifyda/cli/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import io.github.guacsec.trustifyda.impl.ExhortApi;
import io.github.guacsec.trustifyda.license.ProjectLicense;
import io.github.guacsec.trustifyda.license.ProjectLicense.ProjectLicenseInfo;
import io.github.guacsec.trustifyda.providers.DockerfileProvider;
import io.github.guacsec.trustifyda.tools.Ecosystem;
import java.io.IOException;
import java.nio.file.Files;
Expand Down Expand Up @@ -219,6 +220,16 @@ private static Path validateFile(String filePath) {
}

private static CompletableFuture<String> executeCommand(CliArgs args) throws IOException {
if (isDockerfilePath(args.filePath)) {
if (args.command == Command.SBOM || args.command == Command.LICENSE) {
throw new IllegalArgumentException(
args.command
+ " command is not supported for Dockerfiles/Containerfiles."
+ " Use 'stack' or 'component' to analyze container images.");
}
Set<ImageRef> imageRefs = DockerfileProvider.parseImageRefs(args.filePath);
return executeImageAnalysis(imageRefs, args.outputFormat);
}
return switch (args.command) {
case STACK ->
executeStackAnalysis(args.filePath.toAbsolutePath().toString(), args.outputFormat);
Expand All @@ -230,6 +241,10 @@ private static CompletableFuture<String> executeCommand(CliArgs args) throws IOE
};
}

private static boolean isDockerfilePath(Path filePath) {
return filePath != null && Ecosystem.isDockerfile(filePath.getFileName().toString());
}

private static CompletableFuture<String> executeStackAnalysis(
String filePath, OutputFormat outputFormat) throws IOException {
Api api = new ExhortApi();
Expand Down Expand Up @@ -338,7 +353,11 @@ private static CompletableFuture<String> executeSbomGeneration(CliArgs args) thr

private static String formatImageAnalysisResult(Map<ImageRef, AnalysisReport> analysisResults) {
try {
return MAPPER.writeValueAsString(analysisResults);
Map<String, AnalysisReport> keyed = new LinkedHashMap<>();
for (Map.Entry<ImageRef, AnalysisReport> entry : analysisResults.entrySet()) {
keyed.put(entry.getKey().getImage().getFullName(), entry.getValue());
}
return MAPPER.writeValueAsString(keyed);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize image analysis results", e);
}
Expand All @@ -349,7 +368,7 @@ private static Map<String, Map<String, SourceSummary>> extractImageSummary(
Map<String, Map<String, SourceSummary>> imageSummaries = new HashMap<>();

for (Map.Entry<ImageRef, AnalysisReport> entry : analysisResults.entrySet()) {
String imageKey = entry.getKey().toString();
String imageKey = entry.getKey().getImage().getFullName();
Map<String, SourceSummary> imageSummary = extractSummary(entry.getValue());
imageSummaries.put(imageKey, imageSummary);
}
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/io/github/guacsec/trustifyda/image/ImageRef.java
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,26 @@ void checkImageDigest() {
}
}

private static final String DOCKER_HUB_LIBRARY_PREFIX = "docker.io/library/";

// https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#oci
public PackageURL getPackageURL() throws MalformedPackageURLException {
TreeMap<String, String> qualifiers = new TreeMap<>();
var repositoryUrl = this.image.getNameWithoutTag();
var simpleName = this.image.getSimpleName();

// Normalize Docker Hub image references so all forms produce the same PURL:
// node → docker.io/node
// docker.io/library/node → docker.io/node
if (repositoryUrl != null) {
var lower = repositoryUrl.toLowerCase();
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
if (lower.equals(simpleName.toLowerCase())) {
repositoryUrl = "docker.io/" + simpleName;
} else if (lower.startsWith(DOCKER_HUB_LIBRARY_PREFIX)) {
repositoryUrl = "docker.io/" + repositoryUrl.substring(DOCKER_HUB_LIBRARY_PREFIX.length());
}
}

if (repositoryUrl != null && !repositoryUrl.equalsIgnoreCase(simpleName)) {
qualifiers.put(REPOSITORY_QUALIFIER, repositoryUrl.toLowerCase());
}
Expand Down
151 changes: 123 additions & 28 deletions src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ public final class ExhortApi implements Api {
public static final String S_API_V5_LICENSES = "%s/api/v5/licenses/%s";
public static final String S_API_V5_LICENSES_IDENTIFY = "%s/api/v5/licenses/identify";
private static final String TRUSTIFY_DA_LICENSE_CHECK = "TRUSTIFY_DA_LICENSE_CHECK";
private static final String TRUSTIFY_DA_RECOMMEND = "TRUSTIFY_DA_RECOMMEND";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JS client uses TRUSTIFY_DA_RECOMMENDATIONS_ENABLED afaik for the same feature, so this should match that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is from an older worktree (TC-4694). The JS client's main branch has already been updated to use TRUSTIFY_DA_RECOMMEND

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[sdlc-workflow/verify-pr] Classified as: Question

Investigation: Both Java and JS clients use TRUSTIFY_DA_RECOMMEND — the name is consistent. The JS client does NOT use TRUSTIFY_DA_RECOMMENDATIONS_ENABLED.


private String endpoint;

Expand Down Expand Up @@ -296,10 +297,36 @@ public CompletableFuture<byte[]> stackAnalysisHtml(final String manifestFile) th
public CompletableFuture<AnalysisReport> stackAnalysis(final String manifestFile)
throws IOException {
String exClientTraceId = commonHookBeginning(false);
var content = resolveStackContent(manifestFile);
var uri = resolveAnalysisUri(content);
var request = buildRequest(content, uri, MediaType.APPLICATION_JSON, "Stack Analysis");
if (content.batch) {
return this.client
.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(
response -> {
RequestManager.getInstance().addClientTraceIdToRequest(exClientTraceId);
if (debugLoggingIsNeeded()) {
logExhortRequestId(response);
}
Map<String, AnalysisReport> reports = getBatchStackAnalysisReports(response);
commonHookAfterExhortResponse();
return reports.isEmpty()
? new AnalysisReport()
: reports.values().iterator().next();
})
.exceptionally(
exception -> {
LOG.severe(
String.format(
"failed to invoke stackAnalysis (batch) for getting the json report,"
+ " received message= %s ",
exception.getMessage()));
return new AnalysisReport();
});
}
return this.client
.sendAsync(
this.buildStackRequest(manifestFile, MediaType.APPLICATION_JSON),
HttpResponse.BodyHandlers.ofString())
.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(
response ->
getAnalysisReportFromResponse(response, "StackAnalysis", "json", exClientTraceId))
Expand Down Expand Up @@ -361,15 +388,26 @@ public static boolean debugLoggingIsNeeded() {
return Environment.getBoolean("TRUSTIFY_DA_DEBUG", false);
}

private static URI buildAnalysisUri(String template, String endpoint) {
String base = String.format(template, endpoint);
if (!Environment.getBoolean(TRUSTIFY_DA_RECOMMEND, true)) {
return URI.create(base + "?recommend=false");
}
return URI.create(base);
}

@Override
public CompletableFuture<AnalysisReport> componentAnalysis(
final String manifest, final byte[] manifestContent) throws IOException {
String exClientTraceId = commonHookBeginning(false);
var manifestPath = Path.of(manifest);
var provider = Ecosystem.getProvider(manifestPath);
var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getEndpoint()));
var content = provider.provideComponent();
commonHookAfterProviderCreatedSbomAndBeforeExhort();
var uri = resolveAnalysisUri(content);
if (content.batch) {
return getBatchAnalysisReportForComponent(uri, content, exClientTraceId);
}
return getAnalysisReportForComponent(uri, content, exClientTraceId);
}

Expand Down Expand Up @@ -411,9 +449,12 @@ public CompletableFuture<AnalysisReport> componentAnalysis(String manifestFile)
String exClientTraceId = commonHookBeginning(false);
var manifestPath = Path.of(manifestFile);
var provider = Ecosystem.getProvider(manifestPath);
var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getEndpoint()));
var content = provider.provideComponent();
commonHookAfterProviderCreatedSbomAndBeforeExhort();
var uri = resolveAnalysisUri(content);
if (content.batch) {
return getBatchAnalysisReportForComponent(uri, content, exClientTraceId);
}
return getAnalysisReportForComponent(uri, content, exClientTraceId);
}

Expand All @@ -440,8 +481,48 @@ private CompletableFuture<AnalysisReport> getAnalysisReportForComponent(
});
}

/** Resolves the analysis URI based on whether the content is batch or single. */
private URI resolveAnalysisUri(Provider.Content content) {
if (content.batch) {
return buildAnalysisUri(S_API_V_5_BATCH_ANALYSIS, getEndpoint());
}
return buildAnalysisUri(S_API_V_5_ANALYSIS, getEndpoint());
}

/**
* Build an HTTP request wrapper for sending to the Backend API for Stack Analysis only.
* Sends batch content to the batch-analysis endpoint and returns the first analysis report from
* the batch response map.
*/
private CompletableFuture<AnalysisReport> getBatchAnalysisReportForComponent(
URI uri, Provider.Content content, String exClientTraceId) {
return this.client
.sendAsync(
this.buildRequest(content, uri, MediaType.APPLICATION_JSON, "Batch Component Analysis"),
HttpResponse.BodyHandlers.ofString())
.thenApply(
response -> {
RequestManager.getInstance().addClientTraceIdToRequest(exClientTraceId);
if (debugLoggingIsNeeded()) {
logExhortRequestId(response);
}
Map<String, AnalysisReport> reports = getBatchStackAnalysisReports(response);
commonHookAfterExhortResponse();
return reports.isEmpty() ? new AnalysisReport() : reports.values().iterator().next();
})
.exceptionally(
exception -> {
LOG.severe(
String.format(
"failed to invoke Batch Component Analysis for getting the json report,"
+ " received message= %s ",
exception.getMessage()));
return new AnalysisReport();
});
}

/**
* Build an HTTP request wrapper for sending to the Backend API for Stack Analysis only. Uses the
* batch-analysis endpoint when the provider returns batch content.
*
* @param manifestFile the path for the manifest file
* @param acceptType the type of requested content
Expand All @@ -450,13 +531,21 @@ private CompletableFuture<AnalysisReport> getAnalysisReportForComponent(
*/
private HttpRequest buildStackRequest(final String manifestFile, final MediaType acceptType)
throws IOException {
var content = resolveStackContent(manifestFile);
var uri = resolveAnalysisUri(content);
return buildRequest(content, uri, acceptType, "Stack Analysis");
}

/**
* Resolves the provider for the given manifest file and produces the stack content. Also tracks
* timing via {@link #commonHookAfterProviderCreatedSbomAndBeforeExhort()}.
*/
private Provider.Content resolveStackContent(String manifestFile) throws IOException {
var manifestPath = Path.of(manifestFile);
var provider = Ecosystem.getProvider(manifestPath);
var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getEndpoint()));
var content = provider.provideStack();
commonHookAfterProviderCreatedSbomAndBeforeExhort();

return buildRequest(content, uri, acceptType, "Stack Analysis");
return content;
}

@Override
Expand Down Expand Up @@ -539,7 +628,7 @@ <H, T> CompletableFuture<T> performBatchAnalysis(
final String analysisName)
throws IOException {
String exClientTraceId = commonHookBeginning(false);
var uri = URI.create(String.format(S_API_V_5_BATCH_ANALYSIS, getEndpoint()));
var uri = buildAnalysisUri(S_API_V_5_BATCH_ANALYSIS, getEndpoint());
var sboms = sbomsGenerator.get();
var content =
new Provider.Content(
Expand Down Expand Up @@ -601,27 +690,33 @@ public CompletableFuture<ComponentAnalysisResult> componentAnalysisWithLicense(
String exClientTraceId = commonHookBeginning(false);
var manifestPath = Path.of(manifestFile);
var provider = Ecosystem.getProvider(manifestPath);
var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getEndpoint()));
var content = provider.provideComponent();
String sbomJson = new String(content.buffer);
commonHookAfterProviderCreatedSbomAndBeforeExhort();
return getAnalysisReportForComponent(uri, content, exClientTraceId)
.thenCompose(
report -> {
if (!isLicenseCheckEnabled()) {
return CompletableFuture.completedFuture(new ComponentAnalysisResult(report, null));
}
return LicenseCheck.runLicenseCheck(this, provider, manifestPath, sbomJson, report)
.thenApply(summary -> new ComponentAnalysisResult(report, summary))
.exceptionally(
ex -> {
LOG.warning(
String.format(
"License check failed, continuing without it: %s",
ex.getMessage()));
return new ComponentAnalysisResult(report, null);
});
});
var uri = resolveAnalysisUri(content);
CompletableFuture<AnalysisReport> reportFuture =
content.batch
? getBatchAnalysisReportForComponent(uri, content, exClientTraceId)
: getAnalysisReportForComponent(uri, content, exClientTraceId);
return reportFuture.thenCompose(
report -> {
if (!isLicenseCheckEnabled() || content.batch) {
LOG.fine(
String.format(
"Skipping license check: enabled=%b, batch=%b",
isLicenseCheckEnabled(), content.batch));
return CompletableFuture.completedFuture(new ComponentAnalysisResult(report, null));
}
return LicenseCheck.runLicenseCheck(this, provider, manifestPath, sbomJson, report)
.thenApply(summary -> new ComponentAnalysisResult(report, summary))
.exceptionally(
ex -> {
LOG.warning(
String.format(
"License check failed, continuing without it: %s", ex.getMessage()));
return new ComponentAnalysisResult(report, null);
});
});
}

/**
Expand Down
Loading
Loading