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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -229,20 +229,20 @@ public String render(Vulnerability vuln, ReplacementData replacementData, boolea
private static class ParagraphElement extends FancyElement {
private final List<Stringable> altChildren = new ArrayList<>();

ParagraphElement(String content) {
ParagraphElement(String content, int depth) {
String altParagraphTag = "<AltParagraph>";
int altStart = content.indexOf(altParagraphTag);
if (altStart != -1) {
String mainContent = content.substring(0, altStart);
this.children.addAll(FvdlParser.parse(mainContent));
this.children.addAll(FvdlParser.parse(mainContent, depth));

int altEnd = content.indexOf("</AltParagraph>", altStart);
if (altEnd != -1) {
String altContent = content.substring(altStart + altParagraphTag.length(), altEnd);
this.altChildren.addAll(FvdlParser.parse(altContent));
this.altChildren.addAll(FvdlParser.parse(altContent, depth));
}
} else {
this.children.addAll(FvdlParser.parse(content));
this.children.addAll(FvdlParser.parse(content, depth));
}
}

Expand All @@ -269,9 +269,9 @@ public String render(Vulnerability vuln, ReplacementData replacementData, boolea
private static class IfDefElement extends FancyElement {
private final String var;

IfDefElement(String attributes, String content) {
IfDefElement(String attributes, String content, int depth) {
this.var = FvdlParser.parseAttribute("var", attributes);
this.children.addAll(FvdlParser.parse(content));
this.children.addAll(FvdlParser.parse(content, depth));
}

@Override
Expand All @@ -289,9 +289,9 @@ public String render(Vulnerability vuln, ReplacementData replacementData, boolea
private static class IfNotDefElement extends FancyElement {
private final String var;

IfNotDefElement(String attributes, String content) {
IfNotDefElement(String attributes, String content, int depth) {
this.var = FvdlParser.parseAttribute("var", attributes);
this.children.addAll(FvdlParser.parse(content));
this.children.addAll(FvdlParser.parse(content, depth));
}

@Override
Expand All @@ -309,9 +309,9 @@ public String render(Vulnerability vuln, ReplacementData replacementData, boolea
private static class ConditionalTextElement extends FancyElement {
private final String condition;

ConditionalTextElement(String attributes, String content) {
ConditionalTextElement(String attributes, String content, int depth) {
this.condition = FvdlParser.parseAttribute("condition", attributes);
this.children.addAll(FvdlParser.parse(content));
this.children.addAll(FvdlParser.parse(content, depth));
}

@Override
Expand Down Expand Up @@ -340,6 +340,9 @@ private static final class FvdlParser {
private static final String REPLACE_TAG = "Replace ";
private static final String REPLACE_END_TAG = "/>";

// Guards against StackOverflowError on deeply nested tags in untrusted FPR input
private static final int MAX_NESTING_DEPTH = 100;


public static String parseAndRender(String text, Vulnerability vuln, ReplacementData data) {
if (text == null || text.isEmpty()) {
Expand All @@ -359,7 +362,16 @@ public static String parseAndRender(String text, Vulnerability vuln, Replacement
}

public static List<Stringable> parse(String text) {
return parse(text, 0);
}

public static List<Stringable> parse(String text, int depth) {
List<Stringable> elements = new ArrayList<>();
if (depth >= MAX_NESTING_DEPTH) {
logger.warn("Maximum description nesting depth of {} exceeded; rendering remaining content as plain text", MAX_NESTING_DEPTH);
elements.add(new TextElement(text));
return elements;
}
int cursor = 0;

while (cursor < text.length()) {
Expand Down Expand Up @@ -413,7 +425,7 @@ public static List<Stringable> parse(String text) {
String fullAttributes = text.substring(contentStart-1, closingTagStart);
String content = text.substring(closingTagStart + 1, contentEnd);

elements.add(createStructuredElement(startTag, fullAttributes, content));
elements.add(createStructuredElement(startTag, fullAttributes, content, depth + 1));

cursor = contentEnd + endTag.length();
tagFound = true;
Expand All @@ -433,16 +445,16 @@ public static List<Stringable> parse(String text) {
return elements;
}

private static Stringable createStructuredElement(String tag, String attributes, String content) {
private static Stringable createStructuredElement(String tag, String attributes, String content, int depth) {
switch (tag) {
case "Paragraph>":
return new ParagraphElement(content);
return new ParagraphElement(content, depth);
case "IfDef ":
return new IfDefElement(attributes, content);
return new IfDefElement(attributes, content, depth);
case "IfNotDef ":
return new IfNotDefElement(attributes, content);
return new IfNotDefElement(attributes, content, depth);
case "ConditionalText ":
return new ConditionalTextElement(attributes, content);
return new ConditionalTextElement(attributes, content, depth);
default:
logger.warn("Unknown structured tag type: {}", tag);
return new TextElement(""); // Should not happen
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ public final boolean isEnvelope() {
return getOrDefault(RecordWriterStyleElementGroup.ENVELOPE)==RecordWriterStyleElement.envelope;
}

/** Indicates whether CSV values that spreadsheet applications could interpret as formulas should be escaped. Defaults to csv-escape. */
public final boolean isCsvEscape() {
return getOrDefault(RecordWriterStyleElementGroup.CSV_ESCAPE)==RecordWriterStyleElement.csv_escape;
}

private final RecordWriterStyleElement getOrDefault(RecordWriterStyleElementGroup group) {
return styleElementsByGroup.getOrDefault(group, group.defaultStyle());
}
Expand All @@ -119,7 +124,8 @@ public static enum RecordWriterStyleElement {
md_border(RecordWriterStyleElementGroup.BORDER),
wrap(RecordWriterStyleElementGroup.WRAP), no_wrap(RecordWriterStyleElementGroup.WRAP),
fast_output(RecordWriterStyleElementGroup.FAST_OUTPUT), no_fast_output(RecordWriterStyleElementGroup.FAST_OUTPUT),
envelope(RecordWriterStyleElementGroup.ENVELOPE), no_envelope(RecordWriterStyleElementGroup.ENVELOPE)
envelope(RecordWriterStyleElementGroup.ENVELOPE), no_envelope(RecordWriterStyleElementGroup.ENVELOPE),
csv_escape(RecordWriterStyleElementGroup.CSV_ESCAPE), no_csv_escape(RecordWriterStyleElementGroup.CSV_ESCAPE)
;

@Getter private final RecordWriterStyleElementGroup group;
Expand All @@ -142,7 +148,8 @@ public static enum RecordWriterStyleElementGroup {
BORDER("no-border"),
WRAP("wrap"),
FAST_OUTPUT("fast-output"),
ENVELOPE("no-envelope");
ENVELOPE("no-envelope"),
CSV_ESCAPE("csv-escape");

private final String defaultStyleElementName;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
import java.io.Writer;
import java.util.function.Function;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonGenerator.Feature;
import com.fasterxml.jackson.core.SerializableString;
import com.fasterxml.jackson.core.TreeNode;
import com.fasterxml.jackson.core.util.JsonGeneratorDelegate;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.dataformat.csv.CsvFactory;
Expand All @@ -29,11 +33,11 @@

// TODO This class uses a Jackson generator, so should we extend from AbstractRecordWriterJackson?
@RequiredArgsConstructor
public class RecordWriterCsv extends AbstractRecordWriter<CsvGenerator> {
public class RecordWriterCsv extends AbstractRecordWriter<JsonGenerator> {
@Getter private final RecordWriterConfig config;

@Override
protected void append(CsvGenerator out, ObjectNode formattedRecord) throws IOException {
protected void append(JsonGenerator out, ObjectNode formattedRecord) throws IOException {
out.writeTree(formattedRecord);
}

Expand All @@ -44,7 +48,7 @@ protected Function<ObjectNode, ObjectNode> createRecordFormatter(ObjectNode obje
}

@Override
protected void close(CsvGenerator out) throws IOException {
protected void close(JsonGenerator out) throws IOException {
if ( config.getStyle().isArray() ) { out.writeEndArray(); }
out.close();
}
Expand All @@ -55,7 +59,7 @@ protected void closeWithNoData(Writer writer) throws IOException {
}

@Override
protected CsvGenerator createOut(Writer writer, ObjectNode formattedRecord) throws IOException {
protected JsonGenerator createOut(Writer writer, ObjectNode formattedRecord) throws IOException {
if ( formattedRecord==null ) { return null; }
CsvSchema.Builder schemaBuilder = CsvSchema.builder();
formattedRecord.fieldNames().forEachRemaining(schemaBuilder::addColumn);
Expand All @@ -68,6 +72,55 @@ protected CsvGenerator createOut(Writer writer, ObjectNode formattedRecord) thro
if ( config.getStyle().isArray() ) {
result.writeStartArray();
}
return result;
// Escape formula-triggering values as they're written, without mutating the input ObjectNode
return config.getStyle().isCsvEscape() ? new FormulaEscapingGenerator(result) : result;
}

/**
* Prevents CSV formula injection (CWE-1236) by prefixing values starting with a formula-trigger
* character with a single quote, applied as Jackson writes each value to the underlying generator.
* Unlike pre-scanning and mutating the record's {@link ObjectNode}, this also covers non-textual
* node values (e.g. {@code POJONode}) that still get serialized as strings, leaves a potentially
* shared {@link ObjectNode} untouched, and avoids a separate pass over record fields.
*/
private static final class FormulaEscapingGenerator extends JsonGeneratorDelegate {
// Leading characters that spreadsheet applications interpret as the start of a formula
private static final String FORMULA_TRIGGER_CHARS = "=+-@\t\r\n";

FormulaEscapingGenerator(JsonGenerator delegate) {
super(delegate);
}

@Override
public void writeTree(TreeNode rootNode) throws IOException {
// Invoke the codec with 'this' as target (JsonGenerator's default writeTree() behavior),
// instead of JsonGeneratorDelegate's default of forwarding to the delegate and bypassing
// the writeString() overrides below.
if ( rootNode==null ) {
writeNull();
} else {
getCodec().writeValue(this, rootNode);
}
}

@Override
public void writeString(String text) throws IOException {
super.writeString(escape(text));
}

@Override
public void writeString(char[] text, int offset, int len) throws IOException {
super.writeString(escape(new String(text, offset, len)));
}

@Override
public void writeString(SerializableString text) throws IOException {
super.writeString(escape(text.getValue()));
}

private static String escape(String text) {
return text!=null && !text.isEmpty() && FORMULA_TRIGGER_CHARS.indexOf(text.charAt(0))>=0
? "'"+text : text;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import com.fortify.cli.common.report.writer.ReportDirWriter;
import com.fortify.cli.common.report.writer.ReportZipWriter;
import com.fortify.cli.common.util.FcliBuildProperties;
import com.fortify.cli.common.util.FileUtils;

import picocli.CommandLine.ArgGroup;
import picocli.CommandLine.Mixin;
Expand Down Expand Up @@ -74,7 +75,7 @@ private IReportWriter createReportWriter() {
deleteExisting(outputArgGroup.reportZipName, File::delete);
return new ReportZipWriter(outputArgGroup.reportZipName, getCommandHelper().getMessageResolver());
} else if ( StringUtils.isNotBlank(outputArgGroup.reportDirName) ) {
deleteExisting(outputArgGroup.reportDirName, this::deleteDirectory);
deleteExisting(outputArgGroup.reportDirName, f->FileUtils.deleteRecursive(f.toPath()));
return new ReportDirWriter(outputArgGroup.reportDirName, getCommandHelper().getMessageResolver());
} else {
throw new FcliSimpleException("Either --report-file or --report-dir must be specified");
Expand All @@ -89,16 +90,6 @@ private void deleteExisting(String name, Consumer<File> deleter) {
}
}

private boolean deleteDirectory(File directoryToBeDeleted) {
File[] allContents = directoryToBeDeleted.listFiles();
if (allContents != null) {
for (File file : allContents) {
deleteDirectory(file);
}
}
return directoryToBeDeleted.delete();
}

@Override
public final boolean isSingular() {
return true;
Expand Down
Loading