Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>jakarta.annotation</groupId>
<artifactId>jakarta.annotation-api</artifactId>
Expand Down Expand Up @@ -228,7 +233,10 @@
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.datatype/jackson-datatype-jsr310 -->
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
</dependency>

<dependency>
<groupId>jakarta.annotation</groupId>
Expand Down
26 changes: 26 additions & 0 deletions src/main/java/io/github/guacsec/trustifyda/Api.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import io.github.guacsec.trustifyda.api.v5.AnalysisReport;
import io.github.guacsec.trustifyda.image.ImageRef;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
Expand Down Expand Up @@ -134,4 +135,29 @@ CompletableFuture<Map<ImageRef, AnalysisReport>> imageAnalysis(Set<ImageRef> ima
* @throws IllegalStateException when the manifest file type is not supported
*/
String generateSbom(String manifestFile) throws IOException;

/**
* Performs batch stack analysis for all workspace members discovered in the given workspace
* directory. Discovers package.json manifests via pnpm-workspace.yaml or package.json workspaces
* field, generates SBOMs for each, and sends them as a batch to the backend.
*
* @param workspaceDir the workspace root directory path
* @param ignorePatterns glob patterns for paths to exclude from workspace discovery
* @return a map of manifest name to analysis report, wrapped in a CompletableFuture
* @throws IOException when workspace discovery or SBOM generation fails
*/
CompletableFuture<Map<String, AnalysisReport>> stackAnalysisBatch(
Path workspaceDir, Set<String> ignorePatterns) throws IOException;

/**
* Performs batch stack analysis returning an HTML report for all workspace members discovered in
* the given workspace directory.
*
* @param workspaceDir the workspace root directory path
* @param ignorePatterns glob patterns for paths to exclude from workspace discovery
* @return the HTML report as a byte array wrapped in a CompletableFuture
* @throws IOException when workspace discovery or SBOM generation fails
*/
CompletableFuture<byte[]> stackAnalysisBatchHtml(Path workspaceDir, Set<String> ignorePatterns)
throws IOException;
}
242 changes: 242 additions & 0 deletions src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,12 @@
import io.github.guacsec.trustifyda.image.ImageUtils;
import io.github.guacsec.trustifyda.license.LicenseCheck;
import io.github.guacsec.trustifyda.logging.LoggersFactory;
import io.github.guacsec.trustifyda.providers.javascript.workspace.JsWorkspaceDiscovery;
import io.github.guacsec.trustifyda.providers.rust.model.CargoMetadata;
import io.github.guacsec.trustifyda.tools.Ecosystem;
import io.github.guacsec.trustifyda.tools.Operations;
import io.github.guacsec.trustifyda.utils.Environment;
import io.github.guacsec.trustifyda.utils.WorkspaceUtils;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMultipart;
import jakarta.mail.util.ByteArrayDataSource;
Expand All @@ -50,7 +54,9 @@
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
Expand Down Expand Up @@ -703,6 +709,242 @@ public CompletableFuture<String> identifyLicense(Path licenseFilePath) {
});
}

@Override
public CompletableFuture<Map<String, AnalysisReport>> stackAnalysisBatch(
final Path workspaceDir, final Set<String> ignorePatterns) throws IOException {
return this.performBatchAnalysis(
() -> getBatchStackSboms(workspaceDir, ignorePatterns),
MediaType.APPLICATION_JSON,
HttpResponse.BodyHandlers.ofString(),
this::getBatchStackAnalysisReports,
Collections::emptyMap,
"Batch Stack Analysis");
}

@Override
public CompletableFuture<byte[]> stackAnalysisBatchHtml(
final Path workspaceDir, final Set<String> ignorePatterns) throws IOException {
return this.performBatchAnalysis(
() -> getBatchStackSboms(workspaceDir, ignorePatterns),
MediaType.TEXT_HTML,
HttpResponse.BodyHandlers.ofByteArray(),
HttpResponse::body,
() -> new byte[0],
"Batch Stack Analysis");
}

