Documentation
¶
Index ¶
Constants ¶
const ( ModuleID = "surface-scoring" ModuleName = "Attack Surface Scoring" ModuleShort = "Deterministic per-record attack-surface score (0-100) and technology stack" )
Variables ¶
var ( ModuleDesc = `**What it means:** A prioritization signal, not a vulnerability. Each record scores 0-100 as the percentage of the surface signals present, spanning input carried or advertised, method, the host's stack, legacy handlers, session and login surface, non-standard ports, response shape (rich HTML, SPA, JSON, answered), and origin posture (dynamic, permissive CORS, leaked internals, API). **How it's exploited:** No direct exploit. The score lands in ` + "`http_records.surface_score`" + ` so an analyst or agent can rank the corpus by attackable surface, absolute and comparable across hosts and scans. The same pass writes the host's detected stack to ` + "`http_records.technology`" + `. **Fix:** No remediation required.` ModuleConfirmation = "" /* 138-byte string literal not displayed */ ModuleSeverity = severity.Info ModuleConfidence = severity.Certain ModuleTags = []string{"behavior-analysis", "light"} )
Functions ¶
This section is empty.
Types ¶
type Module ¶
type Module struct {
modkit.BasePassiveModule
// contains filtered or unexported fields
}
Module implements a passive module that writes the per-record surface metadata every other view ranks on: http_records.surface_score, and the host's detected stack in http_records.technology.
Scoring is split across two phases on purpose. The record-scoped signals are computed in ScanPerRequest, where the response is in hand. The host-scoped SignalTechStack - and the technology column it comes from - is resolved in Flush, because under ParallelPassive every eligible passive module for one record runs concurrently: reading the tech registry during ScanPerRequest would race the fingerprint module that populates it, and the record that first reveals a host's stack would score itself as if the host had none.
The consequence is that nothing is written until end-of-scan. A scan that is interrupted before the executor's flush leaves scores at 0, which reads correctly as "not scored" rather than as "no surface".
func (*Module) Flush ¶
func (m *Module) Flush(scanCtx *modkit.ScanContext)
Flush implements modules.Flusher. Called once after all workers finish, so every fingerprint module has published its detections by now and the host-scoped tech signal can be resolved without racing them. It is also when each buffered request hash is resolved to a record UUID - the rows are written asynchronously, so most do not exist yet while ScanPerRequest is running.
It writes two columns, both derived from the same host-to-records map: surface_score, and technology.
The technology write lives here rather than in the fingerprint modules themselves because the registry is host-scoped while the column is record-scoped, and only this point in the scan knows both halves: which records belong to which host (buffered during ScanPerRequest) and what the full set of 26 fingerprint modules concluded about that host (settled only once every worker has exited). Doing it in each fingerprint module would mean 26 modules racing to overwrite one column with a partial view.
func (*Module) ScanPerRequest ¶
func (m *Module) ScanPerRequest(ctx *httpmsg.HttpRequestResponse, scanCtx *modkit.ScanContext) ([]*output.ResultEvent, error)
ScanPerRequest computes the record-scoped signals and buffers them against the record's UUID. It emits no findings - the score is record metadata.
type Signal ¶
type Signal uint32
Signal is one bit of attack-surface evidence. The score is the popcount of the set bits scaled onto 0-100, so every signal is worth the same and the score is reproducible from the record alone.
const ( // SignalHasInput - the OBSERVED request carried client-supplied input: a query // string or a body. Attacker-actionable immediately, which is what makes it // the strongest single predictor in the set. SignalHasInput Signal = 1 << iota // SignalAdvertisedInput - the response reveals input surface the observed // request did not exercise: an Allow / Access-Control-Allow-Methods listing a // mutating method, or form markup in the body. // // Its own bit rather than folded into SignalHasInput. A probe sends one bare // GET per host, so without this the input axis would be dead on exactly the // corpus that most needs ranking - but "<form> somewhere on the page" is true // of most HTML documents, and merging the two would have made the strong // predictor near-constant on an HTML corpus. That is the same defect the // SignalAuthBearing and SignalDynamic comments below describe avoiding. SignalAdvertisedInput // SignalStateChanging - a method that mutates (POST/PUT/PATCH/DELETE). // Drives access-control, CSRF and mass-assignment surface. SignalStateChanging // SignalUpload - the request is a file upload (multipart) or the response // offers one (a file input). Its own vulnerability class. SignalUpload // SignalTechStack - the host has at least one detected technology stack. // Host-scoped, so it is resolved at flush rather than per record (see Module). SignalTechStack // SignalLegacyStack - the path ends in a server-executed extension of an // older generation (.php, .jsp, .do, .aspx, .cgi...). These map a file // directly to a handler, predate framework-level defaults, and are what the // Struts/PHP/ASP.NET module families exist for. SignalLegacyStack // SignalAuthBearing - the exchange carries identity: an Authorization header, // or a session cookie in either direction. // // Deliberately NOT "any cookie". A CDN sets __cf_bm and an analytics tag sets // _ga on essentially every response they front, so crediting any Set-Cookie // made this bit near-constant on a CDN-fronted corpus - a constant added to // every score, which changes no ordering and costs a signal slot. SignalAuthBearing // SignalAuthSurface - the exchange advertises an authentication boundary: a // WWW-Authenticate challenge, a password input, or a redirect into an SSO / // OAuth / SAML flow. // // Distinct from SignalAuthBearing, which is about a session already in hand. // A host with accounts is a host with access-control surface, and on a sweep // a login page is the single most informative thing a bare GET can return. SignalAuthSurface // SignalNonStandardPort - the endpoint answers on a port other than 80/443. // On a host list this is disproportionately a dev, admin, or internal service // that was never meant to be reachable. SignalNonStandardPort // SignalRichHTML - an HTML document carrying interactive or navigational // substance (form inputs, or a real set of links). SignalRichHTML // SignalSPA - the response is a single-page-app shell. SignalSPA // SignalJSON - the response body is JSON. SignalJSON // SignalResponsive - the application answered: not a WAF/CDN edge block, and // either a success status or an error verbose enough to leak a stack trace. SignalResponsive // SignalDynamic - the response is origin-rendered or personalized rather than // served from an edge cache: an explicit no-store/private/no-cache, or a Vary // on Cookie/Authorization, and not a cache hit. // // Positive evidence only. "No cache headers at all" is the majority case and // crediting it would make the bit another near-constant; what earns the point // is the origin explicitly saying this response is per-request. SignalDynamic // SignalPermissiveCORS - the response opts into cross-origin reads: an // Access-Control-Allow-Origin of "*" or "null", or any allowed origin // combined with Access-Control-Allow-Credentials. A specific allowlisted // origin without credentials is normal configuration and earns nothing. SignalPermissiveCORS // SignalLeakedInternals - the response exposes something about the origin it // did not mean to: a source map reference, a directory listing, an // origin/dev-server banner, or a debug/version header. SignalLeakedInternals // SignalAPISurface - the endpoint presents as a programmatic API beyond // merely returning JSON: rate-limit or gateway headers, an API/GraphQL path, // or an OpenAPI/Swagger/GraphiQL document. SignalAPISurface )
func (Signal) Score ¶
Score converts a signal bitmask to the stored surface score.
The scale is "percent of signal classes present" rather than a flat points-per -signal: at 16 signals no whole number of points divides 100, and a 10-point scheme clamped at the ceiling would score every record with ten or more signals as 100, flattening exactly the top band that ranking exists to separate. Truncating division keeps the mapping monotonic - more signals is never a lower score - and a full house scores exactly maxScore.