update
This commit is contained in:
@@ -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,
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
|
||||
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">`+
|
||||
`<dimension ref="A1:H%d"/>`+
|
||||
`<sheetViews><sheetView showGridLines="0" workbookViewId="0">`+
|
||||
`<pane ySplit="4" topLeftCell="A5" activePane="bottomLeft" state="frozen"/>`+
|
||||
`</sheetView></sheetViews>`+
|
||||
`<sheetFormatPr defaultRowHeight="15"/>`+
|
||||
`<cols>`+
|
||||
`<col min="1" max="1" width="22" customWidth="1"/>`+
|
||||
`<col min="2" max="2" width="28" customWidth="1"/>`+
|
||||
`<col min="3" max="3" width="30" customWidth="1"/>`+
|
||||
`<col min="4" max="4" width="22" customWidth="1"/>`+
|
||||
`<col min="5" max="5" width="24" customWidth="1"/>`+
|
||||
`<col min="6" max="6" width="24" customWidth="1"/>`+
|
||||
`<col min="7" max="7" width="18" customWidth="1"/>`+
|
||||
`<col min="8" max="8" width="54" customWidth="1"/>`+
|
||||
`</cols><sheetData>`,
|
||||
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, `<row r="3" ht="8" customHeight="1"/>`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
headers := []string{
|
||||
"Timestamp",
|
||||
"Event",
|
||||
"Customer / Deployment",
|
||||
"User",
|
||||
"Hostname",
|
||||
"Package ID",
|
||||
"Source IP",
|
||||
"Details",
|
||||
}
|
||||
if _, err := writeString(w, `<row r="4" ht="24" customHeight="1">`); 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, `</row>`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for index, record := range records {
|
||||
rowNumber := index + 5
|
||||
if _, err := fmt.Fprintf(w, `<row r="%d">`, 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, `</row>`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(
|
||||
w,
|
||||
`</sheetData><autoFilter ref="A4:H%d"/>`+
|
||||
`<mergeCells count="2"><mergeCell ref="A1:H1"/><mergeCell ref="A2:H2"/></mergeCells>`+
|
||||
`<pageMargins left="0.25" right="0.25" top="0.5" bottom="0.5" header="0.2" footer="0.2"/>`+
|
||||
`<pageSetup orientation="landscape" fitToWidth="1" fitToHeight="0"/>`+
|
||||
`</worksheet>`,
|
||||
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 r="%d" ht="%d" customHeight="1">`, 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, `</row>`)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeInlineStringCell(w io.Writer, reference string, value string, style int) error {
|
||||
if _, err := fmt.Fprintf(w, `<c r="%s" s="%d" t="inlineStr"><is><t`, reference, style); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasPrefix(value, " ") || strings.HasSuffix(value, " ") {
|
||||
if _, err := writeString(w, ` xml:space="preserve"`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := writeString(w, `>`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeEscapedXML(w, value); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := writeString(w, `</t></is></c>`)
|
||||
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, `<c r="%s" s="%d"><v>%.10f</v></c>`, reference, style, serial)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeAuditCoreProperties(w io.Writer, exportedAt time.Time) error {
|
||||
if _, err := writeString(
|
||||
w,
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
|
||||
`<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" `+
|
||||
`xmlns:dc="http://purl.org/dc/elements/1.1/" `+
|
||||
`xmlns:dcterms="http://purl.org/dc/terms/" `+
|
||||
`xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">`+
|
||||
`<dc:title>TAPM Audit Log</dc:title><dc:creator>TAPM Broker</dc:creator>`+
|
||||
`<dcterms:created xsi:type="dcterms:W3CDTF">`,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeEscapedXML(w, exportedAt.UTC().Format(time.RFC3339)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := writeString(w, `</dcterms:created></cp:coreProperties>`)
|
||||
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 = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
|
||||
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
|
||||
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
|
||||
</Types>`
|
||||
|
||||
const packageRelationshipsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
|
||||
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const appPropertiesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
|
||||
<Application>TAPM Broker</Application>
|
||||
</Properties>`
|
||||
|
||||
const workbookXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<sheets><sheet name="Audit Log" sheetId="1" r:id="rId1"/></sheets>
|
||||
</workbook>`
|
||||
|
||||
const workbookRelationshipsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const auditStylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<numFmts count="1"><numFmt numFmtId="164" formatCode="yyyy-mm-dd hh:mm:ss"/></numFmts>
|
||||
<fonts count="3">
|
||||
<font><sz val="11"/><color rgb="FF33404A"/><name val="Aptos"/></font>
|
||||
<font><b/><sz val="18"/><color rgb="FFFFFFFF"/><name val="Aptos Display"/></font>
|
||||
<font><b/><sz val="11"/><color rgb="FFFFFFFF"/><name val="Aptos"/></font>
|
||||
</fonts>
|
||||
<fills count="4">
|
||||
<fill><patternFill patternType="none"/></fill>
|
||||
<fill><patternFill patternType="gray125"/></fill>
|
||||
<fill><patternFill patternType="solid"><fgColor rgb="FF33404A"/><bgColor indexed="64"/></patternFill></fill>
|
||||
<fill><patternFill patternType="solid"><fgColor rgb="FFF4F7ED"/><bgColor indexed="64"/></patternFill></fill>
|
||||
</fills>
|
||||
<borders count="2">
|
||||
<border><left/><right/><top/><bottom/><diagonal/></border>
|
||||
<border><left/><right/><top/><bottom style="thin"><color rgb="FFD9DED4"/></bottom><diagonal/></border>
|
||||
</borders>
|
||||
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
||||
<cellXfs count="7">
|
||||
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>
|
||||
<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"><alignment vertical="center"/></xf>
|
||||
<xf numFmtId="0" fontId="0" fillId="3" borderId="0" xfId="0" applyFill="1"><alignment vertical="center"/></xf>
|
||||
<xf numFmtId="0" fontId="2" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"><alignment vertical="center"/></xf>
|
||||
<xf numFmtId="0" fontId="0" fillId="0" borderId="1" xfId="0" applyBorder="1"><alignment vertical="top"/></xf>
|
||||
<xf numFmtId="164" fontId="0" fillId="0" borderId="1" xfId="0" applyNumberFormat="1" applyBorder="1"><alignment vertical="top"/></xf>
|
||||
<xf numFmtId="0" fontId="0" fillId="0" borderId="1" xfId="0" applyBorder="1"><alignment vertical="top" wrapText="1"/></xf>
|
||||
</cellXfs>
|
||||
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
|
||||
</styleSheet>`
|
||||
@@ -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, "<f>") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -47,10 +47,12 @@
|
||||
<label>Details contain <input name="audit_details" value="{{.AuditFilters.Details}}" placeholder="customer or action"></label>
|
||||
<div class="audit-filter-actions">
|
||||
<button class="button secondary" type="submit">Apply filters</button>
|
||||
<button class="button secondary" type="submit" formaction="/portal/audit/export">Download matching Excel</button>
|
||||
<a class="text-button" href="/portal/audit/export?audit_range=all">Download complete Excel</a>
|
||||
<a class="text-button" href="/portal/audit?audit_range=30d&audit_event=code_exchange_failed">Failed code attempts</a>
|
||||
<a class="text-button" href="/portal/audit?audit_range=30d">Clear filters</a>
|
||||
</div>
|
||||
<p class="form-help audit-filter-note">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.</p>
|
||||
<p class="form-help audit-filter-note">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.</p>
|
||||
</form>
|
||||
{{template "audit-rows" .}}
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user