diff --git a/src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java b/src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java index b4d5ab98..296eec87 100644 --- a/src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java +++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java @@ -11,6 +11,8 @@ package org.jboss.tools.intellij.componentanalysis; +import com.fasterxml.jackson.databind.JsonNode; +import com.github.packageurl.MalformedPackageURLException; import com.github.packageurl.PackageURL; import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer; import com.intellij.codeInsight.daemon.HighlightDisplayKey; @@ -29,10 +31,17 @@ import com.intellij.psi.PsiFile; import com.intellij.serviceContainer.AlreadyDisposedException; import io.github.guacsec.trustifyda.api.v5.DependencyReport; +import io.github.guacsec.trustifyda.api.v5.LicenseIdentifier; +import io.github.guacsec.trustifyda.license.LicenseCheck.IncompatibleDependency; +import io.github.guacsec.trustifyda.license.LicenseCheck.LicenseSummary; +import io.github.guacsec.trustifyda.license.LicenseCheck.ProjectLicenseSummary; +import org.jboss.tools.intellij.settings.ApiSettingsState; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -42,7 +51,7 @@ -public abstract class CAAnnotator extends ExternalAnnotator> { +public abstract class CAAnnotator extends ExternalAnnotator { private static final Logger LOG = Logger.getInstance(CAAnnotator.class); @@ -63,7 +72,7 @@ public abstract class CAAnnotator extends ExternalAnnotator doAnnotate(Info info) { + public @Nullable AnnotationData doAnnotate(Info info) { if (info != null && info.getFile() != null && info.getDependencies() != null && !info.getDependencies().isEmpty()) { String path = info.getFile().getVirtualFile().getPath(); @@ -97,16 +106,35 @@ public abstract class CAAnnotator extends ExternalAnnotator> reports = CAService.getReports(path); Map dependencyResultMap = this.matchDependencies(info.getDependencies(), reports); - return dependencyResultMap; + return new AnnotationData(dependencyResultMap, info.getDependencies()); } return null; } @Override - public void apply(@NotNull PsiFile file, Map annotationResult, @NotNull AnnotationHolder holder) { + public void apply(@NotNull PsiFile file, AnnotationData annotationData, @NotNull AnnotationHolder holder) { + if (annotationData == null) { + return; + } + + // Pre-build incompatible license info keyed by Dependency (without version) + Map licenseMessages = new HashMap<>(); + Map licenseTooltips = new HashMap<>(); + LicenseSummary licenseSummary = null; + if (ApiSettingsState.getInstance().licenseCheckEnabled) { + String path = file.getVirtualFile().getPath(); + licenseSummary = CAService.getLicenseSummary(path); + if (licenseSummary != null) { + buildIncompatibleLicenseInfo(licenseSummary, licenseMessages, licenseTooltips); + } + } + LOG.info("Annotate dependencies"); - annotationResult.forEach((key, value) -> { + Set annotatedLicenseDeps = new HashSet<>(); + Map annotationResult = annotationData.getResults(); + if (annotationResult != null) { + annotationResult.forEach((key, value) -> { if (value != null) { Map reports = value.getReports(); List elements = value.getElements(); @@ -119,7 +147,7 @@ public void apply(@NotNull PsiFile file, Map annotationResul String name = getDependencyString(reportOptional.get().getRef().purl()); StringBuilder messageBuilder = new StringBuilder(name); - StringBuilder tooltipBuilder = new StringBuilder("").append("

").append(name).append("

"); + StringBuilder tooltipBuilder = new StringBuilder("").append("

").append(escapeHtml(name)).append("

"); Map quickfixes = new HashMap<>(); reports.forEach((source, report) -> { @@ -131,7 +159,7 @@ public void apply(@NotNull PsiFile file, Map annotationResul messageBuilder.append(source.getProvider()) .append(" vulnerability info: "); tooltipBuilder.append("

") - .append(source.getProvider()) + .append(escapeHtml(source.getProvider())) .append(" vulnerability info:

"); } else { messageBuilder.append(source.getSource()) @@ -139,9 +167,9 @@ public void apply(@NotNull PsiFile file, Map annotationResul .append(source.getProvider()) .append(") vulnerability info: "); tooltipBuilder.append("

") - .append(source.getSource()) + .append(escapeHtml(source.getSource())) .append(" (") - .append(source.getProvider()) + .append(escapeHtml(source.getProvider())) .append(") vulnerability info:

"); } @@ -157,7 +185,7 @@ public void apply(@NotNull PsiFile file, Map annotationResul messageBuilder.append(", Highest severity: ") .append(severity); tooltipBuilder.append("

Highest severity: ") - .append(severity) + .append(escapeHtml(severity)) .append("

"); } } @@ -167,6 +195,15 @@ public void apply(@NotNull PsiFile file, Map annotationResul } }); + // Merge license incompatibility info into the annotation + Dependency noVersionKey = new Dependency(key, false); + String licenseMsg = licenseMessages.get(noVersionKey); + if (licenseMsg != null) { + messageBuilder.append(System.lineSeparator()).append(licenseMsg); + tooltipBuilder.append("

").append(licenseTooltips.get(noVersionKey)); + annotatedLicenseDeps.add(noVersionKey); + } + elements.forEach(e -> { if (e != null) { if (!quickfixes.isEmpty() && this.isQuickFixApplicable(e)) { @@ -195,11 +232,225 @@ public void apply(@NotNull PsiFile file, Map annotationResul } } }); + } + + // License annotations for deps not already annotated above + if (licenseSummary != null) { + applyLicenseMismatchAnnotation(file, licenseSummary, holder); + applyIncompatibleDependencyAnnotations(annotationData.getAllDependencies(), licenseSummary, annotatedLicenseDeps, holder); + } + } + + private void applyLicenseMismatchAnnotation(@NotNull PsiFile file, LicenseSummary licenseSummary, @NotNull AnnotationHolder holder) { + ProjectLicenseSummary projectLicense = licenseSummary.projectLicense(); + if (projectLicense == null || !projectLicense.mismatch()) { + return; + } + + PsiElement licenseElement = getLicenseFieldPsiElement(file); + if (licenseElement == null) { + return; + } + + String manifestLicense = extractLicenseName(projectLicense.manifest()); + String fileLicense = extractLicenseName(projectLicense.file()); + String fileSpdxId = extractSpdxId(projectLicense.file()); + + String message = "License mismatch: manifest declares \"" + manifestLicense + + "\" but LICENSE file contains \"" + fileLicense + "\""; + String tooltip = "

License mismatch:

" + + "

Manifest declares: " + escapeHtml(manifestLicense) + "

" + + "

LICENSE file contains: " + escapeHtml(fileLicense) + "

"; + + AnnotationBuilder builder = holder + .newAnnotation(HighlightSeverity.ERROR, message) + .tooltip(tooltip) + .range(licenseElement); + + // Only offer quick-fix when a real SPDX ID is available + if (fileSpdxId != null) { + LicenseUpdateIntentionAction fix = createLicenseUpdateFix(licenseElement, fileSpdxId); + if (fix != null) { + builder.withFix(fix); + } + } + + builder.create(); + } + + private void buildIncompatibleLicenseInfo( + LicenseSummary licenseSummary, + Map messages, + Map tooltips) { + if (licenseSummary.incompatibleDependencies() == null || licenseSummary.incompatibleDependencies().isEmpty()) { + return; + } + + ProjectLicenseSummary projectLicense = licenseSummary.projectLicense(); + String projectLicenseName = projectLicense != null + ? extractLicenseName(projectLicense.manifest() != null ? projectLicense.manifest() : projectLicense.file()) + : "unknown"; + + for (IncompatibleDependency incompatible : licenseSummary.incompatibleDependencies()) { + try { + PackageURL purl = new PackageURL(incompatible.purl()); + Dependency dep = new Dependency(purl, false); + + String licenseNames = incompatible.licenses() != null + ? incompatible.licenses().stream() + .map(LicenseIdentifier::getName) + .collect(Collectors.joining(", ")) + : "unknown"; + + String reason = incompatible.reason() != null + ? incompatible.reason() + : "This dependency may require relicensing if distributed."; + + messages.put(dep, buildLicenseWarningMessage(licenseNames, projectLicenseName, reason)); + tooltips.put(dep, buildLicenseWarningTooltip(licenseNames, projectLicenseName, reason)); + + } catch (MalformedPackageURLException ex) { + LOG.warn("Failed to parse PURL from incompatible dependency: " + incompatible.purl(), ex); + } + } + } + + private void applyIncompatibleDependencyAnnotations( + Map> allDependencies, + LicenseSummary licenseSummary, + Set alreadyAnnotated, + @NotNull AnnotationHolder holder) { + if (licenseSummary.incompatibleDependencies() == null || licenseSummary.incompatibleDependencies().isEmpty()) { + return; + } + if (allDependencies == null || allDependencies.isEmpty()) { + return; + } + + ProjectLicenseSummary projectLicense = licenseSummary.projectLicense(); + String projectLicenseName = projectLicense != null + ? extractLicenseName(projectLicense.manifest() != null ? projectLicense.manifest() : projectLicense.file()) + : "unknown"; + + // Build a lookup map without version for matching + Map> noVersionMap = allDependencies.entrySet().stream() + .collect(Collectors.toMap( + e -> new Dependency(e.getKey(), false), + e -> new ArrayList<>(e.getValue()), + (l1, l2) -> { l1.addAll(l2); return l1; })); + + for (IncompatibleDependency incompatible : licenseSummary.incompatibleDependencies()) { + try { + PackageURL purl = new PackageURL(incompatible.purl()); + Dependency dep = new Dependency(purl, false); + + // Skip deps already annotated with merged license info + if (alreadyAnnotated.contains(dep)) { + continue; + } + + List elements = noVersionMap.get(dep); + if (elements == null || elements.isEmpty()) { + continue; + } + + String licenseNames = incompatible.licenses() != null + ? incompatible.licenses().stream() + .map(LicenseIdentifier::getName) + .collect(Collectors.joining(", ")) + : "unknown"; + + String reason = incompatible.reason() != null + ? incompatible.reason() + : "This dependency may require relicensing if distributed."; + String message = buildLicenseWarningMessage(licenseNames, projectLicenseName, reason); + String tooltip = buildLicenseWarningTooltip(licenseNames, projectLicenseName, reason); + + String wrappedTooltip = "" + tooltip + ""; + for (PsiElement element : elements) { + if (element != null) { + holder.newAnnotation(HighlightSeverity.WARNING, message) + .tooltip(wrappedTooltip) + .range(element) + .create(); + } + } + } catch (MalformedPackageURLException ex) { + LOG.warn("Failed to parse PURL from incompatible dependency: " + incompatible.purl(), ex); + } + } + } + + private static String extractLicenseName(@Nullable JsonNode licenseDetails) { + if (licenseDetails == null) { + return "unknown"; + } + // Try "expression" first (SPDX expressions like "MIT OR Apache-2.0") + JsonNode exprNode = licenseDetails.get("expression"); + if (exprNode != null && !exprNode.isNull() && !exprNode.asText().isBlank()) { + return exprNode.asText(); + } + // Try "name" field for human-readable display + JsonNode nameNode = licenseDetails.get("name"); + if (nameNode != null && !nameNode.isNull() && !nameNode.asText().isBlank()) { + return nameNode.asText(); + } + // Fall back to SPDX ID + String spdxId = extractSpdxId(licenseDetails); + return spdxId != null ? spdxId : "unknown"; + } + + private static @Nullable String extractSpdxId(@Nullable JsonNode licenseDetails) { + if (licenseDetails == null) { + return null; + } + // Try identifiers array for the SPDX ID (e.g., "Apache-2.0", "MIT") + JsonNode identifiers = licenseDetails.get("identifiers"); + if (identifiers != null && identifiers.isArray() && !identifiers.isEmpty()) { + JsonNode first = identifiers.get(0); + JsonNode idNode = first.get("id"); + if (idNode != null && !idNode.isNull() && !idNode.asText().isBlank()) { + return idNode.asText(); + } + } + // Fall back to SPDX expression if available + JsonNode exprNode = licenseDetails.get("expression"); + if (exprNode != null && !exprNode.isNull() && !exprNode.asText().isBlank()) { + return exprNode.asText(); + } + return null; + } + + private static String buildLicenseWarningMessage(String depLicense, String projectLicense, String reason) { + return "License compatibility warning: " + + "Dependency license: " + depLicense + + ", Project license: " + projectLicense + + ". " + reason; + } + + private static String buildLicenseWarningTooltip(String depLicense, String projectLicense, String reason) { + return "

License compatibility warning:

" + + "

Dependency license: " + escapeHtml(depLicense) + "

" + + "

Project license: " + escapeHtml(projectLicense) + "

" + + "

" + escapeHtml(reason) + "

"; + } + + private static String escapeHtml(String text) { + if (text == null) return ""; + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); } @NotNull private HighlightSeverity getHighlightSeverity(DependencyReport report, @NotNull PsiElement context) { - // Get the configured severity from the inspection settings + // Recommendation-only (no vulnerabilities) should always use WEAK_WARNING + if (CAIntentionAction.thereAreNoIssues(report) && CAIntentionAction.thereIsRecommendation(report)) { + return HighlightSeverity.WEAK_WARNING; + } + + // For actual vulnerabilities, use the configured severity from the inspection settings final InspectionProfileEntry inspection = this.getInspection(context, this.getInspectionShortName()); if (inspection != null) { final InspectionProfile profile = InspectionProjectProfileManager.getInstance(context.getProject()).getCurrentProfile(); @@ -210,12 +461,7 @@ private HighlightSeverity getHighlightSeverity(DependencyReport report, @NotNull } } - // Fallback to original logic if inspection settings can't be determined - if(CAIntentionAction.thereAreNoIssues(report) && CAIntentionAction.thereIsRecommendation(report)) { - return HighlightSeverity.WEAK_WARNING; - } else { - return HighlightSeverity.ERROR; - } + return HighlightSeverity.ERROR; } abstract protected String getInspectionShortName(); @@ -226,6 +472,10 @@ private HighlightSeverity getHighlightSeverity(DependencyReport report, @NotNull abstract protected CAUpdateManifestIntentionAction patchManifest(PsiElement element, DependencyReport report); abstract protected boolean isQuickFixApplicable(PsiElement element); + abstract protected @Nullable PsiElement getLicenseFieldPsiElement(PsiFile file); + + abstract protected @Nullable LicenseUpdateIntentionAction createLicenseUpdateFix(PsiElement element, String newLicense); + private Map matchDependencies(Map> dependencies, Map> reports) { if (dependencies != null && !dependencies.isEmpty() @@ -316,4 +566,22 @@ public Map getReports() { return reports; } } + + public static class AnnotationData { + private final Map results; + private final Map> allDependencies; + + public AnnotationData(Map results, Map> allDependencies) { + this.results = results; + this.allDependencies = allDependencies; + } + + public Map getResults() { + return results; + } + + public Map> getAllDependencies() { + return allDependencies; + } + } } diff --git a/src/main/java/org/jboss/tools/intellij/componentanalysis/CAService.java b/src/main/java/org/jboss/tools/intellij/componentanalysis/CAService.java index 8bdc9673..b786af68 100644 --- a/src/main/java/org/jboss/tools/intellij/componentanalysis/CAService.java +++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/CAService.java @@ -23,10 +23,12 @@ import com.intellij.openapi.project.Project; import com.intellij.psi.PsiDocumentManager; import com.intellij.psi.PsiFile; +import io.github.guacsec.trustifyda.ComponentAnalysisResult; import io.github.guacsec.trustifyda.api.v5.AnalysisReport; import io.github.guacsec.trustifyda.api.v5.DependencyReport; import io.github.guacsec.trustifyda.api.v5.ProviderReport; import io.github.guacsec.trustifyda.api.v5.Source; +import io.github.guacsec.trustifyda.license.LicenseCheck.LicenseSummary; import org.jboss.tools.intellij.exhort.ApiService; import java.util.Collections; @@ -56,12 +58,28 @@ public static CAService getInstance() { .maximumSize(100) .build(); + private final Cache licenseCache = Caffeine.newBuilder() + .maximumSize(100) + .build(); + public static Map> getReports(String filePath) { return Collections.unmodifiableMap(getInstance().vulnerabilityCache.get(filePath, p -> Collections.emptyMap())); } public static void deleteReports(String filePath) { getInstance().vulnerabilityCache.invalidate(filePath); + getInstance().licenseCache.invalidate(filePath); + getInstance().dependencyCache.invalidate(filePath); + } + + public static void invalidateAllCaches() { + getInstance().vulnerabilityCache.invalidateAll(); + getInstance().licenseCache.invalidateAll(); + getInstance().dependencyCache.invalidateAll(); + } + + public static LicenseSummary getLicenseSummary(String filePath) { + return getInstance().licenseCache.getIfPresent(filePath); } public static boolean dependenciesModified(String filePath, Set dependencies) { @@ -90,10 +108,22 @@ public static boolean performAnalysis(String packageManager, ); } - AnalysisReport report = apiService.getComponentAnalysis(packageManager, fileName, filePath); - if (report == null) { + ComponentAnalysisResult analysisResult = apiService.getComponentAnalysis(packageManager, fileName, filePath); + if (analysisResult == null) { throw new RuntimeException("Failed to perform component analysis, result is invalid."); } + AnalysisReport report = analysisResult.report(); + if (report == null) { + throw new RuntimeException("Failed to perform component analysis, report is invalid."); + } + + // Cache license summary + LicenseSummary licenseSummary = analysisResult.licenseSummary(); + if (licenseSummary != null) { + getInstance().licenseCache.put(filePath, licenseSummary); + } else { + getInstance().licenseCache.invalidate(filePath); + } Map> resultMap = new ConcurrentHashMap<>(); diff --git a/src/main/java/org/jboss/tools/intellij/componentanalysis/LicenseUpdateIntentionAction.java b/src/main/java/org/jboss/tools/intellij/componentanalysis/LicenseUpdateIntentionAction.java new file mode 100644 index 00000000..6a700884 --- /dev/null +++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/LicenseUpdateIntentionAction.java @@ -0,0 +1,76 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ + +package org.jboss.tools.intellij.componentanalysis; + +import com.intellij.codeInsight.intention.FileModifier; +import com.intellij.codeInsight.intention.IntentionAction; +import com.intellij.codeInspection.util.IntentionFamilyName; +import com.intellij.codeInspection.util.IntentionName; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.psi.util.PsiTreeUtil; +import com.intellij.util.IncorrectOperationException; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.function.BiConsumer; + +public class LicenseUpdateIntentionAction implements IntentionAction { + + private final @FileModifier.SafeFieldForPreview PsiElement element; + private final String newLicense; + private final BiConsumer replacer; + + public LicenseUpdateIntentionAction(PsiElement element, String newLicense, BiConsumer replacer) { + this.element = element; + this.newLicense = newLicense; + this.replacer = replacer; + } + + @Override + public @IntentionName @NotNull String getText() { + return "Update manifest license to \"" + newLicense + "\" (from LICENSE file)"; + } + + @Override + public @NotNull @IntentionFamilyName String getFamilyName() { + return "RHDA"; + } + + @Override + public boolean isAvailable(@NotNull Project project, Editor editor, PsiFile file) { + return element != null && element.isValid(); + } + + @Override + public void invoke(@NotNull Project project, Editor editor, PsiFile file) throws IncorrectOperationException { + replacer.accept(element, newLicense); + + // Invalidate caches so the next annotator pass triggers a fresh API call + if (file != null && file.getVirtualFile() != null) { + CAService.deleteReports(file.getVirtualFile().getPath()); + } + } + + @Override + public boolean startInWriteAction() { + return true; + } + + @Override + public @Nullable FileModifier getFileModifierForPreview(@NotNull PsiFile target) { + PsiElement copy = PsiTreeUtil.findSameElementInCopy(this.element, target); + return new LicenseUpdateIntentionAction(copy, this.newLicense, this.replacer); + } +} diff --git a/src/main/java/org/jboss/tools/intellij/componentanalysis/cargo/CargoCAAnnotator.java b/src/main/java/org/jboss/tools/intellij/componentanalysis/cargo/CargoCAAnnotator.java index 2cf3baa5..fe88c249 100644 --- a/src/main/java/org/jboss/tools/intellij/componentanalysis/cargo/CargoCAAnnotator.java +++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/cargo/CargoCAAnnotator.java @@ -18,7 +18,9 @@ import org.jboss.tools.intellij.componentanalysis.CAIntentionAction; import org.jboss.tools.intellij.componentanalysis.CAUpdateManifestIntentionAction; import org.jboss.tools.intellij.componentanalysis.Dependency; +import org.jboss.tools.intellij.componentanalysis.LicenseUpdateIntentionAction; import org.jboss.tools.intellij.componentanalysis.VulnerabilitySource; +import org.jetbrains.annotations.Nullable; import com.intellij.psi.PsiComment; import com.intellij.psi.PsiDocumentManager; import com.intellij.psi.util.PsiTreeUtil; @@ -320,6 +322,48 @@ private String extractVersionFromTomlValue(TomlValue tomlValue) { return UNRESOLVED_VERSION; } + @Override + protected @Nullable PsiElement getLicenseFieldPsiElement(PsiFile file) { + // Find [package] table, then "license" key-value + List tables = PsiTreeUtil.getChildrenOfTypeAsList(file, TomlTable.class); + for (TomlTable table : tables) { + TomlTableHeader header = table.getHeader(); + TomlKey key = header.getKey(); + if (key == null) continue; + List segments = key.getSegments(); + if (segments.size() == 1 && "package".equals(segments.get(0).getName())) { + List keyValues = PsiTreeUtil.getChildrenOfTypeAsList(table, TomlKeyValue.class); + for (TomlKeyValue kv : keyValues) { + if ("license".equals(normalizeKeyName(kv.getKey().getText()))) { + TomlValue value = kv.getValue(); + if (value instanceof TomlLiteral) { + return value; + } + } + } + } + } + return null; + } + + @Override + protected @Nullable LicenseUpdateIntentionAction createLicenseUpdateFix(PsiElement element, String newLicense) { + if (!(element instanceof TomlLiteral)) { + return null; + } + return new LicenseUpdateIntentionAction(element, newLicense, (el, license) -> { + // Replace the TOML literal text (including quotes), matching CargoCAIntentionAction pattern + Document doc = PsiDocumentManager.getInstance(el.getProject()) + .getDocument(el.getContainingFile()); + if (doc != null) { + int start = el.getTextRange().getStartOffset(); + int end = el.getTextRange().getEndOffset(); + doc.replaceString(start, end, "\"" + license + "\""); + PsiDocumentManager.getInstance(el.getProject()).commitDocument(doc); + } + }); + } + private void parseFlatDependencies(TomlTable table, Set ignoredDeps, Map> resultMap) { // Parse inline format: [dependencies] with key-value pairs // serde = "1.0" diff --git a/src/main/java/org/jboss/tools/intellij/componentanalysis/golang/GoCAAnnotator.java b/src/main/java/org/jboss/tools/intellij/componentanalysis/golang/GoCAAnnotator.java index 42ede239..1ed63eee 100644 --- a/src/main/java/org/jboss/tools/intellij/componentanalysis/golang/GoCAAnnotator.java +++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/golang/GoCAAnnotator.java @@ -18,7 +18,9 @@ import org.jboss.tools.intellij.componentanalysis.CAIntentionAction; import org.jboss.tools.intellij.componentanalysis.CAUpdateManifestIntentionAction; import org.jboss.tools.intellij.componentanalysis.Dependency; +import org.jboss.tools.intellij.componentanalysis.LicenseUpdateIntentionAction; import org.jboss.tools.intellij.componentanalysis.VulnerabilitySource; +import org.jetbrains.annotations.Nullable; import java.util.Collections; import java.util.HashMap; @@ -142,6 +144,17 @@ protected boolean isQuickFixApplicable(PsiElement element) { return element != null && element.getContainingFile().getName().equals("go.mod"); } + // go.mod has no license field; license detection relies on the LICENSE file + @Override + protected @Nullable PsiElement getLicenseFieldPsiElement(PsiFile file) { + return null; + } + + @Override + protected @Nullable LicenseUpdateIntentionAction createLicenseUpdateFix(PsiElement element, String newLicense) { + return null; + } + private PsiElement findElementAtLine(PsiFile file, int lineNumber) { String[] lines = file.getText().split("\\n"); if (lineNumber >= lines.length) return null; diff --git a/src/main/java/org/jboss/tools/intellij/componentanalysis/gradle/GradleCAAnnotator.java b/src/main/java/org/jboss/tools/intellij/componentanalysis/gradle/GradleCAAnnotator.java index 1bc99876..aaf788e4 100644 --- a/src/main/java/org/jboss/tools/intellij/componentanalysis/gradle/GradleCAAnnotator.java +++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/gradle/GradleCAAnnotator.java @@ -18,7 +18,9 @@ import org.jboss.tools.intellij.componentanalysis.CAIntentionAction; import org.jboss.tools.intellij.componentanalysis.CAUpdateManifestIntentionAction; import org.jboss.tools.intellij.componentanalysis.Dependency; +import org.jboss.tools.intellij.componentanalysis.LicenseUpdateIntentionAction; import org.jboss.tools.intellij.componentanalysis.VulnerabilitySource; +import org.jetbrains.annotations.Nullable; import org.jboss.tools.intellij.componentanalysis.gradle.build.psi.Artifact; import java.util.Arrays; @@ -75,4 +77,15 @@ protected CAUpdateManifestIntentionAction patchManifest(PsiElement element, Depe protected boolean isQuickFixApplicable(PsiElement element) { return true; } + + // Gradle has no standardized license field location; license detection relies on the LICENSE file + @Override + protected @Nullable PsiElement getLicenseFieldPsiElement(PsiFile file) { + return null; + } + + @Override + protected @Nullable LicenseUpdateIntentionAction createLicenseUpdateFix(PsiElement element, String newLicense) { + return null; + } } diff --git a/src/main/java/org/jboss/tools/intellij/componentanalysis/maven/MavenCAAnnotator.java b/src/main/java/org/jboss/tools/intellij/componentanalysis/maven/MavenCAAnnotator.java index 8631b939..e67acb73 100644 --- a/src/main/java/org/jboss/tools/intellij/componentanalysis/maven/MavenCAAnnotator.java +++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/maven/MavenCAAnnotator.java @@ -22,7 +22,9 @@ import org.jboss.tools.intellij.componentanalysis.CAIntentionAction; import org.jboss.tools.intellij.componentanalysis.CAUpdateManifestIntentionAction; import org.jboss.tools.intellij.componentanalysis.Dependency; +import org.jboss.tools.intellij.componentanalysis.LicenseUpdateIntentionAction; import org.jboss.tools.intellij.componentanalysis.VulnerabilitySource; +import org.jetbrains.annotations.Nullable; import java.util.Arrays; @@ -146,4 +148,32 @@ protected boolean isQuickFixApplicable(PsiElement element) { } return false; } + + @Override + protected @Nullable PsiElement getLicenseFieldPsiElement(PsiFile file) { + // Find text element + return Arrays.stream(file.getChildren()) + .filter(e -> e instanceof XmlDocument) + .flatMap(e -> Arrays.stream(e.getChildren())) + .filter(e -> e instanceof XmlTag && "project".equals(((XmlTag) e).getName())) + .flatMap(e -> Arrays.stream(e.getChildren())) + .filter(e -> e instanceof XmlTag && "licenses".equals(((XmlTag) e).getName())) + .flatMap(e -> Arrays.stream(e.getChildren())) + .filter(e -> e instanceof XmlTag && "license".equals(((XmlTag) e).getName())) + .flatMap(e -> Arrays.stream(e.getChildren())) + .filter(e -> e instanceof XmlTag && "name".equals(((XmlTag) e).getName())) + .flatMap(e -> Arrays.stream(((XmlTag) e).getValue().getChildren())) + .filter(e -> e instanceof XmlText) + .findFirst() + .orElse(null); + } + + @Override + protected @Nullable LicenseUpdateIntentionAction createLicenseUpdateFix(PsiElement element, String newLicense) { + if (!(element instanceof XmlText)) { + return null; + } + return new LicenseUpdateIntentionAction(element, newLicense, (el, license) -> + ((XmlText) el).setValue(license)); + } } diff --git a/src/main/java/org/jboss/tools/intellij/componentanalysis/npm/NpmCAAnnotator.java b/src/main/java/org/jboss/tools/intellij/componentanalysis/npm/NpmCAAnnotator.java index a9363f6f..a0a34310 100644 --- a/src/main/java/org/jboss/tools/intellij/componentanalysis/npm/NpmCAAnnotator.java +++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/npm/NpmCAAnnotator.java @@ -11,6 +11,8 @@ package org.jboss.tools.intellij.componentanalysis.npm; +import com.intellij.json.psi.JsonElementGenerator; +import com.intellij.json.psi.JsonObject; import com.intellij.json.psi.JsonProperty; import com.intellij.json.psi.JsonStringLiteral; import com.intellij.psi.PsiElement; @@ -20,8 +22,11 @@ import org.jboss.tools.intellij.componentanalysis.CAIntentionAction; import org.jboss.tools.intellij.componentanalysis.CAUpdateManifestIntentionAction; import org.jboss.tools.intellij.componentanalysis.Dependency; +import org.jboss.tools.intellij.componentanalysis.LicenseUpdateIntentionAction; import org.jboss.tools.intellij.componentanalysis.VulnerabilitySource; +import org.jetbrains.annotations.Nullable; +import java.util.Arrays; import java.util.List; import java.util.Map; @@ -53,4 +58,27 @@ protected CAUpdateManifestIntentionAction patchManifest(PsiElement element, Depe protected boolean isQuickFixApplicable(PsiElement element) { return element instanceof JsonProperty && ((JsonProperty) element).getValue() instanceof JsonStringLiteral; } + + @Override + protected @Nullable PsiElement getLicenseFieldPsiElement(PsiFile file) { + return Arrays.stream(file.getChildren()) + .filter(e -> e instanceof JsonObject) + .flatMap(e -> Arrays.stream(e.getChildren())) + .filter(e -> e instanceof JsonProperty && "license".equals(((JsonProperty) e).getName())) + .map(e -> ((JsonProperty) e).getValue()) + .filter(v -> v instanceof JsonStringLiteral) + .findFirst() + .orElse(null); + } + + @Override + protected @Nullable LicenseUpdateIntentionAction createLicenseUpdateFix(PsiElement element, String newLicense) { + if (!(element instanceof JsonStringLiteral)) { + return null; + } + return new LicenseUpdateIntentionAction(element, newLicense, (el, license) -> { + JsonStringLiteral newValue = new JsonElementGenerator(el.getProject()).createStringLiteral(license); + el.replace(newValue); + }); + } } diff --git a/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PipCAAnnotator.java b/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PipCAAnnotator.java index e87f1f3e..1d891b0c 100644 --- a/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PipCAAnnotator.java +++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PipCAAnnotator.java @@ -18,7 +18,9 @@ import org.jboss.tools.intellij.componentanalysis.CAIntentionAction; import org.jboss.tools.intellij.componentanalysis.CAUpdateManifestIntentionAction; import org.jboss.tools.intellij.componentanalysis.Dependency; +import org.jboss.tools.intellij.componentanalysis.LicenseUpdateIntentionAction; import org.jboss.tools.intellij.componentanalysis.VulnerabilitySource; +import org.jetbrains.annotations.Nullable; import org.jboss.tools.intellij.componentanalysis.pypi.requirements.psi.NameReq; import org.jboss.tools.intellij.componentanalysis.pypi.requirements.psi.NameReqComment; @@ -82,4 +84,15 @@ protected CAUpdateManifestIntentionAction patchManifest(PsiElement element, Depe protected boolean isQuickFixApplicable(PsiElement element) { return element instanceof NameReq && ((NameReq) element).getVersionspec() != null; } + + // requirements.txt has no license field; license detection relies on the LICENSE file + @Override + protected @Nullable PsiElement getLicenseFieldPsiElement(PsiFile file) { + return null; + } + + @Override + protected @Nullable LicenseUpdateIntentionAction createLicenseUpdateFix(PsiElement element, String newLicense) { + return null; + } } diff --git a/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java b/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java index e449c386..b4cf5fd7 100644 --- a/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java +++ b/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java @@ -20,7 +20,7 @@ import com.intellij.util.net.ProxyConfiguration; import com.intellij.util.net.ProxySettings; import io.github.guacsec.trustifyda.Api; -import io.github.guacsec.trustifyda.api.v5.AnalysisReport; +import io.github.guacsec.trustifyda.ComponentAnalysisResult; import io.github.guacsec.trustifyda.impl.ExhortApi; import org.jboss.tools.intellij.settings.ApiSettingsState; import org.jboss.tools.intellij.settings.MavenSettingsUtil; @@ -28,7 +28,6 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; @@ -88,7 +87,7 @@ public Path getStackAnalysis(final String packageManager, final String manifestN } } - public AnalysisReport getComponentAnalysis(final String packageManager, final String manifestName, final String manifestPath) { + public ComponentAnalysisResult getComponentAnalysis(final String packageManager, final String manifestName, final String manifestPath) { var telemetryMsg = TelemetryService.instance().action("component-analysis"); telemetryMsg.property(TelemetryKeys.ECOSYSTEM.toString(), packageManager); telemetryMsg.property(TelemetryKeys.PLATFORM.toString(), System.getProperty("os.name")); @@ -97,16 +96,11 @@ public AnalysisReport getComponentAnalysis(final String packageManager, final St try { setRequestProperties(manifestName); - CompletableFuture componentReport; - if ("go.mod".equals(manifestName) || "requirements.txt".equals(manifestName)) { - var manifestContent = Files.readAllBytes(Paths.get(manifestPath)); - componentReport = exhortApi.componentAnalysis(manifestPath, manifestContent); - } else { - componentReport = exhortApi.componentAnalysis(manifestPath); - } - AnalysisReport report = componentReport.get(); + CompletableFuture componentReport = + exhortApi.componentAnalysisWithLicense(manifestPath); + ComponentAnalysisResult result = componentReport.get(); telemetryMsg.send(); - return report; + return result; } catch (IOException | InterruptedException | ExecutionException ex) { telemetryMsg.error(ex); telemetryMsg.send(); @@ -266,6 +260,12 @@ private void setRequestProperties(final String manifestName) { if (!"go.mod".equals(manifestName) && !"requirements.txt".equals(manifestName)) { System.clearProperty("MATCH_MANIFEST_VERSIONS"); } + if (settings.licenseCheckEnabled) { + System.setProperty("TRUSTIFY_DA_LICENSE_CHECK", "true"); + } else { + System.setProperty("TRUSTIFY_DA_LICENSE_CHECK", "false"); + } + Optional proxyUrlOpt = getProxyUrl(); if (proxyUrlOpt.isPresent()) { System.setProperty("TRUSTIFY_DA_PROXY_URL", proxyUrlOpt.get()); @@ -286,9 +286,7 @@ public static Optional getProxyUrl() { // This API only works in 2024.2+ versions. ProxyConfiguration proxyConfiguration = ProxySettings.getInstance().getProxyConfiguration(); - if (proxyConfiguration instanceof ProxyConfiguration.StaticProxyConfiguration) { - ProxyConfiguration.StaticProxyConfiguration staticProxyConfiguration = - (ProxyConfiguration.StaticProxyConfiguration) proxyConfiguration; + if (proxyConfiguration instanceof ProxyConfiguration.StaticProxyConfiguration staticProxyConfiguration) { String protocol = staticProxyConfiguration.getProtocol().toString().toLowerCase(); // e.g., "http" or "socks" String host = staticProxyConfiguration.getHost(); diff --git a/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsComponent.java b/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsComponent.java index 8ab64e4c..04060642 100644 --- a/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsComponent.java +++ b/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsComponent.java @@ -84,6 +84,8 @@ public class ApiSettingsComponent { private final static String reportFilePathLabel = "Reports > Save Directory: Path" + "
Specifies directory where stack analytics reports will be saved permanently." + "
Leave empty to use temporary files only."; + private final static String licenseCheckEnabledLabel = "Component Analysis > License Check" + + "
Enables license compatibility checking and notifications for incompatible dependencies."; private final JPanel mainPanel; @@ -116,6 +118,7 @@ public class ApiSettingsComponent { private final JBTextArea manifestExclusionPatternsText; private final JBScrollPane manifestExclusionPatternsScrollPane; private final TextFieldWithBrowseButton reportFilePathText; + private final JBCheckBox licenseCheckEnabledCheck; public ApiSettingsComponent() { @@ -252,6 +255,8 @@ public ApiSettingsComponent() { imagePlatformText = new JBTextField(); + licenseCheckEnabledCheck = new JBCheckBox("Enable license compatibility checking"); + manifestExclusionPatternsText = new JBTextArea(); manifestExclusionPatternsText.setRows(5); manifestExclusionPatternsText.setColumns(50); @@ -319,6 +324,9 @@ public ApiSettingsComponent() { .addLabeledComponent(new JBLabel(imagePlatformLabel), imagePlatformText, 1, true) .addSeparator(10) .addVerticalGap(10) + .addLabeledComponent(new JBLabel(licenseCheckEnabledLabel), licenseCheckEnabledCheck, 1, true) + .addSeparator(10) + .addVerticalGap(10) .addLabeledComponent(new JBLabel(manifestExclusionPatternsLabel), manifestExclusionPatternsScrollPane, 1, true) .addVerticalGap(10) .addLabeledComponent(new JBLabel(reportFilePathLabel), reportFilePathText, 1, true) @@ -561,4 +569,12 @@ public String getReportFilePathText() { public void setReportFilePathText(@NotNull String text) { reportFilePathText.setText(text); } + + public boolean getLicenseCheckEnabledCheck() { + return licenseCheckEnabledCheck.isSelected(); + } + + public void setLicenseCheckEnabledCheck(boolean selected) { + licenseCheckEnabledCheck.setSelected(selected); + } } diff --git a/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsConfigurable.java b/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsConfigurable.java index adeb5a73..dce33ff0 100644 --- a/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsConfigurable.java +++ b/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsConfigurable.java @@ -17,6 +17,7 @@ import com.intellij.openapi.project.ProjectManager; import com.intellij.openapi.util.NlsContexts; import com.intellij.serviceContainer.AlreadyDisposedException; +import org.jboss.tools.intellij.componentanalysis.CAService; import org.jetbrains.annotations.Nullable; import javax.swing.JComponent; @@ -71,6 +72,7 @@ public boolean isModified() { modified |= !Objects.equals(settingsComponent.getCargoPathText(), settings.cargoPath); modified |= !settingsComponent.getManifestExclusionPatternsText().equals(settings.manifestExclusionPatterns); modified |= !settingsComponent.getReportFilePathText().equals(settings.reportFilePath); + modified |= settingsComponent.getLicenseCheckEnabledCheck() != settings.licenseCheckEnabled; return modified; } @@ -104,14 +106,21 @@ public void apply() { settings.reportFilePath = settingsComponent.getReportFilePathText(); settings.cargoPath = settingsComponent.getCargoPathText(); + // Check if license check setting changed + boolean licenseCheckChanged = settingsComponent.getLicenseCheckEnabledCheck() != settings.licenseCheckEnabled; + settings.licenseCheckEnabled = settingsComponent.getLicenseCheckEnabledCheck(); + // Check if exclusion patterns changed String oldPatterns = settings.manifestExclusionPatterns; String newPatterns = settingsComponent.getManifestExclusionPatternsText(); boolean patternsChanged = !Objects.equals(oldPatterns, newPatterns); settings.manifestExclusionPatterns = newPatterns; - // Trigger re-analysis if exclusion patterns changed - if (patternsChanged) { + // Trigger re-analysis if exclusion patterns or license check changed + if (patternsChanged || licenseCheckChanged) { + if (licenseCheckChanged) { + CAService.invalidateAllCaches(); + } refreshComponentAnalysis(); } } @@ -160,6 +169,7 @@ public void reset() { settingsComponent.setCargoPathText(settings.cargoPath != null ? settings.cargoPath : ""); settingsComponent.setManifestExclusionPatternsText(settings.manifestExclusionPatterns != null ? settings.manifestExclusionPatterns : ""); settingsComponent.setReportFilePathText(settings.reportFilePath != null ? settings.reportFilePath : ""); + settingsComponent.setLicenseCheckEnabledCheck(settings.licenseCheckEnabled); } @Override diff --git a/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsState.java b/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsState.java index 4c45764b..2a2dafe6 100644 --- a/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsState.java +++ b/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsState.java @@ -69,6 +69,8 @@ public final class ApiSettingsState implements PersistentStateComponent