analysis

package
v0.3.8 Latest Latest
Warning

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

Go to latest
Published: Nov 13, 2026 License: Apache-2.0 Imports: 23 Imported by: 0

Documentation

Overview

Package analysis finds the oddities of a PKI that no alarm covers: a certificate nobody serves, two sources issuing for the same names, a renewal loop, a wave of expiries, a weak key, a CA that ends before the certificates it issues (docs/PHASE3.md section 14).

The rules are pure Go functions over a Snapshot, a plain copy of what the server knows at one moment (Load builds it through the existing services). Each rule answers in one of three states: ok, findings, or unknown when its input is missing (D9: a rule never claims more than the snapshot shows). Nothing is stored, nothing is dismissed and nothing leaves the server: the package opens no connection (a test checks the import list), and findings never become alerts, e-mails or webhooks (D8).

Untrusted text. Certificate, agent and CA names come from CSRs, ACME clients and agents. Findings carry them as raw strings, with control and bidi characters removed and clipped to a fixed length, inside fixed text templates; they never hold markup. Escaping is the job of the template and the JSON encoder that render them.

Index

Constants

View Source
const (
	SeverityInfo     = "info"
	SeverityWarning  = "warning"
	SeverityCritical = "critical"
)

Finding severities (D10). "info" is never a call to act.

View Source
const (
	StateOK       = "ok"
	StateFindings = "findings"
	StateUnknown  = "unknown"
)

Rule states (D9).

View Source
const (
	RuleOrphanedCertificate   = "orphaned_certificate"
	RuleDuplicateCertificates = "duplicate_certificates"
	RuleRenewalLoop           = "renewal_loop"
	RuleExpiryWave            = "expiry_wave"
	RuleWeakKey               = "weak_key"
	RuleWeakSignature         = "weak_signature"
	RuleCACliff               = "ca_cliff"
	RuleOutdatedAgent         = "outdated_agent"
	RuleUnusedACMEAccount     = "unused_acme_account"
	RuleUnusedDomain          = "unused_domain"
)

Rule IDs. They are stable and appear in the API (docs/PHASE3.md section 14.2).

View Source
const CacheTTL = time.Minute

CacheTTL is how long a finished analysis is reused: the monitor's period.

View Source
const FailTTL = 5 * time.Second

FailTTL is how long a failed run is remembered: for this long every caller gets the same error at once, instead of each one repeating the full read of the tenant against a database that is failing.

View Source
const MaxFindingsPerRule = 200

MaxFindingsPerRule bounds the findings a Result keeps; the rest is counted.

View Source
const RecentWindow = 7 * 24 * time.Hour

RecentWindow is how far back Snapshot.Recent reaches.

Variables

This section is empty.

Functions

func CleanText

func CleanText(s string) string

CleanText drops control characters (line breaks, TAB, ESC), format characters (bidi overrides and isolates, zero-width marks) and invalid UTF-8 from text that came from a certificate, a CSR or an agent. A name that reorders the text around it or hides a character must not reach a page, a log or an API consumer. The result is still data: the template escapes it.

func SafeHref

func SafeHref(href string) string

SafeHref returns href if it is a path inside the UI: one leading slash, then no second slash, no backslash and no byte below 0x20 or 0x7f. Browsers strip TAB, CR and LF from a URL, so "/<TAB>/host" would become "//host", and some treat a backslash like a slash. Anything else gives "": the text of a finding stays, its link goes.

Types

type Account

type Account struct {
	ID, Status  string
	AgentLinked bool
	CreatedAt   time.Time
	// LastOrderAt is zero when the account never ordered.
	LastOrderAt time.Time
}

Account is an ACME account with its last order.

type Agent

type Agent struct {
	ID, Hostname, Version string
	// Status is active, disabled or revoked.
	Status      string
	Deployments []Deployment
}

Agent is an enrolled agent with its deployments.

type Agents

type Agents interface {
	ListAgents(ctx context.Context, who keys.Who) ([]agents.Agent, error)
	GetAgent(ctx context.Context, who keys.Who, id string) (*agents.Agent, error)
}

Agents is what Load reads about agents (*agents.Service).

