diff --git a/internal/app/audit_export.go b/internal/app/audit_export.go new file mode 100644 index 0000000..0a689e6 --- /dev/null +++ b/internal/app/audit_export.go @@ -0,0 +1,520 @@ +package app + +import ( + "archive/zip" + "compress/flate" + "fmt" + "hash/crc32" + "io" + "log" + "net/http" + "strings" + "time" + "unicode/utf8" +) + +const maxAuditExportRows = 1_048_572 + +func (s *Server) handleAuditExport(w http.ResponseWriter, r *http.Request) { + tech, err := s.currentTechnician(r) + if err != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + filters := auditFiltersFromRequest(r) + records, err := s.listAuditEvents(r, filters, maxAuditExportRows+1) + if err != nil { + http.Error(w, "unable to export audit events", http.StatusInternalServerError) + return + } + if len(records) > maxAuditExportRows { + http.Error( + w, + "audit export is too large for a single Excel worksheet; narrow the filters and try again", + http.StatusRequestEntityTooLarge, + ) + return + } + + _ = s.audit( + r.Context(), + "audit_exported", + tech.Login, + nil, + "", + "", + s.clientIP(r), + fmt.Sprintf("format=xlsx rows=%d range=%s", len(records), filters.TimeRange), + ) + + exportedAt := time.Now() + filename := "tapm-audit-" + exportedAt.In(s.displayTimeZone()).Format("20060102-150405") + ".xlsx" + w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) + w.Header().Set("Cache-Control", "no-store") + + if err := writeAuditWorkbook(w, records, filters, exportedAt, s.displayTimeZone()); err != nil { + log.Printf("write audit Excel export: %v", err) + } +} + +func (s *Server) displayTimeZone() *time.Location { + if s.cfg.DisplayTimeZone != nil { + return s.cfg.DisplayTimeZone + } + return time.UTC +} + +func writeAuditWorkbook( + output io.Writer, + records []auditRecord, + filters auditFilters, + exportedAt time.Time, + displayTimeZone *time.Location, +) error { + if displayTimeZone == nil { + displayTimeZone = time.UTC + } + + archive := zip.NewWriter(output) + files := []struct { + name string + content func(io.Writer) error + }{ + {"[Content_Types].xml", func(w io.Writer) error { + return writeOnlyString(w, contentTypesXML) + }}, + {"_rels/.rels", func(w io.Writer) error { + return writeOnlyString(w, packageRelationshipsXML) + }}, + {"docProps/app.xml", func(w io.Writer) error { + return writeOnlyString(w, appPropertiesXML) + }}, + {"docProps/core.xml", func(w io.Writer) error { + return writeAuditCoreProperties(w, exportedAt) + }}, + {"xl/workbook.xml", func(w io.Writer) error { + return writeOnlyString(w, workbookXML) + }}, + {"xl/_rels/workbook.xml.rels", func(w io.Writer) error { + return writeOnlyString(w, workbookRelationshipsXML) + }}, + {"xl/styles.xml", func(w io.Writer) error { + return writeOnlyString(w, auditStylesXML) + }}, + {"xl/worksheets/sheet1.xml", func(w io.Writer) error { + return writeAuditWorksheet(w, records, filters, exportedAt, displayTimeZone) + }}, + } + + for _, file := range files { + if err := writeRawZipFile(archive, file.name, file.content); err != nil { + _ = archive.Close() + return err + } + } + return archive.Close() +} + +func writeRawZipFile( + archive *zip.Writer, + name string, + content func(io.Writer) error, +) error { + checksum := crc32.NewIEEE() + var compressedSize byteCounter + var uncompressedSize byteCounter + sizeCompressor, err := flate.NewWriter(&compressedSize, flate.DefaultCompression) + if err != nil { + return err + } + if err := content(io.MultiWriter(checksum, &uncompressedSize, sizeCompressor)); err != nil { + _ = sizeCompressor.Close() + return err + } + if err := sizeCompressor.Close(); err != nil { + return err + } + header := &zip.FileHeader{ + Name: name, + Method: zip.Deflate, + CRC32: checksum.Sum32(), + CompressedSize64: uint64(compressedSize), + UncompressedSize64: uint64(uncompressedSize), + } + entry, err := archive.CreateRaw(header) + if err != nil { + return err + } + compressor, err := flate.NewWriter(entry, flate.DefaultCompression) + if err != nil { + return err + } + if err := content(compressor); err != nil { + _ = compressor.Close() + return err + } + return compressor.Close() +} + +func writeAuditWorksheet( + w io.Writer, + records []auditRecord, + filters auditFilters, + exportedAt time.Time, + displayTimeZone *time.Location, +) error { + lastRow := len(records) + 4 + if lastRow < 4 { + lastRow = 4 + } + if _, err := fmt.Fprintf( + w, + ``+ + ``+ + ``+ + ``+ + ``+ + ``+ + ``+ + ``+ + ``+ + ``+ + ``+ + ``+ + ``+ + ``+ + ``+ + ``+ + ``, + lastRow, + ); err != nil { + return err + } + + if err := writeMergedRow(w, 1, "TAPM Audit Log", 1, 30); err != nil { + return err + } + metadata := fmt.Sprintf( + "Exported %s · %s · %d event(s)", + exportedAt.In(displayTimeZone).Format("Jan 2, 2006 3:04 PM MST"), + auditFilterDescription(filters), + len(records), + ) + if err := writeMergedRow(w, 2, metadata, 2, 24); err != nil { + return err + } + if _, err := writeString(w, ``); err != nil { + return err + } + + headers := []string{ + "Timestamp", + "Event", + "Customer / Deployment", + "User", + "Hostname", + "Package ID", + "Source IP", + "Details", + } + if _, err := writeString(w, ``); err != nil { + return err + } + for index, header := range headers { + if err := writeInlineStringCell(w, cellReference(index+1, 4), header, 3); err != nil { + return err + } + } + if _, err := writeString(w, ``); err != nil { + return err + } + + for index, record := range records { + rowNumber := index + 5 + if _, err := fmt.Fprintf(w, ``, rowNumber); err != nil { + return err + } + if err := writeDateCell( + w, + cellReference(1, rowNumber), + record.CreatedAt, + displayTimeZone, + 5, + ); err != nil { + return err + } + values := []string{ + record.EventType, + record.CustomerLabel, + record.User, + record.Hostname, + record.PackageSlug, + record.SourceIP, + record.Details, + } + for column, value := range values { + style := 4 + if column == len(values)-1 { + style = 6 + } + if err := writeInlineStringCell( + w, + cellReference(column+2, rowNumber), + value, + style, + ); err != nil { + return err + } + } + if _, err := writeString(w, ``); err != nil { + return err + } + } + + if _, err := fmt.Fprintf( + w, + ``+ + ``+ + ``+ + ``+ + ``, + lastRow, + ); err != nil { + return err + } + return nil +} + +func writeMergedRow(w io.Writer, row int, value string, style int, height int) error { + if _, err := fmt.Fprintf(w, ``, row, height); err != nil { + return err + } + for column := 1; column <= 8; column++ { + cellValue := "" + if column == 1 { + cellValue = value + } + if err := writeInlineStringCell(w, cellReference(column, row), cellValue, style); err != nil { + return err + } + } + _, err := writeString(w, ``) + return err +} + +func writeInlineStringCell(w io.Writer, reference string, value string, style int) error { + if _, err := fmt.Fprintf(w, ``); err != nil { + return err + } + if err := writeEscapedXML(w, value); err != nil { + return err + } + _, err := writeString(w, ``) + return err +} + +func writeDateCell( + w io.Writer, + reference string, + value time.Time, + displayTimeZone *time.Location, + style int, +) error { + local := value.In(displayTimeZone) + displayedTime := time.Date( + local.Year(), + local.Month(), + local.Day(), + local.Hour(), + local.Minute(), + local.Second(), + local.Nanosecond(), + time.UTC, + ) + serial := float64(displayedTime.Unix())/86_400 + 25_569 + _, err := fmt.Fprintf(w, `%.10f`, reference, style, serial) + return err +} + +func writeAuditCoreProperties(w io.Writer, exportedAt time.Time) error { + if _, err := writeString( + w, + ``+ + ``+ + `TAPM Audit LogTAPM Broker`+ + ``, + ); err != nil { + return err + } + if err := writeEscapedXML(w, exportedAt.UTC().Format(time.RFC3339)); err != nil { + return err + } + _, err := writeString(w, ``) + return err +} + +func auditFilterDescription(filters auditFilters) string { + rangeLabels := map[string]string{ + "24h": "Past 24 hours", + "7d": "Past 7 days", + "30d": "Past 30 days", + "90d": "Past 90 days", + "all": "All retained events", + } + parts := []string{rangeLabels[filters.TimeRange]} + filterValues := []struct { + label string + value string + }{ + {"event", filters.EventType}, + {"customer", filters.CustomerLabel}, + {"user", filters.User}, + {"hostname", filters.Hostname}, + {"package", filters.PackageSlug}, + {"IP", filters.SourceIP}, + {"details", filters.Details}, + } + for _, filter := range filterValues { + if filter.value != "" { + parts = append(parts, filter.label+": "+filter.value) + } + } + return strings.Join(parts, " · ") +} + +func cellReference(column int, row int) string { + var letters [3]byte + position := len(letters) + for column > 0 { + column-- + position-- + letters[position] = byte('A' + column%26) + column /= 26 + } + return string(letters[position:]) + fmt.Sprintf("%d", row) +} + +func writeEscapedXML(w io.Writer, value string) error { + value = strings.ToValidUTF8(value, "\uFFFD") + for _, character := range value { + if character == '\t' || character == '\n' || character == '\r' || + (character >= 0x20 && character <= 0xD7FF) || + (character >= 0xE000 && character <= 0xFFFD) || + (character >= 0x10000 && character <= utf8.MaxRune) { + switch character { + case '&': + if _, err := writeString(w, "&"); err != nil { + return err + } + case '<': + if _, err := writeString(w, "<"); err != nil { + return err + } + case '>': + if _, err := writeString(w, ">"); err != nil { + return err + } + case '"': + if _, err := writeString(w, """); err != nil { + return err + } + case '\'': + if _, err := writeString(w, "'"); err != nil { + return err + } + default: + if _, err := fmt.Fprint(w, string(character)); err != nil { + return err + } + } + } + } + return nil +} + +func writeString(w io.Writer, value string) (int, error) { + return io.WriteString(w, value) +} + +func writeOnlyString(w io.Writer, value string) error { + _, err := io.WriteString(w, value) + return err +} + +const contentTypesXML = ` + + + + + + + + +` + +const packageRelationshipsXML = ` + + + + +` + +const appPropertiesXML = ` + + TAPM Broker +` + +const workbookXML = ` + + +` + +const workbookRelationshipsXML = ` + + + +` + +const auditStylesXML = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` diff --git a/internal/app/audit_export_test.go b/internal/app/audit_export_test.go new file mode 100644 index 0000000..2e7f9a4 --- /dev/null +++ b/internal/app/audit_export_test.go @@ -0,0 +1,178 @@ +package app + +import ( + "archive/zip" + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +func TestWriteAuditWorkbookCreatesSafeExcelFile(t *testing.T) { + t.Parallel() + location, err := time.LoadLocation("America/Chicago") + if err != nil { + t.Fatal(err) + } + createdAt := time.Date(2026, time.July, 28, 20, 10, 32, 0, location) + records := []auditRecord{{ + EventType: "code_exchange_failed", + CustomerLabel: "Acme & Sons", + User: "taiadmin", + Hostname: "=HYPERLINK(\"https://invalid.example\")", + PackageSlug: "sentinelone-linux", + SourceIP: "203.0.113.10", + Details: "reason=code_not_found code_hint=ABCDE", + CreatedAt: createdAt, + }} + + var output bytes.Buffer + if err := writeAuditWorkbook( + &output, + records, + auditFilters{TimeRange: "all"}, + createdAt, + location, + ); err != nil { + t.Fatal(err) + } + if !bytes.HasPrefix(output.Bytes(), []byte("PK")) { + t.Fatal("workbook is not a ZIP-based Excel file") + } + + archive, err := zip.NewReader(bytes.NewReader(output.Bytes()), int64(output.Len())) + if err != nil { + t.Fatal(err) + } + required := map[string]bool{ + "[Content_Types].xml": false, + "xl/workbook.xml": false, + "xl/styles.xml": false, + "xl/worksheets/sheet1.xml": false, + "xl/_rels/workbook.xml.rels": false, + } + var worksheet string + for _, file := range archive.File { + if _, ok := required[file.Name]; ok { + required[file.Name] = true + } + if file.Name != "xl/worksheets/sheet1.xml" { + continue + } + reader, err := file.Open() + if err != nil { + t.Fatal(err) + } + content, err := io.ReadAll(reader) + _ = reader.Close() + if err != nil { + t.Fatal(err) + } + worksheet = string(content) + } + for file, found := range required { + if !found { + t.Errorf("workbook is missing %s", file) + } + } + if !strings.Contains(worksheet, "Acme & Sons") { + t.Fatal("worksheet text was not XML escaped") + } + if strings.Contains(worksheet, "") { + t.Fatal("untrusted audit text must not be written as a formula") + } + if !strings.Contains(worksheet, `t="inlineStr"`) || + !strings.Contains(worksheet, `=HYPERLINK`) { + t.Fatal("formula-looking audit text was not preserved as a text cell") + } + + if samplePath := os.Getenv("TAPM_AUDIT_SAMPLE_PATH"); samplePath != "" { + if err := os.WriteFile(samplePath, output.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + } +} + +func TestAuditExportReturnsAllRowsAndRecordsDownload(t *testing.T) { + server, sessionToken := newAuthorizationTestServer(t) + server.cfg.DisplayTimeZone = time.UTC + for _, eventType := range []string{"older_event", "newer_event"} { + if _, err := server.db.Exec( + `INSERT INTO audit_events (event_type, details) VALUES (?, '')`, + eventType, + ); err != nil { + t.Fatal(err) + } + } + + request := httptest.NewRequest(http.MethodGet, "/portal/audit/export?audit_range=all", nil) + request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessionToken}) + response := httptest.NewRecorder() + server.handleAuditExport(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + if contentType := response.Header().Get("Content-Type"); contentType != + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" { + t.Fatalf("content type = %q", contentType) + } + if disposition := response.Header().Get("Content-Disposition"); !strings.Contains( + disposition, + `attachment; filename="tapm-audit-`, + ) { + t.Fatalf("content disposition = %q", disposition) + } + + archive, err := zip.NewReader( + bytes.NewReader(response.Body.Bytes()), + int64(response.Body.Len()), + ) + if err != nil { + t.Fatal(err) + } + var worksheet string + for _, file := range archive.File { + if file.Name != "xl/worksheets/sheet1.xml" { + continue + } + reader, err := file.Open() + if err != nil { + t.Fatal(err) + } + content, err := io.ReadAll(reader) + _ = reader.Close() + if err != nil { + t.Fatal(err) + } + worksheet = string(content) + } + if !strings.Contains(worksheet, "older_event") || + !strings.Contains(worksheet, "newer_event") { + t.Fatal("complete export did not contain all retained audit rows") + } + + var exportEvents int + if err := server.db.QueryRow( + `SELECT COUNT(*) FROM audit_events WHERE event_type = 'audit_exported'`, + ).Scan(&exportEvents); err != nil { + t.Fatal(err) + } + if exportEvents != 1 { + t.Fatalf("audit_exported events = %d, want 1", exportEvents) + } +} + +func TestAuditExportRequiresTechnicianSession(t *testing.T) { + server, _ := newAuthorizationTestServer(t) + request := httptest.NewRequest(http.MethodGet, "/portal/audit/export?audit_range=all", nil) + response := httptest.NewRecorder() + server.Routes().ServeHTTP(response, request) + if response.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want redirect", response.Code) + } +} diff --git a/internal/app/server.go b/internal/app/server.go index e22cc8f..d0612d1 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -201,6 +201,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /portal", s.requireTechnician(s.handlePortal)) mux.HandleFunc("GET /portal/packages", s.requireTechnician(s.handlePackagesPortal)) mux.HandleFunc("GET /portal/audit", s.requireTechnician(s.handleAuditPortal)) + mux.HandleFunc("GET /portal/audit/export", s.requireTechnician(s.handleAuditExport)) mux.HandleFunc("GET /portal/hosts", s.requireTechnician(s.handleFleetPortal)) mux.HandleFunc("POST /portal/authorizations", s.requireTechnician(s.handleCreateAuthorization)) mux.HandleFunc("POST /portal/authorizations/{id}/update", s.requireTechnician(s.handleUpdateAuthorization)) diff --git a/internal/app/server_test.go b/internal/app/server_test.go index 81d2ec8..8e5dd76 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go @@ -121,6 +121,10 @@ func TestPackageAndAuditTemplatesExecute(t *testing.T) { !strings.Contains(auditOutput.String(), "audit_event=code_exchange_failed") { t.Fatal("audit page does not include the failed-code-attempt shortcut") } + if !strings.Contains(auditOutput.String(), "Download complete Excel") || + !strings.Contains(auditOutput.String(), `/portal/audit/export?audit_range=all`) { + t.Fatal("audit page does not include the complete Excel export") + } } func TestFleetTemplateExecutes(t *testing.T) { diff --git a/internal/app/static/app.css b/internal/app/static/app.css index d03dc59..d650db4 100644 --- a/internal/app/static/app.css +++ b/internal/app/static/app.css @@ -281,7 +281,7 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; } padding: 22px 28px; border-bottom: 1px solid var(--line); } -.audit-filter-actions { display: flex; align-items: center; gap: 18px; } +.audit-filter-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 18px; grid-column: 1 / -1; } .audit-filter-note { grid-column: 1 / -1; } .audit-table { display: grid; max-height: 520px; overflow: auto; } .audit-row { diff --git a/internal/app/templates/audit.html b/internal/app/templates/audit.html index ab393b6..08d8236 100644 --- a/internal/app/templates/audit.html +++ b/internal/app/templates/audit.html @@ -47,10 +47,12 @@
+ + Download complete Excel Failed code attempts Clear filters
-

Failed code attempts show a reason, hostname, source IP, code length, and only the five-character code hint. Full submitted codes are never retained. Up to the newest 250 matching events are shown.

+

Failed code attempts show a reason, hostname, source IP, code length, and only the five-character code hint. Full submitted codes are never retained. Up to the newest 250 matching events are shown; Excel exports include every matching retained event.

{{template "audit-rows" .}}