From fc54faa26376de97d8442cb8afd40949269cee45 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 06:36:35 +0000 Subject: [PATCH] Extract safecsv writer for spreadsheet-safe exports Wrap encoding/csv with formula-safe cell sanitization and use it from log export streaming. Signed-off-by: Cursor Agent Co-authored-by: Bryan FRIMIN --- pkg/iam/log_export_csv.go | 36 +++++------------------ pkg/iam/log_export_csv_test.go | 10 ------- pkg/iam/log_export_service.go | 4 +-- pkg/safecsv/cell.go | 49 ++++++++++++++++++++++++++++++++ pkg/safecsv/cell_test.go | 52 ++++++++++++++++++++++++++++++++++ pkg/safecsv/writer.go | 50 ++++++++++++++++++++++++++++++++ 6 files changed, 160 insertions(+), 41 deletions(-) create mode 100644 pkg/safecsv/cell.go create mode 100644 pkg/safecsv/cell_test.go create mode 100644 pkg/safecsv/writer.go diff --git a/pkg/iam/log_export_csv.go b/pkg/iam/log_export_csv.go index c650201d6..7ef3ac714 100644 --- a/pkg/iam/log_export_csv.go +++ b/pkg/iam/log_export_csv.go @@ -22,7 +22,6 @@ package iam import ( "context" - "encoding/csv" "fmt" "strconv" "strings" @@ -33,6 +32,7 @@ import ( "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/mail" "go.probo.inc/probo/pkg/page" + "go.probo.inc/probo/pkg/safecsv" ) type ( @@ -93,7 +93,7 @@ func (s *LogExportService) streamAuditLogCSV( organizationID gid.GID, organizationName string, filter *coredata.AuditLogEntryFilter, - w *csv.Writer, + w *safecsv.Writer, ) error { if err := w.Write(auditLogExportCSVHeader); err != nil { return fmt.Errorf("cannot write audit log CSV header: %w", err) @@ -148,7 +148,7 @@ func (s *LogExportService) streamSCIMEventCSV( organizationID gid.GID, organizationName string, filter *coredata.SCIMEventFilter, - w *csv.Writer, + w *safecsv.Writer, ) error { if err := w.Write(scimEventExportCSVHeader); err != nil { return fmt.Errorf("cannot write SCIM event CSV header: %w", err) @@ -207,7 +207,7 @@ func auditLogEntryCSVRow( entry *coredata.AuditLogEntry, actor auditLogActorExportInfo, ) []string { - return csvExportRow( + return []string{ organizationName, entry.ID.String(), entry.CreatedAt.Format(time.RFC3339), @@ -218,7 +218,7 @@ func auditLogEntryCSVRow( entry.Action, entry.ResourceType, entry.ResourceID.String(), - ) + } } func scimEventCSVRow( @@ -233,7 +233,7 @@ func scimEventCSVRow( email = scimEmailFromUserName(event.UserName) } - return csvExportRow( + return []string{ organizationName, event.ID.String(), event.CreatedAt.Format(time.RFC3339), @@ -245,7 +245,7 @@ func scimEventCSVRow( strconv.Itoa(event.StatusCode), stringPtrValue(event.ErrorMessage), event.IPAddress.String(), - ) + } } func loadAuditLogActorExportInfo( @@ -381,28 +381,6 @@ func uniqueNonEmptyStrings(values []string) []string { return out } -func csvExportRow(fields ...string) []string { - row := make([]string, len(fields)) - for i, field := range fields { - row[i] = csvSafeCell(field) - } - - return row -} - -func csvSafeCell(value string) string { - if value == "" { - return value - } - - switch value[0] { - case '=', '+', '-', '@', '\t', '\r': - return "'" + value - default: - return value - } -} - func scimEmailFromUserName(userName string) string { userName = strings.TrimSpace(userName) if userName == "" { diff --git a/pkg/iam/log_export_csv_test.go b/pkg/iam/log_export_csv_test.go index 0dde378f5..d6c371660 100644 --- a/pkg/iam/log_export_csv_test.go +++ b/pkg/iam/log_export_csv_test.go @@ -33,16 +33,6 @@ func TestUniqueNonEmptyStrings(t *testing.T) { assert.Equal(t, []string{"a", "b"}, got) } -func TestCsvSafeCell(t *testing.T) { - t.Parallel() - - assert.Equal(t, "plain", csvSafeCell("plain")) - assert.Equal(t, "'=1+1", csvSafeCell("=1+1")) - assert.Equal(t, "'+cmd", csvSafeCell("+cmd")) - assert.Equal(t, "'-2", csvSafeCell("-2")) - assert.Equal(t, "'@sum", csvSafeCell("@sum")) -} - func TestScimEmailFromUserName(t *testing.T) { t.Parallel() diff --git a/pkg/iam/log_export_service.go b/pkg/iam/log_export_service.go index 833404647..539537d02 100644 --- a/pkg/iam/log_export_service.go +++ b/pkg/iam/log_export_service.go @@ -22,7 +22,6 @@ package iam import ( "context" - "encoding/csv" "fmt" "io" "strings" @@ -35,6 +34,7 @@ import ( "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/mail" + "go.probo.inc/probo/pkg/safecsv" ) type LogExportService struct { @@ -228,7 +228,7 @@ func (s *LogExportService) streamCSV( return fmt.Errorf("cannot load organization for log export: %w", err) } - w := csv.NewWriter(pw) + w := safecsv.NewWriter(pw) switch exportJob.Type { case coredata.ExportJobTypeAuditLog: diff --git a/pkg/safecsv/cell.go b/pkg/safecsv/cell.go new file mode 100644 index 000000000..506c7dcad --- /dev/null +++ b/pkg/safecsv/cell.go @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package safecsv + +// SanitizeRecord returns a copy of record with spreadsheet-safe cell values. +func SanitizeRecord(record []string) []string { + if len(record) == 0 { + return record + } + + out := make([]string, len(record)) + for i, field := range record { + out[i] = SanitizeCell(field) + } + + return out +} + +// SanitizeCell prefixes values that spreadsheet tools may interpret as formulas. +func SanitizeCell(value string) string { + if value == "" { + return value + } + + switch value[0] { + case '=', '+', '-', '@', '\t', '\r': + return "'" + value + default: + return value + } +} diff --git a/pkg/safecsv/cell_test.go b/pkg/safecsv/cell_test.go new file mode 100644 index 000000000..a9f1d9dda --- /dev/null +++ b/pkg/safecsv/cell_test.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package safecsv + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSanitizeCell(t *testing.T) { + t.Parallel() + + assert.Equal(t, "plain", SanitizeCell("plain")) + assert.Equal(t, "'=1+1", SanitizeCell("=1+1")) + assert.Equal(t, "'+cmd", SanitizeCell("+cmd")) + assert.Equal(t, "'-2", SanitizeCell("-2")) + assert.Equal(t, "'@sum", SanitizeCell("@sum")) +} + +func TestWriterWrite(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + + w := NewWriter(&buf) + require.NoError(t, w.WriteRow("ok", "=evil")) + w.Flush() + require.NoError(t, w.Error()) + + assert.Equal(t, "ok,'=evil\n", buf.String()) +} diff --git a/pkg/safecsv/writer.go b/pkg/safecsv/writer.go new file mode 100644 index 000000000..4599bcc5a --- /dev/null +++ b/pkg/safecsv/writer.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package safecsv + +import ( + "encoding/csv" + "io" +) + +type Writer struct { + inner *csv.Writer +} + +func NewWriter(w io.Writer) *Writer { + return &Writer{inner: csv.NewWriter(w)} +} + +func (w *Writer) Write(record []string) error { + return w.inner.Write(SanitizeRecord(record)) +} + +func (w *Writer) WriteRow(fields ...string) error { + return w.Write(fields) +} + +func (w *Writer) Flush() { + w.inner.Flush() +} + +func (w *Writer) Error() error { + return w.inner.Error() +}