database

package
v0.4.9 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 20, 2026 License: MIT Imports: 42 Imported by: 0

Documentation

Overview

Package database provides the repository layer over SQLite (default) or PostgreSQL via the Bun ORM. It persists scans, HTTP records, findings, scopes, and OAST interactions — all scoped to a project_uuid for multi-tenancy. It supports batched HTTP-record ingestion (SaveRecordBatch, RecordWriter) and per-source deduplication (DeduplicateRecordsBySource).

Index

Constants

View Source
const (
	RecordSourceIngestServer = "ingest-server" // POST /api/ingest-http
	RecordSourceIngestProxy  = "ingest-proxy"  // transparent ingest proxy capture
	RecordSourceIngestCLI    = "ingest-cli"    // `vigolium ingest ...` command
	RecordSourceScanner      = "scanner"       // executor feedback re-injection
	RecordSourceProbe        = "probe"         // `vigolium run probe` host sweep (one request per target, no fuzzing)
	RecordSourceFinding      = "finding"       // request/response attached to a finding
	RecordSourceBurp         = "burp"          // Burp Suite, via the loopback bridge or a push to /api/ingest-http
	RecordSourceCaido        = "caido"         // Caido, via the loopback bridge or a push to /api/ingest-http
)

Canonical values for http_records.source. Kept here (rather than inline string literals at call sites) so the scan-on-receive filter and the ingest writers agree on a single set of labels.

View Source
const (
	ModuleTypeActive         = "active"
	ModuleTypePassive        = "passive"
	ModuleTypeNuclei         = "nuclei"
	ModuleTypeAgent          = "agent"
	ModuleTypeOAST           = "oast"
	ModuleTypeExtension      = "extension"
	ModuleTypeKnownIssueScan = "known-issue-scan"
	ModuleTypeWhitebox       = "whitebox"
)

ModuleType constants classify the kind of module that created a finding.

View Source
const (
	FindingSourceDynamicAssessment = "dynamic-assessment"
	FindingSourceKnownIssueScan    = "known-issue-scan"
	FindingSourceAgent             = "agent"
	FindingSourceOAST              = "oast"
	FindingSourceExtension         = "extension"
	FindingSourceAudit             = "audit"
	FindingSourceImport            = "import"
	FindingSourceSpidering         = "spidering"
)

FindingSource constants identify which phase/component produced a finding.

View Source
const (
	RecordKindFinding     = "finding"
	RecordKindCandidate   = "candidate"
	RecordKindObservation = "observation"
)

Record-kind constants distinguish reportable vulnerabilities from retained reconnaissance and unconfirmed hypotheses stored in the same table.

View Source
const (
	StatusDraft         = "draft"
	StatusTriaged       = "triaged"
	StatusFalsePositive = "false_positive"
	StatusAcceptedRisk  = "accepted_risk"
	StatusFixed         = "fixed"
)

Status constants represent the lifecycle state of a Finding.

Lifecycle:

draft ──triage──▶ triaged ──fix──▶ fixed
             ╲──▶ false_positive
             ╲──▶ accepted_risk

Trust model: deterministic engines (native scan, SAST tools) produce findings as `triaged`. LLM/agent-driven and externally imported findings land as `draft` and require triage to be promoted.

View Source
const (
	SeverityCritical = "critical"
	SeverityHigh     = "high"
	SeverityMedium   = "medium"
	SeverityLow      = "low"
	SeverityInfo     = "info"
	SeveritySuspect  = "suspect"
)

Severity constants represent the canonical severity strings stored on a Finding. They match the lowercase keys used by `vigolium finding --severity` and the severity counters on Scan.

View Source
const (
	SourceTypeLocal  = "local"
	SourceTypeGitURL = "git-url"
	SourceTypeGCS    = "gcs"
)

SourceType constants classify how source code was provided.

View Source
const (
	CandidateStatusProposed      = "proposed"
	CandidateStatusVerifying     = "verifying"
	CandidateStatusConfirmed     = "confirmed"
	CandidateStatusRejected      = "rejected"
	CandidateStatusNeedsEvidence = "needs_evidence"
)

Candidate lifecycle status values.

View Source
const (
	SectionStatusRunning     = "running"
	SectionStatusCompleted   = "completed"
	SectionStatusInterrupted = "interrupted"
)

Section lifecycle status values.

View Source
const AnalysisArtifactKindSourceMapOriginal = sourcemap.ArtifactKindOriginal

AnalysisArtifactKindSourceMapOriginal labels an original source file recovered from a source map's sourcesContent. Aliased from the sourcemap package, which owns the name for the writers on the other side of this package boundary.

View Source
const DefaultMaxParamShapeSamples = 3

DefaultMaxParamShapeSamples is the per-shape representative cap used when param-shape coalescing is enabled without an explicit override.

View Source
const DefaultProjectUUID = "00000000-0000-0000-defa-c01001000001"

DefaultProjectUUID is the UUID for the default project created during init. Lowercased for consistency with uuid.New().String() output and byte-wise SQL comparisons.

View Source
const DefaultUserUUID = "00000000-0000-0000-0000-000000000001"

DefaultUserUUID is the well-known UUID for the default local user created during init.

View Source
const EvidenceSeparator = output.EvidenceSeparator

EvidenceSeparator is the delimiter between request and response inside an AdditionalEvidence entry. Re-exported from pkg/output (the canonical owner) so the storage-layer dedup/merge path splits evidence the same way modules emit it.

View Source
const (

	// SQLChunkSize is how many ids or uuids to put in one IN() list. It keeps the
	// generated SQL to a few hundred KB while staying under maxSQLParams with
	// room for the handful of other values a query carries (project scoping, a
	// date range, a LIMIT) — so one constant is correct on both kinds of path.
	SQLChunkSize = 8000
)

Chunking policy for the id/uuid IN() lists this package builds.