type Analysis

type Analysis struct {
	GeneratedAt time.Time
	// Results has one entry per rule, in the order of Rules.
	Results []Result
	// Findings are all findings of all rules, most severe first. Like
	// Results it holds at most MaxFindingsPerRule per rule; Summary counts
	// every finding.
	Findings []Finding
	Summary  Summary
}

Analysis is the outcome of one run.

func Analyze

func Analyze(ctx context.Context, d Deps, now time.Time) (*Analysis, error)

Analyze loads the snapshot and runs the rules at now.

func (*Analysis) Only

func (a *Analysis) Only(id string) (*Analysis, bool)

Only returns the analysis restricted to one rule, or false if there is no such rule. The summary then covers that rule only.

type CA

type CA struct {
	ID, Name string
	// Issuing: active, with a key that is not offline, so it signs
	// end-entity certificates.
	Issuing bool
	// MaxValidityDays caps the validity of the leaves the CA issues; the
	// engine shortens a longer default to it. 0 = not known, no cap assumed.
	MaxValidityDays int
	Generations     []Generation
}

CA is a certificate authority of the tenant.

type Certificate

type Certificate struct {
	ID, Serial, Fingerprint string
	// Source is the certificates.source column: issued, imported or external.
	Source string
	CAID   string
	CAName string
	// DNSNames and IPs are the names; CommonName is used when there are none.
	DNSNames, IPs []string
	CommonName    string
	// Class and IssuerKey say who issued it (ca.SourceUI, ca.SourceACME,
	// ca.SourceAgent; "ui", "acme:<account>", "agent:<id>"). Both are "" for
	// an imported or external certificate, and for an issued one whose origin
	// could not be read.
	Class, IssuerKey               string
	NotBefore, NotAfter, CreatedAt time.Time
	// Traits is nil when the DER could not be parsed.
	Traits *ca.Traits
}

Certificate is an end-entity certificate of the tenant.

type Counts

type Counts struct{ Critical, Warning, Info int }

Counts are findings by severity.

func (Counts) Total

func (c Counts) Total() int

Total is every finding counted.

type Deployment

type Deployment struct {
	Name, State, Serial, CertificateID, Fingerprint string
	Removed                                         bool
}

Deployment is a certificate an agent keeps on its host.

type Deps

type Deps struct {
	Tenant string // for the permission check of Service.Get
	Engine Engine
	Agents Agents    // nil = the agent listener is off
	Scan   Endpoints // nil = no scanning
	// ScanOff: scanning is switched off (monitor.scan_interval: off); the
	// endpoints that are entered then keep their last result, which no rule
	// should lean on.
	ScanOff       bool
	ServerVersion string
	Clock         clock.Clock
	// Who reads the data: a system principal that may read the whole tenant.
	// The zero value means authz.System("analysis").
	Who keys.Who
	// Log gets the debug line with the duration of a run; nil = no logging.
	Log *slog.Logger
}

Deps are the inputs of one tenant's analysis.

type Endpoint

type Endpoint struct {
	ID, Host string
	Port     int
	// Name is the name the certificate is expected for (SNI, else the host).
	Name string
	// LastOKAt is the last successful scan; zero = never. Fingerprint is the
	// leaf it served then (it stays while later scans fail) and
	// FingerprintSince the first successful scan that saw it.
	LastOKAt         time.Time
	Fingerprint      string
	FingerprintSince time.Time
}

Endpoint is a TLS endpoint the scan visits, with its last scan.

type Endpoints

type Endpoints interface {
	List(ctx context.Context, who keys.Who) ([]tlsscan.Endpoint, error)
}

Endpoints is the list of TLS endpoints (*tlsscan.Service). Only the stored result of the last scan is read; the analysis never connects anywhere.

type Engine

type Engine interface {
	Certificates(ctx context.Context, who keys.Who, f ca.CertificateFilter) (*ca.CertificatePage, error)
	CAs(ctx context.Context, who keys.Who) ([]ca.CAInfo, error)
	CertificateTraits(ctx context.Context, who keys.Who) (map[string]ca.Traits, error)
	CertificateOrigins(ctx context.Context, who keys.Who) (map[string]ca.Origin, error)
	CertificateNames(ctx context.Context, who keys.Who) ([]ca.CertName, error)
	ACMEAccountActivity(ctx context.Context, who keys.Who) (accounts []ca.AccountActivity, truncated bool, err error)
	ListDomains(ctx context.Context, who keys.Who) ([]ca.Domain, error)
}

