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
158 changes: 89 additions & 69 deletions server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
/********************************************************************************
* Copyright (c) 2026 Contributors to the Eclipse Foundation
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* See the NOTICE file(s) distributed with this work for additional
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* https://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
package org.eclipse.openvsx.admin;

Expand All @@ -20,7 +20,6 @@
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import org.eclipse.openvsx.entities.*;
import org.eclipse.openvsx.json.*;
Expand All @@ -40,6 +39,7 @@
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;

import org.springframework.data.domain.PageRequest;
Expand Down Expand Up @@ -356,10 +356,10 @@ public ResponseEntity<ScanResultListJson> getAllScans(
var sort = createSort(sortBy, ascending);
var pageNumber = offset / Math.max(size, 1);
var pageable = PageRequest.of(pageNumber, size, sort);

// Automatically include scans with errored check results when filtering by ERRORED status
var includeCheckErrors = statusFilter.contains(ScanStatus.ERRORED);

var page = repositories.findScansFullyFiltered(
statusFilter.isEmpty() ? null : statusFilter,
normalizedNamespace.isEmpty() ? null : normalizedNamespace,
Expand Down Expand Up @@ -434,25 +434,25 @@ public boolean hasFilter() {
/**
* Parse admin decision filter into structured values for DB query.
* Returns null if no filter is specified (show all).
*
*
* API values: allowed, blocked, needs-review
* DB values: ALLOWED, BLOCKED, (no record = needs-review)
*/
private AdminDecisionFilterValues parseAdminDecisionFilter(List<String> adminDecision) {
if (adminDecision == null || adminDecision.isEmpty()) {
return null;
}

var values = adminDecision.stream()
.filter(v -> v != null && !v.isBlank())
.map(String::trim)
.map(String::toLowerCase)
.collect(Collectors.toSet());

if (values.isEmpty()) {
return null;
}

return new AdminDecisionFilterValues(
values.contains("allowed"),
values.contains("blocked"),
Expand Down Expand Up @@ -485,14 +485,14 @@ private String toDbSortField(String sortBy) {
private Sort createSort(String sortBy, boolean ascending) {
var direction = ascending ? Sort.Direction.ASC : Sort.Direction.DESC;
var normalizedSortBy = sortBy == null ? "scanendtime" : sortBy.toLowerCase(Locale.ROOT);

if ("scanendtime".equals(normalizedSortBy)) {
var completedAtOrder = new Sort.Order(direction, "completed_at")
.with(Sort.NullHandling.NULLS_FIRST);
var startedAtOrder = new Sort.Order(direction, "started_at");
return Sort.by(completedAtOrder, startedAtOrder);
}

var dbSortField = toDbSortField(sortBy);
return Sort.by(direction, dbSortField);
}
Expand Down Expand Up @@ -624,12 +624,12 @@ public ResponseEntity<ScanResultJson> retryFailedScannerJobs(
* Make security decisions for one or more quarantined scans.
* Only valid for scans with QUARANTINED status.
* Pass a single scanId for individual decisions, or multiple scanIds for bulk operations.
*
*
* When a scan is allowed:
* - The extension is automatically activated
* - The scan status is updated to PASSED
* - File decisions are created to add enforced threat files to allow list
*
*
* When a scan is blocked:
* - The extension remains inactive
* - File decisions are created to add enforced threat files to block list
Expand Down Expand Up @@ -666,64 +666,64 @@ public ResponseEntity<ScanDecisionResponseJson> makeScanDecisions(
if (request.getDecision() == null || request.getDecision().isBlank()) {
throw new ErrorResultException("Decision is required", HttpStatus.BAD_REQUEST);
}

var decisionValue = parseDecision(request.getDecision());

var results = new java.util.ArrayList<ScanDecisionResultJson>();
int successful = 0;
int failed = 0;

for (var scanIdStr : request.getScanIds()) {
try {
var scanId = Long.parseLong(scanIdStr);
var scan = repositories.findExtensionScan(scanId);

if (scan == null) {
results.add(ScanDecisionResultJson.failure(scanIdStr, "Scan not found"));
failed++;
continue;
}

if (scan.getStatus() != ScanStatus.QUARANTINED) {
results.add(ScanDecisionResultJson.failure(scanIdStr,
results.add(ScanDecisionResultJson.failure(scanIdStr,
"Scan not in quarantined status: " + formatScanStatus(scan.getStatus())));
failed++;
continue;
}

var existingDecision = repositories.findAdminScanDecisionByScanId(scanId);
if (existingDecision != null) {
results.add(ScanDecisionResultJson.failure(scanIdStr,
results.add(ScanDecisionResultJson.failure(scanIdStr,
"Decision already exists: " + existingDecision.getDecision()));
failed++;
continue;
}

AdminScanDecision decision;
if (AdminScanDecision.ALLOWED.equals(decisionValue)) {
decision = AdminScanDecision.allowed(scan, adminUser);
} else {
decision = AdminScanDecision.blocked(scan, adminUser);
}
repositories.saveAdminScanDecision(decision);

var threats = repositories.findExtensionThreats(scan).toList();
for (var threat : threats) {
if (threat.isEnforced() && threat.getFileHash() != null) {
createOrUpdateFileDecision(threat, scan, decisionValue, adminUser);
}
}

// If allowed, activate the extension
boolean activated = false;
if (AdminScanDecision.ALLOWED.equals(decisionValue)) {
activated = completionService.adminAllowScan(scan);
}

// Log the admin decision to /admin/log
var logMessage = formatDecisionLogMessage(scan, decisionValue, threats.size(), activated);
logs.logAction(adminUser, ResultJson.success(logMessage));

results.add(ScanDecisionResultJson.success(scanIdStr));
successful++;
} catch (NumberFormatException e) {
Expand All @@ -734,13 +734,13 @@ public ResponseEntity<ScanDecisionResponseJson> makeScanDecisions(
failed++;
}
}

var response = new ScanDecisionResponseJson();
response.setProcessed(request.getScanIds().size());
response.setSuccessful(successful);
response.setFailed(failed);
response.setResults(results);

return ResponseEntity.ok(response);
} catch (ErrorResultException exc) {
return exc.toResponseEntity(ScanDecisionResponseJson.class);
Expand All @@ -764,22 +764,22 @@ private String parseDecision(String decision) {
*/
private String formatDecisionLogMessage(ExtensionScan scan, String decisionValue, int threatCount, boolean activated) {
var action = AdminScanDecision.ALLOWED.equals(decisionValue) ? "Allowed" : "Blocked";
var extensionId = String.format("%s.%s v%s",
scan.getNamespaceName(),
scan.getExtensionName(),
var extensionId = String.format("%s.%s v%s",
scan.getNamespaceName(),
scan.getExtensionName(),
scan.getExtensionVersion());

var details = new java.util.ArrayList<String>();
if (threatCount > 0) {
details.add(String.format("%d threat%s reviewed", threatCount, threatCount == 1 ? "" : "s"));
}
if (AdminScanDecision.ALLOWED.equals(decisionValue)) {
details.add(activated ? "extension activated" : "activation failed");
}

var detailsStr = details.isEmpty() ? "" : " (" + String.join(", ", details) + ")";
return String.format("%s scan #%d for extension %s%s",

return String.format("%s scan #%d for extension %s%s",
action, scan.getId(), extensionId, detailsStr);
}

Expand All @@ -794,12 +794,12 @@ private void createOrUpdateFileDecision(
UserData adminUser
) {
var fileHash = threat.getFileHash();

// Map scan decision to file decision
var fileDecisionValue = AdminScanDecision.ALLOWED.equals(decisionValue)
? FileDecision.ALLOWED
: FileDecision.BLOCKED;

var existingDecision = repositories.findFileDecisionByHash(fileHash);
if (existingDecision != null) {
existingDecision.setDecision(fileDecisionValue);
Expand All @@ -808,7 +808,7 @@ private void createOrUpdateFileDecision(
repositories.saveFileDecision(existingDecision);
return;
}

// Create new file decision with context from threat and scan
var fileDecision = new FileDecision();
fileDecision.setFileHash(fileHash);
Expand Down Expand Up @@ -898,22 +898,68 @@ private ScanResultJson toScanResultJson(ExtensionScan scan) {
json.setAdminDecision(toAdminDecisionJson(adminDecision));
}

var scannerJobs = scanJobRepository.findByScanId(String.valueOf(scan.getId()));
if (!scannerJobs.isEmpty()) {
// include all non-terminal scanner jobs
var scannerJobJsons = scannerJobs.stream()
.filter(job -> !job.getStatus().isTerminal())
.map(this::toScannerJobJson)
.collect(Collectors.toList());
json.setScannerJobs(scannerJobJsons);
}

// Include all check results for audit trail
var checkResults = repositories.findScanCheckResultsByScanId(scan.getId());
if (!checkResults.isEmpty()) {
var scannerJobsById = scannerJobs.stream().collect(Collectors.toMap(ScannerJob::getId, Function.identity()));

var checkResultJsons = checkResults.stream()
.map(this::toCheckResultJson)
.map(r -> toCheckResultJson(r, scannerJobsById.get(r.getScannerJobId())))
.collect(Collectors.toList());
json.setCheckResults(checkResultJsons);
}

return json;
}

/**
* Converts a ScannerJob entity to a ScannerJobJson DTO.
*/
private ScannerJobJson toScannerJobJson(ScannerJob job) {
var json = new ScannerJobJson();
json.setId(String.valueOf(job.getId()));
json.setScannerType(job.getScannerType());
json.setStatus(job.getStatus().name());
json.setCreatedAt(TimeUtil.toUTCString(job.getCreatedAt()));
json.setUpdatedAt(TimeUtil.toUTCString(job.getUpdatedAt()));
json.setErrorMessage(job.getErrorMessage());
json.setExternalUrl(buildExternalScannerUrl(job));
return json;
}

/**
* Build the external dashboard URL for a scanner job, if its scanner configures
* an external-url-template and the job has an external id.
*/
@Nullable
private String buildExternalScannerUrl(@Nullable ScannerJob job) {
if (job == null) {
return null;
}
if (job.getExternalJobId() == null) {
return null;
}
var scanner = scannerRegistry.getScanner(job.getScannerType());
if (scanner == null) {
return null;
}
return scanner.buildExternalUrl(job.getExternalJobId());
}

/**
* Converts a ScanCheckResult entity to a CheckResultJson DTO.
*/
private CheckResultJson toCheckResultJson(ScanCheckResult checkResult) {
private CheckResultJson toCheckResultJson(ScanCheckResult checkResult, ScannerJob scannerJob) {
var json = new CheckResultJson();
json.setCheckType(checkResult.getCheckType());
json.setCategory(checkResult.getCategory().name());
Expand All @@ -928,36 +974,11 @@ private CheckResultJson toCheckResultJson(ScanCheckResult checkResult) {
json.setSummary(checkResult.getSummary());
json.setErrorMessage(checkResult.getErrorMessage());
json.setRequired(checkResult.getRequired());
json.setExternalUrl(buildExternalScannerUrl(checkResult));
json.setExternalUrl(buildExternalScannerUrl(scannerJob));

return json;
}

/**
* Look up the ScannerJob for a SCANNER_JOB check result and ask its scanner
* to build a dashboard URL. Returns null for publish checks, jobs without an
* external id, or scanners that don't configure a url template.
*/
@Nullable
private String buildExternalScannerUrl(@Nonnull ScanCheckResult checkResult) {
if (checkResult.getCategory() != ScanCheckResult.CheckCategory.SCANNER_JOB) {
return null;
}
Long jobId = checkResult.getScannerJobId();
if (jobId == null) {
return null;
}
var job = scanJobRepository.findById(jobId).orElse(null);
if (job == null || job.getExternalJobId() == null) {
return null;
}
var scanner = scannerRegistry.getScanner(job.getScannerType());
if (scanner == null) {
return null;
}
return scanner.buildExternalUrl(job.getExternalJobId());
}

/**
* Converts an ExtensionThreat entity to a ThreatJson DTO.
*/
Expand Down Expand Up @@ -1139,4 +1160,3 @@ private boolean normalizeSortOrder(String sortOrder) {
}

}

13 changes: 13 additions & 0 deletions server/src/main/java/org/eclipse/openvsx/json/ScanResultJson.java
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ public class ScanResultJson extends ResultJson {
@Schema(description = "All checks/scans that were executed (pass, fail, or skip)")
private List<CheckResultJson> checkResults;

@Schema(description = "Scanner jobs for this scan with their current lifecycle state " +
"(QUEUED, PROCESSING, SUBMITTED, COMPLETE, FAILED, REMOVED). Lets the UI surface " +
"ongoing or queued scanners without inferring their state from checkResults.")
private List<ScannerJobJson> scannerJobs;

@Schema(description = "Error message if the scan failed with an error")
private String errorMessage;

Expand Down Expand Up @@ -248,6 +253,14 @@ public void setCheckResults(List<CheckResultJson> checkResults) {
this.checkResults = checkResults;
}

public List<ScannerJobJson> getScannerJobs() {
return scannerJobs;
}

public void setScannerJobs(List<ScannerJobJson> scannerJobs) {
this.scannerJobs = scannerJobs;
}

public String getErrorMessage() {
return errorMessage;
}
Expand Down
Loading