Skip to content

Commit f8f562e

Browse files
authored
Merge branch 'fortify:dev/v3.x' into develop
2 parents e8accce + c47cad6 commit f8f562e

38 files changed

+774
-124
lines changed

.release-please-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "3.5.1"
2+
".": "3.6.0"
33
}

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
# Changelog
22

3+
## [3.6.0](https://github.com/fortify/fcli/compare/v3.5.2...v3.6.0) (2025-06-14)
4+
5+
6+
### Features
7+
8+
* `*-sast-report` actions: Add `--source-dir` option to allow for matching Fortify-reported source file paths against repository file paths (fixes [#749](https://github.com/fortify/fcli/issues/749)) ([775c5a3](https://github.com/fortify/fcli/commit/775c5a32ca65c435dd77d5eb760ddfda70796c95))
9+
* `ci` actions: Automatically pass `--source-dir` option to SAST report actions (fixes [#749](https://github.com/fortify/fcli/issues/749)) ([775c5a3](https://github.com/fortify/fcli/commit/775c5a32ca65c435dd77d5eb760ddfda70796c95))
10+
* `fcli fod`: New `fcli fod oss list-components` command (resolves [#244](https://github.com/fortify/fcli/issues/244)) ([775c5a3](https://github.com/fortify/fcli/commit/775c5a32ca65c435dd77d5eb760ddfda70796c95))
11+
12+
13+
### Bug Fixes
14+
15+
* `fcli fod sast-scan setup`: Allow assessment type to be specified by Id or Name (resolves [#738](https://github.com/fortify/fcli/issues/738)) ([775c5a3](https://github.com/fortify/fcli/commit/775c5a32ca65c435dd77d5eb760ddfda70796c95))
16+
* `fcli fod`: Fix issue with page handling in REST responses, potentially causing issues if more than 9 pages of results are available on FoD ([775c5a3](https://github.com/fortify/fcli/commit/775c5a32ca65c435dd77d5eb760ddfda70796c95))
17+
18+
## [3.5.2](https://github.com/fortify/fcli/compare/v3.5.1...v3.5.2) (2025-06-05)
19+
20+
21+
### Bug Fixes
22+
23+
* `fcli aviator`: Handle 0-byte and corrupted ZIP entries during FPR processing ([3140991](https://github.com/fortify/fcli/commit/31409913b33456b4515100572ab8d029a02cd4a0))
24+
325
## [3.5.1](https://github.com/fortify/fcli/compare/v3.5.0...v3.5.1) (2025-05-22)
426

527

fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerHelper.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ public static final Object getValueAsObject(ActionRunnerVars vars, TemplateExpre
4848

4949
public static final JsonNode getValueAsJsonNode(ActionRunnerVars vars, TemplateExpressionWithFormatter templateExpressionWithFormatter) {
5050
var rawValue = getValueAsObject(vars, templateExpressionWithFormatter);
51-
return rawValue==null ? null : JsonHelper.getObjectMapper().valueToTree(rawValue);
51+
if ( rawValue==null ) { return null; }
52+
if ( rawValue instanceof JsonNode ) { return (JsonNode)rawValue; }
53+
return JsonHelper.getObjectMapper().valueToTree(rawValue);
5254
}
5355

5456
public static final Object formatValueAsObject(ActionRunnerContext ctx, ActionRunnerVars vars, TemplateExpressionWithFormatter templateExpressionWithFormatter) {

fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerVars.java

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525

2626
import com.fasterxml.jackson.databind.JsonNode;
2727
import com.fasterxml.jackson.databind.ObjectMapper;
28+
import com.fasterxml.jackson.databind.ObjectWriter;
29+
import com.fasterxml.jackson.databind.SerializationFeature;
2830
import com.fasterxml.jackson.databind.node.ArrayNode;
2931
import com.fasterxml.jackson.databind.node.ObjectNode;
3032
import com.fasterxml.jackson.databind.node.TextNode;
@@ -45,6 +47,7 @@
4547
public final class ActionRunnerVars {
4648
private static final Logger LOG = LoggerFactory.getLogger(ActionRunnerVars.class);
4749
private static final ObjectMapper objectMapper = JsonHelper.getObjectMapper();
50+
private static final ObjectWriter debugObjectWriter = new ObjectMapper().configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false).writerWithDefaultPrettyPrinter();
4851
private static final String GLOBAL_VAR_NAME = "global";
4952
private static final String CLI_OPTIONS_VAR_NAME = "cli";
5053
private static final String[] PROTECTED_VAR_NAMES = {GLOBAL_VAR_NAME, CLI_OPTIONS_VAR_NAME};
@@ -134,15 +137,15 @@ public final void set(String name, JsonNode value) {
134137
getter = globalValues::get;
135138
}
136139
var finalName = name; // Needed for lambda below
137-
logDebug(()->String.format("Set %s: %s", finalName, value==null ? null : value.toPrettyString()));
140+
logDebug(()->String.format("Set %s: %s", finalName, toDebugString(value)));
138141
_set(finalName, value, getter, setter);
139142
}
140143

141144
/**
142145
* Set a variable on this instance only
143146
*/
144147
public final void setLocal(String name, JsonNode value) {
145-
logDebug(()->String.format("Set Local %s: %s", name, value==null ? null : value.toPrettyString()));
148+
logDebug(()->String.format("Set Local %s: %s", name, toDebugString(value)));
146149
_set(name, value, values::get, values::set);
147150
}
148151

@@ -244,4 +247,12 @@ private static final void logDebug(Supplier<String> messageSupplier) {
244247
LOG.debug(messageSupplier.get());
245248
}
246249
}
250+
251+
private static final String toDebugString(JsonNode value) {
252+
try {
253+
return value==null ? null : debugObjectWriter.writeValueAsString(value);
254+
} catch ( Exception e ) {
255+
return "<ERROR FORMATTING VALUE>";
256+
}
257+
}
247258
}

fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionSpelFunctions.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040

4141
import com.fasterxml.jackson.databind.node.ArrayNode;
4242
import com.fasterxml.jackson.databind.node.ObjectNode;
43+
import com.fasterxml.jackson.databind.node.POJONode;
4344
import com.formkiq.graalvm.annotations.Reflectable;
4445
import com.fortify.cli.common.action.helper.ActionLoaderHelper;
4546
import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionSource;
@@ -48,6 +49,7 @@
4849
import com.fortify.cli.common.json.JSONDateTimeConverter;
4950
import com.fortify.cli.common.json.JsonHelper;
5051
import com.fortify.cli.common.util.EnvHelper;
52+
import com.fortify.cli.common.util.IssueSourceFileResolver;
5153
import com.fortify.cli.common.util.StringUtils;
5254

5355
import lombok.NoArgsConstructor;
@@ -432,6 +434,13 @@ public static final ArrayNode properties(ObjectNode o) {
432434
return result;
433435
}
434436

437+
public static final POJONode issueSourceFileResolver(Map<String,String> config) {
438+
var sourceDir = config.get("sourceDir");
439+
var builder = IssueSourceFileResolver.builder().sourcePath(StringUtils.isBlank(sourceDir) ? null : Path.of(sourceDir) );
440+
// TODO Update builder based on other config properties
441+
return new POJONode(builder.build());
442+
}
443+
435444
public static final String copyright() {
436445
return String.format("Copyright (c) %s Open Text", Year.now().getValue());
437446
}

fcli-core/fcli-common/src/main/java/com/fortify/cli/common/rest/unirest/URIHelper.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public static final URI addOrReplaceParam(URI uri, String param, Object newValue
3838
// Note that this only works for simple parameter names, not parameter names that
3939
// contain characters that have a special meaning in regex. For example, if param
4040
// contains a dot like in a.c=value, we'd also match and remove abc=value.
41-
var pattern = String.format("^%s=[^&]+&?|&%s=[^&]", param, param);
41+
var pattern = String.format("^%s=[^&]*+&?|&%s=[^&]*", param, param);
4242
var query = uri.getQuery();
4343
if (StringUtils.isNotBlank(query)) { query = query.replaceAll(pattern, ""); }
4444
var newParamAndValue = String.format("%s=%s", param, URLEncoder.encode(newValue.toString(), StandardCharsets.UTF_8));

fcli-core/fcli-common/src/main/java/com/fortify/cli/common/util/FileUtils.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,4 +238,17 @@ public static final boolean isFilePathInUse(Path path) {
238238
}
239239
return false;
240240
}
241+
242+
/**
243+
* Convert the given path to a string, using the given separator character.
244+
* If given path is null, null will be returned.
245+
*/
246+
public static final String pathToString(Path path, char separatorChar) {
247+
if ( path==null ) { return null; }
248+
StringBuilder result = new StringBuilder();
249+
path.iterator().forEachRemaining(part -> result.append(separatorChar).append(part));
250+
if ( !path.isAbsolute() ) { result.deleteCharAt(0); } // Remove leading separator character if path is not absolute
251+
// TODO If original path contains a drive letter, should we include this?
252+
return result.toString();
253+
}
241254
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/**
2+
* Copyright 2023 Open Text.
3+
*
4+
* The only warranties for products and services of Open Text
5+
* and its affiliates and licensors ("Open Text") are as may
6+
* be set forth in the express warranty statements accompanying
7+
* such products and services. Nothing herein should be construed
8+
* as constituting an additional warranty. Open Text shall not be
9+
* liable for technical or editorial errors or omissions contained
10+
* herein. The information contained herein is subject to change
11+
* without notice.
12+
*/
13+
package com.fortify.cli.common.util;
14+
15+
import java.io.File;
16+
import java.nio.file.Files;
17+
import java.nio.file.Path;
18+
import java.util.Collection;
19+
import java.util.HashMap;
20+
import java.util.Map;
21+
import java.util.concurrent.atomic.AtomicReference;
22+
import java.util.stream.Stream;
23+
24+
import org.slf4j.Logger;
25+
import org.slf4j.LoggerFactory;
26+
27+
import com.formkiq.graalvm.annotations.Reflectable;
28+
29+
import lombok.Builder;
30+
import lombok.Getter;
31+
import lombok.RequiredArgsConstructor;
32+
import lombok.SneakyThrows;
33+
34+
/**
35+
* <p>Due to the way that Fortify packages and scans code, source file paths returned by FoD and SSC
36+
* may not exactly match original source file paths. In some cases, a leading path may be added
37+
* by Fortify, like /scancentral12345678/work/..., whereas in other cases, leading paths may be
38+
* stripped, like removing src/main/java from a path like src/main/java/my/pkg/Class.java</p>
39+
*
40+
* <p>This may lead to user confusion, and may cause issues when importing reports generated by
41+
* fcli actions like gitlab-sast-report or github-sast-report into the respective 3rd-party systems,
42+
* as these systems will then be unable to match the file path on which an issue is being reported
43+
* against an actual repository path.</p>
44+
*
45+
* <p>To work around these issues, this class attempts to map Fortify-provided issue source file
46+
* paths against local file system paths relative to a given source code directory, basically
47+
* finding the longest matching path suffix.</p>
48+
*
49+
* @author Ruud Senden
50+
*/
51+
// TODO Also see TODO comments in IssueSourceFileResolverTest
52+
// TODO How to handle absolute paths? Always return as-is (or null if not existing and OnNoMatch.NULL)? Or try to resolve against sourcePath?
53+
// TODO How to handle drive letters (with either absolute or relative path)?
54+
@Builder @Reflectable
55+
public class IssueSourceFileResolver {
56+
private static final Logger LOG = LoggerFactory.getLogger(IssueSourceFileResolver.class);
57+
@Getter private final Path sourcePath;
58+
@Builder.Default private final OnNoMatch onNoMatch = OnNoMatch.ORIGINAL;
59+
@Builder.Default private final FileSeparator separatorOnReturn = FileSeparator.LINUX;
60+
private final AtomicReference<SourceFileIndex> indexReference = new AtomicReference<>();
61+
62+
/**
63+
* Find the given {@link Path} in the configured source path. Based on the Fortify behavior
64+
* described in the class description, if the configured source path contains a single file
65+
* <code>src/main/java/com/fortify/X.java</code>, this method will return a relative {@link Path}
66+
* representing <code>src/main/java/com/fortify/X.java</code> for each of the following input paths:
67+
* <ul>
68+
* <li><code>scancentral123/work/src/main/java/com/fortify/X.java</code></li>
69+
* <li><code>any/leading/dir/src/main/java/com/fortify/X.java</code></li>
70+
* <li><code>src/main/java/com/fortify/X.java</code></li>
71+
* <li><code>main/java/com/fortify/X.java</code></li>
72+
* <li><code>java/com/fortify/X.java</code></li>
73+
* <li><code>com/fortify/X.java</code></li>
74+
* <li><code>fortify/X.java</code></li>
75+
* <li><code>X.java</code></li>
76+
* </ul>
77+
* Any other input path will be considered a non-matching path, in which case either the given
78+
* path or <code>null</code> will be returned, based on the configured {@link OnNoMatch} setting.
79+
* In particular, note that if the given path includes a leading directory, it will only match
80+
* if any of its sub-paths match the full relative source path. With the example above, this
81+
* means that <code>scancentral123/work/com/fortify/X.java</code> will be considered non-matching,
82+
* as it lacks the <code>src/main/java</code> path.
83+
*/
84+
public final Path resolve(Path issuePath) {
85+
var result = indexReference.updateAndGet(this::createIndexIfNull).resolve(issuePath);
86+
if ( result!=null && LOG.isTraceEnabled() ) { LOG.trace("Resolved issue path {} to source path {}", issuePath, result); }
87+
if ( result==null && onNoMatch==OnNoMatch.ORIGINAL ) {
88+
result = issuePath;
89+
}
90+
return result;
91+
}
92+
93+
/**
94+
* This is the {@link String}-based variant of {@link #resolve(Path)}:
95+
* <ol>
96+
* <li>Convert the given {@link String} into a {@link Path} instance</li>
97+
* <li>Call the {@link #resolve(Path)} method to find this path in the configured source path</li>
98+
* <li>Convert the {@link Path} instance returned by {@link #resolve(Path)} into a {@link String},
99+
* using the configured {@link #separatorOnReturn} file separator.</li>
100+
* </ol>
101+
*/
102+
public final String resolve(String issuePath) {
103+
return FileUtils.pathToString(resolve(Path.of(issuePath)), separatorOnReturn.getSeparatorChar());
104+
}
105+
106+
private final SourceFileIndex createIndexIfNull(SourceFileIndex index) {
107+
if ( index==null ) {
108+
index = new SourceFileIndex(sourcePath);
109+
}
110+
return index;
111+
}
112+
113+
public static enum OnNoMatch {
114+
NULL, ORIGINAL
115+
}
116+
117+
@RequiredArgsConstructor
118+
public static enum FileSeparator {
119+
LINUX('/'), WINDOWS('\\'), PLATFORM(File.separatorChar);
120+
121+
@Getter private final char separatorChar;
122+
}
123+
124+
private static final class SourceFileIndex {
125+
/** Full relative paths, like src/main/java/com/fortify/X.java */
126+
private final Map<String,Path> fullRelativePathsIndex;
127+
/** Full and partial relative paths, like src/main/java/com/fortify/X.java, com/fortify/X.java, X.java */
128+
private final Map<String,Path> fullAndPartialRelativePathsIndex;
129+
130+
private SourceFileIndex(Path sourcePath) {
131+
this.fullRelativePathsIndex = createFullRelativePathsIndex(sourcePath);
132+
this.fullAndPartialRelativePathsIndex = createFullAndPartialRelativePathsIndex(fullRelativePathsIndex.values());
133+
}
134+
135+
/**
136+
* <p>This method first tries to match the given path against {@link #fullAndPartialRelativePathsIndex}, which, given a
137+
* source path <code>src/main/java/com/fortify/X.java</code>, will match each of the following input paths:
138+
* <code>X.java</code>, <code>com/fortify/X.java</code>, and <code>src/main/java/com/fortify/X.java</code>. This handles
139+
* situations where Fortify strips leading directories.</p>
140+
*
141+
* <p>If no match is found in {@link #fullAndPartialRelativePathsIndex}, this method will then attempt to match any of
142+
* the sub-paths in the given path against {@link #fullRelativePathsIndex}, which, given a source path
143+
* <code>src/main/java/com/fortify/X.java</code>, will match an input path like
144+
* <code>any/leading/dir/src/main/java/com/fortify/X.java</code>, but not <code>any/leading/dir/com/fortify/X.java</code>.</p>
145+
*/
146+
protected final Path resolve(Path path) {
147+
var normalizedPath = path.normalize();
148+
var result = fullAndPartialRelativePathsIndex.get(pathToString(normalizedPath));
149+
return result!=null ? result : resolveSubPathFromFullRelativePathsIndex(path);
150+
}
151+
152+
private Path resolveSubPathFromFullRelativePathsIndex(Path normalizedPath) {
153+
var result = fullRelativePathsIndex.get(pathToString(normalizedPath));
154+
if ( result==null ) {
155+
var nameCount = normalizedPath.getNameCount();
156+
if ( nameCount > 1 ) {
157+
return resolveSubPathFromFullRelativePathsIndex(normalizedPath.subpath(1, nameCount));
158+
}
159+
}
160+
return result;
161+
}
162+
163+
@SneakyThrows
164+
private static final Map<String, Path> createFullRelativePathsIndex(Path sourcePath) {
165+
var result = new HashMap<String, Path>();
166+
if ( sourcePath!=null && Files.isDirectory(sourcePath) ) {
167+
var normalizedSourcePath = sourcePath.normalize();
168+
try (Stream<Path> stream = Files.walk(normalizedSourcePath)) {
169+
stream.filter(Files::isRegularFile)
170+
.forEach(p->addPathToFullRelativePathsIndex(result, normalizedSourcePath.relativize(p.normalize())));
171+
}
172+
}
173+
return result;
174+
}
175+
176+
private static final void addPathToFullRelativePathsIndex(Map<String, Path> result, Path fullRelativePath) {
177+
if ( LOG.isTraceEnabled() ) { LOG.trace("Adding source path {} to index", fullRelativePath); }
178+
result.put(pathToString(fullRelativePath), fullRelativePath);
179+
}
180+
181+
private static final Map<String, Path> createFullAndPartialRelativePathsIndex(Collection<Path> fullRelativePaths) {
182+
var result = new HashMap<String, Path>();
183+
fullRelativePaths.forEach(p->addPathToFullAndPartialRelativePathsIndex(result, p, p));
184+
return result;
185+
}
186+
187+
private static final void addPathToFullAndPartialRelativePathsIndex(Map<String, Path> index, Path fullRelativePath, Path partialRelativePath) {
188+
index.put(pathToString(partialRelativePath), fullRelativePath);
189+
var nameCount = partialRelativePath.getNameCount();
190+
if ( nameCount > 1 ) {
191+
addPathToFullAndPartialRelativePathsIndex(index, fullRelativePath, partialRelativePath.subpath(1, nameCount));
192+
}
193+
}
194+
195+
private static final String pathToString(Path normalizedPath) {
196+
return FileUtils.pathToString(normalizedPath, '/');
197+
}
198+
}
199+
}

fcli-core/fcli-common/src/main/resources/com/fortify/cli/common/actions/zip/ci-vars.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ steps:
2222
global.ci.qualifiedRepoName: # Fully qualified repository name
2323
global.ci.sourceBranch: # The current branch being processed/scanned
2424
global.ci.commitSHA: # Commit SHA for current commit
25+
global.ci.sourceDir: # Directory containing source files to be scanned
2526
# The following are set by default at the end, but may be overridden by individual CI configurations
2627
global.fod.prCommentAction: # FoD PR comment action
2728
global.ssc.prCommentAction: # SSC PR comment action
@@ -40,6 +41,7 @@ steps:
4041
global.ci.qualifiedRepoName: ${#env('GITHUB_REPOSITORY')}
4142
global.ci.sourceBranch: ${#env('GITHUB_HEAD_REF')?:#env('GITHUB_REF_NAME')}
4243
global.ci.commitSHA: ${#env('GITHUB_SHA')}
44+
global.ci.sourceDir: ${#env('SOURCE_DIR')?:#env('GITHUB_WORKSPACE')?:'.'}
4345

4446
# GitLab
4547
- if: ${#env('GITLAB_CI')=='true'}
@@ -49,6 +51,7 @@ steps:
4951
global.ci.qualifiedRepoName: ${#env('CI_REPOSITORY_URL').replaceAll('[^:]+://[^/]+/','').replaceAll('\.git$', '')}
5052
global.ci.sourceBranch: ${#env('CI_COMMIT_BRANCH')?:#env('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME')}
5153
global.ci.commitSHA: ${#env('CI_COMMIT_SHA')}
54+
global.ci.sourceDir: ${#env('SOURCE_DIR')?:#env('CI_PROJECT_DIR')?:'.'}
5255

5356
# Azure DevOps
5457
- if: ${#isNotBlank(#env('Build.Repository.Name'))}

fcli-core/fcli-common/src/test/java/com/fortify/cli/common/rest/unirest/URIHelperTest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ public class URIHelperTest {
3333
"?o=5&oo=5,?oo=5&o=10",
3434
"?oo=5&o=5&oo=7,?oo=5&oo=7&o=10",
3535
"?oo=5&o=5&o=6&oo=7,?oo=5&oo=7&o=10",
36+
"?oo=5&o=50&o=60&oo=7,?oo=5&oo=7&o=10",
37+
"?oo=5&o=500&o=600&oo=7,?oo=5&oo=7&o=10",
3638
})
3739
public void testAddOrReplaceParam(String input, String expected) throws Exception {
3840
if ( input==null ) { input = ""; }

0 commit comments

Comments
 (0)