diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastIssue.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastIssue.java new file mode 100644 index 00000000000..cdb564c258f --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastIssue.java @@ -0,0 +1,60 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.dast; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import lombok.Data; + +/** + * Represents a DAST issue from WebInspect scan results. + */ +@Data +public class DastIssue { + private String id; + private String checkTypeId; + private String engineType; + private String vulnerabilityId; + private int severity; + private String name; + private String category; // From 7PK Category classification + private String cweId; // From CWE classification + private String cweDescription; // Full CWE description text + private String sessionUrl; // URL of the session containing this issue + private List reproStepUrls = new ArrayList<>(); + + // ReportSections for audit context + private String summary; // Summary from ReportSection + private String implication; // Implication from ReportSection + private String execution; // Execution from ReportSection + private String fix; // Fix recommendation from ReportSection + private String referenceInfo; // Reference Info from ReportSection + + // Additional classifications + private Map classifications = new HashMap<>(); // kind -> value + + // Audit status + private boolean suppressed = false; + + /** + * SAST instance IDs that are already correlated to this DAST issue, as read + * from {@code //} in the + * webinspect.xml from a previous correlation run. + */ + private Set existingCorrelatedSastIds = new HashSet<>(); +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastSession.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastSession.java new file mode 100644 index 00000000000..0eec54535af --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastSession.java @@ -0,0 +1,53 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.dast; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Data; + +/** + * Represents a DAST session from WebInspect scan results. + * A session contains the HTTP request/response context and zero or more issues. + */ +@Data +public class DastSession { + private String requestId; + private String url; + private String scheme; + private String host; + private int port; + private String attackParamDescriptor; + + // Decoded raw HTTP request/response (Base64 decoded) + private String rawRequest; + private String rawResponse; + + // Issues found in this session + private List issues = new ArrayList<>(); + + /** + * Check if this session has any issues. + */ + public boolean hasIssues() { + return issues != null && !issues.isEmpty(); + } + + /** + * Get the number of issues in this session. + */ + public int getIssueCount() { + return issues != null ? issues.size() : 0; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/StreamingWebInspectParser.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/StreamingWebInspectParser.java new file mode 100644 index 00000000000..dda4b2c4a2a --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/StreamingWebInspectParser.java @@ -0,0 +1,602 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.dast; + +import static com.fortify.cli.aviator.fpr.processor.XmlParserUtils.readElementText; +import static com.fortify.cli.aviator.fpr.processor.XmlParserUtils.skipSection; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.aviator.util.FprHandle; + +/** + * Streaming (StAX-based) parser for WebInspect XML files contained in DAST FPR files. + * This is the memory-efficient alternative to the DOM-based {@link WebInspectParser}. + * Uses the same streaming pattern as {@link com.fortify.cli.aviator.fpr.processor.StreamingFVDLProcessor}. + */ +public class StreamingWebInspectParser { + private static final Logger logger = LoggerFactory.getLogger(StreamingWebInspectParser.class); + + private final FprHandle fprHandle; + private final XMLInputFactory xmlInputFactory; + + public StreamingWebInspectParser(FprHandle fprHandle) { + this.fprHandle = fprHandle; + this.xmlInputFactory = XMLInputFactory.newInstance(); + xmlInputFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); + xmlInputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + } + + /** + * Parses the webinspect.xml file and returns all DAST issues. + * Equivalent to {@link WebInspectParser#parse()} but uses streaming (StAX) parsing + * for lower memory consumption on large scan results. + * + * @return List of DastIssue objects + */ + public List parse() { + List issues = new ArrayList<>(); + Path webInspectPath = fprHandle.getPath("/webinspect.xml"); + + if (!Files.exists(webInspectPath)) { + throw new RuntimeException("webinspect.xml not found in DAST FPR"); + } + + try (InputStream inputStream = Files.newInputStream(webInspectPath)) { + XMLStreamReader reader = xmlInputFactory.createXMLStreamReader(inputStream); + + try { + while (reader.hasNext()) { + int event = reader.next(); + + if (event == XMLStreamConstants.START_ELEMENT + && "Session".equals(reader.getLocalName())) { + parseSessionForIssues(reader, issues); + } + } + } finally { + reader.close(); + } + + logger.info("Parsed {} DAST issues from webinspect.xml (streaming)", issues.size()); + + } catch (XMLStreamException e) { + throw new RuntimeException("Failed to parse webinspect.xml: " + e.getMessage(), e); + } catch (IOException e) { + throw new RuntimeException("Failed to read webinspect.xml: " + e.getMessage(), e); + } + + return issues; + } + + /** + * Parses the webinspect.xml file and returns all DAST sessions with their issues. + * Includes Base64-decoded raw request/response data for audit purposes. + * Equivalent to {@link WebInspectParser#parseSessions()} but uses streaming parsing. + * + * @return List of DastSession objects with issues + */ + public List parseSessions() { + List sessions = new ArrayList<>(); + Path webInspectPath = fprHandle.getPath("/webinspect.xml"); + + if (!Files.exists(webInspectPath)) { + throw new RuntimeException("webinspect.xml not found in DAST FPR"); + } + + try (InputStream inputStream = Files.newInputStream(webInspectPath)) { + XMLStreamReader reader = xmlInputFactory.createXMLStreamReader(inputStream); + + try { + while (reader.hasNext()) { + int event = reader.next(); + + if (event == XMLStreamConstants.START_ELEMENT + && "Session".equals(reader.getLocalName())) { + DastSession session = parseFullSession(reader); + if (session.hasIssues()) { + sessions.add(session); + } + } + } + } finally { + reader.close(); + } + + int totalIssues = sessions.stream().mapToInt(DastSession::getIssueCount).sum(); + logger.info("Parsed {} sessions with {} total DAST issues from webinspect.xml (streaming)", + sessions.size(), totalIssues); + + } catch (XMLStreamException e) { + throw new RuntimeException("Failed to parse webinspect.xml: " + e.getMessage(), e); + } catch (IOException e) { + throw new RuntimeException("Failed to read webinspect.xml: " + e.getMessage(), e); + } + + return sessions; + } + + // ========================================================================= + // Session parsing for parse() — collects issues only, skips raw request/response + // ========================================================================= + + private void parseSessionForIssues(XMLStreamReader reader, List issues) + throws XMLStreamException { + + String sessionUrl = null; + + while (reader.hasNext()) { + int event = reader.next(); + + if (event == XMLStreamConstants.START_ELEMENT) { + String localName = reader.getLocalName(); + + switch (localName) { + case "URL": + sessionUrl = readElementText(reader); + break; + case "Issue": + DastIssue issue = parseIssue(reader, sessionUrl); + if (issue != null) { + issues.add(issue); + } + break; + case "RawRequest": + case "RawResponse": + case "Request": + case "Response": + skipSection(reader, localName); + break; + default: + break; + } + } else if (event == XMLStreamConstants.END_ELEMENT + && "Session".equals(reader.getLocalName())) { + return; + } + } + } + + // ========================================================================= + // Session parsing for parseSessions() — full session including raw data + // ========================================================================= + + private DastSession parseFullSession(XMLStreamReader reader) throws XMLStreamException { + var session = new DastSession(); + session.setRequestId(reader.getAttributeValue(null, "requestId")); + + while (reader.hasNext()) { + int event = reader.next(); + + if (event == XMLStreamConstants.START_ELEMENT) { + String localName = reader.getLocalName(); + + switch (localName) { + case "URL": + session.setUrl(readElementText(reader)); + break; + case "Scheme": + session.setScheme(readElementText(reader)); + break; + case "Host": + session.setHost(readElementText(reader)); + break; + case "Port": + session.setPort(parseIntSafe(readElementText(reader), 0)); + break; + case "AttackParamDescriptor": + session.setAttackParamDescriptor(readElementText(reader)); + break; + case "RawRequest": + session.setRawRequest(decodeBase64(readElementText(reader))); + break; + case "RawResponse": + session.setRawResponse(decodeBase64(readElementText(reader))); + break; + case "Issue": + DastIssue issue = parseIssueForAudit(reader, session.getUrl()); + if (issue != null) { + session.getIssues().add(issue); + } + break; + case "Request": + case "Response": + skipSection(reader, localName); + break; + default: + break; + } + } else if (event == XMLStreamConstants.END_ELEMENT + && "Session".equals(reader.getLocalName())) { + return session; + } + } + + return session; + } + + // ========================================================================= + // Issue parsing — lightweight version for parse() + // ========================================================================= + + private DastIssue parseIssue(XMLStreamReader reader, String sessionUrl) throws XMLStreamException { + var issue = new DastIssue(); + issue.setId(reader.getAttributeValue(null, "id")); + issue.setSessionUrl(sessionUrl); + + while (reader.hasNext()) { + int event = reader.next(); + + if (event == XMLStreamConstants.START_ELEMENT) { + String localName = reader.getLocalName(); + + switch (localName) { + case "CheckTypeID": + issue.setCheckTypeId(readElementText(reader)); + break; + case "EngineType": + issue.setEngineType(readElementText(reader)); + break; + case "VulnerabilityID": + issue.setVulnerabilityId(readElementText(reader)); + break; + case "Severity": + issue.setSeverity(parseIntSafe(readElementText(reader), 0)); + break; + case "Name": + issue.setName(readElementText(reader)); + break; + case "Classifications": + parseClassificationsBasic(reader, issue); + break; + case "ReproSteps": + parseReproSteps(reader, issue); + break; + case "ReportSection": + parseReportSectionSummaryOnly(reader, issue); + break; + case "ExternalFindings": + parseExternalFindings(reader, issue); + break; + default: + break; + } + } else if (event == XMLStreamConstants.END_ELEMENT + && "Issue".equals(reader.getLocalName())) { + break; + } + } + + applyFallbackCategory(issue); + return issue; + } + + // ========================================================================= + // Issue parsing — full version for parseSessions() / audit + // ========================================================================= + + private DastIssue parseIssueForAudit(XMLStreamReader reader, String sessionUrl) throws XMLStreamException { + var issue = new DastIssue(); + issue.setId(reader.getAttributeValue(null, "id")); + issue.setSessionUrl(sessionUrl); + + while (reader.hasNext()) { + int event = reader.next(); + + if (event == XMLStreamConstants.START_ELEMENT) { + String localName = reader.getLocalName(); + + switch (localName) { + case "CheckTypeID": + issue.setCheckTypeId(readElementText(reader)); + break; + case "EngineType": + issue.setEngineType(readElementText(reader)); + break; + case "VulnerabilityID": + issue.setVulnerabilityId(readElementText(reader)); + break; + case "Severity": + issue.setSeverity(parseIntSafe(readElementText(reader), 0)); + break; + case "Name": + issue.setName(readElementText(reader)); + break; + case "Classifications": + parseClassificationsFull(reader, issue); + break; + case "ReproSteps": + parseReproSteps(reader, issue); + break; + case "ReportSection": + parseReportSectionAll(reader, issue); + break; + case "ExternalFindings": + parseExternalFindings(reader, issue); + break; + default: + break; + } + } else if (event == XMLStreamConstants.END_ELEMENT + && "Issue".equals(reader.getLocalName())) { + break; + } + } + + applyFallbackCategory(issue); + return issue; + } + + // ========================================================================= + // Classifications parsing + // ========================================================================= + + /** + * Parse Classifications for the lightweight parse() flow. + * Only extracts "7PK Category" and "CWE" kinds. + */ + private void parseClassificationsBasic(XMLStreamReader reader, DastIssue issue) + throws XMLStreamException { + + while (reader.hasNext()) { + int event = reader.next(); + + if (event == XMLStreamConstants.START_ELEMENT + && "Classification".equals(reader.getLocalName())) { + + String kind = reader.getAttributeValue(null, "kind"); + String identifier = reader.getAttributeValue(null, "identifier"); + String text = readElementText(reader); + + if ("7PK Category".equals(kind)) { + issue.setCategory(text.trim()); + } else if ("CWE".equals(kind)) { + issue.setCweId(identifier); + } + + } else if (event == XMLStreamConstants.END_ELEMENT + && "Classifications".equals(reader.getLocalName())) { + return; + } + } + } + + /** + * Parse Classifications for the full audit flow. + * Stores all classification kinds in the classifications map. + */ + private void parseClassificationsFull(XMLStreamReader reader, DastIssue issue) + throws XMLStreamException { + + while (reader.hasNext()) { + int event = reader.next(); + + if (event == XMLStreamConstants.START_ELEMENT + && "Classification".equals(reader.getLocalName())) { + + String kind = reader.getAttributeValue(null, "kind"); + String identifier = reader.getAttributeValue(null, "identifier"); + String text = readElementText(reader); + String trimmedText = text != null ? text.trim() : ""; + + issue.getClassifications().put(kind, trimmedText); + + if ("7PK Category".equals(kind)) { + issue.setCategory(trimmedText); + } else if ("CWE".equals(kind)) { + issue.setCweId(identifier); + issue.setCweDescription(trimmedText); + } + + } else if (event == XMLStreamConstants.END_ELEMENT + && "Classifications".equals(reader.getLocalName())) { + return; + } + } + } + + // ========================================================================= + // ReproSteps parsing + // ========================================================================= + + private void parseReproSteps(XMLStreamReader reader, DastIssue issue) + throws XMLStreamException { + + while (reader.hasNext()) { + int event = reader.next(); + + if (event == XMLStreamConstants.START_ELEMENT) { + if ("Url".equals(reader.getLocalName())) { + String url = readElementText(reader); + if (url != null && !url.isEmpty()) { + issue.getReproStepUrls().add(url); + } + } + } else if (event == XMLStreamConstants.END_ELEMENT + && "ReproSteps".equals(reader.getLocalName())) { + return; + } + } + } + + // ========================================================================= + // ReportSection parsing + // ========================================================================= + + /** + * Parse a single ReportSection — only captures the "Summary" section (for parse()). + */ + private void parseReportSectionSummaryOnly(XMLStreamReader reader, DastIssue issue) + throws XMLStreamException { + + String sectionName = null; + + while (reader.hasNext()) { + int event = reader.next(); + + if (event == XMLStreamConstants.START_ELEMENT) { + String localName = reader.getLocalName(); + + if ("Name".equals(localName)) { + sectionName = readElementText(reader); + } else if ("SectionText".equals(localName)) { + if ("Summary".equals(sectionName)) { + String text = readElementText(reader); + if (text != null && !text.isEmpty()) { + issue.setSummary(stripHtmlTags(text)); + } + } else { + // Skip non-Summary section text + readElementText(reader); + } + } + } else if (event == XMLStreamConstants.END_ELEMENT + && "ReportSection".equals(reader.getLocalName())) { + return; + } + } + } + + /** + * Parse a single ReportSection — captures all known section types (for parseSessions()/audit). + */ + private void parseReportSectionAll(XMLStreamReader reader, DastIssue issue) + throws XMLStreamException { + + String sectionName = null; + + while (reader.hasNext()) { + int event = reader.next(); + + if (event == XMLStreamConstants.START_ELEMENT) { + String localName = reader.getLocalName(); + + if ("Name".equals(localName)) { + sectionName = readElementText(reader); + } else if ("SectionText".equals(localName)) { + String text = readElementText(reader); + if (text != null && !text.isEmpty() && sectionName != null) { + String cleanText = stripHtmlTags(text); + switch (sectionName) { + case "Summary": + issue.setSummary(cleanText); + break; + case "Implication": + issue.setImplication(cleanText); + break; + case "Execution": + issue.setExecution(cleanText); + break; + case "Fix": + issue.setFix(cleanText); + break; + case "Reference Info": + issue.setReferenceInfo(cleanText); + break; + default: + break; + } + } + } + } else if (event == XMLStreamConstants.END_ELEMENT + && "ReportSection".equals(reader.getLocalName())) { + return; + } + } + } + + // ========================================================================= + // Helper methods + // ========================================================================= + + /** + * Parses {@code } and populates + * {@link DastIssue#getExistingCorrelatedSastIds()} with every + * {@code } found inside. These IDs represent SAST + * findings already correlated to this DAST issue in a prior run, and + * are used to skip redundant gRPC calls. + */ + private void parseExternalFindings(XMLStreamReader reader, DastIssue issue) + throws XMLStreamException { + + while (reader.hasNext()) { + int event = reader.next(); + + if (event == XMLStreamConstants.START_ELEMENT + && "OriginFindingID".equals(reader.getLocalName())) { + String originFindingId = readElementText(reader); + if (originFindingId != null && !originFindingId.isEmpty()) { + issue.getExistingCorrelatedSastIds().add(originFindingId); + } + } else if (event == XMLStreamConstants.END_ELEMENT + && "ExternalFindings".equals(reader.getLocalName())) { + return; + } + } + } + + private void applyFallbackCategory(DastIssue issue) { + if ((issue.getCategory() == null || issue.getCategory().isEmpty()) + && issue.getName() != null) { + issue.setCategory(issue.getName().trim()); + } + } + + private String decodeBase64(String base64) { + if (base64 == null || base64.isEmpty()) { + return null; + } + try { + byte[] decoded = Base64.getDecoder().decode(base64); + return new String(decoded, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + logger.warn("Failed to decode Base64 content: {}", e.getMessage()); + return null; + } + } + + private String stripHtmlTags(String html) { + if (html == null) { + return null; + } + return html.replaceAll("<[^>]+>", " ") + .replaceAll("\\s+", " ") + .trim(); + } + + private static int parseIntSafe(String value, int defaultValue) { + if (value == null || value.isEmpty()) { + return defaultValue; + } + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/WebInspectParser.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/WebInspectParser.java new file mode 100644 index 00000000000..d41a998a813 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/WebInspectParser.java @@ -0,0 +1,445 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.dast; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import com.fortify.cli.aviator.util.FprHandle; + +/** + * Parser for WebInspect XML files contained in DAST FPR files. + * Uses DOM-based parsing for simplicity (as specified for PoC). + */ +public class WebInspectParser { + private static final Logger logger = LoggerFactory.getLogger(WebInspectParser.class); + + private final FprHandle fprHandle; + + public WebInspectParser(FprHandle fprHandle) { + this.fprHandle = fprHandle; + } + + /** + * Parses the webinspect.xml file and returns all DAST issues. + * + * @return List of DastIssue objects + */ + public List parse() { + List issues = new ArrayList<>(); + Path webInspectPath = fprHandle.getPath("/webinspect.xml"); + + if (!Files.exists(webInspectPath)) { + throw new RuntimeException("webinspect.xml not found in DAST FPR"); + } + + try (InputStream inputStream = Files.newInputStream(webInspectPath)) { + DocumentBuilderFactory factory = createSecureDocumentBuilderFactory(); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document document = builder.parse(inputStream); + + // Get all Session elements + NodeList sessionNodes = document.getElementsByTagName("Session"); + logger.debug("Found {} sessions in webinspect.xml", sessionNodes.getLength()); + + for (int i = 0; i < sessionNodes.getLength(); i++) { + Element sessionElement = (Element) sessionNodes.item(i); + String sessionUrl = getElementText(sessionElement, "URL"); + + // Get Issues element within this session + NodeList issuesNodes = sessionElement.getElementsByTagName("Issues"); + if (issuesNodes.getLength() > 0) { + Element issuesElement = (Element) issuesNodes.item(0); + NodeList issueNodes = issuesElement.getElementsByTagName("Issue"); + + for (int j = 0; j < issueNodes.getLength(); j++) { + Element issueElement = (Element) issueNodes.item(j); + DastIssue issue = parseIssue(issueElement, sessionUrl); + if (issue != null) { + issues.add(issue); + } + } + } + } + + logger.info("Parsed {} DAST issues from webinspect.xml", issues.size()); + + } catch (ParserConfigurationException | SAXException | IOException e) { + throw new RuntimeException("Failed to parse webinspect.xml: " + e.getMessage(), e); + } + + return issues; + } + + /** + * Parses the webinspect.xml file and returns all DAST sessions with their issues. + * Includes Base64-decoded raw request/response data for audit purposes. + * + * @return List of DastSession objects with issues + */ + public List parseSessions() { + List sessions = new ArrayList<>(); + Path webInspectPath = fprHandle.getPath("/webinspect.xml"); + + if (!Files.exists(webInspectPath)) { + throw new RuntimeException("webinspect.xml not found in DAST FPR"); + } + + try (InputStream inputStream = Files.newInputStream(webInspectPath)) { + DocumentBuilderFactory factory = createSecureDocumentBuilderFactory(); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document document = builder.parse(inputStream); + + // Get all Session elements + NodeList sessionNodes = document.getElementsByTagName("Session"); + logger.debug("Found {} sessions in webinspect.xml", sessionNodes.getLength()); + + for (int i = 0; i < sessionNodes.getLength(); i++) { + Element sessionElement = (Element) sessionNodes.item(i); + DastSession session = parseSession(sessionElement); + + // Only include sessions that have issues + if (session.hasIssues()) { + sessions.add(session); + } + } + + int totalIssues = sessions.stream().mapToInt(DastSession::getIssueCount).sum(); + logger.info("Parsed {} sessions with {} total DAST issues from webinspect.xml", + sessions.size(), totalIssues); + + } catch (ParserConfigurationException | SAXException | IOException e) { + throw new RuntimeException("Failed to parse webinspect.xml: " + e.getMessage(), e); + } + + return sessions; + } + + private DastSession parseSession(Element sessionElement) { + DastSession session = new DastSession(); + + session.setRequestId(sessionElement.getAttribute("requestId")); + session.setUrl(getElementText(sessionElement, "URL")); + session.setScheme(getElementText(sessionElement, "Scheme")); + session.setHost(getElementText(sessionElement, "Host")); + session.setAttackParamDescriptor(getElementText(sessionElement, "AttackParamDescriptor")); + + // Parse port + String portStr = getElementText(sessionElement, "Port"); + if (portStr != null && !portStr.isEmpty()) { + try { + session.setPort(Integer.parseInt(portStr)); + } catch (NumberFormatException e) { + session.setPort(0); + } + } + + // Decode raw request (Base64) + NodeList rawRequestNodes = sessionElement.getElementsByTagName("RawRequest"); + if (rawRequestNodes.getLength() > 0) { + Element rawRequestElement = (Element) rawRequestNodes.item(0); + String base64Content = rawRequestElement.getTextContent(); + if (base64Content != null && !base64Content.trim().isEmpty()) { + session.setRawRequest(decodeBase64(base64Content.trim())); + } + } + + // Decode raw response (Base64) + NodeList rawResponseNodes = sessionElement.getElementsByTagName("RawResponse"); + if (rawResponseNodes.getLength() > 0) { + Element rawResponseElement = (Element) rawResponseNodes.item(0); + String base64Content = rawResponseElement.getTextContent(); + if (base64Content != null && !base64Content.trim().isEmpty()) { + session.setRawResponse(decodeBase64(base64Content.trim())); + } + } + + // Parse issues within this session + NodeList issuesNodes = sessionElement.getElementsByTagName("Issues"); + if (issuesNodes.getLength() > 0) { + Element issuesElement = (Element) issuesNodes.item(0); + NodeList issueNodes = issuesElement.getElementsByTagName("Issue"); + + for (int j = 0; j < issueNodes.getLength(); j++) { + Element issueElement = (Element) issueNodes.item(j); + DastIssue issue = parseIssueForAudit(issueElement, session.getUrl()); + if (issue != null) { + session.getIssues().add(issue); + } + } + } + + return session; + } + + /** + * Parse issue with all ReportSections for audit purposes. + */ + private DastIssue parseIssueForAudit(Element issueElement, String sessionUrl) { + DastIssue issue = new DastIssue(); + + issue.setId(issueElement.getAttribute("id")); + issue.setCheckTypeId(getElementText(issueElement, "CheckTypeID")); + issue.setEngineType(getElementText(issueElement, "EngineType")); + issue.setVulnerabilityId(getElementText(issueElement, "VulnerabilityID")); + issue.setName(getElementText(issueElement, "Name")); + issue.setSessionUrl(sessionUrl); + + // Parse severity + String severityStr = getElementText(issueElement, "Severity"); + if (severityStr != null && !severityStr.isEmpty()) { + try { + issue.setSeverity(Integer.parseInt(severityStr)); + } catch (NumberFormatException e) { + issue.setSeverity(0); + } + } + + // Parse all classifications + NodeList classificationsNodes = issueElement.getElementsByTagName("Classifications"); + if (classificationsNodes.getLength() > 0) { + Element classificationsElement = (Element) classificationsNodes.item(0); + NodeList classificationNodes = classificationsElement.getElementsByTagName("Classification"); + + for (int i = 0; i < classificationNodes.getLength(); i++) { + Element classificationElement = (Element) classificationNodes.item(i); + String kind = classificationElement.getAttribute("kind"); + String value = classificationElement.getTextContent().trim(); + + issue.getClassifications().put(kind, value); + + if ("7PK Category".equals(kind)) { + issue.setCategory(value); + } else if ("CWE".equals(kind)) { + issue.setCweId(classificationElement.getAttribute("identifier")); + issue.setCweDescription(value); + } + } + } + + // Fallback for FoD DAST format: use Name element as category + if ((issue.getCategory() == null || issue.getCategory().isEmpty()) && issue.getName() != null) { + issue.setCategory(issue.getName().trim()); + } + + // Parse repro step URLs + NodeList reproStepsNodes = issueElement.getElementsByTagName("ReproSteps"); + if (reproStepsNodes.getLength() > 0) { + Element reproStepsElement = (Element) reproStepsNodes.item(0); + NodeList reproStepNodes = reproStepsElement.getElementsByTagName("ReproStep"); + + for (int i = 0; i < reproStepNodes.getLength(); i++) { + Element reproStepElement = (Element) reproStepNodes.item(i); + String url = getElementText(reproStepElement, "Url"); + if (url != null && !url.isEmpty()) { + issue.getReproStepUrls().add(url); + } + } + } + + // Parse ALL ReportSections + NodeList reportSectionNodes = issueElement.getElementsByTagName("ReportSection"); + for (int i = 0; i < reportSectionNodes.getLength(); i++) { + Element reportSectionElement = (Element) reportSectionNodes.item(i); + String sectionName = getElementText(reportSectionElement, "Name"); + String sectionText = getElementText(reportSectionElement, "SectionText"); + + if (sectionText != null && !sectionText.isEmpty()) { + String cleanText = stripHtmlTags(sectionText); + switch (sectionName) { + case "Summary": + issue.setSummary(cleanText); + break; + case "Implication": + issue.setImplication(cleanText); + break; + case "Execution": + issue.setExecution(cleanText); + break; + case "Fix": + issue.setFix(cleanText); + break; + case "Reference Info": + issue.setReferenceInfo(cleanText); + break; + } + } + } + + parseExternalFindings(issueElement, issue); + + return issue; + } + + private String decodeBase64(String base64) { + if (base64 == null || base64.isEmpty()) { + return null; + } + try { + byte[] decoded = Base64.getDecoder().decode(base64); + return new String(decoded, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + logger.warn("Failed to decode Base64 content: {}", e.getMessage()); + return null; + } + } + + private DastIssue parseIssue(Element issueElement, String sessionUrl) { + DastIssue issue = new DastIssue(); + + issue.setId(issueElement.getAttribute("id")); + issue.setCheckTypeId(getElementText(issueElement, "CheckTypeID")); + issue.setEngineType(getElementText(issueElement, "EngineType")); + issue.setVulnerabilityId(getElementText(issueElement, "VulnerabilityID")); + issue.setName(getElementText(issueElement, "Name")); + issue.setSessionUrl(sessionUrl); + + // Parse severity + String severityStr = getElementText(issueElement, "Severity"); + if (severityStr != null && !severityStr.isEmpty()) { + try { + issue.setSeverity(Integer.parseInt(severityStr)); + } catch (NumberFormatException e) { + issue.setSeverity(0); + } + } + + // Parse classifications to get category and CWE (standard SSC format) + NodeList classificationsNodes = issueElement.getElementsByTagName("Classifications"); + if (classificationsNodes.getLength() > 0) { + Element classificationsElement = (Element) classificationsNodes.item(0); + NodeList classificationNodes = classificationsElement.getElementsByTagName("Classification"); + + for (int i = 0; i < classificationNodes.getLength(); i++) { + Element classificationElement = (Element) classificationNodes.item(i); + String kind = classificationElement.getAttribute("kind"); + + if ("7PK Category".equals(kind)) { + issue.setCategory(classificationElement.getTextContent().trim()); + } else if ("CWE".equals(kind)) { + issue.setCweId(classificationElement.getAttribute("identifier")); + } + } + } + + // Fallback for FoD DAST format: use Name element as category + // The full name (e.g., "Privacy Violation: Autocomplete") is the category, + // matching how SAST categories are formatted as "Type: SubType" + if ((issue.getCategory() == null || issue.getCategory().isEmpty()) && issue.getName() != null) { + issue.setCategory(issue.getName().trim()); + } + + // Parse repro step URLs + NodeList reproStepsNodes = issueElement.getElementsByTagName("ReproSteps"); + if (reproStepsNodes.getLength() > 0) { + Element reproStepsElement = (Element) reproStepsNodes.item(0); + NodeList reproStepNodes = reproStepsElement.getElementsByTagName("ReproStep"); + + for (int i = 0; i < reproStepNodes.getLength(); i++) { + Element reproStepElement = (Element) reproStepNodes.item(i); + String url = getElementText(reproStepElement, "Url"); + if (url != null && !url.isEmpty()) { + issue.getReproStepUrls().add(url); + } + } + } + + // Parse ReportSection to get Summary + NodeList reportSectionNodes = issueElement.getElementsByTagName("ReportSection"); + for (int i = 0; i < reportSectionNodes.getLength(); i++) { + Element reportSectionElement = (Element) reportSectionNodes.item(i); + String sectionName = getElementText(reportSectionElement, "Name"); + if ("Summary".equals(sectionName)) { + String sectionText = getElementText(reportSectionElement, "SectionText"); + if (sectionText != null && !sectionText.isEmpty()) { + // Strip HTML tags for cleaner summary + issue.setSummary(stripHtmlTags(sectionText)); + } + break; + } + } + + parseExternalFindings(issueElement, issue); + + return issue; + } + + /** + * Reads {@code //} from the given + * issue element and populates {@link DastIssue#getExistingCorrelatedSastIds()}. + * This records which SAST findings were already correlated in a prior run so that + * the processor can skip re-submitting them to the gRPC service. + */ + private void parseExternalFindings(Element issueElement, DastIssue issue) { + NodeList efContainers = issueElement.getElementsByTagName("ExternalFindings"); + if (efContainers.getLength() == 0) return; + + Element efContainer = (Element) efContainers.item(0); + NodeList efNodes = efContainer.getElementsByTagName("ExternalFinding"); + for (int i = 0; i < efNodes.getLength(); i++) { + Element ef = (Element) efNodes.item(i); + String originFindingId = getElementText(ef, "OriginFindingID"); + if (originFindingId != null && !originFindingId.isEmpty()) { + issue.getExistingCorrelatedSastIds().add(originFindingId); + } + } + } + + private String getElementText(Element parent, String tagName) { + NodeList nodes = parent.getElementsByTagName(tagName); + if (nodes.getLength() > 0) { + String text = nodes.item(0).getTextContent(); + return text != null ? text.trim() : null; + } + return null; + } + + private String stripHtmlTags(String html) { + if (html == null) { + return null; + } + // Remove HTML tags and normalize whitespace + return html.replaceAll("<[^>]+>", " ") + .replaceAll("\\s+", " ") + .trim(); + } + + private DocumentBuilderFactory createSecureDocumentBuilderFactory() throws ParserConfigurationException { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + return factory; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java index 5bb2e6c7444..b3a26f9441f 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java @@ -45,6 +45,7 @@ import com.fortify.aviator.entitlement.ListEntitlementsByTenantRequest; import com.fortify.aviator.entitlement.ListEntitlementsByTenantResponse; import com.fortify.aviator.grpc.AuditorServiceGrpc; +import com.fortify.aviator.grpc.CorrelationServiceGrpc; import com.fortify.cli.aviator._common.exception.AviatorSimpleException; import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; import com.fortify.cli.aviator.audit.model.AuditResponse; @@ -81,6 +82,7 @@ public class AviatorGrpcClient implements AutoCloseable { private final TokenServiceGrpc.TokenServiceBlockingStub tokenServiceBlockingStub; private final EntitlementServiceGrpc.EntitlementServiceBlockingStub entitlementServiceBlockingStub; private final DastEntitlementServiceGrpc.DastEntitlementServiceBlockingStub dastEntitlementServiceBlockingStub; + private final CorrelationServiceGrpc.CorrelationServiceStub correlationAsyncStub; private final long defaultTimeoutSeconds; private final java.util.concurrent.ExecutorService processingExecutor; private final long pingIntervalSeconds; @@ -96,6 +98,7 @@ public AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAv this.tokenServiceBlockingStub = TokenServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); this.entitlementServiceBlockingStub = EntitlementServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); this.dastEntitlementServiceBlockingStub = DastEntitlementServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); + this.correlationAsyncStub = CorrelationServiceGrpc.newStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); this.defaultTimeoutSeconds = defaultTimeoutSeconds; this.processingExecutor = Executors.newFixedThreadPool(4, r -> { Thread t = new Thread(r, "aviator-client-processing-" + r.hashCode()); @@ -265,4 +268,20 @@ public List listDastEntitlements(String tenantName, String sign ListDastEntitlementsByTenantResponse response = GrpcUtil.executeGrpcCall(dastEntitlementServiceBlockingStub, DastEntitlementServiceGrpc.DastEntitlementServiceBlockingStub::listDastEntitlementsByTenant, request, Constants.OP_LIST_DAST_ENTITLEMENTS); return response.getEntitlementsList(); } + + public CorrelationServiceGrpc.CorrelationServiceStub getCorrelationAsyncStub() { + return correlationAsyncStub; + } + + public java.util.concurrent.ScheduledExecutorService getPingScheduler() { + return pingScheduler; + } + + public long getPingIntervalSeconds() { + return pingIntervalSeconds; + } + + public long getDefaultTimeoutSeconds() { + return defaultTimeoutSeconds; + } } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelatedPair.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelatedPair.java new file mode 100644 index 00000000000..68c0b341b35 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelatedPair.java @@ -0,0 +1,32 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +/** + * Represents a confirmed SAST–DAST correlation pair. + * Used as the output of the gRPC correlation stream and as input + * to the ExternalFindings injection step. + * + * @param sastInstanceId SAST finding instance ID (from FVDL InstanceID) + * @param dastIssueId DAST issue ID (from webinspect.xml Issue@id) + * @param scanGuid SAST scan UUID (FVDL document UUID, used as OriginID) + * @param confidence Confidence level of the correlation (e.g. HIGH, MEDIUM, LOW) + * @param rationale Server-provided explanation for the correlation decision + */ +public record CorrelatedPair( + String sastInstanceId, + String dastIssueId, + String scanGuid, + String confidence, + String rationale +) {} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationResult.java new file mode 100644 index 00000000000..df66621f213 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationResult.java @@ -0,0 +1,30 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import java.util.List; + +/** + * Holds the outcome of a full correlation stream run — both confirmed + * and rejected SAST–DAST pairs, plus the count of correlation requests + * that received a successful response from the server. + * + * @param confirmedPairs pairs where Phase 2 validation returned confirmed=true + * @param rejectedPairs pairs where Phase 2 validation returned confirmed=false + * @param receivedCorrelationResponses number of Phase 1 correlation requests that received a response + */ +public record CorrelationResult( + List confirmedPairs, + List rejectedPairs, + int receivedCorrelationResponses +) {} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamConfig.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamConfig.java new file mode 100644 index 00000000000..af9f6487bf9 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamConfig.java @@ -0,0 +1,30 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +/** + * Configuration record for initializing a correlation gRPC stream. + * + * @param token Aviator user session token + * @param applicationName Aviator application name + * @param sscApplicationName SSC application name (for metadata) + * @param sscApplicationVersion SSC application version name + * @param fprBuildId SAST FPR build ID + */ +public record CorrelationStreamConfig( + String token, + String applicationName, + String sscApplicationName, + String sscApplicationVersion, + String fprBuildId +) {} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessor.java new file mode 100644 index 00000000000..45ae31779e2 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessor.java @@ -0,0 +1,713 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.aviator.grpc.CorrelationCandidateMatch; +import com.fortify.aviator.grpc.CorrelationClientMessage; +import com.fortify.aviator.grpc.CorrelationErrorResponse; +import com.fortify.aviator.grpc.CorrelationInitResponse; +import com.fortify.aviator.grpc.CorrelationPingRequest; +import com.fortify.aviator.grpc.CorrelationPongResponse; +import com.fortify.aviator.grpc.CorrelationRequest; +import com.fortify.aviator.grpc.CorrelationResponse; +import com.fortify.aviator.grpc.CorrelationServerMessage; +import com.fortify.aviator.grpc.CorrelationServiceGrpc; +import com.fortify.aviator.grpc.CorrelationStreamInitRequest; +import com.fortify.aviator.grpc.CorrelationValidationRequest; +import com.fortify.aviator.grpc.CorrelationValidationResponse; +import com.fortify.aviator.grpc.DastIssueContext; +import com.fortify.aviator.grpc.DastUrlCandidate; +import com.fortify.aviator.grpc.SastCodeFile; +import com.fortify.aviator.grpc.SastCodeLocation; +import com.fortify.aviator.grpc.SastFindingContext; +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.fpr.Vulnerability; +import com.fortify.cli.aviator.grpc.CorrelationStreamState.CandidateMatch; + +import io.grpc.Status; +import io.grpc.stub.ClientCallStreamObserver; +import io.grpc.stub.ClientResponseObserver; +import io.grpc.stub.StreamObserver; + +/** + * Processes SAST–DAST correlation via a gRPC bidirectional stream to the + * Aviator server's CorrelationService. Follows the same patterns as + * {@link AviatorStreamProcessor} for the audit flow. + * + *

The stream operates in two sequential phases: + *

    + *
  1. Correlation: Send one {@link CorrelationRequest} per SAST finding, + * receive {@link CorrelationResponse} with candidate URL matches.
  2. + *
  3. Validation: For each candidate match, send a + * {@link CorrelationValidationRequest} with full DAST issue context, + * receive confirmation/rejection.
  4. + *
+ */ +public class CorrelationStreamProcessor implements AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger(CorrelationStreamProcessor.class); + private static final int MAX_SOURCE_CONTENT_LENGTH = 5000; + + private final AviatorGrpcClient client; + private final IAviatorLogger logger; + private final CorrelationServiceGrpc.CorrelationServiceStub asyncStub; + private final ScheduledExecutorService pingScheduler; + private final long pingIntervalSeconds; + private final long defaultTimeoutSeconds; + + private RequestHandler requestHandler; + private ScheduledFuture pingTask; + private final AtomicBoolean isPinging = new AtomicBoolean(false); + + private volatile CorrelationStreamState state; + private CountDownLatch streamLatch; + + // Input data retained for building validation requests + private List correlationWorkItems; + private Map> urlToDastIssues; + private final java.util.concurrent.ConcurrentHashMap validationRequestToDastId = + new java.util.concurrent.ConcurrentHashMap<>(); + private volatile CompletableFuture resultFuture; + + /** + * Keys of SAST–DAST pairs that were confirmed in a previous run and should + * be skipped during both Phase 1 (correlation) and Phase 2 (validation). + * Each key is formatted as {@code "sastInstanceId::dastIssueId"}. + */ + private Set previouslyCorrelatedPairKeys = Set.of(); + + public CorrelationStreamProcessor( + AviatorGrpcClient client, + IAviatorLogger logger, + CorrelationServiceGrpc.CorrelationServiceStub asyncStub, + ScheduledExecutorService pingScheduler, + long pingIntervalSeconds, + long defaultTimeoutSeconds) { + this.client = client; + this.logger = logger; + this.asyncStub = asyncStub; + this.pingScheduler = pingScheduler; + this.pingIntervalSeconds = pingIntervalSeconds; + this.defaultTimeoutSeconds = defaultTimeoutSeconds; + } + + /** + * Entry point: run correlation on the provided mixed-category buckets. + * Previously confirmed pairs (from prior runs) are not re-processed. + * + * @param config stream init configuration (token, app name, etc.) + * @param mixedBuckets category buckets containing both SAST and DAST findings + * @param scanGuid SAST scan UUID for building CorrelatedPair results + * @param previouslyCorrelatedPairKeys keys of already-confirmed pairs to skip, each formatted as + * {@code "sastInstanceId::dastIssueId"}; may be {@code null} + * @return future that completes with the list of confirmed correlated pairs + */ + public CompletableFuture processCorrelation( + CorrelationStreamConfig config, + List mixedBuckets, + String scanGuid, + Set previouslyCorrelatedPairKeys) { + + this.previouslyCorrelatedPairKeys = + previouslyCorrelatedPairKeys != null ? previouslyCorrelatedPairKeys : Set.of(); + + // Build URL→DAST map first; needed to evaluate Phase 1 skip eligibility + this.urlToDastIssues = buildUrlToDastMap(mixedBuckets); + var workItems = buildCorrelationWorkItems(mixedBuckets); + this.correlationWorkItems = workItems; + + LOG.debug("Checking URL to DAST issue map"); + urlToDastIssues.forEach((k,v)->LOG.debug(" For url {} no. of dast issues {}", k, v.size())); + if (workItems.isEmpty()) { + LOG.info("No SAST findings in mixed buckets; skipping correlation stream."); + return CompletableFuture.completedFuture(new CorrelationResult(List.of(), List.of(), 0)); + } + + String streamId = UUID.randomUUID().toString(); + this.state = new CorrelationStreamState(streamId, config); + state.totalCorrelationRequests = workItems.size(); + + logger.info("Starting correlation stream — " + workItems.size() + " SAST findings to correlate"); + + CompletableFuture resultFuture = new CompletableFuture<>(); + this.resultFuture = resultFuture; + this.streamLatch = new CountDownLatch(1); + + startStream(resultFuture, scanGuid); + + return resultFuture; + } + + /** + * Convenience overload for callers that have no previously confirmed pairs to skip. + */ + public CompletableFuture processCorrelation( + CorrelationStreamConfig config, + List mixedBuckets, + String scanGuid) { + return processCorrelation(config, mixedBuckets, scanGuid, null); + } + + // ─── Stream lifecycle ────────────────────────────────────────────── + + private void startStream(CompletableFuture resultFuture, String scanGuid) { + requestHandler = new RequestHandler<>(state.streamId); + + StreamObserver requestObserver = + asyncStub.processCorrelationStream(new CorrelationResponseObserver(resultFuture, scanGuid)); + + requestHandler.initialize(requestObserver); + startPingPong(); + + // Send init message + var initReq = CorrelationStreamInitRequest.newBuilder() + .setToken(state.token) + .setApplicationName(state.applicationName != null ? state.applicationName : "") + .setStreamId(state.streamId) + .setRequestId(UUID.randomUUID().toString()) + .setTotalReportedIssues(state.totalCorrelationRequests) + .setTotalIssuesToCorrelate(state.totalCorrelationRequests); + + if (state.fprBuildId != null) { + initReq.setFprBuildId(state.fprBuildId); + } + if (state.sscApplicationName != null) { + initReq.setSscApplicationName(state.sscApplicationName); + } + if (state.sscApplicationVersion != null) { + initReq.setSscApplicationVersion(state.sscApplicationVersion); + } + + requestHandler.sendRequest( + CorrelationClientMessage.newBuilder().setInit(initReq.build()).build() + ); + } + + // ─── Phase 1: Send correlation requests ──────────────────────────── + + private void sendCorrelationRequests() { + state.currentPhase = CorrelationStreamState.Phase.CORRELATING; + logger.info("Sending " + correlationWorkItems.size() + " correlation requests..."); + + for (var item : correlationWorkItems) { + var req = buildCorrelationRequest(state.streamId, item); + requestHandler.sendRequest( + CorrelationClientMessage.newBuilder().setCorrelation(req).build() + ); + state.sentCorrelations.incrementAndGet(); + } + } + + // ─── Phase 2: Send validation requests ───────────────────────────── + + private void transitionToValidation(String scanGuid) { + state.currentPhase = CorrelationStreamState.Phase.VALIDATING; + + var validationItems = buildValidationWorkItems(); + state.totalValidationRequests = validationItems.size(); + + if (validationItems.isEmpty()) { + logger.info("No candidates to validate. Completing stream."); + if (!resultFuture.isDone()) { + resultFuture.complete(new CorrelationResult( + new ArrayList<>(state.confirmedPairs), + new ArrayList<>(state.rejectedPairs), + state.receivedCorrelations.get() + )); + } + requestHandler.complete(); + streamLatch.countDown(); + return; + } + + logger.info("Sending " + validationItems.size() + " validation requests..."); + for (var item : validationItems) { + var req = buildValidationRequest(state.streamId, item); + validationRequestToDastId.put(req.getRequestId(), + item.dastIssue().getId() != null ? item.dastIssue().getId() : ""); + requestHandler.sendRequest( + CorrelationClientMessage.newBuilder().setValidation(req).build() + ); + state.sentValidations.incrementAndGet(); + } + } + + // ─── Response observer ───────────────────────────────────────────── + + private class CorrelationResponseObserver + implements ClientResponseObserver { + + private final CompletableFuture resultFuture; + private final String scanGuid; + + CorrelationResponseObserver(CompletableFuture resultFuture, String scanGuid) { + this.resultFuture = resultFuture; + this.scanGuid = scanGuid; + } + + @Override + public void beforeStart(ClientCallStreamObserver requestStream) { + // No special setup needed; RequestHandler manages the stream + } + + @Override + public void onNext(CorrelationServerMessage message) { + try { + switch (message.getResponseTypeCase()) { + case INIT -> handleInitResponse(message.getInit()); + case CORRELATION -> handleCorrelationResponse(message.getCorrelation()); + case VALIDATION -> handleValidationResponse(message.getValidation(), scanGuid); + case ERROR -> handleErrorResponse(message.getError(), resultFuture); + case PONG -> handlePongResponse(message.getPong()); + default -> LOG.warn("Unknown correlation response type: {}", message.getResponseTypeCase()); + } + } catch (Exception e) { + LOG.error("Error handling correlation response", e); + } + } + + @Override + public void onError(Throwable t) { + stopPingPong(); + Status status = Status.fromThrowable(t); + LOG.error("Correlation stream error: {} - {}", status.getCode(), status.getDescription(), t); + resultFuture.completeExceptionally( + new AviatorTechnicalException("Correlation stream failed: " + status.getDescription(), t) + ); + streamLatch.countDown(); + } + + @Override + public void onCompleted() { + stopPingPong(); + state.currentPhase = CorrelationStreamState.Phase.COMPLETE; + logger.info("Correlation stream completed — " + state.confirmedPairs.size() + " confirmed pairs"); + if (!resultFuture.isDone()) { + resultFuture.complete(new CorrelationResult( + new ArrayList<>(state.confirmedPairs), + new ArrayList<>(state.rejectedPairs), + state.receivedCorrelations.get() + )); + } + streamLatch.countDown(); + } + } + + // ─── Response handlers ───────────────────────────────────────────── + + private void handleInitResponse(CorrelationInitResponse resp) { + LOG.info("Correlation stream initialized. Server stream ID: {}, Reserved quota: {}, Status: {}", + resp.getServerStreamId(), resp.getReservedQuota(), resp.getStatus()); + + state.isStreamInitialized = true; + state.quota = resp.getReservedQuota(); + + if (!"OK".equalsIgnoreCase(resp.getStatus()) && !"SUCCESS".equalsIgnoreCase(resp.getStatus())) { + LOG.warn("Init response status: {} — {}", resp.getStatus(), resp.getStatusMessage()); + } + + // Start sending correlation requests + sendCorrelationRequests(); + } + + private void handleCorrelationResponse(CorrelationResponse resp) { + int received = state.receivedCorrelations.incrementAndGet(); + LOG.debug("Correlation response {}/{} for SAST {}: status={}", + received, state.totalCorrelationRequests, resp.getSastId(), resp.getStatus()); + + logger.progress("Correlating " + received + " of " + state.totalCorrelationRequests + " SAST findings"); + + if ("OK".equalsIgnoreCase(resp.getStatus()) || "SUCCESS".equalsIgnoreCase(resp.getStatus())) { + for (CorrelationCandidateMatch match : resp.getMatchesList()) { + state.candidateMatches.add(new CandidateMatch( + resp.getSastId(), + match.getUrl(), + match.getConfidence(), + match.getRationale() + )); + } + } else { + LOG.debug("Non-OK correlation for SAST {}: {} — {}", + resp.getSastId(), resp.getStatus(), resp.getNoCorrelationReason()); + } + + // Check if all correlation responses received → transition + if (received >= state.totalCorrelationRequests) { + logger.info("All " + received + " correlation responses received. " + + state.candidateMatches.size() + " candidate matches found."); + // scanGuid is captured in the observer + transitionToValidation(null); // scanGuid will come from observer; use state + } + } + + private void handleValidationResponse(CorrelationValidationResponse resp, String scanGuid) { + int received = state.receivedValidations.incrementAndGet(); + LOG.info("Validation response {}/{} for SAST {}: confirmed={}", + received, state.totalValidationRequests, resp.getSastId(), + resp.hasDecision() && resp.getDecision().getConfirmed()); + + logger.progress("Validating " + received + " of " + state.totalValidationRequests + " correlation candidates"); + + String dastIssueId = findDastIssueIdForValidation(resp); + if (resp.hasDecision() && resp.getDecision().getConfirmed()) { + if (dastIssueId != null) { + state.confirmedPairs.add(new CorrelatedPair( + resp.getSastId(), + dastIssueId, + scanGuid != null ? scanGuid : "", + resp.getDecision().getConfidence(), + resp.getDecision().getRationale() + )); + } + } else { + // Record rejected pair so the caller can persist it in the SAST FPR + if (dastIssueId != null && !dastIssueId.isEmpty()) { + state.rejectedPairs.add(new CorrelatedPair( + resp.getSastId(), + dastIssueId, + scanGuid != null ? scanGuid : "", + resp.hasDecision() ? resp.getDecision().getConfidence() : "", + resp.hasDecision() ? resp.getDecision().getRationale() : "rejected" + )); + } + } + + // Check if all validations received → complete + if (received >= state.totalValidationRequests) { + logger.info("All " + received + " validation responses received. " + + state.confirmedPairs.size() + " confirmed pairs, " + + state.rejectedPairs.size() + " rejected pairs."); + if (!resultFuture.isDone()) { + resultFuture.complete(new CorrelationResult( + new ArrayList<>(state.confirmedPairs), + new ArrayList<>(state.rejectedPairs), + state.receivedCorrelations.get() + )); + } + requestHandler.complete(); + streamLatch.countDown(); + } + } + + private void handleErrorResponse(CorrelationErrorResponse resp, + CompletableFuture resultFuture) { + LOG.error("Correlation stream error from server: {} — {}", + resp.getStatus(), resp.getStatusMessage()); + resultFuture.completeExceptionally( + new AviatorSimpleException("Correlation error: " + resp.getStatusMessage()) + ); + streamLatch.countDown(); + } + + private void handlePongResponse(CorrelationPongResponse pong) { + long latency = System.currentTimeMillis() - pong.getClientTimestamp(); + LOG.debug("Correlation pong received. Latency: {} ms", latency); + } + + // ─── Ping/pong keepalive ─────────────────────────────────────────── + + private void startPingPong() { + if (pingIntervalSeconds <= 0 || pingScheduler == null) return; + + pingTask = pingScheduler.scheduleAtFixedRate(() -> { + if (isPinging.compareAndSet(false, true)) { + try { + if (requestHandler != null && requestHandler.isReady()) { + var ping = CorrelationPingRequest.newBuilder() + .setStreamId(state.streamId) + .setTimestamp(System.currentTimeMillis()) + .build(); + requestHandler.sendRequest( + CorrelationClientMessage.newBuilder().setPing(ping).build() + ); + } + } finally { + isPinging.set(false); + } + } + }, pingIntervalSeconds, pingIntervalSeconds, TimeUnit.SECONDS); + } + + private void stopPingPong() { + if (pingTask != null && !pingTask.isCancelled()) { + pingTask.cancel(false); + } + } + + // ─── Request builders ────────────────────────────────────────────── + + private CorrelationRequest buildCorrelationRequest(String streamId, CorrelationWorkItem item) { + var sastFinding = buildSastFindingContext(item.vuln()); + + var builder = CorrelationRequest.newBuilder() + .setRequestId(UUID.randomUUID().toString()) + .setStreamId(streamId) + .setCategory(item.category()) + .setSastFinding(sastFinding); + + for (String url : item.dastUrls()) { + builder.addDastUrlCandidates(DastUrlCandidate.newBuilder().setUrl(url).build()); + } + + return builder.build(); + } + + private CorrelationValidationRequest buildValidationRequest(String streamId, ValidationWorkItem item) { + var sastFinding = buildSastFindingContext(item.vuln()); + + var dastContext = DastIssueContext.newBuilder() + .setDastIssueId(item.dastIssue().getId() != null ? item.dastIssue().getId() : "") + .setIssueName(item.dastIssue().getName() != null ? item.dastIssue().getName() : "") + .setSeverity(String.valueOf(item.dastIssue().getSeverity())) + .setUrl(item.dastIssue().getSessionUrl() != null ? item.dastIssue().getSessionUrl() : "") + .setCweId(item.dastIssue().getCweId() != null ? item.dastIssue().getCweId() : "") + .setSummary(item.dastIssue().getSummary() != null ? item.dastIssue().getSummary() : ""); + + if (item.dastIssue().getReproStepUrls() != null) { + dastContext.addAllReproStepUrls(item.dastIssue().getReproStepUrls()); + } + + return CorrelationValidationRequest.newBuilder() + .setRequestId(UUID.randomUUID().toString()) + .setStreamId(streamId) + .setCategory(item.category()) + .setSastFinding(sastFinding) + .setDastIssue(dastContext.build()) + .build(); + } + + private SastFindingContext buildSastFindingContext(Vulnerability vuln) { + var builder = SastFindingContext.newBuilder() + .setInstanceId(vuln.getInstanceID() != null ? vuln.getInstanceID() : "") + .setCategory(vuln.getType() != null ? vuln.getType() : "") + .setType(vuln.getType() != null ? vuln.getType() : "") + .setSubType(vuln.getSubType() != null ? vuln.getSubType() : "") + .setShortDescription(vuln.getShortDescription() != null ? vuln.getShortDescription() : ""); + + if (vuln.getSource() != null) { + builder.setSource(SastCodeLocation.newBuilder() + .setFilename(vuln.getSource().getFilename() != null ? vuln.getSource().getFilename() : "") + .setLine(vuln.getSource().getLine()) + .setCode(vuln.getSource().getCode() != null ? vuln.getSource().getCode() : "") + .build()); + } + + if (vuln.getSink() != null) { + builder.setSink(SastCodeLocation.newBuilder() + .setFilename(vuln.getSink().getFilename() != null ? vuln.getSink().getFilename() : "") + .setLine(vuln.getSink().getLine()) + .setCode(vuln.getSink().getCode() != null ? vuln.getSink().getCode() : "") + .build()); + } + + if (vuln.getFiles() != null) { + for (var file : vuln.getFiles()) { + String content = file.getContent(); + if (content != null && content.length() > MAX_SOURCE_CONTENT_LENGTH) { + content = content.substring(0, MAX_SOURCE_CONTENT_LENGTH); + } + builder.addFiles(SastCodeFile.newBuilder() + .setName(file.getName() != null ? file.getName() : "") + .setContent(content != null ? content : "") + .build()); + } + } + + if (vuln.getFiles() != null && !vuln.getFiles().isEmpty()) { + builder.setFullSourceFileName(vuln.getFiles().get(0).getName() != null + ? vuln.getFiles().get(0).getName() : ""); + } + + return builder.build(); + } + + // ─── Work item construction ──────────────────────────────────────── + + @SuppressWarnings("unchecked") + private List buildCorrelationWorkItems(List mixedBuckets) { + List items = new ArrayList<>(); + + for (Object bucketObj : mixedBuckets) { + if (bucketObj instanceof CorrelationBucketData data) { + Set dastUrls = new HashSet<>(); + for (var dastIssue : data.dastFindings()) { + if (dastIssue.getSessionUrl() != null && !dastIssue.getSessionUrl().isEmpty()) { + dastUrls.add(dastIssue.getSessionUrl()); + } + } + if (dastUrls.isEmpty()) continue; + List urlList = new ArrayList<>(dastUrls); + for (Vulnerability vuln : data.sastFindings()) { + // Strip URLs where every mapped DAST issue is already confirmed with this SAST finding + List newUrls = filterNewUrls(vuln.getInstanceID(), urlList); + if (newUrls.isEmpty()) { + LOG.debug("Skipping SAST finding {} from Phase 1 — all reachable DAST issues already confirmed", + vuln.getInstanceID()); + continue; + } + items.add(new CorrelationWorkItem(data.category(), vuln, newUrls)); + } + } + } + + return items; + } + + /** + * Returns the subset of {@code urls} for which at least one mapped DAST issue + * is NOT yet confirmed with {@code sastInstanceId}. + * + *
    + *
  • URLs with no mapped DAST issues are kept (the server may resolve them).
  • + *
  • URLs where every mapped DAST issue is already in + * {@link #previouslyCorrelatedPairKeys} are excluded — they add no new work.
  • + *
+ */ + private List filterNewUrls(String sastInstanceId, List urls) { + if (previouslyCorrelatedPairKeys.isEmpty()) return urls; // fast path: nothing confirmed yet + List result = new ArrayList<>(); + for (String url : urls) { + List issues = urlToDastIssues.getOrDefault(url, List.of()); + if (issues.isEmpty()) { + result.add(url); // no mapping known locally — let server decide + continue; + } + boolean hasUncorrelated = issues.stream() + .filter(d -> d.getId() != null && !d.getId().isEmpty()) + .anyMatch(d -> !previouslyCorrelatedPairKeys.contains(sastInstanceId + "::" + d.getId())); + if (hasUncorrelated) { + result.add(url); + } + } + return result; + } + + @SuppressWarnings("unchecked") + private Map> buildUrlToDastMap(List mixedBuckets) { + var map = new java.util.HashMap>(); + for (Object bucketObj : mixedBuckets) { + if (bucketObj instanceof CorrelationBucketData data) { + for (DastIssue issue : data.dastFindings()) { + String url = issue.getSessionUrl(); + if (url != null && !url.isEmpty()) { + map.computeIfAbsent(url, k -> new ArrayList<>()).add(issue); + } + } + } + } + return map; + } + + private List buildValidationWorkItems() { + List items = new ArrayList<>(); + + for (CandidateMatch match : state.candidateMatches) { + LOG.debug("Match URL {}", match.url()); + + List issues = urlToDastIssues.get(match.url()); + if (issues == null || issues.isEmpty()) continue; + + // Find the original Vulnerability for this SAST instanceId + Vulnerability vuln = findVulnerabilityById(match.sastInstanceId()); + if (vuln == null) continue; + + String category = findCategoryForSast(match.sastInstanceId()); + + for (DastIssue dastIssue : issues) { + String pairKey = match.sastInstanceId() + "::" + dastIssue.getId(); + if (previouslyCorrelatedPairKeys.contains(pairKey)) { + LOG.debug("Skipping already confirmed pair sast={} dast={} from Phase 2 validation", + match.sastInstanceId(), dastIssue.getId()); + continue; + } + items.add(new ValidationWorkItem(category, vuln, dastIssue)); + } + } + + return items; + } + + private Vulnerability findVulnerabilityById(String instanceId) { + if (correlationWorkItems == null) return null; + return correlationWorkItems.stream() + .filter(item -> instanceId.equals(item.vuln().getInstanceID())) + .map(CorrelationWorkItem::vuln) + .findFirst() + .orElse(null); + } + + private String findCategoryForSast(String instanceId) { + if (correlationWorkItems == null) return ""; + return correlationWorkItems.stream() + .filter(item -> instanceId.equals(item.vuln().getInstanceID())) + .map(CorrelationWorkItem::category) + .findFirst() + .orElse(""); + } + + + private String findDastIssueIdForValidation(CorrelationValidationResponse resp) { + // Use the dastId field added to CorrelationValidationResponse proto, + // falling back to the requestId-to-dastIssueId tracking map + String dastId = resp.getDastId(); + if (dastId != null && !dastId.isEmpty()) { + return dastId; + } + return validationRequestToDastId.get(resp.getRequestId()); + } + + // ─── Data transfer records ───────────────────────────────────────── + + record CorrelationWorkItem(String category, Vulnerability vuln, List dastUrls) {} + + record ValidationWorkItem(String category, Vulnerability vuln, DastIssue dastIssue) {} + + /** + * Interface for passing bucket data from the fcli-aviator module + * into this fcli-aviator-common processor without a direct dependency + * on {@code CategoryBucket}. + */ + public record CorrelationBucketData( + String category, + List sastFindings, + List dastFindings + ) {} + + // ─── Cleanup ─────────────────────────────────────────────────────── + + @Override + public void close() { + stopPingPong(); + if (requestHandler != null && !requestHandler.isCompleted()) { + requestHandler.complete(); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamState.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamState.java new file mode 100644 index 00000000000..f0988b61c88 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamState.java @@ -0,0 +1,77 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Tracks the state of a correlation gRPC bidi stream across its + * two phases: initial correlation and validation. + */ +class CorrelationStreamState { + + enum Phase { INIT, CORRELATING, VALIDATING, COMPLETE } + + volatile String streamId; + final String token; + final String applicationName; + final String sscApplicationName; + final String sscApplicationVersion; + final String fprBuildId; + + volatile Phase currentPhase = Phase.INIT; + + // Correlation phase counters + int totalCorrelationRequests; + final AtomicInteger sentCorrelations = new AtomicInteger(0); + final AtomicInteger receivedCorrelations = new AtomicInteger(0); + + // Validation phase counters + int totalValidationRequests; + final AtomicInteger sentValidations = new AtomicInteger(0); + final AtomicInteger receivedValidations = new AtomicInteger(0); + + // Quota from init response + volatile long quota = -1; + + // Results + final List candidateMatches = Collections.synchronizedList(new ArrayList<>()); + final List confirmedPairs = Collections.synchronizedList(new ArrayList<>()); + final List rejectedPairs = Collections.synchronizedList(new ArrayList<>()); + + // Retry + volatile int streamRetryCount = 0; + volatile boolean isStreamInitialized = false; + + CorrelationStreamState(String streamId, CorrelationStreamConfig config) { + this.streamId = streamId; + this.token = config.token(); + this.applicationName = config.applicationName(); + this.sscApplicationName = config.sscApplicationName(); + this.sscApplicationVersion = config.sscApplicationVersion(); + this.fprBuildId = config.fprBuildId(); + } + + /** + * Intermediate match from the correlation phase, pending validation. + */ + record CandidateMatch( + String sastInstanceId, + String url, + String confidence, + String rationale + ) {} +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FprHandle.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FprHandle.java index 566553fd001..7230b705734 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FprHandle.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FprHandle.java @@ -75,7 +75,9 @@ public FprHandle(Path fprPath) { } catch (IOException e) { throw new AviatorTechnicalException("Failed to open FPR as a zip file system: " + fprPath, e); } - this.sourceFileMap = loadSourceFileMap(); + this.sourceFileMap = Files.exists(zipfs.getPath("/webinspect.xml")) + ? new ConcurrentHashMap<>() + : loadSourceFileMap(); } /** @@ -185,4 +187,4 @@ private Map loadSourceFileMap() { } return map; } -} \ No newline at end of file +} diff --git a/fcli-core/fcli-aviator-common/src/main/proto/correlation.proto b/fcli-core/fcli-aviator-common/src/main/proto/correlation.proto new file mode 100644 index 00000000000..abee4445ff2 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/proto/correlation.proto @@ -0,0 +1,169 @@ +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "com.fortify.aviator.grpc"; + +package correlation; + +service CorrelationService { + rpc ProcessCorrelationStream (stream CorrelationClientMessage) returns (stream CorrelationServerMessage); +} + +message CorrelationStreamInitRequest { + string token = 1; + string applicationName = 2; + string streamId = 3; + string requestId = 4; + int32 totalReportedIssues = 5; + int32 totalIssuesToCorrelate = 6; + optional string fprBuildId = 7; + optional string sscApplicationName = 8; + optional string sscApplicationVersion = 9; +} + +message CorrelationClientMessage { + oneof request_type { + CorrelationStreamInitRequest init = 1; + CorrelationRequest correlation = 2; + CorrelationValidationRequest validation = 3; + CorrelationPingRequest ping = 4; + } +} + +message CorrelationServerMessage { + oneof response_type { + CorrelationInitResponse init = 1; + CorrelationResponse correlation = 2; + CorrelationValidationResponse validation = 3; + CorrelationErrorResponse error = 4; + CorrelationPongResponse pong = 5; + } +} + +message CorrelationInitResponse { + string requestId = 1; + string clientStreamId = 2; + string serverStreamId = 3; + string status = 4; + string statusMessage = 5; + int32 reservedQuota = 6; + int32 exceededCount = 7; + bool unlimitedQuota = 8; + optional string quotaLastUpdated = 9; + optional string nextQuotaUpdateMessage = 10; + optional string reassignedEntitlementId = 11; +} + +message CorrelationErrorResponse { + string requestId = 1; + string clientStreamId = 2; + string serverStreamId = 3; + string status = 4; + string statusMessage = 5; +} + +message CorrelationPingRequest { + string streamId = 1; + int64 timestamp = 2; +} + +message CorrelationPongResponse { + string streamId = 1; + int64 serverTimestamp = 2; + int64 clientTimestamp = 3; +} + +message CorrelationRequest { + string requestId = 1; + string streamId = 2; + string category = 3; + string bucketKey = 4; // ?? + SastFindingContext sastFinding = 5; + repeated DastUrlCandidate dastUrlCandidates = 6; +} + +message CorrelationResponse { + string requestId = 1; + string streamId = 2; + string sastId = 3; + string status = 4; + string statusMessage = 5; + string noCorrelationReason = 6; + repeated CorrelationCandidateMatch matches = 7; +} + +message CorrelationValidationRequest { + string requestId = 1; + string streamId = 2; + string category = 3; + SastFindingContext sastFinding = 4; + DastIssueContext dastIssue = 5; +} + +message CorrelationValidationResponse { + string requestId = 1; + string streamId = 2; + string sastId = 3; + string dastId = 4; + string status = 5; + string statusMessage = 6; + CorrelationDecision decision = 7; +} + +message SastFindingContext { + string instanceId = 1; + string category = 2; + string type = 3; + string subType = 4; + string fullSourceFileName = 5; + string shortDescription = 6; + SastCodeLocation source = 10; + SastCodeLocation sink = 11; + repeated SastCodeFile files = 12; +} + +message SastCodeLocation { + string filename = 1; + int32 line = 2; + string code = 3; +} + +message SastFragment { + string content = 1; + int32 startLine = 2; + int32 endLine = 3; +} + +message SastCodeFile { + string name = 1; + string content = 2; + bool segment = 3; + int32 startLine = 4; + int32 endLine = 5; +} + +message DastUrlCandidate { + string url = 1; +} + +message CorrelationCandidateMatch { + string url = 1; + string rationale = 2; + string confidence = 3; +} + +message DastIssueContext { + string dastIssueId = 1; + string issueName = 2; + string severity = 3; + string url = 4; + string summary = 5; + string cweId = 6; + repeated string reproStepUrls = 7; +} + +message CorrelationDecision { + bool confirmed = 1; + string rationale = 2; + string confidence = 3; +} diff --git a/fcli-core/fcli-aviator-common/src/main/resources/webinspect.xsd b/fcli-core/fcli-aviator-common/src/main/resources/webinspect.xsd new file mode 100644 index 00000000000..d7b37258382 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/resources/webinspect.xsd @@ -0,0 +1,366 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCommands.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCommands.java index 3d92cf0f01c..79125d5c3fe 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCommands.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCommands.java @@ -21,7 +21,8 @@ subcommands = { AviatorSSCAuditCommand.class, AviatorSSCPrepareCommand.class, - AviatorSSCApplyRemediationsCommand.class + AviatorSSCApplyRemediationsCommand.class, + AviatorSSCCorrelateSastDastCommand.class } ) diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommand.java new file mode 100644 index 00000000000..1d701b670ab --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommand.java @@ -0,0 +1,355 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.cli.cmd; + +import static com.fortify.cli.ssc.artifact.helper.SSCArtifactHelper.getLatestDASTArtifact; +import static com.fortify.cli.ssc.artifact.helper.SSCArtifactHelper.getLatestSASTArtifact; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.aviator._common.session.user.cli.mixin.AviatorUserSessionDescriptorSupplier; +import com.fortify.cli.aviator.config.AviatorLoggerImpl; +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.fpr.Vulnerability; +import com.fortify.cli.aviator.grpc.*; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCAttributeHelper; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCCorrelateDownloadHelper; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCCorrelateFprParser; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCCorrelateFprParser.ParseResult; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCCorrelateHelper; +import com.fortify.cli.aviator.ssc.helper.CategoryBucket; +import com.fortify.cli.aviator.ssc.helper.CategoryGrouper; +import com.fortify.cli.aviator.ssc.helper.DastFprCorrelationEnricher; +import com.fortify.cli.aviator.ssc.helper.SastFprCorrelationRecorder; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; +import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; +import com.fortify.cli.common.progress.helper.IProgressWriter; +import com.fortify.cli.ssc._common.output.cli.cmd.AbstractSSCJsonNodeOutputCommand; +import com.fortify.cli.ssc.appversion.cli.mixin.SSCAppVersionResolverMixin; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; + +import kong.unirest.UnirestInstance; +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command(name = "correlate-sast-dast") +public class AviatorSSCCorrelateSastDastCommand extends AbstractSSCJsonNodeOutputCommand implements IActionCommandResultSupplier { + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; + @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; + @Mixin private SSCAppVersionResolverMixin.RequiredOption appVersionResolver; + @Mixin private AviatorUserSessionDescriptorSupplier sessionDescriptorSupplier; + @Option(names = {"--app"}) private String appName; + + private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCCorrelateSastDastCommand.class); + private String actionResult = "CORRELATED"; + + @Override + public JsonNode getJsonNode(UnirestInstance unirest) { + var sessionDescriptor = sessionDescriptorSupplier.getSessionDescriptor(); + + try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { + AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); + SSCAppVersionDescriptor av = appVersionResolver.getAppVersionDescriptor(unirest); + + logger.progress("Status: Starting SAST-DAST correlation for %s:%s", av.getApplicationName(), av.getVersionName()); + + // Step 1: Download SAST and DAST FPRs from SSC + Path downloadedSASTFprPath; + Path downloadedDASTFprPath; + SSCArtifactDescriptor adDast; + try { + logger.progress("Status: Downloading SAST FPR from SSC for %s:%s", av.getApplicationName(), av.getVersionName()); + SSCArtifactDescriptor adSast = getLatestSASTArtifact(unirest, av.getVersionId()); + downloadedSASTFprPath = AviatorSSCCorrelateDownloadHelper.downloadArtifactFpr(unirest, adSast, logger, progressWriter); + + logger.progress("Status: Downloading DAST FPR from SSC for %s:%s", av.getApplicationName(), av.getVersionName()); + adDast = getLatestDASTArtifact(unirest, av.getVersionId()); + downloadedDASTFprPath = AviatorSSCCorrelateDownloadHelper.downloadArtifactFpr(unirest, adDast, logger, progressWriter); + } catch (java.io.IOException e) { + throw new FcliSimpleException("Failed to download FPR from SSC: " + e.getMessage(), e); + } + + AviatorSSCCorrelateHelper.validateDownloadedFpr(downloadedSASTFprPath, "SAST"); + AviatorSSCCorrelateHelper.validateDownloadedFpr(downloadedDASTFprPath, "DAST"); + + // Step 2: Parse both FPRs + logger.progress("Status: Parsing SAST FPR..."); + ParseResult sastResult = AviatorSSCCorrelateFprParser.parseSastFpr(downloadedSASTFprPath); + logger.progress("Status: Parsing DAST FPR..."); + ParseResult dastResult = AviatorSSCCorrelateFprParser.parseDastFpr(downloadedDASTFprPath); + + // Step 3: Filter to unsuppressed issues only + List unsuppressedSast = sastResult.vulnerabilities.stream() + .filter(v -> !AviatorSSCCorrelateHelper.isVulnerabilitySuppressed(v, sastResult.auditIssueMap)) + .collect(Collectors.toList()); + List unsuppressedDast = dastResult.dastIssues.stream() + .filter(d -> !d.isSuppressed()) + .collect(Collectors.toList()); + + // Build the set of already-tried pair keys: + // confirmedPairKeys — from in DAST FPR webinspect.xml (authoritative) + // rejectedPairKeys — from DAST_CORRELATION_STATUS tag in SAST FPR audit.xml + Set confirmedPairKeys = buildPreviouslyCorrelatedPairKeys(unsuppressedDast); + Set rejectedPairKeys = SastFprCorrelationRecorder.readTriedPairKeys(downloadedSASTFprPath); + Set alreadyTriedKeys = new HashSet<>(confirmedPairKeys); + alreadyTriedKeys.addAll(rejectedPairKeys); + + LOG.info("Total SAST issues {}", sastResult.vulnerabilities.size()); + LOG.info("Total DAST issues {}", dastResult.dastIssues.size()); + LOG.info("Total Unsupressed SAST issues {}", unsuppressedSast.size()); + LOG.info("Total Unsupressed DAST issues {}", unsuppressedDast.size()); + LOG.info("Confirmed pairs (from ExternalFindings): {}", confirmedPairKeys.size()); + LOG.info("Pairs from DAST_CORRELATION_STATUS tag: {}", rejectedPairKeys.size()); + LOG.info("Total already-tried pairs (will be skipped): {}", alreadyTriedKeys.size()); + + logger.progress("Status: Found %d SAST and %d DAST unsuppressed issues to correlate", + unsuppressedSast.size(), unsuppressedDast.size()); + + // Step 4: Group by category + logger.progress("Status: Grouping findings by vulnerability category..."); + CategoryGrouper grouper = new CategoryGrouper(); + grouper.groupFindings(unsuppressedSast, unsuppressedDast); + grouper.printStatistics(); + List mixedBuckets = grouper.getMixedBuckets(); + + // Step 5: gRPC correlation (if mixed buckets exist) + // Count only SAST findings with at least one untried DAST pairing in their bucket + int submitted = countNewSastFindings(mixedBuckets, alreadyTriedKeys); + logger.info("New pairs after removing the already-tried pairs is {}", submitted); + List newPairs = new ArrayList<>(); + List newRejectedPairs = new ArrayList<>(); + int succeeded = 0; + if (!mixedBuckets.isEmpty()) { + logger.progress("Status: Found %d mixed category bucket(s) with %d SAST findings to correlate", + mixedBuckets.size(), submitted); + + List bucketData = mixedBuckets.stream() + .map(b -> new CorrelationStreamProcessor.CorrelationBucketData( + b.getCategory(), b.getSastFindings(), b.getDastFindings())) + .collect(Collectors.toList()); + + String aviatorUrl = sessionDescriptor.getAviatorUrl(); + String token = sessionDescriptor.getAviatorToken(); + + var config = new CorrelationStreamConfig( + token, + appName != null ? appName : "", + av.getApplicationName(), + av.getVersionName(), + sastResult.buildId + ); + + //logger.progress("Status: Connecting to Aviator server for correlation..."); + try (AviatorGrpcClient grpcClient = AviatorGrpcClientHelper.createClient(aviatorUrl, logger, 30)) { + CorrelationResult result = performCorrelation(grpcClient, config, bucketData, sastResult.scanGuid, logger, alreadyTriedKeys); + newPairs = result.confirmedPairs(); + newRejectedPairs = result.rejectedPairs(); + succeeded = result.receivedCorrelationResponses(); + } + + logger.progress("Status: Correlation complete — %d of %d SAST findings confirmed as correlated", + newPairs.size(), submitted); + + if (succeeded==0) { + actionResult = "SKIPPED"; + } else if (succeeded < submitted) { + actionResult = "PARTIALLY_CORRELATED"; + } else { + actionResult = "CORRELATED"; + } + } else { + actionResult = "SKIPPED"; + logger.progress("Status: No mixed categories found — skipping correlation."); + logger.info("No mixed categories found — skipping correlation."); + } + + // Step 6: Inject into DAST FPR and upload + + //Testing the upload flow + /*String SAST_INSTANCE_1 = "00403DBC3662FEBAD561B1A578AE7556"; + String SAST_INSTANCE_2 = "00411ED275CA1DCF328136A99613E95E"; + String SAST_INSTANCE_3 = "0080AE7911F7A5D3A8BDEFD0DD046FB2"; + String DAST_ISSUE_1 = "b7391a4f-deb4-664d-3e23-ca2cc43a7527"; + String SAST_SCAN_GUID = "62cd94b2-523a-409e-86eb-9b55a0421380"; + List newPairs = List.of( + new CorrelatedPair(SAST_INSTANCE_1, DAST_ISSUE_1, SAST_SCAN_GUID, "HIGH", "Primary match"), + new CorrelatedPair(SAST_INSTANCE_2, DAST_ISSUE_1, SAST_SCAN_GUID, "MEDIUM", "Secondary match"), + new CorrelatedPair(SAST_INSTANCE_3, DAST_ISSUE_1, SAST_SCAN_GUID, "LOW", "Tertiary match") + );*/ + + String uploadedArtifactId = null; + if (!newPairs.isEmpty()) { + logger.progress("Status: Injecting correlation data into DAST FPR (%d correlated pair(s))...", newPairs.size()); + DastFprCorrelationEnricher enricher = new DastFprCorrelationEnricher(); + enricher.injectAndRepackage(downloadedDASTFprPath, newPairs); + /*try { + Files.copy(downloadedDASTFprPath, Path.of("C:/Users/nmeshram/Documents/TestSastDastCorrelation/enriched-dast.fpr"), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + LOG.info("Enriched DAST FPR saved to: C:/Users/nmeshram/Documents/TestSastDastCorrelation/enriched-dast.fpr"); + } catch (java.io.IOException e) { + LOG.warn("Failed to save enriched DAST FPR locally: {}", e.getMessage()); + }*/ + + //First we have to delete the existing DAST FPR then upload the enriched DAST FPR. Only through this process + // correlation data is available on the SSC + + logger.progress("Status: Uploading correlated DAST FPR to SSC..."); + AviatorSSCCorrelateDownloadHelper.uploadEnrichedDastFpr(unirest, av, downloadedDASTFprPath, progressWriter); + uploadedArtifactId = adDast.getId(); + logger.progress("Status: Correlated DAST FPR uploaded successfully (artifact id=%s)", uploadedArtifactId); + } else { + logger.progress("Status: No correlated pairs found — skipping DAST FPR upload."); + } + + // Step 6b: Write DAST_CORRELATION_STATUS tags into SAST FPR and re-upload + if (!newPairs.isEmpty() || !newRejectedPairs.isEmpty()) { + logger.progress("Status: Writing correlation status tags to SAST FPR (%d confirmed, %d rejected)...", + newPairs.size(), newRejectedPairs.size()); + SastFprCorrelationRecorder.writeCorrelationTags(downloadedSASTFprPath, newPairs, newRejectedPairs); + /*try { + Files.copy(downloadedSASTFprPath, Path.of("C:/Users/nmeshram/Documents/TestSastDastCorrelation/enriched-sast.fpr"), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + LOG.info("Enriched SAST FPR saved to: C:/Users/nmeshram/Documents/TestSastDastCorrelation/enriched-sast.fpr"); + } catch (java.io.IOException e) { + LOG.warn("Failed to save enriched SAST FPR locally: {}", e.getMessage()); + }*/ + logger.progress("Status: Uploading updated SAST FPR to SSC..."); + AviatorSSCCorrelateDownloadHelper.uploadEnrichedSastFpr(unirest, av, downloadedSASTFprPath, progressWriter); + logger.progress("Status: Updated SAST FPR uploaded successfully."); + + // Step 6c: Write last_correlation timestamp to SSC app version attribute. + // Placed after all FPR uploads so it is always later than lastScanDate. + logger.progress("Status: Writing last_correlation timestamp to app version..."); + AviatorSSCAttributeHelper.writeLastCorrelationTimestamp(unirest, av.getVersionId()); + logger.progress("Status: last_correlation timestamp written successfully."); + } + + + // Step 7: Build output + logger.progress("Status: Correlation process complete for %s:%s — result: %s", + av.getApplicationName(), av.getVersionName(), actionResult); + return AviatorSSCCorrelateHelper.buildOutputJson(av, uploadedArtifactId, submitted, succeeded, newPairs, actionResult); + } + } + + private CorrelationResult performCorrelation( + AviatorGrpcClient grpcClient, + CorrelationStreamConfig config, + List bucketData, + String scanGuid, + AviatorLoggerImpl logger, + Set alreadyTriedKeys) { + try { + var processor = new CorrelationStreamProcessor( + grpcClient, logger, + grpcClient.getCorrelationAsyncStub(), + grpcClient.getPingScheduler(), + grpcClient.getPingIntervalSeconds(), + grpcClient.getDefaultTimeoutSeconds() + ); + long timeoutSeconds = Math.max(grpcClient.getDefaultTimeoutSeconds(), 300); + return processor.processCorrelation(config, bucketData, scanGuid, alreadyTriedKeys) + .get(timeoutSeconds, TimeUnit.SECONDS); + } catch (java.util.concurrent.TimeoutException e) { + throw new FcliSimpleException("Correlation stream timed out waiting for server responses", e); + } catch (Exception e) { + throw new FcliSimpleException("Correlation stream failed: " + e.getMessage(), e); + } + } + + /** + * Builds a set of {@code "sastInstanceId::dastIssueId"} keys from the + * {@code } already present in the parsed DAST issues. + * These pairs were confirmed in a previous correlation run and will be + * skipped by the gRPC processor to avoid redundant server calls. + */ + private Set buildPreviouslyCorrelatedPairKeys(List dastIssues) { + Set keys = new HashSet<>(); + for (DastIssue dastIssue : dastIssues) { + if (dastIssue.getId() == null || dastIssue.getId().isEmpty()) continue; + for (String sastId : dastIssue.getExistingCorrelatedSastIds()) { + keys.add(sastId + "::" + dastIssue.getId()); + } + } + LOG.debug("Built {} previously-correlated pair keys from ExternalFindings", keys.size()); + return keys; + } + + /** + * Counts SAST findings across all mixed buckets that have at least one + * uncorrelated DAST pairing in the same bucket. Already fully-correlated + * SAST findings (every co-bucket DAST issue already confirmed) are excluded, + * so {@code submitted} accurately reflects the new work being sent to the + * gRPC server. + */ + private int countNewSastFindings(List buckets, Set alreadyTriedKeys) { + LOG.debug("=== Already-tried pair keys ({}) ===", alreadyTriedKeys.size()); + for (String key : alreadyTriedKeys) { + String[] parts = key.split("::", 2); + LOG.debug(" Already tried — SAST: {} | DAST: {}", + parts.length == 2 ? parts[0] : key, + parts.length == 2 ? parts[1] : "?"); + } + + LOG.debug("=== Mixed bucket SAST/DAST instance IDs ==="); + for (CategoryBucket bucket : buckets) { + LOG.debug(" Category: {}", bucket.getCategory()); + for (Vulnerability sast : bucket.getSastFindings()) { + LOG.debug(" SAST instanceId: {}", sast.getInstanceID()); + } + for (DastIssue dast : bucket.getDastFindings()) { + LOG.debug(" DAST issueId: {}", dast.getId()); + } + } + + if (alreadyTriedKeys.isEmpty()) { + return buckets.stream().mapToInt(CategoryBucket::getSastCount).sum(); + } + + int count = 0; + for (CategoryBucket bucket : buckets) { + for (Vulnerability sast : bucket.getSastFindings()) { + boolean hasNewPairing = bucket.getDastFindings().stream() + .anyMatch(dast -> !alreadyTriedKeys.contains( + sast.getInstanceID() + "::" + dast.getId())); + LOG.debug(" SAST {} hasNewPairing={}", sast.getInstanceID(), hasNewPairing); + if (hasNewPairing) count++; + } + } + return count; + } + + @Override + public String getActionCommandResult() { + return actionResult; + } + + + @Override + public boolean isSingular() { + return true; + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAttributeDefs.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAttributeDefs.java new file mode 100644 index 00000000000..e29558a336f --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAttributeDefs.java @@ -0,0 +1,55 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +public final class AviatorSSCAttributeDefs { + + private AviatorSSCAttributeDefs() {} + + /** + * Descriptor for a custom SSC attribute definition managed by the Aviator module. + * + * @param guid Fixed GUID — must never change once deployed to an SSC instance. + * @param name Attribute name as it appears in SSC (used for lookup and write). + * @param category SSC attribute category (e.g. {@code "Technical"}). + * @param type SSC attribute type string (e.g. {@code "TEXT"}, {@code "DATE"}). + * @param description Human-readable description stored in SSC. + */ + public record AttributeDefinition( + String guid, + String name, + String category, + String type, + String description + ) {} + + /** + * Free-text attribute written to an SSC application version after each + * successful SAST-DAST correlation run. + * + *

Value is an ISO-8601 UTC timestamp produced by {@code Instant.now().toString()}, + * e.g. {@code 2026-04-30T14:32:00.123Z}. The bulk-correlation action reads this + * attribute to decide whether an application version needs re-correlation. + * + *

TEXT type is used rather than DATE because SSC's DATE type only accepts + * {@code yyyy-MM-dd}, which loses the time-of-day precision required for reliable + * comparison with artifact {@code lastScanDate} values. + */ + public static final AttributeDefinition LAST_CORRELATION_ATTR = new AttributeDefinition( + "B2C3D4E5-F6A7-8901-BCDE-F12345678901", // not sent on POST; used only for reference + "last_correlation", + "TECHNICAL", // must be UPPERCASE — SSC rejects "Technical" (HTTP 400) + "TEXT", + "Timestamp of the last successful SAST-DAST correlation run (ISO-8601 UTC). Written by fcli aviator ssc correlate-sast-dast." + ); +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAttributeHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAttributeHelper.java new file mode 100644 index 00000000000..a54753f25e5 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAttributeHelper.java @@ -0,0 +1,154 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import java.time.Instant; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCAttributeDefs.AttributeDefinition; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.rest.unirest.UnexpectedHttpResponseException; +import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; +import com.fortify.cli.ssc.attribute.helper.SSCAttributeUpdateBuilder; + +import kong.unirest.UnirestInstance; +import lombok.RequiredArgsConstructor; + +/** + * Manages SSC attribute definitions used by the Aviator module. + * + *

Mirrors the pattern in {@link AviatorSSCCustomTagHelper} but operates on + * per-application-version attributes ({@code /api/v1/attributeDefinitions} + + * {@code /api/v1/projectVersions/{id}/attributes}) rather than per-issue + * custom tags. + */ +@RequiredArgsConstructor +public class AviatorSSCAttributeHelper { + + private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCAttributeHelper.class); + private final UnirestInstance unirest; + private final AttributeDefinition attrDef; + + /** + * Ensures the attribute definition exists on the SSC instance. + * + *

    + *
  • If already present (matched by GUID): logs VERIFIED and returns. + *
  • If absent: creates it via {@code POST /api/v1/attributeDefinitions}. + *
+ * + * @param result sink for user-visible status entries + */ + public void synchronize(AviatorSSCPrepareHelper.PrepareResult result) { + try { + LOG.debug("Searching for attribute definition '{}' (GUID: {})", attrDef.name(), attrDef.guid()); + if (findDefinition() != null) { + LOG.info("Attribute definition '{}' is already present.", attrDef.name()); + result.addEntry("Attribute Definition", "VERIFIED", + "'" + attrDef.name() + "' is already present on this SSC instance."); + } else { + createDefinition(); + LOG.info("Attribute definition '{}' created successfully.", attrDef.name()); + result.addEntry("Attribute Definition", "CREATED", + "Attribute definition '" + attrDef.name() + "' created successfully."); + } + } catch (UnexpectedHttpResponseException | FcliSimpleException e) { + LOG.error("Error synchronizing attribute definition '{}': {}", attrDef.name(), e.getMessage()); + result.addEntry("Attribute Definition", "WARNING", + "Failed to synchronize attribute definition '" + attrDef.name() + "': " + e.getMessage() + + ". The correlate-sast-dast command will attempt to create it on first use."); + } + } + + /** + * Writes the current UTC timestamp to the {@code last_correlation} attribute on + * the given application version. + * + *

If the attribute definition does not yet exist (e.g. {@code prepare} was never + * run), this method automatically creates it before writing. + * + * @param unirest active SSC session + * @param versionId SSC project version ID + */ + public static void writeLastCorrelationTimestamp(UnirestInstance unirest, String versionId) { + var helper = new AviatorSSCAttributeHelper(unirest, AviatorSSCAttributeDefs.LAST_CORRELATION_ATTR); + helper.ensureDefinitionExists(); + + String timestamp = Instant.now().toString(); + LOG.debug("Writing last_correlation timestamp '{}' to app version {}", timestamp, versionId); + + new SSCAttributeUpdateBuilder(unirest) + .add(Map.of(AviatorSSCAttributeDefs.LAST_CORRELATION_ATTR.name(), timestamp)) + .buildRequest(versionId) + .asObject(JsonNode.class); + + LOG.info("last_correlation timestamp '{}' written to app version {}", timestamp, versionId); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Returns the attribute definition node matched by name, or {@code null} if absent. + * SSC auto-generates the GUID on create (it is not part of the POST request body), + * so name is the only stable lookup key. + */ + private JsonNode findDefinition() { + JsonNode responseBody = unirest.get(SSCUrls.ATTRIBUTE_DEFINITIONS + "?limit=-1") + .asObject(JsonNode.class).getBody(); + JsonNode data = responseBody.get("data"); + if (data == null || !data.isArray()) return null; + return JsonHelper.stream((ArrayNode) data) + .filter(n -> attrDef.name().equals(n.path("name").asText())) + .findFirst().orElse(null); + } + + /** Creates the attribute definition via {@code POST /api/v1/attributeDefinitions}. */ + private void createDefinition() { + ObjectNode payload = buildCreatePayload(); + LOG.debug("Creating attribute definition '{}': {}", attrDef.name(), payload.toPrettyString()); + unirest.post(SSCUrls.ATTRIBUTE_DEFINITIONS) + .body(payload) + .asObject(JsonNode.class) + .getBody(); + } + + /** Ensures the definition exists — used by {@link #writeLastCorrelationTimestamp}. */ + private void ensureDefinitionExists() { + if (findDefinition() == null) { + LOG.info("Attribute definition '{}' not found — creating before write.", attrDef.name()); + createDefinition(); + } + } + + private ObjectNode buildCreatePayload() { + ObjectNode node = JsonHelper.getObjectMapper().createObjectNode(); + // Exact fields validated against SSC UI capture — see comments for each. + // guid: NOT sent — SSC auto-generates it; sending a custom guid causes HTTP 500. + // options: NOT sent — null in SSC response for TEXT type; empty array causes HTTP 500. + node.put("name", attrDef.name()); + node.put("description", attrDef.description()); + node.put("category", attrDef.category()); // must be UPPERCASE, e.g. "TECHNICAL" + node.put("type", attrDef.type()); + node.put("appEntityType", "PROJECT_VERSION"); // required discriminator — missing this causes HTTP 500 + return node; + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateDownloadHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateDownloadHelper.java new file mode 100644 index 00000000000..2b47c4833c1 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateDownloadHelper.java @@ -0,0 +1,106 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.aviator.config.AviatorLoggerImpl; +import com.fortify.cli.common.progress.helper.IProgressWriter; +import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; +import com.fortify.cli.ssc._common.rest.ssc.transfer.SSCFileTransferHelper; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; + +import kong.unirest.UnirestInstance; + +/** + * Handles FPR download and upload operations against SSC for the correlate-sast-dast command. + */ +public final class AviatorSSCCorrelateDownloadHelper { + + private AviatorSSCCorrelateDownloadHelper() {} + + /** + * Downloads the FPR artifact for a single artifact ID. Used for DAST FPR download + * (individual artifact is needed so its webinspect.xml can be enriched and re-uploaded). + */ + public static Path downloadArtifactFpr(UnirestInstance unirest, SSCArtifactDescriptor ad, + AviatorLoggerImpl logger, IProgressWriter progressWriter) throws IOException { + Path fprPath = Files.createTempFile("aviator_" + ad.getId() + "_", ".fpr"); + logger.progress("Status: Downloading FPR from SSC (artifact id=" + ad.getId() + ")"); + SSCFileTransferHelper.download( + unirest, + SSCUrls.DOWNLOAD_ARTIFACT(ad.getId(), true), + fprPath.toFile(), + SSCFileTransferHelper.ISSCAddDownloadTokenFunction.ROUTEPARAM_DOWNLOADTOKEN, + progressWriter); + return fprPath; + } + + /** + * Downloads the current merged SAST FPR for an application version. + * This merged FPR is safe to re-upload to SSC after adding audit tags because its + * internal FVDL contains only audit state (no raw scan results), which SSC processes + * as an audit-only update instead of a new scan submission. + * Using DOWNLOAD_ARTIFACT for the SAST FPR and re-uploading it causes SSC to treat + * it as a duplicate scan and puts the artifact into an error state. + */ + public static Path downloadCurrentSastFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, + AviatorLoggerImpl logger, IProgressWriter progressWriter) throws IOException { + Path fprPath = Files.createTempFile("aviator_sast_merged_", ".fpr"); + logger.progress("Status: Downloading current merged SAST FPR from SSC for %s:%s", + av.getApplicationName(), av.getVersionName()); + SSCFileTransferHelper.download( + unirest, + SSCUrls.DOWNLOAD_CURRENT_FPR(av.getVersionId(), false), + fprPath.toFile(), + SSCFileTransferHelper.ISSCAddDownloadTokenFunction.ROUTEPARAM_DOWNLOADTOKEN, + progressWriter); + return fprPath; + } + + /** + * Uploads an enriched DAST FPR to SSC using the HTML upload endpoint. + */ + public static void uploadEnrichedDastFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, + Path enrichedDastFpr, IProgressWriter progressWriter) { + SSCFileTransferHelper.htmlUpload( + unirest, + SSCUrls.UPLOAD_RESULT_FILE(av.getVersionId()), + enrichedDastFpr.toFile(), + SSCFileTransferHelper.ISSCAddUploadTokenFunction.ROUTEPARAM_UPLOADTOKEN, + String.class, + progressWriter + ); + } + + /** + * Uploads an enriched SAST FPR (with updated DAST_CORRELATION_STATUS tags) to SSC. + * Uses the REST artifacts endpoint — same as the audit command — so SSC merges only + * the audit.xml changes without treating the upload as a new scan result. + * Using UPLOAD_RESULT_FILE would cause SSC to reject it as a duplicate scan (error state). + */ + public static void uploadEnrichedSastFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, + Path enrichedSastFpr, IProgressWriter progressWriter) { + SSCFileTransferHelper.restUpload( + unirest, + SSCUrls.PROJECT_VERSION_ARTIFACTS(av.getVersionId()), + enrichedSastFpr.toFile(), + JsonNode.class, + progressWriter + ); + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateFprParser.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateFprParser.java new file mode 100644 index 00000000000..4a34adf6460 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateFprParser.java @@ -0,0 +1,119 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.dast.StreamingWebInspectParser; +import com.fortify.cli.aviator.fpr.FPRProcessor; +import com.fortify.cli.aviator.fpr.Vulnerability; +import com.fortify.cli.aviator.fpr.model.AuditIssue; +import com.fortify.cli.aviator.fpr.processor.AuditProcessor; +import com.fortify.cli.aviator.fpr.processor.StreamingFVDLProcessor; +import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.exception.FcliSimpleException; + +/** + * Encapsulates FPR parsing for the correlate-sast-dast command. + * Handles both SAST (FVDL-based) and DAST (WebInspect-based) FPRs, + * including parser comparison logging for the streaming WebInspect parser. + */ +public final class AviatorSSCCorrelateFprParser { + private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCCorrelateFprParser.class); + + private AviatorSSCCorrelateFprParser() {} + + /** + * Holds the result of parsing either a SAST or DAST FPR. + */ + public static class ParseResult { + public List vulnerabilities = new ArrayList<>(); + public List dastIssues = new ArrayList<>(); + public Map auditIssueMap; + public String buildId; + public String scanGuid; + } + + /** + * Parses a SAST FPR and returns vulnerabilities, audit map, scanGuid, and buildId. + */ + public static ParseResult parseSastFpr(Path sastfpr) { + try (FprHandle fprHandle = new FprHandle(sastfpr)) { + fprHandle.validate(); + if (!Files.exists(fprHandle.getPath("/audit.xml"))) { + throw new FcliSimpleException("SAST FPR does not contain audit.xml"); + } + AuditProcessor auditProcessor = new AuditProcessor(fprHandle); + Map auditIssueMap = auditProcessor.processAuditXML(); + StreamingFVDLProcessor fvdlProcessor = new StreamingFVDLProcessor(fprHandle); + FPRProcessor fprProcessor = new FPRProcessor(fprHandle, auditIssueMap, auditProcessor); + List vulnerabilities = fprProcessor.process(fvdlProcessor); + + ParseResult result = new ParseResult(); + result.vulnerabilities = vulnerabilities; + result.auditIssueMap = auditIssueMap; + if (!vulnerabilities.isEmpty()) { + result.scanGuid = vulnerabilities.get(0).getUuid(); + result.buildId = vulnerabilities.get(0).getBuildId(); + } + return result; + } catch (FcliSimpleException e) { + throw e; + } catch (Exception e) { + throw new FcliSimpleException("Failed to parse SAST FPR: " + e.getMessage(), e); + } + } + + /** + * Parses a DAST FPR using the streaming WebInspect parser. + * Also runs the DOM parser in parallel for comparison logging. + */ + public static ParseResult parseDastFpr(Path dastfpr) { + try (FprHandle fprHandle = new FprHandle(dastfpr)) { + if (!Files.exists(fprHandle.getPath("/webinspect.xml"))) { + throw new FcliSimpleException("DAST FPR does not contain webinspect.xml"); + } + if (!Files.exists(fprHandle.getPath("/audit.xml"))) { + throw new FcliSimpleException("DAST FPR does not contain audit.xml"); + } + AuditProcessor auditProcessor = new AuditProcessor(fprHandle); + Map auditIssueMap = auditProcessor.processAuditXML(); + + StreamingWebInspectParser streamingParser = new StreamingWebInspectParser(fprHandle); + List streamingDastIssues = streamingParser.parse(); + + for (DastIssue issue : streamingDastIssues) { + if (issue.getId() != null && auditIssueMap.containsKey(issue.getId())) { + issue.setSuppressed(auditIssueMap.get(issue.getId()).isSuppressed()); + } + } + + ParseResult result = new ParseResult(); + result.dastIssues = streamingDastIssues; + result.auditIssueMap = auditIssueMap; + return result; + } catch (FcliSimpleException e) { + throw e; + } catch (Exception e) { + throw new FcliSimpleException("Failed to parse DAST FPR: " + e.getMessage(), e); + } + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelper.java new file mode 100644 index 00000000000..0443067165f --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelper.java @@ -0,0 +1,111 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator.fpr.Vulnerability; +import com.fortify.cli.aviator.fpr.model.AuditIssue; +import com.fortify.cli.aviator.grpc.CorrelatedPair; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; + +/** + * Stateless utility helpers for the correlate-sast-dast command: + * output JSON construction, suppression check, and FPR path validation. + */ +public final class AviatorSSCCorrelateHelper { + private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCCorrelateHelper.class); + + private AviatorSSCCorrelateHelper() {} + + /** + * Builds the final JSON output node for the correlate-sast-dast command. + */ + public static ObjectNode buildOutputJson(SSCAppVersionDescriptor av, + String artifactId, + int submitted, + int succeeded, + List newPairs, + String actionResult) { + int correlated = newPairs.size(); + int skipped = submitted - succeeded; + + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); + result.put("id", av.getVersionId()); + result.put("applicationName", av.getApplicationName()); + result.put("versionName", av.getVersionName()); + if (artifactId != null) { + result.put("artifactId", artifactId); + } else { + result.putNull("artifactId"); + } + result.put(IActionCommandResultSupplier.actionFieldName, actionResult); + + ObjectNode operation = result.putObject("operation"); + ObjectNode correlate = operation.putObject("correlate"); + + if (submitted > 0) { + String message = String.format("%d SAST findings submitted, %d correlated pairs confirmed", + submitted, correlated); + correlate.put("message", message); + correlate.put("submitted", submitted); + correlate.put("succeeded", succeeded); + correlate.put("skipped", skipped); + } else { + correlate.putNull("message"); + correlate.putNull("submitted"); + correlate.putNull("succeeded"); + correlate.putNull("skipped"); + } + correlate.put("correlated", correlated); + + return result; + } + + /** + * Returns true if the given vulnerability is marked as suppressed in the audit map. + */ + public static boolean isVulnerabilitySuppressed(Vulnerability vuln, Map auditIssueMap) { + if (auditIssueMap == null || vuln.getInstanceID() == null) { + return false; + } + AuditIssue auditIssue = auditIssueMap.get(vuln.getInstanceID()); + return auditIssue != null && auditIssue.isSuppressed(); + } + + /** + * Validates that the downloaded FPR path is non-null and points to an existing regular file. + */ + public static void validateDownloadedFpr(Path fprPath, String label) { + LOG.debug("Validate Download FPR {}", label); + if (fprPath == null) { + throw new FcliSimpleException(label + " FPR path is null; download may have failed"); + } + if (!Files.exists(fprPath)) { + throw new FcliSimpleException(label + " FPR file does not exist: " + fprPath); + } + if (!Files.isRegularFile(fprPath)) { + throw new FcliSimpleException(label + " FPR path is not a regular file: " + fprPath); + } + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCustomTagHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCustomTagHelper.java index e56357b4832..12c620587d8 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCustomTagHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCustomTagHelper.java @@ -59,8 +59,25 @@ public JsonNode synchronize(AviatorSSCPrepareHelper.PrepareResult result) { private JsonNode verifyAndUpdateExistingTag(AviatorSSCPrepareHelper.PrepareResult result, JsonNode existingTag) { LOG.debug("Found existing tag '{}'. Verifying values.", tagDef.getName()); JsonNode fullTagDetails = unirest.get(SSCUrls.CUSTOM_TAG(existingTag.get("id").asText())).asObject(JsonNode.class).getBody().get("data"); - Set existingValues = JsonHelper.stream((ArrayNode) fullTagDetails.get("valueList")).map(v -> v.get("lookupValue").asText()).collect(Collectors.toSet()); - List missingValues = tagDef.getValues().stream().filter(v -> !existingValues.contains(v)).collect(Collectors.toList()); + + // TEXT-type tags have no predefined valueList — nothing to verify or update + if ("TEXT".equals(tagDef.getValueType())) { + LOG.info("Custom tag '{}' is a TEXT type tag — no value list to verify.", tagDef.getName()); + result.addEntry("Custom Tag", "VERIFIED", "'" + tagDef.getName() + "' (TEXT type) is already present."); + return fullTagDetails; + } + + JsonNode valueListNode = fullTagDetails.get("valueList"); + ArrayNode valueListArray = (valueListNode != null && valueListNode.isArray()) + ? (ArrayNode) valueListNode + : JsonHelper.getObjectMapper().createArrayNode(); + + Set existingValues = JsonHelper.stream(valueListArray) + .map(v -> v.get("lookupValue").asText()) + .collect(Collectors.toSet()); + List missingValues = tagDef.getValues().stream() + .filter(v -> !existingValues.contains(v)) + .collect(Collectors.toList()); if (missingValues.isEmpty()) { LOG.info("Custom tag '{}' is already configured correctly.", tagDef.getName()); @@ -98,14 +115,17 @@ private JsonNode getTagDefinitionForCreate() { tagNode.put("name", tagDef.getName()); tagNode.put("guid", tagDef.getGuid()); tagNode.put("description", "Custom tag for Fortify Aviator."); - tagNode.put("valueType", "LIST"); + tagNode.put("valueType", tagDef.getValueType()); tagNode.put("customTagType", "CUSTOM"); + // LIST tags need a populated valueList; TEXT tags use an empty array ArrayNode values = tagNode.putArray("valueList"); - for (int i = 0; i < tagDef.getValues().size(); i++) { - values.add(JsonHelper.getObjectMapper().createObjectNode() - .put("lookupValue", tagDef.getValues().get(i)) - .put("deletable", true).put("hidden", false).put("seqNumber", i)); + if (!"TEXT".equals(tagDef.getValueType())) { + for (int i = 0; i < tagDef.getValues().size(); i++) { + values.add(JsonHelper.getObjectMapper().createObjectNode() + .put("lookupValue", tagDef.getValues().get(i)) + .put("deletable", true).put("hidden", false).put("seqNumber", i)); + } } return tagNode; } -} \ No newline at end of file +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCPrepareHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCPrepareHelper.java index 38d535aa05a..bbc5a6dc27a 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCPrepareHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCPrepareHelper.java @@ -67,16 +67,30 @@ public PrepareResult prepare(PrepareOptions options) { progress.writeProgress("Synchronizing Aviator custom tags..."); var tagHelperPrediction = new AviatorSSCCustomTagHelper(unirest, AviatorSSCTagDefs.AVIATOR_PREDICTION_TAG); - var tagHelperStatus = new AviatorSSCCustomTagHelper(unirest, AviatorSSCTagDefs.AVIATOR_STATUS_TAG); + var tagHelperStatus = new AviatorSSCCustomTagHelper(unirest, AviatorSSCTagDefs.AVIATOR_STATUS_TAG); + var tagHelperDastCorr = new AviatorSSCCustomTagHelper(unirest, AviatorSSCTagDefs.DAST_CORRELATION_STATUS_TAG); - JsonNode predictionTag = tagHelperPrediction.synchronize(result); - JsonNode statusTag = tagHelperStatus.synchronize(result); + JsonNode predictionTag = tagHelperPrediction.synchronize(result); + JsonNode statusTag = tagHelperStatus.synchronize(result); + JsonNode dastCorrTag = tagHelperDastCorr.synchronize(result); if (predictionTag == null || statusTag == null) { result.addEntry("Global", "HALTED", "Failed to synchronize one or more required Aviator custom tags."); return result; } - List requiredTags = List.of(predictionTag, statusTag); + if (dastCorrTag == null) { + result.addEntry("DAST Correlation Tag", "WARNING", + "Failed to synchronize 'DAST correlation status' tag. SAST-DAST correlation feature may not be fully visible in SSC UI."); + } + + progress.writeProgress("Synchronizing Aviator custom attributes..."); + new AviatorSSCAttributeHelper(unirest, AviatorSSCAttributeDefs.LAST_CORRELATION_ATTR) + .synchronize(result); + + // Build required tags list — include dastCorrTag only if successfully synchronized + List requiredTags = dastCorrTag != null + ? List.of(predictionTag, statusTag, dastCorrTag) + : List.of(predictionTag, statusTag); if (options.isAllIssueTemplates() || options.getIssueTemplateNameOrId() != null) { new AviatorSSCTemplateUpdater(unirest).process(options, result, requiredTags, progress); @@ -88,4 +102,4 @@ public PrepareResult prepare(PrepareOptions options) { } return result; } -} \ No newline at end of file +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCTagDefs.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCTagDefs.java index d2d019e9a5f..b24379388c4 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCTagDefs.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCTagDefs.java @@ -23,12 +23,15 @@ public final class AviatorSSCTagDefs { public static final class TagDefinition { private final String guid; private final String name; + /** SSC valueType: {@code "LIST"} for enum-style tags, {@code "TEXT"} for free-text tags. */ + private final String valueType; private final List values; } public static final TagDefinition AVIATOR_PREDICTION_TAG = new TagDefinition( "C2D6EC66-CCB3-4FB9-9EE0-0BB02F51008F", "Aviator prediction", + "LIST", List.of( "AVIATOR:Not an Issue", "AVIATOR:Remediation Required", "AVIATOR:Unsure", "AVIATOR:Excluded due to Limiting", "AVIATOR:Suspicious", "AVIATOR:Proposed Not an Issue" @@ -38,6 +41,20 @@ public static final class TagDefinition { public static final TagDefinition AVIATOR_STATUS_TAG = new TagDefinition( "FB7B0462-2C2E-46D9-811A-DCC1F3C83051", "Aviator status", + "LIST", List.of("PROCESSED_BY_AVIATOR") ); -} \ No newline at end of file + + /** + * Free-text tag persisting SAST–DAST correlation status per issue. + * Value format: {@code CORRELATED::DAST-1,DAST-2|REJECTED::DAST-3} + * Written by {@code SastFprCorrelationRecorder} into the SAST FPR's audit.xml. + * The GUID must match {@code SastFprCorrelationRecorder.DAST_CORRELATION_TAG_ID}. + */ + public static final TagDefinition DAST_CORRELATION_STATUS_TAG = new TagDefinition( + "7A3B5C9D-1E2F-4A8B-9C0D-E1F2A3B4C5D6", + "DAST correlation status", + "TEXT", + List.of() + ); +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/CategoryBucket.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/CategoryBucket.java new file mode 100644 index 00000000000..bd745d0ca80 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/CategoryBucket.java @@ -0,0 +1,124 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.fpr.Vulnerability; + +import lombok.Getter; + +/** + * Represents a bucket of findings grouped by category. + * Can contain SAST findings, DAST findings, or both. + * + * The bucket uses a canonical category for grouping (via CategoryEquivalence), + * but also tracks the actual SAST and DAST categories for display purposes + * when they differ. + */ +@Getter +public class CategoryBucket { + /** + * The canonical category used for grouping (from CategoryEquivalence). + */ + private final String category; + + /** + * The actual category names present in SAST findings (may differ from canonical). + */ + private final Set sastCategories = new HashSet<>(); + + /** + * The actual category names present in DAST findings (may differ from canonical). + */ + private final Set dastCategories = new HashSet<>(); + + private final List sastFindings = new ArrayList<>(); + private final List dastFindings = new ArrayList<>(); + + public CategoryBucket(String category) { + this.category = category; + } + + public void addSastFinding(Vulnerability vuln, String originalCategory) { + sastFindings.add(vuln); + if (originalCategory != null) { + sastCategories.add(originalCategory); + } + } + + public void addDastFinding(DastIssue issue, String originalCategory) { + dastFindings.add(issue); + if (originalCategory != null) { + dastCategories.add(originalCategory); + } + } + + /** + * Checks if SAST and DAST categories in this bucket differ. + * This is used to determine whether to display both categories in the output. + * + * @return true if SAST and DAST have different category names + */ + public boolean hasDifferentCategories() { + if (sastCategories.isEmpty() || dastCategories.isEmpty()) { + return false; + } + // If there's any SAST category not in DAST categories (or vice versa), they differ + return !sastCategories.equals(dastCategories); + } + + /** + * Gets a display string for the SAST category/categories. + */ + public String getSastCategoryDisplay() { + if (sastCategories.isEmpty()) { + return category; + } + return String.join(", ", sastCategories); + } + + /** + * Gets a display string for the DAST category/categories. + */ + public String getDastCategoryDisplay() { + if (dastCategories.isEmpty()) { + return category; + } + return String.join(", ", dastCategories); + } + + public boolean isSastOnly() { + return !sastFindings.isEmpty() && dastFindings.isEmpty(); + } + + public boolean isDastOnly() { + return sastFindings.isEmpty() && !dastFindings.isEmpty(); + } + + public boolean isMixed() { + return !sastFindings.isEmpty() && !dastFindings.isEmpty(); + } + + public int getSastCount() { + return sastFindings.size(); + } + + public int getDastCount() { + return dastFindings.size(); + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/CategoryEquivalence.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/CategoryEquivalence.java new file mode 100644 index 00000000000..224eadf694c --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/CategoryEquivalence.java @@ -0,0 +1,145 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import java.util.*; + +/** + * Manages category equivalence classes for SAST-DAST correlation. + * + * In most cases, SAST and DAST use the same category names. However, in some cases, + * different category names may refer to the same underlying vulnerability type. + * This class allows defining equivalence classes of categories that should be + * treated as matching candidates during correlation. + * + * Example: "Cross-Frame Scripting" and "HTML5: Missing Frame Protection" are + * different names for related vulnerabilities and should be considered as + * correlation candidates. + */ +public class CategoryEquivalence { + + /** + * List of equivalence classes. Each set contains categories that should be + * treated as equivalent for correlation purposes. + */ + private static final List> EQUIVALENCE_CLASSES = new ArrayList<>(); + + /** + * Map from each category to its canonical (normalized) form. + * Categories not in any equivalence class map to themselves. + */ + private static final Map CANONICAL_MAP = new HashMap<>(); + + static { + // Define equivalence classes here. + // Add new equivalence classes as they are discovered. + + // Frame protection related vulnerabilities + addEquivalenceClass( + "Cross-Frame Scripting", + "HTML5: Missing Framing Protection" + ); + + // Add more equivalence classes below as needed: + // addEquivalenceClass("Category A", "Category B", "Category C"); + } + + /** + * Adds an equivalence class. All provided categories will be treated as + * equivalent for correlation purposes. + * + * @param categories Two or more category names that should be considered equivalent + */ + private static void addEquivalenceClass(String... categories) { + if (categories.length < 2) { + return; + } + + Set equivalenceClass = Set.of(categories); + EQUIVALENCE_CLASSES.add(equivalenceClass); + + // Use the first category as the canonical form + String canonical = categories[0]; + for (String category : categories) { + CANONICAL_MAP.put(category, canonical); + } + } + + /** + * Gets the canonical (normalized) form of a category. + * + * For categories that are part of an equivalence class, this returns the + * canonical representative of that class. For other categories, it returns + * the category itself. + * + * @param category The category to normalize + * @return The canonical form of the category + */ + public static String getCanonical(String category) { + if (category == null) { + return null; + } + return CANONICAL_MAP.getOrDefault(category, category); + } + + /** + * Checks if two categories are equivalent (either identical or in the same + * equivalence class). + * + * @param category1 First category + * @param category2 Second category + * @return true if the categories are equivalent + */ + public static boolean areEquivalent(String category1, String category2) { + if (category1 == null || category2 == null) { + return false; + } + if (category1.equals(category2)) { + return true; + } + String canonical1 = getCanonical(category1); + String canonical2 = getCanonical(category2); + return canonical1.equals(canonical2); + } + + /** + * Checks if a category is part of any equivalence class. + * + * @param category The category to check + * @return true if the category is part of an equivalence class + */ + public static boolean hasEquivalentCategories(String category) { + return category != null && CANONICAL_MAP.containsKey(category); + } + + /** + * Gets all categories that are equivalent to the given category. + * Returns a set containing at least the input category itself. + * + * @param category The category to find equivalents for + * @return Set of equivalent categories (including the input category) + */ + public static Set getEquivalentCategories(String category) { + if (category == null) { + return Set.of(); + } + + for (Set equivalenceClass : EQUIVALENCE_CLASSES) { + if (equivalenceClass.contains(category)) { + return equivalenceClass; + } + } + + return Set.of(category); + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/CategoryGrouper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/CategoryGrouper.java new file mode 100644 index 00000000000..7128f5bd267 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/CategoryGrouper.java @@ -0,0 +1,258 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.fpr.Vulnerability; + +/** + * Groups SAST and DAST findings by category and classifies buckets. + */ +public class CategoryGrouper { + + private static final Logger LOG = LoggerFactory.getLogger(CategoryGrouper.class); + + private final Map buckets = new HashMap<>(); + + /** + * Groups all SAST and DAST findings by their category. + * + * Uses CategoryEquivalence to group findings with equivalent categories + * into the same bucket. For example, "Cross-Frame Scripting" and + * "HTML5: Missing Frame Protection" will be grouped together. + * + * @param sastFindings List of SAST vulnerabilities + * @param dastFindings List of DAST issues + */ + public void groupFindings(List sastFindings, List dastFindings) { + // Group SAST findings by canonical category + for (Vulnerability vuln : sastFindings) { + String originalCategory = getSastCategory(vuln); + if (originalCategory != null && !originalCategory.isEmpty()) { + String canonicalCategory = CategoryEquivalence.getCanonical(originalCategory); + CategoryBucket bucket = buckets.computeIfAbsent(canonicalCategory, CategoryBucket::new); + bucket.addSastFinding(vuln, originalCategory); + } + } + + // Group DAST findings by canonical category + for (DastIssue issue : dastFindings) { + String originalCategory = issue.getCategory(); + if (originalCategory != null && !originalCategory.isEmpty()) { + String canonicalCategory = CategoryEquivalence.getCanonical(originalCategory); + CategoryBucket bucket = buckets.computeIfAbsent(canonicalCategory, CategoryBucket::new); + bucket.addDastFinding(issue, originalCategory); + } + } + } + + /** + * Extracts the category from a SAST vulnerability. + * Uses Type and SubType, combining them with a colon if SubType exists. + */ + private String getSastCategory(Vulnerability vuln) { + String type = vuln.getType(); + String subType = vuln.getSubType(); + + if (type == null || type.isEmpty()) { + return null; + } + + if (subType != null && !subType.isEmpty()) { + return type + ": " + subType; + } + return type; + } + + /** + * Returns all buckets that contain only SAST findings. + */ + public List getSastOnlyBuckets() { + List result = new ArrayList<>(); + for (CategoryBucket bucket : buckets.values()) { + if (bucket.isSastOnly()) { + result.add(bucket); + } + } + return result; + } + + /** + * Returns all buckets that contain only DAST findings. + */ + public List getDastOnlyBuckets() { + List result = new ArrayList<>(); + for (CategoryBucket bucket : buckets.values()) { + if (bucket.isDastOnly()) { + result.add(bucket); + } + } + return result; + } + + /** + * Returns all buckets that contain both SAST and DAST findings. + */ + public List getMixedBuckets() { + List result = new ArrayList<>(); + for (CategoryBucket bucket : buckets.values()) { + if (bucket.isMixed()) { + result.add(bucket); + } + } + return result; + } + + /** + * Get SAST only findings + + */ + + public int getSASTonlyFinding(){ + List sastOnly = getSastOnlyBuckets(); + // Count total findings in SAST-only buckets + int sastOnlyFindings = 0; + for (CategoryBucket bucket : sastOnly) { + sastOnlyFindings += bucket.getSastCount(); + } + return sastOnlyFindings; + } + + /** + * Prints the grouping statistics to the log. + */ + public void printStatistics() { + List sastOnly = getSastOnlyBuckets(); + List dastOnly = getDastOnlyBuckets(); + List mixed = getMixedBuckets(); + + // Count total findings in SAST-only buckets + int sastOnlyFindings = 0; + for (CategoryBucket bucket : sastOnly) { + sastOnlyFindings += bucket.getSastCount(); + } + + // Count total findings in DAST-only buckets + int dastOnlyFindings = 0; + for (CategoryBucket bucket : dastOnly) { + dastOnlyFindings += bucket.getDastCount(); + } + + // Count total findings in mixed buckets (SAST + DAST) + int mixedFindings = 0; + for (CategoryBucket bucket : mixed) { + mixedFindings += bucket.getSastCount() + bucket.getDastCount(); + } + + int totalCategories = sastOnly.size() + dastOnly.size() + mixed.size(); + int totalFindings = sastOnlyFindings + dastOnlyFindings + mixedFindings; + + LOG.info(""); + LOG.info("=== Category Grouping Results ==="); + LOG.info(""); + LOG.info("+-------------------+------------+----------+"); + LOG.info("| Bucket Type | Categories | Findings |"); + LOG.info("+-------------------+------------+----------+"); + LOG.info(String.format("| SAST-only | %10d | %8d |", sastOnly.size(), sastOnlyFindings)); + LOG.info(String.format("| DAST-only | %10d | %8d |", dastOnly.size(), dastOnlyFindings)); + LOG.info(String.format("| Mixed | %10d | %8d |", mixed.size(), mixedFindings)); + LOG.info("+-------------------+------------+----------+"); + LOG.info(String.format("| TOTAL | %10d | %8d |", totalCategories, totalFindings)); + LOG.info("+-------------------+------------+----------+"); + LOG.info(""); + + if (!mixed.isEmpty()) { + printMixedCategoriesTable(mixed); + } + } + + /** + * Prints the mixed categories as a formatted table to the log. + */ + private void printMixedCategoriesTable(List mixed) { + // First, check if any buckets have different SAST/DAST categories + boolean hasEquivalentCategories = false; + for (CategoryBucket bucket : mixed) { + if (bucket.hasDifferentCategories()) { + hasEquivalentCategories = true; + break; + } + } + + // Build display strings and calculate column widths + List categoryDisplays = new ArrayList<>(); + int categoryWidth = "Category".length(); + for (CategoryBucket bucket : mixed) { + String display; + if (bucket.hasDifferentCategories()) { + display = bucket.getSastCategoryDisplay() + " / " + bucket.getDastCategoryDisplay(); + } else { + display = bucket.getCategory(); + } + categoryDisplays.add(display); + categoryWidth = Math.max(categoryWidth, display.length()); + } + + int sastWidth = 6; // "SAST" + padding + int dastWidth = 6; // "DAST" + padding + int totalWidth = 7; // "Total" + padding + + // Build format strings + String rowFormat = "| %-" + categoryWidth + "s | %" + sastWidth + "d | %" + dastWidth + "d | %" + totalWidth + "d |"; + String headerFormat = "| %-" + categoryWidth + "s | %" + sastWidth + "s | %" + dastWidth + "s | %" + totalWidth + "s |"; + + // Build separator line + StringBuilder separator = new StringBuilder("+"); + separator.append("-".repeat(categoryWidth + 2)); + separator.append("+"); + separator.append("-".repeat(sastWidth + 2)); + separator.append("+"); + separator.append("-".repeat(dastWidth + 2)); + separator.append("+"); + separator.append("-".repeat(totalWidth + 2)); + separator.append("+"); + + // Calculate totals + int totalSast = 0; + int totalDast = 0; + for (CategoryBucket bucket : mixed) { + totalSast += bucket.getSastCount(); + totalDast += bucket.getDastCount(); + } + + LOG.info("Mixed categories (correlation candidates):"); + if (hasEquivalentCategories) { + LOG.info("(Categories shown as 'SAST category / DAST category' where they differ)"); + } + LOG.info(separator.toString()); + LOG.info(String.format(headerFormat, "Category", "SAST", "DAST", "Total")); + LOG.info(separator.toString()); + int i = 0; + for (CategoryBucket bucket : mixed) { + int rowTotal = bucket.getSastCount() + bucket.getDastCount(); + LOG.info(String.format(rowFormat, categoryDisplays.get(i), bucket.getSastCount(), bucket.getDastCount(), rowTotal)); + i++; + } + LOG.info(separator.toString()); + LOG.info(String.format(rowFormat, "TOTAL", totalSast, totalDast, totalSast + totalDast)); + LOG.info(separator.toString()); + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/DastFprCorrelationEnricher.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/DastFprCorrelationEnricher.java new file mode 100644 index 00000000000..dd6a0d482ac --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/DastFprCorrelationEnricher.java @@ -0,0 +1,258 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import com.fortify.cli.aviator.grpc.CorrelatedPair; +import com.fortify.cli.aviator.util.FprHandle; + +import lombok.SneakyThrows; + +/** + * Injects {@code } elements into a DAST FPR's + * {@code webinspect.xml} so that SSC can display SAST–DAST correlation links. + * + *

After injection the modified FPR can be uploaded to SSC; the existing + * upload/parse pipeline will read the {@code } and create + * correlation records without any SSC code changes. + */ +public class DastFprCorrelationEnricher { + private static final Logger LOG = LoggerFactory.getLogger(DastFprCorrelationEnricher.class); + private static final String AI_CORRELATION_SESSION_ID = "AI_CORRELATION_METADATA"; + private static final DateTimeFormatter HTTP_DATE_FORMATTER = + DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.ENGLISH); + + /** + * Injects correlation data into the DAST FPR and returns the path to + * the modified file (which is the same file, modified in-place inside + * the zip filesystem). + * + * @param dastFprPath path to the downloaded DAST FPR + * @param confirmedPairs all confirmed correlated pairs (new + previous) + * @return the same {@code dastFprPath}, now containing injected data + */ + @SneakyThrows + public Path injectAndRepackage(Path dastFprPath, List confirmedPairs) { + if (confirmedPairs == null || confirmedPairs.isEmpty()) { + LOG.info("No correlated pairs to inject; returning DAST FPR unchanged."); + return dastFprPath; + } + + // Group pairs by DAST issue ID for efficient lookup + Map> pairsByDastId = groupByDastId(confirmedPairs); + + try (FprHandle fprHandle = new FprHandle(dastFprPath)) { + Path webinspectPath = fprHandle.getPath("/webinspect.xml"); + if (!Files.exists(webinspectPath)) { + LOG.warn("DAST FPR does not contain webinspect.xml; skipping injection."); + return dastFprPath; + } + + Document doc = parseXml(webinspectPath); + int injectedCount = injectAll(doc, pairsByDastId); + upsertCorrelationMetadataSession(doc); + writeXml(doc, webinspectPath); + + LOG.info("Injected ExternalFindings for {} DAST issues ({} total pairs)", + injectedCount, confirmedPairs.size()); + } + + return dastFprPath; + } + + private int injectAll(Document doc, Map> pairsByDastId) { + NodeList issueNodes = doc.getElementsByTagName("Issue"); + int injectedCount = 0; + + for (int i = 0; i < issueNodes.getLength(); i++) { + if (!(issueNodes.item(i) instanceof Element issue)) continue; + + String issueId = issue.getAttribute("id"); + List pairs = pairsByDastId.get(issueId); + if (pairs == null || pairs.isEmpty()) continue; + + // Remove any existing ExternalFindings to avoid duplicates on re-run + removeExistingExternalFindings(issue); + + Element externalFindings = doc.createElement("ExternalFindings"); + String timestamp = OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + + for (CorrelatedPair pair : pairs) { + Element ef = doc.createElement("ExternalFinding"); + ef.setAttribute("Origin", "SCA"); + + appendChildElement(doc, ef, "OriginID", pair.scanGuid()); + appendChildElement(doc, ef, "OriginFindingID", pair.sastInstanceId()); + appendChildElement(doc, ef, "OriginDateTime", timestamp); + + externalFindings.appendChild(ef); + } + + issue.appendChild(externalFindings); + injectedCount++; + } + + return injectedCount; + } + + private void removeExistingExternalFindings(Element issue) { + NodeList existing = issue.getElementsByTagName("ExternalFindings"); + // Collect first, then remove (to avoid ConcurrentModificationException) + List toRemove = new ArrayList<>(); + for (int i = 0; i < existing.getLength(); i++) { + if (existing.item(i).getParentNode() == issue) { + toRemove.add(existing.item(i)); + } + } + for (org.w3c.dom.Node node : toRemove) { + issue.removeChild(node); + } + } + + private void appendChildElement(Document doc, Element parent, String name, String value) { + Element child = doc.createElement(name); + child.setTextContent(value != null ? value : ""); + parent.appendChild(child); + } + + private Map> groupByDastId(List pairs) { + Map> map = new LinkedHashMap<>(); + for (CorrelatedPair pair : pairs) { + map.computeIfAbsent(pair.dastIssueId(), k -> new ArrayList<>()).add(pair); + } + return map; + } + + @SneakyThrows + private Document parseXml(Path path) { + var factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(false); + return factory.newDocumentBuilder().parse(Files.newInputStream(path)); + } + + @SneakyThrows + private void writeXml(Document doc, Path path) { + var transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); + try (OutputStream os = Files.newOutputStream(path)) { + transformer.transform(new DOMSource(doc), new StreamResult(os)); + } + } + + /** + * Appends a synthetic {@code } at the end + * of the document root if one does not already exist, or updates its Date header if it does. + * This session acts as a lightweight metadata marker for when the AI correlation was run, + * allowing SSC to merge without requiring deletion of the prior DAST scan. + */ + private void upsertCorrelationMetadataSession(Document doc) { + String nowHttpDate = OffsetDateTime.now(ZoneOffset.UTC).format(HTTP_DATE_FORMATTER); + Element root = doc.getDocumentElement(); + + // Check if the metadata session already exists — update if so + NodeList sessions = root.getElementsByTagName("Session"); + for (int i = 0; i < sessions.getLength(); i++) { + if (!(sessions.item(i) instanceof Element s)) continue; + if (AI_CORRELATION_SESSION_ID.equals(s.getAttribute("requestId"))) { + updateDateHeader(s, nowHttpDate); + LOG.info("Updated existing AI_CORRELATION_METADATA session Date header to: {}", nowHttpDate); + return; + } + } + + // Not found — append a new synthetic session at the end of the root element + root.appendChild(buildCorrelationMetadataSession(doc, nowHttpDate)); + LOG.info("Appended new AI_CORRELATION_METADATA session with Date: {}", nowHttpDate); + } + + private void updateDateHeader(Element sessionElement, String newDate) { + NodeList headers = sessionElement.getElementsByTagName("Header"); + for (int i = 0; i < headers.getLength(); i++) { + if (!(headers.item(i) instanceof Element header)) continue; + NodeList names = header.getElementsByTagName("Name"); + if (names.getLength() > 0 && "Date".equals(names.item(0).getTextContent())) { + NodeList values = header.getElementsByTagName("Value"); + if (values.getLength() > 0) { + values.item(0).setTextContent(newDate); + } + return; + } + } + } + + private Element buildCorrelationMetadataSession(Document doc, String httpDate) { + Element session = doc.createElement("Session"); + session.setAttribute("requestId", AI_CORRELATION_SESSION_ID); + + // Empty structural elements required by the WebInspect schema + for (String tag : new String[]{"URL", "Scheme", "Host", "Port", "AttackParamDescriptor", "Issues", "RawResponse"}) { + session.appendChild(doc.createElement(tag)); + } + + // RawRequest with matching requestId + Element rawRequest = doc.createElement("RawRequest"); + rawRequest.setAttribute("id", AI_CORRELATION_SESSION_ID); + session.appendChild(rawRequest); + + // Request with all required empty children + Element request = doc.createElement("Request"); + for (String tag : new String[]{"Method", "Path", "File", "Ext", "PageMark", "HTTPVersion", + "FullQuery", "FullPostData", "XMLPostData", "MultiPartPostData", + "RawASCIIPostData", "Cookie", "Queries", "Headers", "Cookies"}) { + request.appendChild(doc.createElement(tag)); + } + session.appendChild(request); + + // Response with Date header + Element response = doc.createElement("Response"); + for (String tag : new String[]{"HTTPVersion", "StatusCode", "StatusDescription", "SetCookie"}) { + response.appendChild(doc.createElement(tag)); + } + Element responseHeaders = doc.createElement("Headers"); + Element header = doc.createElement("Header"); + appendChildElement(doc, header, "Name", "Date"); + appendChildElement(doc, header, "Value", httpDate); + responseHeaders.appendChild(header); + response.appendChild(responseHeaders); + response.appendChild(doc.createElement("SetCookies")); + response.appendChild(doc.createElement("Forms")); + session.appendChild(response); + + return session; + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SastFprCorrelationRecorder.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SastFprCorrelationRecorder.java new file mode 100644 index 00000000000..cb81c50425c --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SastFprCorrelationRecorder.java @@ -0,0 +1,387 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import com.fortify.cli.aviator.grpc.CorrelatedPair; +import com.fortify.cli.aviator.util.FprHandle; + +import lombok.SneakyThrows; + +/** + * Reads and writes the {@code DAST_CORRELATION_STATUS} custom tag inside + * {@code audit.xml} of the SAST FPR. + * + *

The tag persists the outcome of each `(SAST instanceId, DAST issueId)` pairing — + * either CORRELATED or REJECTED — so that subsequent runs can skip pairs that have + * already been tried. + * + *

Tag value format (500-char SSC limit): + *

CORRELATED::DAST-1,DAST-2|REJECTED::DAST-3,DAST-4
+ * + *
    + *
  • Status groups separated by {@code |}
  • + *
  • Each group: {@code STATUS::dastId1,dastId2,...}
  • + *
  • Possible statuses: {@code CORRELATED}, {@code REJECTED}
  • + *
+ */ +public final class SastFprCorrelationRecorder { + + private static final Logger LOG = LoggerFactory.getLogger(SastFprCorrelationRecorder.class); + + /** Fixed UUID used as the tag ID in audit.xml for all DAST correlation status entries. */ + static final String DAST_CORRELATION_TAG_ID = "7A3B5C9D-1E2F-4A8B-9C0D-E1F2A3B4C5D6"; + + private static final String STATUS_CORRELATED = "CORRELATED"; + private static final String STATUS_REJECTED = "REJECTED"; + private static final String NS_AUDIT = "xmlns://www.fortify.com/schema/audit"; + + /** SSC tag value length limit. */ + private static final int MAX_TAG_VALUE_LENGTH = 500; + + private SastFprCorrelationRecorder() {} + + // ─── Read ────────────────────────────────────────────────────────────────── + + /** + * Reads all tried pair keys — both CORRELATED and REJECTED — from the + * {@code DAST_CORRELATION_STATUS} tag in {@code audit.xml} of the SAST FPR. + * + * @param sastFprPath path to the downloaded SAST FPR file + * @return set of {@code "sastInstanceId::dastIssueId"} keys for all already-tried pairs; + * empty set if no tags are present or the audit.xml cannot be read + */ + public static Set readTriedPairKeys(Path sastFprPath) { + Set keys = new HashSet<>(); + try (FprHandle fprHandle = new FprHandle(sastFprPath)) { + Path auditPath = fprHandle.getPath("/audit.xml"); + if (!Files.exists(auditPath)) { + LOG.debug("audit.xml not found in SAST FPR; returning empty tried-pair set."); + return keys; + } + Document doc = parseXml(auditPath); + NodeList issueNodes = doc.getElementsByTagNameNS(NS_AUDIT, "Issue"); + + for (int i = 0; i < issueNodes.getLength(); i++) { + if (!(issueNodes.item(i) instanceof Element issue)) continue; + String instanceId = issue.getAttribute("instanceId"); + if (instanceId == null || instanceId.isEmpty()) continue; + + String tagValue = findCorrelationTagValue(issue); + if (tagValue == null || tagValue.isEmpty()) continue; + + parseTagValue(tagValue).forEach((dastId, status) -> + keys.add(instanceId + "::" + dastId)); + } + } catch (Exception e) { + LOG.warn("Could not read DAST_CORRELATION_STATUS tags from SAST FPR — pairs will be retried: {}", e.getMessage()); + } + LOG.debug("Read {} already-tried pair keys from SAST FPR audit.xml", keys.size()); + return keys; + } + + // ─── Write ───────────────────────────────────────────────────────────────── + + /** + * Merges the new correlation run results into the {@code DAST_CORRELATION_STATUS} + * tags in {@code audit.xml} and writes the updated file back into the SAST FPR ZIP. + * + *

Merge rule: {@code CORRELATED} is sticky — a pair confirmed in any run + * cannot be downgraded to {@code REJECTED} by a later run. + * + * @param sastFprPath path to the SAST FPR file (modified in-place inside the ZIP FS) + * @param confirmedPairs pairs confirmed by this run's gRPC validation + * @param rejectedPairs pairs rejected by this run's gRPC validation + */ + @SneakyThrows + public static void writeCorrelationTags(Path sastFprPath, + List confirmedPairs, + List rejectedPairs) { + if (confirmedPairs.isEmpty() && rejectedPairs.isEmpty()) { + LOG.debug("No new correlation results to write; skipping audit.xml update."); + return; + } + + // Build incoming results map: sastId → (dastId → newStatus) + Map> incoming = buildIncomingMap(confirmedPairs, rejectedPairs); + + LOG.info("writeCorrelationTags: incoming map has {} SAST entries", incoming.size()); + incoming.forEach((sastId, dastMap) -> { + LOG.info(" SAST instanceId: {}", sastId); + dastMap.forEach((dastId, status) -> LOG.info(" {} = {}", dastId, status)); + }); + + try (FprHandle fprHandle = new FprHandle(sastFprPath)) { + Path auditPath = fprHandle.getPath("/audit.xml"); + if (!Files.exists(auditPath)) { + LOG.warn("audit.xml not found in SAST FPR; cannot write DAST_CORRELATION_STATUS tags."); + return; + } + + Document doc = parseXml(auditPath); + NodeList issueNodes = doc.getElementsByTagNameNS(NS_AUDIT, "Issue"); + LOG.info("writeCorrelationTags: found {} existing nodes in audit.xml", issueNodes.getLength()); + + // Track which incoming instanceIds are already in audit.xml + Set existingInstanceIds = new HashSet<>(); + int patchedCount = 0; + + for (int i = 0; i < issueNodes.getLength(); i++) { + if (!(issueNodes.item(i) instanceof Element issue)) continue; + String instanceId = issue.getAttribute("instanceId"); + if (instanceId == null || instanceId.isEmpty()) continue; + existingInstanceIds.add(instanceId); + + Map newEntries = incoming.get(instanceId); + if (newEntries == null || newEntries.isEmpty()) continue; + + String existingValue = findCorrelationTagValue(issue); + Map merged = parseTagValue(existingValue); + mergeEntries(merged, newEntries); + + String newValue = buildTagValue(merged); + if (newValue.length() > MAX_TAG_VALUE_LENGTH) { + LOG.warn("DAST_CORRELATION_STATUS tag value for SAST {} exceeds {} chars; truncating. " + + "Excess DAST IDs will be retried on the next run.", instanceId, MAX_TAG_VALUE_LENGTH); + newValue = truncateTagValue(merged); + } + + upsertCorrelationTag(doc, issue, newValue); + LOG.info("writeCorrelationTags: patched existing instanceId='{}' → value='{}'", instanceId, newValue); + patchedCount++; + } + + // Create new entries for SAST findings that have no existing audit record. + // An issue appears in audit.xml only if it was previously audited (has tags/comments). + // For brand-new findings never touched in the SSC UI, we must add the element. + int createdCount = 0; + // Try namespace-aware lookup first; fall back to no-namespace for un-audited FPRs + // whose audit.xml uses the default (null) namespace. + Element issueList = (Element) doc.getElementsByTagNameNS(NS_AUDIT, "IssueList").item(0); + if (issueList == null) { + issueList = (Element) doc.getElementsByTagName("IssueList").item(0); + } + + for (var entry : incoming.entrySet()) { + String instanceId = entry.getKey(); + if (existingInstanceIds.contains(instanceId)) continue; // already patched above + + if (issueList == null) { + LOG.warn("writeCorrelationTags: not found in audit.xml; cannot create entry for instanceId='{}'", instanceId); + continue; + } + + Map newEntries = entry.getValue(); + String newValue = buildTagValue(newEntries); + if (newValue.length() > MAX_TAG_VALUE_LENGTH) { + newValue = truncateTagValue(newEntries); + } + + // Match the namespace of the parent element + String ns = issueList.getNamespaceURI(); + Element newIssue = (ns != null) + ? doc.createElementNS(ns, "Issue") + : doc.createElement("Issue"); + newIssue.setAttribute("instanceId", instanceId); + newIssue.setAttribute("revision", "0"); + newIssue.setAttribute("suppressed", "false"); + upsertCorrelationTag(doc, newIssue, newValue); + issueList.appendChild(newIssue); + LOG.info("writeCorrelationTags: created new instanceId='{}' → value='{}'", instanceId, newValue); + createdCount++; + } + + writeXml(doc, auditPath); + LOG.info("writeCorrelationTags complete: patched={} existing, created={} new entries (incoming={})", + patchedCount, createdCount, incoming.size()); + } + } + + // ─── Merge helpers ───────────────────────────────────────────────────────── + + private static Map> buildIncomingMap( + List confirmed, List rejected) { + Map> map = new LinkedHashMap<>(); + for (CorrelatedPair p : confirmed) { + map.computeIfAbsent(p.sastInstanceId(), k -> new LinkedHashMap<>()) + .put(p.dastIssueId(), STATUS_CORRELATED); + } + for (CorrelatedPair p : rejected) { + map.computeIfAbsent(p.sastInstanceId(), k -> new LinkedHashMap<>()) + .put(p.dastIssueId(), STATUS_REJECTED); + } + return map; + } + + /** + * Merges {@code newEntries} into {@code existing}. CORRELATED is sticky: + * a CORRELATED entry is never overwritten by REJECTED. + */ + private static void mergeEntries(Map existing, Map newEntries) { + for (var entry : newEntries.entrySet()) { + String dastId = entry.getKey(); + String newStatus = entry.getValue(); + String oldStatus = existing.get(dastId); + if (STATUS_CORRELATED.equals(oldStatus)) continue; // sticky — never downgrade + existing.put(dastId, newStatus); + } + } + + // ─── Tag value encode/decode ──────────────────────────────────────────────── + + /** + * Parses {@code "CORRELATED::D1,D2|REJECTED::D3"} into a {@code Map}. + * Returns an empty map if {@code value} is null or empty. + */ + static Map parseTagValue(String value) { + Map map = new LinkedHashMap<>(); + if (value == null || value.isEmpty()) return map; + for (String group : value.split("\\|")) { + int sep = group.indexOf("::"); + if (sep < 0) continue; + String status = group.substring(0, sep).trim(); + String ids = group.substring(sep + 2).trim(); + for (String dastId : ids.split(",")) { + String trimmed = dastId.trim(); + if (!trimmed.isEmpty()) map.put(trimmed, status); + } + } + return map; + } + + /** + * Encodes a {@code Map} into + * {@code "CORRELATED::D1,D2|REJECTED::D3"} format. + */ + static String buildTagValue(Map dastIdToStatus) { + Map> grouped = new LinkedHashMap<>(); + grouped.put(STATUS_CORRELATED, new ArrayList<>()); + grouped.put(STATUS_REJECTED, new ArrayList<>()); + dastIdToStatus.forEach((dastId, status) -> + grouped.computeIfAbsent(status, k -> new ArrayList<>()).add(dastId)); + + return grouped.entrySet().stream() + .filter(e -> !e.getValue().isEmpty()) + .map(e -> e.getKey() + "::" + String.join(",", e.getValue())) + .collect(Collectors.joining("|")); + } + + /** + * Truncates the merged map so the resulting tag value fits within + * {@link #MAX_TAG_VALUE_LENGTH} characters. Removes entries from the end + * of the REJECTED list first (CORRELATED entries are preserved). + */ + private static String truncateTagValue(Map merged) { + var working = new LinkedHashMap<>(merged); + while (true) { + String candidate = buildTagValue(working); + if (candidate.length() <= MAX_TAG_VALUE_LENGTH) return candidate; + // Remove last REJECTED entry to shrink + String lastRejectedKey = null; + for (var e : working.entrySet()) { + if (STATUS_REJECTED.equals(e.getValue())) lastRejectedKey = e.getKey(); + } + if (lastRejectedKey == null) { + // Fallback: just hard-truncate (should not normally happen) + return candidate.substring(0, MAX_TAG_VALUE_LENGTH); + } + working.remove(lastRejectedKey); + } + } + + // ─── DOM helpers ──────────────────────────────────────────────────────────── + + private static String findCorrelationTagValue(Element issue) { + NodeList tagNodes = issue.getElementsByTagNameNS(NS_AUDIT, "Tag"); + for (int i = 0; i < tagNodes.getLength(); i++) { + if (!(tagNodes.item(i) instanceof Element tag)) continue; + if (DAST_CORRELATION_TAG_ID.equalsIgnoreCase(tag.getAttribute("id"))) { + NodeList values = tag.getElementsByTagNameNS(NS_AUDIT, "Value"); + if (values.getLength() > 0) return values.item(0).getTextContent().trim(); + } + } + return null; + } + + /** + * Upserts the {@code } element on the issue. + * If the tag already exists, its {@code } is updated in-place. + * If not, a new {@code } element is appended to the issue. + */ + private static void upsertCorrelationTag(Document doc, Element issue, String value) { + // Try to find and update existing tag + LOG.info("Upserting Correlation Tag"); + NodeList tagNodes = issue.getElementsByTagNameNS(NS_AUDIT, "Tag"); + for (int i = 0; i < tagNodes.getLength(); i++) { + if (!(tagNodes.item(i) instanceof Element tag)) continue; + if (DAST_CORRELATION_TAG_ID.equalsIgnoreCase(tag.getAttribute("id"))) { + NodeList values = tag.getElementsByTagNameNS(NS_AUDIT, "Value"); + if (values.getLength() > 0) { + values.item(0).setTextContent(value); + } else { + Element val = doc.createElementNS(NS_AUDIT, "Value"); + val.setTextContent(value); + tag.appendChild(val); + } + return; + } + } + // Not found — append new tag with correct namespace + Element tag = doc.createElementNS(NS_AUDIT, "Tag"); + tag.setAttribute("id", DAST_CORRELATION_TAG_ID); + Element val = doc.createElementNS(NS_AUDIT, "Value"); + val.setTextContent(value); + tag.appendChild(val); + issue.appendChild(tag); + } + + @SneakyThrows + private static Document parseXml(Path path) { + var factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); // must be true to find ns0:Issue / ns0:Tag by local name + return factory.newDocumentBuilder().parse(Files.newInputStream(path)); + } + + @SneakyThrows + private static void writeXml(Document doc, Path path) { + var transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); + try (OutputStream os = Files.newOutputStream(path)) { + transformer.transform(new DOMSource(doc), new StreamResult(os)); + } + } +} diff --git a/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties b/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties index b41836fba15..b7445495933 100644 --- a/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties +++ b/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties @@ -156,7 +156,7 @@ fcli.aviator.ssc.apply-remediations.since = Filter artifacts by upload date. Sup or absolute dates (e.g. 2025-01-01, 2025-01-01T10:30:00, 2025-01-01T10:30:00Z). \ Can only be used with --latest or --all; not compatible with --artifact-id. -fcli.aviator.ssc.prepare.usage.header = (PREVIEW) Prepare an SSC instance for Aviator integration. +fcli.aviator.ssc.prepare.usage.header = Prepare an SSC instance for Aviator integration. fcli.aviator.ssc.prepare.usage.description = This command ensures that the Aviator-specific custom tags ('Aviator prediction',`Aviator status`) \ exist in SSC and are associated with the specified issue templates and/or application versions. \ This is necessary to prevent SSC from stripping Aviator audit data from uploaded FPR files. \ @@ -168,6 +168,14 @@ fcli.aviator.ssc.prepare.all-issue-templates = Update all issue templates on the fcli.aviator.ssc.prepare.appversion = Update a single application version by its ID or name (app:version). fcli.aviator.ssc.prepare.all-appversions = Update all application versions on the SSC instance. +# fcli aviator ssc correlate-sast-dast +fcli.aviator.ssc.correlate-sast-dast.usage.header = Correlate SAST and DAST findings for an SSC application version. +fcli.aviator.ssc.correlate-sast-dast.usage.description = Downloads the latest SAST and DAST FPR artifacts from an SSC application version, \ + groups findings by category, streams mixed-category SAST findings to the Aviator server \ + for correlation via gRPC, injects correlation results into the DAST FPR as ExternalFindings, \ + and uploads it back to SSC. Requires an active SSC session and Aviator user session. +fcli.aviator.ssc.correlate-sast-dast.app = SAST Aviator application name to associate with the correlation. If not provided, the SAST/FPR Build ID of the ssc application is used. + #fcli aviator fod fcli.aviator.fod.usage.header = Use SAST Aviator with FoD. fcli.aviator.fod.apply-remediations.usage.header = Apply remediations on the user source code proposed by latest scan for release Id. @@ -203,5 +211,6 @@ fcli.aviator.entitlement.list.output.table.args = id,tenant_name,start_date,end_ fcli.aviator.entitlement.list-sast.output.table.args = id,tenant_name,start_date,end_date,number_of_applications,number_of_developers,contract_id,currently_linked_applications,is_valid fcli.aviator.entitlement.list-dast.output.table.args = id,tenant_name,start_date,end_date,number_of_units,credits_consumed,credits_reserved,credit_adjustments,credits_remaining,contract_id,is_valid fcli.aviator.ssc.apply-remediations.output.table.args = appVersionId,artifactId,artifactsProcessed,artifactsSkipped,totalRemediation,appliedRemediation,skippedRemediation +fcli.aviator.ssc.correlate-sast-dast.output.table.args = applicationName,versionName,sastUnsuppressedCount,dastUnsuppressedCount,mixedCategories,correlatedPairs,__action__ fcli.aviator.ssc.prepare.output.table.args = status,entity,details fcli.aviator.fod.apply-remediations.output.table.args = releaseId,totalRemediation,appliedRemediation,skippedRemediation diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelperTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelperTest.java new file mode 100644 index 00000000000..579aab41ba3 --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelperTest.java @@ -0,0 +1,168 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.aviator.fpr.Vulnerability; +import com.fortify.cli.aviator.fpr.model.AuditIssue; +import com.fortify.cli.aviator.grpc.CorrelatedPair; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; + +class AviatorSSCCorrelateHelperTest { + + @TempDir Path tempDir; + + // ── buildOutputJson ────────────────────────────────────────────────── + + @Test + void testBuildOutputJson_withCorrelatedPairs() { + var av = createAppVersionDescriptor("37", "MyApp", "1.0"); + List pairs = List.of( + new CorrelatedPair("SAST-1", "DAST-1", "scan-guid", "HIGH", "match"), + new CorrelatedPair("SAST-2", "DAST-2", "scan-guid", "MEDIUM", "match") + ); + + var result = AviatorSSCCorrelateHelper.buildOutputJson(av, "artifact-123", 5, 4, pairs, "CORRELATED"); + + assertEquals("37", result.get("id").asText()); + assertEquals("MyApp", result.get("applicationName").asText()); + assertEquals("1.0", result.get("versionName").asText()); + assertEquals("artifact-123", result.get("artifactId").asText()); + assertEquals("CORRELATED", result.get(IActionCommandResultSupplier.actionFieldName).asText()); + + JsonNode correlate = result.get("operation").get("correlate"); + assertEquals(5, correlate.get("submitted").asInt()); + assertEquals(4, correlate.get("succeeded").asInt()); + assertEquals(1, correlate.get("skipped").asInt()); + assertEquals(2, correlate.get("correlated").asInt()); + assertNotNull(correlate.get("message").asText()); + } + + @Test + void testBuildOutputJson_noPairsSubmitted() { + var av = createAppVersionDescriptor("42", "TestApp", "2.0"); + var result = AviatorSSCCorrelateHelper.buildOutputJson(av, null, 0, 0, List.of(), "SKIPPED"); + + assertTrue(result.get("artifactId").isNull()); + assertEquals("SKIPPED", result.get(IActionCommandResultSupplier.actionFieldName).asText()); + + JsonNode correlate = result.get("operation").get("correlate"); + assertTrue(correlate.get("message").isNull()); + assertTrue(correlate.get("submitted").isNull()); + assertTrue(correlate.get("succeeded").isNull()); + assertTrue(correlate.get("skipped").isNull()); + assertEquals(0, correlate.get("correlated").asInt()); + } + + // ── isVulnerabilitySuppressed ──────────────────────────────────────── + + @Test + void testIsVulnerabilitySuppressed_true() { + var vuln = Vulnerability.builder().instanceID("INST-1").build(); + var auditIssue = AuditIssue.builder().instanceId("INST-1").suppressed(true).build(); + Map auditMap = Map.of("INST-1", auditIssue); + + assertTrue(AviatorSSCCorrelateHelper.isVulnerabilitySuppressed(vuln, auditMap)); + } + + @Test + void testIsVulnerabilitySuppressed_false() { + var vuln = Vulnerability.builder().instanceID("INST-1").build(); + var auditIssue = AuditIssue.builder().instanceId("INST-1").suppressed(false).build(); + Map auditMap = Map.of("INST-1", auditIssue); + + assertFalse(AviatorSSCCorrelateHelper.isVulnerabilitySuppressed(vuln, auditMap)); + } + + @Test + void testIsVulnerabilitySuppressed_notInMap() { + var vuln = Vulnerability.builder().instanceID("INST-999").build(); + Map auditMap = Map.of(); + + assertFalse(AviatorSSCCorrelateHelper.isVulnerabilitySuppressed(vuln, auditMap)); + } + + @Test + void testIsVulnerabilitySuppressed_nullMap() { + var vuln = Vulnerability.builder().instanceID("INST-1").build(); + assertFalse(AviatorSSCCorrelateHelper.isVulnerabilitySuppressed(vuln, null)); + } + + @Test + void testIsVulnerabilitySuppressed_nullInstanceId() { + var vuln = Vulnerability.builder().instanceID(null).build(); + Map auditMap = new HashMap<>(); + assertFalse(AviatorSSCCorrelateHelper.isVulnerabilitySuppressed(vuln, auditMap)); + } + + // ── validateDownloadedFpr ──────────────────────────────────────────── + + @Test + void testValidateDownloadedFpr_validFile() throws IOException { + Path fpr = tempDir.resolve("test.fpr"); + Files.writeString(fpr, "dummy content"); + AviatorSSCCorrelateHelper.validateDownloadedFpr(fpr, "SAST"); + } + + @Test + void testValidateDownloadedFpr_nullPath() { + var ex = assertThrows(FcliSimpleException.class, + () -> AviatorSSCCorrelateHelper.validateDownloadedFpr(null, "SAST")); + assertTrue(ex.getMessage().contains("null")); + } + + @Test + void testValidateDownloadedFpr_nonExistentFile() { + Path nonExistent = tempDir.resolve("does-not-exist.fpr"); + var ex = assertThrows(FcliSimpleException.class, + () -> AviatorSSCCorrelateHelper.validateDownloadedFpr(nonExistent, "DAST")); + assertTrue(ex.getMessage().contains("does not exist")); + } + + @Test + void testValidateDownloadedFpr_directory() throws IOException { + Path dir = tempDir.resolve("adir"); + Files.createDirectory(dir); + var ex = assertThrows(FcliSimpleException.class, + () -> AviatorSSCCorrelateHelper.validateDownloadedFpr(dir, "SAST")); + assertTrue(ex.getMessage().contains("not a regular file")); + } + + // ── helpers ────────────────────────────────────────────────────────── + + private SSCAppVersionDescriptor createAppVersionDescriptor(String id, String appName, String versionName) { + var descriptor = new SSCAppVersionDescriptor(); + descriptor.setVersionId(id); + descriptor.setApplicationName(appName); + descriptor.setVersionName(versionName); + return descriptor; + } +} diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/CategoryBucketTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/CategoryBucketTest.java new file mode 100644 index 00000000000..2133c183fc0 --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/CategoryBucketTest.java @@ -0,0 +1,147 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.fpr.Vulnerability; + +class CategoryBucketTest { + + @Test + void testNewBucket_isEmpty() { + var bucket = new CategoryBucket("SQL Injection"); + assertEquals("SQL Injection", bucket.getCategory()); + assertTrue(bucket.getSastFindings().isEmpty()); + assertTrue(bucket.getDastFindings().isEmpty()); + assertEquals(0, bucket.getSastCount()); + assertEquals(0, bucket.getDastCount()); + } + + @Test + void testSastOnly() { + var bucket = new CategoryBucket("SQL Injection"); + bucket.addSastFinding(createVuln("INST-1"), "SQL Injection"); + + assertTrue(bucket.isSastOnly()); + assertFalse(bucket.isDastOnly()); + assertFalse(bucket.isMixed()); + assertEquals(1, bucket.getSastCount()); + assertEquals(0, bucket.getDastCount()); + } + + @Test + void testDastOnly() { + var bucket = new CategoryBucket("SQL Injection"); + bucket.addDastFinding(createDastIssue("DAST-1", "SQL Injection"), "SQL Injection"); + + assertFalse(bucket.isSastOnly()); + assertTrue(bucket.isDastOnly()); + assertFalse(bucket.isMixed()); + assertEquals(0, bucket.getSastCount()); + assertEquals(1, bucket.getDastCount()); + } + + @Test + void testMixed() { + var bucket = new CategoryBucket("SQL Injection"); + bucket.addSastFinding(createVuln("INST-1"), "SQL Injection"); + bucket.addDastFinding(createDastIssue("DAST-1", "SQL Injection"), "SQL Injection"); + + assertFalse(bucket.isSastOnly()); + assertFalse(bucket.isDastOnly()); + assertTrue(bucket.isMixed()); + assertEquals(1, bucket.getSastCount()); + assertEquals(1, bucket.getDastCount()); + } + + @Test + void testHasDifferentCategories_sameSastAndDast() { + var bucket = new CategoryBucket("SQL Injection"); + bucket.addSastFinding(createVuln("INST-1"), "SQL Injection"); + bucket.addDastFinding(createDastIssue("DAST-1", "SQL Injection"), "SQL Injection"); + + assertFalse(bucket.hasDifferentCategories()); + } + + @Test + void testHasDifferentCategories_differentSastAndDast() { + var bucket = new CategoryBucket("Cross-Frame Scripting"); + bucket.addSastFinding(createVuln("INST-1"), "Cross-Frame Scripting"); + bucket.addDastFinding(createDastIssue("DAST-1", "HTML5: Missing Framing Protection"), + "HTML5: Missing Framing Protection"); + + assertTrue(bucket.hasDifferentCategories()); + } + + @Test + void testHasDifferentCategories_noSast() { + var bucket = new CategoryBucket("SQL Injection"); + bucket.addDastFinding(createDastIssue("DAST-1", "SQL Injection"), "SQL Injection"); + assertFalse(bucket.hasDifferentCategories()); + } + + @Test + void testHasDifferentCategories_noDast() { + var bucket = new CategoryBucket("SQL Injection"); + bucket.addSastFinding(createVuln("INST-1"), "SQL Injection"); + assertFalse(bucket.hasDifferentCategories()); + } + + @Test + void testCategoryDisplay_sameCategories() { + var bucket = new CategoryBucket("SQL Injection"); + bucket.addSastFinding(createVuln("INST-1"), "SQL Injection"); + bucket.addDastFinding(createDastIssue("DAST-1", "SQL Injection"), "SQL Injection"); + + assertEquals("SQL Injection", bucket.getSastCategoryDisplay()); + assertEquals("SQL Injection", bucket.getDastCategoryDisplay()); + } + + @Test + void testCategoryDisplay_emptyFallsBackToCanonical() { + var bucket = new CategoryBucket("SQL Injection"); + assertEquals("SQL Injection", bucket.getSastCategoryDisplay()); + assertEquals("SQL Injection", bucket.getDastCategoryDisplay()); + } + + @Test + void testMultipleFindings() { + var bucket = new CategoryBucket("SQL Injection"); + bucket.addSastFinding(createVuln("INST-1"), "SQL Injection"); + bucket.addSastFinding(createVuln("INST-2"), "SQL Injection"); + bucket.addDastFinding(createDastIssue("DAST-1", "SQL Injection"), "SQL Injection"); + bucket.addDastFinding(createDastIssue("DAST-2", "SQL Injection"), "SQL Injection"); + bucket.addDastFinding(createDastIssue("DAST-3", "SQL Injection"), "SQL Injection"); + + assertEquals(2, bucket.getSastCount()); + assertEquals(3, bucket.getDastCount()); + assertTrue(bucket.isMixed()); + } + + private Vulnerability createVuln(String instanceId) { + return Vulnerability.builder().instanceID(instanceId).build(); + } + + private DastIssue createDastIssue(String id, String category) { + var issue = new DastIssue(); + issue.setId(id); + issue.setCategory(category); + return issue; + } +} diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/CategoryEquivalenceTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/CategoryEquivalenceTest.java new file mode 100644 index 00000000000..8032242715f --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/CategoryEquivalenceTest.java @@ -0,0 +1,110 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; + +import org.junit.jupiter.api.Test; + +class CategoryEquivalenceTest { + + @Test + void testGetCanonical_equivalentCategory() { + String canonical = CategoryEquivalence.getCanonical("HTML5: Missing Framing Protection"); + assertEquals("Cross-Frame Scripting", canonical, + "Equivalent category should map to the canonical form"); + } + + @Test + void testGetCanonical_alreadyCanonical() { + String canonical = CategoryEquivalence.getCanonical("Cross-Frame Scripting"); + assertEquals("Cross-Frame Scripting", canonical); + } + + @Test + void testGetCanonical_unknownCategory() { + String canonical = CategoryEquivalence.getCanonical("SQL Injection"); + assertEquals("SQL Injection", canonical, + "Unknown category should map to itself"); + } + + @Test + void testGetCanonical_null() { + assertNull(CategoryEquivalence.getCanonical(null)); + } + + @Test + void testAreEquivalent_sameCategory() { + assertTrue(CategoryEquivalence.areEquivalent("SQL Injection", "SQL Injection")); + } + + @Test + void testAreEquivalent_equivalentCategories() { + assertTrue(CategoryEquivalence.areEquivalent( + "Cross-Frame Scripting", "HTML5: Missing Framing Protection")); + } + + @Test + void testAreEquivalent_differentCategories() { + assertFalse(CategoryEquivalence.areEquivalent("SQL Injection", "Cross-Site Scripting")); + } + + @Test + void testAreEquivalent_nullInput() { + assertFalse(CategoryEquivalence.areEquivalent(null, "SQL Injection")); + assertFalse(CategoryEquivalence.areEquivalent("SQL Injection", null)); + assertFalse(CategoryEquivalence.areEquivalent(null, null)); + } + + @Test + void testHasEquivalentCategories_true() { + assertTrue(CategoryEquivalence.hasEquivalentCategories("Cross-Frame Scripting")); + assertTrue(CategoryEquivalence.hasEquivalentCategories("HTML5: Missing Framing Protection")); + } + + @Test + void testHasEquivalentCategories_false() { + assertFalse(CategoryEquivalence.hasEquivalentCategories("SQL Injection")); + } + + @Test + void testHasEquivalentCategories_null() { + assertFalse(CategoryEquivalence.hasEquivalentCategories(null)); + } + + @Test + void testGetEquivalentCategories_known() { + Set equivalents = CategoryEquivalence.getEquivalentCategories("Cross-Frame Scripting"); + assertEquals(2, equivalents.size()); + assertTrue(equivalents.contains("Cross-Frame Scripting")); + assertTrue(equivalents.contains("HTML5: Missing Framing Protection")); + } + + @Test + void testGetEquivalentCategories_unknown() { + Set equivalents = CategoryEquivalence.getEquivalentCategories("SQL Injection"); + assertEquals(1, equivalents.size()); + assertTrue(equivalents.contains("SQL Injection")); + } + + @Test + void testGetEquivalentCategories_null() { + Set equivalents = CategoryEquivalence.getEquivalentCategories(null); + assertTrue(equivalents.isEmpty()); + } +} diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/CategoryGrouperTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/CategoryGrouperTest.java new file mode 100644 index 00000000000..2b7195ea263 --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/CategoryGrouperTest.java @@ -0,0 +1,204 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.fpr.Vulnerability; + +class CategoryGrouperTest { + + @Test + void testGroupFindings_singleMixedCategory() { + var grouper = new CategoryGrouper(); + List sast = List.of( + createVuln("INST-1", "SQL Injection", null) + ); + List dast = List.of( + createDastIssue("DAST-1", "SQL Injection") + ); + + grouper.groupFindings(sast, dast); + + assertEquals(0, grouper.getSastOnlyBuckets().size()); + assertEquals(0, grouper.getDastOnlyBuckets().size()); + assertEquals(1, grouper.getMixedBuckets().size()); + assertEquals("SQL Injection", grouper.getMixedBuckets().get(0).getCategory()); + } + + @Test + void testGroupFindings_sastOnlyAndDastOnly() { + var grouper = new CategoryGrouper(); + List sast = List.of( + createVuln("INST-1", "SQL Injection", null) + ); + List dast = List.of( + createDastIssue("DAST-1", "Cross-Site Scripting") + ); + + grouper.groupFindings(sast, dast); + + assertEquals(1, grouper.getSastOnlyBuckets().size()); + assertEquals(1, grouper.getDastOnlyBuckets().size()); + assertEquals(0, grouper.getMixedBuckets().size()); + } + + @Test + void testGroupFindings_equivalentCategories() { + var grouper = new CategoryGrouper(); + List sast = List.of( + createVuln("INST-1", "Cross-Frame Scripting", null) + ); + List dast = List.of( + createDastIssue("DAST-1", "HTML5: Missing Framing Protection") + ); + + grouper.groupFindings(sast, dast); + + assertEquals(0, grouper.getSastOnlyBuckets().size()); + assertEquals(0, grouper.getDastOnlyBuckets().size()); + assertEquals(1, grouper.getMixedBuckets().size(), + "Equivalent categories should be grouped into a single mixed bucket"); + + var mixedBucket = grouper.getMixedBuckets().get(0); + assertEquals(1, mixedBucket.getSastCount()); + assertEquals(1, mixedBucket.getDastCount()); + assertTrue(mixedBucket.hasDifferentCategories(), + "Equivalent but differently-named categories should be flagged"); + } + + @Test + void testGroupFindings_emptyLists() { + var grouper = new CategoryGrouper(); + grouper.groupFindings(List.of(), List.of()); + + assertEquals(0, grouper.getSastOnlyBuckets().size()); + assertEquals(0, grouper.getDastOnlyBuckets().size()); + assertEquals(0, grouper.getMixedBuckets().size()); + } + + @Test + void testGroupFindings_nullCategory() { + var grouper = new CategoryGrouper(); + List sast = List.of( + createVuln("INST-1", null, null) + ); + List dast = List.of( + createDastIssue("DAST-1", null) + ); + + grouper.groupFindings(sast, dast); + + assertEquals(0, grouper.getSastOnlyBuckets().size()); + assertEquals(0, grouper.getDastOnlyBuckets().size()); + assertEquals(0, grouper.getMixedBuckets().size(), + "Findings with null category should be skipped"); + } + + @Test + void testGroupFindings_emptyCategory() { + var grouper = new CategoryGrouper(); + List sast = List.of( + createVuln("INST-1", "", null) + ); + List dast = List.of( + createDastIssue("DAST-1", "") + ); + + grouper.groupFindings(sast, dast); + + assertEquals(0, grouper.getSastOnlyBuckets().size()); + assertEquals(0, grouper.getDastOnlyBuckets().size()); + assertEquals(0, grouper.getMixedBuckets().size(), + "Findings with empty category should be skipped"); + } + + @Test + void testGroupFindings_multipleCategories() { + var grouper = new CategoryGrouper(); + List sast = List.of( + createVuln("INST-1", "SQL Injection", null), + createVuln("INST-2", "SQL Injection", null), + createVuln("INST-3", "Cross-Site Scripting", "Reflected") + ); + List dast = List.of( + createDastIssue("DAST-1", "SQL Injection"), + createDastIssue("DAST-2", "Path Traversal") + ); + + grouper.groupFindings(sast, dast); + + assertEquals(1, grouper.getSastOnlyBuckets().size(), "XSS should be SAST-only"); + assertEquals(1, grouper.getDastOnlyBuckets().size(), "Path Traversal should be DAST-only"); + assertEquals(1, grouper.getMixedBuckets().size(), "SQL Injection should be mixed"); + + var mixedBucket = grouper.getMixedBuckets().get(0); + assertEquals(2, mixedBucket.getSastCount()); + assertEquals(1, mixedBucket.getDastCount()); + } + + @Test + void testGroupFindings_typeAndSubType() { + var grouper = new CategoryGrouper(); + List sast = List.of( + createVuln("INST-1", "Cross-Site Scripting", "Reflected") + ); + List dast = List.of( + createDastIssue("DAST-1", "Cross-Site Scripting: Reflected") + ); + + grouper.groupFindings(sast, dast); + + assertEquals(1, grouper.getMixedBuckets().size(), + "SAST Type+SubType should match DAST 'Type: SubType' category"); + } + + @Test + void testGetSASTonlyFinding() { + var grouper = new CategoryGrouper(); + List sast = List.of( + createVuln("INST-1", "SQL Injection", null), + createVuln("INST-2", "Cross-Site Scripting", null), + createVuln("INST-3", "Cross-Site Scripting", null) + ); + List dast = List.of( + createDastIssue("DAST-1", "SQL Injection") + ); + + grouper.groupFindings(sast, dast); + + assertEquals(2, grouper.getSASTonlyFinding(), + "Should count only SAST findings in SAST-only buckets"); + } + + private Vulnerability createVuln(String instanceId, String type, String subType) { + return Vulnerability.builder() + .instanceID(instanceId) + .type(type) + .subType(subType) + .build(); + } + + private DastIssue createDastIssue(String id, String category) { + var issue = new DastIssue(); + issue.setId(id); + issue.setCategory(category); + return issue; + } +} diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/SastFprCorrelationRecorderTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/SastFprCorrelationRecorderTest.java new file mode 100644 index 00000000000..619ab9d3863 --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/SastFprCorrelationRecorderTest.java @@ -0,0 +1,224 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.aviator.grpc.CorrelatedPair; + +class SastFprCorrelationRecorderTest { + + private static final String SCAN_GUID = "test-scan-guid"; + + @TempDir Path tempDir; + + // ── parseTagValue ──────────────────────────────────────────────────── + + @Test + void testParseTagValue_correlatedAndRejected() { + var map = SastFprCorrelationRecorder.parseTagValue("CORRELATED::D1,D2|REJECTED::D3"); + assertEquals(3, map.size()); + assertEquals("CORRELATED", map.get("D1")); + assertEquals("CORRELATED", map.get("D2")); + assertEquals("REJECTED", map.get("D3")); + } + + @Test + void testParseTagValue_correlatedOnly() { + var map = SastFprCorrelationRecorder.parseTagValue("CORRELATED::D1,D2"); + assertEquals(2, map.size()); + assertEquals("CORRELATED", map.get("D1")); + assertEquals("CORRELATED", map.get("D2")); + } + + @Test + void testParseTagValue_rejectedOnly() { + var map = SastFprCorrelationRecorder.parseTagValue("REJECTED::D3,D4"); + assertEquals(2, map.size()); + assertEquals("REJECTED", map.get("D3")); + assertEquals("REJECTED", map.get("D4")); + } + + @Test + void testParseTagValue_null() { + var map = SastFprCorrelationRecorder.parseTagValue(null); + assertTrue(map.isEmpty()); + } + + @Test + void testParseTagValue_empty() { + var map = SastFprCorrelationRecorder.parseTagValue(""); + assertTrue(map.isEmpty()); + } + + @Test + void testParseTagValue_malformedNoSeparator() { + var map = SastFprCorrelationRecorder.parseTagValue("CORRELATED_D1"); + assertTrue(map.isEmpty(), "Malformed tag value without :: separator should produce empty map"); + } + + // ── buildTagValue ──────────────────────────────────────────────────── + + @Test + void testBuildTagValue_correlatedAndRejected() { + Map map = new java.util.LinkedHashMap<>(); + map.put("D1", "CORRELATED"); + map.put("D2", "CORRELATED"); + map.put("D3", "REJECTED"); + + String value = SastFprCorrelationRecorder.buildTagValue(map); + assertEquals("CORRELATED::D1,D2|REJECTED::D3", value); + } + + @Test + void testBuildTagValue_correlatedOnly() { + Map map = new java.util.LinkedHashMap<>(); + map.put("D1", "CORRELATED"); + + String value = SastFprCorrelationRecorder.buildTagValue(map); + assertEquals("CORRELATED::D1", value); + } + + @Test + void testBuildTagValue_emptyMap() { + String value = SastFprCorrelationRecorder.buildTagValue(Map.of()); + assertEquals("", value); + } + + // ── roundtrip ──────────────────────────────────────────────────────── + + @Test + void testRoundtrip_parseAndBuild() { + String original = "CORRELATED::D1,D2|REJECTED::D3,D4"; + var parsed = SastFprCorrelationRecorder.parseTagValue(original); + String rebuilt = SastFprCorrelationRecorder.buildTagValue(parsed); + // Re-parse to check semantic equality (order may differ) + var reParsed = SastFprCorrelationRecorder.parseTagValue(rebuilt); + assertEquals(parsed, reParsed); + } + + // ── writeCorrelationTags + readTriedPairKeys (integration) ─────────── + + @Test + void testWriteAndReadCorrelationTags() throws Exception { + Path fprPath = createMinimalSastFpr(); + + List confirmed = List.of( + new CorrelatedPair("SAST-1", "DAST-A", SCAN_GUID, "HIGH", "match"), + new CorrelatedPair("SAST-1", "DAST-B", SCAN_GUID, "MEDIUM", "match") + ); + List rejected = List.of( + new CorrelatedPair("SAST-1", "DAST-C", SCAN_GUID, "LOW", "no match"), + new CorrelatedPair("SAST-2", "DAST-A", SCAN_GUID, "LOW", "no match") + ); + + SastFprCorrelationRecorder.writeCorrelationTags(fprPath, confirmed, rejected); + + Set triedKeys = SastFprCorrelationRecorder.readTriedPairKeys(fprPath); + assertEquals(4, triedKeys.size()); + assertTrue(triedKeys.contains("SAST-1::DAST-A")); + assertTrue(triedKeys.contains("SAST-1::DAST-B")); + assertTrue(triedKeys.contains("SAST-1::DAST-C")); + assertTrue(triedKeys.contains("SAST-2::DAST-A")); + } + + @Test + void testWriteCorrelationTags_emptyLists() throws Exception { + Path fprPath = createMinimalSastFpr(); + + // Should not throw + SastFprCorrelationRecorder.writeCorrelationTags(fprPath, List.of(), List.of()); + + Set triedKeys = SastFprCorrelationRecorder.readTriedPairKeys(fprPath); + assertTrue(triedKeys.isEmpty()); + } + + @Test + void testWriteCorrelationTags_mergePreservesCorrelated() throws Exception { + Path fprPath = createMinimalSastFpr(); + + // Run 1: SAST-1 ↔ DAST-A confirmed + SastFprCorrelationRecorder.writeCorrelationTags(fprPath, + List.of(new CorrelatedPair("SAST-1", "DAST-A", SCAN_GUID, "HIGH", "confirmed")), + List.of()); + + // Run 2: Try to reject SAST-1 ↔ DAST-A (should be ignored — CORRELATED is sticky) + SastFprCorrelationRecorder.writeCorrelationTags(fprPath, + List.of(), + List.of(new CorrelatedPair("SAST-1", "DAST-A", SCAN_GUID, "LOW", "rejected"))); + + // The pair should still be present in tried keys + Set triedKeys = SastFprCorrelationRecorder.readTriedPairKeys(fprPath); + assertTrue(triedKeys.contains("SAST-1::DAST-A")); + } + + @Test + void testReadTriedPairKeys_noAuditXml() throws Exception { + Path fprPath = createFprWithoutAuditXml(); + Set keys = SastFprCorrelationRecorder.readTriedPairKeys(fprPath); + assertTrue(keys.isEmpty()); + } + + // ── helpers ────────────────────────────────────────────────────────── + + private Path createMinimalSastFpr() throws Exception { + Path fprPath = tempDir.resolve("test-sast.fpr"); + if (Files.exists(fprPath)) { + Files.delete(fprPath); + } + + Map zipProps = Map.of("create", "true"); + try (FileSystem zipFs = FileSystems.newFileSystem(fprPath, zipProps)) { + Path auditXmlDest = zipFs.getPath("/audit.xml"); + String auditXml = """ + + + + TestProject + + + + """; + Files.writeString(auditXmlDest, auditXml); + } + + return fprPath; + } + + private Path createFprWithoutAuditXml() throws Exception { + Path fprPath = tempDir.resolve("no-audit.fpr"); + if (Files.exists(fprPath)) { + Files.delete(fprPath); + } + + Map zipProps = Map.of("create", "true"); + try (FileSystem zipFs = FileSystems.newFileSystem(fprPath, zipProps)) { + Path dummyFile = zipFs.getPath("/dummy.txt"); + Files.writeString(dummyFile, "placeholder"); + } + + return fprPath; + } +} diff --git a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactHelper.java b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactHelper.java index 42aa99f3091..54dd41c8b4f 100644 --- a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactHelper.java +++ b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactHelper.java @@ -187,6 +187,92 @@ private static boolean isUploadDateOnOrAfter(JsonNode artifact, OffsetDateTime c } } + /** + * Get the latest SAST artifact for an application version. + * Identifies SAST artifacts by checking embedded scan type "SCA". + * + * @param unirest UnirestInstance + * @param appVersionId Application version ID + * @return SSCArtifactDescriptor of the most recent SAST artifact + * @throws FcliSimpleException if no SAST artifacts found + */ + public static final SSCArtifactDescriptor getLatestSASTArtifact(UnirestInstance unirest, String appVersionId) { + return getLatestArtifactByScanType(unirest, appVersionId, "SCA", "SAST"); + } + + /** + * Get the latest DAST artifact for an application version. + * Identifies DAST artifacts by checking embedded scan type "WEBINSPECT". + * + * @param unirest UnirestInstance + * @param appVersionId Application version ID + * @return SSCArtifactDescriptor of the most recent DAST artifact + * @throws FcliSimpleException if no DAST artifacts found + */ + public static final SSCArtifactDescriptor getLatestDASTArtifact(UnirestInstance unirest, String appVersionId) { + return getLatestArtifactByScanType(unirest, appVersionId, "WEBINSPECT", "DAST"); + } + + /** + * Get the latest artifact for an application version matching the given scan type. + * Artifacts are fetched in DESC order by uploadDate and scanned for a matching + * embedded scan type. The first matching artifact is returned. + * + * @param unirest UnirestInstance + * @param appVersionId Application version ID + * @param scanType SSC scan type value (e.g., "SCA", "WEBINSPECT") + * @param displayLabel Human-readable label for error messages (e.g., "SAST", "DAST") + * @return SSCArtifactDescriptor of the most recent matching artifact + * @throws FcliSimpleException if no matching artifacts found + */ + private static SSCArtifactDescriptor getLatestArtifactByScanType(UnirestInstance unirest, String appVersionId, + String scanType, String displayLabel) { + int start = 0; + int pageSize = 50; + + while (true) { + JsonNode response = unirest.get(SSCUrls.PROJECT_VERSION_ARTIFACTS(appVersionId)) + .queryString("orderby", "uploadDate DESC") + .queryString("start", start) + .queryString("limit", pageSize) + .queryString("embed", "scans") + .asObject(JsonNode.class) + .getBody(); + + JsonNode data = response.get("data"); + if (data == null || !data.isArray() || data.isEmpty()) { break; } + + for (JsonNode artifact : data) { + if (hasEmbeddedScanType(artifact, scanType)) { + return getDescriptor(artifact); + } + } + + int totalCount = response.path("count").asInt(0); + start += pageSize; + if (start >= totalCount) { break; } + } + + throw new FcliSimpleException( + "No " + displayLabel + " artifacts found for application version ID: " + appVersionId); + } + + /** + * Check if an artifact has an embedded scan of the given type. + * SSC embeds scan metadata under _embed.scans[]; each scan has a "type" field + * with values like "SCA" (SAST), "WEBINSPECT" (DAST), "SECURITYSCOPE" (Runtime). + */ + private static boolean hasEmbeddedScanType(JsonNode artifact, String scanType) { + JsonNode scans = artifact.path("_embed").path("scans"); + if (!scans.isArray()) { return false; } + for (JsonNode scan : scans) { + if (scanType.equalsIgnoreCase(scan.path("type").asText(""))) { + return true; + } + } + return false; + } + private static String buildNoArtifactsMessage(String appVersionId, OffsetDateTime sinceDate) { String base = "No Aviator-processed artifacts found for application version ID: " + appVersionId; if (sinceDate != null) {