mirror of
https://github.com/BishopFox/joro.git
synced 2026-11-07 10:25:57 -10:00
Added basic functionality for passive vulnerability detection
This commit is contained in:
1 parent
dd98f31e0f
commit
211079f02b
32 files changed
+9841
-31
No files matched your search
@@ -0,0 +1,50 @@
|
||||
package api
|
||||
|
||||
import "context"
|
||||
|
||||
// StartDetectLoop launches the passive detection scanner in the background. It
|
||||
// runs until ctx is cancelled, mirroring StartAutoSaveLoop.
|
||||
func (s *APIServer) StartDetectLoop(ctx context.Context) {
|
||||
if s.detectScanner == nil {
|
||||
return
|
||||
}
|
||||
// Retained so a rescan can outlive the HTTP request that started it.
|
||||
s.mu.Lock()
|
||||
s.detectCtx = ctx
|
||||
s.mu.Unlock()
|
||||
go s.detectScanner.Run(ctx)
|
||||
}
|
||||
|
||||
// detectBackgroundCtx returns the server-lifetime context for rescan jobs,
|
||||
// falling back to context.Background() when the loop was never started.
|
||||
func (s *APIServer) detectBackgroundCtx() context.Context {
|
||||
s.mu.RLock()
|
||||
ctx := s.detectCtx
|
||||
s.mu.RUnlock()
|
||||
if ctx == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// resetDetectCursor moves the live-scan watermark. Must be called wherever
|
||||
// proxy.Store rewrites its sequence numbering (Clear zeroes nextSeq, LoadItems
|
||||
// rewrites it); a stale high cursor stops detection for the session.
|
||||
func (s *APIServer) resetDetectCursor(seq int) {
|
||||
if s.detectScanner == nil {
|
||||
return
|
||||
}
|
||||
s.detectScanner.ResetCursor(seq)
|
||||
}
|
||||
|
||||
// clearDetectFindingsWithHistory clears findings alongside request history when
|
||||
// Config.ClearFindingsWithHistory is set. Off by default.
|
||||
func (s *APIServer) clearDetectFindingsWithHistory() {
|
||||
if s.detectEngine == nil || s.detectFindings == nil {
|
||||
return
|
||||
}
|
||||
if s.detectEngine.Config().ClearFindingsWithHistory {
|
||||
s.detectFindings.Clear()
|
||||
s.broadcastDetectSummary()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
)
|
||||
|
||||
// maxPersistedFindings bounds how many findings ride in a project file. At
|
||||
// roughly 400 bytes of JSON each, the full allowance is about 1.5 MB pre-gzip.
|
||||
const maxPersistedFindings = 2000
|
||||
|
||||
// maxPersistedOccurrences trims the sighting list per finding for storage.
|
||||
const maxPersistedOccurrences = 5
|
||||
|
||||
// detectStateForProject snapshots the live detection state into project DTOs.
|
||||
func (s *APIServer) detectStateForProject() (
|
||||
enabled bool,
|
||||
cfg *projectDetectConfig,
|
||||
disabled []string,
|
||||
overrides map[string]string,
|
||||
rules []projectDetectRule,
|
||||
findings []projectDetectFinding,
|
||||
) {
|
||||
if s.detectEngine == nil {
|
||||
return false, nil, nil, nil, nil, nil
|
||||
}
|
||||
|
||||
enabled = s.detectEngine.IsEnabled()
|
||||
c := s.detectEngine.Config()
|
||||
cfg = &projectDetectConfig{
|
||||
ScopeOnly: c.ScopeOnly,
|
||||
ScanRequests: c.ScanRequests,
|
||||
PersistFindings: c.PersistFindings,
|
||||
ClearFindingsWithHistory: c.ClearFindingsWithHistory,
|
||||
MaxBodyScanBytes: c.MaxBodyScanBytes,
|
||||
MaxRequestBodyScanBytes: c.MaxRequestBodyScanBytes,
|
||||
SkipContentTypes: c.SkipContentTypes,
|
||||
SkipExtensions: c.SkipExtensions,
|
||||
ExcludeHosts: c.ExcludeHosts,
|
||||
}
|
||||
|
||||
// Sorted so a project file is stable across saves and diffs cleanly.
|
||||
disabled = s.detectEngine.DisabledBuiltins()
|
||||
sort.Strings(disabled)
|
||||
overrides = s.detectEngine.SeverityOverrides()
|
||||
|
||||
for _, r := range s.detectEngine.UserRules() {
|
||||
rules = append(rules, projectDetectRule{
|
||||
ID: r.ID, Name: r.Name, Description: r.Description, Remediation: r.Remediation,
|
||||
Category: string(r.Category), Severity: string(r.Severity),
|
||||
Confidence: string(r.Confidence), Target: string(r.Target),
|
||||
Pattern: r.Pattern, Literal: r.Literal, CaptureGroup: r.CaptureGroup,
|
||||
PostFilters: r.PostFilters, GroupBy: string(r.GroupBy),
|
||||
ContentTypes: r.ContentTypes, StatusCodes: r.StatusCodes, Scheme: r.Scheme,
|
||||
MinLength: r.MinLength, MinEntropy: r.MinEntropy,
|
||||
MaxPerResponse: r.MaxPerResponse, RedactEvidence: r.RedactEvidence,
|
||||
Enabled: r.Enabled,
|
||||
})
|
||||
}
|
||||
|
||||
if c.PersistFindings && s.detectFindings != nil {
|
||||
findings = persistableFindings(s.detectFindings.All())
|
||||
}
|
||||
return enabled, cfg, disabled, overrides, rules, findings
|
||||
}
|
||||
|
||||
// persistableFindings selects and trims findings for storage. Triaged findings
|
||||
// (false-positive marks and notes) are always kept when the list is capped,
|
||||
// regardless of severity.
|
||||
func persistableFindings(all []detect.Finding) []projectDetectFinding {
|
||||
triaged := make([]detect.Finding, 0, len(all))
|
||||
rest := make([]detect.Finding, 0, len(all))
|
||||
for _, f := range all {
|
||||
if f.FalsePositive || f.Notes != "" {
|
||||
triaged = append(triaged, f)
|
||||
continue
|
||||
}
|
||||
rest = append(rest, f)
|
||||
}
|
||||
|
||||
// Highest severity first, then most recently seen.
|
||||
sort.SliceStable(rest, func(i, j int) bool {
|
||||
if a, b := rest[i].Severity.Rank(), rest[j].Severity.Rank(); a != b {
|
||||
return a > b
|
||||
}
|
||||
return rest[i].LastSeen.After(rest[j].LastSeen)
|
||||
})
|
||||
|
||||
selected := triaged
|
||||
for _, f := range rest {
|
||||
if len(selected) >= maxPersistedFindings {
|
||||
break
|
||||
}
|
||||
selected = append(selected, f)
|
||||
}
|
||||
|
||||
out := make([]projectDetectFinding, 0, len(selected))
|
||||
for _, f := range selected {
|
||||
occ := f.Occurrences
|
||||
if len(occ) > maxPersistedOccurrences {
|
||||
occ = occ[len(occ)-maxPersistedOccurrences:]
|
||||
}
|
||||
pOcc := make([]projectDetectOccurrence, 0, len(occ))
|
||||
for _, o := range occ {
|
||||
pOcc = append(pOcc, projectDetectOccurrence{
|
||||
RequestID: o.RequestID, Seq: o.Seq, Method: o.Method, URL: o.URL,
|
||||
StatusCode: o.StatusCode, Timestamp: o.Timestamp.Format(time.RFC3339Nano),
|
||||
Offset: o.Offset, Part: o.Part,
|
||||
})
|
||||
}
|
||||
out = append(out, projectDetectFinding{
|
||||
ID: f.ID, RuleID: f.RuleID, RuleName: f.RuleName,
|
||||
Category: string(f.Category), Severity: string(f.Severity),
|
||||
Confidence: string(f.Confidence), Target: string(f.Target),
|
||||
Host: f.Host, Method: f.Method, URL: f.URL, RequestID: f.RequestID,
|
||||
Detail: f.Detail, Evidence: f.Evidence, RawEvidence: f.RawEvidence,
|
||||
EvidenceOffset: f.EvidenceOffset, EvidenceLength: f.EvidenceLength,
|
||||
EvidencePart: f.EvidencePart,
|
||||
FirstSeen: f.FirstSeen.Format(time.RFC3339Nano),
|
||||
LastSeen: f.LastSeen.Format(time.RFC3339Nano),
|
||||
Count: f.Count, FalsePositive: f.FalsePositive, Notes: f.Notes,
|
||||
Truncated: f.Truncated, SeverityOverridden: f.SeverityOverridden,
|
||||
Occurrences: pOcc,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// applyDetectProjectConfig restores detection state from a project file. Returns
|
||||
// the values the load response reports back to the frontend.
|
||||
func (s *APIServer) applyDetectProjectConfig(cfg *projectConfigFile) map[string]any {
|
||||
if s.detectEngine == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.detectEngine.SetEnabled(cfg.DetectEnabled)
|
||||
|
||||
dc := detect.DefaultConfig()
|
||||
if cfg.DetectConfig != nil {
|
||||
p := cfg.DetectConfig
|
||||
dc = detect.Config{
|
||||
ScopeOnly: p.ScopeOnly,
|
||||
ScanRequests: p.ScanRequests,
|
||||
PersistFindings: p.PersistFindings,
|
||||
ClearFindingsWithHistory: p.ClearFindingsWithHistory,
|
||||
MaxBodyScanBytes: p.MaxBodyScanBytes,
|
||||
MaxRequestBodyScanBytes: p.MaxRequestBodyScanBytes,
|
||||
SkipContentTypes: p.SkipContentTypes,
|
||||
SkipExtensions: p.SkipExtensions,
|
||||
ExcludeHosts: p.ExcludeHosts,
|
||||
}
|
||||
}
|
||||
s.detectEngine.SetConfig(dc)
|
||||
|
||||
rules := make([]detect.Rule, 0, len(cfg.DetectRules))
|
||||
for _, r := range cfg.DetectRules {
|
||||
rules = append(rules, detect.Rule{
|
||||
// The ID is preserved; findings reference it.
|
||||
ID: r.ID, Name: r.Name, Description: r.Description, Remediation: r.Remediation,
|
||||
Kind: detect.KindRegex, Category: detect.Category(r.Category),
|
||||
Severity: detect.Severity(r.Severity), Confidence: detect.Confidence(r.Confidence),
|
||||
Target: detect.Target(r.Target), Pattern: r.Pattern, Literal: r.Literal,
|
||||
CaptureGroup: r.CaptureGroup, PostFilters: r.PostFilters,
|
||||
GroupBy: detect.GroupBy(r.GroupBy), ContentTypes: r.ContentTypes,
|
||||
StatusCodes: r.StatusCodes, Scheme: r.Scheme, MinLength: r.MinLength,
|
||||
MinEntropy: r.MinEntropy, MaxPerResponse: r.MaxPerResponse,
|
||||
RedactEvidence: r.RedactEvidence, Enabled: r.Enabled,
|
||||
})
|
||||
}
|
||||
s.detectEngine.SetUserRules(rules)
|
||||
s.detectEngine.SetDisabledBuiltins(cfg.DetectDisabledRules)
|
||||
s.detectEngine.SetSeverityOverrides(cfg.DetectSeverityOverrides)
|
||||
|
||||
if s.detectFindings != nil {
|
||||
s.detectFindings.Load(decodePersistedFindings(cfg.DetectFindings))
|
||||
}
|
||||
|
||||
// The load response carries a finding count, not the findings themselves; the
|
||||
// frontend paginates GET /detect/findings.
|
||||
return map[string]any{
|
||||
"detectEnabled": cfg.DetectEnabled,
|
||||
"detectConfig": s.detectEngine.Config(),
|
||||
"detectRules": s.detectEngine.UserRules(),
|
||||
"detectDisabledRules": s.detectEngine.DisabledBuiltins(),
|
||||
"detectSeverityOverrides": s.detectEngine.SeverityOverrides(),
|
||||
"detectFindingCount": len(cfg.DetectFindings),
|
||||
}
|
||||
}
|
||||
|
||||
// decodePersistedFindings rebuilds findings from project DTOs.
|
||||
func decodePersistedFindings(in []projectDetectFinding) []detect.Finding {
|
||||
out := make([]detect.Finding, 0, len(in))
|
||||
for _, p := range in {
|
||||
first, _ := time.Parse(time.RFC3339Nano, p.FirstSeen)
|
||||
last, _ := time.Parse(time.RFC3339Nano, p.LastSeen)
|
||||
occ := make([]detect.Occurrence, 0, len(p.Occurrences))
|
||||
for _, o := range p.Occurrences {
|
||||
ts, _ := time.Parse(time.RFC3339Nano, o.Timestamp)
|
||||
occ = append(occ, detect.Occurrence{
|
||||
RequestID: o.RequestID, Seq: o.Seq, Method: o.Method, URL: o.URL,
|
||||
StatusCode: o.StatusCode, Timestamp: ts, Offset: o.Offset, Part: o.Part,
|
||||
})
|
||||
}
|
||||
out = append(out, detect.Finding{
|
||||
ID: p.ID, RuleID: p.RuleID, RuleName: p.RuleName,
|
||||
Category: detect.Category(p.Category), Severity: detect.Severity(p.Severity),
|
||||
Confidence: detect.Confidence(p.Confidence), Target: detect.Target(p.Target),
|
||||
Host: p.Host, Method: p.Method, URL: p.URL, RequestID: p.RequestID,
|
||||
Detail: p.Detail, Evidence: p.Evidence, RawEvidence: p.RawEvidence,
|
||||
EvidenceOffset: p.EvidenceOffset, EvidenceLength: p.EvidenceLength,
|
||||
EvidencePart: p.EvidencePart,
|
||||
FirstSeen: first, LastSeen: last, Count: p.Count,
|
||||
FalsePositive: p.FalsePositive, Notes: p.Notes, Truncated: p.Truncated,
|
||||
SeverityOverridden: p.SeverityOverridden, Occurrences: occ,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resetDetectLiveState restores detection to shipped defaults for a fresh
|
||||
// project: built-ins return to the default-enabled table.
|
||||
func (s *APIServer) resetDetectLiveState() {
|
||||
if s.detectEngine == nil {
|
||||
return
|
||||
}
|
||||
s.detectEngine.SetEnabled(true)
|
||||
s.detectEngine.SetConfig(detect.DefaultConfig())
|
||||
s.detectEngine.SetUserRules(nil)
|
||||
s.detectEngine.SetDisabledBuiltins(nil)
|
||||
s.detectEngine.SetSeverityOverrides(nil)
|
||||
if s.detectFindings != nil {
|
||||
s.detectFindings.Clear()
|
||||
}
|
||||
s.resetDetectCursor(0)
|
||||
}
|
||||
|
||||
// detectSignature contributes to the auto-save fingerprint. It includes the
|
||||
// findings store's revision counter: the rest of the fingerprint is built from
|
||||
// counts, and a false-positive toggle or note edit changes no count.
|
||||
func (s *APIServer) detectSignature() string {
|
||||
if s.detectEngine == nil || s.detectFindings == nil {
|
||||
return ""
|
||||
}
|
||||
return "/dr" + itoa(len(s.detectEngine.UserRules())) +
|
||||
"/dd" + itoa(len(s.detectEngine.DisabledBuiltins())) +
|
||||
"/df" + itoa(s.detectFindings.Count()) +
|
||||
"/dg" + utoa(s.detectFindings.Revision())
|
||||
}
|
||||
|
||||
// itoa and utoa keep detectSignature free of a fmt dependency.
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
func utoa(n uint64) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [24]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/configstore"
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
"github.com/hashicorp/go-uuid"
|
||||
)
|
||||
@@ -81,25 +82,117 @@ type projectCapturedRequest struct {
|
||||
RespRaw []byte `json:"respRaw,omitempty"`
|
||||
}
|
||||
|
||||
// projectDetectConfig mirrors detect.Config for the project file.
|
||||
type projectDetectConfig struct {
|
||||
ScopeOnly bool `json:"scopeOnly"`
|
||||
ScanRequests bool `json:"scanRequests"`
|
||||
PersistFindings bool `json:"persistFindings"`
|
||||
ClearFindingsWithHistory bool `json:"clearFindingsWithHistory"`
|
||||
MaxBodyScanBytes int `json:"maxBodyScanBytes"`
|
||||
MaxRequestBodyScanBytes int `json:"maxRequestBodyScanBytes"`
|
||||
SkipContentTypes []string `json:"skipContentTypes,omitempty"`
|
||||
SkipExtensions []string `json:"skipExtensions,omitempty"`
|
||||
ExcludeHosts []string `json:"excludeHosts,omitempty"`
|
||||
}
|
||||
|
||||
// projectDetectRule is a custom detection rule as persisted. Unlike the scope,
|
||||
// noise, replace, and custom-data DTOs, this one keeps ID: Finding.RuleID
|
||||
// references it and Finding.ID derives from it.
|
||||
type projectDetectRule struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Remediation string `json:"remediation,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Severity string `json:"severity"`
|
||||
Confidence string `json:"confidence"`
|
||||
Target string `json:"target"`
|
||||
Pattern string `json:"pattern"`
|
||||
Literal string `json:"literal,omitempty"`
|
||||
CaptureGroup int `json:"captureGroup,omitempty"`
|
||||
PostFilters []string `json:"postFilters,omitempty"`
|
||||
GroupBy string `json:"groupBy,omitempty"`
|
||||
ContentTypes []string `json:"contentTypes,omitempty"`
|
||||
StatusCodes string `json:"statusCodes,omitempty"`
|
||||
Scheme string `json:"scheme,omitempty"`
|
||||
MinLength int `json:"minLength,omitempty"`
|
||||
MinEntropy float64 `json:"minEntropy,omitempty"`
|
||||
MaxPerResponse int `json:"maxPerResponse,omitempty"`
|
||||
RedactEvidence bool `json:"redactEvidence,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// projectDetectOccurrence is a trimmed sighting record.
|
||||
type projectDetectOccurrence struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Seq int `json:"seq"`
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
StatusCode int `json:"statusCode"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Offset int `json:"offset"`
|
||||
Part string `json:"part,omitempty"`
|
||||
}
|
||||
|
||||
// projectDetectFinding is a finding as persisted. ID is preserved: it is the
|
||||
// dedupe identity (a hash of rule, host, and grouping dimension), so a reloaded
|
||||
// project merges with a subsequent scan.
|
||||
type projectDetectFinding struct {
|
||||
ID string `json:"id"`
|
||||
RuleID string `json:"ruleId"`
|
||||
RuleName string `json:"ruleName"`
|
||||
Category string `json:"category"`
|
||||
Severity string `json:"severity"`
|
||||
Confidence string `json:"confidence"`
|
||||
Target string `json:"target"`
|
||||
Host string `json:"host"`
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Evidence string `json:"evidence"`
|
||||
RawEvidence string `json:"rawEvidence,omitempty"`
|
||||
EvidenceOffset int `json:"evidenceOffset,omitempty"`
|
||||
EvidenceLength int `json:"evidenceLength,omitempty"`
|
||||
EvidencePart string `json:"evidencePart,omitempty"`
|
||||
FirstSeen string `json:"firstSeen"`
|
||||
LastSeen string `json:"lastSeen"`
|
||||
Count int `json:"count"`
|
||||
FalsePositive bool `json:"falsePositive,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
SeverityOverridden bool `json:"severityOverridden,omitempty"`
|
||||
Occurrences []projectDetectOccurrence `json:"occurrences,omitempty"`
|
||||
}
|
||||
|
||||
type projectConfigFile struct {
|
||||
Version int `json:"version"`
|
||||
AutoSave bool `json:"autoSave"`
|
||||
SaveHistory bool `json:"saveHistory"`
|
||||
ListenerURL string `json:"listenerUrl"`
|
||||
TeamToken string `json:"teamToken"`
|
||||
TeamNickname string `json:"teamNickname"`
|
||||
ScopeEnabled bool `json:"scopeEnabled"`
|
||||
ScopeRules []projectScopeRule `json:"scopeRules"`
|
||||
NoiseEnabled bool `json:"noiseEnabled"`
|
||||
NoisePatterns []projectNoisePattern `json:"noisePatterns"`
|
||||
ReplaceEnabled bool `json:"replaceEnabled"`
|
||||
ReplaceRules []projectReplaceRule `json:"replaceRules"`
|
||||
CustomDataEnabled bool `json:"customDataEnabled"`
|
||||
CustomDataItems []projectCustomItem `json:"customDataItems"`
|
||||
Notes []projectNote `json:"notes"`
|
||||
Highlights map[string]string `json:"highlights,omitempty"`
|
||||
RequestHistory []projectCapturedRequest `json:"requestHistory,omitempty"`
|
||||
PluginStates map[string]string `json:"pluginStates,omitempty"` // plugin name -> base64(opaque bytes)
|
||||
Version int `json:"version"`
|
||||
AutoSave bool `json:"autoSave"`
|
||||
SaveHistory bool `json:"saveHistory"`
|
||||
ListenerURL string `json:"listenerUrl"`
|
||||
TeamToken string `json:"teamToken"`
|
||||
TeamNickname string `json:"teamNickname"`
|
||||
ScopeEnabled bool `json:"scopeEnabled"`
|
||||
ScopeRules []projectScopeRule `json:"scopeRules"`
|
||||
NoiseEnabled bool `json:"noiseEnabled"`
|
||||
NoisePatterns []projectNoisePattern `json:"noisePatterns"`
|
||||
ReplaceEnabled bool `json:"replaceEnabled"`
|
||||
ReplaceRules []projectReplaceRule `json:"replaceRules"`
|
||||
CustomDataEnabled bool `json:"customDataEnabled"`
|
||||
CustomDataItems []projectCustomItem `json:"customDataItems"`
|
||||
|
||||
// Passive detection (schema v5).
|
||||
DetectEnabled bool `json:"detectEnabled"`
|
||||
DetectConfig *projectDetectConfig `json:"detectConfig,omitempty"`
|
||||
DetectDisabledRules []string `json:"detectDisabledRules,omitempty"`
|
||||
DetectSeverityOverrides map[string]string `json:"detectSeverityOverrides,omitempty"`
|
||||
DetectRules []projectDetectRule `json:"detectRules,omitempty"`
|
||||
DetectFindings []projectDetectFinding `json:"detectFindings,omitempty"`
|
||||
|
||||
Notes []projectNote `json:"notes"`
|
||||
Highlights map[string]string `json:"highlights,omitempty"`
|
||||
RequestHistory []projectCapturedRequest `json:"requestHistory,omitempty"`
|
||||
PluginStates map[string]string `json:"pluginStates,omitempty"` // plugin name -> base64(opaque bytes)
|
||||
}
|
||||
|
||||
// encodePluginStates base64-encodes each blob for transport inside a JSON
|
||||
@@ -222,8 +315,10 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
|
||||
projectFresh = s.pluginManager.ExportProjectStates()
|
||||
}
|
||||
|
||||
dEnabled, dCfg, dDisabled, dOverrides, dRules, dFindings := s.detectStateForProject()
|
||||
|
||||
return projectConfigFile{
|
||||
Version: 4,
|
||||
Version: 5,
|
||||
AutoSave: autoSave,
|
||||
SaveHistory: saveHistory,
|
||||
ListenerURL: listenerURL,
|
||||
@@ -237,10 +332,18 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
|
||||
ReplaceRules: pReplaceRules,
|
||||
CustomDataEnabled: s.customData.IsEnabled(),
|
||||
CustomDataItems: pCustomItems,
|
||||
Notes: pNotes,
|
||||
Highlights: pHighlights,
|
||||
RequestHistory: pReqs,
|
||||
PluginStates: encodePluginStates(mergePluginStates(projectGhost, projectFresh)),
|
||||
|
||||
DetectEnabled: dEnabled,
|
||||
DetectConfig: dCfg,
|
||||
DetectDisabledRules: dDisabled,
|
||||
DetectSeverityOverrides: dOverrides,
|
||||
DetectRules: dRules,
|
||||
DetectFindings: dFindings,
|
||||
|
||||
Notes: pNotes,
|
||||
Highlights: pHighlights,
|
||||
RequestHistory: pReqs,
|
||||
PluginStates: encodePluginStates(mergePluginStates(projectGhost, projectFresh)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +355,11 @@ func normalizeProjectConfig(cfg *projectConfigFile) {
|
||||
cfg.AutoSave = true
|
||||
cfg.SaveHistory = true
|
||||
}
|
||||
if cfg.Version < 5 {
|
||||
// A nil DetectConfig means the engine defaults, with detection enabled.
|
||||
cfg.DetectEnabled = true
|
||||
cfg.DetectConfig = nil
|
||||
}
|
||||
}
|
||||
|
||||
// projectMeta is the per-project metadata surfaced by the project browser and
|
||||
@@ -263,6 +371,7 @@ type projectMeta struct {
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
RequestCount int `json:"requestCount"`
|
||||
NoteCount int `json:"noteCount"`
|
||||
FindingCount int `json:"findingCount"`
|
||||
AutoSave bool `json:"autoSave"`
|
||||
SaveHistory bool `json:"saveHistory"`
|
||||
Active bool `json:"active"`
|
||||
@@ -322,6 +431,7 @@ func (s *APIServer) backfillProjectMeta(name string) projectMeta {
|
||||
m.NoteCount++
|
||||
}
|
||||
}
|
||||
m.FindingCount = len(cfg.DetectFindings)
|
||||
m.AutoSave = cfg.AutoSave
|
||||
m.SaveHistory = cfg.SaveHistory
|
||||
s.writeProjectMeta(name, m)
|
||||
@@ -376,7 +486,7 @@ func (s *APIServer) liveStateSignature() string {
|
||||
return fmt.Sprintf("r%d/s%d/n%d/u%d/h%d/sc%d/rp%d/cd%d/no%d",
|
||||
reqCount, lastSeq, noteCount, maxNoteUpdate, hlCount,
|
||||
len(s.scope.Rules()), len(s.replace.Rules()),
|
||||
len(s.customData.Items()), len(s.noise.Patterns()))
|
||||
len(s.customData.Items()), len(s.noise.Patterns())) + s.detectSignature()
|
||||
}
|
||||
|
||||
// saveProject snapshots live state to the named project's .joro (respecting its
|
||||
@@ -403,6 +513,7 @@ func (s *APIServer) saveProject(name string) error {
|
||||
s.writeProjectMeta(name, projectMeta{
|
||||
RequestCount: len(cfg.RequestHistory),
|
||||
NoteCount: noteCount,
|
||||
FindingCount: len(cfg.DetectFindings),
|
||||
AutoSave: autoSave,
|
||||
SaveHistory: saveHistory,
|
||||
})
|
||||
@@ -432,6 +543,9 @@ func (s *APIServer) resetLiveProjectState() {
|
||||
_ = s.noteStore.ClearAll()
|
||||
}
|
||||
s.store.Clear()
|
||||
// Clear zeroes the store's sequence counter; resetDetectLiveState moves the
|
||||
// detection cursor back to zero with it.
|
||||
s.resetDetectLiveState()
|
||||
|
||||
s.mu.Lock()
|
||||
s.highlights = make(map[string]string)
|
||||
@@ -599,6 +713,12 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
|
||||
s.store.Clear()
|
||||
}
|
||||
|
||||
// Both branches above rewrite the store's sequence counter, so park the scan
|
||||
// cursor at the restored high-water mark, not at zero. Loaded history is
|
||||
// re-examined by an explicit rescan.
|
||||
detectResp := s.applyDetectProjectConfig(cfg)
|
||||
s.resetDetectCursor(s.store.LastSeq())
|
||||
|
||||
var unknownPluginStates []string
|
||||
if s.pluginManager != nil {
|
||||
unknownPluginStates = s.pluginManager.ApplyProjectStates(decodedPluginStates)
|
||||
@@ -620,6 +740,7 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
|
||||
s.writeProjectMeta(name, projectMeta{
|
||||
RequestCount: len(cfg.RequestHistory),
|
||||
NoteCount: noteCount,
|
||||
FindingCount: len(cfg.DetectFindings),
|
||||
AutoSave: autoSave,
|
||||
SaveHistory: saveHistory,
|
||||
})
|
||||
@@ -645,6 +766,9 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
|
||||
"customDataItems": customItems,
|
||||
"historyRestored": true,
|
||||
}
|
||||
for k, v := range detectResp {
|
||||
resp[k] = v
|
||||
}
|
||||
if len(unknownPluginStates) > 0 {
|
||||
resp["unknownPluginStates"] = unknownPluginStates
|
||||
}
|
||||
@@ -1085,6 +1209,10 @@ type sharedConfigPayload struct {
|
||||
ReplaceRules []projectReplaceRule `json:"replaceRules"`
|
||||
CustomDataEnabled bool `json:"customDataEnabled"`
|
||||
CustomDataItems []projectCustomItem `json:"customDataItems"`
|
||||
|
||||
// Custom detection rules are shared. Findings are not: they carry per-operator
|
||||
// triage and their evidence can reference live secrets.
|
||||
DetectRules []projectDetectRule `json:"detectRules,omitempty"`
|
||||
}
|
||||
|
||||
// handleExportProjectConfig returns the current live project config as a
|
||||
@@ -1220,14 +1348,61 @@ func (s *APIServer) handleApplySharedConfig(w http.ResponseWriter, r *http.Reque
|
||||
s.customData.SetItems(customItems)
|
||||
s.customData.SetEnabled(body.Config.CustomDataEnabled || (merge && s.customData.IsEnabled()))
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
// Custom detection rules. Incoming rules get fresh IDs; no local finding
|
||||
// references them yet.
|
||||
resp := map[string]any{
|
||||
"scopeEnabled": s.scope.IsEnabled(),
|
||||
"scopeRules": s.scope.Rules(),
|
||||
"replaceEnabled": s.replace.IsEnabled(),
|
||||
"replaceRules": s.replace.Rules(),
|
||||
"customDataEnabled": s.customData.IsEnabled(),
|
||||
"customDataItems": s.customData.Items(),
|
||||
})
|
||||
}
|
||||
if s.detectEngine != nil && len(body.Config.DetectRules) > 0 {
|
||||
detectRules := make([]detect.Rule, 0, len(body.Config.DetectRules))
|
||||
if merge {
|
||||
detectRules = append(detectRules, s.detectEngine.UserRules()...)
|
||||
}
|
||||
for _, r := range body.Config.DetectRules {
|
||||
if merge && containsDetectRule(detectRules, r) {
|
||||
continue
|
||||
}
|
||||
rule := detect.Rule{
|
||||
ID: proxy.GenerateID(), Name: r.Name, Description: r.Description,
|
||||
Remediation: r.Remediation, Kind: detect.KindRegex,
|
||||
Category: detect.Category(r.Category), Severity: detect.Severity(r.Severity),
|
||||
Confidence: detect.Confidence(r.Confidence), Target: detect.Target(r.Target),
|
||||
Pattern: r.Pattern, Literal: r.Literal, CaptureGroup: r.CaptureGroup,
|
||||
PostFilters: r.PostFilters, GroupBy: detect.GroupBy(r.GroupBy),
|
||||
ContentTypes: r.ContentTypes, StatusCodes: r.StatusCodes, Scheme: r.Scheme,
|
||||
MinLength: r.MinLength, MinEntropy: r.MinEntropy,
|
||||
MaxPerResponse: r.MaxPerResponse, RedactEvidence: r.RedactEvidence,
|
||||
Enabled: true,
|
||||
}
|
||||
// A shared rule with an invalid pattern is skipped; the apply continues.
|
||||
if detect.ValidateRule(&rule) != nil {
|
||||
continue
|
||||
}
|
||||
detectRules = append(detectRules, rule)
|
||||
}
|
||||
s.detectEngine.SetUserRules(detectRules)
|
||||
s.broadcastDetectRules()
|
||||
resp["detectRules"] = s.detectEngine.UserRules()
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// containsDetectRule reports whether an equivalent rule is already present,
|
||||
// compared on the behavioral fields rather than on ID.
|
||||
func containsDetectRule(rules []detect.Rule, r projectDetectRule) bool {
|
||||
for _, x := range rules {
|
||||
if x.Name == r.Name && string(x.Category) == r.Category &&
|
||||
string(x.Target) == r.Target && x.Pattern == r.Pattern {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsScopeRule(rules []proxy.ScopeRule, r projectScopeRule) bool {
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
)
|
||||
|
||||
// detectAvailable rejects requests when detection is not wired, as in listener
|
||||
// and team-server mode. The routes are also behind the proxy-mode gate in
|
||||
// registerRoutes.
|
||||
func (s *APIServer) detectAvailable(w http.ResponseWriter) bool {
|
||||
if s.detectEngine == nil || s.detectFindings == nil || s.detectScanner == nil {
|
||||
writeError(w, http.StatusNotFound, "detection is unavailable in this mode")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ruleEnabledFunc returns a predicate for filtering findings by whether their
|
||||
// rule is currently switched on. Findings from a disabled rule are retained,
|
||||
// not deleted.
|
||||
func (s *APIServer) ruleEnabledFunc() func(string) bool {
|
||||
return s.detectEngine.RuleEnabledFunc()
|
||||
}
|
||||
|
||||
// handleGetDetect returns the overall detection state for the page header.
|
||||
func (s *APIServer) handleGetDetect(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
rules := s.detectEngine.Rules()
|
||||
active := 0
|
||||
for _, rule := range rules {
|
||||
if rule.Enabled {
|
||||
active++
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": s.detectEngine.IsEnabled(),
|
||||
"config": s.detectEngine.Config(),
|
||||
"summary": s.detectFindings.Summary(s.ruleEnabledFunc()),
|
||||
"scan": s.detectScanner.Status(),
|
||||
"cursor": s.detectScanner.Cursor(),
|
||||
"ruleCount": len(rules),
|
||||
"activeRules": active,
|
||||
})
|
||||
}
|
||||
|
||||
// handleSetDetectEnabled toggles passive detection.
|
||||
func (s *APIServer) handleSetDetectEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
s.detectEngine.SetEnabled(body.Enabled)
|
||||
if body.Enabled {
|
||||
// Catch up on anything captured while detection was off.
|
||||
s.detectScanner.Wake()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
|
||||
}
|
||||
|
||||
// handleGetDetectConfig returns the engine configuration.
|
||||
func (s *APIServer) handleGetDetectConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.detectEngine.Config())
|
||||
}
|
||||
|
||||
// detectConfigPatch mirrors detect.Config with pointer fields so a PUT can carry
|
||||
// any subset without zeroing the rest.
|
||||
type detectConfigPatch struct {
|
||||
ScopeOnly *bool `json:"scopeOnly"`
|
||||
ScanRequests *bool `json:"scanRequests"`
|
||||
PersistFindings *bool `json:"persistFindings"`
|
||||
ClearFindingsWithHistory *bool `json:"clearFindingsWithHistory"`
|
||||
MaxBodyScanBytes *int `json:"maxBodyScanBytes"`
|
||||
MaxRequestBodyScanBytes *int `json:"maxRequestBodyScanBytes"`
|
||||
SkipContentTypes *[]string `json:"skipContentTypes"`
|
||||
SkipExtensions *[]string `json:"skipExtensions"`
|
||||
ExcludeHosts *[]string `json:"excludeHosts"`
|
||||
}
|
||||
|
||||
// handleSetDetectConfig applies a partial configuration update.
|
||||
func (s *APIServer) handleSetDetectConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
var patch detectConfigPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
cfg := s.detectEngine.Config()
|
||||
if patch.ScopeOnly != nil {
|
||||
cfg.ScopeOnly = *patch.ScopeOnly
|
||||
}
|
||||
if patch.ScanRequests != nil {
|
||||
cfg.ScanRequests = *patch.ScanRequests
|
||||
}
|
||||
if patch.PersistFindings != nil {
|
||||
cfg.PersistFindings = *patch.PersistFindings
|
||||
}
|
||||
if patch.ClearFindingsWithHistory != nil {
|
||||
cfg.ClearFindingsWithHistory = *patch.ClearFindingsWithHistory
|
||||
}
|
||||
if patch.MaxBodyScanBytes != nil {
|
||||
cfg.MaxBodyScanBytes = *patch.MaxBodyScanBytes
|
||||
}
|
||||
if patch.MaxRequestBodyScanBytes != nil {
|
||||
cfg.MaxRequestBodyScanBytes = *patch.MaxRequestBodyScanBytes
|
||||
}
|
||||
if patch.SkipContentTypes != nil {
|
||||
cfg.SkipContentTypes = *patch.SkipContentTypes
|
||||
}
|
||||
if patch.SkipExtensions != nil {
|
||||
cfg.SkipExtensions = *patch.SkipExtensions
|
||||
}
|
||||
if patch.ExcludeHosts != nil {
|
||||
cfg.ExcludeHosts = *patch.ExcludeHosts
|
||||
}
|
||||
s.detectEngine.SetConfig(cfg)
|
||||
writeJSON(w, http.StatusOK, s.detectEngine.Config())
|
||||
}
|
||||
|
||||
// splitCSV parses a comma-separated query parameter, dropping empties.
|
||||
func splitCSV(v string) []string {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// findingFilterFromQuery builds a filter from the query string.
|
||||
func findingFilterFromQuery(r *http.Request) detect.FindingFilter {
|
||||
q := r.URL.Query()
|
||||
f := detect.FindingFilter{
|
||||
Severities: splitCSV(q.Get("severity")),
|
||||
MinSeverity: q.Get("minSeverity"),
|
||||
Categories: splitCSV(q.Get("category")),
|
||||
RuleID: q.Get("ruleId"),
|
||||
Host: q.Get("host"),
|
||||
Search: q.Get("search"),
|
||||
Confidence: q.Get("confidence"),
|
||||
FP: q.Get("fp"),
|
||||
IncludeDisabled: q.Get("includeDisabled") == "true",
|
||||
Sort: q.Get("sort"),
|
||||
Dir: q.Get("dir"),
|
||||
Limit: 100,
|
||||
}
|
||||
if v, err := strconv.Atoi(q.Get("offset")); err == nil && v >= 0 {
|
||||
f.Offset = v
|
||||
}
|
||||
if q.Has("limit") {
|
||||
if v, err := strconv.Atoi(q.Get("limit")); err == nil {
|
||||
// limit=0 means "all", consistent with the requests endpoint.
|
||||
f.Limit = v
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// handleListFindings returns a filtered, paginated page of findings.
|
||||
func (s *APIServer) handleListFindings(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
f := findingFilterFromQuery(r)
|
||||
items, total := s.detectFindings.List(f, s.ruleEnabledFunc())
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": detect.Summaries(items),
|
||||
"total": total,
|
||||
"offset": f.Offset,
|
||||
"limit": f.Limit,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetFinding returns one finding with its occurrences. Each occurrence
|
||||
// reports whether the referenced request is still in the ring buffer; the
|
||||
// finding itself survives eviction, since its evidence is self-contained.
|
||||
func (s *APIServer) handleGetFinding(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
f, ok := s.detectFindings.Get(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "finding not found")
|
||||
return
|
||||
}
|
||||
|
||||
type occurrence struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Seq int `json:"seq"`
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
StatusCode int `json:"statusCode"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Offset int `json:"offset"`
|
||||
Part string `json:"part"`
|
||||
RequestPresent bool `json:"requestPresent"`
|
||||
}
|
||||
occs := make([]occurrence, 0, len(f.Occurrences))
|
||||
for _, o := range f.Occurrences {
|
||||
present := false
|
||||
if s.store != nil && o.RequestID != "" {
|
||||
present = s.store.Get(o.RequestID) != nil
|
||||
}
|
||||
occs = append(occs, occurrence{
|
||||
RequestID: o.RequestID, Seq: o.Seq, Method: o.Method, URL: o.URL,
|
||||
StatusCode: o.StatusCode, Timestamp: o.Timestamp.Format(timeFormat),
|
||||
Offset: o.Offset, Part: o.Part, RequestPresent: present,
|
||||
})
|
||||
}
|
||||
|
||||
// Included so the detail pane can render description and remediation without
|
||||
// a second request.
|
||||
var rule any
|
||||
if rr, found := s.detectEngine.Rule(f.RuleID); found {
|
||||
rule = rr
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"finding": detect.Summaries([]detect.Finding{f})[0],
|
||||
"notes": f.Notes,
|
||||
"occurrences": occs,
|
||||
"rule": rule,
|
||||
})
|
||||
}
|
||||
|
||||
// timeFormat matches the format used by the history handlers.
|
||||
const timeFormat = "2006-01-02T15:04:05.000Z"
|
||||
|
||||
// handleUpdateFinding applies operator triage: a false-positive mark, a note, or
|
||||
// a severity override. Any subset may be supplied.
|
||||
func (s *APIServer) handleUpdateFinding(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
FalsePositive *bool `json:"falsePositive"`
|
||||
Notes *string `json:"notes"`
|
||||
Severity *string `json:"severity"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
var sev *detect.Severity
|
||||
if body.Severity != nil {
|
||||
v := detect.Severity(strings.ToLower(*body.Severity))
|
||||
if !v.Valid() {
|
||||
writeError(w, http.StatusBadRequest, "unknown severity")
|
||||
return
|
||||
}
|
||||
sev = &v
|
||||
}
|
||||
f, ok := s.detectFindings.Update(r.PathValue("id"), body.FalsePositive, body.Notes, sev)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "finding not found")
|
||||
return
|
||||
}
|
||||
s.broadcastDetectSummary()
|
||||
writeJSON(w, http.StatusOK, detect.Summaries([]detect.Finding{f})[0])
|
||||
}
|
||||
|
||||
// handleDeleteFinding removes one finding.
|
||||
func (s *APIServer) handleDeleteFinding(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if !s.detectFindings.Delete(id) {
|
||||
writeError(w, http.StatusNotFound, "finding not found")
|
||||
return
|
||||
}
|
||||
s.broadcastDetectSummary()
|
||||
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
||||
}
|
||||
|
||||
// handleClearFindings deletes all findings, or only the false-positive ones when
|
||||
// ?fp=true is supplied.
|
||||
func (s *APIServer) handleClearFindings(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
var n int
|
||||
if r.URL.Query().Get("fp") == "true" {
|
||||
n = s.detectFindings.DeleteFalsePositives()
|
||||
} else {
|
||||
n = s.detectFindings.Clear()
|
||||
}
|
||||
s.hub.Broadcast() <- event.WSEvent{
|
||||
Type: "detect.findings.cleared",
|
||||
Data: map[string]any{"deleted": n},
|
||||
}
|
||||
s.broadcastDetectSummary()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": n})
|
||||
}
|
||||
|
||||
// broadcastDetectSummary pushes updated aggregate counts.
|
||||
func (s *APIServer) broadcastDetectSummary() {
|
||||
if s.hub == nil || s.detectFindings == nil {
|
||||
return
|
||||
}
|
||||
s.hub.Broadcast() <- event.WSEvent{
|
||||
Type: "detect.summary",
|
||||
Data: s.detectFindings.Summary(s.ruleEnabledFunc()),
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastDetectRules notifies clients that the rule set changed.
|
||||
func (s *APIServer) broadcastDetectRules() {
|
||||
if s.hub == nil || s.detectEngine == nil {
|
||||
return
|
||||
}
|
||||
rules := s.detectEngine.Rules()
|
||||
builtin, user, active := 0, 0, 0
|
||||
for _, r := range rules {
|
||||
if r.Builtin {
|
||||
builtin++
|
||||
} else {
|
||||
user++
|
||||
}
|
||||
if r.Enabled {
|
||||
active++
|
||||
}
|
||||
}
|
||||
s.hub.Broadcast() <- event.WSEvent{
|
||||
Type: "detect.rules.changed",
|
||||
Data: map[string]any{
|
||||
"builtinCount": builtin, "userCount": user, "activeCount": active,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// handleListDetectRules returns every rule, built-in and custom, as one list.
|
||||
func (s *APIServer) handleListDetectRules(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
cats := splitCSV(q.Get("category"))
|
||||
sevs := splitCSV(q.Get("severity"))
|
||||
origin := q.Get("builtin")
|
||||
state := q.Get("enabled")
|
||||
search := strings.ToLower(q.Get("search"))
|
||||
|
||||
all := s.detectRulesWithCounts()
|
||||
out := make([]detectRuleView, 0, len(all))
|
||||
builtin, user, active := 0, 0, 0
|
||||
for _, rule := range all {
|
||||
if rule.Builtin {
|
||||
builtin++
|
||||
} else {
|
||||
user++
|
||||
}
|
||||
if rule.Enabled {
|
||||
active++
|
||||
}
|
||||
if len(cats) > 0 && !containsFoldStr(cats, string(rule.Category)) {
|
||||
continue
|
||||
}
|
||||
if len(sevs) > 0 && !containsFoldStr(sevs, string(rule.Severity)) {
|
||||
continue
|
||||
}
|
||||
if origin == "true" && !rule.Builtin {
|
||||
continue
|
||||
}
|
||||
if origin == "false" && rule.Builtin {
|
||||
continue
|
||||
}
|
||||
if state == "true" && !rule.Enabled {
|
||||
continue
|
||||
}
|
||||
if state == "false" && rule.Enabled {
|
||||
continue
|
||||
}
|
||||
if search != "" &&
|
||||
!strings.Contains(strings.ToLower(rule.Name), search) &&
|
||||
!strings.Contains(strings.ToLower(rule.ID), search) &&
|
||||
!strings.Contains(strings.ToLower(rule.Description), search) {
|
||||
continue
|
||||
}
|
||||
out = append(out, rule)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rules": out,
|
||||
"builtinCount": builtin,
|
||||
"userCount": user,
|
||||
"activeCount": active,
|
||||
"categories": detect.AllCategories(),
|
||||
"postFilters": detect.PostFilterNames(),
|
||||
})
|
||||
}
|
||||
|
||||
// detectRuleView is a rule plus its current finding count.
|
||||
type detectRuleView struct {
|
||||
detect.Rule
|
||||
FindingCount int `json:"findingCount"`
|
||||
}
|
||||
|
||||
// detectRulesWithCounts joins the rule list with per-rule finding counts.
|
||||
func (s *APIServer) detectRulesWithCounts() []detectRuleView {
|
||||
counts := map[string]int{}
|
||||
for _, f := range s.detectFindings.All() {
|
||||
counts[f.RuleID]++
|
||||
}
|
||||
rules := s.detectEngine.Rules()
|
||||
out := make([]detectRuleView, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
out = append(out, detectRuleView{Rule: r, FindingCount: counts[r.ID]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// containsFoldStr reports whether list contains v, case-insensitively.
|
||||
func containsFoldStr(list []string, v string) bool {
|
||||
for _, item := range list {
|
||||
if strings.EqualFold(item, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleAddDetectRule creates a custom rule.
|
||||
func (s *APIServer) handleAddDetectRule(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
var rule detect.Rule
|
||||
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
created, err := s.detectEngine.AddRule(rule)
|
||||
if err != nil {
|
||||
// Surface the RE2 error text verbatim.
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.broadcastDetectRules()
|
||||
writeJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// handleUpdateDetectRule replaces a custom rule, preserving its ID. Unlike the
|
||||
// other rule collections, delete-and-recreate is not equivalent here: the ID is
|
||||
// referenced by every finding the rule produced.
|
||||
func (s *APIServer) handleUpdateDetectRule(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
var rule detect.Rule
|
||||
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
updated, err := s.detectEngine.UpdateRule(r.PathValue("id"), rule)
|
||||
if err != nil {
|
||||
s.writeRuleError(w, err)
|
||||
return
|
||||
}
|
||||
s.broadcastDetectRules()
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// writeRuleError maps engine rule errors to status codes.
|
||||
func (s *APIServer) writeRuleError(w http.ResponseWriter, err error) {
|
||||
switch err {
|
||||
case detect.ErrBuiltinImmutable:
|
||||
// Built-ins accept only an enabled toggle and a severity override.
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
case detect.ErrRuleNotFound:
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// handleSetDetectRuleEnabled toggles any rule, built-in or custom.
|
||||
func (s *APIServer) handleSetDetectRuleEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := s.detectEngine.SetRuleEnabled(id, body.Enabled); err != nil {
|
||||
s.writeRuleError(w, err)
|
||||
return
|
||||
}
|
||||
s.broadcastDetectRules()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "enabled": body.Enabled})
|
||||
}
|
||||
|
||||
// handleSetDetectRuleSeverity overrides a rule's severity. Allowed on built-ins.
|
||||
func (s *APIServer) handleSetDetectRuleSeverity(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := s.detectEngine.SetRuleSeverity(id, detect.Severity(strings.ToLower(body.Severity))); err != nil {
|
||||
s.writeRuleError(w, err)
|
||||
return
|
||||
}
|
||||
s.broadcastDetectRules()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "severity": body.Severity})
|
||||
}
|
||||
|
||||
// handleResetDetectRule clears operator overrides on a built-in rule.
|
||||
func (s *APIServer) handleResetDetectRule(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := s.detectEngine.ResetRule(id); err != nil {
|
||||
s.writeRuleError(w, err)
|
||||
return
|
||||
}
|
||||
s.broadcastDetectRules()
|
||||
rule, _ := s.detectEngine.Rule(id)
|
||||
writeJSON(w, http.StatusOK, rule)
|
||||
}
|
||||
|
||||
// handleDeleteDetectRule removes a custom rule.
|
||||
func (s *APIServer) handleDeleteDetectRule(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := s.detectEngine.RemoveRule(id); err != nil {
|
||||
s.writeRuleError(w, err)
|
||||
return
|
||||
}
|
||||
s.broadcastDetectRules()
|
||||
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
||||
}
|
||||
|
||||
// maxRuleTestSample bounds the pattern-tester input.
|
||||
const maxRuleTestSample = 1 << 20
|
||||
|
||||
// handleTestDetectRule compiles a pattern and reports its matches against a
|
||||
// sample, so a rule can be authored without live traffic.
|
||||
func (s *APIServer) handleTestDetectRule(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Pattern string `json:"pattern"`
|
||||
Sample string `json:"sample"`
|
||||
SampleB64 string `json:"sampleB64"`
|
||||
CaptureGroup int `json:"captureGroup"`
|
||||
MinEntropy float64 `json:"minEntropy"`
|
||||
MinLength int `json:"minLength"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxRuleTestSample*2)).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
re, err := regexp.Compile(body.Pattern)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"valid": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sample := []byte(body.Sample)
|
||||
if body.SampleB64 != "" {
|
||||
if decoded, decErr := base64.StdEncoding.DecodeString(body.SampleB64); decErr == nil {
|
||||
sample = decoded
|
||||
}
|
||||
}
|
||||
truncated := false
|
||||
if len(sample) > maxRuleTestSample {
|
||||
sample = sample[:maxRuleTestSample]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
type match struct {
|
||||
Match string `json:"match"`
|
||||
Redacted string `json:"redacted"`
|
||||
Offset int `json:"offset"`
|
||||
Length int `json:"length"`
|
||||
Entropy float64 `json:"entropy"`
|
||||
Passes bool `json:"passes"`
|
||||
}
|
||||
const maxMatches = 50
|
||||
matches := make([]match, 0, 8)
|
||||
for _, loc := range re.FindAllSubmatchIndex(sample, maxMatches) {
|
||||
lo, hi := loc[0], loc[1]
|
||||
if g := body.CaptureGroup; g > 0 && 2*g+1 < len(loc) && loc[2*g] >= 0 {
|
||||
lo, hi = loc[2*g], loc[2*g+1]
|
||||
}
|
||||
raw := string(sample[lo:hi])
|
||||
entropy := detect.ShannonEntropy(raw)
|
||||
passes := true
|
||||
if body.MinLength > 0 && len(raw) < body.MinLength {
|
||||
passes = false
|
||||
}
|
||||
if body.MinEntropy > 0 && entropy < body.MinEntropy {
|
||||
passes = false
|
||||
}
|
||||
matches = append(matches, match{
|
||||
Match: raw, Redacted: detect.RedactValue(raw), Offset: lo,
|
||||
Length: hi - lo, Entropy: entropy, Passes: passes,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"valid": true,
|
||||
"groups": re.NumSubexp(),
|
||||
"matches": matches,
|
||||
"truncated": truncated,
|
||||
})
|
||||
}
|
||||
|
||||
// handleStartDetectScan begins an on-demand rescan over captured traffic.
|
||||
func (s *APIServer) handleStartDetectScan(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
var req detect.RescanRequest
|
||||
if r.ContentLength != 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Scope == "" {
|
||||
req.Scope = "all"
|
||||
}
|
||||
// Not r.Context(): the rescan outlives this request.
|
||||
status, err := s.detectScanner.StartRescan(s.detectBackgroundCtx(), req)
|
||||
if err == detect.ErrScanRunning {
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, status)
|
||||
}
|
||||
|
||||
// handleGetDetectScan reports rescan progress.
|
||||
func (s *APIServer) handleGetDetectScan(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.detectScanner.Status())
|
||||
}
|
||||
|
||||
// handleCancelDetectScan stops a running rescan.
|
||||
func (s *APIServer) handleCancelDetectScan(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.detectAvailable(w) {
|
||||
return
|
||||
}
|
||||
s.detectScanner.Cancel()
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "stopping"})
|
||||
}
|
||||
@@ -115,5 +115,9 @@ func (s *APIServer) handleGetRequest(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *APIServer) handleClearRequests(w http.ResponseWriter, r *http.Request) {
|
||||
s.store.Clear()
|
||||
// Clear zeroes the store's sequence counter, so the detection cursor must be
|
||||
// reset with it.
|
||||
s.resetDetectCursor(0)
|
||||
s.clearDetectFindingsWithHistory()
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
|
||||
}
|
||||
@@ -108,6 +108,30 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
|
||||
mux.HandleFunc("DELETE /api/v1/fuzzer/campaigns/{id}", s.handleFuzzerDeleteCampaign)
|
||||
mux.HandleFunc("POST /api/v1/fuzzer/wordlist", s.handleFuzzerUploadWordlist)
|
||||
|
||||
// Passive detection. Unlike the other rule collections below, detect rules
|
||||
// have a PUT (a rule's ID is referenced by every finding it produced) and a
|
||||
// test endpoint.
|
||||
mux.HandleFunc("GET /api/v1/detect", s.handleGetDetect)
|
||||
mux.HandleFunc("PUT /api/v1/detect/enabled", s.handleSetDetectEnabled)
|
||||
mux.HandleFunc("GET /api/v1/detect/config", s.handleGetDetectConfig)
|
||||
mux.HandleFunc("PUT /api/v1/detect/config", s.handleSetDetectConfig)
|
||||
mux.HandleFunc("GET /api/v1/detect/findings", s.handleListFindings)
|
||||
mux.HandleFunc("DELETE /api/v1/detect/findings", s.handleClearFindings)
|
||||
mux.HandleFunc("GET /api/v1/detect/findings/{id}", s.handleGetFinding)
|
||||
mux.HandleFunc("PUT /api/v1/detect/findings/{id}", s.handleUpdateFinding)
|
||||
mux.HandleFunc("DELETE /api/v1/detect/findings/{id}", s.handleDeleteFinding)
|
||||
mux.HandleFunc("GET /api/v1/detect/rules", s.handleListDetectRules)
|
||||
mux.HandleFunc("POST /api/v1/detect/rules", s.handleAddDetectRule)
|
||||
mux.HandleFunc("POST /api/v1/detect/rules/test", s.handleTestDetectRule)
|
||||
mux.HandleFunc("PUT /api/v1/detect/rules/{id}", s.handleUpdateDetectRule)
|
||||
mux.HandleFunc("DELETE /api/v1/detect/rules/{id}", s.handleDeleteDetectRule)
|
||||
mux.HandleFunc("PUT /api/v1/detect/rules/{id}/enabled", s.handleSetDetectRuleEnabled)
|
||||
mux.HandleFunc("PUT /api/v1/detect/rules/{id}/severity", s.handleSetDetectRuleSeverity)
|
||||
mux.HandleFunc("POST /api/v1/detect/rules/{id}/reset", s.handleResetDetectRule)
|
||||
mux.HandleFunc("POST /api/v1/detect/scan", s.handleStartDetectScan)
|
||||
mux.HandleFunc("GET /api/v1/detect/scan", s.handleGetDetectScan)
|
||||
mux.HandleFunc("POST /api/v1/detect/scan/cancel", s.handleCancelDetectScan)
|
||||
|
||||
// Web shell generator
|
||||
mux.HandleFunc("POST /api/v1/generate", s.handleGenerate)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/BishopFox/joro/internal/cert"
|
||||
"github.com/BishopFox/joro/internal/config"
|
||||
"github.com/BishopFox/joro/internal/configstore"
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/fuzzer"
|
||||
"github.com/BishopFox/joro/internal/mythic"
|
||||
@@ -90,6 +91,16 @@ type APIServer struct {
|
||||
fuzzerStore *fuzzer.Store
|
||||
pluginManager *plugins.Manager
|
||||
|
||||
// Passive detection. All three are nil in listener and team-server mode; the
|
||||
// detect routes are gated on proxy mode in registerRoutes, so only the shared
|
||||
// config paths need nil guards.
|
||||
detectEngine *detect.Engine
|
||||
detectFindings *detect.Store
|
||||
detectScanner *detect.Scanner
|
||||
// detectCtx is the server-lifetime context, so a rescan job can outlive the
|
||||
// HTTP request that started it. Guarded by mu.
|
||||
detectCtx context.Context
|
||||
|
||||
listenerRelay *ListenerRelay
|
||||
configStore *configstore.Store
|
||||
|
||||
@@ -151,6 +162,10 @@ func New(
|
||||
mc.SetOnEvent(func(ev mythic.MythicEvent) {
|
||||
hub.Broadcast() <- event.WSEvent{Type: "mythic.event", Data: ev}
|
||||
})
|
||||
// Detection is constructed here rather than injected from main.go; the proxy
|
||||
// holds no reference to it, since the scanner pulls from the capture store.
|
||||
detectEngine := detect.NewEngine()
|
||||
detectFindings := detect.NewStore(0)
|
||||
return &APIServer{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
@@ -169,6 +184,12 @@ func New(
|
||||
mythicClient: mc,
|
||||
fuzzerStore: fuzzer.NewStore(),
|
||||
pluginManager: pluginManager,
|
||||
|
||||
detectEngine: detectEngine,
|
||||
detectFindings: detectFindings,
|
||||
detectScanner: detect.NewScanner(
|
||||
detectEngine, detectFindings, store, scope, hub.Broadcast()),
|
||||
|
||||
listenerRelay: NewListenerRelay(hub),
|
||||
configStore: configstore.NewStore(cfg.DataDir),
|
||||
buildInfo: buildInfo,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,420 @@
|
||||
// Package detect implements passive vulnerability detection over HTTP traffic
|
||||
// already captured by the proxy. It reads proxy.CapturedRequest records and
|
||||
// reports findings; it never sends requests of its own.
|
||||
//
|
||||
// Two kinds of check run against each captured message:
|
||||
//
|
||||
// - Regex rules (KindRegex), from rules_builtin.go or created by the operator:
|
||||
// a pattern plus gates (content type, status, scheme) and named post-filters
|
||||
// that validate the captured group in Go.
|
||||
// - Analyzers (KindAnalyzer), Go functions in analyzers.go, for absence and
|
||||
// relational logic a regex cannot express — a missing header, a cookie flag
|
||||
// matrix, an Access-Control-Allow-Origin that echoes the request.
|
||||
//
|
||||
// Both converge on Engine.newFinding, the only code path that produces a Finding.
|
||||
//
|
||||
// Patterns compile with Go's regexp (RE2): no lookahead, no lookbehind, no
|
||||
// backreferences. PostFilters is the escape hatch for negative conditions.
|
||||
package detect
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Severity ranks how much a finding matters. The rubric for assigning it to a
|
||||
// new rule:
|
||||
//
|
||||
// - Info: not directly exploitable on its own — exposed panels, missing
|
||||
// headers, information disclosure, analytics keys, identifiers
|
||||
// that are not credentials.
|
||||
// - Low: an exposed configuration file or similar.
|
||||
// - Medium: anything not covered by the other bands.
|
||||
// - High: account credentials, sensitive API keys, database connection
|
||||
// strings, low-level PII (phone number, date of birth).
|
||||
// - Critical: high-grade PII (a national identity number or equivalent, or a
|
||||
// payment card), or anything that alone leads to severe
|
||||
// compromise — remote code execution, authentication bypass, a
|
||||
// served database dump.
|
||||
//
|
||||
// Every credential is High; none is Critical. A rule that detects a surface
|
||||
// (an admin console, an absent header) is Info.
|
||||
//
|
||||
// Severity is orthogonal to Confidence: a low-confidence match on a national ID
|
||||
// is still Critical, a certain match on a Server header is still Info.
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
SeverityCritical Severity = "critical"
|
||||
SeverityHigh Severity = "high"
|
||||
SeverityMedium Severity = "medium"
|
||||
SeverityLow Severity = "low"
|
||||
SeverityInfo Severity = "info"
|
||||
)
|
||||
|
||||
// severityRank orders severities for sorting and for MinSeverity filtering.
|
||||
var severityRank = map[Severity]int{
|
||||
SeverityCritical: 5,
|
||||
SeverityHigh: 4,
|
||||
SeverityMedium: 3,
|
||||
SeverityLow: 2,
|
||||
SeverityInfo: 1,
|
||||
}
|
||||
|
||||
// Rank returns the sort weight of a severity (Critical highest, 0 if unknown).
|
||||
func (s Severity) Rank() int { return severityRank[s] }
|
||||
|
||||
// Valid reports whether s is a known severity.
|
||||
func (s Severity) Valid() bool { _, ok := severityRank[s]; return ok }
|
||||
|
||||
// Confidence describes whether a match is what the rule claims it is,
|
||||
// independent of Severity.
|
||||
type Confidence string
|
||||
|
||||
const (
|
||||
ConfidenceHigh Confidence = "high"
|
||||
ConfidenceMedium Confidence = "medium"
|
||||
ConfidenceLow Confidence = "low"
|
||||
)
|
||||
|
||||
// Valid reports whether c is a known confidence level.
|
||||
func (c Confidence) Valid() bool {
|
||||
switch c {
|
||||
case ConfidenceHigh, ConfidenceMedium, ConfidenceLow:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Category groups rules for filtering and for the Rules UI.
|
||||
type Category string
|
||||
|
||||
const (
|
||||
CategorySecrets Category = "secrets"
|
||||
CategoryPII Category = "pii"
|
||||
CategoryCredentials Category = "credentials"
|
||||
CategoryAccess Category = "access"
|
||||
CategoryDisclosure Category = "disclosure"
|
||||
CategoryHeaders Category = "headers"
|
||||
CategoryCookies Category = "cookies"
|
||||
)
|
||||
|
||||
// Valid reports whether c is a known category.
|
||||
func (c Category) Valid() bool {
|
||||
switch c {
|
||||
case CategorySecrets, CategoryPII, CategoryCredentials, CategoryAccess,
|
||||
CategoryDisclosure, CategoryHeaders, CategoryCookies:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AllCategories lists every category in display order.
|
||||
func AllCategories() []Category {
|
||||
return []Category{
|
||||
CategorySecrets, CategoryCredentials, CategoryPII, CategoryAccess,
|
||||
CategoryDisclosure, CategoryHeaders, CategoryCookies,
|
||||
}
|
||||
}
|
||||
|
||||
// RuleKind distinguishes a declarative pattern from a Go analyzer function.
|
||||
// Operators may only create KindRegex rules; KindAnalyzer rules are built in,
|
||||
// because their behavior lives in code rather than in the rule record.
|
||||
type RuleKind string
|
||||
|
||||
const (
|
||||
KindRegex RuleKind = "regex"
|
||||
KindAnalyzer RuleKind = "analyzer"
|
||||
)
|
||||
|
||||
// Target names the part of the message a rule is matched against.
|
||||
type Target string
|
||||
|
||||
const (
|
||||
TargetResponseBody Target = "response_body"
|
||||
TargetResponseHeader Target = "response_header"
|
||||
TargetRequestBody Target = "request_body"
|
||||
TargetRequestHeader Target = "request_header"
|
||||
TargetURL Target = "url"
|
||||
// TargetMessage is used by analyzers, which receive the whole *Message and
|
||||
// decide for themselves what to inspect.
|
||||
TargetMessage Target = "message"
|
||||
)
|
||||
|
||||
// Valid reports whether t is a known target.
|
||||
func (t Target) Valid() bool {
|
||||
switch t {
|
||||
case TargetResponseBody, TargetResponseHeader, TargetRequestBody,
|
||||
TargetRequestHeader, TargetURL, TargetMessage:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GroupBy selects how repeated matches collapse into a single finding.
|
||||
type GroupBy string
|
||||
|
||||
const (
|
||||
// GroupByEvidence: one finding per distinct matched value on a host. Used by
|
||||
// secrets and PII rules.
|
||||
GroupByEvidence GroupBy = "evidence"
|
||||
// GroupByURL: one finding per path on a host. Used by panels, directory
|
||||
// listings, and interesting files.
|
||||
GroupByURL GroupBy = "url"
|
||||
// GroupByHost: one finding per host. Used by header, cookie, and
|
||||
// fingerprint rules.
|
||||
GroupByHost GroupBy = "host"
|
||||
)
|
||||
|
||||
// Valid reports whether g is a known grouping mode.
|
||||
func (g GroupBy) Valid() bool {
|
||||
switch g {
|
||||
case GroupByEvidence, GroupByURL, GroupByHost:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// defaultMaxPerResponse caps findings from one rule against one message; the
|
||||
// remaining matches raise the occurrence count instead.
|
||||
const defaultMaxPerResponse = 3
|
||||
|
||||
// maxEvidenceBytes caps both Evidence and RawEvidence. A size bound only —
|
||||
// RawEvidence holds the unmasked value.
|
||||
const maxEvidenceBytes = 256
|
||||
|
||||
// Rule is a single detection check. Built-in rules use stable string IDs, which
|
||||
// operator toggles, severity overrides, and Finding.RuleID reference across
|
||||
// restarts and project reloads; user rules get a generated ID.
|
||||
type Rule struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Remediation string `json:"remediation,omitempty"`
|
||||
Kind RuleKind `json:"kind"`
|
||||
Category Category `json:"category"`
|
||||
Severity Severity `json:"severity"`
|
||||
Confidence Confidence `json:"confidence"`
|
||||
Target Target `json:"target"`
|
||||
|
||||
// Pattern is the RE2 source for KindRegex rules.
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
// Literal is a case-insensitive substring prescreen: when set, the regex only
|
||||
// runs if the haystack contains it. Must appear in every string the pattern
|
||||
// can match, or the rule silently never fires.
|
||||
Literal string `json:"literal,omitempty"`
|
||||
// CaptureGroup selects which submatch becomes the evidence (0 = whole match).
|
||||
CaptureGroup int `json:"captureGroup,omitempty"`
|
||||
// PostFilters name validators in the postfilters.go registry, run in order
|
||||
// against the captured group.
|
||||
PostFilters []string `json:"postFilters,omitempty"`
|
||||
// Analyzer names a function in the analyzers.go registry (KindAnalyzer only).
|
||||
Analyzer string `json:"analyzer,omitempty"`
|
||||
|
||||
// GroupBy selects the dedupe collapse mode. Empty means GroupByEvidence.
|
||||
GroupBy GroupBy `json:"groupBy,omitempty"`
|
||||
|
||||
// ContentTypes gates body rules to simplified content-type keywords
|
||||
// ("json", "html", "xml", "csv", "plain", "js", "css"). Empty means any
|
||||
// scannable body.
|
||||
ContentTypes []string `json:"contentTypes,omitempty"`
|
||||
// ExcludeContentTypes is the inverse of ContentTypes and takes precedence
|
||||
// over it: the rule never runs against these keywords. Rules that look for
|
||||
// data rather than a format use this instead of ContentTypes.
|
||||
ExcludeContentTypes []string `json:"excludeContentTypes,omitempty"`
|
||||
// StatusCodes gates the rule with a proxy status expression, e.g.
|
||||
// "200,301,302,401,403". Empty means any status.
|
||||
StatusCodes string `json:"statusCodes,omitempty"`
|
||||
// Scheme gates the rule to "http" or "https". Empty means either.
|
||||
Scheme string `json:"scheme,omitempty"`
|
||||
// MinLength rejects captured groups shorter than this.
|
||||
MinLength int `json:"minLength,omitempty"`
|
||||
// MinEntropy rejects captured groups below this Shannon entropy in bits per
|
||||
// character. 0 disables the check.
|
||||
MinEntropy float64 `json:"minEntropy,omitempty"`
|
||||
// MaxPerResponse caps findings per message (0 means defaultMaxPerResponse).
|
||||
MaxPerResponse int `json:"maxPerResponse,omitempty"`
|
||||
// RedactEvidence masks the middle of the captured value before storing it.
|
||||
RedactEvidence bool `json:"redactEvidence,omitempty"`
|
||||
|
||||
Builtin bool `json:"builtin"`
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// Resolved once by Engine.rebuildLocked; the scan path never compiles a regex
|
||||
// or looks up a registry.
|
||||
compiled *regexp.Regexp
|
||||
status func(int) bool
|
||||
filters []postFilter
|
||||
literalLower []byte
|
||||
ctSet map[string]struct{}
|
||||
excludeCtSet map[string]struct{}
|
||||
}
|
||||
|
||||
// Occurrence records one sighting of a finding.
|
||||
type Occurrence struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Seq int `json:"seq"`
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
StatusCode int `json:"statusCode"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
// Offset is the byte offset of the match within the scanned part, used to
|
||||
// highlight the evidence in the response viewer.
|
||||
Offset int `json:"offset"`
|
||||
// Part names which buffer Offset indexes into, so the UI knows whether to
|
||||
// highlight in the request or the response.
|
||||
Part string `json:"part"`
|
||||
}
|
||||
|
||||
// maxOccurrences caps the occurrence ring kept per finding in memory.
|
||||
const maxOccurrences = 20
|
||||
|
||||
// maxOccSeen caps the occurrence-identity set. Past this bound Count is a
|
||||
// monotonic floor rather than an exact tally.
|
||||
const maxOccSeen = 512
|
||||
|
||||
// Finding is one deduplicated detection result.
|
||||
//
|
||||
// ID is a deterministic group hash:
|
||||
//
|
||||
// hex(sha256(ruleID \x00 host \x00 groupDim))[:32]
|
||||
//
|
||||
// It carries no request ID, timestamp, or sequence number, so rescanning the
|
||||
// same traffic reproduces byte-identical IDs. Rescan is therefore idempotent,
|
||||
// live merges are a map upsert, and persisted findings reload without ID
|
||||
// remapping.
|
||||
type Finding struct {
|
||||
ID string `json:"id"`
|
||||
RuleID string `json:"ruleId"`
|
||||
RuleName string `json:"ruleName"`
|
||||
Category Category `json:"category"`
|
||||
Severity Severity `json:"severity"`
|
||||
Confidence Confidence `json:"confidence"`
|
||||
Target Target `json:"target"`
|
||||
|
||||
Host string `json:"host"`
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
RequestID string `json:"requestId"`
|
||||
|
||||
// Detail is a short human-readable qualifier, e.g. the cookie name for a
|
||||
// cookie rule or the matched product for a fingerprint rule.
|
||||
Detail string `json:"detail,omitempty"`
|
||||
// Evidence is the redacted, truncated, control-character-escaped snippet
|
||||
// rendered by every list and table.
|
||||
Evidence string `json:"evidence"`
|
||||
// RawEvidence is the unmasked matched value, stored verbatim and populated
|
||||
// only for rules that redact. Redaction is a display control; the operator
|
||||
// can reveal this per finding.
|
||||
RawEvidence string `json:"rawEvidence,omitempty"`
|
||||
EvidenceOffset int `json:"evidenceOffset"`
|
||||
EvidenceLength int `json:"evidenceLength"`
|
||||
EvidencePart string `json:"evidencePart,omitempty"`
|
||||
|
||||
FirstSeen time.Time `json:"firstSeen"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
Count int `json:"count"`
|
||||
Occurrences []Occurrence `json:"occurrences,omitempty"`
|
||||
|
||||
FalsePositive bool `json:"falsePositive"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
// Truncated marks a finding produced from a body that hit a scan size cap:
|
||||
// the scan was not exhaustive for that response.
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
// SeverityOverridden records that an operator changed Severity by hand. A
|
||||
// rescan leaves it alone.
|
||||
SeverityOverridden bool `json:"severityOverridden,omitempty"`
|
||||
|
||||
// occSeen holds "requestID:part:offset" keys. Upsert increments Count only
|
||||
// for occurrences not already present, so a rescan does not double counts.
|
||||
occSeen map[string]struct{}
|
||||
// generation records the scan pass that last confirmed this finding, used by
|
||||
// an optional purge to drop findings no longer present.
|
||||
generation uint64
|
||||
}
|
||||
|
||||
// Config holds the tunable engine settings that ride with a project.
|
||||
type Config struct {
|
||||
// ScopeOnly limits scanning to in-scope requests. On by default.
|
||||
ScopeOnly bool `json:"scopeOnly"`
|
||||
// ScanRequests enables request-side targets (Basic auth, credentials in
|
||||
// query strings, keys posted by the app).
|
||||
ScanRequests bool `json:"scanRequests"`
|
||||
// PersistFindings snapshots findings into the project file, preserving
|
||||
// false-positive marks and notes. On by default.
|
||||
PersistFindings bool `json:"persistFindings"`
|
||||
// ClearFindingsWithHistory clears findings when request history is cleared.
|
||||
// Off by default.
|
||||
ClearFindingsWithHistory bool `json:"clearFindingsWithHistory"`
|
||||
|
||||
MaxBodyScanBytes int `json:"maxBodyScanBytes"`
|
||||
MaxRequestBodyScanBytes int `json:"maxRequestBodyScanBytes"`
|
||||
|
||||
// SkipContentTypes and SkipExtensions are MIME prefixes and URL suffixes
|
||||
// never scanned. .js and .css are not in either list.
|
||||
SkipContentTypes []string `json:"skipContentTypes"`
|
||||
SkipExtensions []string `json:"skipExtensions"`
|
||||
// ExcludeHosts suppresses findings whose host contains any of these
|
||||
// substrings.
|
||||
ExcludeHosts []string `json:"excludeHosts"`
|
||||
}
|
||||
|
||||
// DefaultConfig returns the shipped engine configuration.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
ScopeOnly: true,
|
||||
ScanRequests: true,
|
||||
PersistFindings: true,
|
||||
ClearFindingsWithHistory: false,
|
||||
MaxBodyScanBytes: 1 << 20,
|
||||
MaxRequestBodyScanBytes: 256 << 10,
|
||||
SkipContentTypes: []string{
|
||||
"image/", "font/", "video/", "audio/",
|
||||
"application/octet-stream", "application/pdf", "application/zip",
|
||||
"application/wasm", "application/x-7z", "application/x-rar",
|
||||
},
|
||||
SkipExtensions: []string{
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".bmp", ".ico",
|
||||
".svg", ".woff", ".woff2", ".ttf", ".otf", ".eot",
|
||||
".mp4", ".webm", ".mp3", ".wav", ".ogg", ".avi", ".mov",
|
||||
".zip", ".gz", ".bz2", ".7z", ".rar", ".pdf", ".wasm",
|
||||
},
|
||||
ExcludeHosts: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
// normalize fills zero values with defaults, so a partially-populated config
|
||||
// (an older project file, a partial PUT) is usable.
|
||||
func (c *Config) normalize() {
|
||||
d := DefaultConfig()
|
||||
if c.MaxBodyScanBytes <= 0 {
|
||||
c.MaxBodyScanBytes = d.MaxBodyScanBytes
|
||||
}
|
||||
if c.MaxRequestBodyScanBytes <= 0 {
|
||||
c.MaxRequestBodyScanBytes = d.MaxRequestBodyScanBytes
|
||||
}
|
||||
if c.SkipContentTypes == nil {
|
||||
c.SkipContentTypes = d.SkipContentTypes
|
||||
}
|
||||
if c.SkipExtensions == nil {
|
||||
c.SkipExtensions = d.SkipExtensions
|
||||
}
|
||||
if c.ExcludeHosts == nil {
|
||||
c.ExcludeHosts = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// Summary aggregates the findings store for the dashboard header and for the
|
||||
// detect.summary WebSocket event.
|
||||
type Summary struct {
|
||||
Total int `json:"total"`
|
||||
BySeverity map[string]int `json:"bySeverity"`
|
||||
ByCategory map[string]int `json:"byCategory"`
|
||||
FalsePositives int `json:"falsePositives"`
|
||||
// HiddenByDisabledRule counts findings whose rule is currently switched off.
|
||||
// The findings table hides them by default.
|
||||
HiddenByDisabledRule int `json:"hiddenByDisabledRule"`
|
||||
SkippedEncoded int `json:"skippedEncoded"`
|
||||
SkippedBinary int `json:"skippedBinary"`
|
||||
Scanned int `json:"scanned"`
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
package detect
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
// ruleSet is an immutable snapshot of the compiled rules. Engine swaps the
|
||||
// pointer under a write lock on every mutation; Scan takes the pointer under a
|
||||
// read lock and releases it before scanning.
|
||||
type ruleSet struct {
|
||||
byTarget map[Target][]*Rule
|
||||
analyzers []analyzerBinding
|
||||
active int
|
||||
}
|
||||
|
||||
// analyzerBinding pairs an analyzer rule with its resolved function.
|
||||
type analyzerBinding struct {
|
||||
rule *Rule
|
||||
fn Analyzer
|
||||
}
|
||||
|
||||
// Engine holds the rule set and produces findings from captured messages.
|
||||
//
|
||||
// Built-in rules are immutable: builtinRules() returns a fresh slice, operator
|
||||
// changes are held separately as a disabled-ID set plus a severity-override map,
|
||||
// and the API rejects edits and deletes of built-ins.
|
||||
type Engine struct {
|
||||
mu sync.RWMutex
|
||||
enabled bool
|
||||
cfg Config
|
||||
builtin []Rule
|
||||
user []Rule
|
||||
// disabled holds built-in IDs the operator switched off. Every shipped rule is
|
||||
// enabled by default, so this set plus severityOverride is the only mutable
|
||||
// state about a built-in rule.
|
||||
disabled map[string]struct{}
|
||||
severityOverride map[string]Severity
|
||||
set *ruleSet
|
||||
}
|
||||
|
||||
// NewEngine returns an enabled engine with the built-in library loaded and the
|
||||
// default configuration.
|
||||
func NewEngine() *Engine {
|
||||
e := &Engine{
|
||||
enabled: true,
|
||||
cfg: DefaultConfig(),
|
||||
builtin: builtinRules(),
|
||||
disabled: map[string]struct{}{},
|
||||
severityOverride: map[string]Severity{},
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.rebuildLocked()
|
||||
e.mu.Unlock()
|
||||
return e
|
||||
}
|
||||
|
||||
// rebuildLocked compiles the effective rule set and swaps in the new snapshot.
|
||||
// Callers must hold the write lock (or be the constructor).
|
||||
//
|
||||
// All per-rule resolution happens here — regex compilation, status predicate,
|
||||
// post-filter lookup, content-type set, folded literal — so the scan path does
|
||||
// none of it. A pattern that fails to compile leaves compiled nil and the rule
|
||||
// is skipped.
|
||||
func (e *Engine) rebuildLocked() {
|
||||
set := &ruleSet{byTarget: map[Target][]*Rule{}}
|
||||
add := func(r Rule) {
|
||||
if _, off := e.disabled[r.ID]; off {
|
||||
return
|
||||
}
|
||||
if sev, ok := e.severityOverride[r.ID]; ok && sev.Valid() {
|
||||
r.Severity = sev
|
||||
}
|
||||
r.Enabled = true
|
||||
if r.MaxPerResponse <= 0 {
|
||||
r.MaxPerResponse = defaultMaxPerResponse
|
||||
}
|
||||
if r.GroupBy == "" {
|
||||
r.GroupBy = GroupByEvidence
|
||||
}
|
||||
if r.StatusCodes != "" {
|
||||
r.status = proxy.NewStatusPredicate(r.StatusCodes)
|
||||
}
|
||||
if r.Literal != "" {
|
||||
r.literalLower = []byte(strings.ToLower(r.Literal))
|
||||
}
|
||||
if len(r.ContentTypes) > 0 {
|
||||
r.ctSet = make(map[string]struct{}, len(r.ContentTypes))
|
||||
for _, ct := range r.ContentTypes {
|
||||
r.ctSet[strings.ToLower(ct)] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(r.ExcludeContentTypes) > 0 {
|
||||
r.excludeCtSet = make(map[string]struct{}, len(r.ExcludeContentTypes))
|
||||
for _, ct := range r.ExcludeContentTypes {
|
||||
r.excludeCtSet[strings.ToLower(ct)] = struct{}{}
|
||||
}
|
||||
}
|
||||
r.filters = resolvePostFilters(r.PostFilters)
|
||||
|
||||
switch r.Kind {
|
||||
case KindAnalyzer:
|
||||
fn, ok := analyzerRegistry[r.Analyzer]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rp := &r
|
||||
set.analyzers = append(set.analyzers, analyzerBinding{rule: rp, fn: fn})
|
||||
set.active++
|
||||
default:
|
||||
compiled, err := regexp.Compile(r.Pattern)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r.compiled = compiled
|
||||
rp := &r
|
||||
set.byTarget[r.Target] = append(set.byTarget[r.Target], rp)
|
||||
set.active++
|
||||
}
|
||||
}
|
||||
for _, r := range e.builtin {
|
||||
if !e.ruleEnabledLocked(r) {
|
||||
continue
|
||||
}
|
||||
add(r)
|
||||
}
|
||||
for _, r := range e.user {
|
||||
if !r.Enabled {
|
||||
continue
|
||||
}
|
||||
add(r)
|
||||
}
|
||||
e.set = set
|
||||
}
|
||||
|
||||
// IsEnabled reports whether detection is active.
|
||||
func (e *Engine) IsEnabled() bool {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.enabled
|
||||
}
|
||||
|
||||
// SetEnabled turns detection on or off.
|
||||
func (e *Engine) SetEnabled(v bool) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.enabled = v
|
||||
}
|
||||
|
||||
// Config returns the current configuration.
|
||||
func (e *Engine) Config() Config {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.cfg
|
||||
}
|
||||
|
||||
// SetConfig replaces the configuration, filling zero values with defaults.
|
||||
func (e *Engine) SetConfig(cfg Config) {
|
||||
cfg.normalize()
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.cfg = cfg
|
||||
}
|
||||
|
||||
// ActiveRuleCount returns how many rules are currently compiled and live.
|
||||
func (e *Engine) ActiveRuleCount() int {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
if e.set == nil {
|
||||
return 0
|
||||
}
|
||||
return e.set.active
|
||||
}
|
||||
|
||||
// Rules returns every rule, built-in and user, as copies with Enabled and
|
||||
// Severity resolved into one flat list.
|
||||
func (e *Engine) Rules() []Rule {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
out := make([]Rule, 0, len(e.builtin)+len(e.user))
|
||||
for _, r := range e.builtin {
|
||||
r.Enabled = e.ruleEnabledLocked(r)
|
||||
if sev, ok := e.severityOverride[r.ID]; ok && sev.Valid() {
|
||||
r.Severity = sev
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
out = append(out, e.user...)
|
||||
return out
|
||||
}
|
||||
|
||||
// ruleEnabledLocked resolves a built-in rule's effective enabled state from the
|
||||
// operator's disabled set.
|
||||
func (e *Engine) ruleEnabledLocked(r Rule) bool {
|
||||
_, off := e.disabled[r.ID]
|
||||
return !off
|
||||
}
|
||||
|
||||
// RuleEnabledFunc returns a predicate over rule IDs, snapshotted into a map for
|
||||
// callers filtering large finding sets. An unknown ID counts as enabled, so a
|
||||
// finding whose custom rule was deleted stays visible.
|
||||
func (e *Engine) RuleEnabledFunc() func(string) bool {
|
||||
enabled := make(map[string]bool)
|
||||
for _, r := range e.Rules() {
|
||||
enabled[r.ID] = r.Enabled
|
||||
}
|
||||
return func(id string) bool {
|
||||
on, known := enabled[id]
|
||||
return !known || on
|
||||
}
|
||||
}
|
||||
|
||||
// Rule returns a single rule by ID.
|
||||
func (e *Engine) Rule(id string) (Rule, bool) {
|
||||
for _, r := range e.Rules() {
|
||||
if r.ID == id {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return Rule{}, false
|
||||
}
|
||||
|
||||
// UserRules returns a copy of the operator-defined rules.
|
||||
func (e *Engine) UserRules() []Rule {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
out := make([]Rule, len(e.user))
|
||||
copy(out, e.user)
|
||||
return out
|
||||
}
|
||||
|
||||
// DisabledBuiltins returns the IDs of built-in rules the operator turned off.
|
||||
func (e *Engine) DisabledBuiltins() []string {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
out := make([]string, 0, len(e.disabled))
|
||||
for id := range e.disabled {
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SeverityOverrides returns the operator's per-rule severity overrides.
|
||||
func (e *Engine) SeverityOverrides() map[string]string {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
out := make(map[string]string, len(e.severityOverride))
|
||||
for id, sev := range e.severityOverride {
|
||||
out[id] = string(sev)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetDisabledBuiltins replaces the disabled-rule set (project load).
|
||||
func (e *Engine) SetDisabledBuiltins(ids []string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.disabled = make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
e.disabled[id] = struct{}{}
|
||||
}
|
||||
e.rebuildLocked()
|
||||
}
|
||||
|
||||
// SetSeverityOverrides replaces the severity override map (project load).
|
||||
func (e *Engine) SetSeverityOverrides(in map[string]string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.severityOverride = make(map[string]Severity, len(in))
|
||||
for id, sev := range in {
|
||||
if s := Severity(sev); s.Valid() {
|
||||
e.severityOverride[id] = s
|
||||
}
|
||||
}
|
||||
e.rebuildLocked()
|
||||
}
|
||||
|
||||
// SetUserRules replaces all operator rules (project load).
|
||||
func (e *Engine) SetUserRules(rules []Rule) {
|
||||
for i := range rules {
|
||||
rules[i].Builtin = false
|
||||
rules[i].Kind = KindRegex
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.user = rules
|
||||
e.rebuildLocked()
|
||||
}
|
||||
|
||||
// ErrBuiltinImmutable is returned when an edit or delete targets a built-in rule.
|
||||
// Built-ins accept only an enabled toggle and a severity override.
|
||||
var ErrBuiltinImmutable = errors.New("built-in rules cannot be edited or deleted")
|
||||
|
||||
// ErrRuleNotFound is returned when no rule has the given ID.
|
||||
var ErrRuleNotFound = errors.New("rule not found")
|
||||
|
||||
// ValidateRule checks an operator-supplied rule, returning a message suitable
|
||||
// for a 400 response. The RE2 compile error is surfaced verbatim.
|
||||
func ValidateRule(r *Rule) error {
|
||||
if strings.TrimSpace(r.Name) == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
if r.Kind == KindAnalyzer {
|
||||
return errors.New("analyzer rules are built in and cannot be created")
|
||||
}
|
||||
r.Kind = KindRegex
|
||||
if strings.TrimSpace(r.Pattern) == "" {
|
||||
return errors.New("pattern is required")
|
||||
}
|
||||
if _, err := regexp.Compile(r.Pattern); err != nil {
|
||||
return fmt.Errorf("invalid pattern: %w", err)
|
||||
}
|
||||
if !r.Category.Valid() {
|
||||
return fmt.Errorf("unknown category %q", r.Category)
|
||||
}
|
||||
if !r.Severity.Valid() {
|
||||
return fmt.Errorf("unknown severity %q", r.Severity)
|
||||
}
|
||||
if r.Confidence == "" {
|
||||
r.Confidence = ConfidenceMedium
|
||||
}
|
||||
if !r.Confidence.Valid() {
|
||||
return fmt.Errorf("unknown confidence %q", r.Confidence)
|
||||
}
|
||||
if r.Target == "" {
|
||||
r.Target = TargetResponseBody
|
||||
}
|
||||
if r.Target == TargetMessage || !r.Target.Valid() {
|
||||
return fmt.Errorf("unknown target %q", r.Target)
|
||||
}
|
||||
if r.GroupBy == "" {
|
||||
r.GroupBy = GroupByEvidence
|
||||
}
|
||||
if !r.GroupBy.Valid() {
|
||||
return fmt.Errorf("unknown groupBy %q", r.GroupBy)
|
||||
}
|
||||
for _, name := range r.PostFilters {
|
||||
if _, ok := postFilterRegistry[name]; !ok {
|
||||
return fmt.Errorf("unknown post-filter %q", name)
|
||||
}
|
||||
}
|
||||
if r.CaptureGroup < 0 {
|
||||
return errors.New("captureGroup must not be negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddRule validates and appends an operator rule, returning the stored copy.
|
||||
func (e *Engine) AddRule(r Rule) (Rule, error) {
|
||||
if err := ValidateRule(&r); err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
r.ID = proxy.GenerateID()
|
||||
r.Builtin = false
|
||||
r.Enabled = true
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.user = append(e.user, r)
|
||||
e.rebuildLocked()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// UpdateRule replaces an operator rule in place. The ID must be preserved:
|
||||
// Finding.RuleID references it and Finding.ID derives from it.
|
||||
func (e *Engine) UpdateRule(id string, r Rule) (Rule, error) {
|
||||
if err := ValidateRule(&r); err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
for _, b := range e.builtin {
|
||||
if b.ID == id {
|
||||
return Rule{}, ErrBuiltinImmutable
|
||||
}
|
||||
}
|
||||
for i := range e.user {
|
||||
if e.user[i].ID != id {
|
||||
continue
|
||||
}
|
||||
r.ID = id
|
||||
r.Builtin = false
|
||||
r.Enabled = e.user[i].Enabled
|
||||
e.user[i] = r
|
||||
e.rebuildLocked()
|
||||
return r, nil
|
||||
}
|
||||
return Rule{}, ErrRuleNotFound
|
||||
}
|
||||
|
||||
// RemoveRule deletes an operator rule.
|
||||
func (e *Engine) RemoveRule(id string) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
for _, b := range e.builtin {
|
||||
if b.ID == id {
|
||||
return ErrBuiltinImmutable
|
||||
}
|
||||
}
|
||||
for i := range e.user {
|
||||
if e.user[i].ID == id {
|
||||
e.user = append(e.user[:i], e.user[i+1:]...)
|
||||
e.rebuildLocked()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ErrRuleNotFound
|
||||
}
|
||||
|
||||
// SetRuleEnabled toggles any rule, built-in or user.
|
||||
func (e *Engine) SetRuleEnabled(id string, enabled bool) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
for _, b := range e.builtin {
|
||||
if b.ID != id {
|
||||
continue
|
||||
}
|
||||
if enabled {
|
||||
delete(e.disabled, id)
|
||||
} else {
|
||||
e.disabled[id] = struct{}{}
|
||||
}
|
||||
e.rebuildLocked()
|
||||
return nil
|
||||
}
|
||||
for i := range e.user {
|
||||
if e.user[i].ID == id {
|
||||
e.user[i].Enabled = enabled
|
||||
e.rebuildLocked()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ErrRuleNotFound
|
||||
}
|
||||
|
||||
// SetRuleSeverity overrides a rule's severity. Allowed on built-ins.
|
||||
func (e *Engine) SetRuleSeverity(id string, sev Severity) error {
|
||||
if !sev.Valid() {
|
||||
return fmt.Errorf("unknown severity %q", sev)
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
for _, b := range e.builtin {
|
||||
if b.ID == id {
|
||||
e.severityOverride[id] = sev
|
||||
e.rebuildLocked()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
for i := range e.user {
|
||||
if e.user[i].ID == id {
|
||||
e.user[i].Severity = sev
|
||||
e.rebuildLocked()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ErrRuleNotFound
|
||||
}
|
||||
|
||||
// ResetRule clears an operator's overrides for a built-in rule.
|
||||
func (e *Engine) ResetRule(id string) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
for _, b := range e.builtin {
|
||||
if b.ID != id {
|
||||
continue
|
||||
}
|
||||
delete(e.severityOverride, id)
|
||||
delete(e.disabled, id)
|
||||
e.rebuildLocked()
|
||||
return nil
|
||||
}
|
||||
return ErrRuleNotFound
|
||||
}
|
||||
|
||||
// hostExcluded reports whether findings for a host should be suppressed.
|
||||
func (e *Engine) hostExcluded(host string, cfg Config) bool {
|
||||
if len(cfg.ExcludeHosts) == 0 {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(host)
|
||||
for _, h := range cfg.ExcludeHosts {
|
||||
if h != "" && strings.Contains(lower, strings.ToLower(h)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Scan runs every live rule against a captured request and returns the findings.
|
||||
// It performs no deduplication; that is Store.Upsert's job.
|
||||
func (e *Engine) Scan(r *proxy.CapturedRequest, inScope proxy.ScopeFunc) []Finding {
|
||||
e.mu.RLock()
|
||||
enabled, cfg, set := e.enabled, e.cfg, e.set
|
||||
e.mu.RUnlock()
|
||||
|
||||
if !enabled || set == nil || set.active == 0 || r == nil {
|
||||
return nil
|
||||
}
|
||||
m := Parse(r, cfg)
|
||||
if e.hostExcluded(m.Host, cfg) {
|
||||
return nil
|
||||
}
|
||||
if cfg.ScopeOnly && inScope != nil && !inScope(m.Host, r.Method, m.Path) {
|
||||
return nil
|
||||
}
|
||||
return e.scanMessage(m, cfg, set)
|
||||
}
|
||||
|
||||
// ScanMessage runs the rule set against an already-parsed message. Exposed for
|
||||
// tests, which build a Message directly rather than a raw capture.
|
||||
func (e *Engine) ScanMessage(m *Message) []Finding {
|
||||
e.mu.RLock()
|
||||
cfg, set := e.cfg, e.set
|
||||
e.mu.RUnlock()
|
||||
if set == nil {
|
||||
return nil
|
||||
}
|
||||
return e.scanMessage(m, cfg, set)
|
||||
}
|
||||
|
||||
func (e *Engine) scanMessage(m *Message, cfg Config, set *ruleSet) []Finding {
|
||||
var out []Finding
|
||||
|
||||
for target, rules := range set.byTarget {
|
||||
if !cfg.ScanRequests &&
|
||||
(target == TargetRequestBody || target == TargetRequestHeader) {
|
||||
continue
|
||||
}
|
||||
hay := m.haystack(target)
|
||||
if len(hay) == 0 {
|
||||
continue
|
||||
}
|
||||
var lower []byte
|
||||
for _, rule := range rules {
|
||||
if !ruleGatesPass(rule, m) {
|
||||
continue
|
||||
}
|
||||
// Literal prescreen: a substring search in place of a full regex pass.
|
||||
if rule.literalLower != nil {
|
||||
if lower == nil {
|
||||
lower = m.lowerHaystack(target)
|
||||
}
|
||||
if !bytes.Contains(lower, rule.literalLower) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, e.applyRegexRule(rule, m, target, hay)...)
|
||||
}
|
||||
}
|
||||
|
||||
for _, binding := range set.analyzers {
|
||||
if !ruleGatesPass(binding.rule, m) {
|
||||
continue
|
||||
}
|
||||
rule := binding.rule
|
||||
count := 0
|
||||
binding.fn(m, func(hit AnalyzerHit) {
|
||||
if count >= rule.MaxPerResponse {
|
||||
return
|
||||
}
|
||||
count++
|
||||
out = append(out, e.newFinding(rule, m, hit))
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ruleGatesPass applies the per-message gates: status expression, scheme, and
|
||||
// content type.
|
||||
func ruleGatesPass(rule *Rule, m *Message) bool {
|
||||
if rule.status != nil && !rule.status(m.RespStatus) {
|
||||
return false
|
||||
}
|
||||
if rule.Scheme != "" && !strings.EqualFold(rule.Scheme, m.Scheme) {
|
||||
return false
|
||||
}
|
||||
// Exclusion is checked before the whitelist and wins outright.
|
||||
if rule.excludeCtSet != nil {
|
||||
if _, bad := rule.excludeCtSet[contentTypeKeyword(m.ContentType)]; bad {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if rule.ctSet != nil {
|
||||
if _, ok := rule.ctSet[contentTypeKeyword(m.ContentType)]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// applyRegexRule runs one pattern and builds findings from its matches.
|
||||
func (e *Engine) applyRegexRule(rule *Rule, m *Message, target Target, hay []byte) []Finding {
|
||||
if rule.compiled == nil {
|
||||
return nil
|
||||
}
|
||||
matches := rule.compiled.FindAllSubmatchIndex(hay, rule.MaxPerResponse*4)
|
||||
if matches == nil {
|
||||
return nil
|
||||
}
|
||||
var out []Finding
|
||||
seen := map[string]struct{}{}
|
||||
for _, loc := range matches {
|
||||
if len(out) >= rule.MaxPerResponse {
|
||||
break
|
||||
}
|
||||
group := rule.CaptureGroup
|
||||
// Fall back to the whole match when the requested group did not
|
||||
// participate (alternation branches).
|
||||
lo, hi := loc[0], loc[1]
|
||||
if group > 0 && 2*group+1 < len(loc) && loc[2*group] >= 0 {
|
||||
lo, hi = loc[2*group], loc[2*group+1]
|
||||
}
|
||||
// Trim surrounding whitespace. Header blocks are CRLF-framed, so a pattern
|
||||
// capturing to end of line picks up a trailing \r, which would land in the
|
||||
// evidence and in the grouping hash. A left-hand trim shifts the value's
|
||||
// start, so the offset moves with it.
|
||||
full := string(hay[lo:hi])
|
||||
raw := strings.Trim(full, " \t\r\n")
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
matchAt := lo + (len(full) - len(strings.TrimLeft(full, " \t\r\n")))
|
||||
if !e.matchPasses(rule, raw, matchAt, hay, m) {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[raw]; dup {
|
||||
continue
|
||||
}
|
||||
seen[raw] = struct{}{}
|
||||
out = append(out, e.newFinding(rule, m, AnalyzerHit{
|
||||
Evidence: raw,
|
||||
Offset: matchAt,
|
||||
OffsetIn: target,
|
||||
OffsetLen: len(raw),
|
||||
// The raw match is the grouping dimension for evidence-grouped rules
|
||||
// and the value the operator reveals.
|
||||
rawMatch: raw,
|
||||
}))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchPasses applies the per-match validators in cost order: length, entropy,
|
||||
// then the named post-filters. Placeholder rejection is not universal; it is the
|
||||
// opt-in "denylist" post-filter.
|
||||
func (e *Engine) matchPasses(rule *Rule, raw string, offset int, hay []byte, m *Message) bool {
|
||||
if rule.MinLength > 0 && utf8.RuneCountInString(raw) < rule.MinLength {
|
||||
return false
|
||||
}
|
||||
if rule.MinEntropy > 0 && ShannonEntropy(raw) < rule.MinEntropy {
|
||||
return false
|
||||
}
|
||||
if len(rule.filters) > 0 {
|
||||
ctx := filterContext{Match: raw, Offset: offset, Hay: hay, Msg: m}
|
||||
for _, f := range rule.filters {
|
||||
if !f(ctx) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// groupDim computes the per-rule dedupe dimension.
|
||||
func groupDim(rule *Rule, m *Message, hit AnalyzerHit) string {
|
||||
extra := hit.GroupExtra
|
||||
switch rule.GroupBy {
|
||||
case GroupByHost:
|
||||
return extra
|
||||
case GroupByURL:
|
||||
return m.Path + "\x00" + extra
|
||||
default: // GroupByEvidence
|
||||
basis := hit.rawMatch
|
||||
if basis == "" {
|
||||
basis = hit.Evidence
|
||||
}
|
||||
// Hash the raw value so two secrets sharing a prefix never collide, and
|
||||
// so the key itself never carries the secret.
|
||||
sum := sha256.Sum256([]byte(basis))
|
||||
return extra + "\x00" + hex.EncodeToString(sum[:8])
|
||||
}
|
||||
}
|
||||
|
||||
// findingID builds the deterministic group hash.
|
||||
func findingID(ruleID, host, dim string) string {
|
||||
sum := sha256.Sum256([]byte(ruleID + "\x00" + host + "\x00" + dim))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
// redact masks the middle of a value, keeping 2-4 characters at each end.
|
||||
func redact(s string) string {
|
||||
if len(s) <= 8 {
|
||||
return strings.Repeat("*", len(s))
|
||||
}
|
||||
keep := 4
|
||||
if len(s) < 16 {
|
||||
keep = 2
|
||||
}
|
||||
return s[:keep] + strings.Repeat("*", 6) + s[len(s)-keep:]
|
||||
}
|
||||
|
||||
// RedactValue exposes the masking helper for the rule tester.
|
||||
func RedactValue(s string) string { return redact(s) }
|
||||
|
||||
// truncateRaw bounds the stored raw value to maxEvidenceBytes without escaping
|
||||
// it; the raw copy must be exactly what matched.
|
||||
func truncateRaw(s string) string {
|
||||
if len(s) <= maxEvidenceBytes {
|
||||
return s
|
||||
}
|
||||
return s[:maxEvidenceBytes]
|
||||
}
|
||||
|
||||
// sanitizeEvidence escapes control characters and truncates to maxEvidenceBytes.
|
||||
func sanitizeEvidence(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '\n':
|
||||
b.WriteString("\\n")
|
||||
case r == '\r':
|
||||
b.WriteString("\\r")
|
||||
case r == '\t':
|
||||
b.WriteString("\\t")
|
||||
case r < 0x20 || r == 0x7f:
|
||||
b.WriteString("\\x")
|
||||
const hexDigits = "0123456789abcdef"
|
||||
b.WriteByte(hexDigits[(r>>4)&0xf])
|
||||
b.WriteByte(hexDigits[r&0xf])
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
if b.Len() >= maxEvidenceBytes {
|
||||
return b.String()[:maxEvidenceBytes-1] + "…"
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// newFinding is the single constructor every detection path funnels through; it
|
||||
// applies redaction, truncation, and ID derivation.
|
||||
func (e *Engine) newFinding(rule *Rule, m *Message, hit AnalyzerHit) Finding {
|
||||
evidence := hit.Evidence
|
||||
var rawEvidence string
|
||||
if rule.RedactEvidence {
|
||||
basis := hit.rawMatch
|
||||
if basis == "" {
|
||||
basis = evidence
|
||||
}
|
||||
evidence = redact(basis)
|
||||
// Only populated where redaction hid something; elsewhere Evidence is
|
||||
// already the raw value.
|
||||
rawEvidence = truncateRaw(basis)
|
||||
}
|
||||
evidence = sanitizeEvidence(evidence)
|
||||
|
||||
sev := rule.Severity
|
||||
if hit.Severity != "" {
|
||||
sev = hit.Severity
|
||||
}
|
||||
conf := rule.Confidence
|
||||
if hit.Confidence != "" {
|
||||
conf = hit.Confidence
|
||||
}
|
||||
// The only place a buffer-relative offset becomes an offset into the rendered
|
||||
// document. An unmappable or zero-length region yields -1.
|
||||
offsetIn := hit.OffsetIn
|
||||
if offsetIn == "" && hit.Offset > 0 {
|
||||
offsetIn = rule.Target
|
||||
}
|
||||
absOffset, part, mapped := m.absoluteOffset(offsetIn, hit.Offset)
|
||||
if !mapped || hit.OffsetLen <= 0 {
|
||||
absOffset = -1
|
||||
}
|
||||
if part == "" {
|
||||
part = partName(rule.Target)
|
||||
}
|
||||
|
||||
ts := m.Req.Timestamp
|
||||
f := Finding{
|
||||
ID: findingID(rule.ID, m.Host, groupDim(rule, m, hit)),
|
||||
RuleID: rule.ID,
|
||||
RuleName: rule.Name,
|
||||
Category: rule.Category,
|
||||
Severity: sev,
|
||||
Confidence: conf,
|
||||
Target: rule.Target,
|
||||
Host: m.Host,
|
||||
Detail: hit.Detail,
|
||||
Evidence: evidence,
|
||||
RawEvidence: rawEvidence,
|
||||
EvidenceOffset: absOffset,
|
||||
EvidenceLength: hit.OffsetLen,
|
||||
EvidencePart: part,
|
||||
FirstSeen: ts,
|
||||
LastSeen: ts,
|
||||
Count: 1,
|
||||
Truncated: m.Truncated,
|
||||
}
|
||||
if m.Req != nil {
|
||||
f.Method = m.Req.Method
|
||||
f.URL = m.Req.URL
|
||||
f.RequestID = m.Req.ID
|
||||
f.Occurrences = []Occurrence{{
|
||||
RequestID: m.Req.ID, Seq: m.Req.Seq, Method: m.Req.Method,
|
||||
URL: m.Req.URL, StatusCode: m.RespStatus, Timestamp: ts,
|
||||
Offset: absOffset, Part: part,
|
||||
}}
|
||||
}
|
||||
return f
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package detect
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ShannonEntropy returns the Shannon entropy of s in bits per character. Used to
|
||||
// validate a value a pattern already matched, never as a detector on its own.
|
||||
// For calibration: random base64 scores around 6.0, random hex around 4.0, and
|
||||
// English prose 4.0 to 4.5.
|
||||
func ShannonEntropy(s string) float64 {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
var counts [256]int
|
||||
for i := 0; i < len(s); i++ {
|
||||
counts[s[i]]++
|
||||
}
|
||||
n := float64(len(s))
|
||||
var h float64
|
||||
for _, c := range counts {
|
||||
if c == 0 {
|
||||
continue
|
||||
}
|
||||
p := float64(c) / n
|
||||
h -= p * math.Log2(p)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// placeholderExact are values appearing verbatim in sample code, templates, and
|
||||
// documentation.
|
||||
var placeholderExact = map[string]struct{}{
|
||||
"": {},
|
||||
"null": {},
|
||||
"nil": {},
|
||||
"none": {},
|
||||
"undefined": {},
|
||||
"true": {},
|
||||
"false": {},
|
||||
"changeme": {},
|
||||
"change_me": {},
|
||||
"example": {},
|
||||
"test": {},
|
||||
"testing": {},
|
||||
"password": {},
|
||||
"passwd": {},
|
||||
"secret": {},
|
||||
"token": {},
|
||||
"apikey": {},
|
||||
"api_key": {},
|
||||
"your_api_key": {},
|
||||
"your_api_key_here": {},
|
||||
"your-api-key": {},
|
||||
"your_secret": {},
|
||||
"your_secret_here": {},
|
||||
"your_token_here": {},
|
||||
"my_api_key": {},
|
||||
"replace_me": {},
|
||||
"replaceme": {},
|
||||
"placeholder": {},
|
||||
"redacted": {},
|
||||
"insert_key_here": {},
|
||||
"todo": {},
|
||||
"fixme": {},
|
||||
"dummy": {},
|
||||
"sample": {},
|
||||
"foobar": {},
|
||||
"lorem": {},
|
||||
"abc123": {},
|
||||
"123456": {},
|
||||
"12345678": {},
|
||||
"password123": {},
|
||||
"admin": {},
|
||||
"root": {},
|
||||
"unset": {},
|
||||
"empty": {},
|
||||
"n/a": {},
|
||||
"na": {},
|
||||
"xxxxxxxx": {},
|
||||
"deadbeef": {},
|
||||
"00000000": {},
|
||||
"sk_test_key": {},
|
||||
"development": {},
|
||||
"production": {},
|
||||
"localhost": {},
|
||||
"do_not_use": {},
|
||||
"not_a_real_key": {},
|
||||
"enter_your_key_here": {},
|
||||
}
|
||||
|
||||
// placeholderSubstrings mark a value as a template rather than a live secret.
|
||||
var placeholderSubstrings = []string{
|
||||
"your_", "your-", "yourapi", "youraccount",
|
||||
"example.com", "example.org", "example_",
|
||||
"replace_", "replaceme", "insert_", "enter_",
|
||||
"changeme", "change_me", "placeholder", "redacted",
|
||||
"dummy", "notreal", "not_real", "fake_", "_fake",
|
||||
"xxxxx", "aaaaa", "zzzzz", "12345678",
|
||||
"todo", "fixme", "tbd",
|
||||
"my_secret", "my_token", "my_api",
|
||||
"sample_", "_sample", "demo_", "_demo",
|
||||
"test_key", "testkey", "test_token", "test_secret",
|
||||
"lorem", "ipsum",
|
||||
}
|
||||
|
||||
// isPlaceholder reports whether v is a template, sample, or documentation value
|
||||
// rather than a plausible live secret. Runs before the entropy gate;
|
||||
// "YOUR_API_KEY_HERE" and "${process.env.SECRET}" both score respectably.
|
||||
func isPlaceholder(v string) bool {
|
||||
t := strings.TrimSpace(v)
|
||||
if t == "" {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(t)
|
||||
if _, ok := placeholderExact[lower]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
// Template interpolation and shell/env indirection: the literal value is a
|
||||
// reference, not the secret.
|
||||
switch {
|
||||
case strings.HasPrefix(t, "${"), strings.HasPrefix(t, "{{"),
|
||||
strings.HasPrefix(t, "<%"), strings.HasPrefix(t, "%("),
|
||||
strings.HasPrefix(t, "$("), strings.HasPrefix(t, "#{"):
|
||||
return true
|
||||
case strings.HasPrefix(t, "<") && strings.HasSuffix(t, ">"):
|
||||
// <your-key-here>, <REDACTED>
|
||||
return true
|
||||
case strings.HasPrefix(lower, "process.env"), strings.HasPrefix(lower, "os.environ"),
|
||||
strings.HasPrefix(lower, "env."), strings.HasPrefix(lower, "config."),
|
||||
strings.HasPrefix(lower, "import.meta.env"):
|
||||
return true
|
||||
}
|
||||
|
||||
// Format specifiers left in a template string.
|
||||
if t == "%s" || t == "%d" || t == "%v" || t == "*" {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, sub := range placeholderSubstrings {
|
||||
if strings.Contains(lower, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// A single repeated character, or a masked value such as "••••" / "****".
|
||||
if isRepeatedRune(t) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isRepeatedRune reports whether s consists of one rune repeated, which covers
|
||||
// masked values ("****", "••••") and filler ("aaaaaaaa", "00000000").
|
||||
func isRepeatedRune(s string) bool {
|
||||
var first rune
|
||||
set := false
|
||||
for _, r := range s {
|
||||
if !set {
|
||||
first, set = r, true
|
||||
continue
|
||||
}
|
||||
if r != first {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package detect
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
// Message is a captured request/response pair decomposed into the buffers rules
|
||||
// are matched against. Parse handles the raw-byte quirks so rules do not have to:
|
||||
//
|
||||
// - RespRaw is Content-Length framed with a dechunked body (every capture
|
||||
// helper in internal/proxy clears TransferEncoding), but proxy hook plugins
|
||||
// can substitute their own bytes, so an LF-only header terminator is also
|
||||
// accepted.
|
||||
// - Bodies may arrive gzip- or deflate-encoded with Content-Encoding intact;
|
||||
// Parse decompresses them.
|
||||
// - Brotli and zstd bodies are marked unscannable and counted.
|
||||
type Message struct {
|
||||
Req *proxy.CapturedRequest
|
||||
|
||||
URL *url.URL
|
||||
Scheme string
|
||||
Host string
|
||||
Path string
|
||||
|
||||
RespStatus int
|
||||
RespHeader http.Header
|
||||
RespRawHdr []byte
|
||||
RespBody []byte
|
||||
|
||||
ReqHeader http.Header
|
||||
ReqRawHdr []byte
|
||||
ReqBody []byte
|
||||
|
||||
ContentType string
|
||||
|
||||
// BodyScannable reports whether RespBody is worth matching against.
|
||||
BodyScannable bool
|
||||
// SkipReason explains why not: "binary", "encoding:br", "content-type",
|
||||
// "extension", "empty", or "no-response".
|
||||
SkipReason string
|
||||
// Truncated marks that a body hit a scan size cap, so findings from this
|
||||
// message cannot claim to be exhaustive.
|
||||
Truncated bool
|
||||
|
||||
// RespBodyStart and ReqBodyStart are where each body begins inside the
|
||||
// corresponding raw document, used to turn a body-relative match offset into
|
||||
// an offset into the document the UI renders.
|
||||
RespBodyStart int
|
||||
ReqBodyStart int
|
||||
// RespBodyDecoded records that the body was decompressed for scanning. RespBody
|
||||
// and RespRaw then share no coordinate system, so no body offset is reported.
|
||||
RespBodyDecoded bool
|
||||
|
||||
// Case-folded copies for the Literal prescreen, built lazily per target. A
|
||||
// Message is scanned by one goroutine only, so this needs no synchronization.
|
||||
lowerCache map[Target][]byte
|
||||
}
|
||||
|
||||
// headerSep and headerSepLF are the two header terminators Parse accepts.
|
||||
var (
|
||||
headerSep = []byte("\r\n\r\n")
|
||||
headerSepLF = []byte("\n\n")
|
||||
)
|
||||
|
||||
// splitRawAt separates a raw HTTP dump into its header block and body, and
|
||||
// reports where the body begins within the dump. Accepts both CRLF and LF
|
||||
// header terminators, so bodyStart is len(hdr)+4 or len(hdr)+2 and must be used
|
||||
// rather than assumed.
|
||||
func splitRawAt(raw []byte) (hdr, body []byte, bodyStart int) {
|
||||
if i := bytes.Index(raw, headerSep); i >= 0 {
|
||||
return raw[:i], raw[i+len(headerSep):], i + len(headerSep)
|
||||
}
|
||||
if i := bytes.Index(raw, headerSepLF); i >= 0 {
|
||||
return raw[:i], raw[i+len(headerSepLF):], i + len(headerSepLF)
|
||||
}
|
||||
return raw, nil, len(raw)
|
||||
}
|
||||
|
||||
// parseHeaderLines parses a header block whose first line is a request or status
|
||||
// line, returning the parsed headers and that first line. Uses textproto, which
|
||||
// handles folded continuation lines and canonicalizes key case.
|
||||
func parseHeaderLines(block []byte) (http.Header, string) {
|
||||
if len(block) == 0 {
|
||||
return http.Header{}, ""
|
||||
}
|
||||
nl := bytes.IndexByte(block, '\n')
|
||||
first := block
|
||||
rest := []byte(nil)
|
||||
if nl >= 0 {
|
||||
first = bytes.TrimRight(block[:nl], "\r")
|
||||
rest = block[nl+1:]
|
||||
}
|
||||
if len(rest) == 0 {
|
||||
return http.Header{}, string(first)
|
||||
}
|
||||
// ReadMIMEHeader needs a blank line to terminate the header section.
|
||||
buf := make([]byte, 0, len(rest)+2)
|
||||
buf = append(buf, rest...)
|
||||
if !bytes.HasSuffix(buf, []byte("\n")) {
|
||||
buf = append(buf, '\r', '\n')
|
||||
}
|
||||
buf = append(buf, '\r', '\n')
|
||||
mh, err := textproto.NewReader(bufio.NewReader(bytes.NewReader(buf))).ReadMIMEHeader()
|
||||
if err != nil && len(mh) == 0 {
|
||||
return http.Header{}, string(first)
|
||||
}
|
||||
return http.Header(mh), string(first)
|
||||
}
|
||||
|
||||
// statusFromLine extracts the status code from a status line, accepting any
|
||||
// HTTP version token (the h2 capture path synthesizes "HTTP/2 200 OK").
|
||||
func statusFromLine(line string) int {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 || !strings.HasPrefix(fields[0], "HTTP/") {
|
||||
return 0
|
||||
}
|
||||
code, err := strconv.Atoi(fields[1])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// contentTypeKeyword maps a MIME type to the simplified keyword used by
|
||||
// Rule.ContentTypes.
|
||||
func contentTypeKeyword(ct string) string {
|
||||
c := strings.ToLower(ct)
|
||||
switch {
|
||||
case strings.Contains(c, "json"):
|
||||
return "json"
|
||||
case strings.Contains(c, "text/html"), strings.Contains(c, "xhtml"):
|
||||
return "html"
|
||||
case strings.Contains(c, "javascript"), strings.Contains(c, "ecmascript"):
|
||||
return "js"
|
||||
case strings.Contains(c, "xml"):
|
||||
return "xml"
|
||||
case strings.Contains(c, "text/css"):
|
||||
return "css"
|
||||
case strings.Contains(c, "csv"):
|
||||
return "csv"
|
||||
case strings.Contains(c, "text/plain"), c == "":
|
||||
return "plain"
|
||||
case strings.Contains(c, "text/"):
|
||||
return "plain"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
// encodingSupported reports whether proxy.TryDecompress handles a
|
||||
// Content-Encoding value. Anything else marks the body unscannable.
|
||||
func encodingSupported(enc string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(enc)) {
|
||||
case "", "identity", "gzip", "deflate", "x-gzip":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse decomposes a captured request into a Message, applying the
|
||||
// false-positive and cost gates from cfg. It never returns nil.
|
||||
func Parse(r *proxy.CapturedRequest, cfg Config) *Message {
|
||||
m := &Message{Req: r, Host: r.Host, ContentType: r.ContentType}
|
||||
|
||||
if u, err := url.Parse(r.URL); err == nil && u != nil {
|
||||
m.URL = u
|
||||
m.Scheme = u.Scheme
|
||||
m.Path = u.Path
|
||||
if u.Host != "" {
|
||||
m.Host = u.Host
|
||||
}
|
||||
}
|
||||
if m.Scheme == "" {
|
||||
// CONNECT-tunnelled requests carry an absolute URL, so an empty scheme
|
||||
// means a plain-HTTP proxy request.
|
||||
m.Scheme = "http"
|
||||
}
|
||||
if m.Path == "" {
|
||||
m.Path = "/"
|
||||
}
|
||||
|
||||
// Request side.
|
||||
if len(r.ReqRaw) > 0 {
|
||||
hdr, reqBody, reqStart := splitRawAt(r.ReqRaw)
|
||||
m.ReqRawHdr = hdr
|
||||
m.ReqBodyStart = reqStart
|
||||
m.ReqHeader, _ = parseHeaderLines(hdr)
|
||||
if cfg.ScanRequests {
|
||||
body := reqBody
|
||||
limit := cfg.MaxRequestBodyScanBytes
|
||||
if limit > 0 && len(body) > limit {
|
||||
body = body[:limit]
|
||||
m.Truncated = true
|
||||
}
|
||||
m.ReqBody = body
|
||||
}
|
||||
}
|
||||
|
||||
// Response side.
|
||||
if len(r.RespRaw) == 0 {
|
||||
m.SkipReason = "no-response"
|
||||
return m
|
||||
}
|
||||
hdr, body, respStart := splitRawAt(r.RespRaw)
|
||||
m.RespRawHdr = hdr
|
||||
m.RespBodyStart = respStart
|
||||
m.RespHeader, m.RespStatus = func() (http.Header, int) {
|
||||
h, first := parseHeaderLines(hdr)
|
||||
return h, statusFromLine(first)
|
||||
}()
|
||||
if m.RespStatus == 0 {
|
||||
m.RespStatus = r.StatusCode
|
||||
}
|
||||
if m.ContentType == "" {
|
||||
m.ContentType = m.RespHeader.Get("Content-Type")
|
||||
}
|
||||
|
||||
m.RespBody, m.BodyScannable, m.SkipReason, m.Truncated = prepareBody(m, body, cfg)
|
||||
return m
|
||||
}
|
||||
|
||||
// prepareBody decompresses and gates a response body, returning the scannable
|
||||
// bytes plus why they are not scannable when that is the case.
|
||||
func prepareBody(m *Message, body []byte, cfg Config) (out []byte, ok bool, skip string, truncated bool) {
|
||||
if len(body) == 0 {
|
||||
return nil, false, "empty", false
|
||||
}
|
||||
|
||||
if enc := m.RespHeader.Get("Content-Encoding"); !encodingSupported(enc) {
|
||||
return nil, false, "encoding:" + strings.ToLower(strings.TrimSpace(enc)), false
|
||||
} else if enc != "" {
|
||||
if decoded, didDecode := proxy.TryDecompress(enc, body); didDecode {
|
||||
body = decoded
|
||||
// Decoded bytes no longer correspond to anything in RespRaw, so offsets
|
||||
// into them are unmappable.
|
||||
m.RespBodyDecoded = true
|
||||
}
|
||||
}
|
||||
|
||||
// Content-type gate.
|
||||
ctLower := strings.ToLower(m.ContentType)
|
||||
for _, prefix := range cfg.SkipContentTypes {
|
||||
if prefix != "" && strings.Contains(ctLower, prefix) {
|
||||
return nil, false, "content-type", false
|
||||
}
|
||||
}
|
||||
|
||||
// Extension gate. cfg.SkipExtensions does not include .js or .css.
|
||||
if ext := pathExt(m.Path); ext != "" {
|
||||
for _, skipExt := range cfg.SkipExtensions {
|
||||
if strings.EqualFold(ext, skipExt) {
|
||||
return nil, false, "extension", false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Size cap before the binary sniff so a huge binary is cheap to reject.
|
||||
if cfg.MaxBodyScanBytes > 0 && len(body) > cfg.MaxBodyScanBytes {
|
||||
body = body[:cfg.MaxBodyScanBytes]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
if isBinary(body) {
|
||||
return nil, false, "binary", truncated
|
||||
}
|
||||
return body, true, "", truncated
|
||||
}
|
||||
|
||||
// pathExt returns the lowercase extension of a URL path, or "".
|
||||
func pathExt(p string) string {
|
||||
if i := strings.LastIndexByte(p, '/'); i >= 0 {
|
||||
p = p[i+1:]
|
||||
}
|
||||
i := strings.LastIndexByte(p, '.')
|
||||
if i <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(p[i:])
|
||||
}
|
||||
|
||||
// isBinary reports whether a body is not worth scanning as text, from a NUL
|
||||
// byte in the first kilobyte or a failed UTF-8 check.
|
||||
func isBinary(body []byte) bool {
|
||||
head := body
|
||||
if len(head) > 1024 {
|
||||
head = head[:1024]
|
||||
}
|
||||
if bytes.IndexByte(head, 0) >= 0 {
|
||||
return true
|
||||
}
|
||||
detected := http.DetectContentType(head)
|
||||
switch {
|
||||
case strings.HasPrefix(detected, "text/"),
|
||||
strings.Contains(detected, "json"),
|
||||
strings.Contains(detected, "javascript"),
|
||||
strings.Contains(detected, "xml"):
|
||||
return false
|
||||
}
|
||||
return !utf8.Valid(head)
|
||||
}
|
||||
|
||||
// haystack returns the buffer a target is matched against, or nil when the
|
||||
// message has nothing for that target.
|
||||
func (m *Message) haystack(t Target) []byte {
|
||||
switch t {
|
||||
case TargetResponseBody:
|
||||
if !m.BodyScannable {
|
||||
return nil
|
||||
}
|
||||
return m.RespBody
|
||||
case TargetResponseHeader:
|
||||
return m.RespRawHdr
|
||||
case TargetRequestHeader:
|
||||
return m.ReqRawHdr
|
||||
case TargetRequestBody:
|
||||
return m.ReqBody
|
||||
case TargetURL:
|
||||
if m.Req == nil {
|
||||
return nil
|
||||
}
|
||||
return []byte(m.Req.URL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// absoluteOffset translates a match offset relative to one scanned buffer into
|
||||
// an offset within the raw document the UI renders, plus that document's name.
|
||||
// A body-relative offset is not an offset into the document, which also holds
|
||||
// the status line, headers, and separator.
|
||||
//
|
||||
// ok is false when no faithful mapping exists (a decompressed body, or a URL
|
||||
// match, which is not a slice of either document). Callers must then report no
|
||||
// offset at all.
|
||||
func (m *Message) absoluteOffset(in Target, rel int) (abs int, part string, ok bool) {
|
||||
switch in {
|
||||
case TargetResponseHeader:
|
||||
// The header block starts at byte 0 of the document.
|
||||
return rel, "response", true
|
||||
case TargetResponseBody:
|
||||
if m.RespBodyDecoded {
|
||||
return 0, "response", false
|
||||
}
|
||||
return m.RespBodyStart + rel, "response", true
|
||||
case TargetRequestHeader:
|
||||
return rel, "request", true
|
||||
case TargetRequestBody:
|
||||
return m.ReqBodyStart + rel, "request", true
|
||||
case TargetURL:
|
||||
// Not a slice of either document; naming the request pane lets the
|
||||
// client's text-search fallback find it in the request line.
|
||||
return 0, "request", false
|
||||
}
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
// partName labels which buffer an offset indexes into.
|
||||
func partName(t Target) string {
|
||||
switch t {
|
||||
case TargetRequestHeader, TargetRequestBody:
|
||||
return "request"
|
||||
case TargetURL:
|
||||
return "url"
|
||||
default:
|
||||
return "response"
|
||||
}
|
||||
}
|
||||
|
||||
// lowerHaystack returns a case-folded copy of a target's buffer, cached per
|
||||
// message. Used only by the Literal prescreen, which is case-insensitive.
|
||||
func (m *Message) lowerHaystack(t Target) []byte {
|
||||
if m.lowerCache == nil {
|
||||
m.lowerCache = make(map[Target][]byte, 3)
|
||||
}
|
||||
if v, ok := m.lowerCache[t]; ok {
|
||||
return v
|
||||
}
|
||||
v := bytes.ToLower(m.haystack(t))
|
||||
m.lowerCache[t] = v
|
||||
return v
|
||||
}
|
||||
|
||||
// SetCookies returns the raw Set-Cookie header values.
|
||||
func (m *Message) SetCookies() []string {
|
||||
if m.RespHeader == nil {
|
||||
return nil
|
||||
}
|
||||
return m.RespHeader.Values("Set-Cookie")
|
||||
}
|
||||
|
||||
// IsHTMLDocument reports whether the response is an HTML page. Document-level
|
||||
// header analyzers (CSP, frame-options) gate on this.
|
||||
func (m *Message) IsHTMLDocument() bool {
|
||||
if m.RespStatus < 200 || m.RespStatus >= 300 {
|
||||
return false
|
||||
}
|
||||
if len(m.RespBody) == 0 {
|
||||
return false
|
||||
}
|
||||
kw := contentTypeKeyword(m.ContentType)
|
||||
return kw == "html"
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
package detect
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// filterContext is everything a post-filter may inspect: the captured group, its
|
||||
// position inside the scanned buffer, that buffer, and the parsed message.
|
||||
// Position and buffer support filters that examine the match's neighbours, such
|
||||
// as separating the IP 10.1.2.3 from the version string 1.10.1.2.3.
|
||||
type filterContext struct {
|
||||
Match string
|
||||
Offset int
|
||||
Hay []byte
|
||||
Msg *Message
|
||||
}
|
||||
|
||||
// postFilter reports whether a captured group survives. Returning false drops
|
||||
// the match silently, as if the pattern had not matched. RE2 has no lookahead or
|
||||
// lookbehind, so negative conditions are expressed here as Go code.
|
||||
type postFilter func(filterContext) bool
|
||||
|
||||
// postFilterRegistry maps the names used in Rule.PostFilters to their
|
||||
// implementations. Operators may reference these by name but cannot add new ones.
|
||||
var postFilterRegistry = map[string]postFilter{
|
||||
"luhn": filterLuhn,
|
||||
"iban97": filterIBAN97,
|
||||
"ssn": filterSSN,
|
||||
"mod11nhs": filterNHS,
|
||||
"cpf": filterCPF,
|
||||
"verhoeff": filterVerhoeff,
|
||||
"rutMod11": filterRUT,
|
||||
"denylist": filterDenylist,
|
||||
"notVersionString": filterNotVersionString,
|
||||
"notHTML": filterNotHTML,
|
||||
"basicCreds": filterBasicCreds,
|
||||
"jwtStructure": filterJWTStructure,
|
||||
"notHashLike": filterNotHashLike,
|
||||
}
|
||||
|
||||
// resolvePostFilters looks up filter names, skipping unknown ones so a rule from
|
||||
// a newer build degrades to a looser check rather than failing.
|
||||
func resolvePostFilters(names []string) []postFilter {
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]postFilter, 0, len(names))
|
||||
for _, n := range names {
|
||||
if f, ok := postFilterRegistry[n]; ok {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PostFilterNames returns the registered post-filter names, for API validation
|
||||
// of operator-supplied rules.
|
||||
func PostFilterNames() []string {
|
||||
out := make([]string, 0, len(postFilterRegistry))
|
||||
for n := range postFilterRegistry {
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// digitsOnly strips every non-digit byte.
|
||||
func digitsOnly(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] >= '0' && s[i] <= '9' {
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// filterDenylist rejects template, sample, and masked values.
|
||||
func filterDenylist(ctx filterContext) bool {
|
||||
return !isPlaceholder(ctx.Match)
|
||||
}
|
||||
|
||||
// knownTestPANs are the card numbers published in payment-gateway documentation.
|
||||
// All pass Luhn, so they are rejected by value.
|
||||
var knownTestPANs = map[string]struct{}{
|
||||
"4111111111111111": {}, "4012888888881881": {}, "4222222222222": {},
|
||||
"4242424242424242": {}, "4000000000000002": {}, "4000000000000069": {},
|
||||
"4000000000000119": {}, "4000000000009995": {}, "4000056655665556": {},
|
||||
"5555555555554444": {}, "5105105105105100": {}, "5200828282828210": {},
|
||||
"5555555555555555": {}, "2223003122003222": {},
|
||||
"378282246310005": {}, "371449635398431": {}, "378734493671000": {},
|
||||
"340000000000009": {},
|
||||
"30569309025904": {}, "38520000023237": {}, "36227206271667": {},
|
||||
"6011111111111117": {}, "6011000990139424": {}, "6011000000000004": {},
|
||||
"3530111333300000": {}, "3566002020360505": {},
|
||||
"6200000000000005": {}, "6205500000000000004": {},
|
||||
}
|
||||
|
||||
// panLengthValid checks the digit count against the issuer identified by the
|
||||
// leading digits.
|
||||
func panLengthValid(d string) bool {
|
||||
n := len(d)
|
||||
switch {
|
||||
case strings.HasPrefix(d, "34"), strings.HasPrefix(d, "37"):
|
||||
return n == 15 // American Express
|
||||
case strings.HasPrefix(d, "36"), strings.HasPrefix(d, "38"),
|
||||
strings.HasPrefix(d, "300"), strings.HasPrefix(d, "301"),
|
||||
strings.HasPrefix(d, "302"), strings.HasPrefix(d, "303"),
|
||||
strings.HasPrefix(d, "304"), strings.HasPrefix(d, "305"):
|
||||
return n == 14 // Diners Club
|
||||
case strings.HasPrefix(d, "62"):
|
||||
return n >= 16 && n <= 19 // UnionPay
|
||||
case d[0] == '4':
|
||||
return n == 16 || n == 13 // Visa (13 is legacy but still issued historically)
|
||||
default:
|
||||
return n == 16
|
||||
}
|
||||
}
|
||||
|
||||
// luhnValid runs the Luhn checksum.
|
||||
func luhnValid(d string) bool {
|
||||
if len(d) < 12 {
|
||||
return false
|
||||
}
|
||||
sum := 0
|
||||
double := false
|
||||
for i := len(d) - 1; i >= 0; i-- {
|
||||
n := int(d[i] - '0')
|
||||
if double {
|
||||
n *= 2
|
||||
if n > 9 {
|
||||
n -= 9
|
||||
}
|
||||
}
|
||||
sum += n
|
||||
double = !double
|
||||
}
|
||||
return sum%10 == 0
|
||||
}
|
||||
|
||||
// filterLuhn validates a candidate card number with the Luhn checksum, a brand
|
||||
// length check, and the test-PAN denylist.
|
||||
func filterLuhn(ctx filterContext) bool {
|
||||
d := digitsOnly(ctx.Match)
|
||||
if d == "" {
|
||||
return false
|
||||
}
|
||||
if _, isTest := knownTestPANs[d]; isTest {
|
||||
return false
|
||||
}
|
||||
if isRepeatedRune(d) {
|
||||
return false
|
||||
}
|
||||
return panLengthValid(d) && luhnValid(d)
|
||||
}
|
||||
|
||||
// knownFakeSSNs are the placeholder social security numbers used in adverts,
|
||||
// training material, and test fixtures.
|
||||
var knownFakeSSNs = map[string]struct{}{
|
||||
"078051120": {}, // the 1938 wallet-insert number
|
||||
"111111111": {}, "123456789": {}, "219099999": {},
|
||||
"222222222": {}, "333333333": {}, "444444444": {},
|
||||
"555555555": {}, "666666666": {}, "777777777": {},
|
||||
"888888888": {}, "987654320": {}, "987654321": {},
|
||||
"987654322": {}, "987654323": {}, "987654324": {},
|
||||
"987654325": {}, "987654326": {}, "987654327": {},
|
||||
"987654328": {}, "987654329": {},
|
||||
}
|
||||
|
||||
// filterSSN applies the Social Security Administration's structural rules (area
|
||||
// 000, 666, and 900-999, group 00, and serial 0000 are never assigned) plus the
|
||||
// knownFakeSSNs list.
|
||||
func filterSSN(ctx filterContext) bool {
|
||||
d := digitsOnly(ctx.Match)
|
||||
if len(d) != 9 {
|
||||
return false
|
||||
}
|
||||
if _, fake := knownFakeSSNs[d]; fake {
|
||||
return false
|
||||
}
|
||||
area := d[0:3]
|
||||
group := d[3:5]
|
||||
serial := d[5:9]
|
||||
if area == "000" || area == "666" || area[0] == '9' {
|
||||
return false
|
||||
}
|
||||
if group == "00" || serial == "0000" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ibanLengths is the official IBAN length per country, checked before the mod-97
|
||||
// arithmetic.
|
||||
var ibanLengths = map[string]int{
|
||||
"AD": 24, "AE": 23, "AL": 28, "AT": 20, "AZ": 28, "BA": 20, "BE": 16,
|
||||
"BG": 22, "BH": 22, "BR": 29, "BY": 28, "CH": 21, "CR": 22, "CY": 28,
|
||||
"CZ": 24, "DE": 22, "DK": 18, "DO": 28, "EE": 20, "EG": 29, "ES": 24,
|
||||
"FI": 18, "FO": 18, "FR": 27, "GB": 22, "GE": 22, "GI": 23, "GL": 18,
|
||||
"GR": 27, "GT": 28, "HR": 21, "HU": 28, "IE": 22, "IL": 23, "IQ": 23,
|
||||
"IS": 26, "IT": 27, "JO": 30, "KW": 30, "KZ": 20, "LB": 28, "LC": 32,
|
||||
"LI": 21, "LT": 20, "LU": 20, "LV": 21, "LY": 25, "MC": 27, "MD": 24,
|
||||
"ME": 22, "MK": 19, "MR": 27, "MT": 31, "MU": 30, "NL": 18, "NO": 15,
|
||||
"PK": 24, "PL": 28, "PS": 29, "PT": 25, "QA": 29, "RO": 24, "RS": 22,
|
||||
"SA": 24, "SC": 31, "SE": 24, "SI": 19, "SK": 24, "SM": 27, "ST": 25,
|
||||
"SV": 28, "TL": 23, "TN": 24, "TR": 26, "UA": 29, "VA": 22, "VG": 24,
|
||||
"XK": 20,
|
||||
}
|
||||
|
||||
// filterIBAN97 runs the ISO 13616 mod-97 check.
|
||||
func filterIBAN97(ctx filterContext) bool {
|
||||
s := strings.ToUpper(strings.ReplaceAll(ctx.Match, " ", ""))
|
||||
if len(s) < 15 || len(s) > 34 {
|
||||
return false
|
||||
}
|
||||
want, ok := ibanLengths[s[0:2]]
|
||||
if !ok || len(s) != want {
|
||||
return false
|
||||
}
|
||||
// Move the first four characters to the end, then treat letters as numbers
|
||||
// (A=10 ... Z=35) and take the whole value mod 97 incrementally.
|
||||
rearranged := s[4:] + s[0:4]
|
||||
rem := 0
|
||||
for i := 0; i < len(rearranged); i++ {
|
||||
c := rearranged[i]
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
rem = (rem*10 + int(c-'0')) % 97
|
||||
case c >= 'A' && c <= 'Z':
|
||||
v := int(c-'A') + 10
|
||||
rem = (rem*100 + v) % 97
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return rem == 1
|
||||
}
|
||||
|
||||
// filterNHS validates an NHS number's mod-11 check digit.
|
||||
func filterNHS(ctx filterContext) bool {
|
||||
d := digitsOnly(ctx.Match)
|
||||
if len(d) != 10 || isRepeatedRune(d) {
|
||||
return false
|
||||
}
|
||||
sum := 0
|
||||
for i := 0; i < 9; i++ {
|
||||
sum += int(d[i]-'0') * (10 - i)
|
||||
}
|
||||
check := 11 - (sum % 11)
|
||||
switch check {
|
||||
case 11:
|
||||
check = 0
|
||||
case 10:
|
||||
return false // 10 is not a valid check digit; the number is invalid
|
||||
}
|
||||
return check == int(d[9]-'0')
|
||||
}
|
||||
|
||||
// filterCPF validates the two check digits of a Brazilian CPF.
|
||||
func filterCPF(ctx filterContext) bool {
|
||||
d := digitsOnly(ctx.Match)
|
||||
if len(d) != 11 || isRepeatedRune(d) {
|
||||
return false
|
||||
}
|
||||
check := func(upTo int) int {
|
||||
sum := 0
|
||||
weight := upTo + 1
|
||||
for i := 0; i < upTo; i++ {
|
||||
sum += int(d[i]-'0') * weight
|
||||
weight--
|
||||
}
|
||||
r := (sum * 10) % 11
|
||||
if r == 10 {
|
||||
return 0
|
||||
}
|
||||
return r
|
||||
}
|
||||
return check(9) == int(d[9]-'0') && check(10) == int(d[10]-'0')
|
||||
}
|
||||
|
||||
// verhoeffD, verhoeffP, and verhoeffInv are the Verhoeff algorithm tables used
|
||||
// by India's Aadhaar number.
|
||||
var (
|
||||
verhoeffD = [10][10]int{
|
||||
{0, 1, 2, 3, 4, 5, 6, 7, 8, 9},
|
||||
{1, 2, 3, 4, 0, 6, 7, 8, 9, 5},
|
||||
{2, 3, 4, 0, 1, 7, 8, 9, 5, 6},
|
||||
{3, 4, 0, 1, 2, 8, 9, 5, 6, 7},
|
||||
{4, 0, 1, 2, 3, 9, 5, 6, 7, 8},
|
||||
{5, 9, 8, 7, 6, 0, 4, 3, 2, 1},
|
||||
{6, 5, 9, 8, 7, 1, 0, 4, 3, 2},
|
||||
{7, 6, 5, 9, 8, 2, 1, 0, 4, 3},
|
||||
{8, 7, 6, 5, 9, 3, 2, 1, 0, 4},
|
||||
{9, 8, 7, 6, 5, 4, 3, 2, 1, 0},
|
||||
}
|
||||
verhoeffP = [8][10]int{
|
||||
{0, 1, 2, 3, 4, 5, 6, 7, 8, 9},
|
||||
{1, 5, 7, 6, 2, 8, 3, 0, 9, 4},
|
||||
{5, 8, 0, 3, 7, 9, 6, 1, 4, 2},
|
||||
{8, 9, 1, 6, 0, 4, 3, 5, 2, 7},
|
||||
{9, 4, 5, 3, 1, 2, 6, 8, 7, 0},
|
||||
{4, 2, 8, 6, 5, 7, 3, 9, 0, 1},
|
||||
{2, 7, 9, 3, 8, 0, 6, 4, 1, 5},
|
||||
{7, 0, 4, 6, 9, 1, 3, 2, 5, 8},
|
||||
}
|
||||
)
|
||||
|
||||
// filterVerhoeff validates a 12-digit Aadhaar number's Verhoeff check digit.
|
||||
func filterVerhoeff(ctx filterContext) bool {
|
||||
d := digitsOnly(ctx.Match)
|
||||
if len(d) != 12 || isRepeatedRune(d) {
|
||||
return false
|
||||
}
|
||||
// Aadhaar numbers never start with 0 or 1.
|
||||
if d[0] == '0' || d[0] == '1' {
|
||||
return false
|
||||
}
|
||||
c := 0
|
||||
for i := 0; i < len(d); i++ {
|
||||
digit := int(d[len(d)-1-i] - '0')
|
||||
c = verhoeffD[c][verhoeffP[i%8][digit]]
|
||||
}
|
||||
return c == 0
|
||||
}
|
||||
|
||||
// filterRUT validates a Chilean RUT (Rol Único Tributario) by its mod-11 check
|
||||
// digit. The body is 7-8 digits and the check digit is 0-9 or K: weight the body
|
||||
// right-to-left by 2,3,4,5,6,7 cycling; the digit is 11-(sum mod 11), with 11
|
||||
// rendered as 0 and 10 as K.
|
||||
func filterRUT(ctx filterContext) bool {
|
||||
v := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(ctx.Match), ".", ""))
|
||||
dash := strings.LastIndexByte(v, '-')
|
||||
if dash < 0 || dash != len(v)-2 {
|
||||
return false
|
||||
}
|
||||
body, given := v[:dash], v[dash+1]
|
||||
if len(body) < 7 || len(body) > 8 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(body); i++ {
|
||||
if body[i] < '0' || body[i] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 11.111.111-1 satisfies the checksum but is the standard Chilean placeholder.
|
||||
if isRepeatedRune(body) {
|
||||
return false
|
||||
}
|
||||
|
||||
sum, weight := 0, 2
|
||||
for i := len(body) - 1; i >= 0; i-- {
|
||||
sum += int(body[i]-'0') * weight
|
||||
if weight == 7 {
|
||||
weight = 2
|
||||
} else {
|
||||
weight++
|
||||
}
|
||||
}
|
||||
var want byte
|
||||
switch r := 11 - (sum % 11); r {
|
||||
case 11:
|
||||
want = '0'
|
||||
case 10:
|
||||
want = 'K'
|
||||
default:
|
||||
want = byte('0' + r)
|
||||
}
|
||||
return given == want
|
||||
}
|
||||
|
||||
// versionContextWords indicate the surrounding text is describing a version
|
||||
// number rather than an address.
|
||||
var versionContextWords = []string{
|
||||
"version", "release", "build", "semver", "changelog", "revision",
|
||||
"v.", "sdk", "runtime", "engine", "schema", "migrat",
|
||||
}
|
||||
|
||||
// filterNotVersionString rejects private-range IP matches that are part of a
|
||||
// longer dotted-numeric token or sit in version-string context, e.g. "1.10.0.1".
|
||||
func filterNotVersionString(ctx filterContext) bool {
|
||||
// A digit or dot immediately before the match means the match is a suffix of
|
||||
// a longer numeric token (1.10.1.2.3), not a standalone address.
|
||||
if ctx.Offset > 0 && ctx.Offset <= len(ctx.Hay) {
|
||||
prev := ctx.Hay[ctx.Offset-1]
|
||||
if prev == '.' || (prev >= '0' && prev <= '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// A dot followed by a digit immediately after means the same on the right.
|
||||
end := ctx.Offset + len(ctx.Match)
|
||||
if end+1 < len(ctx.Hay) && ctx.Hay[end] == '.' &&
|
||||
ctx.Hay[end+1] >= '0' && ctx.Hay[end+1] <= '9' {
|
||||
return false
|
||||
}
|
||||
// Look at a small window of surrounding text for version vocabulary.
|
||||
lo := ctx.Offset - 40
|
||||
if lo < 0 {
|
||||
lo = 0
|
||||
}
|
||||
hi := end + 40
|
||||
if hi > len(ctx.Hay) {
|
||||
hi = len(ctx.Hay)
|
||||
}
|
||||
if lo < hi {
|
||||
window := strings.ToLower(string(ctx.Hay[lo:hi]))
|
||||
for _, w := range versionContextWords {
|
||||
if strings.Contains(window, w) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// filterNotHTML rejects matches in an HTML response. Requesting /.env from a
|
||||
// single-page app returns index.html with status 200, which a URL-only rule
|
||||
// would otherwise report as a config-file exposure.
|
||||
func filterNotHTML(ctx filterContext) bool {
|
||||
if ctx.Msg == nil {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(strings.ToLower(ctx.Msg.ContentType), "text/html") {
|
||||
return false
|
||||
}
|
||||
head := ctx.Msg.RespBody
|
||||
if len(head) > 512 {
|
||||
head = head[:512]
|
||||
}
|
||||
lower := strings.ToLower(strings.TrimSpace(string(head)))
|
||||
if strings.HasPrefix(lower, "<!doctype html") || strings.HasPrefix(lower, "<html") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// filterBasicCreds validates that an Authorization: Basic payload base64-decodes
|
||||
// to a credential pair.
|
||||
func filterBasicCreds(ctx filterContext) bool {
|
||||
dec, err := base64.StdEncoding.DecodeString(strings.TrimSpace(ctx.Match))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
s := string(dec)
|
||||
if !strings.Contains(s, ":") {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r > unicode.MaxASCII || (r < 0x20 && r != '\t') || r == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// filterJWTStructure requires that the first segment base64url-decodes to
|
||||
// something that looks like a JOSE header, which rules out arbitrary
|
||||
// dot-separated base64 tokens.
|
||||
func filterJWTStructure(ctx filterContext) bool {
|
||||
parts := strings.Split(ctx.Match, ".")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
hdr, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
s := string(hdr)
|
||||
return strings.Contains(s, "\"alg\"") || strings.Contains(s, "'alg'")
|
||||
}
|
||||
|
||||
// filterNotHashLike rejects bare 32/40/64-character hex strings, which are MD5,
|
||||
// SHA-1, and SHA-256 digests rather than secrets.
|
||||
func filterNotHashLike(ctx filterContext) bool {
|
||||
v := strings.TrimSpace(ctx.Match)
|
||||
switch len(v) {
|
||||
case 32, 40, 64:
|
||||
default:
|
||||
return true
|
||||
}
|
||||
for i := 0; i < len(v); i++ {
|
||||
c := v[i] | 0x20 // fold case
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,938 @@
|
||||
package detect
|
||||
|
||||
// This file is the built-in rule library, exposed as a function rather than a
|
||||
// package variable so nothing outside the engine can mutate the table.
|
||||
//
|
||||
// Every pattern must be valid RE2 — no lookahead, no lookbehind, no
|
||||
// backreferences. Negative conditions belong in Rule.PostFilters.
|
||||
//
|
||||
// Conventions:
|
||||
// - IDs are stable kebab-case strings. Never rename one: Finding.RuleID and the
|
||||
// persisted disabled-rule set both reference it.
|
||||
// - Literal is a case-insensitive substring that must appear in any real match.
|
||||
// Getting it wrong silently disables the rule.
|
||||
// - Every shipped rule is enabled; there is no present-but-disabled tier.
|
||||
// A rule that cannot reach usable precision is not shipped.
|
||||
|
||||
// builtinRules returns a fresh copy of the shipped rule set.
|
||||
func builtinRules() []Rule {
|
||||
var rules []Rule
|
||||
rules = append(rules, secretRules()...)
|
||||
rules = append(rules, piiRules()...)
|
||||
rules = append(rules, credentialRules()...)
|
||||
rules = append(rules, accessRules()...)
|
||||
rules = append(rules, disclosureRules()...)
|
||||
rules = append(rules, analyzerRules()...)
|
||||
return finalizeBuiltins(rules)
|
||||
}
|
||||
|
||||
// finalizeBuiltins applies the shared defaults: built-in flag, regex kind,
|
||||
// enabled, evidence grouping, and high confidence. Rules that differ set the
|
||||
// field explicitly.
|
||||
func finalizeBuiltins(rules []Rule) []Rule {
|
||||
out := make([]Rule, len(rules))
|
||||
for i, r := range rules {
|
||||
r.Builtin = true
|
||||
if r.Kind == "" {
|
||||
r.Kind = KindRegex
|
||||
}
|
||||
if r.GroupBy == "" {
|
||||
r.GroupBy = GroupByEvidence
|
||||
}
|
||||
if r.Confidence == "" {
|
||||
r.Confidence = ConfidenceHigh
|
||||
}
|
||||
r.Enabled = true
|
||||
out[i] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Simplified content-type keywords used by Rule.ContentTypes and
|
||||
// Rule.ExcludeContentTypes, matching the values contentTypeKeyword returns.
|
||||
//
|
||||
// There is no constant for "js": no rule may exclude JavaScript.
|
||||
const (
|
||||
ctJSON = "json"
|
||||
ctHTML = "html"
|
||||
ctXML = "xml"
|
||||
ctCSV = "csv"
|
||||
ctPlain = "plain"
|
||||
ctCSS = "css"
|
||||
)
|
||||
|
||||
// secretRules covers provider-issued credentials, matched on a provider prefix
|
||||
// plus a fixed-width body. Most need no post-filter.
|
||||
func secretRules() []Rule {
|
||||
return []Rule{
|
||||
{
|
||||
ID: "aws-access-key-id", Name: "AWS access key ID",
|
||||
Description: "An AWS access key identifier. ASIA-prefixed keys are temporary STS credentials; AKIA are long-lived.",
|
||||
Remediation: "Rotate the key in IAM and remove it from the served content.",
|
||||
Category: CategorySecrets, Severity: SeverityInfo,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `\b((?:AKIA|ASIA|ABIA|ACCA|A3T[A-Z0-9])[A-Z0-9]{16})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "aws-secret-access-key", Name: "AWS secret access key",
|
||||
Description: "A 40-character AWS secret access key adjacent to a key-name label. A bare 40-character base64 string is not matchable without flooding the results, so this rule requires the label.",
|
||||
Remediation: "Rotate the credential pair immediately and audit CloudTrail for use.",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "secret",
|
||||
Pattern: `(?i)\b(?:aws[_.-]?secret[_.-]?access[_.-]?key|aws[_.-]?secret|secretaccesskey)\b["'\s:=]{1,12}([A-Za-z0-9/+=]{40})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "aws-session-token", Name: "AWS session token",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "token",
|
||||
Pattern: `(?i)\b(?:aws[_.-]?session[_.-]?token|x-amz-security-token)\b["'\s:=]{1,12}([A-Za-z0-9/+=_-]{100,})`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "gcp-api-key", Name: "Google API key",
|
||||
Description: "A Google/Firebase API key. Browser-embedded Maps and Firebase keys are legitimately public — the finding is whether the key carries application and API restrictions.",
|
||||
Remediation: "Restrict the key by HTTP referrer and API in the Google Cloud console, or rotate it if unrestricted.",
|
||||
Category: CategorySecrets, Severity: SeverityInfo,
|
||||
Target: TargetResponseBody, Literal: "AIza",
|
||||
Pattern: `\b(AIza[0-9A-Za-z_-]{35})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "gcp-service-account-json", Name: "GCP service account key file",
|
||||
Description: "A service-account JSON key including its private key material.",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "service_account",
|
||||
Pattern: `"type"\s*:\s*"service_account"`,
|
||||
MaxPerResponse: 1,
|
||||
},
|
||||
{
|
||||
ID: "gcp-oauth-client-secret", Name: "Google OAuth client secret",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "GOCSPX-",
|
||||
Pattern: `\b(GOCSPX-[A-Za-z0-9_-]{28})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "google-oauth-refresh-token", Name: "Google OAuth refresh token",
|
||||
Category: CategorySecrets, Severity: SeverityHigh, Confidence: ConfidenceMedium,
|
||||
Target: TargetResponseBody, Literal: "1//0",
|
||||
Pattern: `\b(1//0[0-9A-Za-z_-]{20,})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "azure-storage-connection-string", Name: "Azure storage connection string",
|
||||
Description: "A storage account connection string including the shared account key. Evidence records the account name only.",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "AccountKey=",
|
||||
Pattern: `(?i)AccountName=([A-Za-z0-9]{3,24});AccountKey=[A-Za-z0-9+/=]{60,100}`,
|
||||
CaptureGroup: 1,
|
||||
},
|
||||
{
|
||||
ID: "azure-sas-token", Name: "Azure SAS token",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "sig=",
|
||||
Pattern: `(?i)\bsv=\d{4}-\d{2}-\d{2}[^"'\s]{0,200}?[?&]sig=([A-Za-z0-9%+/=]{20,})`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "azure-ad-client-secret", Name: "Azure AD client secret",
|
||||
Category: CategorySecrets, Severity: SeverityHigh, Confidence: ConfidenceMedium,
|
||||
Target: TargetResponseBody, Literal: "secret",
|
||||
Pattern: `(?i)\bclient[_-]?secret\b["'\s:=]{1,12}([A-Za-z0-9~._-]{32,44})\b`,
|
||||
CaptureGroup: 1, MinEntropy: 3.5,
|
||||
PostFilters: []string{"denylist", "notHashLike"},
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "github-pat", Name: "GitHub personal access token",
|
||||
Description: "A GitHub token. The prefix identifies the type: ghp classic PAT, gho OAuth, ghu user-to-server, ghs server-to-server, ghr refresh.",
|
||||
Remediation: "Revoke the token in GitHub settings and audit repository access.",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "gh",
|
||||
Pattern: `\b(gh[pousr]_[A-Za-z0-9]{36})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "github-fine-grained-pat", Name: "GitHub fine-grained PAT",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "github_pat_",
|
||||
Pattern: `\b(github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "slack-token", Name: "Slack API token",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "xox",
|
||||
Pattern: `\b(xox[baprse]-[0-9]{10,13}-[0-9A-Za-z-]{10,64})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "slack-app-token", Name: "Slack app-level token",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "xapp-",
|
||||
Pattern: `\b(xapp-[0-9]-[A-Z0-9]+-[0-9]+-[a-f0-9]{64})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "slack-webhook-url", Name: "Slack incoming webhook URL",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "hooks.slack.com",
|
||||
Pattern: `(https://hooks\.slack\.com/services/T[A-Za-z0-9]+/B[A-Za-z0-9]+/[A-Za-z0-9]{20,28})`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "stripe-live-secret-key", Name: "Stripe live secret key",
|
||||
Description: "A live-mode Stripe secret or restricted key. Publishable keys (pk_live_) are intentionally public and are not reported.",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "_live_",
|
||||
Pattern: `\b((?:sk|rk)_live_[A-Za-z0-9]{20,99})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "twilio-auth-token", Name: "Twilio auth token",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "twilio",
|
||||
Pattern: `(?i)\btwilio[_-]?(?:auth[_-]?token|token|secret)\b["'\s:=]{1,12}([0-9a-f]{32})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "twilio-account-sid", Name: "Twilio account SID",
|
||||
Description: "An account identifier rather than a secret. Reported because it pairs with an auth token.",
|
||||
Category: CategorySecrets, Severity: SeverityInfo,
|
||||
Target: TargetResponseBody, Literal: "AC",
|
||||
Pattern: `\b(AC[0-9a-fA-F]{32})\b`,
|
||||
CaptureGroup: 1,
|
||||
},
|
||||
{
|
||||
ID: "sendgrid-api-key", Name: "SendGrid API key",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "SG.",
|
||||
Pattern: `\b(SG\.[A-Za-z0-9_-]{20,24}\.[A-Za-z0-9_-]{39,64})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "mailgun-api-key", Name: "Mailgun API key",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "key-",
|
||||
Pattern: `\bkey-([0-9a-f]{32})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "npm-token", Name: "npm access token",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "npm_",
|
||||
Pattern: `\b(npm_[A-Za-z0-9]{36})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "openai-api-key", Name: "OpenAI API key",
|
||||
Description: "An OpenAI secret key. The sk- prefix is generic enough to warrant medium confidence; note Stripe uses sk_ with an underscore.",
|
||||
Category: CategorySecrets, Severity: SeverityHigh, Confidence: ConfidenceMedium,
|
||||
Target: TargetResponseBody, Literal: "sk-",
|
||||
Pattern: `\b(sk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{32,})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "anthropic-api-key", Name: "Anthropic API key",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "sk-ant-",
|
||||
Pattern: `\b(sk-ant-(?:api|admin)[0-9]{2}-[A-Za-z0-9_-]{80,})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "shopify-token", Name: "Shopify access token",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "shp",
|
||||
Pattern: `\b(shp(?:at|ca|pa|ss)_[a-fA-F0-9]{32})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "square-access-token", Name: "Square access token",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "sq0atp-",
|
||||
Pattern: `\b(sq0atp-[A-Za-z0-9_-]{22})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "atlassian-api-token", Name: "Atlassian API token",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "ATATT3",
|
||||
Pattern: `\b(ATATT3[A-Za-z0-9_=-]{100,})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "telegram-bot-token", Name: "Telegram bot token",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: ":AA",
|
||||
Pattern: `\b([0-9]{8,10}:AA[A-Za-z0-9_-]{33})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "datadog-api-key", Name: "Datadog API key",
|
||||
Category: CategorySecrets, Severity: SeverityInfo, Confidence: ConfidenceMedium,
|
||||
Target: TargetResponseBody, Literal: "dd",
|
||||
Pattern: `(?i)\bdd[_-]?api[_-]?key\b["'\s:=]{1,12}([a-f0-9]{32})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "algolia-admin-key", Name: "Algolia admin API key",
|
||||
Category: CategorySecrets, Severity: SeverityHigh, Confidence: ConfidenceMedium,
|
||||
Target: TargetResponseBody, Literal: "algolia",
|
||||
Pattern: `(?i)\balgolia[_-]?(?:admin|api)[_-]?key\b["'\s:=]{1,12}([a-f0-9]{32})\b`,
|
||||
CaptureGroup: 1,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "private-key-pem", Name: "Private key PEM block",
|
||||
Description: "A PEM-encoded private key served in a response body. Evidence records the BEGIN line only.",
|
||||
Remediation: "Remove the key from the served path and rotate the key pair.",
|
||||
Category: CategorySecrets, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "-----BEGIN",
|
||||
Pattern: `-----BEGIN (?:RSA |DSA |EC |OPENSSH |PGP |ENCRYPTED |SSH2 )?PRIVATE KEY(?: BLOCK)?-----`,
|
||||
MaxPerResponse: 1,
|
||||
},
|
||||
{
|
||||
ID: "jwt-present", Name: "JSON Web Token",
|
||||
Description: "A JWT observed in traffic. Grouped per host because an authenticated session presents one on nearly every request.",
|
||||
Category: CategorySecrets, Severity: SeverityInfo,
|
||||
Target: TargetResponseBody, Literal: "eyJ",
|
||||
Pattern: `\b(eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})\b`,
|
||||
CaptureGroup: 1, GroupBy: GroupByHost,
|
||||
PostFilters: []string{"jwtStructure"},
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "generic-api-key-assignment", Name: "Generic API key assignment",
|
||||
Description: "A key-like name assigned a high-entropy value. Ships enabled only because the entropy threshold and placeholder denylist suppress template values; without them this rule is unusable against a minified bundle.",
|
||||
Category: CategorySecrets, Severity: SeverityHigh, Confidence: ConfidenceMedium,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `(?i)\b(?:api[_-]?key|apikey|access[_-]?token|auth[_-]?token|secret[_-]?key|client[_-]?secret|private[_-]?token|bearer[_-]?token)\b\s*[:=]\s*["']?([A-Za-z0-9_\-./+=]{16,120})["']?`,
|
||||
CaptureGroup: 1, MinEntropy: 3.5, MinLength: 16,
|
||||
PostFilters: []string{"denylist", "notHashLike"},
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "generic-password-assignment", Name: "Generic password assignment",
|
||||
Description: "A password-like name assigned a literal value. Excludes JavaScript and CSS, where framework bundles produce constant noise.",
|
||||
Category: CategorySecrets, Severity: SeverityHigh, Confidence: ConfidenceMedium,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `(?i)\b(?:password|passwd|pwd|passphrase)\b\s*[:=]\s*["']([^"'\s]{6,80})["']`,
|
||||
CaptureGroup: 1, MaxPerResponse: 2,
|
||||
ContentTypes: []string{ctHTML, ctJSON, ctXML, ctPlain},
|
||||
PostFilters: []string{"denylist"},
|
||||
RedactEvidence: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// piiRules covers personal data. Every rule here carries at least one of: a
|
||||
// checksum post-filter, a required label, or a content-type gate. Formats with
|
||||
// neither a checksum nor a label are not shipped.
|
||||
func piiRules() []Rule {
|
||||
return []Rule{
|
||||
{
|
||||
ID: "email-address", Name: "Email address",
|
||||
Description: "An email address in a structured response. Reported as info because an address in a JSON API response is usually intentional; the aggregate signal is the bulk-exposure analyzer.",
|
||||
Category: CategoryPII, Severity: SeverityInfo,
|
||||
Target: TargetResponseBody, Literal: "@",
|
||||
Pattern: `\b([A-Za-z0-9._%+\-]{1,64}@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,24})\b`,
|
||||
CaptureGroup: 1, GroupBy: GroupByURL, MaxPerResponse: 3,
|
||||
ContentTypes: []string{ctJSON, ctXML, ctCSV, ctPlain},
|
||||
PostFilters: []string{"denylist"},
|
||||
},
|
||||
{
|
||||
ID: "us-ssn-formatted", Name: "US Social Security number",
|
||||
Description: "A dash-formatted SSN whose area, group, and serial are all assignable. There is deliberately no unformatted variant: a bare nine-digit run matches timestamps and identifiers.",
|
||||
Category: CategoryPII, Severity: SeverityCritical, Confidence: ConfidenceMedium,
|
||||
Target: TargetResponseBody, Literal: "-",
|
||||
Pattern: `\b([0-8]\d{2}-\d{2}-\d{4})\b`,
|
||||
CaptureGroup: 1,
|
||||
ContentTypes: []string{ctJSON, ctXML, ctCSV, ctPlain},
|
||||
PostFilters: []string{"ssn"}, RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "us-ssn-labeled", Name: "Labeled US Social Security number",
|
||||
Category: CategoryPII, Severity: SeverityCritical,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `(?i)\b(?:ssn|social[_ -]?security(?:[_ -]?(?:number|no|num))?|tax[_ -]?id)\b[^0-9\n]{0,24}(\d{3}-?\d{2}-?\d{4})\b`,
|
||||
CaptureGroup: 1,
|
||||
// Excludes css rather than whitelisting formats, so the rule stays live
|
||||
// everywhere else, including .js bundles.
|
||||
ExcludeContentTypes: []string{ctCSS},
|
||||
PostFilters: []string{"ssn"}, RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "credit-card", Name: "Credit card number",
|
||||
Description: "A payment card number passing the Luhn checksum with a length matching its issuer. Published gateway test numbers are excluded.",
|
||||
Remediation: "Confirm whether the response should expose cardholder data; if so, mask all but the last four digits.",
|
||||
Category: CategoryPII, Severity: SeverityCritical, Confidence: ConfidenceMedium,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `\b((?:4\d{3}|5[1-5]\d{2}|2(?:2[2-9]\d|[3-6]\d{2}|7[01]\d|720)|3[47]\d{2}|6(?:011|5\d{2}|4[4-9]\d)|3(?:0[0-5]|[68]\d)\d)[ -]?\d{4}[ -]?\d{4}[ -]?\d{0,4})\b`,
|
||||
CaptureGroup: 1,
|
||||
ContentTypes: []string{ctJSON, ctXML, ctCSV, ctPlain, ctHTML},
|
||||
PostFilters: []string{"luhn"}, RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "iban", Name: "IBAN",
|
||||
Description: "An International Bank Account Number passing the ISO 13616 mod-97 check.",
|
||||
Category: CategoryPII, Severity: SeverityCritical,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `\b([A-Z]{2}\d{2} ?(?:[A-Z0-9]{4} ?){2,7}[A-Z0-9]{1,4})\b`,
|
||||
CaptureGroup: 1,
|
||||
ExcludeContentTypes: []string{ctCSS},
|
||||
PostFilters: []string{"iban97"}, RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "phone-labeled", Name: "Labeled phone number",
|
||||
Category: CategoryPII, Severity: SeverityHigh, Confidence: ConfidenceMedium,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `(?i)\b(?:phone|mobile|cell|telephone|msisdn|fax)(?:[_ -]?number)?\b["'\s:=]{1,12}(\+?[0-9][0-9 ()\-.]{7,18}\d)`,
|
||||
CaptureGroup: 1,
|
||||
ExcludeContentTypes: []string{ctCSS},
|
||||
},
|
||||
{
|
||||
ID: "date-of-birth-labeled", Name: "Labeled date of birth",
|
||||
Description: "A birth date next to an explicit label. Unlabeled dates are not honestly detectable, so no unlabeled variant exists.",
|
||||
Category: CategoryPII, Severity: SeverityHigh, Confidence: ConfidenceMedium,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `(?i)\b(?:dob|d\.o\.b|date[_ -]?of[_ -]?birth|birth[_ -]?date|birthday)\b["'\s:=]{1,16}([0-9]{1,4}[-/.][0-9]{1,2}[-/.][0-9]{1,4})`,
|
||||
CaptureGroup: 1,
|
||||
ExcludeContentTypes: []string{ctCSS}, RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "uk-national-insurance", Name: "UK National Insurance number",
|
||||
Category: CategoryPII, Severity: SeverityCritical, Confidence: ConfidenceMedium,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `\b([ABCEGHJ-PRSTW-Z][ABCEGHJ-NPRSTW-Z] ?\d{2} ?\d{2} ?\d{2} ?[A-D])\b`,
|
||||
CaptureGroup: 1,
|
||||
ContentTypes: []string{ctJSON, ctXML, ctCSV, ctPlain},
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "nhs-number", Name: "NHS number",
|
||||
Description: "A UK NHS number passing the mod-11 check digit. Only shippable because of that checksum.",
|
||||
Category: CategoryPII, Severity: SeverityCritical,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `\b(\d{3}[ -]?\d{3}[ -]?\d{4})\b`,
|
||||
CaptureGroup: 1,
|
||||
ContentTypes: []string{ctJSON, ctXML, ctCSV},
|
||||
PostFilters: []string{"mod11nhs"}, RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "cl-rut", Name: "Chilean RUT",
|
||||
Description: "A Chilean national tax and identity number, validated by its mod-11 check digit. The check digit is the whole precision mechanism, which is why no adjacent label is required.",
|
||||
Category: CategoryPII, Severity: SeverityCritical,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `\b(\d{1,2}\.?\d{3}\.?\d{3}-[\dkK])\b`,
|
||||
CaptureGroup: 1,
|
||||
// HTML is included, as for credit-card and br-cpf: a RUT has a dotted
|
||||
// shape and a mod-11 check digit behind it.
|
||||
ContentTypes: []string{ctJSON, ctXML, ctCSV, ctPlain, ctHTML},
|
||||
PostFilters: []string{"rutMod11"}, RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "br-cpf", Name: "Brazilian CPF",
|
||||
Category: CategoryPII, Severity: SeverityCritical,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `\b(\d{3}\.\d{3}\.\d{3}-\d{2})\b`,
|
||||
CaptureGroup: 1,
|
||||
ExcludeContentTypes: []string{ctCSS},
|
||||
PostFilters: []string{"cpf"}, RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "ca-sin-labeled", Name: "Labeled Canadian SIN",
|
||||
Category: CategoryPII, Severity: SeverityCritical, Confidence: ConfidenceMedium,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `(?i)\b(?:sin|social[_ -]?insurance)\b[^0-9\n]{0,20}(\d{3}[ -]?\d{3}[ -]?\d{3})\b`,
|
||||
CaptureGroup: 1,
|
||||
ExcludeContentTypes: []string{ctCSS},
|
||||
PostFilters: []string{"luhn"}, RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "in-aadhaar-labeled", Name: "Labeled Aadhaar number",
|
||||
Category: CategoryPII, Severity: SeverityCritical, Confidence: ConfidenceMedium,
|
||||
Target: TargetResponseBody, Literal: "aadha",
|
||||
Pattern: `(?i)\baadha?ar\b[^0-9\n]{0,20}(\d{4}[ -]?\d{4}[ -]?\d{4})\b`,
|
||||
CaptureGroup: 1,
|
||||
ExcludeContentTypes: []string{ctCSS},
|
||||
PostFilters: []string{"verhoeff"}, RedactEvidence: true,
|
||||
},
|
||||
// No US driver's licence rule: fifty state formats with no checksum leave
|
||||
// label proximity as the only precision mechanism, which is not enough.
|
||||
{
|
||||
ID: "pii-in-url", Name: "Personal data in URL",
|
||||
Description: "Personal data in a query string leaks to access logs, Referer headers, and browser history regardless of TLS.",
|
||||
Remediation: "Move the parameter into a request body or a server-side session.",
|
||||
Category: CategoryPII, Severity: SeverityHigh,
|
||||
Target: TargetURL, Literal: "=",
|
||||
Pattern: `(?i)[?&]((?:e?mail|ssn|dob|birth[_-]?date|first[_-]?name|last[_-]?name|full[_-]?name|phone|mobile|passport|national[_-]?id|tax[_-]?id|card[_-]?number)=[^&\s#]{2,})`,
|
||||
CaptureGroup: 1, GroupBy: GroupByURL,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// credentialRules cover authentication material in transit and credential files
|
||||
// served by mistake.
|
||||
func credentialRules() []Rule {
|
||||
return []Rule{
|
||||
{
|
||||
ID: "basic-auth-in-url", Name: "Credentials in URL userinfo",
|
||||
Category: CategoryCredentials, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "://",
|
||||
Pattern: `\b[a-z][a-z0-9+.\-]{1,20}://([^/\s:@"']{1,64}):[^/\s:@"']{1,64}@`,
|
||||
CaptureGroup: 1, RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "auth-material-in-query-string", Name: "Authentication material in query string",
|
||||
Description: "A token, key, or password in a URL. Leaks to access logs, Referer headers, and browser history.",
|
||||
Remediation: "Move the credential into a header or request body.",
|
||||
Category: CategoryCredentials, Severity: SeverityHigh,
|
||||
Target: TargetURL, Literal: "=",
|
||||
Pattern: `(?i)[?&]((?:access_token|id_token|refresh_token|api[_-]?key|apikey|authorization|jwt|session_?id|password|passwd|pwd|secret)=[^&\s#]{8,})`,
|
||||
CaptureGroup: 1, GroupBy: GroupByURL,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "db-connection-string", Name: "Database connection string with password",
|
||||
Category: CategoryCredentials, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "://",
|
||||
Pattern: `(?i)\b(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|mariadb|rediss?|amqps?|clickhouse|cassandra)://([^\s"'<>]{1,64}):[^\s"'<>]{1,64}@([^\s"'<>/]{1,120})`,
|
||||
CaptureGroup: 0, RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "ado-net-connection-string", Name: "ADO.NET connection string with password",
|
||||
Category: CategoryCredentials, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "=",
|
||||
Pattern: `(?i)\b(?:server|data source|initial catalog)\s*=\s*[^;\n]{1,64};[^\n]{0,240}?\b(?:password|pwd)\s*=\s*([^;\s"'<]{1,64})`,
|
||||
CaptureGroup: 1, RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "htpasswd-exposed", Name: "htpasswd file exposed",
|
||||
Category: CategoryCredentials, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "$",
|
||||
Pattern: `(?m)^([A-Za-z0-9._-]{1,32}):\$(?:apr1|2[aby]|1|5|6)\$`,
|
||||
CaptureGroup: 1,
|
||||
PostFilters: []string{"notHTML"},
|
||||
},
|
||||
{
|
||||
ID: "shadow-file-exposed", Name: "Unix shadow entry exposed",
|
||||
Category: CategoryCredentials, Severity: SeverityHigh,
|
||||
Target: TargetResponseBody, Literal: "$",
|
||||
Pattern: `(?m)^([A-Za-z0-9._-]{1,32}):[!*]?\$[16y5]\$[^:]{8,}:\d{4,5}:`,
|
||||
CaptureGroup: 1,
|
||||
PostFilters: []string{"notHTML"},
|
||||
},
|
||||
{
|
||||
ID: "hardcoded-credential-pair", Name: "Hardcoded credential pair",
|
||||
Description: "A username and password assigned near each other. Requiring the pair is substantially more precise than matching a password alone.",
|
||||
Category: CategoryCredentials, Severity: SeverityHigh, Confidence: ConfidenceMedium,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `(?i)\b(?:user(?:name)?|login|uid|account)\b\s*[:=]\s*["']([^"'\s]{3,48})["'][\s\S]{0,120}?\b(?:password|passwd|pwd|secret)\b\s*[:=]\s*["'][^"'\s]{3,64}["']`,
|
||||
CaptureGroup: 1,
|
||||
PostFilters: []string{"denylist"}, RedactEvidence: true,
|
||||
},
|
||||
{
|
||||
ID: "credentials-in-set-cookie", Name: "Credential in Set-Cookie",
|
||||
Category: CategoryCredentials, Severity: SeverityHigh,
|
||||
Target: TargetResponseHeader, Literal: "set-cookie",
|
||||
Pattern: `(?im)^set-cookie:[^\n]{0,80}\b(?:pass(?:word)?|pwd|passwd|secret)=([^;\s]{3,})`,
|
||||
CaptureGroup: 1, GroupBy: GroupByHost,
|
||||
RedactEvidence: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// accessRules identify authentication surfaces and management consoles. Product
|
||||
// fingerprints group per host.
|
||||
func accessRules() []Rule {
|
||||
fingerprint := func(id, name string, sev Severity, target Target, literal, pattern string, group int) Rule {
|
||||
return Rule{
|
||||
ID: id, Name: name, Category: CategoryAccess, Severity: sev,
|
||||
Target: target, Literal: literal, Pattern: pattern,
|
||||
CaptureGroup: group, GroupBy: GroupByHost,
|
||||
StatusCodes: "200,401,403",
|
||||
}
|
||||
}
|
||||
return []Rule{
|
||||
{
|
||||
ID: "admin-panel-path", Name: "Administrative path",
|
||||
Description: "A URL path matching a common administrative or management route. Medium confidence because /dashboard and /console are also ordinary application routes.",
|
||||
Category: CategoryAccess, Severity: SeverityInfo, Confidence: ConfidenceMedium,
|
||||
Target: TargetURL, Literal: "/",
|
||||
Pattern: `(?i)/(admin(?:istrator|_?panel|cp|login)?|wp-admin|manage(?:r|ment)?|console|cpanel|whm|plesk|webadmin|phpmyadmin|pma|adminer|sysadmin|backend|controlpanel|siteadmin|useradmin)(?:/|$|\?)`,
|
||||
CaptureGroup: 1, GroupBy: GroupByURL,
|
||||
StatusCodes: "200,301,302,401,403",
|
||||
},
|
||||
{
|
||||
ID: "http-auth-challenge", Name: "HTTP authentication challenge",
|
||||
Description: "A Basic or Digest challenge. The realm frequently names an internal system.",
|
||||
Category: CategoryAccess, Severity: SeverityInfo,
|
||||
Target: TargetResponseHeader, Literal: "www-authenticate",
|
||||
Pattern: `(?im)^www-authenticate:[ \t]*(basic|digest)(?:[ \t]+realm="([^"]{0,80})")?`,
|
||||
CaptureGroup: 0, GroupBy: GroupByHost,
|
||||
StatusCodes: "401",
|
||||
},
|
||||
{
|
||||
ID: "ntlm-negotiate-endpoint", Name: "Windows integrated authentication endpoint",
|
||||
Description: "An NTLM or Negotiate challenge, indicating a Windows-integrated application reachable here.",
|
||||
Category: CategoryAccess, Severity: SeverityInfo,
|
||||
Target: TargetResponseHeader, Literal: "www-authenticate",
|
||||
Pattern: `(?im)^www-authenticate:[ \t]*(?:NTLM|Negotiate)`,
|
||||
GroupBy: GroupByHost, StatusCodes: "401",
|
||||
},
|
||||
{
|
||||
ID: "spring-actuator-open", Name: "Spring Boot Actuator exposed",
|
||||
Description: "An unauthenticated Actuator endpoint. /env, /heapdump, /jolokia, and /threaddump disclose credentials or memory contents.",
|
||||
Category: CategoryAccess, Severity: SeverityInfo,
|
||||
Target: TargetResponseBody, Literal: "_links",
|
||||
Pattern: `"_links"\s*:\s*\{[\s\S]{0,999}?"(?:health|env|beans|heapdump|threaddump|configprops)"`,
|
||||
GroupBy: GroupByHost,
|
||||
StatusCodes: "200",
|
||||
},
|
||||
{
|
||||
ID: "graphql-introspection", Name: "GraphQL introspection enabled",
|
||||
Description: "The schema is queryable, which hands an attacker the full API surface.",
|
||||
Remediation: "Disable introspection in production.",
|
||||
Category: CategoryAccess, Severity: SeverityInfo,
|
||||
Target: TargetResponseBody, Literal: "__schema",
|
||||
Pattern: `"__schema"\s*:\s*\{[\s\S]{0,400}?"types"\s*:\s*\[`,
|
||||
GroupBy: GroupByHost,
|
||||
StatusCodes: "200",
|
||||
},
|
||||
{
|
||||
ID: "swagger-openapi-exposed", Name: "API schema document exposed",
|
||||
Category: CategoryAccess, Severity: SeverityInfo,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `(?i)(?:swagger-ui|"swagger"\s*:\s*"2|"openapi"\s*:\s*"3)`,
|
||||
GroupBy: GroupByURL,
|
||||
StatusCodes: "200",
|
||||
},
|
||||
{
|
||||
ID: "keycloak-realm-exposed", Name: "Keycloak realm metadata exposed",
|
||||
Category: CategoryAccess, Severity: SeverityInfo, Confidence: ConfidenceMedium,
|
||||
Target: TargetURL, Literal: "realms/",
|
||||
Pattern: `(?i)/(?:auth/)?realms/[^/]+/\.well-known/openid-configuration`,
|
||||
GroupBy: GroupByHost,
|
||||
StatusCodes: "200",
|
||||
},
|
||||
{
|
||||
ID: "vpn-appliance-login", Name: "Remote-access appliance login",
|
||||
Description: "An internet-facing VPN or remote-access appliance portal. These are high-value targets and frequently unpatched.",
|
||||
Category: CategoryAccess, Severity: SeverityInfo,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `(?i)(/dana-na/|Citrix Gateway|/remote/login\?lang=|Pulse Secure|GlobalProtect Portal|<title>\s*SonicWall)`,
|
||||
GroupBy: GroupByHost,
|
||||
StatusCodes: "200",
|
||||
},
|
||||
fingerprint("phpmyadmin-exposed", "phpMyAdmin exposed", SeverityInfo, TargetResponseBody,
|
||||
"", `(?i)(?:<title>\s*phpMyAdmin|name="pma_username"|pmahomme)`, 0),
|
||||
fingerprint("adminer-exposed", "Adminer exposed", SeverityInfo, TargetResponseBody,
|
||||
"adminer", `(?i)<title>\s*(?:Login\s*-\s*)?Adminer\b`, 0),
|
||||
fingerprint("tomcat-manager-exposed", "Tomcat Manager exposed", SeverityInfo, TargetResponseBody,
|
||||
"", `(?i)(?:<title>/manager\b|Tomcat Web Application Manager|<title>Apache Tomcat/([0-9.]+))`, 0),
|
||||
fingerprint("jboss-wildfly-console", "JBoss/WildFly console exposed", SeverityInfo, TargetResponseBody,
|
||||
"console", `(?i)(?:JBoss AS Administration Console|WildFly Management Console)`, 0),
|
||||
fingerprint("weblogic-console", "WebLogic console exposed", SeverityInfo, TargetResponseBody,
|
||||
"weblogic", `(?i)<title>\s*Oracle WebLogic Server Administration Console`, 0),
|
||||
fingerprint("websphere-console", "WebSphere console exposed", SeverityInfo, TargetResponseBody,
|
||||
"websphere", `(?i)WebSphere Integrated Solutions Console`, 0),
|
||||
fingerprint("rabbitmq-management", "RabbitMQ management exposed", SeverityInfo, TargetResponseBody,
|
||||
"rabbitmq", `(?i)RabbitMQ Management`, 0),
|
||||
fingerprint("jupyter-exposed", "Jupyter exposed", SeverityInfo, TargetResponseBody,
|
||||
"jupyter", `(?i)(?:<title>\s*Jupyter\b|jupyter-config-data)`, 0),
|
||||
fingerprint("elasticsearch-exposed", "Elasticsearch exposed", SeverityInfo, TargetResponseBody,
|
||||
"lucene_version", `"cluster_name"\s*:[\s\S]{0,400}?"lucene_version"\s*:`, 0),
|
||||
fingerprint("mongo-express-exposed", "mongo-express exposed", SeverityInfo, TargetResponseBody,
|
||||
"mongo", `(?i)<title>\s*(?:Mongo Express|mongo-express)`, 0),
|
||||
fingerprint("pgadmin-exposed", "pgAdmin exposed", SeverityInfo, TargetResponseBody,
|
||||
"pgadmin", `(?i)<title>\s*pgAdmin\b`, 0),
|
||||
fingerprint("solr-admin-exposed", "Solr admin exposed", SeverityInfo, TargetResponseBody,
|
||||
"solr", `(?i)<title>\s*Solr Admin\b`, 0),
|
||||
fingerprint("airflow-exposed", "Airflow exposed", SeverityInfo, TargetResponseBody,
|
||||
"", `(?i)(?:<title>\s*(?:Sign In - )?Airflow|/static/appbuilder)`, 0),
|
||||
fingerprint("jenkins-exposed", "Jenkins exposed", SeverityInfo, TargetResponseHeader,
|
||||
"x-jenkins", `(?im)^x-jenkins:[ \t]*([0-9][0-9.]*)`, 1),
|
||||
fingerprint("grafana-exposed", "Grafana exposed", SeverityInfo, TargetResponseBody,
|
||||
"grafana", `(?i)window\.grafanaBootData`, 0),
|
||||
fingerprint("kibana-exposed", "Kibana exposed", SeverityInfo, TargetResponseHeader,
|
||||
"kbn-name", `(?im)^kbn-name:[ \t]*([^\r\n]+)`, 1),
|
||||
fingerprint("sonarqube-exposed", "SonarQube exposed", SeverityInfo, TargetResponseBody,
|
||||
"sonarqube", `(?i)<title>\s*SonarQube\b`, 0),
|
||||
fingerprint("gitlab-exposed", "GitLab exposed", SeverityInfo, TargetResponseBody,
|
||||
"gitlab", `(?i)(?:gon\.gitlab_url|<title>\s*GitLab\b)`, 0),
|
||||
fingerprint("wordpress-login", "WordPress login exposed", SeverityInfo, TargetResponseBody,
|
||||
"login", `(?i)(?:/wp-login\.php|id="loginform")`, 0),
|
||||
fingerprint("joomla-admin", "Joomla administrator exposed", SeverityInfo, TargetResponseBody,
|
||||
"", `(?i)(?:/administrator/index\.php|Joomla! - Open Source Content Management)`, 0),
|
||||
fingerprint("magento-admin", "Magento admin exposed", SeverityInfo, TargetResponseBody,
|
||||
"", `(?i)(?:<title>Magento Admin|/admin/admin/dashboard)`, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// disclosureRules cover error pages, version fingerprints, internal network
|
||||
// details, and files that should not be served.
|
||||
func disclosureRules() []Rule {
|
||||
stack := func(id, name string, sev Severity, literal, pattern string, group int) Rule {
|
||||
return Rule{
|
||||
ID: id, Name: name, Category: CategoryDisclosure, Severity: sev,
|
||||
Target: TargetResponseBody, Literal: literal, Pattern: pattern,
|
||||
CaptureGroup: group, GroupBy: GroupByURL,
|
||||
}
|
||||
}
|
||||
sqlErr := func(id, name, literal, pattern string) Rule {
|
||||
return Rule{
|
||||
ID: id, Name: name,
|
||||
Description: "A database error message reached the client, which both discloses schema details and indicates unsanitized input reaching the query.",
|
||||
// Above Info: a database error reaching the client signals that
|
||||
// unsanitised input reaches a query.
|
||||
Category: CategoryDisclosure, Severity: SeverityMedium,
|
||||
Target: TargetResponseBody, Literal: literal, Pattern: pattern,
|
||||
GroupBy: GroupByURL,
|
||||
}
|
||||
}
|
||||
urlFile := func(id, name string, sev Severity, conf Confidence, pattern string, filters ...string) Rule {
|
||||
return Rule{
|
||||
ID: id, Name: name, Category: CategoryDisclosure, Severity: sev, Confidence: conf,
|
||||
Target: TargetURL, Pattern: pattern,
|
||||
GroupBy: GroupByURL, StatusCodes: "200",
|
||||
PostFilters: filters,
|
||||
}
|
||||
}
|
||||
|
||||
rules := []Rule{
|
||||
// Stack traces and debug pages.
|
||||
stack("java-stack-trace", "Java stack trace", SeverityInfo, "",
|
||||
`(?m)^\s*at [a-zA-Z_$][a-zA-Z0-9_$]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*){2,}\((?:[A-Za-z0-9_$]+\.java:\d+|Native Method|Unknown Source)\)`, 0),
|
||||
stack("java-exception-name", "Java exception class name", SeverityInfo, "",
|
||||
`\b((?:java|javax|jakarta|org\.(?:springframework|hibernate|apache))\.[A-Za-z0-9.]+(?:Exception|Error))\b`, 1),
|
||||
stack("dotnet-stack-trace", "ASP.NET exception", SeverityInfo, "system.",
|
||||
`\b(System\.[A-Za-z.]+Exception)\b`, 1),
|
||||
stack("aspnet-yellow-screen", "ASP.NET error page", SeverityInfo, "error",
|
||||
`(?i)<title>\s*(?:Runtime Error|Server Error in '[^']{0,120}' Application)`, 0),
|
||||
stack("iis-detailed-error", "IIS detailed error page", SeverityInfo, "iis",
|
||||
`(?i)<title>\s*IIS \d+\.\d+ Detailed Error`, 0),
|
||||
stack("python-traceback", "Python traceback", SeverityInfo, "traceback",
|
||||
`Traceback \(most recent call last\):[\s\S]{0,400}?(?m)^\s+File "([^"]{1,200})", line \d+`, 1),
|
||||
stack("django-debug-page", "Django debug page", SeverityInfo, "",
|
||||
`(?i)(?:You're seeing this error because you have <code>DEBUG=True|Django Version:\s*<)`, 0),
|
||||
stack("flask-werkzeug-debugger", "Werkzeug interactive debugger", SeverityCritical, "",
|
||||
`(?i)(?:Werkzeug Debugger|The debugger caught an exception|console\.js\?__debugger__)`, 0),
|
||||
stack("rails-error-page", "Rails error page", SeverityInfo, "",
|
||||
`(?i)(?:<title>\s*Action Controller: Exception caught|ActiveRecord::[A-Za-z]+)`, 0),
|
||||
stack("rails-web-console", "Rails web console exposed", SeverityCritical, "web-console",
|
||||
`(?i)web-console[\s\S]{0,200}(?:REPL|Rails\.env)`, 0),
|
||||
stack("php-error-with-path", "PHP error with filesystem path", SeverityInfo, "on line",
|
||||
`(?i)\b(?:Fatal error|Parse error|Warning|Notice|Deprecated)\s*:[\s\S]{0,200}?\bin\s+((?:/|[A-Za-z]:\\)[^\s:<]{3,200})\s+on line \d+`, 1),
|
||||
stack("laravel-whoops", "Laravel Whoops error page", SeverityInfo, "",
|
||||
`(?i)(?:<title>[^<]{0,80}Whoops|/vendor/laravel/framework/src)`, 0),
|
||||
stack("node-stack-trace", "Node.js stack trace", SeverityInfo, " at ",
|
||||
`(?m)^\s{4,}at [^\n]{0,160}\((?:/|[A-Za-z]:\\|node:internal|internal/)[^\n]{1,200}:\d+:\d+\)`, 0),
|
||||
stack("go-panic", "Go panic trace", SeverityInfo, "goroutine ",
|
||||
`(?m)^panic: [^\n]{1,200}\n[\s\S]{0,200}?^goroutine \d+ \[running\]:`, 0),
|
||||
stack("spring-whitelabel-error", "Spring whitelabel error page", SeverityInfo, "whitelabel",
|
||||
`(?i)Whitelabel Error Page`, 0),
|
||||
stack("graphql-verbose-error", "GraphQL error with stack trace", SeverityInfo, "errors",
|
||||
`"errors"\s*:\s*\[[\s\S]{0,400}?"(?:stacktrace|exception)"\s*:`, 0),
|
||||
{
|
||||
ID: "symfony-debug-token", Name: "Symfony profiler token exposed",
|
||||
Category: CategoryDisclosure, Severity: SeverityInfo,
|
||||
Target: TargetResponseHeader, Literal: "x-debug-token",
|
||||
Pattern: `(?im)^x-debug-token(?:-link)?:[ \t]*([^\r\n]+)`,
|
||||
CaptureGroup: 1, GroupBy: GroupByHost,
|
||||
},
|
||||
|
||||
// SQL errors, one rule per engine so the finding names the DBMS.
|
||||
sqlErr("sql-error-mysql", "MySQL error disclosed", "",
|
||||
`(?i)(?:You have an error in your SQL syntax|Warning: mysqli?_[a-z_]+\(\)|MySQLSyntaxErrorException|com\.mysql\.(?:jdbc|cj)|check the manual that corresponds to your (?:MySQL|MariaDB))`),
|
||||
sqlErr("sql-error-postgres", "PostgreSQL error disclosed", "",
|
||||
`(?i)(?:PG::[A-Za-z]+Error|org\.postgresql\.util\.PSQLException|pg_query\(\)|unterminated quoted string at or near|invalid input syntax for)`),
|
||||
sqlErr("sql-error-mssql", "SQL Server error disclosed", "",
|
||||
`(?i)(?:Unclosed quotation mark after the character string|Incorrect syntax near|System\.Data\.SqlClient\.SqlException|Microsoft OLE DB Provider for SQL Server|\[SQL Server\])`),
|
||||
sqlErr("sql-error-oracle", "Oracle error disclosed", "ora-",
|
||||
`\b(ORA-\d{5})\b`),
|
||||
sqlErr("sql-error-sqlite", "SQLite error disclosed", "",
|
||||
`(?i)(?:SQLite3?::[A-Za-z]+|sqlite3\.(?:Operational|Programming)Error|SQLITE_ERROR|unrecognized token:|no such table:)`),
|
||||
sqlErr("sql-error-odbc", "ODBC/JET database error disclosed", "",
|
||||
`(?i)(?:\[Microsoft\]\[ODBC|Microsoft JET Database Engine|DB2 SQL error|SQLSTATE\[[0-9A-Z]{5}\])`),
|
||||
|
||||
// Version and technology fingerprints.
|
||||
{
|
||||
ID: "server-header-with-version", Name: "Server header discloses version",
|
||||
Description: "Only reported when the header actually contains a version number: a bare 'Server: nginx' is not a finding, and flagging it would drown the real ones.",
|
||||
Remediation: "Suppress or genericize the Server header.",
|
||||
Category: CategoryDisclosure, Severity: SeverityInfo,
|
||||
Target: TargetResponseHeader, Literal: "server:",
|
||||
Pattern: `(?im)^server:[ \t]*([^\r\n]*[0-9]+\.[0-9]+[^\r\n]*)`,
|
||||
CaptureGroup: 1, GroupBy: GroupByHost,
|
||||
},
|
||||
{
|
||||
ID: "x-powered-by", Name: "X-Powered-By header present",
|
||||
Category: CategoryDisclosure, Severity: SeverityInfo,
|
||||
Target: TargetResponseHeader, Literal: "x-powered-by",
|
||||
Pattern: `(?im)^x-powered-by:[ \t]*([^\r\n]+)`,
|
||||
CaptureGroup: 1, GroupBy: GroupByHost,
|
||||
},
|
||||
{
|
||||
ID: "tech-fingerprint-headers", Name: "Technology fingerprint header",
|
||||
Category: CategoryDisclosure, Severity: SeverityInfo,
|
||||
Target: TargetResponseHeader,
|
||||
Pattern: `(?im)^(x-aspnet-version|x-aspnetmvc-version|x-generator|x-drupal-cache|x-varnish|x-litespeed-cache|x-runtime|x-rack-cache|x-mod-pagespeed|x-shopify-stage|x-turbo-charged-by|liferay-portal):[ \t]*([^\r\n]+)`,
|
||||
CaptureGroup: 0, GroupBy: GroupByHost,
|
||||
},
|
||||
{
|
||||
ID: "internal-infra-headers", Name: "Internal infrastructure header",
|
||||
Description: "Headers that leak internal hostnames, pool names, or application context.",
|
||||
Category: CategoryDisclosure, Severity: SeverityInfo,
|
||||
Target: TargetResponseHeader, Literal: "x-",
|
||||
Pattern: `(?im)^(x-backend-server|x-application-context|x-served-by|x-node|x-real-ip|x-forwarded-server|x-upstream|x-iinfo|x-envoy-upstream-service-time):[ \t]*([^\r\n]+)`,
|
||||
CaptureGroup: 0, GroupBy: GroupByHost,
|
||||
},
|
||||
{
|
||||
ID: "generator-meta-tag", Name: "Generator meta tag",
|
||||
Category: CategoryDisclosure, Severity: SeverityInfo,
|
||||
Target: TargetResponseBody, Literal: "generator",
|
||||
Pattern: `(?i)<meta\s+name=["']generator["']\s+content=["']([^"']{1,120})["']`,
|
||||
CaptureGroup: 1, GroupBy: GroupByHost,
|
||||
},
|
||||
|
||||
// Internal network and filesystem disclosure.
|
||||
{
|
||||
ID: "internal-ip-address", Name: "Internal IP address disclosed",
|
||||
Description: "A private-range address in a response. The residual false positives are version tuples, which the notVersionString filter suppresses by inspecting neighbouring bytes.",
|
||||
Category: CategoryDisclosure, Severity: SeverityInfo, Confidence: ConfidenceMedium,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `\b(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|169\.254\.\d{1,3}\.\d{1,3})\b`,
|
||||
CaptureGroup: 1, GroupBy: GroupByHost,
|
||||
ContentTypes: []string{ctJSON, ctXML, ctPlain, ctHTML},
|
||||
PostFilters: []string{"notVersionString"},
|
||||
},
|
||||
{
|
||||
ID: "cloud-metadata-endpoint", Name: "Cloud metadata endpoint referenced",
|
||||
Description: "A link-local metadata address in the response, useful as an SSRF target.",
|
||||
Category: CategoryDisclosure, Severity: SeverityInfo,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `\b(169\.254\.169\.254|metadata\.google\.internal|100\.100\.100\.200)\b`,
|
||||
CaptureGroup: 1, GroupBy: GroupByHost,
|
||||
},
|
||||
{
|
||||
ID: "internal-hostname", Name: "Internal hostname disclosed",
|
||||
Category: CategoryDisclosure, Severity: SeverityInfo, Confidence: ConfidenceMedium,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `\b([a-z0-9][a-z0-9-]{1,62}\.(?:local|internal|corp|lan|intranet|localdomain|svc\.cluster\.local|ec2\.internal|compute\.internal))\b`,
|
||||
CaptureGroup: 1, GroupBy: GroupByHost,
|
||||
},
|
||||
{
|
||||
ID: "absolute-filesystem-path", Name: "Absolute filesystem path disclosed",
|
||||
Category: CategoryDisclosure, Severity: SeverityInfo, Confidence: ConfidenceMedium,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `((?:/(?:home|Users|var/www|var/log|usr/local|opt|srv|root|builds|workspace)/[A-Za-z0-9_.+\-/]{3,140})|(?:[A-Za-z]:\\(?:Users|inetpub|Windows|xampp|wamp|laragon|Sites)\\[A-Za-z0-9 _.+\-\\]{3,140}))`,
|
||||
CaptureGroup: 1, GroupBy: GroupByHost,
|
||||
ContentTypes: []string{ctJSON, ctXML, ctPlain, ctHTML},
|
||||
},
|
||||
{
|
||||
ID: "source-map-reference", Name: "Source map reference",
|
||||
Description: "A sourceMappingURL comment. Verifying the map is actually fetchable is a natural first check for an active scanner.",
|
||||
Category: CategoryDisclosure, Severity: SeverityInfo,
|
||||
Target: TargetResponseBody, Literal: "sourcemappingurl",
|
||||
Pattern: `(?im)^\s*//[#@]\s*sourceMappingURL=([^\r\n]+)`,
|
||||
CaptureGroup: 1, GroupBy: GroupByURL,
|
||||
},
|
||||
{
|
||||
ID: "sourcemap-build-path", Name: "Source map discloses build paths",
|
||||
Category: CategoryDisclosure, Severity: SeverityInfo,
|
||||
Target: TargetResponseBody, Literal: "sources",
|
||||
Pattern: `"sources"\s*:\s*\[\s*"((?:/|[A-Za-z]:\\|webpack:///)[^"]{4,200})"`,
|
||||
CaptureGroup: 1, GroupBy: GroupByURL,
|
||||
},
|
||||
{
|
||||
ID: "aws-account-id-arn", Name: "AWS account ID in ARN",
|
||||
Category: CategoryDisclosure, Severity: SeverityInfo,
|
||||
Target: TargetResponseBody, Literal: "arn:aws",
|
||||
Pattern: `\b(arn:aws[a-z-]*:[a-z0-9-]{2,24}:[a-z0-9-]*:\d{12}:)`,
|
||||
CaptureGroup: 1, GroupBy: GroupByHost,
|
||||
},
|
||||
{
|
||||
ID: "s3-bucket-reference", Name: "S3 bucket reference",
|
||||
Category: CategoryDisclosure, Severity: SeverityInfo,
|
||||
Target: TargetResponseBody, Literal: "amazonaws.com",
|
||||
Pattern: `\b([a-z0-9][a-z0-9.\-]{1,61}[a-z0-9]\.s3(?:[.-][a-z0-9-]{1,20})?\.amazonaws\.com)\b`,
|
||||
CaptureGroup: 1, GroupBy: GroupByHost,
|
||||
},
|
||||
|
||||
// Exposed files and listings.
|
||||
{
|
||||
ID: "directory-listing", Name: "Directory listing enabled",
|
||||
Description: "An autoindex page. Requires both the title and a sort-link marker, so a page merely titled 'Index of things' is not reported.",
|
||||
Remediation: "Disable directory indexing for the path.",
|
||||
Category: CategoryDisclosure, Severity: SeverityInfo,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `(?i)(?:<title>\s*Index of /|<h1>\s*Index of /|<title>\s*Directory Listing (?:For|of)|To Parent Directory</a>)`,
|
||||
GroupBy: GroupByURL,
|
||||
StatusCodes: "200",
|
||||
},
|
||||
{
|
||||
ID: "phpinfo-page", Name: "phpinfo() page exposed",
|
||||
Category: CategoryDisclosure, Severity: SeverityInfo,
|
||||
Target: TargetResponseBody,
|
||||
Pattern: `(?i)(?:<title>\s*phpinfo\(\)|>phpinfo\(\)</h1>|<h1 class="p">PHP Version)`,
|
||||
GroupBy: GroupByURL,
|
||||
StatusCodes: "200",
|
||||
},
|
||||
urlFile("config-file-exposed", "Configuration file exposed", SeverityLow, ConfidenceHigh,
|
||||
`(?i)/(?:\.env(?:\.[a-z0-9]+)?|web\.config|app\.config|appsettings(?:\.[A-Za-z]+)?\.json|settings\.py|local_settings\.py|application\.(?:ya?ml|properties)|database\.ya?ml|secrets\.ya?ml|docker-compose(?:\.[a-z]+)?\.ya?ml|\.npmrc|\.pypirc|\.netrc|\.git-credentials|\.htpasswd|credentials\.json|id_(?:rsa|dsa|ecdsa|ed25519))$`,
|
||||
"notHTML"),
|
||||
urlFile("backup-or-temp-file", "Backup or temporary file exposed", SeverityLow, ConfidenceMedium,
|
||||
`(?i)\.(?:bak|old|orig|save|sav|swp|swo|tmp|backup|copy|dist|inc)$|(?i)\.(?:php|asp|aspx|jsp|rb|py|js|json|ya?ml|xml|conf|ini)\.(?:bak|old|txt|save|orig|dist|sample)$`,
|
||||
"notHTML"),
|
||||
urlFile("archive-or-dump-exposed", "Archive or database dump exposed", SeverityCritical, ConfidenceMedium,
|
||||
`(?i)\.(?:sql|sql\.gz|dump|dmp|bacpac|mdb|sqlite3?|war|jar|ear|pst|vmdk|ova)$`,
|
||||
"notHTML"),
|
||||
urlFile("vcs-metadata-exposed", "Version control metadata exposed", SeverityHigh, ConfidenceHigh,
|
||||
`(?i)/\.(?:git/(?:HEAD|config|index|packed-refs)|svn/(?:entries|wc\.db)|hg/(?:requires|hgrc)|bzr/branch-format)$`,
|
||||
"notHTML"),
|
||||
urlFile("ide-editor-artifacts", "IDE or editor artifact exposed", SeverityLow, ConfidenceHigh,
|
||||
`(?i)/(?:\.vscode/|\.idea/|\.project$|\.settings/|nbproject/|\.sublime-project$)`,
|
||||
"notHTML"),
|
||||
urlFile("ci-config-exposed", "CI configuration exposed", SeverityLow, ConfidenceHigh,
|
||||
`(?i)/(?:\.gitlab-ci\.ya?ml|\.travis\.ya?ml|\.circleci/config\.ya?ml|Jenkinsfile|azure-pipelines\.ya?ml|buildspec\.ya?ml)$`,
|
||||
"notHTML"),
|
||||
urlFile("ds-store-exposed", "macOS .DS_Store exposed", SeverityLow, ConfidenceHigh,
|
||||
`(?i)/\.DS_Store$`, "notHTML"),
|
||||
{
|
||||
ID: "dependency-manifest-exposed", Name: "Dependency manifest exposed",
|
||||
Description: "A dependency manifest served to clients. Normal for npm packages and many static deploys, so this is not itself a weakness — the value is the exact dependency versions it hands over for CVE cross-referencing.",
|
||||
Category: CategoryDisclosure, Severity: SeverityLow,
|
||||
Target: TargetURL,
|
||||
Pattern: `(?i)/(?:composer\.(?:json|lock)|package(?:-lock)?\.json|yarn\.lock|Gemfile(?:\.lock)?|requirements\.txt|Pipfile(?:\.lock)?|go\.(?:mod|sum)|pom\.xml)$`,
|
||||
GroupBy: GroupByURL, StatusCodes: "200",
|
||||
},
|
||||
}
|
||||
return rules
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package detect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
const (
|
||||
// scanInterval is how often the cursor loop looks for new captures. An idle
|
||||
// tick costs one mutex acquire and one int read.
|
||||
scanInterval = 250 * time.Millisecond
|
||||
// maxPerTick bounds work per tick; a burst drains over several ticks.
|
||||
maxPerTick = 200
|
||||
// progressInterval throttles rescan progress events.
|
||||
progressInterval = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// ScanStatus reports rescan progress.
|
||||
type ScanStatus struct {
|
||||
Running bool `json:"running"`
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Scanned int `json:"scanned"`
|
||||
Total int `json:"total"`
|
||||
FindingsNew int `json:"findingsNew"`
|
||||
StartedAt time.Time `json:"startedAt,omitempty"`
|
||||
FinishedAt time.Time `json:"finishedAt,omitempty"`
|
||||
Status string `json:"status,omitempty"` // "running" | "complete" | "stopped"
|
||||
}
|
||||
|
||||
// RescanRequest describes an on-demand scan.
|
||||
type RescanRequest struct {
|
||||
// Scope is "all" (every captured request) or "host".
|
||||
Scope string `json:"scope"`
|
||||
Host string `json:"host,omitempty"`
|
||||
// Purge drops findings this pass does not re-confirm, except triaged ones.
|
||||
Purge bool `json:"purge,omitempty"`
|
||||
}
|
||||
|
||||
// ErrScanRunning is returned when a rescan is already in progress.
|
||||
var ErrScanRunning = errors.New("a scan is already running")
|
||||
|
||||
// Scanner drives detection over the proxy's capture store. It pulls on an
|
||||
// interval rather than being called from the proxy's request path, so detection
|
||||
// never runs on the goroutine serving the browser, and a rescan is the same code
|
||||
// path with a different starting cursor.
|
||||
type Scanner struct {
|
||||
engine *Engine
|
||||
findings *Store
|
||||
store *proxy.Store
|
||||
scope *proxy.Scope
|
||||
broadcast chan<- any
|
||||
|
||||
// wake is a coalescing doorbell (capacity 1, non-blocking send) for
|
||||
// requesting an immediate pass without blocking the caller.
|
||||
wake chan struct{}
|
||||
|
||||
cursor atomic.Int64
|
||||
|
||||
mu sync.Mutex
|
||||
status ScanStatus
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewScanner wires a scanner. scope may be nil (no scope gating available).
|
||||
func NewScanner(engine *Engine, findings *Store, store *proxy.Store, scope *proxy.Scope, broadcast chan<- any) *Scanner {
|
||||
return &Scanner{
|
||||
engine: engine,
|
||||
findings: findings,
|
||||
store: store,
|
||||
scope: scope,
|
||||
broadcast: broadcast,
|
||||
wake: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// ResetCursor sets the live-scan watermark. Must be called wherever the proxy
|
||||
// store rewrites its sequence numbering (Store.Clear, Store.LoadItems); a stale
|
||||
// high cursor stops the loop from seeing any further request.
|
||||
func (s *Scanner) ResetCursor(seq int) {
|
||||
s.cursor.Store(int64(seq))
|
||||
}
|
||||
|
||||
// Cursor returns the current watermark.
|
||||
func (s *Scanner) Cursor() int { return int(s.cursor.Load()) }
|
||||
|
||||
// Wake requests an immediate scan pass without blocking.
|
||||
func (s *Scanner) Wake() {
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Run drives the live scan loop until ctx is cancelled. The work is in scanOnce,
|
||||
// which is synchronous and has no timing dependency.
|
||||
func (s *Scanner) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(scanInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
case <-s.wake:
|
||||
}
|
||||
s.scanOnce()
|
||||
}
|
||||
}
|
||||
|
||||
// scopeFunc returns the scope predicate, or nil when unavailable.
|
||||
func (s *Scanner) scopeFunc() proxy.ScopeFunc {
|
||||
if s.scope == nil {
|
||||
return nil
|
||||
}
|
||||
return s.scope.InScope
|
||||
}
|
||||
|
||||
// scanOnce scans up to maxPerTick newly captured requests and returns how many
|
||||
// were scanned and how many new findings resulted.
|
||||
func (s *Scanner) scanOnce() (scanned, newFindings int) {
|
||||
if s.store == nil || s.engine == nil || !s.engine.IsEnabled() {
|
||||
return 0, 0
|
||||
}
|
||||
cursor := int(s.cursor.Load())
|
||||
items := s.store.SinceSeq(cursor, maxPerTick)
|
||||
if len(items) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
scope := s.scopeFunc()
|
||||
for _, item := range items {
|
||||
n := s.scanItem(item, scope, true)
|
||||
newFindings += n
|
||||
scanned++
|
||||
if item.Seq > cursor {
|
||||
cursor = item.Seq
|
||||
}
|
||||
}
|
||||
s.cursor.Store(int64(cursor))
|
||||
if newFindings > 0 {
|
||||
s.emitSummary()
|
||||
}
|
||||
return scanned, newFindings
|
||||
}
|
||||
|
||||
// scanItem scans one capture, stores the results, and optionally streams each new
|
||||
// finding. Returns the number of newly-created findings.
|
||||
func (s *Scanner) scanItem(item *proxy.CapturedRequest, scope proxy.ScopeFunc, stream bool) int {
|
||||
cfg := s.engine.Config()
|
||||
// Parsed here only to record why a body could not be read.
|
||||
if msg := Parse(item, cfg); msg.SkipReason != "" {
|
||||
s.findings.NoteSkipped(msg.SkipReason)
|
||||
}
|
||||
s.findings.NoteScanned(1)
|
||||
|
||||
results := s.engine.Scan(item, scope)
|
||||
created := 0
|
||||
for _, f := range results {
|
||||
stored, isNew := s.findings.Upsert(f)
|
||||
if isNew {
|
||||
created++
|
||||
}
|
||||
if stream {
|
||||
s.emitFinding(*stored, isNew)
|
||||
}
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
// emitFinding streams a single finding. The send is non-blocking and the event
|
||||
// is droppable: the hub's broadcast channel is shared with request capture, and
|
||||
// the UI reconciles from detect.summary.
|
||||
func (s *Scanner) emitFinding(f Finding, isNew bool) {
|
||||
if s.broadcast == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case s.broadcast <- event.WSEvent{
|
||||
Type: "detect.finding",
|
||||
Data: map[string]any{"finding": summaryOf(f), "isNew": isNew},
|
||||
}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// emitSummary streams the aggregate counts.
|
||||
func (s *Scanner) emitSummary() {
|
||||
if s.broadcast == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case s.broadcast <- event.WSEvent{Type: "detect.summary", Data: s.findings.Summary(s.engine.RuleEnabledFunc())}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// emit sends a lifecycle event, blocking so it cannot be lost.
|
||||
func (s *Scanner) emit(kind string, data any) {
|
||||
if s.broadcast == nil {
|
||||
return
|
||||
}
|
||||
s.broadcast <- event.WSEvent{Type: kind, Data: data}
|
||||
}
|
||||
|
||||
// Status returns a copy of the current scan status.
|
||||
func (s *Scanner) Status() ScanStatus {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.status
|
||||
}
|
||||
|
||||
// Cancel stops a running rescan.
|
||||
func (s *Scanner) Cancel() {
|
||||
s.mu.Lock()
|
||||
cancel := s.cancel
|
||||
s.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// StartRescan runs the rule set over already-captured traffic in the background.
|
||||
// Findings are not cleared first; Store.Upsert merges the results and keeps
|
||||
// counts stable.
|
||||
func (s *Scanner) StartRescan(ctx context.Context, req RescanRequest) (ScanStatus, error) {
|
||||
if s.store == nil || s.engine == nil {
|
||||
return ScanStatus{}, errors.New("detection is unavailable")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.status.Running {
|
||||
s.mu.Unlock()
|
||||
return s.status, ErrScanRunning
|
||||
}
|
||||
|
||||
items := s.store.All()
|
||||
if req.Scope == "host" && req.Host != "" {
|
||||
filtered := items[:0:0]
|
||||
for _, it := range items {
|
||||
if it.Host == req.Host {
|
||||
filtered = append(filtered, it)
|
||||
}
|
||||
}
|
||||
items = filtered
|
||||
}
|
||||
|
||||
jobCtx, cancel := context.WithCancel(ctx)
|
||||
s.cancel = cancel
|
||||
jobID := proxy.GenerateID()
|
||||
s.status = ScanStatus{
|
||||
Running: true, JobID: jobID, Kind: req.Scope, Total: len(items),
|
||||
StartedAt: time.Now(), Status: "running",
|
||||
}
|
||||
status := s.status
|
||||
s.mu.Unlock()
|
||||
|
||||
go s.runRescan(jobCtx, cancel, jobID, items, req)
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// runRescan executes a rescan over a snapshot of captures.
|
||||
func (s *Scanner) runRescan(ctx context.Context, cancel context.CancelFunc, jobID string, items []*proxy.CapturedRequest, req RescanRequest) {
|
||||
defer cancel()
|
||||
|
||||
gen := s.findings.NextGeneration()
|
||||
s.emit("detect.scan.started", map[string]any{
|
||||
"jobId": jobID, "kind": req.Scope, "total": len(items),
|
||||
})
|
||||
|
||||
// Fan out across workers. Findings funnel through the mutex-serialized
|
||||
// Upsert, which is cheap next to the scanning.
|
||||
workers := runtime.NumCPU() / 2
|
||||
if workers < 2 {
|
||||
workers = 2
|
||||
}
|
||||
if workers > 8 {
|
||||
workers = 8
|
||||
}
|
||||
|
||||
var scanned, created atomic.Int64
|
||||
work := make(chan *proxy.CapturedRequest, workers)
|
||||
scope := s.scopeFunc()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for item := range work {
|
||||
// Per-finding events are suppressed during a rescan; progress
|
||||
// events and the final summary carry the information instead.
|
||||
created.Add(int64(s.scanItem(item, scope, false)))
|
||||
scanned.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Producer with a throttled progress emitter.
|
||||
lastProgress := time.Now()
|
||||
stopped := false
|
||||
producer:
|
||||
for _, item := range items {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
stopped = true
|
||||
break producer
|
||||
case work <- item:
|
||||
}
|
||||
if time.Since(lastProgress) >= progressInterval {
|
||||
lastProgress = time.Now()
|
||||
s.emit("detect.scan.progress", map[string]any{
|
||||
"jobId": jobID, "scanned": int(scanned.Load()),
|
||||
"total": len(items), "findingsNew": int(created.Load()),
|
||||
})
|
||||
}
|
||||
}
|
||||
close(work)
|
||||
wg.Wait()
|
||||
|
||||
purged := 0
|
||||
if req.Purge && !stopped {
|
||||
purged = s.findings.PurgeBelowGeneration(gen)
|
||||
}
|
||||
|
||||
finalStatus := "complete"
|
||||
if stopped {
|
||||
finalStatus = "stopped"
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.status.Running = false
|
||||
s.status.Scanned = int(scanned.Load())
|
||||
s.status.FindingsNew = int(created.Load())
|
||||
s.status.FinishedAt = time.Now()
|
||||
s.status.Status = finalStatus
|
||||
s.cancel = nil
|
||||
started := s.status.StartedAt
|
||||
s.mu.Unlock()
|
||||
|
||||
s.emit("detect.scan.complete", map[string]any{
|
||||
"jobId": jobID, "status": finalStatus,
|
||||
"scanned": int(scanned.Load()), "findingsNew": int(created.Load()),
|
||||
"purged": purged, "durationMs": time.Since(started).Milliseconds(),
|
||||
})
|
||||
s.emitSummary()
|
||||
}
|
||||
|
||||
// FindingSummary is the row shape sent to the UI and returned by list endpoints.
|
||||
// It omits occurrences and the internal dedupe bookkeeping.
|
||||
type FindingSummary struct {
|
||||
ID string `json:"id"`
|
||||
RuleID string `json:"ruleId"`
|
||||
RuleName string `json:"ruleName"`
|
||||
Category Category `json:"category"`
|
||||
Severity Severity `json:"severity"`
|
||||
Confidence Confidence `json:"confidence"`
|
||||
Target Target `json:"target"`
|
||||
Host string `json:"host"`
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
RequestID string `json:"requestId"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Evidence string `json:"evidence"`
|
||||
RawEvidence string `json:"rawEvidence,omitempty"`
|
||||
EvidenceOffset int `json:"evidenceOffset"`
|
||||
EvidenceLength int `json:"evidenceLength"`
|
||||
EvidencePart string `json:"evidencePart,omitempty"`
|
||||
Count int `json:"count"`
|
||||
FirstSeen time.Time `json:"firstSeen"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
FalsePositive bool `json:"falsePositive"`
|
||||
HasNotes bool `json:"hasNotes"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// summaryOf projects a finding into its wire shape.
|
||||
func summaryOf(f Finding) FindingSummary {
|
||||
return FindingSummary{
|
||||
ID: f.ID, RuleID: f.RuleID, RuleName: f.RuleName,
|
||||
Category: f.Category, Severity: f.Severity, Confidence: f.Confidence,
|
||||
Target: f.Target, Host: f.Host, Method: f.Method, URL: f.URL,
|
||||
RequestID: f.RequestID, Detail: f.Detail, Evidence: f.Evidence,
|
||||
RawEvidence: f.RawEvidence,
|
||||
EvidenceOffset: f.EvidenceOffset, EvidenceLength: f.EvidenceLength,
|
||||
EvidencePart: f.EvidencePart,
|
||||
Count: f.Count, FirstSeen: f.FirstSeen, LastSeen: f.LastSeen,
|
||||
FalsePositive: f.FalsePositive, HasNotes: f.Notes != "",
|
||||
Truncated: f.Truncated,
|
||||
}
|
||||
}
|
||||
|
||||
// Summaries projects a slice of findings.
|
||||
func Summaries(in []Finding) []FindingSummary {
|
||||
out := make([]FindingSummary, len(in))
|
||||
for i, f := range in {
|
||||
out[i] = summaryOf(f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
package detect
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// defaultMaxFindings bounds the in-memory store as a runaway guard.
|
||||
const defaultMaxFindings = 20000
|
||||
|
||||
// FindingFilter holds the criteria for listing findings.
|
||||
type FindingFilter struct {
|
||||
Severities []string
|
||||
MinSeverity string
|
||||
Categories []string
|
||||
RuleID string
|
||||
Host string
|
||||
Search string
|
||||
Confidence string
|
||||
// FP selects false-positive handling: "false" (default) hides them, "true"
|
||||
// shows only them, "all" shows everything.
|
||||
FP string
|
||||
// IncludeDisabled includes findings whose rule is currently switched off.
|
||||
// Disabling a rule retains its findings; this toggle controls visibility.
|
||||
IncludeDisabled bool
|
||||
Sort string // "severity" (default) | "lastSeen" | "firstSeen" | "count"
|
||||
Dir string // "desc" (default) | "asc"
|
||||
Offset int
|
||||
Limit int
|
||||
}
|
||||
|
||||
// Store holds deduplicated findings. Every finding, from live scanning or from a
|
||||
// rescan, enters through Upsert and is keyed on the engine's deterministic
|
||||
// Finding.ID, so rescanning the same traffic merges rather than duplicates.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
byID map[string]*Finding
|
||||
order []string
|
||||
maxItems int
|
||||
|
||||
// revision increments on every mutation, including ones that change no counts
|
||||
// (a false-positive toggle, a note edit). The auto-save fingerprint reads it.
|
||||
revision atomic.Uint64
|
||||
|
||||
// generation is the current scan pass, used by an optional purge to drop
|
||||
// findings a rescan did not re-confirm.
|
||||
generation atomic.Uint64
|
||||
|
||||
skippedEncoded atomic.Int64
|
||||
skippedBinary atomic.Int64
|
||||
scanned atomic.Int64
|
||||
}
|
||||
|
||||
// NewStore returns an empty findings store. maxItems <= 0 uses the default.
|
||||
func NewStore(maxItems int) *Store {
|
||||
if maxItems <= 0 {
|
||||
maxItems = defaultMaxFindings
|
||||
}
|
||||
return &Store{
|
||||
byID: map[string]*Finding{},
|
||||
maxItems: maxItems,
|
||||
}
|
||||
}
|
||||
|
||||
// Revision returns the mutation counter.
|
||||
func (s *Store) Revision() uint64 { return s.revision.Load() }
|
||||
|
||||
// Generation returns the current scan generation.
|
||||
func (s *Store) Generation() uint64 { return s.generation.Load() }
|
||||
|
||||
// NextGeneration advances the scan generation and returns the new value.
|
||||
func (s *Store) NextGeneration() uint64 { return s.generation.Add(1) }
|
||||
|
||||
// NoteSkipped records a message the scanner could not read.
|
||||
func (s *Store) NoteSkipped(reason string) {
|
||||
switch {
|
||||
case strings.HasPrefix(reason, "encoding:"):
|
||||
s.skippedEncoded.Add(1)
|
||||
case reason == "binary":
|
||||
s.skippedBinary.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// NoteScanned records that a message was scanned.
|
||||
func (s *Store) NoteScanned(n int) { s.scanned.Add(int64(n)) }
|
||||
|
||||
// occKey identifies one sighting for counting purposes.
|
||||
func occKey(o Occurrence) string {
|
||||
return o.RequestID + ":" + o.Part + ":" + itoa(o.Offset)
|
||||
}
|
||||
|
||||
// itoa avoids importing strconv for one call site.
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// Upsert merges a finding into the store, returning the stored finding and
|
||||
// whether it was newly created.
|
||||
//
|
||||
// On a hit it bumps LastSeen, appends the occurrence, and increments Count only
|
||||
// for an occurrence not already in occSeen, which keeps a rescan idempotent.
|
||||
// Operator state (false-positive mark, notes, severity override) is preserved.
|
||||
func (s *Store) Upsert(f Finding) (*Finding, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
gen := s.generation.Load()
|
||||
existing, ok := s.byID[f.ID]
|
||||
if !ok {
|
||||
nf := f
|
||||
nf.generation = gen
|
||||
nf.occSeen = map[string]struct{}{}
|
||||
for _, o := range nf.Occurrences {
|
||||
nf.occSeen[occKey(o)] = struct{}{}
|
||||
}
|
||||
if nf.Count == 0 {
|
||||
nf.Count = 1
|
||||
}
|
||||
s.byID[nf.ID] = &nf
|
||||
s.order = append(s.order, nf.ID)
|
||||
s.evictLocked()
|
||||
s.revision.Add(1)
|
||||
out := nf
|
||||
return &out, true
|
||||
}
|
||||
|
||||
existing.generation = gen
|
||||
if f.LastSeen.After(existing.LastSeen) {
|
||||
existing.LastSeen = f.LastSeen
|
||||
}
|
||||
if !f.FirstSeen.IsZero() && f.FirstSeen.Before(existing.FirstSeen) {
|
||||
existing.FirstSeen = f.FirstSeen
|
||||
}
|
||||
// Point at the most recent sighting, which is the one most likely still in
|
||||
// the ring buffer. Offset, length, and part describe one location and must be
|
||||
// updated together: a host-grouped rule can merge two different matches.
|
||||
if f.RequestID != "" {
|
||||
existing.RequestID = f.RequestID
|
||||
existing.URL = f.URL
|
||||
existing.Method = f.Method
|
||||
existing.EvidenceOffset = f.EvidenceOffset
|
||||
existing.EvidenceLength = f.EvidenceLength
|
||||
existing.EvidencePart = f.EvidencePart
|
||||
// Evidence moves with the location it points at.
|
||||
existing.Evidence = f.Evidence
|
||||
existing.RawEvidence = f.RawEvidence
|
||||
}
|
||||
if existing.Evidence == "" {
|
||||
existing.Evidence = f.Evidence
|
||||
}
|
||||
// Adopt the incoming severity only when the operator has not overridden it.
|
||||
if !existing.SeverityOverridden && f.Severity != "" {
|
||||
existing.Severity = f.Severity
|
||||
}
|
||||
if f.Truncated {
|
||||
existing.Truncated = true
|
||||
}
|
||||
|
||||
for _, o := range f.Occurrences {
|
||||
k := occKey(o)
|
||||
if _, counted := existing.occSeen[k]; counted {
|
||||
continue
|
||||
}
|
||||
if len(existing.occSeen) < maxOccSeen {
|
||||
existing.occSeen[k] = struct{}{}
|
||||
}
|
||||
existing.Count++
|
||||
existing.Occurrences = append(existing.Occurrences, o)
|
||||
if len(existing.Occurrences) > maxOccurrences {
|
||||
existing.Occurrences = existing.Occurrences[len(existing.Occurrences)-maxOccurrences:]
|
||||
}
|
||||
}
|
||||
|
||||
s.revision.Add(1)
|
||||
out := *existing
|
||||
return &out, false
|
||||
}
|
||||
|
||||
// evictLocked drops the oldest findings when over capacity.
|
||||
func (s *Store) evictLocked() {
|
||||
for len(s.order) > s.maxItems {
|
||||
id := s.order[0]
|
||||
s.order = s.order[1:]
|
||||
delete(s.byID, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns a copy of one finding.
|
||||
func (s *Store) Get(id string) (Finding, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
f, ok := s.byID[id]
|
||||
if !ok {
|
||||
return Finding{}, false
|
||||
}
|
||||
return *f, true
|
||||
}
|
||||
|
||||
// Update applies operator edits to a finding. Passing nil for a field leaves it
|
||||
// unchanged, so a PUT can carry any subset.
|
||||
func (s *Store) Update(id string, falsePositive *bool, notes *string, severity *Severity) (Finding, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
f, ok := s.byID[id]
|
||||
if !ok {
|
||||
return Finding{}, false
|
||||
}
|
||||
if falsePositive != nil {
|
||||
f.FalsePositive = *falsePositive
|
||||
}
|
||||
if notes != nil {
|
||||
f.Notes = *notes
|
||||
}
|
||||
if severity != nil && severity.Valid() {
|
||||
f.Severity = *severity
|
||||
f.SeverityOverridden = true
|
||||
}
|
||||
s.revision.Add(1)
|
||||
return *f, true
|
||||
}
|
||||
|
||||
// Delete removes one finding.
|
||||
func (s *Store) Delete(id string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.byID[id]; !ok {
|
||||
return false
|
||||
}
|
||||
delete(s.byID, id)
|
||||
for i, oid := range s.order {
|
||||
if oid == id {
|
||||
s.order = append(s.order[:i], s.order[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
s.revision.Add(1)
|
||||
return true
|
||||
}
|
||||
|
||||
// Clear removes every finding.
|
||||
func (s *Store) Clear() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
n := len(s.byID)
|
||||
s.byID = map[string]*Finding{}
|
||||
s.order = nil
|
||||
s.skippedEncoded.Store(0)
|
||||
s.skippedBinary.Store(0)
|
||||
s.scanned.Store(0)
|
||||
s.revision.Add(1)
|
||||
return n
|
||||
}
|
||||
|
||||
// DeleteFalsePositives removes only findings marked as false positives.
|
||||
func (s *Store) DeleteFalsePositives() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var kept []string
|
||||
n := 0
|
||||
for _, id := range s.order {
|
||||
f := s.byID[id]
|
||||
if f != nil && f.FalsePositive {
|
||||
delete(s.byID, id)
|
||||
n++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, id)
|
||||
}
|
||||
s.order = kept
|
||||
if n > 0 {
|
||||
s.revision.Add(1)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// PurgeBelowGeneration drops findings a scan pass did not re-confirm. Findings
|
||||
// marked false-positive or carrying notes are always kept.
|
||||
func (s *Store) PurgeBelowGeneration(gen uint64) int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var kept []string
|
||||
n := 0
|
||||
for _, id := range s.order {
|
||||
f := s.byID[id]
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
if f.generation < gen && !f.FalsePositive && f.Notes == "" {
|
||||
delete(s.byID, id)
|
||||
n++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, id)
|
||||
}
|
||||
s.order = kept
|
||||
if n > 0 {
|
||||
s.revision.Add(1)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Count returns the number of findings held.
|
||||
func (s *Store) Count() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.byID)
|
||||
}
|
||||
|
||||
// All returns copies of every finding, newest first.
|
||||
func (s *Store) All() []Finding {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]Finding, 0, len(s.byID))
|
||||
for i := len(s.order) - 1; i >= 0; i-- {
|
||||
if f := s.byID[s.order[i]]; f != nil {
|
||||
out = append(out, *f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Load replaces the store contents (project load). Findings keep the IDs they
|
||||
// were persisted with; the ID is the dedupe identity.
|
||||
func (s *Store) Load(findings []Finding) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.byID = make(map[string]*Finding, len(findings))
|
||||
s.order = make([]string, 0, len(findings))
|
||||
for i := range findings {
|
||||
f := findings[i]
|
||||
if f.ID == "" {
|
||||
continue
|
||||
}
|
||||
if f.occSeen == nil {
|
||||
f.occSeen = map[string]struct{}{}
|
||||
for _, o := range f.Occurrences {
|
||||
f.occSeen[occKey(o)] = struct{}{}
|
||||
}
|
||||
}
|
||||
if _, dup := s.byID[f.ID]; dup {
|
||||
continue
|
||||
}
|
||||
s.byID[f.ID] = &f
|
||||
s.order = append(s.order, f.ID)
|
||||
}
|
||||
s.evictLocked()
|
||||
s.revision.Add(1)
|
||||
}
|
||||
|
||||
// Summary aggregates the store for the header and the summary event. It takes
|
||||
// the same rule-enabled predicate as List so the counts match the table.
|
||||
func (s *Store) Summary(ruleEnabled func(string) bool) Summary {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
sum := Summary{
|
||||
BySeverity: map[string]int{},
|
||||
ByCategory: map[string]int{},
|
||||
SkippedEncoded: int(s.skippedEncoded.Load()),
|
||||
SkippedBinary: int(s.skippedBinary.Load()),
|
||||
Scanned: int(s.scanned.Load()),
|
||||
}
|
||||
for _, f := range s.byID {
|
||||
if f.FalsePositive {
|
||||
sum.FalsePositives++
|
||||
continue
|
||||
}
|
||||
if ruleEnabled != nil && !ruleEnabled(f.RuleID) {
|
||||
sum.HiddenByDisabledRule++
|
||||
continue
|
||||
}
|
||||
sum.Total++
|
||||
sum.BySeverity[string(f.Severity)]++
|
||||
sum.ByCategory[string(f.Category)]++
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
// List applies a filter and returns a page plus the total number of matches.
|
||||
func (s *Store) List(f FindingFilter, ruleEnabled func(string) bool) ([]Finding, int) {
|
||||
s.mu.RLock()
|
||||
matched := make([]Finding, 0, len(s.byID))
|
||||
for _, item := range s.byID {
|
||||
if matchesFinding(item, f, ruleEnabled) {
|
||||
matched = append(matched, *item)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
sortFindings(matched, f.Sort, f.Dir)
|
||||
|
||||
total := len(matched)
|
||||
if f.Offset >= total {
|
||||
// Return an empty slice rather than nil so the JSON envelope emits [].
|
||||
return []Finding{}, total
|
||||
}
|
||||
page := matched[f.Offset:]
|
||||
if f.Limit > 0 && len(page) > f.Limit {
|
||||
page = page[:f.Limit]
|
||||
}
|
||||
out := make([]Finding, len(page))
|
||||
copy(out, page)
|
||||
return out, total
|
||||
}
|
||||
|
||||
// matchesFinding applies every filter dimension.
|
||||
func matchesFinding(f *Finding, filter FindingFilter, ruleEnabled func(string) bool) bool {
|
||||
switch strings.ToLower(filter.FP) {
|
||||
case "all":
|
||||
case "true":
|
||||
if !f.FalsePositive {
|
||||
return false
|
||||
}
|
||||
default: // "false" or empty
|
||||
if f.FalsePositive {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if !filter.IncludeDisabled && ruleEnabled != nil && !ruleEnabled(f.RuleID) {
|
||||
return false
|
||||
}
|
||||
// An explicit severity selection overrides MinSeverity rather than combining
|
||||
// with it.
|
||||
switch {
|
||||
case len(filter.Severities) > 0:
|
||||
if !containsFold(filter.Severities, string(f.Severity)) {
|
||||
return false
|
||||
}
|
||||
case filter.MinSeverity != "":
|
||||
if f.Severity.Rank() < Severity(strings.ToLower(filter.MinSeverity)).Rank() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if len(filter.Categories) > 0 && !containsFold(filter.Categories, string(f.Category)) {
|
||||
return false
|
||||
}
|
||||
if filter.Confidence != "" && !strings.EqualFold(filter.Confidence, string(f.Confidence)) {
|
||||
return false
|
||||
}
|
||||
if filter.RuleID != "" {
|
||||
needle := strings.ToLower(filter.RuleID)
|
||||
if !strings.Contains(strings.ToLower(f.RuleID), needle) &&
|
||||
!strings.Contains(strings.ToLower(f.RuleName), needle) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if filter.Host != "" && !strings.Contains(strings.ToLower(f.Host), strings.ToLower(filter.Host)) {
|
||||
return false
|
||||
}
|
||||
if filter.Search != "" {
|
||||
needle := strings.ToLower(filter.Search)
|
||||
if !strings.Contains(strings.ToLower(f.Evidence), needle) &&
|
||||
!strings.Contains(strings.ToLower(f.URL), needle) &&
|
||||
!strings.Contains(strings.ToLower(f.RuleName), needle) &&
|
||||
!strings.Contains(strings.ToLower(f.Detail), needle) &&
|
||||
!strings.Contains(strings.ToLower(f.Notes), needle) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// containsFold reports whether list contains v, case-insensitively.
|
||||
func containsFold(list []string, v string) bool {
|
||||
for _, item := range list {
|
||||
if strings.EqualFold(item, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sortFindings orders results, defaulting to severity descending.
|
||||
func sortFindings(items []Finding, key, dir string) {
|
||||
desc := !strings.EqualFold(dir, "asc")
|
||||
less := func(i, j int) bool {
|
||||
a, b := items[i], items[j]
|
||||
var cmp int
|
||||
switch strings.ToLower(key) {
|
||||
case "lastseen":
|
||||
cmp = compareTime(a.LastSeen, b.LastSeen)
|
||||
case "firstseen":
|
||||
cmp = compareTime(a.FirstSeen, b.FirstSeen)
|
||||
case "count":
|
||||
cmp = a.Count - b.Count
|
||||
case "host":
|
||||
cmp = strings.Compare(a.Host, b.Host)
|
||||
case "rule":
|
||||
cmp = strings.Compare(a.RuleName, b.RuleName)
|
||||
default: // severity
|
||||
cmp = a.Severity.Rank() - b.Severity.Rank()
|
||||
}
|
||||
if cmp == 0 {
|
||||
// Deterministic tiebreak, so pagination stays coherent across requests.
|
||||
if c := a.Severity.Rank() - b.Severity.Rank(); c != 0 {
|
||||
cmp = c
|
||||
} else if c := compareTime(a.LastSeen, b.LastSeen); c != 0 {
|
||||
cmp = c
|
||||
} else {
|
||||
cmp = strings.Compare(a.ID, b.ID)
|
||||
}
|
||||
}
|
||||
if desc {
|
||||
return cmp > 0
|
||||
}
|
||||
return cmp < 0
|
||||
}
|
||||
sort.SliceStable(items, less)
|
||||
}
|
||||
|
||||
// compareTime returns -1, 0, or 1.
|
||||
func compareTime(a, b time.Time) int {
|
||||
switch {
|
||||
case a.Before(b):
|
||||
return -1
|
||||
case a.After(b):
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func sendH1Raw(ctx context.Context, raw []byte, scheme, host, proto string, opts
|
||||
duration := time.Since(start)
|
||||
|
||||
if opts.Decompress {
|
||||
if decoded, ok := tryDecompress(resp.Header.Get("Content-Encoding"), bodyBytes); ok {
|
||||
if decoded, ok := TryDecompress(resp.Header.Get("Content-Encoding"), bodyBytes); ok {
|
||||
bodyBytes = decoded
|
||||
resp.Header.Del("Content-Encoding")
|
||||
resp.Header.Del("Transfer-Encoding")
|
||||
@@ -218,7 +218,7 @@ func sendH2(ctx context.Context, raw []byte, scheme, host string, opts SendOptio
|
||||
resp.Body.Close()
|
||||
|
||||
if opts.Decompress {
|
||||
if decoded, ok := tryDecompress(resp.Header.Get("Content-Encoding"), bodyBytes); ok {
|
||||
if decoded, ok := TryDecompress(resp.Header.Get("Content-Encoding"), bodyBytes); ok {
|
||||
bodyBytes = decoded
|
||||
resp.Header.Del("Content-Encoding")
|
||||
resp.Header.Del("Transfer-Encoding")
|
||||
@@ -423,9 +423,9 @@ func buildSimpleGet(proto, host, path string) []byte {
|
||||
return fmt.Appendf(nil, "GET %s %s\r\nHost: %s\r\nAccept: */*\r\n\r\n", path, proto, host)
|
||||
}
|
||||
|
||||
// tryDecompress decodes gzip / deflate response bodies. Returns (decoded, true)
|
||||
// TryDecompress decodes gzip / deflate response bodies. Returns (decoded, true)
|
||||
// on success, or (original, false) if the encoding is empty or unsupported.
|
||||
func tryDecompress(encoding string, body []byte) ([]byte, bool) {
|
||||
func TryDecompress(encoding string, body []byte) ([]byte, bool) {
|
||||
enc := strings.ToLower(strings.TrimSpace(encoding))
|
||||
if enc == "" {
|
||||
return body, false
|
||||
|
||||
@@ -19,6 +19,14 @@ type statusMatcher struct {
|
||||
ranges [][2]int
|
||||
}
|
||||
|
||||
// NewStatusPredicate compiles a status expression into a standalone predicate
|
||||
// for packages outside proxy. An empty or fully unparsable expression yields a
|
||||
// predicate matching every status, as parseStatusFilter does.
|
||||
func NewStatusPredicate(expr string) func(int) bool {
|
||||
sm := parseStatusFilter(expr)
|
||||
return sm.match
|
||||
}
|
||||
|
||||
// parseStatusFilter compiles a status expression. Unparsable tokens are skipped
|
||||
// silently, so a half-typed value degrades to the tokens that do parse rather
|
||||
// than matching nothing. An expression with no parsable token is inactive
|
||||
|
||||
@@ -313,6 +313,29 @@ func (s *Store) All() []*CapturedRequest {
|
||||
return out
|
||||
}
|
||||
|
||||
// SinceSeq returns requests with Seq > seq, oldest first, at most limit
|
||||
// (limit <= 0 means no limit). Items are stored in Seq order, so this scans
|
||||
// backwards from the newest and stops at the first already-seen record.
|
||||
//
|
||||
// The returned pointers are shared with the store; a CapturedRequest is never
|
||||
// mutated after Add. Requests evicted from the ring buffer before being read are
|
||||
// absent, so callers must tolerate gaps in the sequence numbers.
|
||||
func (s *Store) SinceSeq(seq, limit int) []*CapturedRequest {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
start := len(s.items)
|
||||
for start > 0 && s.items[start-1].Seq > seq {
|
||||
start--
|
||||
}
|
||||
out := s.items[start:]
|
||||
if limit > 0 && len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
cp := make([]*CapturedRequest, len(out))
|
||||
copy(cp, out)
|
||||
return cp
|
||||
}
|
||||
|
||||
// LoadItems replaces all stored requests with the given items.
|
||||
// If more items are provided than maxSize, only the last maxSize are kept.
|
||||
func (s *Store) LoadItems(items []*CapturedRequest) {
|
||||
|
||||
@@ -379,6 +379,7 @@ func runProxyMode(ctx context.Context, cfg config.Config) {
|
||||
|
||||
// Periodically save the active project when auto-save is enabled.
|
||||
apiSrv.StartAutoSaveLoop(ctx)
|
||||
apiSrv.StartDetectLoop(ctx)
|
||||
|
||||
// Start proxy server.
|
||||
proxyDone := make(chan struct{})
|
||||
|
||||
@@ -16,6 +16,7 @@ import { NAV } from './lib/nav'
|
||||
import { currentTheme } from './lib/theme'
|
||||
import Callbacks from './pages/Callbacks'
|
||||
import History from './pages/History'
|
||||
import Detect from './pages/Detect'
|
||||
import Intercept from './pages/Intercept'
|
||||
import Manipulate from './pages/Manipulate'
|
||||
import Fuzz from './pages/Fuzz'
|
||||
@@ -251,6 +252,7 @@ export default function App() {
|
||||
} />
|
||||
<Route path="/map" element={<Map />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/detect" element={<Detect />} />
|
||||
<Route path="/intercept" element={<Intercept />} />
|
||||
<Route path="/manipulate" element={<Manipulate />} />
|
||||
<Route path="/fuzz" element={<Fuzz />} />
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { CATEGORY_OPTIONS } from '../lib/detectFilters'
|
||||
import { categoryPill, severityBadge, SEVERITY_ORDER, type Severity } from '../lib/severity'
|
||||
import type { DetectRule } from '../stores/detectStore'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
import ConfirmModal from './ConfirmModal'
|
||||
import { Tooltip } from './Tooltip'
|
||||
|
||||
// DetectRuleModal is the per-rule configuration surface. It owns the state that
|
||||
// only exists while a rule is being configured: the editable draft, the regex
|
||||
// test sample, and its result.
|
||||
//
|
||||
// Enabled and Severity are at the top and are editable for built-in rules as
|
||||
// well as custom ones; everything below is read-only for a built-in.
|
||||
|
||||
const emptyRuleDraft: Partial<DetectRule> = {
|
||||
name: '',
|
||||
description: '',
|
||||
category: 'secrets',
|
||||
severity: 'medium',
|
||||
confidence: 'medium',
|
||||
target: 'response_body',
|
||||
pattern: '',
|
||||
groupBy: 'evidence',
|
||||
captureGroup: 0,
|
||||
redactEvidence: false,
|
||||
}
|
||||
|
||||
type TestResult = {
|
||||
valid: boolean
|
||||
error?: string
|
||||
matches?: { match: string; redacted: string; offset: number; passes: boolean }[]
|
||||
}
|
||||
|
||||
export default function DetectRuleModal({
|
||||
rule,
|
||||
creating,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
rule: DetectRule | null
|
||||
creating?: boolean
|
||||
onClose: () => void
|
||||
onChanged: () => void | Promise<void>
|
||||
}) {
|
||||
const addToast = useToastStore((s) => s.addToast)
|
||||
|
||||
// A custom rule opens straight into edit mode.
|
||||
const [draft, setDraft] = useState<Partial<DetectRule> | null>(() => {
|
||||
if (creating) return { ...emptyRuleDraft }
|
||||
if (rule && !rule.builtin) return { ...rule }
|
||||
return null
|
||||
})
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [testSample, setTestSample] = useState('')
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const patchDraft = useCallback((patch: Partial<DetectRule>) => {
|
||||
setDraft((d) => ({ ...(d ?? {}), ...patch }))
|
||||
setDirty(true)
|
||||
}, [])
|
||||
|
||||
// Backdrop click and Escape are ignored while a draft has unsaved edits; use
|
||||
// Cancel.
|
||||
const guardedClose = useCallback(() => {
|
||||
if (draft && dirty) {
|
||||
addToast('Unsaved rule changes — use Cancel to discard', 'info')
|
||||
return
|
||||
}
|
||||
onClose()
|
||||
}, [draft, dirty, onClose, addToast])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation()
|
||||
guardedClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [guardedClose])
|
||||
|
||||
// Client-side regex preview only. JavaScript accepts lookahead and
|
||||
// backreferences that Go's RE2 rejects, so the backend stays authoritative.
|
||||
const localRegexError = useMemo(() => {
|
||||
if (!draft?.pattern) return null
|
||||
try {
|
||||
new RegExp(draft.pattern, 'g')
|
||||
return null
|
||||
} catch (err) {
|
||||
return (err as Error).message
|
||||
}
|
||||
}, [draft?.pattern])
|
||||
|
||||
async function run<T>(fn: () => Promise<T>, ok?: string): Promise<T | null> {
|
||||
setBusy(true)
|
||||
try {
|
||||
const out = await fn()
|
||||
if (ok) addToast(ok, 'info')
|
||||
await onChanged()
|
||||
return out
|
||||
} catch (err) {
|
||||
addToast((err as Error).message, 'error')
|
||||
return null
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(enabled: boolean) {
|
||||
if (!rule) return
|
||||
await run(() => api.setDetectRuleEnabled(rule.id, enabled))
|
||||
}
|
||||
|
||||
async function changeSeverity(sev: string) {
|
||||
if (!rule) return
|
||||
await run(() => api.setDetectRuleSeverity(rule.id, sev))
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
if (!draft) return
|
||||
const saved = await run(
|
||||
() =>
|
||||
draft.id
|
||||
? api.updateDetectRule(draft.id, draft)
|
||||
: api.addDetectRule(draft),
|
||||
draft.id ? 'Rule updated' : 'Rule created'
|
||||
)
|
||||
if (saved) {
|
||||
setDirty(false)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRule() {
|
||||
if (!rule) return
|
||||
setConfirmDelete(false)
|
||||
const done = await run(() => api.deleteDetectRule(rule.id), 'Rule deleted')
|
||||
if (done !== null) onClose()
|
||||
}
|
||||
|
||||
async function resetRule() {
|
||||
if (!rule) return
|
||||
await run(() => api.resetDetectRule(rule.id), 'Rule reset to defaults')
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
if (!draft?.pattern) return
|
||||
const res = await api
|
||||
.testDetectRule({
|
||||
pattern: draft.pattern,
|
||||
sample: testSample,
|
||||
captureGroup: draft.captureGroup ?? 0,
|
||||
minEntropy: draft.minEntropy ?? 0,
|
||||
minLength: draft.minLength ?? 0,
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
addToast(err.message, 'error')
|
||||
return null
|
||||
})
|
||||
if (res) setTestResult(res)
|
||||
}
|
||||
|
||||
const title = creating ? 'New rule' : (rule?.name ?? 'Rule')
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6"
|
||||
onMouseDown={guardedClose}
|
||||
>
|
||||
<div
|
||||
className="flex flex-col w-full max-w-3xl max-h-[85vh] bg-surface-card border border-border rounded shadow-lg overflow-hidden"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-border shrink-0">
|
||||
<span className="text-xs font-semibold text-content-primary uppercase tracking-wide truncate">
|
||||
{title}
|
||||
</span>
|
||||
{rule && !creating && (
|
||||
<>
|
||||
{categoryPill(rule.category)}
|
||||
<span
|
||||
className={`inline-block px-1 py-px rounded-sm bg-surface-input text-[10px] font-semibold uppercase tracking-wide align-middle ${
|
||||
rule.builtin ? 'text-accent-secondary' : 'text-accent-tertiary'
|
||||
}`}
|
||||
>
|
||||
{rule.builtin ? 'Built-in' : 'Custom'}
|
||||
</span>
|
||||
{severityBadge(rule.severity)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={guardedClose}
|
||||
className="ml-auto w-6 h-5 flex items-center justify-center rounded-sm bg-surface-input text-content-secondary hover:bg-surface-hover"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto min-h-0 px-4 py-3 space-y-3">
|
||||
{/* Opt in/out + severity: the two routine actions, available for
|
||||
built-in rules as well as custom ones. */}
|
||||
{rule && !creating && (
|
||||
<div className="flex flex-wrap items-center gap-4 pb-3 border-b border-border-subtle">
|
||||
<label className="flex items-center gap-1.5 text-xs text-content-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-accent"
|
||||
checked={rule.enabled}
|
||||
disabled={busy}
|
||||
onChange={(e) => void toggleEnabled(e.target.checked)}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-content-secondary">
|
||||
<span>Severity</span>
|
||||
<Tooltip content="Overrides the shipped default for this rule. Applies to findings from the next scan onward.">
|
||||
<select
|
||||
value={rule.severity}
|
||||
disabled={busy}
|
||||
onChange={(e) => void changeSeverity(e.target.value)}
|
||||
className="bg-surface-input text-xs px-2 py-1 rounded-sm border border-border"
|
||||
>
|
||||
{SEVERITY_ORDER.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Tooltip>
|
||||
</label>
|
||||
{rule.findingCount ? (
|
||||
<span className="text-[10px] text-content-muted">
|
||||
{rule.findingCount} finding{rule.findingCount === 1 ? '' : 's'} so far
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft ? (
|
||||
<RuleForm
|
||||
draft={draft}
|
||||
patch={patchDraft}
|
||||
localRegexError={localRegexError}
|
||||
testSample={testSample}
|
||||
setTestSample={setTestSample}
|
||||
testResult={testResult}
|
||||
onTest={() => void runTest()}
|
||||
/>
|
||||
) : rule ? (
|
||||
<RuleReference rule={rule} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-t border-border shrink-0">
|
||||
{draft ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => void saveDraft()}
|
||||
disabled={busy}
|
||||
className="px-3 py-1.5 rounded-sm bg-accent-tertiary hover:bg-accent-tertiary-hover text-black text-xs font-semibold disabled:opacity-50"
|
||||
>
|
||||
{draft.id ? 'Save changes' : 'Create rule'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 rounded-sm bg-surface-input hover:bg-surface-hover text-content-secondary text-xs font-semibold"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{rule && !rule.builtin && (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="ml-auto px-3 py-1.5 rounded-sm bg-surface-input hover:bg-surface-hover text-semantic-error text-xs font-semibold"
|
||||
>
|
||||
Delete rule
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{rule?.builtin && (
|
||||
<>
|
||||
<Tooltip content="Built-in rules cannot be edited. Cloning makes an editable custom copy.">
|
||||
<button
|
||||
onClick={() => {
|
||||
setDraft({
|
||||
...rule,
|
||||
id: undefined,
|
||||
builtin: false,
|
||||
name: `${rule.name} (copy)`,
|
||||
})
|
||||
setDirty(true)
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-sm bg-surface-input hover:bg-surface-hover text-content-secondary text-xs font-semibold"
|
||||
>
|
||||
Clone to custom
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
onClick={() => void resetRule()}
|
||||
disabled={busy}
|
||||
className="px-3 py-1.5 rounded-sm bg-surface-input hover:bg-surface-hover text-content-secondary text-xs font-semibold disabled:opacity-50"
|
||||
>
|
||||
Reset to defaults
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-auto px-3 py-1.5 rounded-sm bg-surface-input hover:bg-surface-hover text-content-secondary text-xs font-semibold"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmDelete && rule && (
|
||||
<ConfirmModal
|
||||
title={`Delete rule "${rule.name}"?`}
|
||||
message="The rule is removed. Findings it already produced are kept."
|
||||
confirmLabel="Delete rule"
|
||||
onConfirm={() => void deleteRule()}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// field renders one labelled row in the reference view.
|
||||
function field(label: string, value?: string, mono?: boolean): ReactNode {
|
||||
if (!value) return null
|
||||
return (
|
||||
<div key={label}>
|
||||
<div className="text-[10px] text-content-muted uppercase tracking-wide">{label}</div>
|
||||
<div className={`text-xs text-content-secondary break-all ${mono ? 'font-mono' : ''}`}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// RuleReference is the read-only view of a built-in rule: what it looks for, and
|
||||
// every gate that decides when it applies.
|
||||
function RuleReference({ rule }: { rule: DetectRule }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{field('Description', rule.description)}
|
||||
{field('Remediation', rule.remediation)}
|
||||
{field('Rule ID', rule.id, true)}
|
||||
{field('Kind', rule.kind)}
|
||||
{field('Target', rule.target)}
|
||||
{field('Confidence', rule.confidence)}
|
||||
{field('Grouping', rule.groupBy ?? 'evidence')}
|
||||
{field('Status gate', rule.statusCodes)}
|
||||
{field('Scheme gate', rule.scheme)}
|
||||
{field('Content types', rule.contentTypes?.join(', '))}
|
||||
{field('Post-filters', rule.postFilters?.join(', '))}
|
||||
{rule.minEntropy ? field('Min entropy', String(rule.minEntropy)) : null}
|
||||
{field('Analyzer', rule.analyzer, true)}
|
||||
{rule.pattern && (
|
||||
<div>
|
||||
<div className="text-[10px] text-content-muted uppercase tracking-wide mb-1">Pattern</div>
|
||||
<pre className="bg-surface-input border border-border rounded-sm p-2 text-[11px] font-mono text-content-secondary whitespace-pre-wrap break-all">
|
||||
{rule.pattern}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// RuleForm is the editable form for a custom rule, plus the pattern tester.
|
||||
function RuleForm({
|
||||
draft,
|
||||
patch,
|
||||
localRegexError,
|
||||
testSample,
|
||||
setTestSample,
|
||||
testResult,
|
||||
onTest,
|
||||
}: {
|
||||
draft: Partial<DetectRule>
|
||||
patch: (p: Partial<DetectRule>) => void
|
||||
localRegexError: string | null
|
||||
testSample: string
|
||||
setTestSample: (v: string) => void
|
||||
testResult: TestResult | null
|
||||
onTest: () => void
|
||||
}) {
|
||||
const inputCls = 'bg-surface-input text-xs px-2 py-1.5 rounded-sm border border-border flex-1'
|
||||
const row = (label: string, node: ReactNode) => (
|
||||
<label className="flex items-start gap-1.5">
|
||||
<span className="text-xs text-content-muted w-28 pt-1.5 shrink-0">{label}</span>
|
||||
{node}
|
||||
</label>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{row(
|
||||
'Name',
|
||||
<input
|
||||
value={draft.name ?? ''}
|
||||
onChange={(e) => patch({ name: e.target.value })}
|
||||
className={inputCls}
|
||||
/>
|
||||
)}
|
||||
{row(
|
||||
'Description',
|
||||
<input
|
||||
value={draft.description ?? ''}
|
||||
onChange={(e) => patch({ description: e.target.value })}
|
||||
className={inputCls}
|
||||
/>
|
||||
)}
|
||||
{row(
|
||||
'Category',
|
||||
<select
|
||||
value={draft.category ?? 'secrets'}
|
||||
onChange={(e) => patch({ category: e.target.value })}
|
||||
className={inputCls}
|
||||
>
|
||||
{CATEGORY_OPTIONS.map((c) => (
|
||||
<option key={c.key} value={c.key}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{row(
|
||||
'Severity',
|
||||
<select
|
||||
value={draft.severity ?? 'medium'}
|
||||
onChange={(e) => patch({ severity: e.target.value as Severity })}
|
||||
className={inputCls}
|
||||
>
|
||||
{SEVERITY_ORDER.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{row(
|
||||
'Confidence',
|
||||
<select
|
||||
value={draft.confidence ?? 'medium'}
|
||||
onChange={(e) => patch({ confidence: e.target.value as DetectRule['confidence'] })}
|
||||
className={inputCls}
|
||||
>
|
||||
{['high', 'medium', 'low'].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{row(
|
||||
'Target',
|
||||
<select
|
||||
value={draft.target ?? 'response_body'}
|
||||
onChange={(e) => patch({ target: e.target.value })}
|
||||
className={inputCls}
|
||||
>
|
||||
{['response_body', 'response_header', 'request_body', 'request_header', 'url'].map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{row(
|
||||
'Grouping',
|
||||
<Tooltip content="How repeated matches collapse: one finding per distinct value, per URL, or per host.">
|
||||
<select
|
||||
value={draft.groupBy ?? 'evidence'}
|
||||
onChange={(e) => patch({ groupBy: e.target.value })}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="evidence">Per distinct value</option>
|
||||
<option value="url">Per URL</option>
|
||||
<option value="host">Per host</option>
|
||||
</select>
|
||||
</Tooltip>
|
||||
)}
|
||||
{row(
|
||||
'Capture group',
|
||||
<Tooltip content="Which submatch becomes the evidence. 0 uses the whole match.">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={draft.captureGroup ?? 0}
|
||||
onChange={(e) => patch({ captureGroup: Number(e.target.value) })}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{row(
|
||||
'Min entropy',
|
||||
<Tooltip content="Rejects matches below this Shannon entropy in bits per character. Useful for generic patterns; leave at 0 for structurally distinctive ones.">
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min={0}
|
||||
value={draft.minEntropy ?? 0}
|
||||
onChange={(e) => patch({ minEntropy: Number(e.target.value) })}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<label className="flex items-center gap-1.5 text-xs text-content-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-accent"
|
||||
checked={Boolean(draft.redactEvidence)}
|
||||
onChange={(e) => patch({ redactEvidence: e.target.checked })}
|
||||
/>
|
||||
Redact the matched value in stored evidence
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] text-content-muted uppercase tracking-wide mb-1">Pattern</div>
|
||||
<textarea
|
||||
value={draft.pattern ?? ''}
|
||||
onChange={(e) => patch({ pattern: e.target.value })}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
className="w-full bg-surface-input text-xs font-mono px-2 py-1 border border-border rounded-sm"
|
||||
/>
|
||||
{localRegexError && (
|
||||
<div className="text-semantic-error text-[10px]">{localRegexError}</div>
|
||||
)}
|
||||
<div className="text-[10px] text-content-muted mt-0.5">
|
||||
Client-side preview only — the backend uses Go RE2, which rejects lookahead,
|
||||
lookbehind, and backreferences. Save or Test to validate for real.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] text-content-muted uppercase tracking-wide mb-1">
|
||||
Test sample
|
||||
</div>
|
||||
<textarea
|
||||
value={testSample}
|
||||
onChange={(e) => setTestSample(e.target.value)}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
placeholder="Paste a response body to test the pattern against"
|
||||
className="w-full bg-surface-input text-xs font-mono px-2 py-1 border border-border rounded-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={onTest}
|
||||
className="text-xs px-2 py-1 mt-1 rounded-sm bg-surface-input text-content-secondary hover:bg-surface-hover"
|
||||
>
|
||||
Test pattern
|
||||
</button>
|
||||
{testResult && (
|
||||
<div className="mt-1 text-[11px]">
|
||||
{!testResult.valid ? (
|
||||
<span className="text-semantic-error">{testResult.error}</span>
|
||||
) : testResult.matches && testResult.matches.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{testResult.matches.map((m, i) => (
|
||||
<div key={i} className="flex items-center gap-2 font-mono">
|
||||
<span className={m.passes ? 'text-semantic-success' : 'text-content-muted'}>
|
||||
{m.passes ? 'match' : 'filtered'}
|
||||
</span>
|
||||
<span className="text-content-secondary truncate">{m.match}</span>
|
||||
<span className="text-content-muted">@{m.offset}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-content-muted">Pattern is valid, no matches in sample.</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -91,6 +91,14 @@ div:has(> .cm-editor) {
|
||||
::-webkit-scrollbar-thumb { background: var(--color-content-muted); border-radius: 9999px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--color-surface-hover); }
|
||||
|
||||
/* Detection evidence highlight in CodeMirror */
|
||||
.cm-detect-match {
|
||||
background: var(--color-semantic-error-bg);
|
||||
color: #fff;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
/* FUZZ keyword highlighting in CodeMirror (per-position colors) */
|
||||
.cm-fuzz-keyword,
|
||||
.cm-fuzz-pos-0 {
|
||||
|
||||
+109
-1
@@ -2,6 +2,14 @@ import type { CallbackInteraction, CallbackToken } from '../stores/callbackStore
|
||||
import type { ChatMessage, ActiveUser } from '../stores/teamStore'
|
||||
import type { FlaggedSummary, FlaggedRequest } from '../stores/teamFlaggedStore'
|
||||
import type { SharedConfigSummary, SharedConfig, SharedConfigPayload } from '../stores/teamSharedConfigStore'
|
||||
import type {
|
||||
Finding,
|
||||
FindingOccurrence,
|
||||
DetectRule,
|
||||
DetectConfig,
|
||||
DetectSummary,
|
||||
ScanState,
|
||||
} from '../stores/detectStore'
|
||||
|
||||
export interface CollabRequest {
|
||||
id: string
|
||||
@@ -20,6 +28,7 @@ export interface ProjectMeta {
|
||||
sizeBytes: number
|
||||
requestCount: number
|
||||
noteCount: number
|
||||
findingCount: number
|
||||
autoSave: boolean
|
||||
saveHistory: boolean
|
||||
active: boolean
|
||||
@@ -594,10 +603,13 @@ export const api = {
|
||||
getCollab: (id: string) => req<CollabRequest>('GET', `/team/collab/${id}`),
|
||||
acceptCollab: (id: string) => req<{ status: string }>('POST', `/team/collab/${id}/accept`, {}),
|
||||
gatherCurrentRules: async (): Promise<SharedConfigPayload> => {
|
||||
const [scope, replace, custom] = await Promise.all([
|
||||
const [scope, replace, custom, detect] = await Promise.all([
|
||||
api.getScope(),
|
||||
api.getReplace(),
|
||||
api.getCustomData(),
|
||||
// Detection is unavailable in listener mode, so a failure here must not
|
||||
// block the bundle.
|
||||
api.listDetectRules().catch(() => null),
|
||||
])
|
||||
return {
|
||||
scopeEnabled: scope.enabled,
|
||||
@@ -606,6 +618,10 @@ export const api = {
|
||||
replaceRules: replace.rules.map(({ target, matchType, match, replace }) => ({ target, matchType, match, replace })),
|
||||
customDataEnabled: custom.enabled,
|
||||
customDataItems: custom.items.map(({ type, name, value }) => ({ type, name, value })),
|
||||
// Only custom rules travel; built-ins exist on every install.
|
||||
detectRules: (detect?.rules ?? [])
|
||||
.filter((r) => !r.builtin)
|
||||
.map((r) => ({ ...r, id: undefined })),
|
||||
}
|
||||
},
|
||||
|
||||
@@ -634,6 +650,98 @@ export const api = {
|
||||
return req<unknown>('DELETE', `/plugin/${plugin}/interact/interactions${qs}`)
|
||||
},
|
||||
|
||||
// Detect (passive vulnerability detection)
|
||||
getDetect: () =>
|
||||
req<{
|
||||
enabled: boolean
|
||||
config: DetectConfig
|
||||
summary: DetectSummary
|
||||
scan: ScanState
|
||||
cursor: number
|
||||
ruleCount: number
|
||||
activeRules: number
|
||||
}>('GET', '/detect'),
|
||||
setDetectEnabled: (enabled: boolean) =>
|
||||
req<{ enabled: boolean }>('PUT', '/detect/enabled', { enabled }),
|
||||
getDetectConfig: () => req<DetectConfig>('GET', '/detect/config'),
|
||||
updateDetectConfig: (patch: Partial<DetectConfig>) =>
|
||||
req<DetectConfig>('PUT', '/detect/config', patch),
|
||||
|
||||
listFindings: (params: Record<string, string | number>) => {
|
||||
const qs = new URLSearchParams(
|
||||
Object.entries(params)
|
||||
.filter(([, v]) => v !== '' && v !== 0)
|
||||
.map(([k, v]) => [k, String(v)])
|
||||
).toString()
|
||||
return req<{ items: Finding[]; total: number; offset: number; limit: number }>(
|
||||
'GET',
|
||||
`/detect/findings${qs ? `?${qs}` : ''}`
|
||||
)
|
||||
},
|
||||
getFinding: (id: string) =>
|
||||
req<{
|
||||
finding: Finding
|
||||
notes: string
|
||||
occurrences: FindingOccurrence[]
|
||||
rule: DetectRule | null
|
||||
}>('GET', `/detect/findings/${id}`),
|
||||
updateFinding: (
|
||||
id: string,
|
||||
patch: { falsePositive?: boolean; notes?: string; severity?: string }
|
||||
) => req<Finding>('PUT', `/detect/findings/${id}`, patch),
|
||||
deleteFinding: (id: string) => req<unknown>('DELETE', `/detect/findings/${id}`),
|
||||
clearFindings: (onlyFalsePositives = false) =>
|
||||
req<{ deleted: number }>(
|
||||
'DELETE',
|
||||
`/detect/findings${onlyFalsePositives ? '?fp=true' : ''}`
|
||||
),
|
||||
|
||||
listDetectRules: () =>
|
||||
req<{
|
||||
rules: DetectRule[]
|
||||
builtinCount: number
|
||||
userCount: number
|
||||
activeCount: number
|
||||
categories: string[]
|
||||
postFilters: string[]
|
||||
}>('GET', '/detect/rules'),
|
||||
addDetectRule: (rule: Partial<DetectRule>) =>
|
||||
req<DetectRule>('POST', '/detect/rules', rule),
|
||||
updateDetectRule: (id: string, rule: Partial<DetectRule>) =>
|
||||
req<DetectRule>('PUT', `/detect/rules/${id}`, rule),
|
||||
deleteDetectRule: (id: string) => req<unknown>('DELETE', `/detect/rules/${id}`),
|
||||
setDetectRuleEnabled: (id: string, enabled: boolean) =>
|
||||
req<unknown>('PUT', `/detect/rules/${id}/enabled`, { enabled }),
|
||||
setDetectRuleSeverity: (id: string, severity: string) =>
|
||||
req<unknown>('PUT', `/detect/rules/${id}/severity`, { severity }),
|
||||
resetDetectRule: (id: string) => req<DetectRule>('POST', `/detect/rules/${id}/reset`),
|
||||
testDetectRule: (body: {
|
||||
pattern: string
|
||||
sample: string
|
||||
captureGroup?: number
|
||||
minEntropy?: number
|
||||
minLength?: number
|
||||
}) =>
|
||||
req<{
|
||||
valid: boolean
|
||||
error?: string
|
||||
groups?: number
|
||||
truncated?: boolean
|
||||
matches?: {
|
||||
match: string
|
||||
redacted: string
|
||||
offset: number
|
||||
length: number
|
||||
entropy: number
|
||||
passes: boolean
|
||||
}[]
|
||||
}>('POST', '/detect/rules/test', body),
|
||||
|
||||
startDetectScan: (opts?: { scope?: string; host?: string; purge?: boolean }) =>
|
||||
req<ScanState>('POST', '/detect/scan', opts ?? {}),
|
||||
getDetectScan: () => req<ScanState>('GET', '/detect/scan'),
|
||||
cancelDetectScan: () => req<{ status: string }>('POST', '/detect/scan/cancel'),
|
||||
|
||||
// Highlights
|
||||
getHighlights: () => req<{ highlights: Record<string, string> }>('GET', '/highlights'),
|
||||
setHighlight: (id: string, color: string) => req<unknown>('PUT', `/highlights/${id}`, { color }),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api } from './api'
|
||||
import { useDetectStore } from '../stores/detectStore'
|
||||
import { useRequestStore } from '../stores/requestStore'
|
||||
import { useSettingsStore, type Settings } from '../stores/settingsStore'
|
||||
|
||||
@@ -9,6 +10,10 @@ import { useSettingsStore, type Settings } from '../stores/settingsStore'
|
||||
// by those pages when they next mount, so it isn't touched here.
|
||||
export function applyProjectResp(_resp: unknown): void {
|
||||
useRequestStore.getState().invalidate()
|
||||
// Findings live in the project file, so a switch or import drops the previous
|
||||
// project's findings.
|
||||
useDetectStore.getState().clearAll()
|
||||
useDetectStore.getState().invalidate()
|
||||
api
|
||||
.getSettings()
|
||||
.then((s) => useSettingsStore.getState().setSettings(s as Settings))
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Finding, DetectFilter } from '../stores/detectStore'
|
||||
import { type Severity } from './severity'
|
||||
|
||||
// Client-side filter predicate for live-streamed findings.
|
||||
//
|
||||
// A detect.finding payload carries every field the filter bar filters on, so
|
||||
// live inserts are gated on the full filter and the row count agrees with the
|
||||
// server's total. Do not add a dimension the WebSocket payload does not carry.
|
||||
|
||||
export const CATEGORY_OPTIONS = [
|
||||
{ key: 'secrets', label: 'Secrets', title: 'API keys, tokens, private keys' },
|
||||
{ key: 'credentials', label: 'Credentials', title: 'Auth material, connection strings, credential files' },
|
||||
{ key: 'pii', label: 'PII', title: 'Personal data' },
|
||||
{ key: 'access', label: 'Access', title: 'Login forms, admin panels, management consoles' },
|
||||
{ key: 'disclosure', label: 'Disclosure', title: 'Stack traces, versions, internal paths, exposed files' },
|
||||
{ key: 'headers', label: 'Headers', title: 'Security header and CORS issues' },
|
||||
{ key: 'cookies', label: 'Cookies', title: 'Cookie flag issues' },
|
||||
]
|
||||
|
||||
export const TARGET_OPTIONS = [
|
||||
{ key: 'response_body', label: 'Response body' },
|
||||
{ key: 'response_header', label: 'Response header' },
|
||||
{ key: 'request_body', label: 'Request body' },
|
||||
{ key: 'request_header', label: 'Request header' },
|
||||
{ key: 'url', label: 'URL' },
|
||||
{ key: 'message', label: 'Analyzer' },
|
||||
]
|
||||
|
||||
// matchesFindingFilter reports whether a finding belongs in the current view.
|
||||
export function matchesFindingFilter(f: Finding, filter: DetectFilter): boolean {
|
||||
// False-positive handling mirrors the server: hidden unless asked for.
|
||||
if (!filter.showFalsePositives && f.falsePositive) return false
|
||||
|
||||
// severities is the visible set; empty means no severity filter at all.
|
||||
if (filter.severities.length > 0 && !filter.severities.includes(f.severity as Severity)) {
|
||||
return false
|
||||
}
|
||||
if (filter.categories.length > 0 && !filter.categories.includes(f.category)) return false
|
||||
if (filter.confidence && f.confidence !== filter.confidence) return false
|
||||
|
||||
if (filter.rule) {
|
||||
const needle = filter.rule.toLowerCase()
|
||||
if (
|
||||
!f.ruleId.toLowerCase().includes(needle) &&
|
||||
!f.ruleName.toLowerCase().includes(needle)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (filter.host && !f.host.toLowerCase().includes(filter.host.toLowerCase())) return false
|
||||
|
||||
if (filter.search) {
|
||||
const needle = filter.search.toLowerCase()
|
||||
if (
|
||||
!(f.evidence ?? '').toLowerCase().includes(needle) &&
|
||||
!(f.url ?? '').toLowerCase().includes(needle) &&
|
||||
!f.ruleName.toLowerCase().includes(needle) &&
|
||||
!(f.detail ?? '').toLowerCase().includes(needle)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// buildFindingQuery converts the filter into query params for the list endpoint.
|
||||
// The shared query-string builder in api.ts drops '' and 0, so booleans are sent
|
||||
// as the string 'true' or omitted, and the server reads absence as false.
|
||||
export function buildFindingQuery(
|
||||
filter: DetectFilter,
|
||||
sort: string,
|
||||
dir: string
|
||||
): Record<string, string | number> {
|
||||
return {
|
||||
// Omitted when empty, so "no selection" means every band on the wire too.
|
||||
// minSeverity is never sent; severity would override it at the endpoint.
|
||||
...(filter.severities.length > 0 && { severity: filter.severities.join(',') }),
|
||||
...(filter.categories.length > 0 && { category: filter.categories.join(',') }),
|
||||
...(filter.confidence && { confidence: filter.confidence }),
|
||||
...(filter.rule && { ruleId: filter.rule }),
|
||||
...(filter.host && { host: filter.host }),
|
||||
...(filter.search && { search: filter.search }),
|
||||
...(filter.showFalsePositives && { fp: 'all' }),
|
||||
...(filter.includeDisabled && { includeDisabled: 'true' }),
|
||||
sort,
|
||||
dir,
|
||||
offset: filter.offset,
|
||||
// Stringified: the shared query builder drops the number 0, and limit=0
|
||||
// means "all" rather than the server's default page size.
|
||||
limit: String(filter.limit),
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export const NAV: NavEntry[] = [
|
||||
{ to: '/dashboard', label: 'Dashboard' },
|
||||
{ to: '/map', label: 'Map', proxyOnly: true },
|
||||
{ to: '/history', label: 'History', proxyOnly: true },
|
||||
{ to: '/detect', label: 'Detect', proxyOnly: true },
|
||||
{ to: '/intercept', label: 'Intercept', proxyOnly: true },
|
||||
{ to: '/manipulate', label: 'Manipulate', proxyOnly: true },
|
||||
{ to: '/fuzz', label: 'Fuzz', proxyOnly: true },
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
// Severity and category rendering for detection findings, shared by the findings
|
||||
// table, the detail pane, the count bar, and the rules table. Scope is narrow:
|
||||
// only severity, confidence, and category.
|
||||
|
||||
export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info'
|
||||
export type Confidence = 'high' | 'medium' | 'low'
|
||||
|
||||
export const SEVERITY_ORDER: Severity[] = ['critical', 'high', 'medium', 'low', 'info']
|
||||
|
||||
export const SEVERITY_RANK: Record<Severity, number> = {
|
||||
critical: 5,
|
||||
high: 4,
|
||||
medium: 3,
|
||||
low: 2,
|
||||
info: 1,
|
||||
}
|
||||
|
||||
export const SEVERITY_OPTIONS = [
|
||||
{
|
||||
key: 'critical',
|
||||
label: 'Critical',
|
||||
title:
|
||||
'High-grade PII (national ID or payment card), or anything that alone leads to severe compromise: RCE, auth bypass, a served database dump',
|
||||
},
|
||||
{
|
||||
key: 'high',
|
||||
label: 'High',
|
||||
title: 'Account credentials, sensitive API keys, database connection strings, low-level PII',
|
||||
},
|
||||
{ key: 'medium', label: 'Medium', title: 'Anything not covered by the other bands' },
|
||||
{ key: 'low', label: 'Low', title: 'An exposed configuration file or similar' },
|
||||
{
|
||||
key: 'info',
|
||||
label: 'Info',
|
||||
title:
|
||||
'Not directly exploitable on its own: exposed panels, missing headers, information disclosure, analytics keys',
|
||||
},
|
||||
]
|
||||
|
||||
export const CONFIDENCE_OPTIONS = [
|
||||
{ key: 'high', label: 'High' },
|
||||
{ key: 'medium', label: 'Medium' },
|
||||
{ key: 'low', label: 'Low' },
|
||||
]
|
||||
|
||||
// SEVERITY_CLS maps the five levels onto the semantic palette. Unusable tokens:
|
||||
// `semantic-success` collides with `semantic-info` or `accent-tertiary` depending
|
||||
// on the theme, `semantic-special` collides with `semantic-error` in five themes,
|
||||
// and the three accent tokens are reserved for selection state and primary
|
||||
// actions. Critical is the only background fill, so it stays distinct from High
|
||||
// in themes where the error and warning hues are close.
|
||||
const SEVERITY_CLS: Record<Severity, string> = {
|
||||
critical: 'bg-semantic-error-bg text-content-primary',
|
||||
high: 'bg-surface-input text-semantic-error',
|
||||
medium: 'bg-surface-input text-semantic-warning',
|
||||
low: 'bg-surface-input text-semantic-info',
|
||||
info: 'bg-surface-input text-content-muted',
|
||||
}
|
||||
|
||||
const SEVERITY_LABEL: Record<Severity, string> = {
|
||||
critical: 'Crit',
|
||||
high: 'High',
|
||||
medium: 'Med',
|
||||
low: 'Low',
|
||||
info: 'Info',
|
||||
}
|
||||
|
||||
const BADGE_BASE =
|
||||
'inline-block px-1 py-px rounded-sm text-[10px] font-bold uppercase tracking-wide align-middle'
|
||||
|
||||
// severityBadge renders a severity pill.
|
||||
export function severityBadge(sev: string): ReactNode {
|
||||
const key = (sev || 'info') as Severity
|
||||
const cls = SEVERITY_CLS[key] ?? SEVERITY_CLS.info
|
||||
const label = SEVERITY_LABEL[key] ?? sev
|
||||
return <span className={`${BADGE_BASE} ${cls}`}>{label}</span>
|
||||
}
|
||||
|
||||
// severityTextClass returns just the text colour, for icons and inline text.
|
||||
export function severityTextClass(sev: string): string {
|
||||
switch (sev as Severity) {
|
||||
case 'critical':
|
||||
return 'text-semantic-error'
|
||||
case 'high':
|
||||
return 'text-semantic-error'
|
||||
case 'medium':
|
||||
return 'text-semantic-warning'
|
||||
case 'low':
|
||||
return 'text-semantic-info'
|
||||
default:
|
||||
return 'text-content-muted'
|
||||
}
|
||||
}
|
||||
|
||||
export const CATEGORY_LABEL: Record<string, string> = {
|
||||
secrets: 'Secrets',
|
||||
credentials: 'Creds',
|
||||
pii: 'PII',
|
||||
access: 'Access',
|
||||
disclosure: 'Disclosure',
|
||||
headers: 'Headers',
|
||||
cookies: 'Cookies',
|
||||
}
|
||||
|
||||
// categoryPill renders a category as one neutral pill. The seven categories are
|
||||
// not colour-coded: four of the seven semantic tokens are already spoken for.
|
||||
export function categoryPill(cat: string): ReactNode {
|
||||
return (
|
||||
<span
|
||||
className={`${BADGE_BASE} bg-surface-input text-accent-secondary normal-case font-semibold`}
|
||||
>
|
||||
{CATEGORY_LABEL[cat] ?? cat}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// maxSeverity returns the highest severity in a list.
|
||||
export function maxSeverity(list: string[]): Severity {
|
||||
let best: Severity = 'info'
|
||||
for (const s of list) {
|
||||
const key = s as Severity
|
||||
if ((SEVERITY_RANK[key] ?? 0) > (SEVERITY_RANK[best] ?? 0)) best = key
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallbackStore, type CallbackInteraction } from '../stores/callbackStore'
|
||||
import { useDetectStore, type Finding, type DetectSummary } from '../stores/detectStore'
|
||||
import { useFuzzStore, type FuzzResult } from '../stores/fuzzStore'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
import { useInterceptStore } from '../stores/interceptStore'
|
||||
@@ -53,6 +54,31 @@ function flushFuzzResultBuffer() {
|
||||
}
|
||||
}
|
||||
|
||||
// Findings are RAF-batched, as request captures are: each upsert re-sorts the
|
||||
// list and rebuilds an id map, and a rescan emits them in a tight burst.
|
||||
let findingBuffer: Finding[] = []
|
||||
let findingRafScheduled = false
|
||||
|
||||
function flushFindingBuffer() {
|
||||
findingRafScheduled = false
|
||||
if (findingBuffer.length === 0) return
|
||||
const batch = findingBuffer
|
||||
findingBuffer = []
|
||||
useDetectStore.getState().upsertFindings(batch)
|
||||
}
|
||||
|
||||
// Scan progress is coalesced to the newest value per frame; a rescan reports
|
||||
// faster than the UI can paint.
|
||||
let pendingScanProgress: { scanned: number; total: number; findingsNew: number } | null = null
|
||||
let scanProgressRafScheduled = false
|
||||
|
||||
function flushScanProgress() {
|
||||
scanProgressRafScheduled = false
|
||||
if (!pendingScanProgress) return
|
||||
useDetectStore.getState().setScan(pendingScanProgress)
|
||||
pendingScanProgress = null
|
||||
}
|
||||
|
||||
export function connectWS() {
|
||||
if (ws && ws.readyState < WebSocket.CLOSING) return
|
||||
|
||||
@@ -246,6 +272,73 @@ function handleMessage(msg: WSMessage) {
|
||||
useFuzzStore.getState().setCampaignStatus(d.campaignId, d.status === 'stopped' ? 'stopped' : 'completed')
|
||||
break
|
||||
}
|
||||
case 'detect.finding': {
|
||||
const d = msg.data as { finding: Finding; isNew: boolean }
|
||||
findingBuffer.push(d.finding)
|
||||
if (!findingRafScheduled) {
|
||||
findingRafScheduled = true
|
||||
requestAnimationFrame(flushFindingBuffer)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'detect.summary': {
|
||||
useDetectStore.getState().setSummary(msg.data as DetectSummary)
|
||||
break
|
||||
}
|
||||
case 'detect.scan.started': {
|
||||
const d = msg.data as { jobId: string; kind: string; total: number }
|
||||
useDetectStore.getState().setScan({
|
||||
running: true,
|
||||
jobId: d.jobId,
|
||||
kind: d.kind,
|
||||
scanned: 0,
|
||||
total: d.total,
|
||||
findingsNew: 0,
|
||||
status: 'running',
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'detect.scan.progress': {
|
||||
const d = msg.data as { scanned: number; total: number; findingsNew: number }
|
||||
pendingScanProgress = { scanned: d.scanned, total: d.total, findingsNew: d.findingsNew }
|
||||
if (!scanProgressRafScheduled) {
|
||||
scanProgressRafScheduled = true
|
||||
requestAnimationFrame(flushScanProgress)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'detect.scan.complete': {
|
||||
const d = msg.data as {
|
||||
status: string
|
||||
scanned: number
|
||||
findingsNew: number
|
||||
}
|
||||
useDetectStore.getState().setScan({
|
||||
running: false,
|
||||
scanned: d.scanned,
|
||||
findingsNew: d.findingsNew,
|
||||
status: d.status,
|
||||
})
|
||||
// Completion is toasted; the start is not, since the operator clicked it.
|
||||
useToastStore
|
||||
.getState()
|
||||
.addToast(
|
||||
`Detection scan ${d.status} — ${d.findingsNew} new finding${d.findingsNew === 1 ? '' : 's'}`,
|
||||
'info'
|
||||
)
|
||||
// Per-finding events are suppressed during a rescan, so reload the table.
|
||||
useDetectStore.getState().invalidate()
|
||||
break
|
||||
}
|
||||
case 'detect.findings.cleared': {
|
||||
useDetectStore.getState().clearAll()
|
||||
break
|
||||
}
|
||||
case 'detect.rules.changed': {
|
||||
// Force the rules view to refetch on next mount.
|
||||
useDetectStore.setState({ rulesLoaded: false })
|
||||
break
|
||||
}
|
||||
default:
|
||||
if (msg.type.startsWith('plugin.')) {
|
||||
pluginEventListeners.forEach((fn) => fn(msg))
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,481 @@
|
||||
import { create } from 'zustand'
|
||||
import { api } from '../lib/api'
|
||||
import { SEVERITY_RANK, type Severity, type Confidence } from '../lib/severity'
|
||||
import { matchesFindingFilter } from '../lib/detectFilters'
|
||||
|
||||
export interface Finding {
|
||||
// id is the server's deterministic group hash. Re-emission for a known group
|
||||
// arrives with the same id and a bumped count, so the live merge below is a
|
||||
// plain map upsert.
|
||||
id: string
|
||||
ruleId: string
|
||||
ruleName: string
|
||||
category: string
|
||||
severity: Severity
|
||||
confidence: Confidence
|
||||
target: string
|
||||
host: string
|
||||
method: string
|
||||
url: string
|
||||
requestId: string
|
||||
detail?: string
|
||||
evidence: string
|
||||
// Present only for rules that redact. The real value travels with the finding,
|
||||
// so revealing it is a local toggle rather than a fetch.
|
||||
rawEvidence?: string
|
||||
evidenceOffset: number
|
||||
evidenceLength: number
|
||||
evidencePart?: string
|
||||
count: number
|
||||
firstSeen: string
|
||||
lastSeen: string
|
||||
falsePositive: boolean
|
||||
hasNotes: boolean
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
export interface FindingOccurrence {
|
||||
requestId: string
|
||||
seq: number
|
||||
method: string
|
||||
url: string
|
||||
statusCode: number
|
||||
timestamp: string
|
||||
offset: number
|
||||
part: string
|
||||
requestPresent: boolean
|
||||
}
|
||||
|
||||
export interface DetectRule {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
remediation?: string
|
||||
kind: 'regex' | 'analyzer'
|
||||
category: string
|
||||
severity: Severity
|
||||
confidence: Confidence
|
||||
target: string
|
||||
pattern?: string
|
||||
literal?: string
|
||||
captureGroup?: number
|
||||
postFilters?: string[]
|
||||
analyzer?: string
|
||||
groupBy?: string
|
||||
contentTypes?: string[]
|
||||
statusCodes?: string
|
||||
scheme?: string
|
||||
minLength?: number
|
||||
minEntropy?: number
|
||||
maxPerResponse?: number
|
||||
redactEvidence?: boolean
|
||||
builtin: boolean
|
||||
enabled: boolean
|
||||
findingCount?: number
|
||||
}
|
||||
|
||||
export interface DetectConfig {
|
||||
scopeOnly: boolean
|
||||
scanRequests: boolean
|
||||
persistFindings: boolean
|
||||
clearFindingsWithHistory: boolean
|
||||
maxBodyScanBytes: number
|
||||
maxRequestBodyScanBytes: number
|
||||
skipContentTypes: string[]
|
||||
skipExtensions: string[]
|
||||
excludeHosts: string[]
|
||||
}
|
||||
|
||||
export interface DetectSummary {
|
||||
total: number
|
||||
bySeverity: Record<string, number>
|
||||
byCategory: Record<string, number>
|
||||
falsePositives: number
|
||||
hiddenByDisabledRule: number
|
||||
skippedEncoded: number
|
||||
skippedBinary: number
|
||||
scanned: number
|
||||
}
|
||||
|
||||
export interface ScanState {
|
||||
running: boolean
|
||||
jobId: string
|
||||
kind?: string
|
||||
scanned: number
|
||||
total: number
|
||||
findingsNew: number
|
||||
startedAt?: string
|
||||
finishedAt?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface DetectFilter {
|
||||
severities: Severity[]
|
||||
categories: string[]
|
||||
confidence: string
|
||||
rule: string
|
||||
host: string
|
||||
search: string
|
||||
showFalsePositives: boolean
|
||||
includeDisabled: boolean
|
||||
offset: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
export const emptyDetectFilter: DetectFilter = {
|
||||
// severities is the set of bands the table shows. An EMPTY array means no
|
||||
// severity filter at all, matching MultiSelectDropdown's "Any"/Clear and the
|
||||
// server's absent severity param.
|
||||
//
|
||||
// The default lists the four non-Info bands. Info rules still run and their
|
||||
// findings are stored, counted, and persisted; only the view narrows. Not
|
||||
// persisted, so every session starts here.
|
||||
severities: ['critical', 'high', 'medium', 'low'],
|
||||
categories: [],
|
||||
confidence: '',
|
||||
rule: '',
|
||||
host: '',
|
||||
search: '',
|
||||
showFalsePositives: false,
|
||||
includeDisabled: false,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
}
|
||||
|
||||
export type FindingSortColumn =
|
||||
| 'severity'
|
||||
| 'rule'
|
||||
| 'category'
|
||||
| 'host'
|
||||
| 'url'
|
||||
| 'count'
|
||||
| 'lastSeen'
|
||||
export type SortDir = 'asc' | 'desc'
|
||||
|
||||
// MAX_ITEMS caps the rendered list. The table is not virtualized and limit=0
|
||||
// ("All") is reachable from the UI.
|
||||
const MAX_ITEMS = 5000
|
||||
|
||||
const SORT_KEY = 'joro-detect-sort'
|
||||
|
||||
function loadSort(): { column: FindingSortColumn; dir: SortDir } {
|
||||
try {
|
||||
const raw = localStorage.getItem(SORT_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as { column: FindingSortColumn; dir: SortDir }
|
||||
if (parsed.column && parsed.dir) return parsed
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed storage and fall through to the default.
|
||||
}
|
||||
return { column: 'severity', dir: 'desc' }
|
||||
}
|
||||
|
||||
function persistSort(column: FindingSortColumn, dir: SortDir) {
|
||||
try {
|
||||
localStorage.setItem(SORT_KEY, JSON.stringify({ column, dir }))
|
||||
} catch {
|
||||
// Storage is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
// sortFindings orders the list in place. Every mutation calls it, so render
|
||||
// order cannot drift from the sort state. See requestStore's sort helper.
|
||||
function sortFindings(items: Finding[], col: FindingSortColumn, dir: SortDir): Finding[] {
|
||||
const mul = dir === 'asc' ? 1 : -1
|
||||
items.sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (col) {
|
||||
case 'severity':
|
||||
cmp = (SEVERITY_RANK[a.severity] ?? 0) - (SEVERITY_RANK[b.severity] ?? 0)
|
||||
break
|
||||
case 'count':
|
||||
cmp = a.count - b.count
|
||||
break
|
||||
case 'lastSeen':
|
||||
cmp = (a.lastSeen || '').localeCompare(b.lastSeen || '')
|
||||
break
|
||||
case 'rule':
|
||||
cmp = a.ruleName.localeCompare(b.ruleName)
|
||||
break
|
||||
default:
|
||||
cmp = String(a[col] ?? '').localeCompare(String(b[col] ?? ''))
|
||||
}
|
||||
if (cmp === 0) {
|
||||
// Deterministic tiebreak, so equal rows never reorder between renders.
|
||||
cmp =
|
||||
(SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0) ||
|
||||
a.id.localeCompare(b.id)
|
||||
return cmp
|
||||
}
|
||||
return cmp * mul
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
const emptySummary: DetectSummary = {
|
||||
total: 0,
|
||||
bySeverity: {},
|
||||
byCategory: {},
|
||||
falsePositives: 0,
|
||||
hiddenByDisabledRule: 0,
|
||||
skippedEncoded: 0,
|
||||
skippedBinary: 0,
|
||||
scanned: 0,
|
||||
}
|
||||
|
||||
const emptyScan: ScanState = {
|
||||
running: false,
|
||||
jobId: '',
|
||||
scanned: 0,
|
||||
total: 0,
|
||||
findingsNew: 0,
|
||||
}
|
||||
|
||||
interface DetectState {
|
||||
enabled: boolean
|
||||
items: Finding[]
|
||||
total: number
|
||||
truncated: boolean
|
||||
loading: boolean
|
||||
selected: Finding | null
|
||||
occurrences: FindingOccurrence[]
|
||||
selectedRule: DetectRule | null
|
||||
selectedNotes: string
|
||||
filter: DetectFilter
|
||||
sortColumn: FindingSortColumn
|
||||
sortDir: SortDir
|
||||
reloadCounter: number
|
||||
livePaused: boolean
|
||||
pending: Finding[]
|
||||
summary: DetectSummary
|
||||
scan: ScanState
|
||||
rules: DetectRule[]
|
||||
rulesLoaded: boolean
|
||||
config: DetectConfig | null
|
||||
|
||||
setEnabled: (v: boolean) => void
|
||||
setItems: (items: Finding[], total: number) => void
|
||||
upsertFindings: (batch: Finding[]) => void
|
||||
setSelected: (f: Finding | null) => void
|
||||
setSelectedDetail: (
|
||||
occurrences: FindingOccurrence[],
|
||||
rule: DetectRule | null,
|
||||
notes: string
|
||||
) => void
|
||||
setFilter: (patch: Partial<DetectFilter>) => void
|
||||
setSort: (col: FindingSortColumn) => void
|
||||
setLoading: (v: boolean) => void
|
||||
setSummary: (s: DetectSummary) => void
|
||||
setScan: (patch: Partial<ScanState>) => void
|
||||
invalidate: () => void
|
||||
clearAll: () => void
|
||||
togglePaused: () => void
|
||||
flushPending: () => void
|
||||
markFalsePositive: (id: string, v: boolean) => void
|
||||
setNotes: (id: string, notes: string) => void
|
||||
overrideSeverity: (id: string, sev: Severity) => void
|
||||
removeFinding: (id: string) => void
|
||||
setRules: (rules: DetectRule[]) => void
|
||||
setRuleEnabled: (id: string, enabled: boolean) => void
|
||||
setConfig: (c: DetectConfig) => void
|
||||
}
|
||||
|
||||
const initialSort = loadSort()
|
||||
|
||||
export const useDetectStore = create<DetectState>((set, get) => ({
|
||||
enabled: true,
|
||||
items: [],
|
||||
total: 0,
|
||||
truncated: false,
|
||||
loading: false,
|
||||
selected: null,
|
||||
occurrences: [],
|
||||
selectedRule: null,
|
||||
selectedNotes: '',
|
||||
filter: { ...emptyDetectFilter },
|
||||
sortColumn: initialSort.column,
|
||||
sortDir: initialSort.dir,
|
||||
reloadCounter: 0,
|
||||
livePaused: false,
|
||||
pending: [],
|
||||
summary: emptySummary,
|
||||
scan: { ...emptyScan },
|
||||
rules: [],
|
||||
rulesLoaded: false,
|
||||
config: null,
|
||||
|
||||
setEnabled: (v) => set({ enabled: v }),
|
||||
|
||||
setItems: (items, total) =>
|
||||
set((s) => ({
|
||||
items: sortFindings([...items], s.sortColumn, s.sortDir).slice(0, MAX_ITEMS),
|
||||
total,
|
||||
truncated: items.length > MAX_ITEMS,
|
||||
})),
|
||||
|
||||
// upsertFindings merges live findings. The server's finding id is a
|
||||
// deterministic group hash, so `total` increments only for new ids; an
|
||||
// occurrence bump changes `count`, never `total`.
|
||||
upsertFindings: (batch) =>
|
||||
set((s) => {
|
||||
if (s.livePaused) {
|
||||
return { pending: [...s.pending, ...batch] }
|
||||
}
|
||||
const idx = new Map(s.items.map((f, i) => [f.id, i]))
|
||||
const next = [...s.items]
|
||||
let total = s.total
|
||||
let changed = false
|
||||
let selected = s.selected
|
||||
|
||||
for (const f of batch) {
|
||||
const i = idx.get(f.id)
|
||||
if (i !== undefined) {
|
||||
// Known group: bump only the volatile fields. Never replace wholesale,
|
||||
// since the row may carry an optimistic local false-positive mark.
|
||||
//
|
||||
// The evidence location is one value spread over five fields and they
|
||||
// must be updated together, as Store.Upsert does on the server: a
|
||||
// host-grouped rule merges two different matches.
|
||||
next[i] = {
|
||||
...next[i],
|
||||
count: f.count,
|
||||
lastSeen: f.lastSeen,
|
||||
evidence: f.evidence || next[i].evidence,
|
||||
rawEvidence: f.rawEvidence ?? next[i].rawEvidence,
|
||||
requestId: f.requestId || next[i].requestId,
|
||||
url: f.url || next[i].url,
|
||||
evidenceOffset: f.evidenceOffset,
|
||||
evidenceLength: f.evidenceLength,
|
||||
evidencePart: f.evidencePart,
|
||||
}
|
||||
if (selected?.id === f.id) selected = next[i]
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if (!matchesFindingFilter(f, s.filter)) continue
|
||||
idx.set(f.id, next.length)
|
||||
next.push(f)
|
||||
total += 1
|
||||
changed = true
|
||||
}
|
||||
if (!changed) return s
|
||||
|
||||
sortFindings(next, s.sortColumn, s.sortDir)
|
||||
const truncated = next.length > MAX_ITEMS
|
||||
return {
|
||||
items: truncated ? next.slice(0, MAX_ITEMS) : next,
|
||||
total,
|
||||
selected,
|
||||
truncated: s.truncated || truncated,
|
||||
}
|
||||
}),
|
||||
|
||||
setSelected: (f) =>
|
||||
set({ selected: f, occurrences: [], selectedRule: null, selectedNotes: '' }),
|
||||
|
||||
setSelectedDetail: (occurrences, rule, notes) =>
|
||||
set({ occurrences, selectedRule: rule, selectedNotes: notes }),
|
||||
|
||||
setFilter: (patch) =>
|
||||
set((s) => ({
|
||||
// Any filter change resets pagination; a narrower filter could otherwise
|
||||
// land on an empty page.
|
||||
filter: { ...s.filter, ...patch, ...(patch.offset === undefined && { offset: 0 }) },
|
||||
})),
|
||||
|
||||
setSort: (col) =>
|
||||
set((s) => {
|
||||
const dir: SortDir = s.sortColumn === col && s.sortDir === 'desc' ? 'asc' : 'desc'
|
||||
persistSort(col, dir)
|
||||
return {
|
||||
sortColumn: col,
|
||||
sortDir: dir,
|
||||
items: sortFindings([...s.items], col, dir),
|
||||
}
|
||||
}),
|
||||
|
||||
setLoading: (v) => set({ loading: v }),
|
||||
setSummary: (summary) => set({ summary }),
|
||||
setScan: (patch) => set((s) => ({ scan: { ...s.scan, ...patch } })),
|
||||
|
||||
invalidate: () =>
|
||||
set((s) => ({
|
||||
reloadCounter: s.reloadCounter + 1,
|
||||
selected: null,
|
||||
occurrences: [],
|
||||
selectedRule: null,
|
||||
pending: [],
|
||||
rulesLoaded: false,
|
||||
})),
|
||||
|
||||
clearAll: () =>
|
||||
set({
|
||||
items: [],
|
||||
total: 0,
|
||||
selected: null,
|
||||
occurrences: [],
|
||||
selectedRule: null,
|
||||
pending: [],
|
||||
truncated: false,
|
||||
summary: emptySummary,
|
||||
}),
|
||||
|
||||
togglePaused: () => set((s) => ({ livePaused: !s.livePaused })),
|
||||
|
||||
flushPending: () => {
|
||||
const pending = get().pending
|
||||
if (pending.length === 0) return
|
||||
set({ pending: [] })
|
||||
get().upsertFindings(pending)
|
||||
},
|
||||
|
||||
// Optimistic update, then fire-and-forget. See requestStore.setHighlight.
|
||||
markFalsePositive: (id, v) => {
|
||||
set((s) => ({
|
||||
items: s.items.map((f) => (f.id === id ? { ...f, falsePositive: v } : f)),
|
||||
selected: s.selected?.id === id ? { ...s.selected, falsePositive: v } : s.selected,
|
||||
}))
|
||||
api.updateFinding(id, { falsePositive: v }).catch(() => {})
|
||||
},
|
||||
|
||||
setNotes: (id, notes) => {
|
||||
set((s) => ({
|
||||
items: s.items.map((f) => (f.id === id ? { ...f, hasNotes: notes !== '' } : f)),
|
||||
selectedNotes: s.selected?.id === id ? notes : s.selectedNotes,
|
||||
}))
|
||||
api.updateFinding(id, { notes }).catch(() => {})
|
||||
},
|
||||
|
||||
overrideSeverity: (id, sev) => {
|
||||
set((s) => ({
|
||||
items: sortFindings(
|
||||
s.items.map((f) => (f.id === id ? { ...f, severity: sev } : f)),
|
||||
s.sortColumn,
|
||||
s.sortDir
|
||||
),
|
||||
selected: s.selected?.id === id ? { ...s.selected, severity: sev } : s.selected,
|
||||
}))
|
||||
api.updateFinding(id, { severity: sev }).catch(() => {})
|
||||
},
|
||||
|
||||
removeFinding: (id) => {
|
||||
set((s) => ({
|
||||
items: s.items.filter((f) => f.id !== id),
|
||||
total: Math.max(0, s.total - 1),
|
||||
selected: s.selected?.id === id ? null : s.selected,
|
||||
}))
|
||||
api.deleteFinding(id).catch(() => {})
|
||||
},
|
||||
|
||||
setRules: (rules) => set({ rules, rulesLoaded: true }),
|
||||
|
||||
setRuleEnabled: (id, enabled) => {
|
||||
set((s) => ({
|
||||
rules: s.rules.map((r) => (r.id === id ? { ...r, enabled } : r)),
|
||||
}))
|
||||
api.setDetectRuleEnabled(id, enabled).catch(() => {})
|
||||
},
|
||||
|
||||
setConfig: (config) => set({ config }),
|
||||
}))
|
||||
@@ -22,6 +22,8 @@ export interface SharedConfigPayload {
|
||||
replaceRules: { target: string; matchType: string; match: string; replace: string }[]
|
||||
customDataEnabled: boolean
|
||||
customDataItems: { type: string; name: string; value: string }[]
|
||||
// Custom detection rules travel with the bundle; findings do not.
|
||||
detectRules?: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
interface TeamSharedConfigState {
|
||||
|
||||
Reference in new issue
Block a user