Added basic functionality for passive vulnerability detection

This commit is contained in:
Tony West committed 2026-07-31 22:22:06 -04:00
1 parent dd98f31e0f
commit 211079f02b
32 files changed
+9841 -31

No files matched your search

+50
View File
@@ -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()
}
}
+287
View File
@@ -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:])
}
+201 -26
View File
@@ -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 {
+697
View File
@@ -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"})
}
+4
View File
@@ -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"})
}
+24
View File
@@ -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)
+21
View File
@@ -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
+420
View File
@@ -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"`
}
+821
View File
@@ -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
}
+171
View File
@@ -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
}
+410
View File
@@ -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"
}
+489
View File
@@ -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
}
+938
View File
@@ -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
}
+405
View File
@@ -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
}
+542
View File
@@ -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
}
}
+4 -4
View File
@@ -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
+8
View File
@@ -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
+23
View File
@@ -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) {
+1
View File
@@ -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{})
+2
View File
@@ -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 />} />
+592
View File
@@ -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>
)
}
+8
View File
@@ -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
View File
@@ -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 }),
+5
View File
@@ -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))
+92
View File
@@ -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),
}
}
+1
View File
@@ -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 },
+127
View File
@@ -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
}
+93
View File
@@ -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
+481
View File
@@ -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 }),
}))
+2
View File
@@ -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 {