refactor: rebuild PrivHunter with React, Gin and SQLite

This commit is contained in:
Ed1s0nZ committed 2026-10-07 19:55:17 +08:00
1 parent 2c842cb1da
commit bea3940977
96 files changed
+9150 -2309

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
package httpapi
import (
"github.com/gin-gonic/gin"
"strconv"
)
func (s *Server) bulkReview(c *gin.Context) {
var v struct {
IDs []int64 `json:"ids"`
Review string `json:"review"`
}
if c.ShouldBindJSON(&v) != nil || len(v.IDs) == 0 || len(v.IDs) > 100 {
fail(c, 400, "请选择 1–100 条记录")
return
}
switch v.Review {
case "pending", "confirmed", "false_positive", "resolved":
default:
fail(c, 400, "无效复核状态")
return
}
if e := s.Store.BulkReview(v.IDs, v.Review); e != nil {
fail(c, 400, "批量更新失败,请刷新后重试")
return
}
s.Store.Log(session(c).User.Username, "bulk_review", v.Review+":"+strconv.Itoa(len(v.IDs)))
c.JSON(200, gin.H{"message": "批量复核已保存", "count": len(v.IDs)})
}
+38
View File
@@ -0,0 +1,38 @@
package httpapi
import (
"github.com/gin-gonic/gin"
"os"
"yuequanScan/internal/store"
)
func (s *Server) dashboard(c *gin.Context) {
summary, e := s.Store.Dashboard()
if e != nil {
fail(c, 500, "汇总查询失败")
return
}
counts, e := s.Store.Stats()
if e != nil {
fail(c, 500, "统计读取失败")
return
}
recent, _, e := s.Store.Findings(store.Filter{Page: 1, Size: 5})
if e != nil {
fail(c, 500, "最近记录读取失败")
return
}
result := gin.H{"summary": summary, "counts": counts, "recent": recent}
if session(c).User.Role == "admin" {
settings, e := s.Store.Settings()
if e != nil {
fail(c, 500, "就绪状态读取失败")
return
}
result["readiness"] = gin.H{"scope": len(settings.Domains) > 0, "identity": len(settings.Headers) > 0, "model": settings.Endpoint != "" && settings.Model != "" && (settings.APIKey != "" || os.Getenv("PRIVHUNTER_API_KEY") != ""), "paused": settings.Paused}
if s.ScannerStatus != nil {
result["runtime"] = s.ScannerStatus()
}
}
c.JSON(200, result)
}
+60
View File
@@ -0,0 +1,60 @@
package httpapi
import (
"encoding/csv"
"encoding/json"
"errors"
"strconv"
"strings"
"yuequanScan/internal/store"
"github.com/gin-gonic/gin"
)
func safeCell(v string) string {
if strings.HasPrefix(v, "=") || strings.HasPrefix(v, "+") || strings.HasPrefix(v, "-") || strings.HasPrefix(v, "@") || strings.HasPrefix(v, "\t") || strings.HasPrefix(v, "\r") {
return "'" + v
}
return v
}
func (s *Server) export(c *gin.Context) {
f := filter(c)
f.Size = 100
f.Page = 1
format := c.DefaultQuery("format", "csv")
if format != "csv" && format != "json" {
fail(c, 400, "不支持的导出格式")
return
}
all, err := s.Store.Export(f)
if errors.Is(err, store.ErrExportLimit) {
fail(c, 400, "导出限制为 10000 条 / 32 MiB,请缩小筛选范围")
return
}
if err != nil {
fail(c, 500, "导出失败")
return
}
s.Store.Log(session(c).User.Username, "export", strconv.Itoa(len(all)))
c.Header("Content-Disposition", "attachment; filename=privhunter-results."+format)
if format == "json" {
c.Header("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(c.Writer).Encode(all)
return
}
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Writer.Write([]byte{0xef, 0xbb, 0xbf})
w := csv.NewWriter(c.Writer)
defer w.Flush()
w.Write([]string{"ID", "Method", "URL", "Result", "Confidence", "Review", "Reason", "Note", "Created At"})
for _, v := range all {
row := []string{strconv.FormatInt(v.ID, 10), v.Method, v.URL, v.Result, v.Confidence, v.Review, v.Reason, v.Note, v.CreatedAt.Format("2006-01-02T15:04:05Z")}
for i := range row {
row[i] = safeCell(row[i])
}
if w.Write(row) != nil {
return
}
}
}
+24
View File
@@ -0,0 +1,24 @@
package httpapi
import (
"database/sql"
"errors"
"github.com/gin-gonic/gin"
)
func (s *Server) finding(c *gin.Context) {
n, ok := id(c)
if !ok {
return
}
v, e := s.Store.Finding(n)
if errors.Is(e, sql.ErrNoRows) {
fail(c, 404, "记录不存在或已删除")
return
}
if e != nil {
fail(c, 500, "证据加载失败")
return
}
c.JSON(200, v)
}
+137
View File
@@ -0,0 +1,137 @@
package httpapi
import (
"strconv"
"github.com/gin-gonic/gin"
"yuequanScan/internal/auth"
"yuequanScan/internal/security"
"yuequanScan/internal/store"
)
func (s *Server) stats(c *gin.Context) {
v, e := s.Store.Stats()
if e != nil {
fail(c, 500, "查询失败")
return
}
c.JSON(200, v)
}
func filter(c *gin.Context) store.Filter {
size, _ := strconv.Atoi(c.Query("size"))
return store.Filter{Search: c.Query("search"), Result: c.Query("result"), Review: c.Query("review"), Page: page(c), Size: size}
}
func (s *Server) findings(c *gin.Context) {
f := filter(c)
v, total, e := s.Store.Findings(f)
if e != nil {
fail(c, 500, "查询失败")
return
}
c.JSON(200, gin.H{"data": v, "total": total, "page": f.Page})
}
func (s *Server) review(c *gin.Context) {
n, ok := id(c)
if !ok {
return
}
var v struct {
Review string `json:"review"`
Note string `json:"note"`
}
if c.ShouldBindJSON(&v) != nil || len(v.Note) > 4000 {
fail(c, 400, "无效复核内容")
return
}
switch v.Review {
case "pending", "confirmed", "false_positive", "resolved":
default:
fail(c, 400, "无效复核状态")
return
}
if s.Store.Review(n, v.Review, security.Text(v.Note)) != nil {
fail(c, 404, "记录不存在")
return
}
s.Store.Log(session(c).User.Username, "review", strconv.FormatInt(n, 10))
c.JSON(200, gin.H{"message": "已保存"})
}
func (s *Server) deleteFinding(c *gin.Context) {
n, ok := id(c)
if !ok {
return
}
if s.Store.DeleteFinding(n) != nil {
fail(c, 500, "删除失败")
return
}
s.Store.Log(session(c).User.Username, "delete_finding", strconv.FormatInt(n, 10))
c.JSON(200, gin.H{"message": "已删除"})
}
func (s *Server) users(c *gin.Context) {
v, e := s.Store.Users()
if e != nil {
fail(c, 500, "查询失败")
return
}
c.JSON(200, gin.H{"data": v})
}
func (s *Server) createUser(c *gin.Context) {
var v struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}
if c.ShouldBindJSON(&v) != nil {
fail(c, 400, "无效请求")
return
}
if e := auth.Validate(v.Username, v.Password); e != nil {
fail(c, 400, e.Error())
return
}
if v.Role != "admin" && v.Role != "viewer" {
fail(c, 400, "无效角色")
return
}
h, e := auth.Hash(v.Password)
if e != nil {
fail(c, 500, "创建失败")
return
}
n, e := s.Store.CreateUser(v.Username, h, v.Role)
if e != nil {
fail(c, 409, "用户名已存在或创建失败")
return
}
s.Store.Log(session(c).User.Username, "create_user", strconv.FormatInt(n, 10))
c.JSON(201, gin.H{"id": n})
}
func (s *Server) updateUser(c *gin.Context) {
n, ok := id(c)
if !ok {
return
}
var v struct {
Role string `json:"role"`
Disabled bool `json:"disabled"`
}
if c.ShouldBindJSON(&v) != nil {
fail(c, 400, "无效请求")
return
}
if e := s.Auth.SetUser(n, v.Role, v.Disabled); e != nil {
fail(c, 400, e.Error())
return
}
s.Store.Log(session(c).User.Username, "update_user", strconv.FormatInt(n, 10))
c.JSON(200, gin.H{"message": "已更新,会话已失效"})
}
func (s *Server) audit(c *gin.Context) {
v, total, e := s.Store.Audit(store.AuditFilter{Page: page(c), Search: c.Query("search"), Action: c.Query("action")})
if e != nil {
fail(c, 500, "查询失败")
return
}
c.JSON(200, gin.H{"data": v, "total": total})
}
+252
View File
@@ -0,0 +1,252 @@
package httpapi
import (
"crypto/subtle"
"net/http"
"net/url"
"strconv"
"sync"
"time"
"github.com/gin-gonic/gin"
"yuequanScan/internal/auth"
"yuequanScan/internal/store"
)
type Server struct {
ScannerStatus func() map[string]any
Store *store.Store
Auth *auth.Service
SecureCookie bool
attemptsMu sync.Mutex
attempts map[string]attempt
}
type attempt struct {
Count int
Until time.Time
}
func New(db *store.Store, secure bool) *Server {
return &Server{Store: db, Auth: &auth.Service{Store: db}, SecureCookie: secure, attempts: map[string]attempt{}}
}
func fail(c *gin.Context, status int, message string) {
c.AbortWithStatusJSON(status, gin.H{"error": message})
}
func (s *Server) Router() *gin.Engine {
r := gin.New()
r.Use(gin.Recovery(), func(c *gin.Context) {
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("Referrer-Policy", "no-referrer")
c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'")
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
c.Next()
})
r.SetTrustedProxies(nil)
a := r.Group("/api")
a.Use(func(c *gin.Context) { c.Header("Cache-Control", "no-store"); c.Next() })
a.GET("/auth/status", s.status)
a.POST("/auth/setup", s.sameOrigin, s.setup)
a.POST("/auth/login", s.sameOrigin, s.login)
p := a.Group("")
p.Use(s.requireSession)
p.GET("/auth/me", s.me)
p.POST("/auth/logout", s.logout)
p.POST("/auth/password", s.password)
p.GET("/stats", s.stats)
p.GET("/dashboard", s.dashboard)
p.GET("/findings", s.findings)
p.GET("/findings/:id", s.finding)
p.GET("/export", s.export)
admin := p.Group("")
admin.Use(s.requireAdmin)
admin.POST("/findings/bulk-review", s.bulkReview)
admin.PATCH("/findings/:id", s.review)
admin.DELETE("/findings/:id", s.deleteFinding)
admin.GET("/users", s.users)
admin.POST("/users", s.createUser)
admin.PATCH("/users/:id", s.updateUser)
admin.GET("/audit", s.audit)
admin.GET("/scanner/status", func(c *gin.Context) {
if s.ScannerStatus == nil {
c.JSON(200, gin.H{})
return
}
c.JSON(200, s.ScannerStatus())
})
admin.GET("/settings", s.settings)
admin.PUT("/settings", s.saveSettings)
return r
}
func (s *Server) sameOrigin(c *gin.Context) {
if origin := c.GetHeader("Origin"); origin != "" {
u, e := url.Parse(origin)
if e != nil || u.Host != c.Request.Host || (u.Scheme != "http" && u.Scheme != "https") {
fail(c, 403, "请求来源不允许")
return
}
}
if site := c.GetHeader("Sec-Fetch-Site"); site == "cross-site" {
fail(c, 403, "请求来源不允许")
return
}
}
func (s *Server) requireSession(c *gin.Context) {
token, e := c.Cookie("privhunter_session")
if e != nil {
fail(c, 401, "请先登录")
return
}
v, e := s.Auth.Authenticate(token)
if e != nil {
fail(c, 401, "会话已过期,请重新登录")
return
}
c.Set("session", v)
if c.Request.Method != "GET" && c.Request.Method != "HEAD" {
s.sameOrigin(c)
if c.IsAborted() {
return
}
if subtle.ConstantTimeCompare([]byte(c.GetHeader("X-CSRF-Token")), []byte(v.CSRF)) != 1 {
fail(c, 403, "请求校验失败")
return
}
}
c.Next()
}
func session(c *gin.Context) auth.Session { return c.MustGet("session").(auth.Session) }
func (s *Server) requireAdmin(c *gin.Context) {
if session(c).User.Role != "admin" {
fail(c, 403, "需要管理员权限")
return
}
c.Next()
}
func id(c *gin.Context) (int64, bool) {
n, e := strconv.ParseInt(c.Param("id"), 10, 64)
if e != nil || n < 1 {
fail(c, 400, "无效记录编号")
return 0, false
}
return n, true
}
func page(c *gin.Context) int {
n, _ := strconv.Atoi(c.Query("page"))
if n < 1 {
n = 1
}
return n
}
func (s *Server) status(c *gin.Context) {
n, e := s.Store.UserCount()
if e != nil {
fail(c, 500, "数据库不可用")
return
}
c.JSON(200, gin.H{"setupRequired": n == 0})
}
func (s *Server) me(c *gin.Context) {
v := session(c)
c.JSON(200, gin.H{"user": v.User, "csrf": v.CSRF})
}
func (s *Server) setup(c *gin.Context) {
var v struct {
Username string `json:"username"`
Password string `json:"password"`
}
if c.ShouldBindJSON(&v) != nil {
fail(c, 400, "无效请求")
return
}
if e := auth.Validate(v.Username, v.Password); e != nil {
fail(c, 400, e.Error())
return
}
h, e := auth.Hash(v.Password)
if e != nil {
fail(c, 500, "初始化失败")
return
}
ok, e := s.Store.Bootstrap(v.Username, h)
if e != nil {
fail(c, 500, "初始化失败")
return
}
if !ok {
fail(c, 409, "系统已初始化")
return
}
s.Store.Log(v.Username, "setup", "administrator")
c.JSON(201, gin.H{"message": "初始化完成,请登录"})
}
func (s *Server) login(c *gin.Context) {
var v struct {
Username string `json:"username"`
Password string `json:"password"`
}
if c.ShouldBindJSON(&v) != nil || len(v.Password) > 72 {
fail(c, 400, "无效请求")
return
}
key := c.ClientIP()
now := time.Now()
s.attemptsMu.Lock()
a := s.attempts[key]
if now.Before(a.Until) && a.Count >= 10 {
s.attemptsMu.Unlock()
fail(c, 429, "尝试次数过多,请稍后再试")
return
}
if !now.Before(a.Until) {
a = attempt{Until: now.Add(5 * time.Minute)}
}
a.Count++
s.attempts[key] = a
if len(s.attempts) > 1024 {
for k, v := range s.attempts {
if now.After(v.Until) {
delete(s.attempts, k)
}
}
}
s.attemptsMu.Unlock()
token, vsession, e := s.Auth.Login(v.Username, v.Password)
if e != nil {
fail(c, 401, "用户名或密码错误")
return
}
s.attemptsMu.Lock()
delete(s.attempts, key)
s.attemptsMu.Unlock()
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie("privhunter_session", token, 43200, "/", "", s.SecureCookie, true)
s.Store.Log(vsession.User.Username, "login", "session")
c.JSON(200, gin.H{"user": vsession.User, "csrf": vsession.CSRF})
}
func (s *Server) logout(c *gin.Context) {
token, _ := c.Cookie("privhunter_session")
if s.Auth.Logout(token) != nil {
fail(c, 500, "退出失败")
return
}
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie("privhunter_session", "", -1, "/", "", s.SecureCookie, true)
c.JSON(200, gin.H{"message": "已退出"})
}
func (s *Server) password(c *gin.Context) {
var v struct {
Current string `json:"current"`
Password string `json:"password"`
}
if c.ShouldBindJSON(&v) != nil {
fail(c, 400, "无效请求")
return
}
if e := s.Auth.ChangePassword(session(c).User.ID, v.Current, v.Password); e != nil {
fail(c, 400, e.Error())
return
}
s.Store.Log(session(c).User.Username, "password_changed", "self")
c.JSON(200, gin.H{"message": "密码已修改,请重新登录"})
}
+66
View File
@@ -0,0 +1,66 @@
package httpapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"yuequanScan/internal/store"
)
func TestAuthAndCSRF(t *testing.T) {
db, e := store.Open(filepath.Join(t.TempDir(), "api.db"))
if e != nil {
t.Fatal(e)
}
defer db.Close()
router := New(db, false).Router()
request := func(method, path, body string, cookie *http.Cookie, csrf string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, bytes.NewBufferString(body))
r.Header.Set("Content-Type", "application/json")
if cookie != nil {
r.AddCookie(cookie)
}
r.Header.Set("X-CSRF-Token", csrf)
w := httptest.NewRecorder()
router.ServeHTTP(w, r)
return w
}
if w := request("GET", "/api/findings", "", nil, ""); w.Code != 401 {
t.Fatal("unprotected findings", w.Code)
}
if w := request("GET", "/api/findings/1", "", nil, ""); w.Code != 401 {
t.Fatal("unprotected evidence", w.Code)
}
if w := request("POST", "/api/auth/setup", `{"username":"admin","password":"test-password-123"}`, nil, ""); w.Code != 201 {
t.Fatal("setup failed", w.Code)
}
w := request("POST", "/api/auth/login", `{"username":"admin","password":"test-password-123"}`, nil, "")
if w.Code != 200 {
t.Fatal("login failed", w.Code)
}
cookies := w.Result().Cookies()
if w := request("GET", "/api/findings/999", "", cookies[0], ""); w.Code != 404 {
t.Fatal("missing evidence status", w.Code)
}
if len(cookies) != 1 || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteStrictMode {
t.Fatal("unsafe cookie")
}
var payload struct {
CSRF string `json:"csrf"`
}
if e = json.Unmarshal(w.Body.Bytes(), &payload); e != nil {
t.Fatal(e)
}
if w = request("POST", "/api/auth/logout", "{}", cookies[0], ""); w.Code != 403 {
t.Fatal("CSRF bypass", w.Code)
}
if w = request("POST", "/api/auth/logout", "{}", cookies[0], payload.CSRF); w.Code != 200 {
t.Fatal("logout failed", w.Code)
}
if w = request("GET", "/api/auth/me", "", cookies[0], ""); w.Code != 401 {
t.Fatal("logout did not revoke", w.Code)
}
}
+98
View File
@@ -0,0 +1,98 @@
package httpapi
import (
"errors"
"net/url"
"strings"
"yuequanScan/internal/store"
"github.com/gin-gonic/gin"
)
func (s *Server) settings(c *gin.Context) {
v, e := s.Store.Settings()
if e != nil {
fail(c, 500, "设置读取失败")
return
}
c.JSON(200, gin.H{"settings": v, "hasAPIKey": v.APIKey != "", "hasHeaders": len(v.Headers) > 0})
}
func (s *Server) saveSettings(c *gin.Context) {
var input struct {
Revision *int64 `json:"revision"`
Paused bool `json:"paused"`
Domains []string `json:"domains"`
Endpoint string `json:"endpoint"`
Model string `json:"model"`
APIKey *string `json:"apiKey"`
Headers *map[string]string `json:"headers"`
SkipSuffixes []string `json:"skipSuffixes"`
DenyKeywords []string `json:"denyKeywords"`
}
if c.ShouldBindJSON(&input) != nil {
fail(c, 400, "设置格式无效")
return
}
if input.Revision == nil || *input.Revision < 0 {
fail(c, 400, "配置版本缺失,请重新加载设置")
return
}
if len(input.Domains) > 100 || len(input.Model) > 200 || len(input.DenyKeywords) > 100 {
fail(c, 400, "设置超过限制")
return
}
if input.Endpoint != "" {
u, e := url.Parse(input.Endpoint)
if e != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || (u.Scheme != "https" && u.Scheme != "http") {
fail(c, 400, "模型端点必须是有效 HTTP(S) URL")
return
}
}
for i, d := range input.Domains {
d = strings.ToLower(strings.TrimSpace(d))
if d == "" || strings.ContainsAny(d, "/:?@ ") || strings.Contains(d, "*") {
fail(c, 400, "目标范围应为精确域名或 IP,不含协议和通配符")
return
}
input.Domains[i] = d
}
if input.Headers != nil {
for k, v := range *input.Headers {
if k == "" || strings.ContainsAny(k+v, "\r\n") {
fail(c, 400, "请求头格式无效")
return
}
}
}
old, e := s.Store.Settings()
if e != nil {
fail(c, 500, "设置读取失败")
return
}
old.Paused = input.Paused
old.Domains = input.Domains
old.Endpoint = input.Endpoint
old.Model = input.Model
old.SkipSuffixes = input.SkipSuffixes
old.DenyKeywords = input.DenyKeywords
if input.APIKey != nil {
old.APIKey = *input.APIKey
}
if input.Headers != nil {
old.Headers = *input.Headers
}
if !old.Paused && (len(old.Domains) == 0 || len(old.Headers) == 0) {
fail(c, 400, "启用扫描前请设置目标范围和账号 B 请求头")
return
}
if e := s.Store.SaveSettingsRevision(old, *input.Revision); e != nil {
if errors.Is(e, store.ErrSettingsConflict) {
fail(c, 409, "设置已被其他操作修改。请重新加载后再保存;当前草稿仍保留。")
return
}
fail(c, 500, "设置保存失败")
return
}
s.Store.Log(session(c).User.Username, "update_settings", "scanner")
c.JSON(200, gin.H{"message": "设置已保存"})
}
+74
View File
@@ -0,0 +1,74 @@
package httpapi
import (
"bytes"
"encoding/json"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"yuequanScan/internal/auth"
"yuequanScan/internal/store"
)
func TestSettingsDisclosureAndViewerPermissions(t *testing.T) {
db, e := store.Open(filepath.Join(t.TempDir(), "settings.db"))
if e != nil {
t.Fatal(e)
}
defer db.Close()
hash, _ := auth.Hash("fictional-password-123")
db.Bootstrap("admin", hash)
db.CreateUser("viewer", hash, "viewer")
server := New(db, false)
router := server.Router()
token, session, e := server.Auth.Login("admin", "fictional-password-123")
if e != nil {
t.Fatal(e)
}
call := func(method, path, body, token, csrf string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, bytes.NewBufferString(body))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Cookie", "privhunter_session="+token)
r.Header.Set("X-CSRF-Token", csrf)
w := httptest.NewRecorder()
router.ServeHTTP(w, r)
return w
}
body := `{"revision":0,"paused":true,"domains":["example.test"],"apiKey":"synthetic-key","headers":{"Cookie":"synthetic-cookie"},"endpoint":"https://example.test/chat","model":"fixture"}`
if w := call("PUT", "/api/settings", body, token, session.CSRF); w.Code != 200 {
t.Fatal("save failed", w.Code)
}
w := call("GET", "/api/settings", "", token, "")
if w.Code != 200 || strings.Contains(w.Body.String(), "synthetic") {
t.Fatal("secret disclosed")
}
var v struct {
HasKey bool `json:"hasAPIKey"`
HasHeaders bool `json:"hasHeaders"`
}
json.Unmarshal(w.Body.Bytes(), &v)
if !v.HasKey || !v.HasHeaders {
t.Fatal("missing configured flags")
}
viewer, vs, e := server.Auth.Login("viewer", "fictional-password-123")
if e != nil {
t.Fatal(e)
}
for _, path := range []string{"/api/settings", "/api/users", "/api/audit"} {
if w := call("GET", path, "", viewer, vs.CSRF); w.Code != 403 {
t.Fatal("viewer access", path, w.Code)
}
}
if w := call("PUT", "/api/settings", body, viewer, vs.CSRF); w.Code != 403 {
t.Fatal("viewer mutated settings")
}
}
func TestCSVFormulaGuard(t *testing.T) {
for _, value := range []string{"=1+1", "+cmd", "-cmd", "@cmd", "\tformula", "\rformula"} {
if !strings.HasPrefix(safeCell(value), "'") {
t.Fatal("formula not escaped")
}
}
}