Map<String, JsonNode> getBatchStackSboms(
final Path workspaceDir, final Set<String> ignorePatterns) {
boolean continueOnError = Environment.getBoolean("TRUSTIFY_DA_CONTINUE_ON_ERROR", true);
int concurrency = resolveBatchConcurrency();
try {
Set<String> resolved = resolveIgnorePatterns(ignorePatterns);
List<Path> manifests = discoverWorkspaceManifests(workspaceDir, resolved);
if (manifests.isEmpty()) {
LOG.warning("No workspace members discovered in " + workspaceDir);
return Collections.emptyMap();
}

var executor = java.util.concurrent.Executors.newFixedThreadPool(concurrency);
try {
var futures =
manifests.stream()
.map(
manifest ->
java.util.concurrent.CompletableFuture.supplyAsync(
() -> {
try {
var provider = Ecosystem.getProvider(manifest);
var content = provider.provideStack();
var sbomJson = mapper.readTree(content.buffer);
var purl =
sbomJson
.at("/metadata/component/purl")
.asText(
sbomJson
.at("/metadata/component/bom-ref")
.asText(null));
if (purl == null || purl.isBlank()) {
throw new IllegalStateException(
"Missing purl in SBOM metadata.component for " + manifest);
}
return new AbstractMap.SimpleEntry<>(purl, sbomJson);
} catch (Exception ex) {
if (continueOnError) {
LOG.warning(
String.format(
"Skipping manifest %s due to error: %s",
manifest, ex.getMessage()));
return null;
}
throw new RuntimeException(
"Failed to generate SBOM for " + manifest, ex);
}
},
executor))
.toList();

var results =
futures.stream()
.map(java.util.concurrent.CompletableFuture::join)
.filter(Objects::nonNull)
.collect(
Collectors.toMap(
AbstractMap.SimpleEntry::getKey, AbstractMap.SimpleEntry::getValue));

if (Environment.getBoolean("TRUSTIFY_DA_BATCH_METADATA", false)) {
int failed = manifests.size() - results.size();
LOG.info(
String.format(
"Batch metadata: workspaceRoot=%s, total=%d, successful=%d, failed=%d",
workspaceDir, manifests.size(), results.size(), failed));
}

return results;
} finally {
executor.shutdown();
}
} catch (IOException e) {
throw new RuntimeException("Failed to discover workspace manifests", e);
}
}

Map<String, AnalysisReport> getBatchStackAnalysisReports(
final HttpResponse<String> httpResponse) {
if (httpResponse.statusCode() == 200) {
try {
Map<?, ?> reports = this.mapper.readValue(httpResponse.body(), Map.class);
return reports.entrySet().stream()
.collect(
Collectors.toMap(
e -> e.getKey().toString(),
e -> mapper.convertValue(e.getValue(), AnalysisReport.class)));
} catch (JsonProcessingException e) {
throw new CompletionException(e);
}
} else {
return Collections.emptyMap();
}
}

/** Resolves batch concurrency from TRUSTIFY_DA_BATCH_CONCURRENCY. default 10, max 256. */
int resolveBatchConcurrency() {
String raw = Environment.get("TRUSTIFY_DA_BATCH_CONCURRENCY", "10");
try {
int n = Integer.parseInt(raw.trim());
if (n < 1) {
return 10;
}
return Math.min(256, n);
} catch (NumberFormatException e) {
return 10;
}
}

private static final Set<String> DEFAULT_WORKSPACE_DISCOVERY_IGNORE =
Set.of("**/node_modules/**", "**/.git/**");