The reason is NOT the bound-parameter ceiling, and assuming it was is how the wrong chunk size got picked before. Two facts, both measured against the dependencies in go.mod rather than inferred:

  • Through database/sql and the modernc driver directly, a statement binds at most 32766 parameters; 32767 fails with "too many SQL variables". That is the ceiling maxSQLParams records, and it applies to the raw-SQL paths (merge's pinned connection, the scratch-database probes).
  • Through bun — which is every query builder call in this package — `?` and bun.List do not produce bound parameters at all. The formatter interpolates the values as SQL literals, so a 100k-element IN() list executes happily.

So an unchunked list does not fail: it silently becomes a multi-megabyte SQL string, built and escaped element by element in Go and then parsed by SQLite, for a statement whose result is the same either way. Chunking bounds that string. It also bounds the raw-SQL paths below the hard ceiling, so a caller does not have to know which kind of path it is on.

If a future bun upgrade switches to real parameter binding, the ceiling starts applying to these calls too — TestBunInterpolatesRatherThanBinds is the test that notices.

Variables

View Source
var (
	// ErrTableNotFound indicates the requested table is unknown to the generic API.
	ErrTableNotFound = errors.New("table not found")
	// ErrInvalidColumn indicates a supplied column does not exist on the target table.
	ErrInvalidColumn = errors.New("invalid column")
	// ErrNoValidFields indicates no usable fields were supplied for an insert/update.
	ErrNoValidFields = errors.New("no valid fields provided")
	// ErrCompositePKUnsupported indicates a single-value PK operation was attempted
	// on a table whose primary key spans multiple columns.
	ErrCompositePKUnsupported = errors.New("single-value operation not supported for composite primary key table")
	// ErrImmutablePrimaryKey indicates an attempt to update a primary-key column.
	ErrImmutablePrimaryKey = errors.New("cannot update primary key column")
)

Sentinel errors returned by the generic table API. Callers (notably the REST DB handlers) discriminate on these with errors.Is to map failures to HTTP status codes, rather than matching on error-message substrings. They classify client-fixable input problems (unknown table/column, empty field set, single-value ops on composite-PK tables) distinctly from internal failures.

View Source
var AllowedCleanTables = map[string]cleanableTable{
	"http_records":             {SQLName: "http_records", CascadeFirst: []string{"finding_records"}},
	"findings":                 {SQLName: "findings", CascadeFirst: []string{"finding_records"}},
	"finding_records":          {SQLName: "finding_records"},
	"scans":                    {SQLName: "scans"},
	"agentic_scans":            {SQLName: "agentic_scans", CascadeFirst: []string{"agent_sections", "agent_finding_candidates"}},
	"agent_sections":           {SQLName: "agent_sections"},
	"agent_finding_candidates": {SQLName: "agent_finding_candidates"},
	"oast_interactions":        {SQLName: "oast_interactions"},
	"scan_logs":                {SQLName: "scan_logs"},
	"authentication_hostnames": {SQLName: "authentication_hostnames"},
	"scopes":                   {SQLName: "scopes"},
}

AllowedCleanTables maps user-facing table names to their SQL table name and any dependent tables that must be cleaned first.

View Source
var BridgeRecordSources = []string{
	RecordSourceBurp,
	RecordSourceCaido,
}

BridgeRecordSources lists the http_records.source values a loopback bridge listener can produce. Which one a given record gets is decided by the implementation identity the listener reports (see pkg/burpbridge), not by the flag spelling the user typed — --burp-bridge-url and --caido-bridge-url are one flag pointing at one protocol.

View Source
var ErrRecordWriterClosed = errors.New("record writer is closed")

ErrRecordWriterClosed is returned when writes are attempted after shutdown starts.

View Source
var ErrScanProjectMismatch = errors.New("scan UUID exists under a different project")

ErrScanProjectMismatch is returned by CreateScan / CreateAgenticScan when the caller pins a UUID that already exists under a different project. This guards against cross-project record corruption when remote nodes sync via --scan-uuid.

View Source
var ErrSchemaOutdated = errors.New("database schema is older than this vigolium and --read-only cannot migrate it")

ErrSchemaOutdated marks a database written by an older vigolium that this handle cannot migrate because it was opened read-only. It is a distinct error because it is FATAL to the command — every query that follows would fail on a missing column — whereas a migration failure on a writable handle is best-effort and worth only a warning.

View Source
var HostFactsFieldNames = []string{"a", "aaaa", "cname", "tls"}

HostFactsFieldNames are the JSON keys AsMap can produce, for the --fields allowlist. Derived from the struct so a new fact reaches the allowlist with its emitter.

IngestRecordSources lists the http_records.source values that represent user-ingested traffic. Scan-on-receive filters the DBInputSource by this list so it only processes traffic the user actually ingested — excluding scanner-produced artefacts that would otherwise cause fan-out.

The bridge vendors belong here for the same reason ingest-server does: a record that arrived from Burp or Caido is traffic the user deliberately handed over, whether it was pulled in by `traffic --save-to-vigolium-db` / `import -B` or pushed to /api/ingest-http by the plugin. Omitting them is silent — the rows land in the database and scan-on-receive simply never polls them, with no error to explain why nothing was scanned.

View Source
var TerminalAgenticScanStatuses = []string{
	"completed",
	"completed_with_errors",
	"failed",
	"cancelled",
	"timeout",
	"error",
}

TerminalAgenticScanStatuses is the authoritative set of agentic_scans.status values meaning the run has finished. Two subsystems must agree on it — the SSE log follower, which tails runtime.log until the run is terminal, and the retention sweep below, which only deletes finished runs — and a status present in one and absent from the other is either a stream that never ends or a row that is never cleaned. Both were true of "completed_with_errors", what a multi-driver audit writes when one leg fails: following its log spun until the two-hour cap and its row outlived every retention window. Add new terminal statuses here, not at either call site.

Functions

func AllTablesDeleteOrder

func AllTablesDeleteOrder() []string

AllTablesDeleteOrder returns the ordered list of cleanable table names.

func AuthenticationHostnameToSession

func AuthenticationHostnameToSession(sh *AuthenticationHostname) *authentication.Session

AuthenticationHostnameToSession converts a DB AuthenticationHostname row to a native authentication.Session.

Note: the DB schema does not carry the type/token_path shorthand fields — only the expanded ExtractRules. SessionToAuthenticationHostname normalizes shorthand into explicit rules on write so they round-trip correctly.

func AuthenticationHostnamesToSessionConfig

func AuthenticationHostnamesToSessionConfig(rows []*AuthenticationHostname) *authentication.SessionConfig

AuthenticationHostnamesToSessionConfig converts a slice of DB rows (typically for one hostname) into a authentication.SessionConfig with ordered sessions.

func CachedDNS

func CachedDNS(hostname string) (a, aaaa, cname []string)

CachedDNS returns the full DNS answer this PROCESS resolved for hostname, or nothing when it never looked it up (or the entry has aged out).

Deliberately not persisted. `ip` — one address per record — is the only DNS the schema stores. A host sweep writes many records per host (a redirect chain alone is three), and a corpus of thousands of records across a few hundred hosts would otherwise store the same A/AAAA/CNAME arrays thousands of times, inflating the database for data that is identical on every row and stale the moment the TTL expires. Resolving is cheap and repeatable; storing it is not.

So this is a read-time attachment: the run that did the resolving reports the full answer inline in its output, and reading the same database back later in a fresh process correctly shows only `ip`. Expiry is deliberately NOT checked — this reports what was resolved for the records being emitted, and a sweep long enough to cross the TTL should still describe the rows it wrote.

func CountFindingsByAgenticScan

func CountFindingsByAgenticScan(ctx context.Context, db *DB, agenticScanUUID string) (map[string]int64, error)

CountFindingsByAgenticScan returns finding counts grouped by severity for one agentic-scan run. Severity strings are lowercased/trimmed so callers get a canonical key set regardless of how the finding was inserted. Empty agenticScanUUID returns an empty map without querying.

func CountFindingsByAgenticScans

func CountFindingsByAgenticScans(ctx context.Context, db *DB, agenticScanUUIDs []string) (map[string]int64, error)

CountFindingsByAgenticScans is like CountFindingsByAgenticScan but counts across several agentic-scan UUIDs at once (a parent run plus its driver / sub-run children). Returns severity→count with lowercase, trimmed keys.

func CountFindingsByConfidence

func CountFindingsByConfidence(ctx context.Context, db *DB, projectUUID string) (map[string]int64, error)

CountFindingsByConfidence returns finding counts grouped by confidence.

func CountFindingsByModule

func CountFindingsByModule(ctx context.Context, db *DB, agenticScanUUID string) (map[string]int64, error)

CountFindingsByModule returns finding counts grouped by module_id, scoped to one agentic-scan run when agenticScanUUID is non-empty (empty = all rows).

func CountFindingsBySeverity

func CountFindingsBySeverity(ctx context.Context, db *DB, projectUUID string, hostnames ...string) (map[string]int64, error)

CountFindingsBySeverity returns finding counts grouped by severity, filtered by project and, when hostnames is non-empty, restricted to findings on those in-scope hosts. Empty hostnames means no host filter. Findings are scoped by hostname only (the findings table carries no scheme/port column), unlike the origin-precise record scoping; this lets the scan-completion summary count only findings on the hosts actually scanned, not leftovers from prior scans in the same project.

func CountFindingsByURL

func CountFindingsByURL(ctx context.Context, db *DB, agenticScanUUID string) (map[string]int64, error)

CountFindingsByURL returns finding counts grouped by URL, scoped to one agentic-scan run when agenticScanUUID is non-empty (empty = all rows). Findings with an empty URL are skipped — they aren't endpoint-attributable.

func CountRecordsByColumn

func CountRecordsByColumn(ctx context.Context, db *DB, projectUUID, column string) (map[string]int64, error)

CountRecordsByColumn returns http_record counts grouped by a single column (one of method, status_code, response_content_type), filtered by project (empty = every row, e.g. a --glob-db merge). Keys are the column values as text. Powers the traffic listing's status/method/content-type summary.

func CountSaved

func CountSaved(uuids []string) int

CountSaved reports how many records in a SaveRecordBatch result were actually persisted. The result is index-aligned with the input and carries "" for a record that was skipped, so len() over-reports — this is the only correct way to turn that slice into a count.

func CurrentSchemaVersion

func CurrentSchemaVersion() int

CurrentSchemaVersion exposes the schema revision this binary expects, so a consumer can fail loudly on drift at startup (`vigolium version --json`) rather than discovering it as a query error mid-run.

func DeleteGenericRecord

func DeleteGenericRecord(ctx context.Context, db *DB, tableName, pkValue string) error

DeleteGenericRecord deletes a record by primary key.

func EscapeLikeLiteral

func EscapeLikeLiteral(s string) string

EscapeLikeLiteral neutralizes the LIKE metacharacters in s so the string matches itself. The escape character is escaped first; doing it last would double the backslashes this function just introduced.

func ExpandPath

func ExpandPath(path string) string

ExpandPath resolves ~ and environment variables in a filesystem path using the same rules as the SQLite DSN builder. Exported so callers (e.g. the merge lock) can derive sidecar paths next to the real database file.

func ExtractPrimaryToken

func ExtractPrimaryToken(headers map[string]string) string

extractPrimaryToken extracts the primary session token value from a headers map. It checks Authorization (Bearer/token) first, then Cookie, then falls back to the first header value. Returns empty string if no headers.

func FormatStats

func FormatStats(stats *DatabaseStats) string

FormatStats formats statistics as a human-readable string

func GetGenericRecord

func GetGenericRecord(ctx context.Context, db *DB, tableName, pkValue string) (map[string]interface{}, error)

GetGenericRecord fetches a single record by primary key.

func GroupRecordsByField

func GroupRecordsByField(field string) (string, error)

GroupRecordsByField resolves a public field name to its physical column, or explains what the accepted names are.

Exported so the CLI can reject a bad --group-by before it opens a database: a name typo is knowable from the flags alone, and finding out after the open is how a read command comes to touch a file it had no business touching.

func GroupableFields

func GroupableFields() []string

GroupableFields lists the accepted --group-by names, sorted, for help text and for the error a rejected name produces.

func HasDynamicSegment

func HasDynamicSegment(path string) bool

HasDynamicSegment returns true if the path contains at least one dynamic segment — a numeric ID, UUID, long hex string, or long alphanumeric token. Version-like segments (v1, v2, v10) are not considered dynamic.

func HasSQLiteHeader

func HasSQLiteHeader(b []byte) bool

HasSQLiteHeader reports whether b begins with the SQLite 3 file magic. Callers that already hold the leading bytes of a file use this to avoid a second read.

func HostnamesOf

func HostnamesOf(hosts []HostTarget) []string

HostnamesOf returns the distinct hostnames of the given origins, order-preserving. Used to derive a hostname-only filter (e.g. for findings, which carry no port column) from a set of in-scope origins.

func InferSourceType

func InferSourceType(sourcePath string) string

InferSourceType returns the source type for a given path or URL. Both "gs://" (canonical) and "gcs://" (alias) are recognized as cloud storage.

func InsertGenericRecord

func InsertGenericRecord(ctx context.Context, db *DB, tableName string, fields map[string]interface{}) error

InsertGenericRecord inserts a record into the given table. fields is a map of column_name -> value. Column names are validated.

func IsBridgeRecordSource

func IsBridgeRecordSource(source string) bool

IsBridgeRecordSource reports whether a source label names a bridge vendor. Case-insensitive, since it answers both a user-typed --source and a header value written by a plugin.

This is the single answer to "is this label a bridge vendor" — the traffic and replay record selection ask it to decide whether a --source filter can be satisfied by live bridge traffic at all, and the ingest endpoint asks it to decide whether a client may claim the label. A second copy of the list is how a third vendor ends up working on one path and not the other.

func IsRedirectStatus

func IsRedirectStatus(code int) bool

IsRedirectStatus reports whether a status code is one that carries a Location.

304 is excluded: it is a cache validator, not a redirect, and has no destination to show. Shared so the write path (which decides what to store in response_location) and the read path (which decides what to render) can never disagree about what counts as a redirect — a disagreement whose symptom is a blank destination rather than an error.

func IsSQLiteFile

func IsSQLiteFile(path string) (bool, error)

IsSQLiteFile reports whether the file at path is a SQLite 3 database, detected by its magic header rather than its extension — so a .sqlite/.sqlite3/.db or a bare filename all resolve correctly. Files shorter than the header, or with any other leading bytes (e.g. a JSONL export), read as not-SQLite; a genuine open/read error is surfaced.

func IsValidFindingSeverity

func IsValidFindingSeverity(s string) bool

IsValidFindingSeverity reports whether s is a recognised Finding severity level.

func IsValidFindingStatus

func IsValidFindingStatus(s string) bool

IsValidFindingStatus reports whether s is a recognised Finding lifecycle status.

func LikeContains

func LikeContains(term string) string

LikeContains renders term as a pattern matching any row that CONTAINS it as a literal substring. This is the shape almost every search filter wants.

func LikeGlob

func LikeGlob(pattern string) string

LikeGlob renders a user-facing glob as a LIKE pattern, where `*` is the only wildcard. It backs the filters documented as accepting wildcards (--host, --path): `*` becomes `%`, and every other metacharacter — including a `%` or `_` the user typed — stays literal.

The caller decides whether a pattern without a `*` means "contains" or "equals"; this function only translates.

func ListTables

func ListTables(ctx context.Context, db *DB) ([]string, error)

ListTables returns the names of all user tables in the database.

func ParameterTypeFromParamType

func ParameterTypeFromParamType(ptype httpmsg.ParamType) string

ParameterTypeFromParamType converts ParamType to database parameter type string

func PathToTemplate

func PathToTemplate(path string) string

PathToTemplate normalizes a URL path for related-record lookup by replacing dynamic path segments (IDs, UUIDs, tokens) with a wildcard "*". Static segments and version segments (v1, v2, v10) are preserved. The resulting template uses "*" which callers can replace with "%" for SQL LIKE.

Segments are replaced if they match any of:

  • Pure numeric: `^\d+$`
  • UUID: `^[0-9a-f]{8}-...-[0-9a-f]{12}$` (case-insensitive)
  • All-hex ≥ 8 chars: `^[0-9a-f]{8,}$` (case-insensitive)
  • Long alphanumeric ≥ 20 chars (token-like): `^[A-Za-z0-9_-]{20,}$`

Version-like segments (v1, v2, v10) are always kept as-is.

func QueryGenericTable

func QueryGenericTable(ctx context.Context, db *DB, tableName string, limit, offset int) ([]map[string]interface{}, []string, int64, error)

QueryGenericTable runs a paginated SELECT * on the given table. Returns rows as ordered maps, column names, and total row count. The tableName must be validated against ListTables before calling.

func QueryGenericTableFiltered

func QueryGenericTableFiltered(ctx context.Context, db *DB, tableName string, opts GenericQueryOptions) ([]map[string]interface{}, []string, int64, error)

QueryGenericTableFiltered runs a filtered, sorted, paginated SELECT on the given table. The tableName and all column references are validated before use.

func RecordToHttpRequestResponse

func RecordToHttpRequestResponse(record *HTTPRecord) (*httpmsg.HttpRequestResponse, error)

RecordToHttpRequestResponse converts an HTTPRecord back to HttpRequestResponse. Exported for use by the agent input normalizer and other packages.

func ResolveHostnameNow

func ResolveHostnameNow(ctx context.Context, hostname string) (ipv4, ipv6, cname []string)

ResolveHostnameNow resolves hostname synchronously and caches the answer, returning the full answer. Returns the cached value when one is fresh.

This is the DNS-prefetch entry point for a host sweep, where DNS is part of the ANSWER rather than incidental metadata: the probe phase resolves its whole target list through here (bounded, before any HTTP) so every record it writes carries a complete A/AAAA/CNAME set. Every other path keeps using the background best-effort resolver, because on a normal scan's write path a blocking lookup would stall the record writer for a full DNS timeout per dead host.

ctx cancels the WAIT, not the lookup: a resolution in flight is shared with every other caller asking the same question, so one caller walking away must not take the answer with it. The lookup itself is bounded by dnsLookupTimeout.

func UpdateGenericRecord

func UpdateGenericRecord(ctx context.Context, db *DB, tableName, pkValue string, fields map[string]interface{}) error

UpdateGenericRecord updates a record by primary key. fields is a map of column_name -> new_value. PK columns cannot be updated.

func ValidateTableName

func ValidateTableName(ctx context.Context, db *DB, tableName string) error

ValidateTableName checks that the given name is in the list of real tables.

func WithFacts

func WithFacts(src *StoredHostFacts, r *HTTPRecord) any

WithFacts pairs a record with what src observed about its host. Returns the bare record when nothing was observed, so a process that probed nothing emits exactly the bytes it always did. A nil src answers from the process caches.

func WithLikeEscape

func WithLikeEscape(pred string) string

WithLikeEscape appends the ESCAPE clause to every `LIKE ?` placeholder in a predicate.

It is a rewrite rather than 30 hand-edited SQL strings on purpose. The predicates in this package span multi-line raw strings with up to twelve placeholders each, and an ESCAPE clause missed on one branch is a filter that silently keeps the old over-matching behavior — the exact failure mode this whole file exists to remove. Rewriting makes the guarantee positional: if the text says LIKE ?, it carries an ESCAPE.

Call it once per predicate at package level where possible; the per-query cost is a single pass over a short constant string.

func WithMergeLock

func WithMergeLock(destPath string, timeout time.Duration, fn func() error) error

WithMergeLock runs fn while holding a best-effort cross-process advisory lock for destPath, serializing concurrent merges into the same destination database. The lock is an O_EXCL sentinel file (<destPath>.merge-lock), which is portable across platforms and needs no syscall-level file locking.

It is intentionally best-effort: if the lock cannot be acquired within timeout, fn runs anyway and relies on SQLite's own busy_timeout + the merge's retry-on-busy loop for correctness. The lock is an optimization that keeps staggered finishers from contending, not a correctness requirement, so a stale or contended lock degrades to DB-level serialization rather than failing the merge.

Types

type AgentFindingCandidate

type AgentFindingCandidate struct {
	bun.BaseModel `bun:"table:agent_finding_candidates,alias:acand" json:"-"`

	ID              int64  `bun:"id,pk,autoincrement" json:"id"`
	UUID            string `bun:"uuid,notnull,unique" json:"uuid"`
	AgenticScanUUID string `bun:"agentic_scan_uuid,nullzero" json:"agentic_scan_uuid,omitempty"`
	ProjectUUID     string `bun:"project_uuid,nullzero" json:"project_uuid,omitempty"`
	SectionUUID     string `bun:"section_uuid,nullzero" json:"section_uuid,omitempty"`

	Title       string `bun:"title,nullzero" json:"title,omitempty"`
	Severity    string `bun:"severity,nullzero" json:"severity,omitempty"`
	Description string `bun:"description,nullzero" json:"description,omitempty"`
	Remediation string `bun:"remediation,nullzero" json:"remediation,omitempty"`
	CWEID       string `bun:"cwe_id,nullzero" json:"cwe_id,omitempty"`
	SourceFile  string `bun:"source_file,nullzero" json:"source_file,omitempty"`
	URL         string `bun:"url,nullzero" json:"url,omitempty"`
	Hostname    string `bun:"hostname,nullzero" json:"hostname,omitempty"`
	Confidence  string `bun:"confidence,nullzero" json:"confidence,omitempty"`
	Class       string `bun:"class,nullzero" json:"class,omitempty"` // idor | xss | sqli | ssrf | rce | auth | other

	Status        string `bun:"status,notnull,default:'proposed'" json:"status"` // proposed | verifying | confirmed | rejected | needs_evidence
	VerdictReason string `bun:"verdict_reason,nullzero" json:"verdict_reason,omitempty"`
	EvidenceGrade string `bun:"evidence_grade,nullzero" json:"evidence_grade,omitempty"`

	RecordUUIDs []string `bun:"record_uuids,type:jsonb,nullzero" json:"record_uuids,omitempty"`
	OASTIDs     []string `bun:"oast_ids,type:jsonb,nullzero" json:"oast_ids,omitempty"`
	Request     string   `bun:"request,nullzero" json:"request,omitempty"`
	Response    string   `bun:"response,nullzero" json:"response,omitempty"`

	DedupHash         string   `bun:"dedup_hash,notnull,default:''" json:"dedup_hash"`
	PromotedFindingID int64    `bun:"promoted_finding_id,notnull,default:0" json:"promoted_finding_id"`
	Tags              []string `bun:"tags,type:jsonb,nullzero" json:"tags,omitempty"`

	CreatedAt  time.Time `bun:"created_at,notnull,default:current_timestamp" json:"created_at"`
	VerifiedAt time.Time `bun:"verified_at,nullzero" json:"verified_at,omitempty"`
}

AgentFindingCandidate is a proposed finding awaiting fresh-context verification in the durable-autopilot verify-before-promote pipeline. The operator's propose_candidate tool writes rows here (status=proposed); a skeptic verifier grades them, and confirmed ones are promoted into a real Finding (promoted_finding_id set, status=confirmed). Rows are only written when agent.olium.autopilot_mode != legacy. Cascade-deleted with the parent agentic scan.

type AgentSection

type AgentSection struct {
	bun.BaseModel `bun:"table:agent_sections,alias:asec" json:"-"`

	ID              int64  `bun:"id,pk,autoincrement" json:"id"`
	UUID            string `bun:"uuid,notnull,unique" json:"uuid"`
	AgenticScanUUID string `bun:"agentic_scan_uuid,nullzero" json:"agentic_scan_uuid,omitempty"`
	ProjectUUID     string `bun:"project_uuid,nullzero" json:"project_uuid,omitempty"`

	Seq            int    `bun:"seq,notnull,default:0" json:"seq"`
	Kind           string `bun:"kind,nullzero" json:"kind,omitempty"`
	Status         string `bun:"status,notnull,default:'running'" json:"status"` // running | completed | interrupted
	Task           string `bun:"task,nullzero" json:"task,omitempty"`
	ClosingSummary string `bun:"closing_summary,nullzero" json:"closing_summary,omitempty"`
	RotationReason string `bun:"rotation_reason,nullzero" json:"rotation_reason,omitempty"`
	TurnCount      int    `bun:"turn_count,notnull,default:0" json:"turn_count"`
	InputTokens    int64  `bun:"input_tokens,notnull,default:0" json:"input_tokens"`
	OutputTokens   int64  `bun:"output_tokens,notnull,default:0" json:"output_tokens"`
	ErrorMessage   string `bun:"error_message,nullzero" json:"error_message,omitempty"`

	StartedAt time.Time `bun:"started_at,nullzero" json:"started_at,omitempty"`
	EndedAt   time.Time `bun:"ended_at,nullzero" json:"ended_at,omitempty"`
	CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp" json:"created_at"`
}

AgentSection represents one bounded operator section within a durable autopilot run (an engine Reset() + reconstructed-brief cycle). Rows are only written when agent.olium.autopilot_mode != legacy; legacy runs never touch this table. Cascade-deleted with the parent agentic scan.

type AgenticScan

type AgenticScan struct {
	bun.BaseModel `bun:"table:agentic_scans,alias:ar" json:"-"`

	ID          int64  `bun:"id,pk,autoincrement" json:"id"`
	UUID        string `bun:"uuid,notnull,unique" json:"uuid"`
	ProjectUUID string `bun:"project_uuid,notnull" json:"project_uuid"`
	ScanUUID    string `bun:"scan_uuid,nullzero" json:"scan_uuid,omitempty"`

	// Config
	Mode        string   `bun:"mode,notnull" json:"mode"` // query, autopilot, pipeline, scan
	AgentName   string   `bun:"agent_name,notnull" json:"agent_name"`
	Protocol    string   `bun:"protocol,nullzero" json:"protocol,omitempty"` // engine identifier — currently always "olium-engine" (subprocess SDK backends removed)
	Model       string   `bun:"model,nullzero" json:"model,omitempty"`       // model name used for this run (for cost audit)
	InputRaw    string   `bun:"input_raw,nullzero" json:"input_raw,omitempty"`
	InputType   string   `bun:"input_type,nullzero" json:"input_type,omitempty"` // url, curl, burp, raw, record_uuid
	TargetURL   string   `bun:"target_url,nullzero" json:"target_url,omitempty"`
	VulnType    string   `bun:"vuln_type,nullzero" json:"vuln_type,omitempty"`
	ModuleNames []string `bun:"module_names,type:jsonb,nullzero" json:"module_names,omitempty"`
	TemplateID  string   `bun:"template_id,nullzero" json:"template_id,omitempty"`

	// Execution
	Status       string   `bun:"status,notnull,default:'pending'" json:"status"` // pending, running, completed, failed, cancelled
	CurrentPhase string   `bun:"current_phase,nullzero" json:"current_phase,omitempty"`
	PhasesRun    []string `bun:"phases_run,type:jsonb,nullzero" json:"phases_run,omitempty"`

	// Results
	FindingCount int `bun:"finding_count,default:0" json:"finding_count"`
	RecordCount  int `bun:"record_count,default:0" json:"record_count"`
	SavedCount   int `bun:"saved_count,default:0" json:"saved_count"`

	// Agent context
	SourcePath            string                 `bun:"source_path,nullzero" json:"source_path,omitempty"`            // source code path used
	SourceType            string                 `bun:"source_type,nullzero" json:"source_type,omitempty"`            // local, git-url, gcs
	TokenUsage            map[string]interface{} `bun:"token_usage,type:jsonb,nullzero" json:"token_usage,omitempty"` // input/output token counts per phase
	TotalInputTokens      int64                  `bun:"total_input_tokens,default:0" json:"total_input_tokens"`
	TotalOutputTokens     int64                  `bun:"total_output_tokens,default:0" json:"total_output_tokens"`
	EstimatedCostUSD      float64                `bun:"estimated_cost_usd,default:0" json:"estimated_cost_usd"`
	RetryCount            int                    `bun:"retry_count,default:0" json:"retry_count"`
	ParentAgenticScanUUID string                 `bun:"parent_run_uuid,nullzero" json:"parent_agentic_scan_uuid,omitempty"` // for swarm sub-runs
	InputRecordCount      int                    `bun:"input_record_count,default:0" json:"input_record_count"`

	// Agent session ID (for resume)
	SessionID string `bun:"session_id,nullzero" json:"session_id,omitempty"`

	// Filesystem path to the session directory holding agent artifacts
	// (output.md, audit-stream.jsonl, extensions/, etc.). Populated by the
	// server handlers so API clients can locate run outputs.
	SessionDir string `bun:"session_dir,nullzero" json:"session_dir,omitempty"`

	// Debug (stored as JSON text blobs)
	AttackPlan     string `bun:"attack_plan,nullzero" json:"attack_plan,omitempty"`
	TriageResult   string `bun:"triage_result,nullzero" json:"triage_result,omitempty"`
	PromptSent     string `bun:"prompt_sent,nullzero" json:"prompt_sent,omitempty"`
	AgentRawOutput string `bun:"agent_raw_output,nullzero" json:"agent_raw_output,omitempty"`
	ErrorMessage   string `bun:"error_message,nullzero" json:"error_message,omitempty"`

	// Pipeline/scan result (JSON blob for full result objects)
	ResultJSON string `bun:"result_json,nullzero" json:"result_json,omitempty"`

	StorageURL string `bun:"storage_url,nullzero" json:"storage_url,omitempty"`

	// Timing
	StartedAt   time.Time `bun:"started_at,nullzero" json:"started_at,omitempty"`
	CompletedAt time.Time `bun:"completed_at,nullzero" json:"completed_at,omitempty"`
	DurationMs  int64     `bun:"duration_ms,default:0" json:"duration_ms"`
	CreatedAt   time.Time `bun:"created_at,notnull,default:current_timestamp" json:"created_at"`
}

AgenticScan represents a single agent execution for debugging and status tracking. It replaces the in-memory agent run status map with persistent DB storage.

type AnalysisArtifact

type AnalysisArtifact struct {
	bun.BaseModel `bun:"table:analysis_artifacts,alias:aa" json:"-"`

	ID             int64     `bun:"id,pk,autoincrement" json:"id"`
	ProjectUUID    string    `bun:"project_uuid,notnull" json:"project_uuid"`
	ScanUUID       string    `bun:"scan_uuid,nullzero" json:"scan_uuid,omitempty"`
	HTTPRecordUUID string    `bun:"http_record_uuid,notnull" json:"http_record_uuid"`
	Kind           string    `bun:"kind,notnull" json:"kind"`
	Filename       string    `bun:"filename,nullzero" json:"filename,omitempty"`
	MediaType      string    `bun:"media_type,nullzero" json:"media_type,omitempty"`
	SHA256         string    `bun:"sha256,notnull" json:"sha256"`
	ByteLength     int64     `bun:"byte_length,notnull" json:"byte_length"`
	Content        []byte    `bun:"content,type:bytea,notnull" json:"content,omitempty"`
	Metadata       string    `bun:"metadata,type:jsonb,nullzero" json:"metadata,omitempty"`
	CreatedAt      time.Time `bun:"created_at,notnull,default:current_timestamp" json:"created_at"`
}

AnalysisArtifact stores immutable derived content linked to an HTTP record. It deliberately lives beside, rather than inside, HTTPRecord so analysis can never replace the raw evidence captured from the target.

type AuthenticationHostname

type AuthenticationHostname struct {
	bun.BaseModel `bun:"table:authentication_hostnames,alias:sh" json:"-"`

	ID          int64  `bun:"id,pk,autoincrement" json:"id"`
	ProjectUUID string `bun:"project_uuid,notnull" json:"project_uuid"`
	ScanUUID    string `bun:"scan_uuid,nullzero" json:"scan_uuid,omitempty"`

	Hostname string `bun:"hostname,notnull" json:"hostname"`

	SessionName string `bun:"session_name,notnull" json:"session_name"`
	SessionRole string `bun:"session_role,nullzero,default:''" json:"session_role,omitempty"`
	Position    int    `bun:"position,default:0" json:"position"`

	// Primary session token (JWT, cookie value, API key) for quick access.
	SessionToken string `bun:"session_token,nullzero" json:"session_token,omitempty"`

	// Static auth headers (JSON map)
	Headers map[string]string `bun:"headers,type:jsonb,nullzero" json:"headers,omitempty"`

	// Login flow fields (flat for queryability)
	LoginURL         string `bun:"login_url,nullzero" json:"login_url,omitempty"`
	LoginMethod      string `bun:"login_method,nullzero" json:"login_method,omitempty"`
	LoginContentType string `bun:"login_content_type,nullzero" json:"login_content_type,omitempty"`
	LoginBody        string `bun:"login_body,nullzero" json:"login_body,omitempty"`
	LoginRequest     string `bun:"login_request,nullzero" json:"login_request,omitempty"`
	LoginResponse    string `bun:"login_response,nullzero" json:"login_response,omitempty"`

	// Extract rules (JSON array)
	ExtractRules string `bun:"extract_rules,type:jsonb,nullzero" json:"extract_rules,omitempty"`

	Source     string     `bun:"source,nullzero,default:''" json:"source,omitempty"`
	HydratedAt *time.Time `bun:"hydrated_at,nullzero" json:"hydrated_at,omitempty"`
	CreatedAt  time.Time  `bun:"created_at,notnull,default:current_timestamp" json:"created_at"`
	UpdatedAt  time.Time  `bun:"updated_at,notnull,default:current_timestamp" json:"updated_at"`
}

AuthenticationHostname persists per-hostname session auth configs in the DB.

func SessionToAuthenticationHostname

func SessionToAuthenticationHostname(s *authentication.Session, position int) *AuthenticationHostname

SessionToAuthenticationHostname converts a native authentication.Session to a DB AuthenticationHostname row. The caller must set ProjectUUID, Hostname, and optionally ScanUUID on the returned row.

func SessionsToAuthenticationHostnames

func SessionsToAuthenticationHostnames(sessions []*authentication.Session, projectUUID, hostname string) []*AuthenticationHostname

SessionsToAuthenticationHostnames converts a slice of authentication.Session objects to DB rows for a given hostname. Sets ProjectUUID and Hostname on each row.

type ColumnInfo

type ColumnInfo struct {
	Name     string `json:"name"`
	Type     string `json:"type"`
	Nullable string `json:"nullable"`
	Default  string `json:"default,omitempty"`
}

ColumnInfo describes a single column in a database table.

func ListColumns

func ListColumns(ctx context.Context, db *DB, tableName string) ([]ColumnInfo, error)

ListColumns returns column metadata for the given table.

type DB

type DB struct {
	*bun.DB
	// contains filtered or unexported fields
}

DB wraps bun.DB with additional metadata

func NewDB

func NewDB(cfg *config.DatabaseConfig) (*DB, error)

NewDB creates database connection based on config

func NewDBFromBun

func NewDBFromBun(bunDB *bun.DB, driver string) *DB

NewDBFromBun wraps an existing bun.DB for use in tests or external tooling.

func (*DB) Close

func (db *DB) Close() error

Close closes database connection

func (*DB) CreateRecordIndexes

func (db *DB) CreateRecordIndexes(ctx context.Context) error

CreateRecordIndexes builds the http_records read indexes. Idempotent (every statement is IF NOT EXISTS), so calling it on a store that never deferred is harmless.

func (*DB) CreateSchema

func (db *DB) CreateSchema(ctx context.Context) error

CreateSchema creates all database tables and indexes if they don't exist.

It is a no-op on a read-only handle. The read commands call it on open to self-heal a fresh project database; against a source opened --read-only that would be a schema write to someone else's evidence, and a source genuinely missing the tables surfaces as a query error rather than being silently migrated into the current shape.

func (*DB) DeferRecordIndexes

func (db *DB) DeferRecordIndexes()

DeferRecordIndexes tells CreateSchema to leave the http_records read indexes uncreated, so a bulk load runs against the primary key alone. The caller MUST call CreateRecordIndexes once loading is done — a store left without them is schema-complete but plans every read as a full scan.

Only for disposable stores that are filled once and then read (the --glob-db scratch merge). A persistent store must not defer: it is written to continuously, so there is no "after the load" to build them in.

func (*DB) Driver

func (db *DB) Driver() string

Driver returns the database driver name

func (*DB) EnsureSchemaCurrent

func (db *DB) EnsureSchemaCurrent(ctx context.Context) error

EnsureSchemaCurrent brings an existing database up to date, doing NOTHING when it already is.

The "do nothing" path is the whole point. CreateSchema issues DDL, and DDL takes SQLite's WRITE lock — so calling it unconditionally on every open makes every read command queue behind any concurrent writer for up to busy_timeout (60s by default). Detecting staleness first is a read-only question, so the common case takes no lock at all.

A read-only handle cannot be migrated (mode=ro refuses DDL); CreateSchema's own read-only branch turns that into ErrSchemaOutdated naming the missing columns, so it is reached through the same call rather than special-cased here.

Indexes are deliberately not part of the check: they are a performance concern, not a correctness one, and the write commands still call CreateSchema directly. Only a missing TABLE or COLUMN can make a query fail outright.

func (*DB) GetStats

func (db *DB) GetStats(ctx context.Context, filters QueryFilters) (*DatabaseStats, error)

GetStats retrieves database statistics

func (*DB) GetTopHosts

func (db *DB) GetTopHosts(ctx context.Context, filters QueryFilters, limit int) ([]HostStats, error)

GetTopHosts retrieves top hosts by request count with per-host finding counts. It uses a constant TWO queries — one for the top hosts, one grouped finding-count query — instead of the previous 1 + N (a per-host count query), so its cost no longer grows with the requested host limit.

func (*DB) HasFTS

func (db *DB) HasFTS() bool

HasFTS reports whether full-text search is available on this handle (FTS5 for SQLite, tsvector for PostgreSQL).

It DISCOVERS the answer rather than remembering whether this process created the index. It used to be a plain field assigned only by SeedDefaults, which made the answer depend on how the handle was opened rather than on what the database contains: `vigolium server` seeds and reported true, every read command (traffic, finding, db ls) does not seed and reported false on the very same file. Two commands then took different query paths over one database.

Discovery is lazy and cached: a handle that never searches pays nothing, and one that does pays a single sqlite_master lookup.

func (*DB) HasVigoliumSchema

func (db *DB) HasVigoliumSchema(ctx context.Context) bool

HasVigoliumSchema reports whether this store has been initialized as a vigolium database, by the presence of the core table every read touches.

It is the question "is this the right file?", which is NOT the same as "does it have rows": a foreign SQLite file and a scanned target both open cleanly, and only this distinguishes them before vigolium's tables get created inside the former.

func (*DB) Optimize

func (db *DB) Optimize(ctx context.Context)

Optimize refreshes the query-planner statistics so subsequent dedup and finding queries plan against real row counts. Call it after a bulk ingest phase (e.g. discovery) and before the heavy dedup passes; it also runs on Close and on the WAL checkpointer cadence. Best-effort — a failure degrades plans, never correctness.

SQLite uses `PRAGMA optimize` (incremental: only re-analyzes tables that changed enough to matter). PostgreSQL analyzes just the hot ingest tables rather than the whole database, so a short-lived CLI run against a shared server doesn't trigger a blocking full-DB ANALYZE on every close.

func (*DB) Path

func (db *DB) Path() string

Path returns the resolved SQLite file backing this handle, or "" for a driver that is not a single file (Postgres).

It exists because DatabaseStats.Database.Path and .Size were declared and never filled: `db stats -j` reported `"path": "", "size": 0` for a store it had open and had just counted 60 records in. A consumer reading that to confirm WHICH database it was looking at — the whole reason the -j envelope carries db_path — was told nothing, in a field that looked like an answer.

func (*DB) SQLDB

func (db *DB) SQLDB() *sql.DB

SQLDB returns the underlying *sql.DB. Used by callers that need a pinned *sql.Conn for connection-scoped operations (e.g. ATTACH DATABASE during a SQLite-to-SQLite merge) that must run on a single physical connection.

func (*DB) SeedDefaults

func (db *DB) SeedDefaults(ctx context.Context) error

SeedDefaults creates the default user and project if they don't exist. This is called during initialization to ensure CLI has a working project context.

func (*DB) ServerVersion

func (db *DB) ServerVersion(ctx context.Context) (string, error)

ServerVersion returns the database server version string (e.g. the Postgres "PostgreSQL 16.1 ..." banner or the SQLite library version).

type DBInputSource

type DBInputSource struct {
	// contains filtered or unexported fields
}

DBInputSource polls the database for HTTP records after the scan cursor and provides them as WorkItems. It implements source.InputSource.

func NewDBInputSource

func NewDBInputSource(db *DB, repo *Repository, scanUUID string, pollInterval time.Duration) *DBInputSource

NewDBInputSource creates a new DBInputSource that polls for records after the scan cursor at the given interval.

func NewOneShotDBInputSource

func NewOneShotDBInputSource(db *DB, repo *Repository, scanUUID string) *DBInputSource

NewOneShotDBInputSource creates a DBInputSource that returns io.EOF when no records remain after the cursor, instead of polling indefinitely.

func (*DBInputSource) Close

func (s *DBInputSource) Close() error

Close stops the source. After Close, Next will return io.EOF.

func (*DBInputSource) IdleFor

func (s *DBInputSource) IdleFor() time.Duration

IdleFor returns how long it has been since the source last observed a new row (or since creation, if none have arrived yet). Useful for status reporting to tell the user "scan is idle — server is still listening for more records."

func (*DBInputSource) Next

func (s *DBInputSource) Next(ctx context.Context) (*work.WorkItem, error)

Next returns the next record after the scan cursor as a WorkItem. It blocks (polling) until a record is available, the context is cancelled, or the source is closed.

func (*DBInputSource) WithHostScopes

func (s *DBInputSource) WithHostScopes(hosts []HostTarget) *DBInputSource

WithHostScopes sets an in-scope origin filter so only records matching these (scheme, hostname, port) origins are returned. This keeps HTTP records from unrelated origins — including the same host on a different port left over from a previous scan (e.g. localhost:8080 when targeting localhost:3000) — out of the scan.

func (*DBInputSource) WithIdleTimeout

func (s *DBInputSource) WithIdleTimeout(timeout time.Duration) *DBInputSource

WithIdleTimeout configures the source to return io.EOF after this long without any new rows arriving from the database. Only honored in polling mode (not oneShot). Zero (the default) keeps the original behavior — poll forever. Typical use: one-shot scan-on-receive where the caller wants the scan to terminate once the ingestion pipeline has settled.

func (*DBInputSource) WithIncludeSources

func (s *DBInputSource) WithIncludeSources(sources []string) *DBInputSource

WithIncludeSources restricts the source to records whose http_records.source value is in the given list. Used by scan-on-receive shallow mode to exclude scanner-produced artefacts (source="finding", source="scanner", etc.) so the scan only processes user-ingested traffic and doesn't fan out across records that were created as byproducts of scanning itself. An empty slice disables the filter (default).

func (*DBInputSource) WithOnActivity

func (s *DBInputSource) WithOnActivity(fn func(recordCount int, idleFor time.Duration, firstBatch bool)) *DBInputSource

WithOnActivity registers a callback invoked once per non-empty fetch batch. firstBatch is true for the very first batch ever returned by this source. idleFor is the duration the source had been quiet *before* this batch landed. Callers typically use this to print a "scan started / resumed" line so the user gets immediate feedback rather than waiting for the next status tick.

func (*DBInputSource) WithPageSize

func (s *DBInputSource) WithPageSize(pageSize int) *DBInputSource

WithPageSize sets the number of records fetched from the database per batch.

type DatabaseInfo

type DatabaseInfo struct {
	Path    string `json:"path"`
	Driver  string `json:"driver"`
	Version string `json:"version,omitempty"`
	Size    int64  `json:"size"`
}

DatabaseInfo holds database metadata

type DatabaseStats

type DatabaseStats struct {
	Database     DatabaseInfo        `json:"database"`
	Records      RecordCounts        `json:"records"`
	Findings     FindingsStats       `json:"findings"`
	HTTPMethods  map[string]int64    `json:"http_methods"`
	StatusCodes  StatusCodeBreakdown `json:"status_codes"`
	Performance  PerformanceMetrics  `json:"performance"`
	DateRange    DateRangeStats      `json:"date_range"`
	ScanSessions []ScanSessionStats  `json:"scan_sessions,omitempty"`
	TopHosts     []HostStats         `json:"top_hosts,omitempty"`
}

DatabaseStats holds database statistics

type DateRangeStats

type DateRangeStats struct {
	FirstRequest time.Time     `json:"first_request"`
	LastRequest  time.Time     `json:"last_request"`
	Duration     time.Duration `json:"duration"`
}

DateRangeStats holds date range information

type DeleteBuilder

type DeleteBuilder struct {
	// contains filtered or unexported fields
}

DeleteBuilder builds delete queries with filters

func NewDeleteBuilder

func NewDeleteBuilder(db *DB, filters QueryFilters) *DeleteBuilder

NewDeleteBuilder creates a new delete builder

func (*DeleteBuilder) DeleteAllTables

func (db *DeleteBuilder) DeleteAllTables(ctx context.Context, dryRun bool) (map[string]int64, error)

DeleteAllTables deletes all data from every data table.

func (*DeleteBuilder) DeleteFindings

func (db *DeleteBuilder) DeleteFindings(ctx context.Context, dryRun bool) (int64, error)

DeleteFindings deletes findings matching the configured filters. If dryRun is true, returns the count without deleting.

func (*DeleteBuilder) DeleteOrphans

func (db *DeleteBuilder) DeleteOrphans(ctx context.Context, dryRun bool) (int64, error)

DeleteOrphans deletes orphaned findings (findings where none of the http_record_uuids exist in http_records).

It honors filters.ProjectUUID. Orphan cleanup reads as store maintenance, but the rows it removes belong to projects: an unscoped sweep run from one engagement deleted another engagement's findings, which is the same cross-project leak the scoped delete paths exist to prevent. With no project filter set the behavior is unchanged and the whole store is swept.

func (*DeleteBuilder) DeleteRecords

func (db *DeleteBuilder) DeleteRecords(ctx context.Context, dryRun bool) (int64, error)

DeleteRecords deletes the HTTP records matching filters, along with the findings whose only evidence they were.

The order below is the policy, and it is not the obvious one. Deleting every finding that merely *touches* a deleted record destroys findings that also cite records the caller is keeping — one module reporting the same issue across five endpoints would lose the whole finding because one endpoint was pruned. So the junction is narrowed first, and a finding is removed only once nothing it links to survives:

  1. collect the findings linked to the doomed records (bounded by findings touched, not by records deleted);
  2. drop those records' junction rows, so the junction describes surviving evidence only;
  3. delete the records;
  4. delete the collected findings that now link to nothing.

Step 4 is scoped to the collected candidates rather than sweeping every evidence-less finding, because a finding with no HTTP record is normal — audit and source-scan findings never have one, and this is not the command that removes them (see DeleteOrphans).

Every IN() list is chunked (see SQLChunkSize). The filters can select an unbounded number of records, and bun renders a list as SQL literals rather than bound parameters — so an unchunked delete builds one statement holding every uuid as text, tens of megabytes of it on a large corpus.

func (*DeleteBuilder) DeleteTable

func (db *DeleteBuilder) DeleteTable(ctx context.Context, tableName string, dryRun bool) (int64, error)

DeleteTable deletes all rows from a specific table.

type DeparosStatusPolicy

type DeparosStatusPolicy struct {
	// KeepOnePerHost statuses collapse to a single representative per
	// (hostname, status_code) — the shortest path. Use where "this host has one"
	// is enough signal: authentication/authorization walls (401/403) and pacing
	// (429). Route identity across paths is intentionally NOT preserved here — an
	// authz wall is a host-level fact, and keeping every 403 would reintroduce
	// catch-all floods.
	KeepOnePerHost []int
	// KeepPerPath statuses preserve every DISTINCT path (only exact-duplicate
	// paths collapse), capped at PerPathCap records per (hostname, status_code) to
	// bound pathological floods. Use for "endpoint exists" evidence where the path
	// itself is the attack surface: 405/415/422 mean the route is real but the
	// method/payload was wrong, so dynamic assessment should retry it with the
	// right method. Routers only emit these for matched routes, so fuzz-flood risk
	// is low — the cap is a backstop, not the primary control.
	KeepPerPath []int
	// PerPathCap bounds the KeepPerPath tier per (hostname, status_code). <=0
	// disables the cap (keep all distinct paths).
	PerPathCap int
}

DeparosStatusPolicy controls how discovery client-error (4xx) records are retained. It replaces a blanket "drop all 4xx" with priority tiers so that distinct route identities carrying real attack-surface signal survive, while fuzz noise (nonexistent paths → 404/400) is still pruned. Statuses in neither tier are dropped entirely.

type EmbeddedParam

type EmbeddedParam struct {
	Name       string `json:"name"`
	Value      string `json:"value,omitempty"`
	Type       string `json:"type"`
	NameStart  int    `json:"name_start,omitempty"`
	NameEnd    int    `json:"name_end,omitempty"`
	ValueStart int    `json:"value_start,omitempty"`
	ValueEnd   int    `json:"value_end,omitempty"`
	Metadata   string `json:"metadata,omitempty"`
}

EmbeddedParam represents a parameter stored as JSON within HTTPRecord

type Finding

type Finding struct {
	bun.BaseModel `bun:"table:findings,alias:f" json:"-"`

	ID          int64  `bun:"id,pk,autoincrement" json:"id"`
	ProjectUUID string `bun:"project_uuid,notnull" json:"project_uuid"`

	// UUID references to HTTPRecords (not FK, allows flexibility)
	HTTPRecordUUIDs []string `bun:"http_record_uuids,type:jsonb,notnull" json:"http_record_uuids"`

	// Scan reference (soft, no FK)
	ScanUUID        string `bun:"scan_uuid,nullzero" json:"scan_uuid,omitempty"`
	AgenticScanUUID string `bun:"agentic_scan_uuid,nullzero" json:"agentic_scan_uuid,omitempty"` // link to agentic_scan that produced this finding

	// Denormalized target info (for fast display/filtering without joining)
	URL      string `bun:"url,nullzero" json:"url,omitempty"`
	Hostname string `bun:"hostname,nullzero" json:"hostname,omitempty"`

	// Module info
	ModuleID      string   `bun:"module_id,notnull" json:"module_id"`
	ModuleName    string   `bun:"module_name,notnull" json:"module_name"`
	ModuleType    string   `bun:"module_type,nullzero" json:"module_type,omitempty"`
	FindingSource string   `bun:"finding_source,nullzero" json:"finding_source,omitempty"`
	RecordKind    string   `bun:"record_kind,notnull,default:'finding'" json:"record_kind,omitempty"`
	EvidenceGrade string   `bun:"evidence_grade,nullzero" json:"evidence_grade,omitempty"`
	ModuleShort   string   `bun:"module_short,nullzero" json:"module_short,omitempty"`
	Description   string   `bun:"description,nullzero" json:"description,omitempty"`
	Severity      string   `bun:"severity,notnull" json:"severity"`
	Confidence    string   `bun:"confidence,notnull,default:'firm'" json:"confidence"`
	Tags          []string `bun:"tags,type:jsonb,nullzero" json:"tags,omitempty"`

	// Finding lifecycle
	Status      string `bun:"status,nullzero,default:'triaged'" json:"status,omitempty"` // draft, triaged, false_positive, accepted_risk, fixed
	Remediation string `bun:"remediation,nullzero" json:"remediation,omitempty"`

	// Classification
	CWEID     string  `bun:"cwe_id,nullzero" json:"cwe_id,omitempty"`
	CVSSScore float64 `bun:"cvss_score,default:0" json:"cvss_score"`

	// Source info (for audit findings)
	SourceFile string `bun:"source_file,nullzero" json:"source_file,omitempty"`
	RepoName   string `bun:"repo_name,nullzero" json:"repo_name,omitempty"` // repository name or URL for audit findings

	MatchedAt          []string `bun:"matched_at,type:jsonb,nullzero" json:"matched_at,omitempty"`
	ExtractedResults   []string `bun:"extracted_results,type:jsonb,nullzero" json:"extracted_results,omitempty"`
	AdditionalEvidence []string `bun:"additional_evidence,type:jsonb,nullzero" json:"additional_evidence,omitempty"`

	Request     string `bun:"request,nullzero" json:"request,omitempty"`
	Response    string `bun:"response,nullzero" json:"response,omitempty"`
	FindingHash string `bun:"finding_hash,notnull" json:"finding_hash"`

	FoundAt   time.Time `bun:"found_at,notnull,default:current_timestamp" json:"found_at"`
	CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp" json:"created_at"`
}

Finding represents a vulnerability finding (no FK, soft UUID reference)

func (*Finding) FromResultEvent

func (f *Finding) FromResultEvent(event *output.ResultEvent) error

FromResultEvent converts output.ResultEvent to Finding

type FindingSaveResult

type FindingSaveResult struct {
	Inserted bool  // a new finding row was written (false on a dedup-append to an existing finding)
	Err      error // non-nil if this finding failed to save
}

FindingSaveResult reports the outcome of persisting one finding in a batched direct save, aligned by index with the input slice passed to SaveFindingsDirectBatch.

type FindingWrite

type FindingWrite struct {
	Event           *output.ResultEvent
	HTTPRecordUUIDs []string
	ScanUUID        string
	ProjectUUID     string
}

FindingWrite bundles the arguments needed to persist a single finding, allowing a batch of findings to be coalesced into one transaction. See Repository.SaveFindingsBatch.

type FindingWriter

type FindingWriter struct {
	// contains filtered or unexported fields
}

FindingWriter decouples finding persistence from scan workers. SaveFinding is a multi-statement, dedup-aware operation; calling it synchronously on every worker blocks the worker on a database round-trip. FindingWriter funnels findings through a single background goroutine that coalesces them into batch transactions (one fsync for many findings) via Repository.SaveFindingsBatch.

Findings are low-volume relative to HTTP records, so a single writer goroutine keeps up while also eliminating write contention between concurrent finding transactions. This mirrors RecordWriter's lifecycle (buffered channel, background flush, bounded shutdown drain) but is fire-and-forget: the result UUID is never needed by the caller, so Save does not block on persistence.

func NewFindingWriter

func NewFindingWriter(repo *Repository, cfg FindingWriterConfig) *FindingWriter

NewFindingWriter creates and starts a FindingWriter. Call Close() to flush remaining findings and stop the background goroutine.

func (*FindingWriter) Close

func (w *FindingWriter) Close()

Close stops accepting new findings, flushes the buffer, and returns. After Close, Save persists synchronously. The drain is bounded by FlushTimeout so a wedged database can't hang Close forever.

func (*FindingWriter) Metrics

func (w *FindingWriter) Metrics() FindingWriterMetrics

Metrics returns a snapshot of the writer's counters.

func (*FindingWriter) Save

func (w *FindingWriter) Save(ctx context.Context, event *output.ResultEvent, recordUUIDs []string, scanUUID, projectUUID string) error

Save enqueues a finding for batched persistence. It does not block on the database: if the buffer is full or the writer is shutting down, the finding is persisted synchronously so nothing is dropped. Safe for concurrent callers.

type FindingWriterConfig

type FindingWriterConfig struct {
	// BufferSize is the channel capacity. When full, Save persists inline rather
	// than blocking the scan worker. Default: 1024.
	BufferSize int

	// BatchSize is the maximum number of findings coalesced into one transaction.
	// Default: 64.
	BatchSize int

	// FlushInterval is the maximum time a finding waits in the buffer before
	// being flushed, even if the batch isn't full. Default: 50ms.
	FlushInterval time.Duration

	// FlushTimeout bounds the shutdown drain so a wedged database can't block
	// Close() forever. Steady-state flushes use an uncancellable context.
	// Default: 2m.
	FlushTimeout time.Duration
}

FindingWriterConfig configures the batching behavior of FindingWriter.

type FindingWriterMetrics

type FindingWriterMetrics struct {
	Enqueued int64 // findings handed to the background writer
	Written  int64 // findings persisted by the background writer
	Inline   int64 // findings persisted synchronously (buffer full / shutting down)
	Errors   int64 // findings in a flush that ultimately failed to persist
}

FindingWriterMetrics exposes counters for monitoring.

type FindingsQueryBuilder

type FindingsQueryBuilder struct {
	// contains filtered or unexported fields
}

FindingsQueryBuilder builds filtered queries for the findings table

func NewFindingsQueryBuilder

func NewFindingsQueryBuilder(db *DB, filters QueryFilters) *FindingsQueryBuilder

NewFindingsQueryBuilder creates a new findings query builder

func (*FindingsQueryBuilder) Count

func (fqb *FindingsQueryBuilder) Count(ctx context.Context) (int64, error)

Count returns total number of matching findings

func (*FindingsQueryBuilder) Execute

func (fqb *FindingsQueryBuilder) Execute(ctx context.Context) ([]*Finding, error)

Execute executes the query and returns findings

func (*FindingsQueryBuilder) ExecuteWithCount

func (fqb *FindingsQueryBuilder) ExecuteWithCount(ctx context.Context) ([]*Finding, int64, error)

ExecuteWithCount runs the filtered query and returns matching findings alongside the total unfiltered count, in a single round-trip via Bun's ScanAndCount. Use this when callers need both the page and the total (paginated views) — saves one DB roundtrip vs. Execute + Count.

type FindingsStats

type FindingsStats struct {
	BySeverity map[string]int64 `json:"by_severity"`
	Total      int64            `json:"total"`
}

FindingsStats holds finding statistics by severity

type GenericFilter

type GenericFilter struct {
	Column   string
	Operator string // "eq", "like", "gt", "lt", "gte", "lte", "in", "neq"
	Value    string
}

GenericFilter represents a single column filter.

type GenericQueryOptions

type GenericQueryOptions struct {
	Limit      int
	Offset     int
	SortBy     string // Column to sort by
	SortAsc    bool
	Columns    []string // Column whitelist (empty = all)
	SearchTerm string   // Fuzzy search across text columns
	Truncate   int      // Truncate []byte fields to this length (0 = no truncation)
	Filters    []GenericFilter
}

GenericQueryOptions holds options for filtered, sorted, paginated generic queries.

type GroupFindingOptions

type GroupFindingOptions struct {
	// PerHost groups within each hostname — a value seen on two hosts stays two
	// findings. When false, grouping is project-wide regardless of host.
	PerHost bool
	// Tags, when non-empty, restricts grouping to findings carrying at least one
	// matching tag (case-insensitive).
	Tags []string
	// ByModule lists module IDs whose findings collapse to a single finding per
	// (module, severity[, host]) regardless of the per-URL extracted value — for
	// modules that fire once per asset (e.g. sourcemap-detect, one .map filename
	// per JS bundle). These bypass the value-identity requirement and the Tags
	// gate; module + severity is the guardrail.
	ByModule []string
	// ByRule lists module IDs whose findings collapse per (module, rule_name,
	// severity[, host]) regardless of value — like ByModule, but the rule
	// (module_name) stays part of the key, so DISTINCT rules remain distinct
	// findings while repeats of the SAME rule fold together. This is for
	// secret-detect, whose one module_id carries many rule_names (secret-scan
	// rules): a "Looker Client ID" rule matching every chunk hash in a minified
	// bundle's content-hash map yields dozens of identical findings differing only
	// by value, which collapse to one finding (all values unioned on), while a
	// genuinely different secret — an AWS key, a Slack token — keeps its own row.
	ByRule []string
	// BundleSuspect lists module IDs whose GENERIC Suspect-severity findings
	// collapse by (module, severity[, host]) — the rule dropped from the key — so
	// the family-less low-signal noise on a host folds into ONE bundle. Only
	// findings carrying output.SuspectBundleTag qualify (secret-detect sets it on
	// its generic-namespace rules); a NAMED provider family at Suspect severity is
	// left untagged and stays per-rule via ByRule, so distinct families are never
	// merged into one rollup. The module's higher-severity findings also stay
	// per-rule. The bundle survivor's module_name is relabeled to
	// suspectBundleFindingName and its description to suspectBundleDescription so it
	// isn't mislabeled with just the first rule's name/text. Default: secret-detect.
	BundleSuspect []string
	// MaxURLs caps the merged matched-URL list on the survivor (0 = unlimited).
	MaxURLs int
	// Hostnames, when non-empty, scopes the pass to findings on those hosts. The
	// dynamic-assessment feedback loop sets it to the hosts being scanned so a
	// growing findings table isn't loaded in full each round. Empty = project-wide.
	Hostnames []string
}

GroupFindingOptions configures value-based finding grouping (see GroupFindingsByValue).

type HTTPRecord

type HTTPRecord struct {
	bun.BaseModel `bun:"table:http_records,alias:r" json:"-"`

	UUID        string `bun:"uuid,pk,notnull" json:"uuid"`
	ProjectUUID string `bun:"project_uuid,notnull" json:"project_uuid"`
	ScanUUID    string `bun:"scan_uuid,nullzero" json:"scan_uuid,omitempty"` // link to scan that produced this record

	// Host info (embedded, replaces hosts table)
	Scheme   string `bun:"scheme,notnull" json:"scheme"`
	Hostname string `bun:"hostname,notnull" json:"hostname"`
	Port     int    `bun:"port,notnull" json:"port"`
	IP       string `bun:"ip,nullzero" json:"ip,omitempty"` // resolved IP address the request went to (httpx: host_ip)

	// Request fields
	Method               string `bun:"method,notnull" json:"method"`
	Path                 string `bun:"path,notnull" json:"path"`
	URL                  string `bun:"url,notnull" json:"url"`
	HTTPVersion          string `bun:"http_version,notnull" json:"http_version"`
	RequestContentType   string `bun:"request_content_type,nullzero" json:"request_content_type,omitempty"`
	RequestContentLength int64  `bun:"request_content_length,default:0" json:"request_content_length"`
	RawRequest           []byte `bun:"raw_request,type:bytea,nullzero" json:"raw_request,omitempty"`
	RequestHash          string `bun:"request_hash,notnull" json:"request_hash"`
	RequestAuthorization string `bun:"request_authorization,nullzero" json:"request_authorization,omitempty"`

	// Response fields
	StatusCode            int    `bun:"status_code,default:0" json:"status_code"`
	StatusPhrase          string `bun:"status_phrase,nullzero" json:"status_phrase,omitempty"`
	ResponseHTTPVersion   string `bun:"response_http_version,nullzero" json:"response_http_version,omitempty"`
	ResponseContentType   string `bun:"response_content_type,nullzero" json:"response_content_type,omitempty"`
	ResponseContentLength int64  `bun:"response_content_length,default:0" json:"response_content_length"`
	RawResponse           []byte `bun:"raw_response,type:bytea,nullzero" json:"raw_response,omitempty"`
	ResponseHash          string `bun:"response_hash,nullzero" json:"response_hash,omitempty"`
	ResponseNormHash      string `bun:"response_norm_hash,nullzero" json:"response_norm_hash,omitempty"` // hash of the body with reflected URL/path + dynamic runs stripped, for reflected-URL-robust dedup
	// ResponseTimeMs is 0 when the exchange was never timed, which is not the
	// same as "it took no time". Only the requester measures, so every record
	// rebuilt from stored bytes — and every intermediate redirect hop, which
	// the transport followed inside one measurement — has nothing to report.
	//
	// omitempty so an unmeasured row omits the field entirely. A silent zero is
	// worse than an absent one: a consumer averaging over a chain gets a number
	// that is wrong rather than one it knows it cannot compute.
	ResponseTimeMs int64  `bun:"response_time_ms,default:0" json:"response_time_ms,omitempty"`
	ResponseWords  int64  `bun:"response_words,default:0" json:"response_words"`
	HasResponse    bool   `bun:"has_response,notnull,default:false" json:"has_response"`
	ResponseTitle  string `bun:"response_title,nullzero" json:"response_title,omitempty"`

	// ResponseLocation is the Location header of a 3xx response, stored verbatim
	// — relative values included, because rewriting them to absolute would be
	// this column asserting something the server did not say.
	//
	// It exists because the redirect tree needs exactly one header out of a
	// response whose body it has no use for. Without a column the destination is
	// only recoverable by reading raw_response back, which on a `--glob-db` read
	// means reopening every source file the selected redirects came from and
	// holding an 8 KiB prefix per row until rendering finishes. Parsing it once at
	// ingestion and storing ~30 bytes replaces all of that.
	//
	// Empty means "no Location", which for a record written by this version is
	// also the answer for a non-redirect. It does NOT distinguish "not backfilled"
	// on a record written before this column existed — see
	// redirectLocationsAreBackfilled for how the read path handles that.
	ResponseLocation string `bun:"response_location,nullzero" json:"response_location,omitempty"`

	// Parameters (JSON array, replaces http_parameters table)
	Parameters []EmbeddedParam `bun:"parameters,type:jsonb,nullzero" json:"parameters,omitempty"`

	// Timestamps
	SentAt     time.Time `bun:"sent_at,notnull,default:current_timestamp" json:"sent_at"`
	ReceivedAt time.Time `bun:"received_at,nullzero" json:"received_at,omitempty"`
	CreatedAt  time.Time `bun:"created_at,notnull,default:current_timestamp" json:"created_at"`

	Source string `bun:"source,nullzero,default:''" json:"source,omitempty"`

	// Analysis & enrichment
	Technology      []string `bun:"technology,type:jsonb,nullzero" json:"technology,omitempty"`     // detected technologies/frameworks
	ContentHash     string   `bun:"content_hash,nullzero" json:"content_hash,omitempty"`            // hash of meaningful response content for change detection
	IsAuthenticated bool     `bun:"is_authenticated,notnull,default:false" json:"is_authenticated"` // whether request was sent with valid auth
	ParentUUID      string   `bun:"parent_uuid,nullzero" json:"parent_uuid,omitempty"`              // parent record UUID (crawl/spider parent)

	// Target is the input line this record's chain started from, as it appears
	// in the run's target list (normalized to an absolute URL). Empty for
	// records that did not come from a submitted target — crawl discoveries,
	// ingest.
	//
	// It exists because hostname is THIS HOP's hostname, not the one that was
	// asked about. A followed redirect that leaves the host produces records
	// under names nobody submitted, and a consumer keying on hostname then
	// reports a host it invented. Recovering the input otherwise means walking
	// parent_uuid to the root, which no single record can answer and which a
	// truncated chain can break outright.
	Target string `bun:"target,nullzero" json:"target,omitempty"`
	// RootUUID is the first record of this record's redirect chain — itself for
	// a root. Redundant with walking ParentUUID, and deliberately so: the walk
	// is O(chain) and needs the whole stream, this is O(1) on one record and
	// survives the middle of a chain being dropped by the storage cap.
	RootUUID string `bun:"root_uuid,nullzero" json:"root_uuid,omitempty"`
	// ChainTruncated marks the last stored row of a redirect chain that did not
	// reach its destination — the follow cap was hit, or the storage cap
	// dropped hops after this one.
	//
	// Without it a 3xx at the end of a chain is ambiguous in a way that reads
	// as an answer: "this redirect pointed somewhere that never replied" and
	// "we stopped walking" produce an identical row, and the obvious consumer
	// reading ("the terminal hop is the row nothing claims as a parent") takes
	// the 3xx as final in both cases.
	ChainTruncated bool `bun:"chain_truncated,notnull,default:false" json:"chain_truncated,omitempty"`

	// Risk labeling (populated by background analysis)
	Remarks   []string `bun:"remarks,type:jsonb,nullzero" json:"remarks,omitempty"`
	RiskScore int      `bun:"risk_score,default:0" json:"risk_score"`

	// SurfaceScore is the deterministic attack-surface score (0-100) written by
	// the surface_scoring passive module, which owns the signal set and the
	// scale — see pkg/modules/passive/surface_scoring. It
	// answers "is this record worth attacking" from properties of the record
	// itself, and is reproducible across scans.
	//
	// Deliberately NOT merged into RiskScore: that column holds anomaly_ranking's
	// rank-within-batch percentile, which measures statistical rarity against
	// whichever records happened to be flushed alongside it. The two are different
	// scales from different inputs; sharing one column would mean whichever module
	// flushed last silently overwrote the other.
	SurfaceScore int `bun:"surface_score,default:0" json:"surface_score"`
}

HTTPRecord represents a single HTTP request/response record (denormalized)

func (*HTTPRecord) EnrichFromHttpRequestResponse

func (r *HTTPRecord) EnrichFromHttpRequestResponse(ctx *httpmsg.HttpRequestResponse) error

EnrichFromHttpRequestResponse fills everything PrepareIdentity left out: the owned raw bytes, all response-derived columns, request authorization, parameters, resolved IP, and timestamps. Run it only for a record that is actually going to be stored. Safe to call on a record whose identity half has already run; it does not recompute it.

func (*HTTPRecord) FromHttpRequestResponse

func (r *HTTPRecord) FromHttpRequestResponse(ctx *httpmsg.HttpRequestResponse) error

FromHttpRequestResponse populates an HTTPRecord from httpmsg.HttpRequestResponse.

The record OWNS its RawRequest/RawResponse: both are copied out of the source message rather than aliased. This is a lifetime requirement, not a style choice. A converted record routinely outlives the call that produced it — RecordWriter.admit hands it to a shard channel and returns, the flush goroutine inserts it later, and Repository.emitRecordSaved passes it to the filesystem mirror, which writes it from yet another goroutine. Meanwhile the executor builds its responses over a POOLED buffer (see pkg/core, getResponseBuffer) that processItem returns to the pool as soon as it is done with the item. Aliasing therefore let a record be persisted or mirrored with another response's bytes whenever the write outlived the caller — on scan cancellation the caller stops waiting while the record is still queued, which is exactly when this happened.

The copy (bytes.Clone below) costs one memcpy per converted record, which is small next to the two SHA-256 passes below, and is only paid for records that get enriched at all.

Conversion is split in two halves, and a caller that deduplicates should use them separately rather than calling this: PrepareIdentity fills only the columns that decide WHICH row this is, and EnrichFromHttpRequestResponse does the expensive response-derived work. See PrepareIdentity.

func (*HTTPRecord) MarshalJSON

func (r *HTTPRecord) MarshalJSON() ([]byte, error)

MarshalJSON preserves the external JSON contract by injecting four derived fields (request_headers, request_body, response_headers, response_body) alongside the stored struct fields.

func (*HTTPRecord) ParsedRequest

func (r *HTTPRecord) ParsedRequest() *httpmsg.HttpRequest

ParsedRequest parses RawRequest into an httpmsg.HttpRequest. Returns nil when there is no raw request. Each call constructs a new parser; callers that need multiple fields should hoist the result into a local, or use parsedView for the standard four-field expansion.

func (*HTTPRecord) ParsedResponse

func (r *HTTPRecord) ParsedResponse() *httpmsg.HttpResponse

ParsedResponse parses RawResponse into an httpmsg.HttpResponse, or nil when no raw response is present.

func (*HTTPRecord) ParsedView

func (r *HTTPRecord) ParsedView() (reqHdrs, respHdrs map[string][]string, reqBody, respBody []byte)

ParsedView decodes RawRequest and RawResponse exactly once each and returns the four derived fields. Use this whenever a caller needs more than one derived field — calling individual accessors for each field re-parses.

When raw bytes are absent (e.g., list endpoints exclude raw_* columns), returned values are nil — that is what makes the JSON projection in HandleListRecords drop the four derived fields.

func (*HTTPRecord) PrepareIdentity

func (r *HTTPRecord) PrepareIdentity(ctx *httpmsg.HttpRequestResponse) error

PrepareIdentity fills the columns that establish a record's identity — the ones recordDedupKey builds its key from and findDuplicateRecordUUIDs matches on — and nothing else. Cheap: a URL parse, a few header reads, and one SHA-256 over the raw request.

It exists so a duplicate can be recognized WITHOUT paying for enrichment. EnrichFromHttpRequestResponse runs two more passes over the response body (a normalized-body-hash regex, a word count), tokenizes HTML to pull out a title, and copies the raw bytes. Profiling a 64 KiB HTML response put ~57% of conversion CPU in the normalizing regex alone, and the whole conversion at ~2.6ms and ~786 KiB allocated. On duplicate-heavy ingestion — discovery and spidering re-encountering the same URLs, which is the case the dedup cache was added for — all of that was spent on records thrown away microseconds later.

Deliberately NOT here: hostname resolution. The IP is stored metadata, not identity, and scheduling it from this half would spawn a background DNS goroutine for every duplicate too.

Callers must set Source (and ProjectUUID) before building a dedup key from the result: recordDedupKey consults both.

func (*HTTPRecord) RedirectLocation

func (r *HTTPRecord) RedirectLocation() string

RedirectLocation returns where this record's response points, or "" when it is not a redirect and when the destination is not recoverable.

The stored column answers first. Parsing raw_response is the fallback for records written before response_location existed, whose column is empty whether or not the response carried a Location — the two are indistinguishable, so a legacy redirect with no Location costs one parse of bytes already in hand.

It lives here, beside the write path that fills the column, so the question "where does this redirect go" has ONE answer. The read path used to carry its own copy of the parse, which is exactly the drift this file exists to prevent.

func (*HTTPRecord) RequestBodyBytes

func (r *HTTPRecord) RequestBodyBytes() []byte

RequestBodyBytes returns the request body bytes parsed from RawRequest.

func (*HTTPRecord) RequestHeadersMap

func (r *HTTPRecord) RequestHeadersMap() map[string][]string

RequestHeadersMap returns request headers as a map[name][]values.

func (*HTTPRecord) ResponseBodyBytes

func (r *HTTPRecord) ResponseBodyBytes() []byte

ResponseBodyBytes returns the response body bytes parsed from RawResponse.

func (*HTTPRecord) ResponseHeadersMap

func (r *HTTPRecord) ResponseHeadersMap() map[string][]string

ResponseHeadersMap returns response headers as a map[name][]values.

type HostFacts

type HostFacts struct {
	A     []string       `json:"a,omitempty"`
	AAAA  []string       `json:"aaaa,omitempty"`
	CNAME []string       `json:"cname,omitempty"`
	TLS   *tlsprobe.Info `json:"tls,omitempty"`
}

HostFacts are the per-host observations a run collects alongside its records: the full DNS answer and, under --tls-probe, the TLS handshake and leaf certificate.

They are NOT columns, and not attached by HTTPRecord.MarshalJSON. Both decisions follow from the same fact — these describe the HOST, not the record:

  • Storing them per row would put many identical copies of one answer in the database (a sweep writes several records per host; a redirect chain alone is three), and they are stale the moment their TTL passes. Only the single `ip` column is persisted.
  • Attaching them inside MarshalJSON would decorate every record that every caller in the process serializes from a global cache. The ingest server marshals `[]*HTTPRecord` straight to the HTTP response, so one project's rows would carry a name resolved at an unknown time while ingesting another's.

So the emit sites that KNOW this process did the probing opt in, and everyone else keeps serializing a record as itself.

func LookupHostFacts

func LookupHostFacts(hostname string, port int) HostFacts

LookupHostFacts returns what this PROCESS observed for a record's host, or zero facts when it observed nothing (a run without --tls-probe, a plaintext endpoint, or any process that did no probing — an `export` of an old database, for instance).

func (HostFacts) AsMap

func (h HostFacts) AsMap() map[string]any

AsMap renders the facts as JSON keys, for the map-shaped agent views. Keys match httpx's names, and match the struct tags above — one definition of the key set, so the two output shapes cannot drift.

func (HostFacts) Empty

func (h HostFacts) Empty() bool

Empty reports whether this process observed nothing for the host. Empty facts are omitted entirely rather than emitted as empty arrays: "not probed" and "no records" are different answers.

type HostObservation

type HostObservation struct {
	bun.BaseModel `bun:"table:host_observations,alias:ho" json:"-"`

	ID          int64     `bun:"id,pk,autoincrement" json:"id"`
	ProjectUUID string    `bun:"project_uuid,notnull" json:"project_uuid"`
	ScanUUID    string    `bun:"scan_uuid,nullzero" json:"scan_uuid,omitempty"`
	Hostname    string    `bun:"hostname,notnull" json:"hostname"`
	Port        int       `bun:"port,notnull" json:"port"`
	ObservedAt  time.Time `bun:"observed_at,notnull,default:current_timestamp" json:"observed_at"`

	// DNS answers as jsonb columns, the same way Finding.Tags and
	// HTTPRecord.Technology store their slices. Stored whole rather than as three
	// child tables: they are read as a whole or not at all, and never queried by
	// element.
	DNSA     []string `bun:"dns_a,type:jsonb,nullzero" json:"dns_a,omitempty"`
	DNSAAAA  []string `bun:"dns_aaaa,type:jsonb,nullzero" json:"dns_aaaa,omitempty"`
	DNSCNAME []string `bun:"dns_cname,type:jsonb,nullzero" json:"dns_cname,omitempty"`

	// TLS is the handshake and leaf certificate, absent when the endpoint was not
	// handshaked (no --tls-probe, or a plaintext endpoint).
	TLS *tlsprobe.Info `bun:"tls,type:jsonb,nullzero" json:"tls,omitempty"`

	// Complete reports that the lookup actually ran to an answer. False marks an
	// observation the run never finished - a cancelled or budget-limited
	// prefetch - which is a different fact from a host that genuinely resolves to
	// nothing, and the two are indistinguishable without it.
	Complete  bool      `bun:"complete,notnull" json:"complete"`
	CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp" json:"created_at"`
}

HostObservation is what one scan observed about one endpoint: the full DNS answer and, under --tls-probe, the handshake and leaf certificate.

It is the durable counterpart to the process-local caches HostFacts reads. Those caches are bounded (8192 entries each) and evict in insertion order, so on a sweep larger than that the hosts resolved FIRST had their answers dropped before the export that wanted them - the work was done, the answer was known, and the output silently omitted it. A row here survives eviction, the process, and the run.

One row per (scan, hostname, port). Re-probing a host adds an observation, it does not replace one: the addresses a host resolved to during an earlier scan are that scan's evidence, and an upsert keyed on the host alone would erase it.

func (*HostObservation) Facts

func (o *HostObservation) Facts() HostFacts

Facts renders a stored observation back into the output shape. The columns are bun jsonb fields, so this is a field copy rather than a decode.

type HostObservationInput

type HostObservationInput struct {
	Hostname string
	Port     int
	HostFacts

	// Complete reports that the lookup ran to an answer. A cut-short prefetch
	// records incomplete observations rather than none, so a later reader can
	// tell "no AAAA record" from "we never got there".
	Complete bool
}

HostObservationInput is one endpoint's observation, as the probe collects it, before it becomes a row.

HostFacts is embedded rather than restated: the answer being stored IS the answer that will later be emitted, and copying its fields here meant two places to edit for every new fact and two Empty() methods that could silently disagree. What this type adds is the two things HostFacts cannot carry - which endpoint the answer describes, and whether the lookup finished.

func (HostObservationInput) Empty

func (o HostObservationInput) Empty() bool

Empty reports whether the observation carries nothing worth storing. A row with no answer AND no completeness claim states nothing that the absence of a row does not already state.

type HostStats

type HostStats struct {
	Scheme       string `json:"scheme"`
	Hostname     string `json:"hostname"`
	Port         int    `json:"port"`
	RequestCount int64  `json:"request_count"`
	FindingCount int64  `json:"finding_count"`
}

HostStats holds statistics for a host

type HostTarget

type HostTarget struct {
	Scheme   string `bun:"scheme"`
	Hostname string `bun:"hostname"`
	Port     int    `bun:"port"`
}

HostTarget represents a distinct scheme+hostname+port combination from HTTP records.

type MergeOptions

type MergeOptions struct {
	// SkipHTTPRecords omits the http_records table from the copy. It is safe for
	// readers that never resolve a finding's linked records: finding_records
	// carries no foreign key to http_records (it is a bare
	// PRIMARY KEY (finding_id, record_uuid) table), and findings embed their own
	// request/response inline, so the junction and every finding still merge
	// intact — a later record lookup simply resolves to nothing.
	//
	// This exists because http_records holds the raw request/response blobs and
	// is ~98% of the bytes in a typical result database. A read that renders
	// purely from findings (the finding tree/table/JSON views) would otherwise
	// pay to copy every blob into the destination only to discard it.
	SkipHTTPRecords bool

	// SkipRecordBodies copies http_records without its raw_request/raw_response
	// columns, which are nullable and hold ~96% of the table's bytes. Every row
	// still lands, with its metadata intact, so row counts and any predicate over
	// metadata (status/method/host/content-type) are unaffected — unlike dropping
	// rows, this changes nothing observable for such a reader.
	//
	// It is only safe when nothing downstream reads the raw corpus: a filter that
	// LIKEs over it (see QueryFilters.UsesRawCorpus) silently matches nothing, and
	// a renderer that prints it silently prints empty. Ignored when
	// SkipHTTPRecords is set, which already omits the table.
	SkipRecordBodies bool

	// SkipFindings omits findings, the finding_records junction, and
	// oast_interactions from the copy. Safe only for a reader that shows no
	// finding and filters no record by one.
	//
	// It is the only option here that bounds the MERGING process's memory rather
	// than the destination's size. The three tables it drops are the ones that
	// cannot be copied set-based — findings.id is AUTOINCREMENT and is referenced
	// by both the junction and the OAST rows, so the id remapping needs every
	// source row in Go before the write transaction opens. Each finding row
	// carries its inline request, response, and additional_evidence, so a corpus
	// with many findings is buffered in full, per source, whether or not anything
	// will read one.
	SkipFindings bool
}

MergeOptions tunes what a SQLite-to-SQLite merge copies.

type MergeStats

type MergeStats struct {
	ProjectsMerged       int
	ScansMerged          int
	AgenticScansMerged   int
	RecordsMerged        int
	FindingsMerged       int
	FindingsDeduped      int
	FindingRecordsMerged int
	OASTMerged           int
}

MergeStats reports what a SQLite-to-SQLite merge copied into the destination.

func MergeSQLiteFile

func MergeSQLiteFile(ctx context.Context, dest *DB, srcPath string) (*MergeStats, error)

MergeSQLiteFile merges every native-scan result row from the SQLite database at srcPath into dest (which must be an already-open, schema-current SQLite database). It is safe to call repeatedly with the same source: rows dedup on their natural keys (uuid for records/scans, (project_uuid, finding_hash) for findings), so a re-merge is idempotent. project_uuid values are copied verbatim, so the merged rows stay scoped to whatever project the scan ran under.

The whole operation runs on a single pinned connection inside one BEGIN IMMEDIATE transaction, and retries on SQLITE_BUSY/locked so it tolerates other processes briefly holding the destination's write lock.

func MergeSQLiteFileWithOptions

func MergeSQLiteFileWithOptions(ctx context.Context, dest *DB, srcPath string, opts MergeOptions) (*MergeStats, error)

MergeSQLiteFileWithOptions is MergeSQLiteFile with control over what is copied; see MergeOptions.

func (*MergeStats) Add

func (s *MergeStats) Add(o *MergeStats)

Add accumulates o's counters into s, for aggregating the stats of several merges (e.g. a multi-source import) into a single total.

type OASTInteraction

type OASTInteraction struct {
	bun.BaseModel `bun:"table:oast_interactions,alias:oi" json:"-"`

	ID            int64     `bun:"id,pk,autoincrement" json:"id"`
	ProjectUUID   string    `bun:"project_uuid,notnull" json:"project_uuid"`
	ScanUUID      string    `bun:"scan_uuid,nullzero" json:"scan_uuid,omitempty"`
	UniqueID      string    `bun:"unique_id,notnull" json:"unique_id"`
	FullID        string    `bun:"full_id,notnull" json:"full_id"`
	Protocol      string    `bun:"protocol,notnull" json:"protocol"`
	QType         string    `bun:"q_type,nullzero" json:"q_type,omitempty"`
	RawRequest    string    `bun:"raw_request,nullzero" json:"raw_request,omitempty"`
	RawResponse   string    `bun:"raw_response,nullzero" json:"raw_response,omitempty"`
	RemoteAddress string    `bun:"remote_address,nullzero" json:"remote_address,omitempty"`
	InteractedAt  time.Time `bun:"interacted_at,notnull" json:"interacted_at"`

	// Correlated context from payload tracker
	TargetURL     string `bun:"target_url,nullzero" json:"target_url,omitempty"`
	ParameterName string `bun:"parameter_name,nullzero" json:"parameter_name,omitempty"`
	InjectionType string `bun:"injection_type,nullzero" json:"injection_type,omitempty"`
	ModuleID      string `bun:"module_id,nullzero" json:"module_id,omitempty"`
	FindingID     int64  `bun:"finding_id,nullzero" json:"finding_id,omitempty"` // link to finding this interaction proves
	Payload       string `bun:"payload,nullzero" json:"payload,omitempty"`       // exact payload that triggered the interaction

	CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp" json:"created_at"`
}

OASTInteraction records an out-of-band interaction received from an interactsh server.

type PathTarget

type PathTarget struct {
	Scheme   string `bun:"scheme"`
	Hostname string `bun:"hostname"`
	Port     int    `bun:"port"`
	Path     string `bun:"path"`
}

PathTarget represents a distinct scheme+hostname+port+path combination from HTTP records.

type PerformanceMetrics

type PerformanceMetrics struct {
	AvgResponseTime int64 `json:"avg_response_time"`
	MinResponseTime int64 `json:"min_response_time"`
	MaxResponseTime int64 `json:"max_response_time"`
	P50ResponseTime int64 `json:"p50_response_time"`
	P95ResponseTime int64 `json:"p95_response_time"`
	P99ResponseTime int64 `json:"p99_response_time"`
}

PerformanceMetrics holds response time statistics

type PrimaryKeyInfo

type PrimaryKeyInfo struct {
	Columns []string `json:"columns"`
}

PrimaryKeyInfo describes the primary key column(s) of a table.

func DetectPrimaryKey

func DetectPrimaryKey(ctx context.Context, db *DB, tableName string) (PrimaryKeyInfo, error)

DetectPrimaryKey returns the primary key column(s) for the given table.

type Project

type Project struct {
	bun.BaseModel `bun:"table:projects,alias:p" json:"-"`

	UUID        string `bun:"uuid,pk,notnull" json:"uuid"`
	Name        string `bun:"name,notnull" json:"name"`
	Description string `bun:"description,nullzero" json:"description,omitempty"`
	OwnerUUID   string `bun:"owner_uuid,nullzero" json:"owner_uuid,omitempty"` // soft FK → users
	ConfigPath  string `bun:"config_path,nullzero" json:"config_path,omitempty"`

	Tags          []string  `bun:"tags,type:jsonb,nullzero" json:"tags,omitempty"`
	DefaultTarget string    `bun:"default_target,nullzero" json:"default_target,omitempty"`
	LastScanAt    time.Time `bun:"last_scan_at,nullzero" json:"last_scan_at,omitempty"`

	CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp" json:"created_at"`
	UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp" json:"updated_at"`
}

Project represents a logical grouping for all scan data.

type ProjectStatsRow

type ProjectStatsRow struct {
	ProjectUUID      string `bun:"project_uuid"`
	HTTPRecords      int64  `bun:"http_records"`
	HTTP2xx          int64  `bun:"http_2xx"`
	HTTP3xx          int64  `bun:"http_3xx"`
	HTTP4xx          int64  `bun:"http_4xx"`
	HTTP5xx          int64  `bun:"http_5xx"`
	Findings         int64  `bun:"findings"`
	Critical         int64  `bun:"critical"`
	High             int64  `bun:"high"`
	Medium           int64  `bun:"medium"`
	Low              int64  `bun:"low"`
	Info             int64  `bun:"info"`
	Scans            int64  `bun:"scans"`
	AgenticScans     int64  `bun:"agentic_scans"`
	OASTInteractions int64  `bun:"oast_interactions"`
}

ProjectStatsRow holds per-project aggregated counts used by GetAllProjectsStats.

type QueryBuilder

type QueryBuilder struct {
	// contains filtered or unexported fields
}

QueryBuilder builds filtered database queries

func NewQueryBuilder

func NewQueryBuilder(db *DB, filters QueryFilters) *QueryBuilder

NewQueryBuilder creates a new query builder

func (*QueryBuilder) BuildRecordsQuery

func (qb *QueryBuilder) BuildRecordsQuery() *bun.SelectQuery

BuildRecordsQuery builds a query for http_records table

func (*QueryBuilder) Count

func (qb *QueryBuilder) Count(ctx context.Context) (int64, error)

Count returns total number of matching records

func (*QueryBuilder) Execute

func (qb *QueryBuilder) Execute(ctx context.Context) ([]*HTTPRecord, error)

Execute executes the query and returns results

func (*QueryBuilder) ExecuteWithCount

func (qb *QueryBuilder) ExecuteWithCount(ctx context.Context) ([]*HTTPRecord, int64, error)

ExecuteWithCount runs the filtered query and returns the matching page of records alongside the total count for the same filters.

It is NOT one round trip, despite what this comment used to claim. Bun's ScanAndCount collapses to a single statement only when there is no limit and no offset; a paginated call — the only reason to use this method — takes the two-query path, and with no explicit conn it runs the scan and the count CONCURRENTLY, so it occupies two pool connections for its duration. On SQLite that is worth remembering when this runs alongside a busy RecordWriter.

Still preferable to Execute + Count, which serializes the same two queries. Callers that do not need an exact total on every refresh should use Execute alone rather than paying the count.

func (*QueryBuilder) GroupRecordsBy

func (qb *QueryBuilder) GroupRecordsBy(ctx context.Context, field string, limit int) (RecordGrouping, error)

GroupRecordsBy counts the filtered records by one column, largest bucket first, capped at limit (<= 0 means no cap).

The totals come from a second query ONLY when the cap actually bit. Summing the returned buckets describes the whole result exactly when every bucket came back — each filtered row lands in exactly one bucket, since COALESCE removes the NULL/empty split — and describes only the visible part otherwise, which is how a bounded view comes to report a complete-looking number that is wrong. So the expensive case is paid for and the common one is not: the filters here are the listing's filters, which include `LIKE '%…%'` over the raw request/response corpus, and running those twice reads every matched record's blobs twice to produce a dozen integers.

func (*QueryBuilder) OmitBodies

func (qb *QueryBuilder) OmitBodies() *QueryBuilder

OmitBodies drops raw_request/raw_response from the SELECT list, for callers that read only metadata off the returned records. Those two columns hold the whole request/response corpus and dwarf every other column, so hydrating them for a list view costs memory proportional to the traffic scanned rather than to the page size.

Filters are unaffected: they run in SQL against the stored columns, so this is safe even with --search/--header/--body active (contrast MergeOptions. SkipRecordBodies, which removes the columns from the destination and so cannot be used with those filters). The only requirement is that the caller not read HTTPRecord.RawRequest/RawResponse — they come back empty.

This mirrors FindingsQueryBuilder.ExecuteWithCount, which always excludes its equivalent blob columns; records can't default to that because --raw/--burp and the record views legitimately need them.

type QueryFilters

type QueryFilters struct {
	// Project scoping
	ProjectUUID string // Required: filter all queries to this project

	// Identity filtering. RecordUUIDs selects exact stored records by UUID and is
	// applied as a SQL predicate, so it narrows BEFORE pagination — a record is
	// never reported absent merely because it fell outside the current page.
	// Without it the read surface had no identity selector at all: the only flags
	// that took a record UUID (`replay -u`, `fuzz -u`) both send traffic, and a
	// UUID passed as the fuzzy search term matched zero rows because that term
	// searches URL/path/body, not identity.
	RecordUUIDs []string

	// URLsExact selects records whose stored url matches EXACTLY — an equality
	// IN (), never a LIKE.
	//
	// It exists because `--url` was the name callers reached for and the one name
	// the read surface did not have: --host takes a hostname, --path a fuzzy
	// pattern, and a URL passed as the positional term is a substring search that
	// also matches every URL containing it. So "show me this one endpoint" had no
	// spelling, and the nearest miss was answered by the flag suggester with
	// "did you mean --all?", which lifts the result cap.
	//
	// Repeated values are OR-ed within the field and AND-ed against every other
	// filter, matching how --uuid and --method already behave. No normalization,
	// no case folding, no trailing-slash equivalence: equality is against the
	// bytes that were stored, because a comparison that quietly canonicalizes is
	// one the caller cannot predict from the value they passed.
	URLsExact []string

	// Host filtering
	HostPattern string // Hostname pattern (supports wildcards)

	// Request filtering
	Methods          []string // HTTP methods (GET, POST, etc.)
	PathPattern      string   // Path pattern (supports wildcards)
	StatusCodes      []int    // Status codes (200, 404, etc.)
	ScanUUID         string   // Scan session UUID (for findings filtering)
	AgenticScanUUIDs []string // Agentic-scan UUID(s) (links agent-produced findings; pass the scan tree for nested audit/swarm runs)
	Source           string   // Filter by record source (e.g. scanner, ingest-cli, ingest-server)

	// Response filtering
	ContentType string // Filter by response content type

	// Risk filtering
	MinRiskScore    int      // Minimum risk score filter (anomaly_ranking's batch percentile)
	MinSurfaceScore int      // Minimum attack-surface score filter (surface_scoring's 0-100 signal score)
	Remark          string   // Filter by remark substring (single)
	Remarks         []string // Filter by multiple remarks (AND: record must have all)

	// Finding filtering
	FindingID      int      // Filter by finding ID
	FindingIDAfter int64    // Filter findings with ID greater than this value
	Severity       []string // Finding severity (critical, high, medium, low, info)
	Confidence     []string // Finding confidence (certain, firm, tentative)
	ModuleName     string   // Filter findings by module name
	ModuleType     string   // Filter findings by module type (active, passive, nuclei, etc.)
	FindingSource  string   // Filter findings by source (audit, spa, agent, etc.)
	RecordKinds    []string // finding (default), candidate, observation
	RepoName       string   // Filter findings by repo name
	Status         []string // Filter findings by lifecycle status (draft, triaged, false_positive, accepted_risk, fixed)

	// Date range filtering
	DateFrom *time.Time
	DateTo   *time.Time

	// Full-text search
	SearchTerm   string   // Search across URLs, paths
	SearchTerms  []string // Findings: repeatable search terms, AND-combined (each further narrows the match)
	FuzzyTerm    string   // Broad fuzzy search across multiple fields
	HeaderSearch string   // Search in headers
	BodySearch   string   // Search in request/response body

	// Negative / exclusion search — drop rows where the term appears in the same
	// corpus the positive counterpart scans.
	ExcludeTerms        []string // Repeatable, AND-combined: a row is dropped if ANY term matches (inverse of SearchTerms)
	ExcludeHeaderSearch string   // Drop rows whose header/raw corpus contains the term (inverse of HeaderSearch)
	ExcludeBodySearch   string   // Drop rows whose body/raw corpus contains the term (inverse of BodySearch)

	// Pagination
	Limit  int
	Offset int

	// Sorting
	SortBy  string // Field to sort by
	SortAsc bool   // Sort ascending (default: descending)
}

QueryFilters holds filter criteria for database queries

func (QueryFilters) EffectiveExcludeTerms

func (f QueryFilters) EffectiveExcludeTerms() []string

EffectiveExcludeTerms returns the exclusion search terms with blanks dropped, so callers can loop the result directly. Each term becomes an independent NOT (...) conjunct — a row is dropped if ANY term matches.

func (QueryFilters) EffectiveSearchTerms

func (f QueryFilters) EffectiveSearchTerms() []string

EffectiveSearchTerms returns the search terms to AND-combine: the repeatable SearchTerms when set, otherwise the single SearchTerm. Blank terms are dropped, so callers can loop the result directly.

func (QueryFilters) UsesLinkedFindings

func (f QueryFilters) UsesLinkedFindings() bool

UsesLinkedFindings reports whether any active filter reaches into findings when the query is over HTTP RECORDS. It is the mirror of UsesLinkedRecords: records have no finding columns of their own, so --severity resolves through a join over the finding_records junction (see applyFilters).

A caller that would otherwise omit the finding tables entirely (globDBSkipSet.Findings, MergeOptions.SkipFindings) MUST check this first. The failure is silent: with no findings to join, a --severity record listing returns nothing rather than erroring.

Keep in sync with applyFilters.

func (QueryFilters) UsesLinkedRecords

func (f QueryFilters) UsesLinkedRecords() bool

UsesLinkedRecords reports whether any active filter reaches into http_records when the query is over FINDINGS. It is the findings-side counterpart to UsesRawCorpus and a strictly wider question: a findings query has no record columns of its own, so host/path/method/status/source all resolve through an EXISTS over the finding_records junction (see applyFindingFilters), and every raw-corpus filter does too.

The question does not arise for traffic, whose query is over http_records directly — there the rows are always present by construction.

A caller that would otherwise omit the table entirely (globDBSkipSet.Records, MergeOptions.SkipHTTPRecords) MUST check this first. The failure is silent: with no rows to satisfy them, every EXISTS predicate is false, so --host returns nothing and the NOT EXISTS exclude forms return everything.

Keep in sync with applyFindingFilters.

func (QueryFilters) UsesRawCorpus

func (f QueryFilters) UsesRawCorpus() bool

UsesRawCorpus reports whether any active filter matches against the raw_request/raw_response blob columns — the FuzzyTerm branches, recordSearchPredicate (--search) and the region predicates behind --header/--body (see message_regions.go), plus all three exclude forms.

A caller that would otherwise avoid materializing those blobs (a projected SELECT, or a merge that omits the columns) MUST check this first and keep them when it returns true. The failure is silent, not an error: with the blobs absent every LIKE matches nothing, so --search returns an empty result and the --exclude-* forms — which are negated — return everything.

Keep in sync with applyFilters; this is the reason to add a field here rather than open-code the flag checks at each call site.

type ReSpiderCandidate

type ReSpiderCandidate struct {
	UUID                string `bun:"uuid"`
	URL                 string `bun:"url"`
	Scheme              string `bun:"scheme"`
	Hostname            string `bun:"hostname"`
	Port                int    `bun:"port"`
	Source              string `bun:"source"`
	ResponseContentType string `bun:"response_content_type"`
	RawResponse         []byte `bun:"raw_response"`
}

ReSpiderCandidate is a lightweight projection of an HTTP record used by the targeted re-spider phase. It carries just enough to evaluate rich/SPA content and dedup app shells (source + raw_response) without materializing every column.

type RecordCounts

type RecordCounts struct {
	HTTPRecords     int64   `json:"http_records"`
	WithResponse    int64   `json:"with_response"`
	ResponsePercent float64 `json:"response_percent"`
	Findings        int64   `json:"findings"`
}

RecordCounts holds counts of each record type

type RecordGroup

type RecordGroup struct {
	Value string `json:"value"`
	Count int64  `json:"count"`
}

RecordGroup is one bucket: a column value and how many records carry it.

type RecordGrouping

type RecordGrouping struct {
	// Field is the public name that was grouped on, echoed back.
	Field string `json:"field"`
	// Groups are the largest buckets first, at most the requested limit.
	Groups []RecordGroup `json:"groups"`
	// DistinctGroups is how many buckets exist in total, whatever Groups holds.
	DistinctGroups int64 `json:"distinct_groups"`
	// TotalRecords is how many records the filters matched, whatever was bucketed.
	TotalRecords int64 `json:"total_records"`
	// OtherGroups / OtherRecords describe the tail beyond the limit. Both zero
	// means the grouping is complete.
	OtherGroups  int64 `json:"other_groups"`
	OtherRecords int64 `json:"other_records"`
}

RecordGrouping is a bounded answer, and says so.

A grouping is not automatically small — one recorded case grouped 56 rows into 54 distinct buckets, which is a listing wearing a summary's name. Reporting only the top N without saying what was left out turns that into a wrong answer, so the tail is counted rather than dropped.

type RecordLineage added in v0.4.9

type RecordLineage struct {
	// ParentUUID is the record this one redirected from. Empty for a root.
	ParentUUID string
	// RootUUID is the first record of the chain. Empty means "this row is the
	// root", which applyTo resolves to the row's own UUID.
	RootUUID string
	// Target is the submitted input line the chain started from, verbatim.
	Target string
	// ChainTruncated marks a row whose destination is not the row after it.
	ChainTruncated bool
}

RecordLineage is the chain metadata one record carries: where it sits in a redirect chain, and which submitted target the chain started from.

Grouped into a struct rather than added to the parameter lists because the four fields are one fact about one row and are always decided together — a hop's parent, root and target all come from the same walk, and splitting them across positional arguments is how a caller ends up passing a root from one chain with a parent from another.

type RecordResponseUpdate

type RecordResponseUpdate struct {
	StatusCode            int
	StatusPhrase          string
	ResponseHTTPVersion   string
	ResponseContentType   string
	ResponseContentLength int64
	RawResponse           []byte
	ResponseHash          string
	ResponseTimeMs        int64
}

RecordResponseUpdate contains response fields to update on an existing HTTP record. Used by the traffic --replay --in-replace feature.

type RecordWithHostFacts

type RecordWithHostFacts struct {
	Record *HTTPRecord
	Facts  HostFacts
}

RecordWithHostFacts is an HTTPRecord serialized together with its host facts. Marshals as the record's own object with the fact keys merged in at the top level, which is where httpx puts them and therefore where a consumer written against that output looks.

func (RecordWithHostFacts) MarshalJSON

func (rw RecordWithHostFacts) MarshalJSON() ([]byte, error)

MarshalJSON merges the fact keys into the record's own object rather than nesting them under a wrapper, so the emitted shape is the record a consumer already parses plus the extra keys.

type RecordWriter

type RecordWriter struct {
	// contains filtered or unexported fields
}

RecordWriter serializes database writes through sharded goroutines that coalesce individual SaveRecord calls into batch transactions. Records are routed to shards by hashing the host name, so writes for the same host are serialized within a shard. With Shards=1 (default), behavior is identical to a single-goroutine writer. This eliminates SQLite SQLITE_BUSY errors under concurrent ingestion.

func NewRecordWriter

func NewRecordWriter(repo *Repository, cfg RecordWriterConfig) *RecordWriter

NewRecordWriter creates and starts a RecordWriter. Call Close() to flush remaining records and stop the background goroutines.

func (*RecordWriter) Close

func (w *RecordWriter) Close()

Close stops accepting new writes, flushes remaining records, and returns.

Ordering matters: it marks the writer closed under the admission write lock (so no new write can admit past this point), waits for every already-admitted enqueue to finish landing in a shard channel, and only THEN cancels the flush context. That guarantees the shutdown drain observes every admitted record — none is orphaned in a channel after the flush loop exits, and every admitted write receives its real result instead of a spurious "closed" error.

func (*RecordWriter) Metrics

func (w *RecordWriter) Metrics() RecordWriterMetrics

Metrics returns a snapshot of the writer's counters.

func (*RecordWriter) SaveRecord

func (w *RecordWriter) SaveRecord(ctx context.Context, rr *httpmsg.HttpRequestResponse, source string, projectUUID string) (string, error)

SaveRecord implements the network.RecordSaver interface by delegating to Write.

func (*RecordWriter) SaveRecordBatch

func (w *RecordWriter) SaveRecordBatch(ctx context.Context, records []*httpmsg.HttpRequestResponse, source string, projectUUID string) ([]string, error)

SaveRecordBatch enqueues records to their shards BEFORE awaiting any result, so a single bulk producer's records land in the same writer batch instead of paying one flush interval per record (the trap of a Write-in-a-loop: Write blocks until its record is flushed, so a lone producer never fills a batch). Results are returned positionally aligned with the input — uuids[i] corresponds to records[i] — including deduplicated, invalid, and failed entries, so callers can't misassociate a UUID. The first error encountered is returned alongside the (still complete) uuid slice.

Submissions are admitted in BOUNDED slices rather than all at once. Callers hand this whole collections: discovery submits an entire provenance group in one call, which on a large corpus is tens of thousands of records. Admitting them together made every one of their converted HTTPRecords — each owning a full raw request and response — plus a result channel each live simultaneously, so peak memory scaled with the corpus rather than with anything configured. Slices stay large enough to fill many writer batches, so the coalescing this method exists for is unaffected.

func (*RecordWriter) Write

func (w *RecordWriter) Write(ctx context.Context, rr *httpmsg.HttpRequestResponse, source string, projectUUID string) (string, error)

Write enqueues a record for batched insertion. It blocks until the record is persisted (or the context is cancelled). This is safe to call from multiple goroutines concurrently.

func (*RecordWriter) WriteWithLineage added in v0.4.9

func (w *RecordWriter) WriteWithLineage(ctx context.Context, rr *httpmsg.HttpRequestResponse, source string, projectUUID string, lineage RecordLineage) (string, error)

WriteWithLineage is Write carrying the record's full chain metadata. See RecordLineage, and admit for why it is applied to new rows only.

type RecordWriterConfig

type RecordWriterConfig struct {
	// BufferSize is the channel capacity. Senders block when the buffer is full,
	// providing natural backpressure. Default: 4096.
	BufferSize int

	// BatchSize is the maximum number of records flushed in a single transaction.
	// Default: 128.
	BatchSize int

	// Shards is the number of independent flush goroutines. Each shard has its
	// own channel and flushLoop. Records are routed to shards by hashing the
	// host name, so writes for the same host are serialized within a shard.
	// Default (when <= 0): 1 for SQLite, 4 for PostgreSQL. SQLite is a
	// single-writer database, so multiple flush goroutines would only contend
	// on BEGIN IMMEDIATE / busy_timeout without gaining write parallelism;
	// PostgreSQL supports genuinely concurrent writers, so it fans out.
	Shards int

	// FlushTimeout bounds the shutdown drain, not steady-state flushes. Normal
	// flushes run on an uncancellable context.Background() so a slow insert never
	// drops records; only the drain triggered by Close() is capped by this single
	// budget, so a wedged database can't block Close() forever while a healthy one
	// still drains in full. Default: 2m (far longer than any healthy drain).
	FlushTimeout time.Duration

	// DedupCacheSize bounds the in-memory dedup cache (LRU) that maps a record's
	// dedup key to its UUID. A cache hit lets Write skip the per-record SELECT and
	// the redundant insert for a key already seen by this writer (common in
	// discovery/spidering, which re-encounter the same URLs). Zero means "unset"
	// and selects the default of 50000; pass a NEGATIVE size to disable the cache.
	DedupCacheSize int

	// MaxSubmitRecords and MaxSubmitBytes bound ONE SaveRecordBatch submission:
	// how many records (and how many raw request+response bytes) it admits before
	// waiting for that slice to resolve. They cap the peak memory of a bulk caller
	// that hands over a whole collection at once — without them, peak retention is
	// set by the caller's corpus size and nothing else.
	//
	// Both defaults sit well above BatchSize, so a submission still fills many
	// writer batches and the coalescing SaveRecordBatch exists for is preserved.
	// Defaults: 1024 records, 128 MiB.
	MaxSubmitRecords int
	MaxSubmitBytes   int64
}

RecordWriterConfig configures the batching behavior of RecordWriter.

type RecordWriterMetrics

type RecordWriterMetrics struct {
	Enqueued    int64
	Flushed     int64
	FlushFailed int64
	FlushErrors int64
	BatchCount  int64
	BufferDepth int64
}

RecordWriterMetrics exposes counters for monitoring.

Flushed and FlushFailed partition the batch entries that reached a terminal outcome: Flushed counts entries that ended up persisted or deduplicated onto an existing row, FlushFailed counts entries whose insert transaction failed. FlushErrors counts failing BATCHES, so it is not comparable with either — one failed batch fails every entry in it.

type Repository

type Repository struct {

	// OnRecordSaved, when non-nil, is invoked with each newly inserted HTTP
	// record (deduplicated saves and dedup hits do NOT fire). OnFindingSaved is
	// the equivalent for newly inserted findings (a dedup-append to an existing
	// finding does not fire). Both run synchronously on the save goroutine, so
	// implementations must be fast and non-blocking (e.g. enqueue and return).
	// Used by the server's --mirror-fs filesystem mirror; left nil elsewhere, so
	// CLI scans and other repo users are unaffected.
	OnRecordSaved  func(*HTTPRecord)
	OnFindingSaved func(*Finding)
	// contains filtered or unexported fields
}

Repository handles HTTP record and finding storage

func NewRepository

func NewRepository(db *DB) *Repository

NewRepository creates a new repository instance

func (*Repository) AdoptRecord added in v0.4.9

func (r *Repository) AdoptRecord(ctx context.Context, uuid string, lineage RecordLineage, source string)

AdoptRecord repairs a row that lost a dedup race, in ONE statement: it backfills the chain metadata the winning row never had, and promotes a finding-evidence source to the crawl source of the phase that just fetched it.

Why any of this is needed. A passive module persists the response it reports on BEFORE the item's own save runs (processItem runs the passive stage first), so on a passive-heavy phase the evidence row routinely wins the dedup and the phase's own row — its lineage and its source label — is discarded. The visible symptom was that `vigolium traffic --source probe` returned a sweep's redirect hops and hid every page it actually fetched.

The deeper fix is to save before the passive stage rather than repair afterwards; that is a behavioural change to processItem and belongs in its own commit. Until then this is the repair, and it must stay cheap: it runs once per deduped record, which on a sweep is most of them.

One statement, not five. Every column here is conditional in SQL rather than in Go, so a no-op costs nothing beyond the row lock and the caller pays one round trip instead of one per column. Each condition encodes the same promote-only contract:

  • parent_uuid, root_uuid, target: backfilled when empty, NEVER overwritten. A shared redirect destination already belongs to the chain it was first recorded in; re-rooting it to the most recent traversal would rewrite history for every earlier chain through the same URL. A root_uuid equal to the row's own uuid counts as empty — that is the "no chain" default.
  • chain_truncated: only ever raised. A row once known to be cut short does not become whole because a later, shorter walk reached it.
  • source: a finding-evidence source is displaced because it describes HOW the row first reached the database, not what the row IS. A crawl source is never displaced by another crawl source, or "which phase found this" would degrade into "which phase saw it last".

Best-effort: a failure leaves the row readable under its old lineage, which reads as less information rather than wrong information, and must not fail the save that prompted it.

func (*Repository) AdoptRecordParent

func (r *Repository) AdoptRecordParent(ctx context.Context, uuid, parentUUID string)

AdoptRecordParent links an existing record to a parent, but ONLY when it has none. A no-op for an empty uuid or parent.

Backfill-never-overwrite is the whole contract. Filling an empty parent adds information and cannot contradict anything. Overwriting a set one would let the most recent scan to traverse a shared redirect target silently re-parent it out of the chain it was first recorded in, rewriting history for every earlier chain that ran through the same URL.

Best-effort: a failure to link leaves a shorter chain, which reads correctly as less lineage rather than as wrong lineage, and must not fail the save that prompted it.

A thin wrapper on AdoptRecord, which applies every column in one statement; kept because "link this orphan to its parent" is a question callers ask on its own.

func (*Repository) AdvanceScanCursor

func (r *Repository) AdvanceScanCursor(ctx context.Context, scanUUID string, recordCreatedAt time.Time, recordUUID string) error

AdvanceScanCursor updates the cursor position and increments ProcessedCount.

func (*Repository) AdvanceScanCursorBy

func (r *Repository) AdvanceScanCursorBy(ctx context.Context, scanUUID string, recordCreatedAt time.Time, recordUUID string, delta int64) error

AdvanceScanCursorBy updates the cursor position and increments ProcessedCount by delta.

func (*Repository) AgenticScanStatusByUUID

func (r *Repository) AgenticScanStatusByUUID(ctx context.Context, uuid string) (string, error)

AgenticScanStatusByUUID returns just the status column for a run.

It exists for the SSE log follower, which asks "is this run finished yet?" twice a second for as long as a client watches: doing that through GetAgenticScan reads the run's full row — blobs included — so N followers on a finished-but-still-watched autopilot run pull 2N megabyte-scale reads per second out of the database to learn one short string.

func (*Repository) AppendRemarks

func (r *Repository) AppendRemarks(ctx context.Context, annotations map[string][]string) error

AppendRemarks batch-appends remarks to HTTPRecords identified by UUID. Existing remarks are preserved and duplicates within each record are deduplicated.

The read and the merged write happen in ONE transaction. Remarks are a growing set written by several analysis passes against the same records, and reading the current value outside the transaction turns every append into a read- modify-write race: two passes both read ["a"], both write their own merge, and whichever commits second silently drops the other's remark. The symptom is a missing annotation, which looks like the analysis never fired.

SQLite's write lock serializes the transactions outright. On PostgreSQL the rows are locked FOR UPDATE, so a concurrent appender blocks on the read rather than proceeding from a stale copy.

func (*Repository) ApplyDeparosStatusPolicy

func (r *Repository) ApplyDeparosStatusPolicy(ctx context.Context, projectUUID string, policy DeparosStatusPolicy) (int64, map[int]int64, error)

ApplyDeparosStatusPolicy enforces the discovery status-retention policy on stored deparos records. A fuzzed path the server answers with a 4xx is usually not a discovered resource — it's a rejection (malformed/ambiguous path → 400, not-found → 404). But some 4xx statuses carry real attack surface: 401/403 prove an authz boundary exists, 405/415/422 prove a route exists but was hit with the wrong method/payload, and 429 is a pacing signal. The policy retains those (see DeparosStatusPolicy) and drops the rest, rather than deleting every 4xx and losing the signal.

Returns the number of deleted records and a status-code breakdown of them.

func (*Repository) AttributeScanToAgenticScan

func (r *Repository) AttributeScanToAgenticScan(ctx context.Context, scanUUID, agenticScanUUID string) error

AttributeScanToAgenticScan links a native scan (and every finding it produced) to the agentic scan that launched it. The run_scan tool calls this after a blocking LaunchScan returns, so the child scan's findings show up under `finding --agentic-scan <parent>` — which filters on finding.agentic_scan_uuid directly rather than joining through scans. Both the scan row and its findings are stamped; a no-op when either UUID is empty.

func (*Repository) BackfillRecordResponse

func (r *Repository) BackfillRecordResponse(ctx context.Context, uuid string, rr *httpmsg.HttpRequestResponse) error

BackfillRecordResponse populates the response columns of an existing request-only http_record stub from a freshly fetched request/response, computing every response-derived field (content-type/length, hashes, normalized hash, word count, title) via the shared FromHttpRequestResponse converter so the backfilled row matches a normally-saved record.

Discovery stores endpoints synthesized from a parsed OpenAPI/Swagger/Postman spec as request-only stubs (status 0, empty body); the executor then fetches each baseline to scan the endpoint, but without this the stub stays at status 0 with an empty response in the traffic view. The update is guarded on has_response = false so it only ever fills a genuine stub — it never clobbers a record that already captured a response, making it idempotent across the discovery and dynamic-assessment phases. An rr with no response is a no-op.

func (*Repository) CompleteScan

func (r *Repository) CompleteScan(ctx context.Context, scanUUID string, errMsg string) error

CompleteScan marks a scan as completed (or failed if errMsg is non-empty) and populates severity counts from the findings table.

func (*Repository) CountRecordsAfterCursor

func (r *Repository) CountRecordsAfterCursor(ctx context.Context, cursorAt time.Time, cursorUUID string, hosts ...HostTarget) (int64, error)

CountRecordsAfterCursor counts records after the given cursor position. A zero cursorAt means count all records. When hosts is non-empty, only records matching those in-scope origins (scheme+hostname+port) are counted.

func (*Repository) CountRecordsAfterCursorBySource

func (r *Repository) CountRecordsAfterCursorBySource(ctx context.Context, cursorAt time.Time, cursorUUID string, sources []string, hosts []HostTarget) (int64, error)

CountRecordsAfterCursorBySource is like CountRecordsAfterCursor but also filters on http_records.source. Used by scan-on-receive shallow mode to report only user-ingested traffic in the "new ingested records" status, excluding finding/scanner artefacts produced by the scan itself.

func (*Repository) CountRecordsByStatusCode

func (r *Repository) CountRecordsByStatusCode(ctx context.Context, hosts ...HostTarget) (map[int]int64, error)

CountRecordsByStatusCode returns http_record counts grouped by HTTP status code, restricted to the same in-scope origins as CountRecordsAfterCursor (empty hosts = every record). Keys are the raw numeric status codes (0 for a missing/unset status); callers bucket them into 2xx/3xx/… classes. Powers the scan-completion summary's status-class line, so its host scope matches the record count printed alongside it.

func (*Repository) CountRecordsForScan

func (r *Repository) CountRecordsForScan(ctx context.Context, projectUUID, scanUUID string) (int64, error)

CountRecordsForScan returns how many HTTP records this scan persisted.

func (*Repository) CreateAgenticScan

func (r *Repository) CreateAgenticScan(ctx context.Context, run *AgenticScan) error

CreateAgenticScan stores a new agent run record. When a row with run.UUID already exists, the call is a no-op as long as the existing project_uuid matches — this is the get-or-create path used for cross-node sync via --scan-uuid. Returns ErrScanProjectMismatch when the existing row belongs to a different project.

func (*Repository) CreateProject

func (r *Repository) CreateProject(ctx context.Context, project *Project) error

CreateProject inserts a new project.

func (*Repository) CreateScan

func (r *Repository) CreateScan(ctx context.Context, scan *Scan) error

CreateScan inserts a new scan record. When a row with scan.UUID already exists, the call is a no-op as long as the existing project_uuid matches — this is the get-or-create path used for cross-node sync via --scan-uuid. Returns ErrScanProjectMismatch when the existing row belongs to a different project.

func (*Repository) CreateScanLog

func (r *Repository) CreateScanLog(ctx context.Context, log *ScanLog) error

CreateScanLog inserts a scan log entry.

func (*Repository) CreateScanLogBatch

func (r *Repository) CreateScanLogBatch(ctx context.Context, logs []*ScanLog) error

CreateScanLogBatch inserts multiple scan log entries in a single bulk insert.

func (*Repository) CreateScanWithCursor

func (r *Repository) CreateScanWithCursor(ctx context.Context, scan *Scan) error

CreateScanWithCursor creates a Scan record. If mode is "incremental", copies cursor from the last completed scan with matching Modules. Otherwise starts at zero.

func (*Repository) CreateUser

func (r *Repository) CreateUser(ctx context.Context, user *User) error

CreateUser inserts a new user.

func (*Repository) DB

func (r *Repository) DB() *DB

DB returns the underlying database handle.

func (*Repository) DeduplicateDeparosByNormHash

func (r *Repository) DeduplicateDeparosByNormHash(ctx context.Context, projectUUID string) (int64, map[int]int64, error)

DeduplicateDeparosByNormHash collapses deparos records whose NORMALIZED response body is identical — records that differ only by a reflected request URL/path or per-request dynamic tokens (timestamps, ids). This is the case the exact (hostname, status, content_length, response_hash) dedup misses: an error or echo page that mirrors the requested URI gives every probed path a distinct body, length, and hash, so they all survive as separate records even though they are the same page. response_norm_hash (computed at storage time with the reflected URL/path stripped and dynamic runs collapsed) makes them identical.

Grouping is on (hostname, method, status_code, response_content_type, response_norm_hash); the shortest path per group survives. Records with no normalized hash (empty bodies) are left to exact-hash dedup. Returns the number of deleted records and a status-code breakdown.

func (*Repository) DeduplicateDeparosRecords

func (r *Repository) DeduplicateDeparosRecords(ctx context.Context, projectUUID string) (int64, error)

DeduplicateDeparosRecords removes duplicate deparos HTTP records. Delegates to DeduplicateRecordsBySource with source "deparos".

func (*Repository) DeduplicateFindings

func (r *Repository) DeduplicateFindings(ctx context.Context, projectUUID string, hostnames ...string) (deleted int64, grouped int64, err error)

DeduplicateFindings merges duplicate findings that share the same (module_id, severity, matched_at URL) within a project. This collapses findings where the same module fires many times on the same URL with different payloads (e.g., input-behavior-probe producing dozens of results per endpoint). Within each group, the earliest finding is kept and the request/response pairs from duplicates are collected into its AdditionalEvidence field.

When one or more hostnames are passed, the pass is scoped to findings on those hosts (an empty/omitted list dedupes the whole project). The dynamic-assessment feedback loop scopes each round to the hosts being scanned — coverage-equivalent there, since every finding produced during DA is on an in-scope host — so a growing findings table isn't full-scanned once per round. Returns the count of deleted findings and the number of groups that were merged.

func (*Repository) DeduplicateRecordsBySource

func (r *Repository) DeduplicateRecordsBySource(ctx context.Context, projectUUID, source string) (int64, error)

DeduplicateRecordsBySource removes duplicate HTTP records for a given source that share identical (hostname, method, status_code, response_content_length, response_hash). Within each group, the record with the shortest path is kept. Returns the number of deleted records.

func (*Repository) DeduplicateSoftDeparosRecords

func (r *Repository) DeduplicateSoftDeparosRecords(ctx context.Context, projectUUID string) (int64, map[int]int64, error)

DeduplicateSoftDeparosRecords removes deparos HTTP records that are "soft duplicates": same response characteristics (status, word count, content type) under the same 2-segment path prefix. This catches cases where the server echoes part of the URL in the response body, causing different response_hash values for functionally identical pages. Exact response_content_length is deliberately NOT part of the key: a reflected URL/path shifts the body length by a handful of bytes per probe, which would otherwise split an otherwise-identical family into singletons (the very case this pass exists to collapse) — word count + content type + status + path prefix is the stable shape signal. Only groups with 3+ members are collapsed. The shortest path per group is kept.

func (*Repository) DeleteAuthenticationHostname

func (r *Repository) DeleteAuthenticationHostname(ctx context.Context, id int64) error

DeleteAuthenticationHostname deletes a single session hostname by ID.

func (*Repository) DeleteAuthenticationHostnamesByHostname

func (r *Repository) DeleteAuthenticationHostnamesByHostname(ctx context.Context, projectUUID, hostname string) error

DeleteAuthenticationHostnamesByHostname deletes all session hostnames for a project+hostname.

func (*Repository) DeleteFinding

func (r *Repository) DeleteFinding(ctx context.Context, id int64) error

DeleteFinding deletes a finding by its numeric ID, including any finding_records junction rows.

func (*Repository) DeleteFindingByHash

func (r *Repository) DeleteFindingByHash(ctx context.Context, projectUUID, findingHash string) error

DeleteFindingByHash deletes the finding(s) with the given content hash within a project, including their finding_records junction rows. Used by the OAST collector to replace a weaker finding (e.g. a DNS-resolution lead) with a stronger one (the HTTP-fetch confirmation) for the same callback payload, so each payload yields exactly one finding reflecting the strongest evidence. No-op when findingHash is empty or no such finding exists.

func (*Repository) DeleteOASTInteraction

func (r *Repository) DeleteOASTInteraction(ctx context.Context, id int64) error

DeleteOASTInteraction deletes an OAST interaction by its numeric ID.

func (*Repository) DeleteOldAgenticScans

func (r *Repository) DeleteOldAgenticScans(ctx context.Context, olderThan time.Duration) (int, error)

DeleteOldAgenticScans removes finished agent runs older than the given duration. "Finished" is TerminalAgenticScanStatuses, not a literal list — see that variable.

func (*Repository) DeleteProject

func (r *Repository) DeleteProject(ctx context.Context, uuid string) error

DeleteProject deletes a project by UUID.

func (*Repository) DeleteRecord

func (r *Repository) DeleteRecord(ctx context.Context, uuid string) error

DeleteRecord deletes an HTTP record by UUID, including any finding_records junction rows.

func (*Repository) DeleteScan

func (r *Repository) DeleteScan(ctx context.Context, uuid string) error

DeleteScan deletes a scan record by UUID.

func (*Repository) ExistingRecordUUIDs

func (r *Repository) ExistingRecordUUIDs(ctx context.Context, projectUUID string, uuids []string) ([]string, error)

ExistingRecordUUIDs returns the subset of uuids that exist. It selects the uuid column straight into a []string, so validating a caller-supplied list costs neither the raw_request/raw_response blobs nor an HTTPRecord struct per row.

The answer is always scoped to a project, blank meaning the default one as everywhere else in this package. The scoping is not optional because enforcing ownership is this method's whole purpose: record UUIDs are globally unique, so an unscoped check answers "does this row exist anywhere" when the question is "does the caller own it", and a caller who learns one UUID from another engagement could pull its traffic into their own scan. An opt-out parameter would make the safe and unsafe calls look identical.

func (*Repository) FindingProjectUUID

func (r *Repository) FindingProjectUUID(ctx context.Context, id int64) (string, error)

FindingProjectUUID returns just the project a finding belongs to, for the existence-plus-scope check the mutating handlers perform before acting on it. Returns sql.ErrNoRows when the finding does not exist, like GetFindingByID.

Projected on purpose: the callers read only this one field, and a finding carries its own inline request/response blobs.

func (*Repository) GetAgenticScan

func (r *Repository) GetAgenticScan(ctx context.Context, uuid string) (*AgenticScan, error)

GetAgenticScan retrieves an agent run by UUID.

func (*Repository) GetAllProjectsStats

func (r *Repository) GetAllProjectsStats(ctx context.Context) (map[string]*ProjectStatsRow, error)

GetAllProjectsStats returns aggregated stats for all projects in bulk. Uses GROUP BY to avoid N+1 queries when listing projects.

func (*Repository) GetAuthenticationHostnamesByHostname

func (r *Repository) GetAuthenticationHostnamesByHostname(ctx context.Context, projectUUID, hostname string) ([]*AuthenticationHostname, error)

GetAuthenticationHostnamesByHostname returns session hostnames for a project+hostname, ordered by position.

func (*Repository) GetAuthenticationHostnamesByProject

func (r *Repository) GetAuthenticationHostnamesByProject(ctx context.Context, projectUUID string) ([]*AuthenticationHostname, error)

GetAuthenticationHostnamesByProject returns all session hostnames for a project, ordered by hostname then position.

func (*Repository) GetAuthenticationHostnamesByScan

func (r *Repository) GetAuthenticationHostnamesByScan(ctx context.Context, projectUUID, scanUUID string) ([]*AuthenticationHostname, error)

GetAuthenticationHostnamesByScan returns session hostnames for a project+scan, ordered by hostname then position.

func (*Repository) GetChildAgenticScans

func (r *Repository) GetChildAgenticScans(ctx context.Context, parentUUID string) ([]*AgenticScan, error)

GetChildAgenticScans returns agent runs whose ParentAgenticScanUUID matches the given UUID.

func (*Repository) GetDistinctHosts

func (r *Repository) GetDistinctHosts(ctx context.Context, projectUUID string, since ...time.Time) ([]HostTarget, error)

GetDistinctHosts returns distinct scheme+hostname+port combinations from HTTP records, filtered by project. When a non-zero `since` is supplied, only records created at/after that time are considered (used to find origins discovered during a specific scan).

func (*Repository) GetDistinctPaths

func (r *Repository) GetDistinctPaths(ctx context.Context, projectUUID string, hosts ...HostTarget) ([]PathTarget, error)

GetDistinctPaths returns distinct scheme+hostname+port+path combinations from HTTP records, filtered by project and, when hosts is non-empty, restricted to those in-scope origins (matching the executor's WithHostScopes convention). Empty hosts means no origin filter.

func (*Repository) GetFindingByID

func (r *Repository) GetFindingByID(ctx context.Context, id int64) (*Finding, error)

GetFindingByID retrieves a single finding by numeric ID.

func (*Repository) GetFindingsByRecordUUID

func (r *Repository) GetFindingsByRecordUUID(ctx context.Context, uuid string) ([]*Finding, error)

GetFindingsByRecordUUID retrieves findings that reference a specific HTTP record UUID. Since http_record_uuids is a JSONB array, we use json_each to search inside it.

func (*Repository) GetFindingsBySeverity

func (r *Repository) GetFindingsBySeverity(ctx context.Context, projectUUID, sev string, limit int) ([]*Finding, error)

GetFindingsBySeverity retrieves findings filtered by severity within a project.

func (*Repository) GetOASTInteractionByID

func (r *Repository) GetOASTInteractionByID(ctx context.Context, id int64) (*OASTInteraction, error)

GetOASTInteractionByID retrieves a single OAST interaction by its numeric ID.

func (*Repository) GetOASTInteractionsByScan

func (r *Repository) GetOASTInteractionsByScan(ctx context.Context, scanUUID string) ([]*OASTInteraction, error)

GetOASTInteractionsByScan retrieves OAST interactions for a specific scan.

func (*Repository) GetProjectByName

func (r *Repository) GetProjectByName(ctx context.Context, name string) (*Project, error)

GetProjectByName retrieves a project by name. Returns an error if zero or multiple projects match (names are not guaranteed to be unique).

func (*Repository) GetProjectByUUID

func (r *Repository) GetProjectByUUID(ctx context.Context, uuid string) (*Project, error)

GetProjectByUUID retrieves a project by UUID.

func (*Repository) GetProjectStats

func (r *Repository) GetProjectStats(ctx context.Context, projectUUID string) (*ProjectStatsRow, error)

GetProjectStats returns aggregated stats for a single project.

func (*Repository) GetReSpiderCandidates

func (r *Repository) GetReSpiderCandidates(ctx context.Context, projectUUID, afterUUID string, limit int, hosts ...HostTarget) ([]ReSpiderCandidate, error)

GetReSpiderCandidates returns records that carry a response body, for the targeted re-spider phase, using UUID-based cursor pagination (afterUUID="" for the first page). It deliberately includes spidering-sourced records too, so the caller can pre-seed its shell-dedup set with pages the browser already crawled. Ordered by uuid so paging is stable and per-host capping deterministic. Each returned row carries a full raw_response body, so the caller pages with a modest limit and reduces each page before reading the next rather than materializing every body at once.

func (*Repository) GetRecordByRequestHash

func (r *Repository) GetRecordByRequestHash(ctx context.Context, projectUUID, requestHash string) (*HTTPRecord, error)

GetRecordByRequestHash retrieves the most recent HTTP record whose raw request hashes to requestHash (the SHA-256 over the raw request bytes, matching httpmsg.HttpRequest.ID). It recovers the originating request for an out-of-band (OAST) finding when the in-memory hash→UUID resolver is no longer available — e.g. a callback that lands after the executor that planted the payload has been torn down. projectUUID may be empty to search across projects.

func (*Repository) GetRecordByUUID

func (r *Repository) GetRecordByUUID(ctx context.Context, uuid string) (*HTTPRecord, error)

GetRecordByUUID retrieves a single HTTP record by UUID

func (*Repository) GetRecordMetadataByHostname

func (r *Repository) GetRecordMetadataByHostname(ctx context.Context, projectUUID, hostname string, limit int) ([]*HTTPRecord, error)

GetRecordMetadataByHostname is GetRecordsByHostname without the raw_request / raw_response BLOB columns, for callers that only need request-line metadata (method, URL, path, status). It avoids pulling multi-KB bodies for every row when they're immediately discarded — notably the autopilot coverage probe, which scans up to 5000 rows just to build (method, URL) signatures. Use the full GetRecordsByHostname when the bodies are needed.

func (*Repository) GetRecordsByHostname

func (r *Repository) GetRecordsByHostname(ctx context.Context, projectUUID, hostname string, limit int) ([]*HTTPRecord, error)

GetRecordsByHostname retrieves HTTP records for a hostname within a project.

func (*Repository) GetRecordsByUUIDs

func (r *Repository) GetRecordsByUUIDs(ctx context.Context, uuids []string) ([]*HTTPRecord, error)

GetRecordsByUUIDs retrieves HTTP records matching the given UUIDs.

func (*Repository) GetRecordsWithResponseBody

func (r *Repository) GetRecordsWithResponseBody(ctx context.Context, projectUUID, afterUUID string, limit int, hosts ...HostTarget) ([]*HTTPRecord, error)

GetRecordsWithResponseBody returns HTTP records that have a non-empty response body, using UUID-based cursor pagination. Only columns needed for batch secret scanning are selected.

func (*Repository) GetRelatedRecords

func (r *Repository) GetRelatedRecords(ctx context.Context, uuid string, limit int) ([]*HTTPRecord, error)

GetRelatedRecords finds HTTP records with the same hostname and a path matching the path-template of the given UUID's record. Default limit 10; excludes the source record itself. Results are filtered to the same path depth as the source record.

func (*Repository) GetScanByUUID

func (r *Repository) GetScanByUUID(ctx context.Context, uuid string) (*Scan, error)

GetScanByUUID retrieves a scan by its UUID

func (*Repository) GetScanRecordsByUUIDs

func (r *Repository) GetScanRecordsByUUIDs(ctx context.Context, uuids []string) ([]*HTTPRecord, error)

GetScanRecordsByUUIDs retrieves HTTP records matching the given UUIDs, projecting only scanRecordColumns — what the dynamic-assessment scan feed consumes. This avoids loading (and JSON-deserializing) the parameters/technology/remarks jsonb columns plus ~25 unused scalar columns per record. Modules receive the reconstructed HttpRequestResponse (via recordToHttpRequestResponse), never the HTTPRecord, so no downstream consumer reads the dropped fields.

func (*Repository) GetUnprobedRecordsBySource

func (r *Repository) GetUnprobedRecordsBySource(ctx context.Context, projectUUID, source, hostname string, limit int) ([]*HTTPRecord, error)

GetUnprobedRecordsBySource returns records with has_response=false for the given source and hostname.

func (*Repository) GetUserByUUID

func (r *Repository) GetUserByUUID(ctx context.Context, uuid string) (*User, error)

GetUserByUUID retrieves a user by UUID.

func (*Repository) GroupFindingsByValue

func (r *Repository) GroupFindingsByValue(ctx context.Context, projectUUID string, opts GroupFindingOptions) (deleted int64, grouped int64, err error)

GroupFindingsByValue collapses findings that share an identical extracted value (e.g. the same leaked secret reported once per URL) into a single finding. The survivor (earliest by created_at) absorbs every duplicate's matched URLs into MatchedAt and a sample of their request/response pairs into AdditionalEvidence; the duplicates are then deleted. Grouping is keyed on (module_id, severity[, hostname], normalized extracted_results) — the value must match byte-for-byte, so findings with distinct or empty extracted values are never merged.

Modules listed in opts.ByModule are an exception: their findings collapse on (module_id, severity[, hostname]) alone, regardless of (or absent) an extracted value, and skip the Tags gate. This is for modules that fire once per asset where the per-URL value is noise (e.g. sourcemap-detect, a distinct .map filename per JS bundle).

Modules listed in opts.ByRule collapse on (module_id, rule_name, severity[, hostname]) — the same as ByModule but with the rule (module_name) kept in the key, so a module whose single id fronts many rules (secret-detect) folds repeats of one rule while keeping different rules apart. Their distinct values are unioned onto the survivor just like ByModule.

Returns the count of deleted findings and the number of groups that were collapsed.

func (*Repository) HasHostObservations

func (r *Repository) HasHostObservations(ctx context.Context) bool

HasHostObservations reports whether this database can answer endpoint lookups at all.

A database written before this table existed has nothing stored, which is a fact about it and not a failure. Asked once up front so a read of an older file degrades to the cache-only behaviour silently, instead of failing a query per endpoint about a table it was never going to have. A read-only handle cannot be migrated into having one either, and missingColumns only tracks ALTER-added columns, never whole tables.

func (*Repository) HydrateFindingRequests

func (r *Repository) HydrateFindingRequests(ctx context.Context, findings []*Finding)

HydrateFindingRequests populates the Request field on findings that were loaded without it — the list query (FindingsQueryBuilder) omits the request/response blobs for speed. It fetches id→request for the given findings in as few round-trips as one statement's worth of ids allows; findings that already carry a Request, or that have no id, are skipped. Used by display paths that need a finding's own PoC request (e.g. the tree's attack-URL rendering) without pulling the full raw view.

func (*Repository) InScopeHosts

func (r *Repository) InScopeHosts(ctx context.Context, scopeCfg config.ScopeConfig, targets []string, projectUUID, scanUUID string) []HostTarget

InScopeHosts returns the distinct (scheme, hostname, port) origins in the project that are in scope for the supplied CLI targets. An origin is in scope when its hostname passes the scope matcher AND EITHER its (scheme, port) matches one of the targets' origins (default ports normalized like stored records: https→443, http→80) OR it was discovered during the current scan (records created at/after the scan's start). Returns nil when there are no targets (no filter — a project-wide pass). This is the single source of truth for the origin scoping the executor applies via WithHostScopes, so DynamicAssessment, KnownIssueScan, spidering/discovery seeds, and the scan-completion record count all derive the same set.

The full-origin match keeps e.g. localhost:3000 separate from localhost:8080 left in the project by a prior scan, while the current-scan provenance carve-out still lets THIS scan's own discoveries on a different port (e.g. the --intensity deep port sweep, or a followed cross-port link) be scanned. Records carry no scan_uuid, so provenance is keyed on the scan's start time. If no target yields a determinable scheme/port (e.g. all bare hosts), the scheme/port constraint is dropped — a safe fallback that never over-excludes.

func (*Repository) IncrementProcessedCount

func (r *Repository) IncrementProcessedCount(ctx context.Context, scanUUID string, delta int64) error

IncrementProcessedCount adds delta to the scan's processed_count. Use this for phases that don't advance the cursor (discovery, spidering, etc.).

func (*Repository) ListAgentSections

func (r *Repository) ListAgentSections(ctx context.Context, agenticScanUUID string) ([]*AgentSection, error)

ListAgentSections returns all sections for an agentic scan ordered by seq (execution order). Empty agenticScanUUID returns an empty slice.

func (*Repository) ListAgenticScanSummaries

func (r *Repository) ListAgenticScanSummaries(ctx context.Context, projectUUID string, mode string, limit, offset int) ([]*AgenticScan, int64, error)

ListAgenticScanSummaries is ListAgenticScans with agenticScanBlobColumns dropped — same rows, same order, same total. Use it for any view that renders a run rather than replaying it; the omitted fields come back empty, so a caller that serializes the *AgenticScan verbatim (the `--json` CLI paths) must keep using ListAgenticScans.

func (*Repository) ListAgenticScans

func (r *Repository) ListAgenticScans(ctx context.Context, projectUUID string, mode string, limit, offset int) ([]*AgenticScan, int64, error)

ListAgenticScans returns paginated agent runs for a project, ordered by created_at DESC, with every column populated. Callers that only render the run should prefer ListAgenticScanSummaries.

func (*Repository) ListCandidates

func (r *Repository) ListCandidates(ctx context.Context, agenticScanUUID string, statuses ...string) ([]*AgentFindingCandidate, error)

ListCandidates returns candidates for an agentic scan, optionally filtered to the given statuses (empty statuses = all). Ordered by id so the verifier processes them in proposal order.

func (*Repository) ListFindings

func (r *Repository) ListFindings(ctx context.Context, filters QueryFilters) ([]*Finding, int64, error)

ListFindings runs a filtered findings query and returns the matching page plus the total unfiltered count, in a single round-trip. The canonical entry point for callers that want paginated results — keeps FindingsQueryBuilder behind the repository boundary so they don't need to reach for Repository.DB().

func (*Repository) ListOASTInteractions

func (r *Repository) ListOASTInteractions(ctx context.Context, projectUUID, scanUUID, protocol, moduleID, search string, limit, offset int) ([]*OASTInteraction, int64, error)

ListOASTInteractions returns a paginated, filtered list of OAST interactions. Heavy columns (raw_request, raw_response) are excluded for list performance.

func (*Repository) ListProjects

func (r *Repository) ListProjects(ctx context.Context, ownerUUID string) ([]*Project, error)

ListProjects returns projects, optionally filtered by owner.

func (*Repository) ListScanLogs

func (r *Repository) ListScanLogs(ctx context.Context, scanUUID string, level, phase string, limit, offset int) ([]*ScanLog, int64, error)

ListScanLogs returns log entries for a scan, ordered by created_at ascending. Both level and phase are optional filters; pass "" to skip.

func (*Repository) ListScans

func (r *Repository) ListScans(ctx context.Context, projectUUID string, limit, offset int) ([]*Scan, int64, error)

ListScans returns scans ordered by created_at descending with limit/offset, filtered by project.

func (*Repository) ListUsers

func (r *Repository) ListUsers(ctx context.Context) ([]*User, error)

ListUsers returns all users.

func (*Repository) LoadEnabledScopes

func (r *Repository) LoadEnabledScopes(ctx context.Context, projectUUID string) ([]*Scope, error)

LoadEnabledScopes loads enabled scope rules for a project, ordered by priority. An empty projectUUID means the default project — never "all projects". Falls back to the default project's scopes when a non-default project has none, but if the default project itself has no enabled scopes it returns empty rather than an unscoped query that would leak every project's rules across engagements.

func (*Repository) LookupHostObservation

func (r *Repository) LookupHostObservation(ctx context.Context, projectUUID, scanUUID, hostname string, port int) (HostFacts, error)

LookupHostObservation returns the observation for one endpoint, or zero facts when none is stored.

One endpoint per call, rather than loading a run's observations up front. Preloading looked right for a streaming emit path - fetch once, then answer every record from memory - and was wrong in both directions. This table accumulates a row per endpoint per RUN, so the project-wide preload returned endpoints × runs rows to build a map of endpoints: on 20k endpoints across 25 runs, exporting a two-record database peaked at 842 MB against a 120 MB baseline, with export memory growing in scan history rather than in the corpus being exported. Resolving "latest per endpoint" in SQL instead still cost a full scan and a temp b-tree. And a paginated reader (`traffic -j`, 100 rows) paid all of it to ask about a handful of hosts.

A point lookup is index-only against idx_host_obs_project_host_id, and the caller memoizes, so the cost is one query per DISTINCT endpoint actually emitted and the memory is one entry per the same.

scanUUID empty widens the read to the whole project and takes the most recently RECORDED observation - the right answer for `export` of a database whose runs this process did not perform.

func (*Repository) OverwriteRecordResponseBody

func (r *Repository) OverwriteRecordResponseBody(ctx context.Context, uuid string, rawResponse []byte) error

OverwriteRecordResponseBody replaces the stored raw response of the record with the given UUID and recomputes its derived fields (response_hash, response_norm_hash, response_words, response_content_length), keeping them consistent with dedup/resume/fingerprinting that key off those columns.

rawResponse must be a complete HTTP response (status line + headers + body). Used by the passive js-beautify module to overwrite a minified JS body with its beautified form in place. The derivation mirrors Record.FromRequestResponse so a rewritten record hashes identically to one ingested with the new body. (Distinct from UpdateRecordResponse, which is the replay feature's full response-field swap.)

func (*Repository) PauseScan

func (r *Repository) PauseScan(ctx context.Context, scanUUID string) error

PauseScan sets a scan's status to "paused".

func (*Repository) PurgeProjectData

func (r *Repository) PurgeProjectData(ctx context.Context, projectUUID string) error

PurgeProjectData deletes every row tied to projectUUID across all per-project tables. finding_records has no project_uuid, so it's pruned via a subquery on findings.id before the findings rows themselves are removed. Runs in a single transaction so a partial failure leaves the project intact.

func (*Repository) ReassignProjectData

func (r *Repository) ReassignProjectData(ctx context.Context, sourceUUID, targetUUID string) error

ReassignProjectData moves all data owned by sourceUUID to targetUUID. This should be called before deleting a project so its records are not orphaned.

func (*Repository) RecordProjectUUID

func (r *Repository) RecordProjectUUID(ctx context.Context, uuid string) (string, error)

RecordProjectUUID returns just the project a record belongs to, for the existence-plus-scope check the mutating handlers perform before acting on it. Returns sql.ErrNoRows when the record does not exist, like GetRecordByUUID.

Projected on purpose: the callers read only this one field, and a record's raw_response alone can be megabytes.

func (*Repository) RefreshScanStats

func (r *Repository) RefreshScanStats(ctx context.Context, scanUUID string) error

RefreshScanStats updates running scan stats during long-running scans where CompleteScan hasn't been called yet.

func (*Repository) ResetScanCursor

func (r *Repository) ResetScanCursor(ctx context.Context, scanUUID string) error

ResetScanCursor resets the scan cursor to the beginning so all records are re-read on the next iteration (e.g., between seed and audit phases).

func (*Repository) ResumeScan

func (r *Repository) ResumeScan(ctx context.Context, scanUUID string) error

ResumeScan sets a scan's status back to "running".

func (*Repository) SaveAgentSection

func (r *Repository) SaveAgentSection(ctx context.Context, section *AgentSection) error

SaveAgentSection inserts a new bounded-section row. The uuid carries the section identity; a duplicate uuid is a no-op (idempotent on resume).

func (*Repository) SaveAnalysisArtifact

func (r *Repository) SaveAnalysisArtifact(
	ctx context.Context,
	httpRecordUUID, kind, filename, mediaType, sha256 string,
	content []byte,
	metadata string,
) error

SaveAnalysisArtifact is the primitive-argument adapter used by input sources that deliberately avoid importing the database package in their interfaces.

func (*Repository) SaveAnalysisArtifactForRecord

func (r *Repository) SaveAnalysisArtifactForRecord(ctx context.Context, artifact *AnalysisArtifact) error

SaveAnalysisArtifactForRecord stores a content-addressed immutable artifact beside an existing HTTP record. Duplicate writes of the same record/kind/hash are idempotent.

func (*Repository) SaveAuthenticationHostname

func (r *Repository) SaveAuthenticationHostname(ctx context.Context, sh *AuthenticationHostname) error

SaveAuthenticationHostname upserts a single session hostname record. Conflict key: (project_uuid, hostname, session_name).

func (*Repository) SaveAuthenticationHostnames

func (r *Repository) SaveAuthenticationHostnames(ctx context.Context, rows []*AuthenticationHostname) error

SaveAuthenticationHostnames batch-upserts session hostname records in a transaction.

func (*Repository) SaveCandidate

func (r *Repository) SaveCandidate(ctx context.Context, cand *AgentFindingCandidate) (bool, error)

SaveCandidate inserts a proposed finding candidate, deduping on (agentic_scan_uuid, dedup_hash) via ON CONFLICT DO NOTHING so a looping model that re-proposes the same bug produces exactly one row. Returns inserted=true only when a new row was written (false on a dedup hit), so callers can keep an accurate proposed-count.

func (*Repository) SaveFinding

func (r *Repository) SaveFinding(ctx context.Context, event *output.ResultEvent, httpRecordUUIDs []string, scanUUID string, projectUUID string) error

SaveFinding stores a vulnerability finding linked to HTTP records by UUIDs. Uses INSERT ON CONFLICT for atomic dedup when finding_hash is non-empty.

func (*Repository) SaveFindingDirect

func (r *Repository) SaveFindingDirect(ctx context.Context, finding *Finding) error

SaveFindingDirect inserts a pre-built Finding directly (without ResultEvent conversion). Uses INSERT ON CONFLICT for atomic dedup when finding_hash is non-empty.

func (*Repository) SaveFindingReported

func (r *Repository) SaveFindingReported(ctx context.Context, finding *Finding) (bool, error)

SaveFindingReported is SaveFindingDirect that additionally reports whether a new finding row was inserted (true) vs collapsed into an existing finding by dedup (false). The autopilot report_finding tool uses it so its run counter only tallies distinct findings.

func (*Repository) SaveFindingsBatch

func (r *Repository) SaveFindingsBatch(ctx context.Context, writes []FindingWrite) error

SaveFindingsBatch persists a batch of findings in a single transaction, coalescing what would otherwise be one transaction (and fsync) per finding. Findings are converted up front so a conversion error skips just that finding rather than aborting the batch. If the transaction itself fails (e.g. a database-level error), every finding is retried individually so one bad finding can't drop the rest — preserving the error isolation of per-finding SaveFinding while keeping the fast path a single transaction.

func (*Repository) SaveFindingsDirectBatch

func (r *Repository) SaveFindingsDirectBatch(ctx context.Context, findings []*Finding) ([]FindingSaveResult, error)

SaveFindingsDirectBatch persists a batch of pre-built findings in a single transaction — the batch analogue of SaveFindingDirect, used by bulk importers to coalesce what would otherwise be one transaction (and fsync) per finding. The returned slice is aligned with the input; the top-level error is the first per-finding error, if any.

func (*Repository) SaveHostObservations

func (r *Repository) SaveHostObservations(ctx context.Context, projectUUID, scanUUID string, obs []HostObservationInput) error

SaveHostObservations persists a run's host observations in bounded batches.

ON CONFLICT (scan_uuid, hostname, port) updates rather than inserting: within ONE run an endpoint has one observation, so a second write for it is a refinement (the TLS handshake finishing after the DNS answer, say) and not a second event. ACROSS runs the conflict cannot fire, which is the point - re-probing a host next month adds that month's observation and leaves this one intact.

func (*Repository) SaveOASTInteraction

func (r *Repository) SaveOASTInteraction(ctx context.Context, interaction *OASTInteraction) error

SaveOASTInteraction stores an OAST interaction record.

func (*Repository) SaveRecord

func (r *Repository) SaveRecord(ctx context.Context, httpRR *httpmsg.HttpRequestResponse, source string, projectUUID string) (string, error)

SaveRecord stores a denormalized HTTP record (request + response + host + parameters). The source identifies the origin of the record (e.g. "scanner", "ingest-cli", "ingest-server", "ingest-proxy"). Returns the UUID of the saved record. If a matching record already exists (same method, hostname, path, URL, and request body), the existing UUID is returned without inserting.

func (*Repository) SaveRecordBatch

func (r *Repository) SaveRecordBatch(ctx context.Context, records []*httpmsg.HttpRequestResponse, source string, projectUUID string) ([]string, error)

SaveRecordBatch converts httpmsg.HttpRequestResponse objects to HTTPRecord models and batch-inserts them. This is the high-level batch equivalent of SaveRecord.

func (*Repository) SaveRecordWithLineage added in v0.4.9

func (r *Repository) SaveRecordWithLineage(ctx context.Context, httpRR *httpmsg.HttpRequestResponse, source string, projectUUID string, lineage RecordLineage) (string, error)

SaveRecordWithLineage is SaveRecord carrying the record's full chain metadata.

On a duplicate hit the existing row is ADOPTED rather than replaced: see AdoptRecord for which columns that repairs and why the row it repairs is so often the one that matters.

func (*Repository) SaveRecordsBatch

func (r *Repository) SaveRecordsBatch(ctx context.Context, records []*HTTPRecord) ([]string, error)

SaveRecordsBatch inserts multiple HTTP records in a single transaction. Returns the UUIDs of all successfully inserted records.

func (*Repository) ScanSeverityTally

func (r *Repository) ScanSeverityTally(ctx context.Context, scanUUID string) map[string]int

ScanSeverityTally returns a scan's persisted finding counts keyed by severity name, omitting severities with no findings. It reads the same rows the scan row's own counters are computed from, so a consumer reading the machine event stream's scan.finished and a consumer querying the database afterwards agree.

func (*Repository) SetRecordTechnology

func (r *Repository) SetRecordTechnology(ctx context.Context, technology map[string][]string) error

SetRecordTechnology batch-writes the detected technology stack onto HTTPRecords identified by UUID, replacing whatever the column held.

Replace rather than merge (the shape AppendRemarks uses) because technology is not accumulated analyst annotation: it is the fingerprint modules' verdict about the host, recomputed from scratch on every scan, and a merge would make a record carry a stack the target stopped running two scans ago.

Records are grouped by their technology list before writing, because the source is host-scoped: every record on one host shares one list, so a corpus of N records over H hosts costs H UPDATEs rather than N. The per-value UUID set is still chunked so a single busy host cannot build an IN() clause past the driver's parameter limit.

func (*Repository) StreamAnalysisArtifactsByKind added in v0.4.9

func (r *Repository) StreamAnalysisArtifactsByKind(
	ctx context.Context,
	projectUUID, kind string,
	batchSize int,
	fn func(artifact *AnalysisArtifact) error,
) error

StreamAnalysisArtifactsByKind walks stored artifacts of one kind in id order, handing each to fn. It streams rather than returning a slice because recovered source content is unbounded in aggregate — one SPA source map can carry hundreds of files — and the callers scan each body once and discard it.

A zero-length page ends the walk; fn returning an error aborts it.

func (*Repository) UpdateAgentSection

func (r *Repository) UpdateAgentSection(ctx context.Context, section *AgentSection) error

UpdateAgentSection updates a section row identified by uuid. OmitZero lets the caller pass a partial struct (e.g. only status + closing_summary + ended_at) without clobbering columns set at BeginSection time.

func (*Repository) UpdateAgenticScan

func (r *Repository) UpdateAgenticScan(ctx context.Context, run *AgenticScan) error

UpdateAgenticScan updates an agent run record by UUID. Uses OmitZero so callers (SwarmRunner.Run and the API enrichAgenticScanRecord helpers) can pass partial structs without zeroing fields they don't intend to touch. To explicitly clear a field, use a column-list update via NewUpdate().

func (*Repository) UpdateAgenticScanStorageURL

func (r *Repository) UpdateAgenticScanStorageURL(ctx context.Context, agenticScanUUID, storageURL string) error

UpdateAgenticScanStorageURL sets the storage_url field on an agentic scan record.

func (*Repository) UpdateCandidateStatus

func (r *Repository) UpdateCandidateStatus(ctx context.Context, uuid, status, verdictReason string, promotedFindingID int64) error

UpdateCandidateStatus records a verifier verdict on a candidate by uuid, setting status, verdict_reason, promoted_finding_id (when > 0), and stamping verified_at. Used by the fresh-context verifier to mark confirmed / rejected / needs_evidence.

func (*Repository) UpdateFindingSeverity

func (r *Repository) UpdateFindingSeverity(ctx context.Context, id int64, severity string) error

UpdateFindingSeverity sets the severity of a single finding by ID. Returns sql.ErrNoRows if no finding matches.

func (*Repository) UpdateFindingStatus

func (r *Repository) UpdateFindingStatus(ctx context.Context, id int64, status string) error

UpdateFindingStatus sets the lifecycle status of a single finding by ID. Returns sql.ErrNoRows if no finding matches.

func (*Repository) UpdateFindingStatusByHash

func (r *Repository) UpdateFindingStatusByHash(ctx context.Context, agenticScanUUID, findingHash, status string) (int64, error)

UpdateFindingStatusByHash sets the lifecycle status of findings matching a finding_hash within an agentic_scan_uuid scope. Used by the swarm triage writeback to promote draft findings to triaged / false_positive based on agent verdicts. Returns the number of rows updated.

func (*Repository) UpdateFindingTriage

func (r *Repository) UpdateFindingTriage(ctx context.Context, id int64, severity, description string) error

UpdateFindingTriage sets severity and description on a single finding in one statement. Used by the agent triage flow so a false-positive verdict can't land a half-updated row (severity downgraded but reasoning lost) if the process is killed between two separate UPDATEs.

func (*Repository) UpdateProject

func (r *Repository) UpdateProject(ctx context.Context, project *Project) error

UpdateProject updates an existing project.

func (*Repository) UpdateRecordAnnotations

func (r *Repository) UpdateRecordAnnotations(ctx context.Context, uuid string, riskScore *int, remarks []string) error

UpdateRecordAnnotations updates the risk_score and/or remarks of an HTTP record. Only non-nil fields are updated. Returns an error if no record matches the UUID.

func (*Repository) UpdateRecordResponse

func (r *Repository) UpdateRecordResponse(ctx context.Context, uuid string, update *RecordResponseUpdate) error

UpdateRecordResponse replaces the response fields of an existing HTTP record.

func (*Repository) UpdateRiskScores

func (r *Repository) UpdateRiskScores(ctx context.Context, scores map[string]int) error

UpdateRiskScores batch-updates risk_score on HTTPRecords identified by UUID. Uses CASE/WHEN SQL to update up to 500 records per statement, minimizing roundtrips.

func (*Repository) UpdateScan

func (r *Repository) UpdateScan(ctx context.Context, scan *Scan) error

UpdateScan updates an existing scan record

func (*Repository) UpdateScanPartial

func (r *Repository) UpdateScanPartial(ctx context.Context, scan *Scan) error

UpdateScanPartial updates a scan row by UUID, skipping fields whose Go value is the zero value. Use this when the caller only wants to touch a subset of fields (e.g. an API PATCH that should leave omitted fields unchanged). To explicitly clear a field, use a column-level Set via NewUpdate().

func (*Repository) UpdateScanStorageURL

func (r *Repository) UpdateScanStorageURL(ctx context.Context, scanUUID, storageURL string) error

UpdateScanStorageURL sets the storage_url field on a scan record.

func (*Repository) UpdateSurfaceScores

func (r *Repository) UpdateSurfaceScores(ctx context.Context, scores map[string]int) error

UpdateSurfaceScores batch-updates surface_score on HTTPRecords identified by UUID. Written by the surface_scoring passive module; see HTTPRecord.SurfaceScore for why this is a separate column from risk_score rather than a second writer of the same one.

func (*Repository) UpsertSnapshotRecord

func (r *Repository) UpsertSnapshotRecord(ctx context.Context, httpRR *httpmsg.HttpRequestResponse, source string, projectUUID string) (uuid, outcome string, err error)

UpsertSnapshotRecord stores a Burp snapshot record idempotently. When an existing request later gains a response, or its response changes, the response-derived columns are refreshed in place while the stable UUID and finding links are preserved. outcome is inserted, updated, or unchanged.

func (*Repository) UpsertUser

func (r *Repository) UpsertUser(ctx context.Context, user *User) error

UpsertUser inserts a new user or updates name/email if the UUID already exists. Returns the user's UUID.

func (*Repository) WalkJavaScriptRecords

func (r *Repository) WalkJavaScriptRecords(ctx context.Context, projectUUID, hostname string, limit int, fn func(recordURL, contentType string, body []byte) error) error

WalkJavaScriptRecords streams JavaScript responses already stored for a host by earlier phases (spidering, proxy/Burp ingestion) so the discovery engine can feed browser-collected bundles through JSTangle even though they live in the main DB rather than the ephemeral discovery sitemap. Each callback receives the record URL, its response content-type, and the decoded (gzip-inflated) response body. JavaScript is matched by content-type or a .js/.mjs URL suffix; the walk is bounded by limit and ordered by uuid for determinism. A callback error stops the walk and is returned. This satisfies the source.spideredJSProvider optional interface structurally (primitive-typed) so neither package imports the other.

type RiskPrioritizedDBInputSource

type RiskPrioritizedDBInputSource struct {
	// contains filtered or unexported fields
}

RiskPrioritizedDBInputSource processes high-risk records first, then falls back to normal cursor-based order. It implements source.InputSource.

func NewRiskPrioritizedDBInputSource

func NewRiskPrioritizedDBInputSource(db *DB, repo *Repository, scanUUID string) *RiskPrioritizedDBInputSource

NewRiskPrioritizedDBInputSource creates a DBInputSource that processes records with risk_score > 0 first (highest risk first), then continues with the normal cursor-based order for remaining records.

func (*RiskPrioritizedDBInputSource) Close

Close stops the source.

func (*RiskPrioritizedDBInputSource) CoalescedDropped

func (s *RiskPrioritizedDBInputSource) CoalescedDropped() int

CoalescedDropped returns how many records the param-shape coalescing pass removed from this scan's iteration list. Valid after the snapshot loads (i.e. after the first Next / after Execute). Zero when coalescing is disabled.

func (*RiskPrioritizedDBInputSource) Next

Next streams records high-risk-first then chronologically, via bounded keyset pages, so memory scales with the page size (not the whole eligible table).

func (*RiskPrioritizedDBInputSource) WithHostScopes

WithHostScopes sets an in-scope origin filter so only records matching these (scheme, hostname, port) origins are returned.

func (*RiskPrioritizedDBInputSource) WithParamShapeCoalescing

func (s *RiskPrioritizedDBInputSource) WithParamShapeCoalescing(maxSamples int) *RiskPrioritizedDBInputSource

WithParamShapeCoalescing enables param-shape coalescing of the snapshot: GET records that share a (host, path, query-param-name-set) are reduced to at most maxSamples value-distinct representatives, cutting redundant dynamic-assessment fan-out over value-only-different URLs (e.g. /search?q=1..N). Records stay in the database; only this scan's iteration list is pruned. The scan cursor still advances past every record, so coalesced-away records are not re-scanned in a later feedback round. maxSamples <= 0 (the default) disables it.

type Scan

type Scan struct {
	bun.BaseModel `bun:"table:scans,alias:sc" json:"-"`

	UUID        string `bun:"uuid,pk,notnull" json:"uuid"`
	ProjectUUID string `bun:"project_uuid,notnull" json:"project_uuid"`
	Name        string `bun:"name,nullzero" json:"name"`
	Description string `bun:"description,nullzero" json:"description"`
	Status      string `bun:"status,notnull,default:'running'" json:"status"` // running, paused, completed, failed, cancelled
	// Target can be a URL, domain, IP, CIDR, or file path (for imported requests)
	Target  string `bun:"target,nullzero" json:"target"`
	Modules string `bun:"modules,nullzero" json:"modules"`
	Threads int    `bun:"threads,default:0" json:"threads"`

	// Scan context
	Profile         string   `bun:"profile,nullzero" json:"profile"`                     // scanning profile used (light, full, api, etc.)
	SourcePath      string   `bun:"source_path,nullzero" json:"source_path"`             // source code path for audit/agent scans
	SourceType      string   `bun:"source_type,nullzero" json:"source_type"`             // local, git-url, gcs
	Tags            []string `bun:"tags,type:jsonb,nullzero" json:"tags"`                // arbitrary tags for filtering/grouping
	TriggeredBy     string   `bun:"triggered_by,nullzero" json:"triggered_by"`           // user, schedule, webhook, agent
	AgenticScanUUID string   `bun:"agentic_scan_uuid,nullzero" json:"agentic_scan_uuid"` // link to agentic_scan that spawned this scan

	// Soft reference to the HTTP record that triggered this scan (scan-on-receive)
	HTTPRecordUUID string `bun:"http_record_uuid,nullzero" json:"http_record_uuid"`

	// Cursor-based scan tracking
	ScanSource      string    `bun:"scan_source,nullzero" json:"scan_source"`         // "cli", "api", "scan-on-receive"
	ScanMode        string    `bun:"scan_mode,nullzero" json:"scan_mode"`             // "incremental" or "full"
	StartCursorAt   time.Time `bun:"start_cursor_at,nullzero" json:"start_cursor_at"` // cursor position at scan start
	StartCursorUUID string    `bun:"start_cursor_uuid,nullzero" json:"start_cursor_uuid"`
	CursorAt        time.Time `bun:"cursor_at,nullzero" json:"cursor_at"` // current cursor position
	CursorUUID      string    `bun:"cursor_uuid,nullzero" json:"cursor_uuid"`
	ProcessedCount  int64     `bun:"processed_count,default:0" json:"processed_count"`

	StartedAt  time.Time `bun:"started_at,notnull,default:current_timestamp" json:"started_at"`
	FinishedAt time.Time `bun:"finished_at,nullzero" json:"finished_at"`
	DurationMs int64     `bun:"duration_ms,default:0" json:"duration_ms"`
	// Summary fields (populated at end of scan)
	TotalRequests int64 `bun:"total_requests,default:0" json:"total_requests"`
	TotalFindings int64 `bun:"total_findings,default:0" json:"total_findings"`

	// Risk level counts (populated at end of scan)
	CriticalCount int64 `bun:"critical_count,default:0" json:"critical_count"`
	HighCount     int64 `bun:"high_count,default:0" json:"high_count"`
	MediumCount   int64 `bun:"medium_count,default:0" json:"medium_count"`
	LowCount      int64 `bun:"low_count,default:0" json:"low_count"`
	InfoCount     int64 `bun:"info_count,default:0" json:"info_count"`
	SuspectCount  int64 `bun:"suspect_count,default:0" json:"suspect_count"`
	// Error count (populated at end of scan)
	ErrorMessage string `bun:"error_message,nullzero" json:"error_message"`

	StorageURL string `bun:"storage_url,nullzero" json:"storage_url"`

	CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp" json:"created_at"`
	UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp" json:"updated_at"`
}

Scan represents a scan session

type ScanLog

type ScanLog struct {
	bun.BaseModel `bun:"table:scan_logs,alias:sl" json:"-"`

	ID          int64     `bun:"id,pk,autoincrement" json:"id"`
	ProjectUUID string    `bun:"project_uuid,notnull" json:"project_uuid"`
	ScanUUID    string    `bun:"scan_uuid,notnull" json:"scan_uuid"`
	Level       string    `bun:"level,notnull" json:"level"`            // trace, info, warn, error
	Phase       string    `bun:"phase,nullzero" json:"phase,omitempty"` // discovery, spidering, dynamic-assessment, etc.
	Message     string    `bun:"message,notnull" json:"message"`
	Metadata    string    `bun:"metadata,nullzero" json:"metadata,omitempty"` // JSON blob for extra context
	CreatedAt   time.Time `bun:"created_at,notnull,default:current_timestamp" json:"created_at"`
}

ScanLog represents a log entry for a scan session.

type ScanLogger

type ScanLogger struct {
	// contains filtered or unexported fields
}

ScanLogger provides a convenient interface for writing scan log entries. It is safe to use with a nil Repository (all methods become no-ops).

func NewScanLogger

func NewScanLogger(repo *Repository, scanUUID string) *ScanLogger

NewScanLogger creates a ScanLogger for the given scan. If repo is nil, all logging methods are no-ops so callers don't need nil checks.

func (*ScanLogger) Close

func (l *ScanLogger) Close()

Close stops the batcher goroutine and flushes any remaining entries.

func (*ScanLogger) Error

func (l *ScanLogger) Error(phase, message string)

Error logs an error message.

func (*ScanLogger) Flush

func (l *ScanLogger) Flush()

Flush writes all buffered trace entries to the database.

func (*ScanLogger) Info

func (l *ScanLogger) Info(phase, message string)

Info logs an informational message.

func (*ScanLogger) InfoWithMeta

func (l *ScanLogger) InfoWithMeta(phase, message string, meta map[string]interface{})

InfoWithMeta logs an informational message with structured metadata.

func (*ScanLogger) StartBatcher

func (l *ScanLogger) StartBatcher()

StartBatcher launches a background goroutine that periodically flushes buffered trace entries to the database. Call Close() to stop and flush.

func (*ScanLogger) Trace

func (l *ScanLogger) Trace(phase, message string)

Trace logs a trace-level message (raw console output). These entries are buffered and flushed in batches to avoid per-line DB inserts.

func (*ScanLogger) Warn

func (l *ScanLogger) Warn(phase, message string)

Warn logs a warning message.

type ScanSessionStats

type ScanSessionStats struct {
	ScanUUID       string `json:"scan_uuid"`
	Name           string `json:"name,omitempty"`
	Status         string `json:"status"`
	ProcessedCount int64  `json:"processed_count"`
	ScanMode       string `json:"scan_mode,omitempty"`
}

ScanSessionStats holds statistics for a scan session

type ScanView

type ScanView struct {
	*Scan
	Target              string `json:"target"`
	Modules             string `json:"modules"`
	TotalActiveModules  int    `json:"total_active_modules"`
	TotalPassiveModules int    `json:"total_passive_modules"`
}

ScanView is a display-oriented wrapper around Scan used by listing endpoints (CLI `db ls --table scans --json` and REST `GET /api/scans`). It overrides the `modules` JSON field so a fully-populated module list renders as "all" instead of a multi-kilobyte CSV, and surfaces active/passive module counts for quick tracking without forcing callers to split the CSV themselves.

type Scope

type Scope struct {
	bun.BaseModel `bun:"table:scopes,alias:s" json:"-"`

	ID          int64  `bun:"id,pk,autoincrement" json:"id"`
	ProjectUUID string `bun:"project_uuid,notnull" json:"project_uuid"`
	Name        string `bun:"name,notnull" json:"name"`
	Description string `bun:"description,nullzero" json:"description,omitempty"`

	RuleType string `bun:"rule_type,notnull" json:"rule_type"` // "include" or "exclude"

	HostPattern        string   `bun:"host_pattern,nullzero" json:"host_pattern,omitempty"`
	PathPattern        string   `bun:"path_pattern,nullzero" json:"path_pattern,omitempty"`
	ContentTypePattern string   `bun:"content_type_pattern,nullzero" json:"content_type_pattern,omitempty"` // e.g., "image/*" to exclude
	Methods            []string `bun:"methods,type:jsonb,nullzero" json:"methods,omitempty"`
	Ports              []int    `bun:"ports,type:jsonb,nullzero" json:"ports,omitempty"`
	Schemes            []string `bun:"schemes,type:jsonb,nullzero" json:"schemes,omitempty"`

	Priority      int       `bun:"priority,notnull,default:100" json:"priority"`
	Enabled       bool      `bun:"enabled,notnull,default:true" json:"enabled"`
	HitCount      int64     `bun:"hit_count,default:0" json:"hit_count"`
	LastMatchedAt time.Time `bun:"last_matched_at,nullzero" json:"last_matched_at,omitempty"`

	CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp" json:"created_at"`
	UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp" json:"updated_at"`
}

Scope defines URL/request scope rules (firewall-style: first match wins)

type ScopeEvaluator

type ScopeEvaluator struct {
	// contains filtered or unexported fields
}

ScopeEvaluator evaluates URLs against scope rules (firewall-style: first match wins, default=exclude)

func NewScopeEvaluator

func NewScopeEvaluator(rules []*Scope) *ScopeEvaluator

NewScopeEvaluator creates a new evaluator from pre-loaded scope rules

func (*ScopeEvaluator) IsInScope

func (e *ScopeEvaluator) IsInScope(scheme, hostname string, port int, method, path string) bool

IsInScope checks if a URL matches the scope rules. Iterates rules by priority. First matching rule wins. If no rule matches, returns false (default=exclude).

type SeverityCount

type SeverityCount struct {
	Severity string `bun:"severity" json:"severity"`
	Count    int64  `bun:"count" json:"count"`
}

SeverityCount holds a severity label and its count.

type StatusCodeBreakdown

type StatusCodeBreakdown struct {
	Success   int64         `json:"success"`    // 2xx
	Redirect  int64         `json:"redirect"`   // 3xx
	ClientErr int64         `json:"client_err"` // 4xx
	ServerErr int64         `json:"server_err"` // 5xx
	ByCode    map[int]int64 `json:"by_code"`
}

StatusCodeBreakdown holds status code statistics

type StoredHostFacts

type StoredHostFacts struct {
	// contains filtered or unexported fields
}

StoredHostFacts serves a run's persisted observations, falling back to the process caches.

The fallback order is deliberate. The stored row is the durable answer and survives both cache eviction and the process; the caches can still be ahead of it for an endpoint observed after the observations were written. Preferring the stored row means a large sweep emits the same facts for its first host as for its last, which the caches alone could not do: they hold 8192 entries and evict in insertion order, so on a bigger sweep the hosts resolved EARLIEST had their answers dropped before the export that wanted them.

Resolution is LAZY and memoized: one query per distinct endpoint the caller actually asks about, not a preload of the run's observations. See Repository.LookupHostObservation for why preloading was the wrong shape.

Every method is nil-receiver safe and a nil *StoredHostFacts behaves as "process caches only", so callers never branch on it.

func NewStoredHostFacts

func NewStoredHostFacts(ctx context.Context, repo *Repository, projectUUID, scanUUID string) *StoredHostFacts

NewStoredHostFacts prepares an endpoint-fact reader for an emit pass. A nil repo, or a database predating the observations table, yields a reader that answers from the process caches alone.

func (*StoredHostFacts) Facts

func (s *StoredHostFacts) Facts(hostname string, port int) HostFacts

Facts returns what was observed about one endpoint.

func (*StoredHostFacts) FactsMap

func (s *StoredHostFacts) FactsMap(hostname string, port int) map[string]any

FactsMap is Facts rendered as JSON keys for the map-shaped agent views, memoized alongside the facts so the render cost is per endpoint, not per record.

func (*StoredHostFacts) MissingCount

func (s *StoredHostFacts) MissingCount() int

MissingCount is how many distinct endpoints this pass could describe from neither the stored observations nor the process caches.

func (*StoredHostFacts) Stored

func (s *StoredHostFacts) Stored() int

Stored reports how many distinct endpoints this pass could describe.

type TableInfo

type TableInfo struct {
	Name     string `json:"name"`
	RowCount int64  `json:"row_count"`
}

TableInfo describes a table with its row count.

func ListTablesWithCounts

func ListTablesWithCounts(ctx context.Context, db *DB) ([]TableInfo, error)

ListTablesWithCounts returns all user tables with their row counts.

type UUIDListDBInputSource

type UUIDListDBInputSource struct {
	// contains filtered or unexported fields
}

UUIDListDBInputSource iterates over a pre-defined list of HTTP record UUIDs, fetching each from the database and converting to WorkItems. It implements source.InputSource.

func NewUUIDListDBInputSource

func NewUUIDListDBInputSource(repo *Repository, uuids []string) *UUIDListDBInputSource

NewUUIDListDBInputSource creates a new UUIDListDBInputSource for the given UUIDs.

func (*UUIDListDBInputSource) Close

func (s *UUIDListDBInputSource) Close() error

Close stops the source. After Close, Next will return io.EOF.

func (*UUIDListDBInputSource) Next

Next returns the next record from the UUID list as a WorkItem. Skips invalid or missing UUIDs. Returns io.EOF when all UUIDs have been processed.

type User

type User struct {
	bun.BaseModel `bun:"table:users,alias:u" json:"-"`

	UUID      string    `bun:"uuid,pk,notnull" json:"uuid"`
	Email     string    `bun:"email,nullzero" json:"email,omitempty"`
	Name      string    `bun:"name,nullzero" json:"name,omitempty"`
	CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp" json:"created_at"`
	UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp" json:"updated_at"`
}

User represents a system user.

type WriteResult

type WriteResult struct {
	UUID string
	Err  error
}

WriteResult is the outcome of a single record write.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL