mirror of
https://github.com/Ed1s0nZ/PrivHunterAI.git
synced 2026-10-28 19:30:58 +00:00
refactor: rebuild PrivHunter with React, Gin and SQLite
This commit is contained in:
1 parent
2c842cb1da
commit
bea3940977
96 files changed
+9150
-2309
No files matched your search
@@ -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)})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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": "密码已修改,请重新登录"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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": "设置已保存"})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user