/** Merges default ignore patterns, env var overrides, and caller-provided patterns. */
Set<String> resolveIgnorePatterns(Set<String> callerPatterns) {
var merged = new java.util.LinkedHashSet<>(DEFAULT_WORKSPACE_DISCOVERY_IGNORE);
String fromEnv = Environment.get("TRUSTIFY_DA_WORKSPACE_DISCOVERY_IGNORE", null);
if (fromEnv != null && !fromEnv.isBlank()) {
for (String p : fromEnv.split(",")) {
String trimmed = p.trim();
if (!trimmed.isEmpty()) {
merged.add(trimmed);
}
}
}
if (callerPatterns != null) {
merged.addAll(callerPatterns);
}
return merged;
}

/**
* Detects the workspace ecosystem and discovers manifest paths. Checks for Cargo workspace first
* (Cargo.toml + Cargo.lock), then falls back to JS workspace discovery.
*/
List<Path> discoverWorkspaceManifests(Path workspaceDir, Set<String> ignorePatterns)
throws IOException {
// Cargo workspace: Cargo.toml + Cargo.lock
Path cargoToml = workspaceDir.resolve("Cargo.toml");
Path cargoLock = workspaceDir.resolve("Cargo.lock");
if (Files.isRegularFile(cargoToml) && Files.isRegularFile(cargoLock)) {
return discoverCargoManifests(workspaceDir, ignorePatterns);
}

// JS workspace: require package.json + a lock file
Path packageJson = workspaceDir.resolve("package.json");
boolean hasJsLock =
Files.isRegularFile(workspaceDir.resolve("pnpm-lock.yaml"))
|| Files.isRegularFile(workspaceDir.resolve("yarn.lock"))
|| Files.isRegularFile(workspaceDir.resolve("package-lock.json"));

if (Files.isRegularFile(packageJson) && hasJsLock) {
List<Path> manifests =
JsWorkspaceDiscovery.discoverWorkspaceManifests(workspaceDir, ignorePatterns);
if (manifests.isEmpty()) {
return List.of(packageJson);
}
// Include root package.json if it is not private and not already discovered
if (!manifests.contains(packageJson) && !isPrivatePackageJson(packageJson)) {
var withRoot = new ArrayList<>(manifests);
withRoot.addFirst(packageJson);
manifests = withRoot;
}
return manifests;
}

return Collections.emptyList();
}

private List<Path> discoverCargoManifests(Path workspaceDir, Set<String> ignorePatterns) {
try {
String cargo = Operations.getCustomPathOrElse("cargo");
Operations.ProcessExecOutput output =
Operations.runProcessGetFullOutput(
workspaceDir,
new String[] {cargo, "metadata", "--format-version", "1", "--no-deps"},
null);
if (output.getExitCode() != 0) {
LOG.warning("cargo metadata failed with exit code " + output.getExitCode());
return Collections.emptyList();
}
CargoMetadata metadata = mapper.readValue(output.getOutput(), CargoMetadata.class);
var memberIds = new java.util.HashSet<String>(metadata.workspaceMembers());
List<Path> manifests = new ArrayList<>();
for (var pkg : metadata.packages()) {
if (memberIds.contains(pkg.id()) && pkg.manifestPath() != null) {
Path manifestPath = Path.of(pkg.manifestPath());
if (Files.isRegularFile(manifestPath)) {
manifests.add(manifestPath);
}
}
}
return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifests, ignorePatterns);
} catch (Exception e) {
LOG.warning("Failed to discover Cargo workspace manifests: " + e.getMessage());
return Collections.emptyList();
}
}

/**
* Checks whether a package.json has "private": true, meaning it should not be analyzed as a
* publishable package.
*/
private boolean isPrivatePackageJson(Path packageJson) {
try {
JsonNode root = mapper.readTree(Files.newInputStream(packageJson));
JsonNode privateField = root.get("private");
return privateField != null && privateField.asBoolean(false);
} catch (IOException e) {
LOG.warning("Failed to read " + packageJson + ": " + e.getMessage());
return true;
}
}

/**
* Build an HTTP request for sending to the Backend API.
*
Expand Down
Loading
Loading