Engine is what Load reads from the CA engine (*ca.Engine).

type Finding

type Finding struct {
	// Key is "rule:subject id", stable between runs.
	Key  string
	Rule string
	// Severity is SeverityInfo, SeverityWarning or SeverityCritical.
	Severity string
	// Subject names what it is about (a certificate, an agent, a week).
	Subject string
	// Seen is what the snapshot shows; Why says why it matters; Recommendation
	// says what to do. A finding never states more than Seen.
	Seen, Why, Recommendation string
	// Href is a path inside the UI ("/certificates/<id>"), or "".
	Href string
}

Finding is one thing a rule saw. Every field is text, none is markup.

func Findings

func Findings(results []Result) []Finding

Findings flattens the results into one list sorted by severity (most severe first), rule and subject.

type Generation

type Generation struct {
	Number  int64
	Current bool
	// Signed: the certificate exists (a pending generation has none).
	Signed  bool
	Revoked bool
	// Intermediate: the CA certificate has an issuer; a root signs itself.
	Intermediate bool
	NotAfter     time.Time
	Signature    x509.SignatureAlgorithm
}

Generation is one key and certificate of a CA.

type Missing

type Missing struct {
	// Agents: the agent listener is off, so there is no agent data.
	Agents string
	// Scan: the TLS scan is off, so no endpoint was scanned.
	Scan string
}

Missing names the input a rule cannot do without, when Load could not get it. A rule that depends on it answers "unknown" with this reason.

type Result

type Result struct {
	ID, Title string
	// State is StateOK, StateFindings or StateUnknown.
	State string
	// Counts are all findings of the rule by severity; Findings holds at most
	// MaxFindingsPerRule of them, most severe first.
	Counts   Counts
	Findings []Finding
	// Unassessed is how many things the rule could not judge for lack of data;
	// UnknownReason says why. With State unknown the reason is the whole
	// answer; with State findings it qualifies them ("and N could not be
	// assessed").
	Unassessed    int
	UnknownReason string
}

Result is what one rule found.

func CACliff

func CACliff(s Snapshot) Result

CACliff flags an issuing CA whose current certificate ends sooner than the default validity of the certificates it issues. The engine shortens a default validity to the end of the CA and refuses a longer explicit one, so every certificate issued from now on ends with the CA. The default is the ACME default: the longest validity_days of the domains of the CA, 90 days without a domain, and never more than the CA's maximum validity. The monitor's ca_expiry alert stays the alarm.

func DuplicateCertificates

func DuplicateCertificates(s Snapshot) Result

DuplicateCertificates flags valid certificates with identical names from two sources (warning) or three or more from one source (info). A certificate counts only while more than a third of its lifetime is left, so the overlap of a renewal does not.

func ExpiryWave

func ExpiryWave(s Snapshot) Result

ExpiryWave flags a calendar week (ISO, UTC) in which at least 10 valid certificates and at least 30% of all valid certificates end. A tenant with fewer than 20 valid certificates cannot show a wave: unknown.

func OrphanedCertificate

func OrphanedCertificate(s Snapshot) Result

OrphanedCertificate flags a current certificate that no scanned endpoint and no agent has served for a week although endpoints that cover its names were scanned. It says "not seen", never "unused": the scan reaches only the endpoints somebody entered, and only their last answer is stored.

Evidence needed: an endpoint that covers one of the names, was scanned successfully within 7 days, serves another certificate, and has done so since at least 7 days (FingerprintSince), so no scan of the window can have seen this certificate. A certificate without such an endpoint is not judged; the rule then reports one summary count, never a finding per certificate.

func OutdatedAgent

func OutdatedAgent(s Snapshot) Result

OutdatedAgent flags an active agent older than the server: a minor version or more behind is a warning, a patch only info. With a server that is a development build, or an agent version that is not strict SemVer, nothing can be compared: unknown.

func RenewalLoop

func RenewalLoop(s Snapshot) Result

RenewalLoop flags a name set for which a client got three or more certificates within 24 hours (critical at six) or eight within 7 days. It counts certificates of any status: a loop that revokes its predecessors is still a loop.

func Run

func Run(s Snapshot) []Result

Run evaluates every rule against the snapshot. The results come in the order of Rules; the findings of each are sorted by severity (most severe first), subject and key.

func UnusedACMEAccount

func UnusedACMEAccount(s Snapshot) Result

UnusedACMEAccount flags a valid account without agent link that is at least 30 days old and has no order for 90 days or ever. Contacts are not shown.

func UnusedDomain

func UnusedDomain(s Snapshot) Result

UnusedDomain flags a domain at least 30 days old for which no certificate of the tenant, whatever its status, has a name the domain covers.

func WeakKey

func WeakKey(s Snapshot) Result

WeakKey flags valid certificates with an RSA key under 2048 bits. RSA 2048 is not flagged. A certificate whose key size could not be read is not judged.

func WeakSignature

func WeakSignature(s Snapshot) Result

WeakSignature flags a valid end-entity or intermediate CA certificate that is signed with SHA-1, MD5 or MD2. The signature of a root certificate is its own and irrelevant. An algorithm Go does not know is not judged.

type Rule

type Rule struct {
	ID    string
	Title string
	Eval  func(Snapshot) Result
}

Rule is one rule of the analysis.

func Rules

func Rules() []Rule

Rules returns the ten rules in the order of docs/PHASE3.md section 14.2.

type Service

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

Service runs the analysis of one tenant and keeps the result for CacheTTL. It is the way in for the API and the pages.

func NewService

func NewService(d Deps) *Service

NewService returns the analysis service of one tenant.

func (*Service) Get

func (s *Service) Get(ctx context.Context, who keys.Who) (*Analysis, error)

Get returns the analysis to who. The tenant-wide authz.ReadPKI check comes first and nothing is read for a caller who fails it: findings name the CAs, hosts, accounts and domains of the whole tenant. The cache belongs to the tenant, not to the caller. The Analysis is shared: do not change it.

One run at a time: a caller that finds a run in progress waits for it, but gives up when its own context ends. A failed run is remembered for FailTTL (except when the caller of that run gave up: that says nothing about the data).

type Snapshot

type Snapshot struct {
	Now time.Time
	// ServerVersion is the running version; "dev" for a development build.
	ServerVersion string

	// Certificates are the valid end-entity certificates, whatever their
	// source (agent identity certificates are left out).
	Certificates []Certificate
	// Recent are the certificates created within RecentWindow of Now, valid
	// or not: a revoked or expired one still counts as something a client got.
	Recent []Certificate
	// Names are the DNS names and IPs of every certificate of the tenant,
	// whatever its status.
	Names []ca.CertName

	CAs     []CA
	Domains []ca.Domain
	// Accounts are the ACME accounts that were read, newest first. If there
	// are more than the engine reads, AccountsLimit is how many it did read and
	// the older accounts are not in the list; 0 = every account is.
	Accounts      []Account
	AccountsLimit int
	Agents        []Agent
	Endpoints     []Endpoint

	// Missing says why a section is not there ("" = it is).
	Missing Missing
}

Snapshot is what the server knows at one moment, as plain values. The rules read nothing else, and never the clock: Now is the time of the snapshot.

Every name in it (certificate names, host names, versions) is attacker input and stays a raw string; see the package comment.

func Load

func Load(ctx context.Context, d Deps, now time.Time) (Snapshot, error)

Load builds the snapshot at now through the existing services. It does no permission check: Service.Get does, and is the way in.

type Summary

type Summary struct {
	Critical, Warning, Info int
	// Unknown is the number of rules that cannot be assessed.
	Unknown int
}

Summary counts what the page and the API show in one line.

func Summarize

func Summarize(results []Result) Summary

Summarize counts all findings (also those beyond the cap) and the rules in state unknown.

Jump to

Keyboard shortcuts

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