<#1079 Add vulnerability alerts, posture evidence ...
# osctrl
g
#1079 Add vulnerability alerts, posture evidence and feed health Pull request opened by javuto Add vulnerability alerts, posture evidence and feed health Plan 3 of vulnerability monitoring. It builds on #1076 (backend) and #1078 (frontend). Problem osctrl records vulnerability findings and shows them in the UI, but: • nothing notifies operators when a node becomes exposed; • findings don't count toward the posture risk score; • advisory feed health is only visible on the feeds page. What changes Alerts • New rule source
vuln_finding
, with a
vuln_min_severity
threshold: | Threshold | Matches | | --------- | ------------------------------------------- | | kev | Known-exploited findings only | | critical | Critical findings, plus all known-exploited | | high | High and above, plus all known-exploited | | medium | Medium and above, plus all known-exploited | | low | Low and above, plus all known-exploited | | any | Everything, including unknown severity | Thresholds are case- and space-insensitive and are stored normalized. Environment and node scoping, cooldown and channels work as for every other source. • Watcher in osctrl-tls (
pkg/alerts/vuln_watcher.go
).
It runs every minute and needs both
--alerts-enabled
and
--vuln-enabled
on TLS. • Cursor: a Redis key,
osctrl:alert:vuln:cursor
, holds the last finding ID processed. Restarts don't re-alert. A reopened finding keeps its ID, so it doesn't alert again. • Missing cursor (first enable, Redis flushed): the watcher starts from the newest finding and never replays history. • Redis error: the watcher skips the sweep instead of replaying findings. • Possible findings never alert. • Grouping. Findings are grouped per rule, environment and advisory, so one advisory affecting many nodes is one notification. Each node is counted once. • A single-node hit keeps the entity
node_uuid:advisory_id
and the node attribution. • A deleted node is named by its UUID. • Storm cap. Each rule sends at most 10 advisories per environment per hour, worst first (KEV, then severity), plus one summary per hour for the rest. • The budget is stored in Redis (
osctrl:alert:vuln:budget:*
), so it holds across sweeps and across osctrl-tls replicas. • If Redis fails, the watcher falls back to a cap of 10 per sweep. • API.
vuln_min_severity
is added to the rule create and update request and to the rule response. Creating or updating a
vuln_finding
rule returns 400 when vulnerability monitoring is off. OpenAPI is regenerated. • UI. The rule form has a "New vulnerability findings" source and a "Minimum vulnerability severity" select, and the rules table shows the threshold. The source is offered only when vulnerability monitoring is enabled. Posture score • Extension point in `pkg/posture`: •
ScoringRule.Grade
lets the evidence set a control's severity, and with it its weight; •
posture.EvidenceSource
lets another subsystem add per-node evidence; •
PostureManager.ScoreNode
scores one node from its posture records plus that evidence. The node score handler and the fleet risk summaries (
GetSummaryByNode(s)
) use them.
pkg/posture
still doesn't import
pkg/vulns
. • New control: "Known vulnerabilities" (ISO 27001 A.8.8). Rule key:
known_vulnerabilities
. It counts open confirmed findings: | Open confirmed findings | Result | | ------------------------------------- | ----------------------- | | Any KEV or critical | Fail at critical weight | | Otherwise any high | Fail at high weight | | Otherwise medium, low or unknown | Warn | | None, with some packages not assessed | Warn | | None, everything assessed | Pass | • Unevaluated, never a pass: • before advisory data has loaded; • for a node that has no findings and was never matched; • for a node that has no findings and no assessed packages, for example Amazon Linux, an OS-only Windows node, or an ecosystem whose feed hasn't synced. • Wiring. The control is added in osctrl-api only when both
--posture-enabled
and
--vuln-enabled
are on. Matching (change to the backend from #1076) • Packages in an ecosystem without a successful
osv:<dir>
sync now count as not assessed, as the spec requires. Before, they were matched against an empty advisory table and looked clean. • "Not assessed" counts go up for such nodes until their feed syncs. Health • The health page gets an "Advisory feeds" item: • degraded when a feed is failing; • unknown before the first sync; • stale after three missed sync intervals. • It reads under the health request's timeout, so a stalled database can't hang the page. • The item's name is translated in all 20 locales. Docs
ARCHITECTURE.md
,
pkg/vulns/README.md
(new Alerts, Posture score and Health sections) and the spec's implementation notes. Security and operational impact • Off by default. With
--vuln-enabled
off, alerting and posture behave as before. Posture scores are identical when no evidence source is wired, and a test covers this. • Schema: one additive, nullable column,
alert_rules.vuln_min_severity
, added through AutoMigrate. No other model changes. • Redis: two new key families, the cursor and the hourly budget, both under
osctrl:alert:vuln:
. • Authorization: unchanged. The alert-rule endpoints keep their existing checks, and the health item sits behind the existing admin-only health status endpoint. • Untrusted content: alert details include hostnames and package names from node inventory. This is the same trust model as existing log-based alerts. • Notification volume is bounded per rule and environment per hour, including during an inventory profile rollout. • Known limitations: • A finding that later becomes known-exploited (added to CISA KEV) or rises in severity does not alert again. A
kev
rule fires only for findings that are already KEV when recorded. This is documented; an escalation cursor is a planned follow-up. • A node's first inventory counts as new findings. The hourly cap bounds it. • osctrl-tls replicas sweep without coordination; the shared budget bounds the total. • Rollout: 1. Enable
--vuln-enabled
on the API and wait for the first sync. 2. Enable
--vuln-enabled
and
--alerts-enabled
on TLS. 3. Apply the inventory profile. 4. Create
vuln_finding
rules. Validation •
gofmt -l pkg cmd
(empty),
go vet ./...
,
go build ./...
• `go test ./... -count=1`: all 55 packages pass •
go test ./pkg/alerts -race
• `golangci-lint run ./pkg/alerts/... ./pkg/posture/... ./pkg/vulns/... ./cmd/...`: 0 issues •
make openapi
and `make openapi-check`: the only diff is
vuln_min_severity
on the rule request and response schemas • `cd frontend && npm run check && npm test && npm run build`: 489 tests pass • Mutation checks: • removing the per-sweep cap fails the tests; • bypassing the Redis budget fails the tests; • dropping the "advisory data loaded" guard on the posture control fails the tests; • dropping the "assessed package" guard fails the tests; • ignoring the health request's timeout fails the tests. • A fresh-context review of the whole branch was done. The issues it found are fixed with tests that failed first: • the posture control passed unassessed nodes; • the notification cap applied per sweep instead of per hour; • the health item ignored the request timeout; • alerts double-counted nodes. • Not run: •
TestRedisVulnBudgetLive
needs
REDIS_URL
, so it was skipped. Run `REDIS_URL=redis://… go test ./pk… jmpsec/osctrl