mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-31 18:23:50 +08:00
245 lines
6.9 KiB
Go
245 lines
6.9 KiB
Go
package server
|
|
|
|
import (
|
|
"archive/zip"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Autumn-27/artex/db"
|
|
"github.com/Autumn-27/artex/evidence"
|
|
"github.com/Autumn-27/artex/report"
|
|
)
|
|
|
|
type exportedScreenshot struct {
|
|
ReportPath string `json:"report_path"`
|
|
ArchivePath string `json:"archive_path"`
|
|
SourcePath string `json:"-"`
|
|
CapturedAt string `json:"captured_at"`
|
|
}
|
|
|
|
var screenshotMarkdownLink = regexp.MustCompile(`!\[([^\]]*)\]\(([^)\s]+)\)`)
|
|
|
|
func writeFindingsEvidenceZip(out io.Writer, findings []*db.DBFinding, stage string, now time.Time, workspaceDir string) error {
|
|
zw := zip.NewWriter(out)
|
|
if err := writeFindingsZipEntries(zw, findings, stage, now, workspaceDir); err != nil {
|
|
zw.Close()
|
|
return err
|
|
}
|
|
return zw.Close()
|
|
}
|
|
|
|
func writeFindingsZipEntries(zw *zip.Writer, findings []*db.DBFinding, stage string, now time.Time, workspaceDir string) error {
|
|
store := evidence.New(nil, nil, stage) // private export copy; not subject to GC
|
|
used := map[string]int{}
|
|
evidencePaths := map[string]bool{}
|
|
for _, f := range findings {
|
|
base := report.FindingFilename(f)
|
|
name := base
|
|
if n := used[base]; n > 0 {
|
|
name = fmt.Sprintf("%s-%d.md", strings.TrimSuffix(base, ".md"), n+1)
|
|
}
|
|
used[base]++
|
|
reportText, screenshots, err := prepareFindingExportReport(report.SingleFindingMarkdown(f, now), f.ID, workspaceDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
w, err := zw.Create(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err = io.WriteString(w, reportText); err != nil {
|
|
return err
|
|
}
|
|
for _, screenshot := range screenshots {
|
|
if err := copyZipFile(zw, screenshot.ArchivePath, screenshot.SourcePath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(screenshots) > 0 {
|
|
manifestPath := fmt.Sprintf("screenshots/%d/manifest.json", f.ID)
|
|
w, err := zw.Create(manifestPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := json.NewEncoder(w).Encode(map[string]any{"finding_id": f.ID, "screenshots": screenshots}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, b := range f.TrafficBindings {
|
|
prefix := fmt.Sprintf("evidence/%d/%d/", f.ID, b.ID)
|
|
if evidencePaths[prefix] {
|
|
continue
|
|
}
|
|
evidencePaths[prefix] = true
|
|
w, err := zw.Create(prefix + "manifest.json")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = json.NewEncoder(w).Encode(b); err != nil {
|
|
return err
|
|
}
|
|
for _, side := range []string{"request", "response"} {
|
|
if err := func() error {
|
|
body, _, err := store.OpenBody(b.Snapshot, side)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer body.Close()
|
|
w, err := zw.Create(prefix + side + ".http")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
head := b.Snapshot.ReqHead
|
|
if side == "response" {
|
|
head = b.Snapshot.RespHead
|
|
}
|
|
if _, err = io.WriteString(w, strings.TrimRight(head, "\r\n")+"\n\n"); err != nil {
|
|
return err
|
|
}
|
|
if _, err = io.Copy(w, body); err != nil {
|
|
return err
|
|
}
|
|
if _, err = body.Seek(0, io.SeekStart); err != nil {
|
|
return err
|
|
}
|
|
w, err = zw.Create(prefix + side + ".bin")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = io.Copy(w, body)
|
|
return err
|
|
}(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildFindingsEvidenceZip(path string, findings []*db.DBFinding, stage string, now time.Time, workspaceDir string) error {
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeErr := writeFindingsEvidenceZip(f, findings, stage, now, workspaceDir)
|
|
syncErr := f.Sync()
|
|
closeErr := f.Close()
|
|
if err := errors.Join(writeErr, syncErr, closeErr); err != nil {
|
|
return err
|
|
}
|
|
// Re-read every compressed entry to validate the finished ZIP, including CRCs,
|
|
// before the caller starts an HTTP download response.
|
|
archive, err := zip.OpenReader(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer archive.Close()
|
|
for _, entry := range archive.File {
|
|
r, err := entry.Open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, readErr := io.Copy(io.Discard, r)
|
|
if err := errors.Join(readErr, r.Close()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func copyZipFile(zw *zip.Writer, archivePath, sourcePath string) error {
|
|
in, err := os.Open(sourcePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
out, err := zw.Create(archivePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = io.Copy(out, in)
|
|
return err
|
|
}
|
|
|
|
func prepareFindingExportReport(markdown string, findingID int64, workspaceDir string) (string, []exportedScreenshot, error) {
|
|
var out strings.Builder
|
|
var screenshots []exportedScreenshot
|
|
seen := map[string]bool{}
|
|
last := 0
|
|
for _, match := range screenshotMarkdownLink.FindAllStringSubmatchIndex(markdown, -1) {
|
|
out.WriteString(markdown[last:match[0]])
|
|
raw := markdown[match[4]:match[5]]
|
|
source, ok := resolveExportScreenshot(workspaceDir, raw)
|
|
if !ok {
|
|
out.WriteString(markdown[match[0]:match[1]])
|
|
last = match[1]
|
|
continue
|
|
}
|
|
archivePath := filepath.ToSlash(fmt.Sprintf("screenshots/%d/%s", findingID, filepath.Base(source)))
|
|
if !seen[archivePath] {
|
|
info, statErr := os.Stat(source)
|
|
if statErr != nil {
|
|
return "", nil, statErr
|
|
}
|
|
screenshots = append(screenshots, exportedScreenshot{ReportPath: raw, ArchivePath: archivePath, SourcePath: source, CapturedAt: info.ModTime().Format("2006-01-02 15:04:05")})
|
|
seen[archivePath] = true
|
|
}
|
|
// Preserve the original alt text and only replace the image URL.
|
|
out.WriteString(markdown[match[0]:match[4]])
|
|
out.WriteString(archivePath)
|
|
out.WriteString(markdown[match[5]:match[1]])
|
|
last = match[1]
|
|
}
|
|
out.WriteString(markdown[last:])
|
|
return out.String(), screenshots, nil
|
|
}
|
|
|
|
func resolveExportScreenshot(workspaceDir, raw string) (string, bool) {
|
|
value := strings.TrimSpace(raw)
|
|
value = strings.TrimPrefix(value, "workspace://")
|
|
value = strings.TrimPrefix(value, "file://")
|
|
if value == "" {
|
|
return "", false
|
|
}
|
|
if filepath.IsAbs(value) {
|
|
candidate := filepath.Clean(value)
|
|
base := filepath.Clean(workspaceDir)
|
|
if candidate != base && !strings.HasPrefix(candidate, base+string(os.PathSeparator)) {
|
|
return "", false
|
|
}
|
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
|
return candidate, true
|
|
}
|
|
return "", false
|
|
}
|
|
rel := filepath.Clean(strings.TrimPrefix(value, "/"))
|
|
const prefix = "findings" + string(os.PathSeparator) + "screenshots" + string(os.PathSeparator)
|
|
if rel == "." || !strings.HasPrefix(rel, prefix) || strings.Contains(rel, ".."+string(os.PathSeparator)) {
|
|
return "", false
|
|
}
|
|
matches, err := filepath.Glob(filepath.Join(filepath.Clean(workspaceDir), "sessions", "*", rel))
|
|
if err != nil || len(matches) == 0 {
|
|
return "", false
|
|
}
|
|
var newest string
|
|
var newestTime int64
|
|
for _, candidate := range matches {
|
|
info, statErr := os.Stat(candidate)
|
|
if statErr != nil || info.IsDir() {
|
|
continue
|
|
}
|
|
if newest == "" || info.ModTime().UnixNano() > newestTime {
|
|
newest, newestTime = candidate, info.ModTime().UnixNano()
|
|
}
|
|
}
|
|
return newest, newest != ""
|
|
}
|