chore: replace em dashes with hyphens, add no-em-dash rule to CLAUDE.md
This commit is contained in:
+1
-1
@@ -25,7 +25,7 @@ RUN --mount=type=secret,id=netrc,target=/root/.netrc \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
|
||||
|
||||
# Staged so the scratch image below can have a /tmp. It cannot mkdir one
|
||||
# itself — scratch has no shell — and os.MkdirTemp fails outright without it.
|
||||
# itself - scratch has no shell - and os.MkdirTemp fails outright without it.
|
||||
RUN mkdir -p /staging/tmp && chmod 1777 /staging/tmp
|
||||
|
||||
# Runtime stage
|
||||
|
||||
+8
-8
@@ -39,7 +39,7 @@ import (
|
||||
// comment group, separated by a real blank line rather than a bare "//": Go's
|
||||
// parser only splits ast.CommentGroups on an actual blank line, and
|
||||
// swag v2.0.0-rc5's parseSecAttributesV3 resolves a scheme's map key by
|
||||
// scanning from the start of whatever comment group it was handed — so three
|
||||
// scanning from the start of whatever comment group it was handed - so three
|
||||
// stacked blocks sharing one group all collapse onto the first block's name.
|
||||
// Three groups means three independent scans, each finding its own name.
|
||||
|
||||
@@ -63,7 +63,7 @@ func main() {
|
||||
//
|
||||
// Under Docker Compose neither is set and nothing changes: one process
|
||||
// migrates and then serves. Under Kubernetes with more than one replica
|
||||
// that is unsafe — every pod would run MigrateOrgToInstance at once, and
|
||||
// that is unsafe - every pod would run MigrateOrgToInstance at once, and
|
||||
// renaming collections while a sibling reads them is not a race anyone
|
||||
// wins. The chart therefore runs a pre-upgrade Job with MIGRATE_ONLY and
|
||||
// starts the Deployment with SKIP_MIGRATIONS.
|
||||
@@ -198,7 +198,7 @@ func runSchemaSetup() {
|
||||
}
|
||||
|
||||
// apiVersion mirrors the @version annotation on the swagger block above,
|
||||
// which is the only version string this server already establishes — there is
|
||||
// which is the only version string this server already establishes - there is
|
||||
// no separate runtime build-version constant to reuse instead. Nothing ties
|
||||
// the two together mechanically, so change them in the same commit: this is
|
||||
// the value mcp.SetVersion reports to MCP clients, and it must keep agreeing
|
||||
@@ -225,8 +225,8 @@ func serve() {
|
||||
}
|
||||
log.Printf("message bus ready as node %s", bus.NodeID())
|
||||
|
||||
// Cancelled on SIGTERM/SIGINT. Everything below that takes a context — the
|
||||
// housekeeping jobs, the leader lock — stops when the pod is asked to.
|
||||
// Cancelled on SIGTERM/SIGINT. Everything below that takes a context - the
|
||||
// housekeeping jobs, the leader lock - stops when the pod is asked to.
|
||||
ctx, shutdown := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer shutdown()
|
||||
|
||||
@@ -241,7 +241,7 @@ func serve() {
|
||||
// every monitor check firing N times, every incident notification delivered
|
||||
// to the customer N times, every retention sweep deleting concurrently, and
|
||||
// N reapers racing to purge the same instance. They share one lock rather
|
||||
// than holding four, because they are one role — housekeeping — and
|
||||
// than holding four, because they are one role - housekeeping - and
|
||||
// splitting them would only spread that role across pods for no benefit.
|
||||
bus.RunAsLeader(ctx, "housekeeping", func(jobCtx context.Context) {
|
||||
services.StartLogSweeper(jobCtx)
|
||||
@@ -278,7 +278,7 @@ func serve() {
|
||||
// caller wrote in X-Forwarded-For. That was survivable while ClientIP()
|
||||
// only produced audit strings; the public status limiter makes it load
|
||||
// bearing. Empty means trust nobody, which is correct for a direct
|
||||
// exposure and wrong behind a proxy — hence the explicit setting.
|
||||
// exposure and wrong behind a proxy - hence the explicit setting.
|
||||
if err := r.SetTrustedProxies(api.TrustedProxies()); err != nil {
|
||||
log.Fatalf("trusted proxies: %v", err)
|
||||
}
|
||||
@@ -365,7 +365,7 @@ func boolEnv(key string) bool {
|
||||
// It replaces a substring filter that fed in only routes whose path contained
|
||||
// "server", ":serverId", "console" or "assign". That filter could only ever
|
||||
// catch a route whose *path* named a server, and a route can act on one named
|
||||
// in its body, in a query parameter, or derived by the handler — it caught one
|
||||
// in its body, in a query parameter, or derived by the handler - it caught one
|
||||
// of the leaks found in the final review of the MCP feature, and none of the
|
||||
// eleven found during implementation. Declaring every route is more typing
|
||||
// once and no maintenance after: a new route fails boot until somebody answers
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
// consoleConnect godoc
|
||||
//
|
||||
// @Summary Open a browser console session
|
||||
// @Description Mints a one-time session token for the /console/tunnel websocket. Requires a live agent — answers 409 agent_offline otherwise.
|
||||
// @Description Mints a one-time session token for the /console/tunnel websocket. Requires a live agent - answers 409 agent_offline otherwise.
|
||||
// @Tags console
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -109,7 +109,7 @@ func queryIntDefault(r *http.Request, key string, def int) int {
|
||||
// Every branch here logs. That is deliberate and worth keeping: this handler
|
||||
// spans four hops (session store, agent dispatch, relay announcement, guacd),
|
||||
// any of which can fail, and the client is told the same near-useless thing by
|
||||
// most of them — a 500 that guacamole then reports as an *upstream* error,
|
||||
// most of them - a 500 that guacamole then reports as an *upstream* error,
|
||||
// naming the wrong hop entirely. Without a line per branch the only evidence a
|
||||
// failure leaves is a GIN status code, and with several replicas you cannot
|
||||
// even tell which process produced it.
|
||||
@@ -215,7 +215,7 @@ func consoleTunnel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
// The client is deliberately told nothing specific, so this is the only
|
||||
// place the real reason exists — a failed dispatch and a relay that was
|
||||
// place the real reason exists - a failed dispatch and a relay that was
|
||||
// never announced are the same generic 500 to the browser.
|
||||
tlog("reject: open relay: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not open relay"})
|
||||
@@ -286,7 +286,7 @@ func consoleTunnel(c *gin.Context) {
|
||||
}
|
||||
|
||||
// The handshake is where guacd connects onward to the relay, so a
|
||||
// failure here is guacd reporting it could not reach %s:%d — the hop
|
||||
// failure here is guacd reporting it could not reach %s:%d - the hop
|
||||
// that has been hardest to see from either end.
|
||||
stream := guac.NewStream(conn, guac.SocketTimeout)
|
||||
if err := stream.Handshake(config); err != nil {
|
||||
|
||||
@@ -1631,14 +1631,14 @@
|
||||
"$ref": "#/components/schemas/models.AlertSettings"
|
||||
},
|
||||
"api_token_max_days": {
|
||||
"description": "APITokenMaxDays caps how long a newly created API token may live.\n\nA pointer for the same reason the retention fields are: absent must mean\nthe default, and the default here is no cap at all — never-expire tokens\nare allowed until an instance decides otherwise, so an upgrade changes\nnothing. Nil or 0 is no cap. A positive value refuses both a longer\nexpiry and a token with no expiry.\n\nIt is a policy on issuance, not on use: raising or lowering it never\ninvalidates a token that already exists.",
|
||||
"description": "APITokenMaxDays caps how long a newly created API token may live.\n\nA pointer for the same reason the retention fields are: absent must mean\nthe default, and the default here is no cap at all - never-expire tokens\nare allowed until an instance decides otherwise, so an upgrade changes\nnothing. Nil or 0 is no cap. A positive value refuses both a longer\nexpiry and a token with no expiry.\n\nIt is a policy on issuance, not on use: raising or lowering it never\ninvalidates a token that already exists.",
|
||||
"type": "integer"
|
||||
},
|
||||
"instance_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"local_login_enabled": {
|
||||
"description": "LocalLoginEnabled is a pointer because it is absent on every settings\ndocument written before this feature existed, and a plain bool would read\nabsent as disabled — turning off password login for the entire fleet at\nupgrade. Nil means enabled.",
|
||||
"description": "LocalLoginEnabled is a pointer because it is absent on every settings\ndocument written before this feature existed, and a plain bool would read\nabsent as disabled - turning off password login for the entire fleet at\nupgrade. Nil means enabled.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"secrets": {
|
||||
@@ -1970,7 +1970,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"fixed_in": {
|
||||
"description": "FixedIn empty means no vendor fix has been published. That is a real and\ncommon state and must never be conflated with \"not vulnerable\" — it is\nthe finding most in need of acceptance, since there is nothing to patch.",
|
||||
"description": "FixedIn empty means no vendor fix has been published. That is a real and\ncommon state and must never be conflated with \"not vulnerable\" - it is\nthe finding most in need of acceptance, since there is nothing to patch.",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
@@ -3319,7 +3319,7 @@
|
||||
},
|
||||
"/console/connect": {
|
||||
"post": {
|
||||
"description": "Mints a one-time session token for the /console/tunnel websocket. Requires a live agent — answers 409 agent_offline otherwise.",
|
||||
"description": "Mints a one-time session token for the /console/tunnel websocket. Requires a live agent - answers 409 agent_offline otherwise.",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
@@ -5645,7 +5645,7 @@
|
||||
},
|
||||
"/secrets/{group}/values": {
|
||||
"get": {
|
||||
"description": "Consumed by Kubernetes External Secrets Operator. Authenticated with a bearer token whose SHA-256 hash is stored in settings — a different credential from an API token, never substitutable for one.",
|
||||
"description": "Consumed by Kubernetes External Secrets Operator. Authenticated with a bearer token whose SHA-256 hash is stored in settings - a different credential from an API token, never substitutable for one.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Secret group name",
|
||||
@@ -6278,7 +6278,7 @@
|
||||
},
|
||||
"/servers/{id}/packages": {
|
||||
"get": {
|
||||
"description": "A server that has not reported yet answers reported=false rather than 404 — that is the normal state for the first hour after install.",
|
||||
"description": "A server that has not reported yet answers reported=false rather than 404 - that is the normal state for the first hour after install.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Server ID",
|
||||
@@ -8627,7 +8627,7 @@
|
||||
},
|
||||
"/vulnerabilities": {
|
||||
"get": {
|
||||
"description": "Groups findings by CVE, most severe first — the same CVE on forty servers is one decision, not forty rows.",
|
||||
"description": "Groups findings by CVE, most severe first - the same CVE on forty servers is one decision, not forty rows.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Filter by severity",
|
||||
@@ -8848,7 +8848,7 @@
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"description": "Requires a reason and a future expiry. Reopens automatically at expiry — permanent dismissal is never allowed.",
|
||||
"description": "Requires a reason and a future expiry. Reopens automatically at expiry - permanent dismissal is never allowed.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Finding ID",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -70,7 +70,7 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
|
||||
apiGroup.GET("/servers", listServers)
|
||||
// Static segment, registered alongside /servers/:id exactly as
|
||||
// /servers/new already is — gin resolves statics ahead of wildcards.
|
||||
// /servers/new already is - gin resolves statics ahead of wildcards.
|
||||
apiGroup.GET("/servers/tags", listKnownTags)
|
||||
apiGroup.POST("/servers", createServer)
|
||||
apiGroup.GET("/servers/new", newServer)
|
||||
@@ -132,7 +132,7 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
// handler answers every GET with a hardcoded 405, because a stateless
|
||||
// server has no session to open the server-to-client SSE stream against.
|
||||
// That 405 is the protocol-correct response for an MCP server that offers
|
||||
// no SSE leg — an unregistered GET would 404 instead, which a client reads
|
||||
// no SSE leg - an unregistered GET would 404 instead, which a client reads
|
||||
// as "no MCP endpoint here at all" rather than "this one is POST-only".
|
||||
// This route is not a working GET; it exists solely to produce that 405.
|
||||
mcpGroup := apiGroup.Group("/mcp", RequireFeature(license.FeatureMCP))
|
||||
@@ -576,11 +576,11 @@ func getKey(c *gin.Context) {
|
||||
all, _ := services.GetAssignmentsWithServers(auth.InstanceID(c), id)
|
||||
|
||||
// A tag-restricted token may legitimately hold a key that is also
|
||||
// assigned to a server outside its restriction — the key itself is
|
||||
// assigned to a server outside its restriction - the key itself is
|
||||
// still returned above. Only the assignment list is filtered, and
|
||||
// silently: an assignment whose Server is nil or out of scope is
|
||||
// dropped rather than kept with the hostname redacted, so the response
|
||||
// gives no signal — not even a count — of what was removed.
|
||||
// gives no signal - not even a count - of what was removed.
|
||||
scope := auth.ServerScope(c)
|
||||
assignments := make([]services.AssignmentWithServer, 0, len(all))
|
||||
for _, a := range all {
|
||||
@@ -778,7 +778,7 @@ func applyUpdates(c *gin.Context) {
|
||||
// downloads and installs the latest agent. Deliberately not in the generated
|
||||
// OpenAPI document: it is registered on the bare engine, not under the /api
|
||||
// group the document's BasePath assumes, so a @Router annotation here would
|
||||
// publish /api/update — a path that 404s — rather than the real top-level
|
||||
// publish /api/update - a path that 404s - rather than the real top-level
|
||||
// /update. It serves a shell script, not JSON, so there is nothing lost by
|
||||
// leaving it out of a JSON API reference.
|
||||
func handleUpdateScript(c *gin.Context) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
//
|
||||
// /healthz is liveness: the process is up and serving. It touches nothing
|
||||
// external, because a Mongo outage must not make Kubernetes restart every
|
||||
// server pod — a restart loop cannot fix someone else's database, and it
|
||||
// server pod - a restart loop cannot fix someone else's database, and it
|
||||
// destroys every open command stream and console session on the way.
|
||||
//
|
||||
// /readyz is readiness: this pod can serve a request end to end, which needs
|
||||
|
||||
@@ -48,7 +48,7 @@ func licenceExempt(c *gin.Context) bool {
|
||||
// RequireActiveLicense blocks mutating requests when the licence is not valid.
|
||||
//
|
||||
// Mounted on the /api group, so a route added tomorrow is gated because of where
|
||||
// it lives rather than because someone remembered. GET and HEAD always pass —
|
||||
// it lives rather than because someone remembered. GET and HEAD always pass -
|
||||
// reading is never blocked.
|
||||
func RequireActiveLicense() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
const publicStatusRateLimit = 120
|
||||
|
||||
// RateLimitPublicStatus counts requests per client address in a one-minute
|
||||
// fixed window, exactly as RateLimitTokens does — including the part that
|
||||
// fixed window, exactly as RateLimitTokens does - including the part that
|
||||
// matters most: when Redis is unavailable it allows rather than denies. A
|
||||
// status page must survive the outage it exists to report.
|
||||
func RateLimitPublicStatus() gin.HandlerFunc {
|
||||
@@ -65,7 +65,7 @@ func RateLimitPublicStatus() gin.HandlerFunc {
|
||||
//
|
||||
// It carries no @Router annotation deliberately. openapi.json declares a
|
||||
// single server of "/api", so a @Router of /public/status/{pageId} would be
|
||||
// published as /api/public/status/{pageId} — a path that does not exist, and
|
||||
// published as /api/public/status/{pageId} - a path that does not exist, and
|
||||
// which would sit behind auth.Middleware if it did. The real address is:
|
||||
//
|
||||
// GET {scheme}://{instance-host}/public/status/{pageId}
|
||||
@@ -116,8 +116,8 @@ func getPublicStatusPage(c *gin.Context) {
|
||||
// so it is honoured only when the machine that opened the connection is one of
|
||||
// the configured trusted proxies.
|
||||
//
|
||||
// When the resulting host names no slug at all — vantage.acme.com,
|
||||
// status.acme.com, a bare IP — and the deployment is not cloud, the single
|
||||
// When the resulting host names no slug at all - vantage.acme.com,
|
||||
// status.acme.com, a bare IP - and the deployment is not cloud, the single
|
||||
// instance of that install is used. A self-hosted install has exactly one, and
|
||||
// without this every self-hosted status page 404s forever. More than one is a
|
||||
// refusal rather than a guess.
|
||||
|
||||
@@ -18,7 +18,7 @@ func runFixture() *models.WorkflowRun {
|
||||
|
||||
// A run document names every host it touched, hostname included. A restricted
|
||||
// caller must see only its own, and must be told some entries are missing
|
||||
// without being told how many — the targets_restricted precedent.
|
||||
// without being told how many - the targets_restricted precedent.
|
||||
func TestScopeRunHidesOutOfScopeServerRuns(t *testing.T) {
|
||||
got := scopeRun(runFixture(), map[string]bool{"stg-1": true}, true)
|
||||
if len(got.ServerRuns) != 1 || got.ServerRuns[0].ServerID != "stg-1" {
|
||||
@@ -45,7 +45,7 @@ func TestScopeRunLeavesUnrestrictedCallerWhole(t *testing.T) {
|
||||
}
|
||||
|
||||
// A restricted caller whose scope happens to cover the whole run must not be
|
||||
// told anything was hidden — the flag is about disclosure, not about being
|
||||
// told anything was hidden - the flag is about disclosure, not about being
|
||||
// restricted in general.
|
||||
func TestScopeRunNoFlagWhenNothingDropped(t *testing.T) {
|
||||
got := scopeRun(runFixture(), map[string]bool{"stg-1": true, "prod-1": true}, true)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// routeScopes maps a registered gin route — "<METHOD> <full path pattern>" — to
|
||||
// routeScopes maps a registered gin route - "<METHOD> <full path pattern>" - to
|
||||
// the scope an API token must hold to reach it.
|
||||
//
|
||||
// It is keyed on the route pattern rather than declared per route with a
|
||||
|
||||
@@ -40,7 +40,7 @@ func secretsReadAuth() gin.HandlerFunc {
|
||||
// esoGetGroup godoc
|
||||
//
|
||||
// @Summary Read a secret group's values (ESO)
|
||||
// @Description Consumed by Kubernetes External Secrets Operator. Authenticated with a bearer token whose SHA-256 hash is stored in settings — a different credential from an API token, never substitutable for one.
|
||||
// @Description Consumed by Kubernetes External Secrets Operator. Authenticated with a bearer token whose SHA-256 hash is stored in settings - a different credential from an API token, never substitutable for one.
|
||||
// @Tags secrets
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
|
||||
@@ -10,7 +10,7 @@ const (
|
||||
scoped scopeDecl = iota
|
||||
// fleetWide: the route deliberately reaches the whole fleet. Every
|
||||
// fleetWide entry carries a comment giving the reason. It must never mean
|
||||
// "not scoped yet" — an unresolved gap belongs on a fix list, not here,
|
||||
// "not scoped yet" - an unresolved gap belongs on a fix list, not here,
|
||||
// because this value is read as a considered decision.
|
||||
fleetWide
|
||||
// exempt: the route touches no server-derived data at all. Every exempt
|
||||
@@ -38,14 +38,14 @@ const (
|
||||
// 1. It can only ever check that a DECLARATION EXISTS, never that the handler
|
||||
// honours it. "POST /api/workflows/:id/run" was declared scoped here while
|
||||
// services.TriggerWorkflow resolved its targets through the unscoped
|
||||
// ResolveTargets — a true entry that lied, boot-enforced, for the whole
|
||||
// ResolveTargets - a true entry that lied, boot-enforced, for the whole
|
||||
// life of the feature. A declaration is a claim a reviewer must verify,
|
||||
// not a property this file establishes.
|
||||
//
|
||||
// 2. /api/mcp is exempt at route level, and that is the honest answer rather
|
||||
// than an omission. One route serves roughly twenty tools of very
|
||||
// different shapes — some read no server data at all, some resolve one
|
||||
// host, some enumerate the fleet — so no single route-level value could
|
||||
// different shapes - some read no server data at all, some resolve one
|
||||
// host, some enumerate the fleet - so no single route-level value could
|
||||
// be true of all of them. The decision genuinely lives per tool, where
|
||||
// each tool that touches server data applies auth.ServerScope's selector
|
||||
// itself, and the registry's own tests are where that is enforced.
|
||||
@@ -68,8 +68,8 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
"GET /api/servers/new": fleetWide,
|
||||
"POST /api/servers/new": fleetWide,
|
||||
|
||||
// KnownTags aggregates the tag *vocabulary* in use across the fleet — keys
|
||||
// and the values seen for them — never a server identifier or any other
|
||||
// KnownTags aggregates the tag *vocabulary* in use across the fleet - keys
|
||||
// and the values seen for them - never a server identifier or any other
|
||||
// server attribute, so it does not let a restricted token enumerate which
|
||||
// hosts exist. Filtering it would mean plumbing a selector through an
|
||||
// aggregation query for a leak that carries no server identity; ruled
|
||||
@@ -124,7 +124,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
"POST /api/vulnerabilities/rescan": fleetWide,
|
||||
|
||||
// Accepting or reopening a finding names the finding, not a server, but a
|
||||
// finding does belong to one — so a restricted token can accept a finding
|
||||
// finding does belong to one - so a restricted token can accept a finding
|
||||
// on a host outside its scope if it learns the finding ID. It cannot learn
|
||||
// one through this API any more (every listing is now scoped), so this is
|
||||
// left fleet-wide rather than given a lookup of its own. Owner|admin only.
|
||||
@@ -143,7 +143,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
// assignments whose server passes services.ServerInTokenScope before
|
||||
// returning it, so a restricted token cannot learn the hostname of an
|
||||
// out-of-scope server through a key it happens to also hold there. The
|
||||
// key document itself is still returned unfiltered — a token restricted
|
||||
// key document itself is still returned unfiltered - a token restricted
|
||||
// to staging may legitimately hold a key that is also assigned in prod,
|
||||
// and only the assignment list, not the key's existence, is the leak
|
||||
// this closes.
|
||||
@@ -152,7 +152,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
// listKeys' services.ListKeys narrows each key's AssignedCount to
|
||||
// assignments on servers ServerInTokenScope admits, for the same reason
|
||||
// as getKey above: a nonzero count on a key a restricted token sees
|
||||
// nothing assigned to in its own scope is itself the leak — it tells the
|
||||
// nothing assigned to in its own scope is itself the leak - it tells the
|
||||
// token an assignment exists on a host it must not know about, without
|
||||
// naming the host.
|
||||
"GET /api/keys": scoped,
|
||||
@@ -169,7 +169,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
"GET /api/keys/:id/private-key": exempt,
|
||||
|
||||
// Deleting a key removes it everywhere it is assigned, including on hosts
|
||||
// outside a restricted token's scope — the delete is of the key, not of a
|
||||
// outside a restricted token's scope - the delete is of the key, not of a
|
||||
// server, and there is no partial delete that leaves a key half-revoked.
|
||||
// Nothing about which hosts held it is disclosed by the call.
|
||||
"DELETE /api/keys/:id": fleetWide,
|
||||
@@ -179,7 +179,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
// listWorkflows/getWorkflow narrow Workflow.TargetServerIDs to what the
|
||||
// caller's scope admits via services.VisibleServerIDs +
|
||||
// FilterVisibleServerIDs, wrapped in WorkflowResponse so the JSON field
|
||||
// name is unchanged. TargetTags is left untouched — the tag vocabulary
|
||||
// name is unchanged. TargetTags is left untouched - the tag vocabulary
|
||||
// itself is ruled acceptable to expose, unlike a resolved server ID.
|
||||
// TargetsRestricted is set (with no count) whenever at least one target
|
||||
// was dropped.
|
||||
@@ -191,7 +191,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
// validate the ID-union-tags target set as a whole through
|
||||
// services.validateWorkflowTargetScope, which resolves the workflow's
|
||||
// targets both unscoped and scoped and refuses to save unless they match
|
||||
// — the same all-or-nothing rule the MCP create_workflow tool applies.
|
||||
// - the same all-or-nothing rule the MCP create_workflow tool applies.
|
||||
// Together these mean a restricted token can neither save a workflow
|
||||
// targeting a host or tag outside its scope (which the scheduler, firing
|
||||
// as the system, would otherwise run there) nor learn which IDs or tags
|
||||
@@ -203,12 +203,12 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
// runWorkflow passes auth.ServerScope into services.TriggerWorkflow, which
|
||||
// resolves through ResolveTargetsScoped. Note the history: this entry read
|
||||
// scoped for the whole life of the feature while TriggerWorkflow called
|
||||
// the UNSCOPED ResolveTargets — see this file's header on what this
|
||||
// the UNSCOPED ResolveTargets - see this file's header on what this
|
||||
// assertion can and cannot prove.
|
||||
"POST /api/workflows/:id/run": scoped,
|
||||
|
||||
// getRun and listWorkflowRuns narrow WorkflowRun.ServerRuns — each entry
|
||||
// of which carries a ServerID and a Hostname — to what the caller's scope
|
||||
// getRun and listWorkflowRuns narrow WorkflowRun.ServerRuns - each entry
|
||||
// of which carries a ServerID and a Hostname - to what the caller's scope
|
||||
// admits, setting servers_restricted (a boolean, never a count) when any
|
||||
// entry was dropped.
|
||||
"GET /api/runs/:runId": scoped,
|
||||
@@ -224,7 +224,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
// Deleting a workflow and cancelling a run both act on a definition rather
|
||||
// than on a server, and neither returns server data. Each can
|
||||
// nevertheless reach a definition whose targets a restricted token cannot
|
||||
// see — a cancel stops work on out-of-scope hosts. That reach is real but
|
||||
// see - a cancel stops work on out-of-scope hosts. That reach is real but
|
||||
// bounded: the caller learns nothing about which hosts are involved (both
|
||||
// /workflows listings are scoped), and a scope-narrowed variant of
|
||||
// "cancel this run" would have to either half-cancel a run or refuse one
|
||||
@@ -236,17 +236,17 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
// Arming a schedule applies no scope check of its own, and that is safe
|
||||
// only because it has nothing left to check: CreateWorkflow and
|
||||
// UpdateWorkflow (internal/services/workflows.go) already refuse to save
|
||||
// a workflow whose resolved targets — TargetServerIDs union TargetTags —
|
||||
// a workflow whose resolved targets - TargetServerIDs union TargetTags -
|
||||
// reach outside the acting credential's scope, the same all-or-nothing
|
||||
// rule the MCP create_workflow tool applies. So a workflow written after
|
||||
// this check existed had its targets constrained to whichever scope wrote
|
||||
// it, and the scheduler firing it later with a nil token scope — acting
|
||||
// as the system, not as any caller — reaches nothing that write didn't
|
||||
// it, and the scheduler firing it later with a nil token scope - acting
|
||||
// as the system, not as any caller - reaches nothing that write didn't
|
||||
// already allow.
|
||||
//
|
||||
// This holds only for workflows written after the check was added. Rows
|
||||
// already in the database were saved under the old, unvalidated rule and
|
||||
// are never re-validated — neither this route nor the writers re-check an
|
||||
// are never re-validated - neither this route nor the writers re-check an
|
||||
// existing row's targets after the fact. A workflow saved before this fix
|
||||
// with an out-of-scope tag selector still schedules and fires exactly as
|
||||
// it did before.
|
||||
@@ -270,17 +270,17 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
|
||||
// listMonitors/getMonitor redact models.Monitor.Runner to
|
||||
// models.RunnerRestricted via services.RedactMonitorRunner when it names
|
||||
// a server outside the caller's scope — Runner is literally a server ID
|
||||
// a server outside the caller's scope - Runner is literally a server ID
|
||||
// for an agent-pushed monitor, so left unfiltered it discloses one
|
||||
// directly. The monitor itself is still returned: a restricted operator
|
||||
// may legitimately need to see that it exists and is up or down, so only
|
||||
// the runner field goes neutral. Runner "server" (control-plane-run) is
|
||||
// never touched — it names no server.
|
||||
// never touched - it names no server.
|
||||
"GET /api/monitors": scoped,
|
||||
"GET /api/monitors/:id": scoped,
|
||||
|
||||
// createMonitor/updateMonitor validate the runner — which is a server ID
|
||||
// for an agent-pushed monitor — through services.validateRunner, resolving
|
||||
// createMonitor/updateMonitor validate the runner - which is a server ID
|
||||
// for an agent-pushed monitor - through services.validateRunner, resolving
|
||||
// with GetServerScoped so a restricted token can neither point a check at
|
||||
// an out-of-scope agent nor use the not-found answer as an oracle.
|
||||
"POST /api/monitors": scoped,
|
||||
@@ -293,7 +293,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
"DELETE /api/monitors/:id": fleetWide,
|
||||
|
||||
// A monitor's incidents, uptime rollups and recent samples are all about
|
||||
// the monitored endpoint — status, latency, timestamps — and carry no
|
||||
// the monitored endpoint - status, latency, timestamps - and carry no
|
||||
// server identifier at all; the runner is a field of the monitor
|
||||
// document, which these do not return.
|
||||
"GET /api/monitors/:id/incidents": exempt,
|
||||
@@ -302,7 +302,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
|
||||
// ---- notification channels ----
|
||||
|
||||
// A channel is an outbound destination — a webhook URL, an SMTP account.
|
||||
// A channel is an outbound destination - a webhook URL, an SMTP account.
|
||||
// Nothing about a server reaches these routes.
|
||||
"GET /api/channels": exempt,
|
||||
"POST /api/channels": exempt,
|
||||
@@ -328,7 +328,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
|
||||
// A status page pairs monitor IDs with per-page display names, and every
|
||||
// public read goes through services.assembleSnapshot, which is the
|
||||
// redaction boundary — its PublicComponent vocabulary has no field for a
|
||||
// redaction boundary - its PublicComponent vocabulary has no field for a
|
||||
// host, URL or runner. These authoring routes handle the page document
|
||||
// itself and never a server.
|
||||
"GET /api/status-pages": exempt,
|
||||
@@ -347,7 +347,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
// Audit rows are a record of what people and tokens did, and a row's free
|
||||
// text detail can name a host in passing ("run <id> triggered", "key
|
||||
// assigned to web-01"). Filtering the log by tag would mean parsing those
|
||||
// strings, or dropping every row whose target this token cannot resolve —
|
||||
// strings, or dropping every row whose target this token cannot resolve -
|
||||
// which would hide a restricted token's own actions from itself the
|
||||
// moment a server is renamed or deleted. The log is left whole and
|
||||
// deliberately so: an audit trail with holes in it is worth less than the
|
||||
@@ -402,7 +402,7 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
|
||||
// AssertServerScopeMapComplete refuses to boot when any registered /api route
|
||||
// is missing from serverScopedRoutes. routes is every /api route the engine
|
||||
// registered — not a filtered subset — which is the whole point of the
|
||||
// registered - not a filtered subset - which is the whole point of the
|
||||
// inversion: a new route is checked by default rather than only when its path
|
||||
// happens to match a pattern somebody remembered to add.
|
||||
func AssertServerScopeMapComplete(routes []string) error {
|
||||
|
||||
@@ -32,7 +32,7 @@ func registerStatusPageRoutes(g *gin.RouterGroup) {
|
||||
|
||||
// statusPageError maps the service errors onto codes once, so ten handlers do
|
||||
// not each invent their own. services.ErrPageInvalid covers every validation
|
||||
// failure in the status page and incident services — a missing title or an
|
||||
// failure in the status page and incident services - a missing title or an
|
||||
// invalid incident status is a 400, not a 500.
|
||||
func statusPageError(c *gin.Context, err error) {
|
||||
switch {
|
||||
|
||||
@@ -87,7 +87,7 @@ func createToken(c *gin.Context) {
|
||||
|
||||
// A token-authenticated request may only mint a token whose scopes are a
|
||||
// subset of its own. Role is capped against the creating *user* below (in
|
||||
// services.CreateAPIToken), but a role cap alone does not confine scopes —
|
||||
// services.CreateAPIToken), but a role cap alone does not confine scopes -
|
||||
// without this, a CI token holding only settings:write could mint a token
|
||||
// holding keys:write and secrets:write, since minting only ever required
|
||||
// settings:write and never checked what the caller itself could reach. A
|
||||
|
||||
@@ -24,7 +24,7 @@ type LimitExceededResponse struct {
|
||||
}
|
||||
|
||||
// LicenceErrorResponse pairs an error with a machine-readable reason rather
|
||||
// than a code — used only on the two licence rejection paths that predate the
|
||||
// than a code - used only on the two licence rejection paths that predate the
|
||||
// error/code convention used everywhere else.
|
||||
type LicenceErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
@@ -115,7 +115,7 @@ type AgentVersionResponse struct {
|
||||
}
|
||||
|
||||
// WorkflowResponse is a workflow with its TargetServerIDs narrowed to what
|
||||
// the acting token's scope admits — the explicit field shadows the embedded
|
||||
// the acting token's scope admits - the explicit field shadows the embedded
|
||||
// one for JSON marshalling, matching the pattern KeyDetailResponse already
|
||||
// uses. TargetTags is not filtered: the tag vocabulary itself is ruled
|
||||
// acceptable to expose, and only the resolved ID list can name a specific
|
||||
@@ -134,7 +134,7 @@ type WorkflowResponse struct {
|
||||
// RunResponse is a workflow run with its ServerRuns narrowed to the servers
|
||||
// the acting token's scope admits. Each models.ServerRun carries both a
|
||||
// ServerID and a Hostname, so an unfiltered run document names every host it
|
||||
// touched — the same disclosure WorkflowResponse.TargetServerIDs closes one
|
||||
// touched - the same disclosure WorkflowResponse.TargetServerIDs closes one
|
||||
// level up, and the parent of the per-server log routes that were already
|
||||
// scoped.
|
||||
//
|
||||
|
||||
@@ -29,7 +29,7 @@ type vulnGroup struct {
|
||||
// listVulnerabilities godoc
|
||||
//
|
||||
// @Summary List vulnerabilities
|
||||
// @Description Groups findings by CVE, most severe first — the same CVE on forty servers is one decision, not forty rows.
|
||||
// @Description Groups findings by CVE, most severe first - the same CVE on forty servers is one decision, not forty rows.
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param severity query string false "Filter by severity"
|
||||
@@ -102,7 +102,7 @@ func groupByCVE(findings []models.VulnFinding) []vulnGroup {
|
||||
}
|
||||
|
||||
// hasFixFromQuery reads ?has_fix=true|false. Anything else, including an empty
|
||||
// or malformed value, is no filter — a filter nobody asked for must never hide
|
||||
// or malformed value, is no filter - a filter nobody asked for must never hide
|
||||
// findings, and the wrong direction here hides the unfixable ones.
|
||||
func hasFixFromQuery(c *gin.Context) *bool {
|
||||
switch c.Query("has_fix") {
|
||||
@@ -199,7 +199,7 @@ type acceptFindingRequest struct {
|
||||
// acceptFinding godoc
|
||||
//
|
||||
// @Summary Accept a finding
|
||||
// @Description Requires a reason and a future expiry. Reopens automatically at expiry — permanent dismissal is never allowed.
|
||||
// @Description Requires a reason and a future expiry. Reopens automatically at expiry - permanent dismissal is never allowed.
|
||||
// @Tags vulnerabilities
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -221,7 +221,7 @@ func acceptFinding(c *gin.Context) {
|
||||
|
||||
// Both rejected deliberately. An acceptance with no reason is a dismissal
|
||||
// nobody can audit, and one already expired is a permanent dismissal
|
||||
// wearing an expiry — the graveyard the expiry exists to prevent.
|
||||
// wearing an expiry - the graveyard the expiry exists to prevent.
|
||||
if strings.TrimSpace(req.Reason) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "a reason is required"})
|
||||
return
|
||||
@@ -313,7 +313,7 @@ func listServerVulnerabilities(c *gin.Context) {
|
||||
// getServerPackages godoc
|
||||
//
|
||||
// @Summary Get a server's package inventory
|
||||
// @Description A server that has not reported yet answers reported=false rather than 404 — that is the normal state for the first hour after install.
|
||||
// @Description A server that has not reported yet answers reported=false rather than 404 - that is the normal state for the first hour after install.
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
|
||||
@@ -151,7 +151,7 @@ func controlWorkload(c *gin.Context) {
|
||||
case errors.Is(err, services.ErrAgentNotConnected):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
case services.IsWorkloadProtected(err):
|
||||
// Nothing failed — the agent refused, which is the design. 409, not
|
||||
// Nothing failed - the agent refused, which is the design. 409, not
|
||||
// 500, and the reason is carried through.
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
default:
|
||||
|
||||
@@ -44,7 +44,7 @@ func githubOAuthConfig(p *models.AuthProvider, secret, redirectURL string) *oaut
|
||||
//
|
||||
// Verified alone is not enough: a non-primary address is one the person happens
|
||||
// to have proved, not the one they present as themselves. Primary alone is far
|
||||
// worse — an unverified address is not proof of control at all, and accepting
|
||||
// worse - an unverified address is not proof of control at all, and accepting
|
||||
// one would let anyone with a GitHub account claim any address in the instance.
|
||||
func selectGitHubEmail(emails []githubEmail) (string, error) {
|
||||
for _, e := range emails {
|
||||
|
||||
@@ -61,8 +61,8 @@ func hostSlug(host string) string {
|
||||
func HostSlug(host string) string { return hostSlug(host) }
|
||||
|
||||
// InstanceFromHost resolves the instance named by the request's own Host
|
||||
// header. Callers that must resolve a host from somewhere else — the public
|
||||
// status page reads a trusted X-Forwarded-Host — use InstanceForHost so the
|
||||
// header. Callers that must resolve a host from somewhere else - the public
|
||||
// status page reads a trusted X-Forwarded-Host - use InstanceForHost so the
|
||||
// slug rules and the 60s cache stay single-implementation.
|
||||
func InstanceFromHost(c *gin.Context) (*models.Instance, bool) {
|
||||
return InstanceForHost(c.Request.Host)
|
||||
@@ -82,7 +82,7 @@ func InstanceForHost(host string) (*models.Instance, bool) {
|
||||
if err != nil || inst == nil {
|
||||
// Negative entries are cached too. Without them an unknown but
|
||||
// well-formed host costs a Mongo query per anonymous request, which
|
||||
// the public status page exposes to the open internet — and the
|
||||
// the public status page exposes to the open internet - and the
|
||||
// round trip is itself a timing oracle separating "no such instance"
|
||||
// from "instance exists, page does not".
|
||||
storeInstance(slug, nil)
|
||||
@@ -93,7 +93,7 @@ func InstanceForHost(host string) (*models.Instance, bool) {
|
||||
}
|
||||
|
||||
// SoleInstance resolves the one instance of a deployment that has exactly one.
|
||||
// It is how a self-hosted install serves a host that names no slug at all —
|
||||
// It is how a self-hosted install serves a host that names no slug at all -
|
||||
// vantage.acme.com, status.acme.com, or a bare address. It reuses the same
|
||||
// count-then-read that bootstrap uses, and refuses rather than guessing when
|
||||
// more than one instance exists.
|
||||
|
||||
@@ -89,7 +89,7 @@ func HandleLocalLogin(c *gin.Context) {
|
||||
}
|
||||
|
||||
// HandleListPublicProviders is unauthenticated: it is what the login page reads
|
||||
// to decide what to draw. It carries no issuer, no client ID and no secret —
|
||||
// to decide what to draw. It carries no issuer, no client ID and no secret -
|
||||
// only what a button needs, because anyone who can reach the login page can
|
||||
// read this.
|
||||
func HandleListPublicProviders(c *gin.Context) {
|
||||
|
||||
@@ -104,7 +104,7 @@ func sessionFromCookie(c *gin.Context) (*Session, bool) {
|
||||
}
|
||||
sess, err := GetSession(c.Request.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
// A stale cookie plus a valid bearer token is a real combination —
|
||||
// A stale cookie plus a valid bearer token is a real combination -
|
||||
// a browser tab left open beside a curl. Fall through rather than
|
||||
// refusing a credential that would have worked.
|
||||
if bearerToken(c) != "" {
|
||||
@@ -203,7 +203,7 @@ func IsToken(c *gin.Context) bool { return TokenID(c) != "" }
|
||||
|
||||
// ServerScope is the tag restriction the acting credential carries, or nil for
|
||||
// an unrestricted token and for every cookie session. Callers pass it to
|
||||
// services.ServerInTokenScope or services.IntersectSelectors — nil means the
|
||||
// services.ServerInTokenScope or services.IntersectSelectors - nil means the
|
||||
// whole fleet, never nothing.
|
||||
func ServerScope(c *gin.Context) map[string]string {
|
||||
if s := GetSessionFromContext(c); s != nil {
|
||||
|
||||
@@ -44,7 +44,7 @@ var presets = []Preset{
|
||||
Kind: models.KindOIDC,
|
||||
IssuerFormat: "https://%s/oauth2/default",
|
||||
InputLabel: "Okta org domain",
|
||||
InputHint: "e.g. acme.okta.com — no scheme, no trailing slash.",
|
||||
InputHint: "e.g. acme.okta.com - no scheme, no trailing slash.",
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@ type Session struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
// The four fields below are set only when the request authenticated with
|
||||
// an API token. They are never persisted to Redis — a token authenticates
|
||||
// an API token. They are never persisted to Redis - a token authenticates
|
||||
// per request and mints no session, so a revoked token stops working
|
||||
// immediately rather than at the end of a session TTL.
|
||||
TokenID string `json:"-"`
|
||||
@@ -38,7 +38,7 @@ var rdb *redis.Client
|
||||
// InitRedis connects the session store.
|
||||
//
|
||||
// Username and password may both be empty for an unauthenticated instance. For
|
||||
// a legacy `requirepass` Redis, pass the password with an empty username —
|
||||
// a legacy `requirepass` Redis, pass the password with an empty username -
|
||||
// go-redis then sends AUTH with one argument instead of two.
|
||||
func InitRedis(addr, username, password string) error {
|
||||
rdb = redis.NewClient(&redis.Options{
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
//
|
||||
// Everything here is deliberately best-effort delivery with an explicit ack
|
||||
// rather than a queue. A command whose owner pod died between the presence
|
||||
// check and the publish must fail loudly and immediately — the caller answers
|
||||
// 503 and the operator retries — not sit in a queue waiting for a stream that
|
||||
// check and the publish must fail loudly and immediately - the caller answers
|
||||
// 503 and the operator retries - not sit in a queue waiting for a stream that
|
||||
// no longer exists.
|
||||
package bus
|
||||
|
||||
@@ -97,7 +97,7 @@ const (
|
||||
//
|
||||
// The listener cannot be bound in advance on any particular pod. An agent's
|
||||
// ProxyStream is a separate HTTP/2 request from its CommandStream, and an
|
||||
// L7 proxy (Traefik) balances requests, not connections — so it may land on
|
||||
// L7 proxy (Traefik) balances requests, not connections - so it may land on
|
||||
// any replica, not the one holding the command stream. The pod it does land
|
||||
// on binds the listener and announces it here.
|
||||
ProxyAddrChannel = prefix + "proxyaddr:"
|
||||
@@ -295,7 +295,7 @@ func SetPendingProxy(ctx context.Context, proxyID, instanceID, serverID string,
|
||||
// round trips: single use is the whole security property, and two agents
|
||||
// racing the same proxy_id must not both be served.
|
||||
//
|
||||
// A missing record is reported as "", "" rather than an error — an unknown
|
||||
// A missing record is reported as "", "" rather than an error - an unknown
|
||||
// proxy_id, an expired one and a second claim are all the same refusal.
|
||||
func ClaimPendingProxy(ctx context.Context, proxyID string) (instanceID, serverID string) {
|
||||
v, err := claimPending.Run(ctx, rdb, []string{ProxyPendingKey + proxyID}).Text()
|
||||
|
||||
@@ -23,7 +23,7 @@ const (
|
||||
// a race anyone wins.
|
||||
//
|
||||
// Redis rather than a Kubernetes Lease so that Docker Compose, which has no
|
||||
// API server, takes the identical code path — one implementation to reason
|
||||
// API server, takes the identical code path - one implementation to reason
|
||||
// about, not two.
|
||||
//
|
||||
// job is given a context cancelled the moment leadership is lost, and must
|
||||
|
||||
@@ -45,7 +45,7 @@ func (s *vantageServer) ProxyStream(stream pb.Vantage_ProxyStreamServer) error {
|
||||
// testable without a real stream.
|
||||
//
|
||||
// The listener is bound here, on whichever replica the stream reached, rather
|
||||
// than in advance on the pod holding the agent's command stream — those are not
|
||||
// than in advance on the pod holding the agent's command stream - those are not
|
||||
// the same pod, because an L7 proxy balances HTTP/2 requests independently.
|
||||
func serveProxy(open *pb.ProxyOpen, instanceID string, stream proxy.AgentStream) error {
|
||||
sess, err := services.ClaimProxyStream(instanceID, open.ServerId, open.ProxyId)
|
||||
|
||||
@@ -168,7 +168,7 @@ func (s *vantageServer) ReportPackages(ctx context.Context, req *pb.ReportPackag
|
||||
//
|
||||
// It is not gated by licence: the workload registry reads as core fleet
|
||||
// management rather than a premium add-on. If that ever changes, the check
|
||||
// belongs here — gating collection, not display — for the same reason it does
|
||||
// belongs here - gating collection, not display - for the same reason it does
|
||||
// in ReportPackages.
|
||||
func (s *vantageServer) ReportWorkloads(ctx context.Context, req *pb.ReportWorkloadsRequest) (*pb.ReportWorkloadsResponse, error) {
|
||||
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
|
||||
@@ -304,7 +304,7 @@ func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) err
|
||||
|
||||
// Serve claims this agent's presence on the bus and subscribes this pod to
|
||||
// its command channel, so a dispatch issued by any other replica arrives
|
||||
// here. The teardown releases both — an agent that reconnects to a
|
||||
// here. The teardown releases both - an agent that reconnects to a
|
||||
// different pod must not leave this one advertising a stream it no longer
|
||||
// has.
|
||||
ch, release := services.Dispatcher.Serve(stream.Context(), srv.ServerID)
|
||||
@@ -422,7 +422,7 @@ const pingSummaryInterval = 5 * time.Minute
|
||||
// presence claim, released by a deferred call that a killed process never runs.
|
||||
// The claim then outlives its owner for the remainder of its 30s TTL, during
|
||||
// which dispatch believes the agent is reachable, publishes to a channel with
|
||||
// no subscriber, and fails as "agent offline" — a pod that has already exited
|
||||
// no subscriber, and fails as "agent offline" - a pod that has already exited
|
||||
// still answering for an agent it can no longer reach.
|
||||
func StartGRPC(port int) (stop func(), err error) {
|
||||
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
@@ -458,7 +458,7 @@ func StartGRPC(port int) (stop func(), err error) {
|
||||
//
|
||||
// It is bounded: an idle CommandStream returns as soon as its context is
|
||||
// cancelled, but a console relay mid-transfer would otherwise hold the
|
||||
// process past the pod's grace period and earn a SIGKILL — which is the
|
||||
// process past the pod's grace period and earn a SIGKILL - which is the
|
||||
// abrupt exit this exists to avoid.
|
||||
return func() {
|
||||
done := make(chan struct{})
|
||||
|
||||
@@ -35,7 +35,7 @@ var ErrConfirmRequired = errors.New("confirmation required")
|
||||
var ErrOutOfScope = errors.New("targets outside token scope")
|
||||
|
||||
// logEvent is services.LogEvent behind a package variable so tests can
|
||||
// observe what would have been audited without a live database connection —
|
||||
// observe what would have been audited without a live database connection -
|
||||
// services.LogEvent talks straight to Mongo via db.Col, which panics on a nil
|
||||
// client outside a real boot.
|
||||
var logEvent = services.LogEvent
|
||||
@@ -117,7 +117,7 @@ func LogDenied(c Caller, toolName, gate string) {
|
||||
}
|
||||
|
||||
// LogFailure records a write tool call that reached a service and that
|
||||
// service returned an error — as opposed to LogDenied, which records a
|
||||
// service returned an error - as opposed to LogDenied, which records a
|
||||
// policy refusal that never reached one. Distinguishing the two in
|
||||
// audit_logs is what lets a human reading it tell "the agent was stopped"
|
||||
// from "the agent tried and the machine failed".
|
||||
@@ -130,7 +130,7 @@ func LogFailure(c Caller, t Tool, args map[string]any, err error) {
|
||||
//
|
||||
// It is a distinct event type rather than another mcp.tool_call row because of
|
||||
// the question a human will actually ask, which is "what has this agent added
|
||||
// to my instance" — an answer buried among hundreds of read rows is not an
|
||||
// to my instance" - an answer buried among hundreds of read rows is not an
|
||||
// answer.
|
||||
func LogCreated(c Caller, kind, id, name string) {
|
||||
logEvent(c.InstanceID, "mcp.created", c.TokenName, "", "",
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// It is a presentation layer over the service layer and introduces no authority
|
||||
// of its own: every tool calls the same service functions the REST handlers
|
||||
// call, and every decision about who may do what is made by machinery that
|
||||
// already exists. Three gates apply to every call — the licence feature, the
|
||||
// mcp:* scope, and the tool's own resource scope — and all three must pass.
|
||||
// already exists. Three gates apply to every call - the licence feature, the
|
||||
// mcp:* scope, and the tool's own resource scope - and all three must pass.
|
||||
package mcp
|
||||
|
||||
import (
|
||||
@@ -50,7 +50,7 @@ const (
|
||||
// what tagArg decodes.
|
||||
ArgTagMap ArgType = "tag_map"
|
||||
// ArgObject is a free-form object whose inner shape the tool documents in
|
||||
// the argument description — create_monitor's target, whose fields differ
|
||||
// the argument description - create_monitor's target, whose fields differ
|
||||
// per monitor type.
|
||||
ArgObject ArgType = "object"
|
||||
)
|
||||
@@ -83,7 +83,7 @@ type Tool struct {
|
||||
Write bool
|
||||
// Args declares every argument the handler reads, in the order a client
|
||||
// should see them. A tool taking none declares an empty slice, which is
|
||||
// distinct from "nobody has written the schema yet" — see the registry
|
||||
// distinct from "nobody has written the schema yet" - see the registry
|
||||
// tests, which require the declaration to be deliberate.
|
||||
Args []ToolArg
|
||||
// TouchesServers marks a tool that returns or acts on server-derived data:
|
||||
@@ -92,7 +92,7 @@ type Tool struct {
|
||||
// ResolveTargetsScoped, ListServersFiltered or VisibleServerIDs.
|
||||
//
|
||||
// Like serverScopedRoutes in the api package, this can only ever assert
|
||||
// that a declaration exists, never that the handler honours it — get_run_logs
|
||||
// that a declaration exists, never that the handler honours it - get_run_logs
|
||||
// proved the run's instance and the server's membership in the run and then
|
||||
// read production stdout for a staging token. What it does buy is that
|
||||
// adding a tool forces an answer to "does this touch server data?", and the
|
||||
|
||||
@@ -72,8 +72,8 @@ func TestInputSchemaMarshals(t *testing.T) {
|
||||
// serverTouchingTools names every tool that returns or acts on server-derived
|
||||
// data. The test below pins the registry against it, so a tool added that
|
||||
// reads a hostname, a server ID, a package list or a run's per-server output
|
||||
// fails until somebody declares TouchesServers and — the point of the exercise
|
||||
// — decides how it applies Caller.TokenScope.
|
||||
// fails until somebody declares TouchesServers and - the point of the exercise
|
||||
// - decides how it applies Caller.TokenScope.
|
||||
//
|
||||
// This is the assertion that would have caught get_run_logs, which proved the
|
||||
// run's instance and the named server's membership in the run and then read
|
||||
|
||||
@@ -141,7 +141,7 @@ func buildMonitor(args map[string]any) (models.Monitor, error) {
|
||||
// named agent, and a silently ignored argument would leave a model
|
||||
// believing it had. services.CreateMonitor now validates a runner
|
||||
// through GetServerScoped as well, so this is a second line rather than
|
||||
// the only one — but the clearer answer belongs here.
|
||||
// the only one - but the clearer answer belongs here.
|
||||
if _, present := args["runner"]; present {
|
||||
return models.Monitor{}, fmt.Errorf("runner cannot be set from here; monitors created this way always run on the control plane")
|
||||
}
|
||||
@@ -176,7 +176,7 @@ func init() {
|
||||
Write: true,
|
||||
Scope: "workflows:write",
|
||||
Description: "Create a reusable workflow step: a named script with an interpreter. " +
|
||||
"The step is SAVED to this Vantage instance but is not run by creating it — " +
|
||||
"The step is SAVED to this Vantage instance but is not run by creating it - " +
|
||||
"add it to a workflow with create_workflow, then run that with run_workflow. " +
|
||||
"Steps created this way cannot reference secrets.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// Runner in particular is not merely omitted as noise: for an agent-pushed
|
||||
// monitor it is literally a server ID, and REST's listMonitors/getMonitor
|
||||
// redact it to models.RunnerRestricted when that server is outside the
|
||||
// caller's scope. This projection never had a runner field to redact — the
|
||||
// caller's scope. This projection never had a runner field to redact - the
|
||||
// same outcome, reached by never including it rather than by filtering it
|
||||
// out, so this tool and get_monitor_status cannot disagree with the REST
|
||||
// surface about what a restricted token learns.
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestServerSummaryStaysSmall(t *testing.T) {
|
||||
}
|
||||
|
||||
// The real status vocabulary is "pending" / "active" / "offline" (see
|
||||
// internal/services/servers.go) — "online" is never assigned anywhere. A
|
||||
// internal/services/servers.go) - "online" is never assigned anywhere. A
|
||||
// server carrying the live status ("active") must project as Online: true,
|
||||
// or list_servers/get_server misreport the entire fleet as down.
|
||||
func TestSummariseServerReportsActiveAsOnline(t *testing.T) {
|
||||
|
||||
@@ -39,7 +39,7 @@ type workflowDetail struct {
|
||||
Tags map[string]string `json:"target_tags,omitempty"`
|
||||
Schedule string `json:"schedule,omitempty"`
|
||||
// TargetsRestricted is set, with no count, when Targets omits at least
|
||||
// one server ID outside this token's scope — mirroring
|
||||
// one server ID outside this token's scope - mirroring
|
||||
// WorkflowResponse's REST field, so a model reading this alongside a
|
||||
// run_workflow refusal for the same workflow is not left to conclude the
|
||||
// refusal invented a problem this tool never mentioned.
|
||||
@@ -341,7 +341,7 @@ func init() {
|
||||
//
|
||||
// The refusal reuses the membership message verbatim so that
|
||||
// "in the run but out of your scope" and "not in the run at all"
|
||||
// are indistinguishable — otherwise the difference between the
|
||||
// are indistinguishable - otherwise the difference between the
|
||||
// two answers enumerates hosts the token cannot see.
|
||||
if _, err := services.GetServerScoped(c.InstanceID, serverID, c.TokenScope); err != nil {
|
||||
return nil, fmt.Errorf("server %q is not part of run %q", serverID, runID)
|
||||
@@ -461,7 +461,7 @@ func init() {
|
||||
}
|
||||
|
||||
// Go randomises map iteration order, so truncating a ranged map
|
||||
// to a page made two identical calls return different CVEs — a
|
||||
// to a page made two identical calls return different CVEs - a
|
||||
// model comparing its own two answers would see the fleet change
|
||||
// under it. Sorting by CVE ID (then package, since the key is a
|
||||
// pair) makes the page deterministic.
|
||||
@@ -541,7 +541,7 @@ func init() {
|
||||
},
|
||||
TouchesServers: true,
|
||||
Scope: "vulns:read",
|
||||
Description: "Search every server's installed packages by name across the whole fleet — " +
|
||||
Description: "Search every server's installed packages by name across the whole fleet - " +
|
||||
"answers questions like \"which hosts still run OpenSSL 1.1\". version_below is not " +
|
||||
"currently supported: filtering package versions correctly requires knowing each " +
|
||||
"distribution's own version-ordering scheme (dpkg/rpm/apk), which this tool cannot " +
|
||||
@@ -555,7 +555,7 @@ func init() {
|
||||
if stringArg(args, "version_below") != "" {
|
||||
return nil, fmt.Errorf("version_below is not supported: correct version ordering " +
|
||||
"depends on each host's distribution (dpkg/rpm/apk each order differently), " +
|
||||
"which this tool cannot resolve here — omit version_below and every matching " +
|
||||
"which this tool cannot resolve here - omit version_below and every matching " +
|
||||
"install is returned instead")
|
||||
}
|
||||
|
||||
@@ -616,7 +616,7 @@ func init() {
|
||||
// This tool reads no arguments at all.
|
||||
Args: []ToolArg{},
|
||||
Scope: "secrets:read",
|
||||
Description: "List secret group and key names on this instance. Metadata only — no " +
|
||||
Description: "List secret group and key names on this instance. Metadata only - no " +
|
||||
"tool ever returns a secret's plaintext value to a model.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
groups, err := services.ListSecretGroups(c.InstanceID)
|
||||
|
||||
@@ -43,8 +43,8 @@ type runStartedResult struct {
|
||||
// workflow's own configured target_server_ids/target_tags via
|
||||
// services.ResolveTargets (unscoped) and runs against exactly that set. There
|
||||
// is no per-call server_ids/tags override to plumb through, so this tool takes
|
||||
// only workflow_id. To keep the token's scope meaningful — TriggerWorkflow
|
||||
// itself does not consult it — this handler first loads the workflow and
|
||||
// only workflow_id. To keep the token's scope meaningful - TriggerWorkflow
|
||||
// itself does not consult it - this handler first loads the workflow and
|
||||
// resolves its configured targets through ResolveTargetsScoped with the
|
||||
// caller's TokenScope, and refuses the run outright if that scoped view does
|
||||
// not cover every server the unscoped resolution would touch. That is the
|
||||
@@ -61,7 +61,7 @@ func init() {
|
||||
Write: true,
|
||||
Scope: "workflows:write",
|
||||
Description: "Run a workflow against the servers it is already configured to target " +
|
||||
"(its saved server list and tags — this call does not let you pick different " +
|
||||
"(its saved server list and tags - this call does not let you pick different " +
|
||||
"targets). This EXECUTES COMMANDS on real machines and cannot be undone from " +
|
||||
"here. Returns a run ID immediately; poll get_run for progress and get_run_logs " +
|
||||
"for output. Refused if the workflow's targets reach outside this token's own " +
|
||||
@@ -121,7 +121,7 @@ type cancelledResult struct {
|
||||
// cancel_run. The REST cancel route (workflows.go's cancelRun) calls
|
||||
// services.CancelRun(instanceID, runID) directly; that call is already scoped
|
||||
// to the caller's instance by instanceID, which is what "verifies the run
|
||||
// belongs to the caller's instance" reduces to here — there is no separate
|
||||
// belongs to the caller's instance" reduces to here - there is no separate
|
||||
// per-server scope to check, since cancelling touches the run record, not a
|
||||
// server.
|
||||
func init() {
|
||||
@@ -164,7 +164,7 @@ type updateBatchResult struct {
|
||||
// There is no fleet-wide variant of that service call to invoke once, so this
|
||||
// tool resolves the requested targets through ResolveTargetsScoped exactly as
|
||||
// the brief describes, then calls the same DispatchApplyUpdates the REST route
|
||||
// calls, once per resolved server — the identical dispatch, just looped
|
||||
// calls, once per resolved server - the identical dispatch, just looped
|
||||
// instead of hardcoded to one server_id from the URL.
|
||||
func init() {
|
||||
All().Register(Tool{
|
||||
@@ -275,12 +275,12 @@ type assignKeyResult struct {
|
||||
|
||||
// assign_key. The REST route (handlers.go's assignKey) takes one server_id in
|
||||
// the body and calls services.AssignKey(instanceID, keyID, serverID) directly
|
||||
// — AssignKey itself resolves the server with the unscoped services.GetServer,
|
||||
// - AssignKey itself resolves the server with the unscoped services.GetServer,
|
||||
// not GetServerScoped, so the REST route carries no token-scope check of its
|
||||
// own (session auth has no server-scope restriction; only API tokens do). For
|
||||
// the MCP surface, this tool resolves every named target through
|
||||
// ResolveTargetsScoped first — the same chokepoint every other target-
|
||||
// resolving write tool goes through — so a token whose scope excludes a server
|
||||
// ResolveTargetsScoped first - the same chokepoint every other target-
|
||||
// resolving write tool goes through - so a token whose scope excludes a server
|
||||
// cannot reach it here even though the REST handler's own server lookup would
|
||||
// not have stopped it. Then it calls the identical AssignKey once per resolved
|
||||
// server.
|
||||
@@ -298,7 +298,7 @@ func init() {
|
||||
Scope: "keys:write",
|
||||
Description: "Assign an SSH key to real servers, selected by server_ids and/or " +
|
||||
"tags. The agent rewrites /root/.ssh/authorized_keys on each targeted machine " +
|
||||
"and this cannot be undone from here — use revoke to remove it afterward.",
|
||||
"and this cannot be undone from here - use revoke to remove it afterward.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
keyID := stringArg(args, "key_id")
|
||||
if keyID == "" {
|
||||
|
||||
@@ -31,7 +31,7 @@ func callerFromContext(c *gin.Context) Caller {
|
||||
// because a stateless server has no session to open the server-to-client SSE
|
||||
// stream against. The GET route is still registered deliberately (see
|
||||
// handlers.go) so a client probing for the endpoint sees a protocol-correct
|
||||
// 405 rather than gin's 404 — the MCP spec expects exactly that response from
|
||||
// 405 rather than gin's 404 - the MCP spec expects exactly that response from
|
||||
// a server that does not offer the GET/SSE leg. Nothing here should route GET
|
||||
// requests differently or try to make them do anything else.
|
||||
func Handler() gin.HandlerFunc {
|
||||
@@ -69,7 +69,7 @@ func Handler() gin.HandlerFunc {
|
||||
func registerSDKTool(srv *sdk.Server, tool Tool, caller Caller) {
|
||||
// InputSchema is set explicitly rather than inferred from the handler's
|
||||
// argument type. The SDK can infer one from a typed In parameter, which is
|
||||
// cleaner where it fits — but every ToolFunc here takes map[string]any, and
|
||||
// cleaner where it fits - but every ToolFunc here takes map[string]any, and
|
||||
// inference over that yields a bare open object saying nothing. Giving each
|
||||
// tool its own Go argument struct would mean twenty-odd structs and a
|
||||
// generic registry that could no longer hold them in one map, losing the
|
||||
@@ -86,8 +86,8 @@ func registerSDKTool(srv *sdk.Server, tool Tool, caller Caller) {
|
||||
}
|
||||
|
||||
// callTool is the gate check, dispatch and audit write registerSDKTool wraps
|
||||
// onto the SDK's call signature. It is a separate function — rather than the
|
||||
// closure body inline — so it can be exercised directly in tests without
|
||||
// onto the SDK's call signature. It is a separate function - rather than the
|
||||
// closure body inline - so it can be exercised directly in tests without
|
||||
// standing up an sdk.Server and driving a real MCP request through it.
|
||||
func callTool(ctx context.Context, tool Tool, caller Caller, args map[string]any) (*sdk.CallToolResult, any, error) {
|
||||
if ok, gate := Allowed(tool, caller); !ok {
|
||||
@@ -98,14 +98,14 @@ func callTool(ctx context.Context, tool Tool, caller Caller, args map[string]any
|
||||
out, err := tool.Handler(ctx, caller, args)
|
||||
if err != nil {
|
||||
// A write tool's own handler never gets a chance to audit its own
|
||||
// refusal or failure — it returns before reaching its LogCall, and
|
||||
// refusal or failure - it returns before reaching its LogCall, and
|
||||
// unlike a successful write, this layer does not know a resolved
|
||||
// server count to pass along anyway. So every write failure is
|
||||
// audited here instead: a policy refusal (fan-out or tag scope) as
|
||||
// mcp.tool_denied naming the gate, everything else as
|
||||
// mcp.tool_failed, so a human reading audit_logs can tell "the agent
|
||||
// was stopped" from "the agent tried and the machine failed". Read
|
||||
// tools are unaffected — a failed read was never going to change
|
||||
// tools are unaffected - a failed read was never going to change
|
||||
// anything and carries no gate to name.
|
||||
if tool.Write {
|
||||
switch {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
// TestRefusedWriteIsAudited exercises the real dispatch path (callTool, which
|
||||
// registerSDKTool wraps) for a write tool whose handler refuses the call
|
||||
// before it ever reaches its own LogCall — a fan-out refusal, in this case,
|
||||
// before it ever reaches its own LogCall - a fan-out refusal, in this case,
|
||||
// which run_workflow, apply_updates, update_agent and assign_key all reach
|
||||
// the same way via CheckFanOut. The refusal must still produce an audit row:
|
||||
// a blocked mutation attempt is the single most audit-worthy event a write
|
||||
@@ -77,8 +77,8 @@ func TestOutOfScopeWriteIsAudited(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestServiceFailureIsAuditedDistinctly makes sure a write tool failing for a
|
||||
// reason that is not a policy refusal — the underlying service call itself
|
||||
// erroring — is still audited, but as mcp.tool_failed rather than
|
||||
// reason that is not a policy refusal - the underlying service call itself
|
||||
// erroring - is still audited, but as mcp.tool_failed rather than
|
||||
// mcp.tool_denied, so a human reading audit_logs can tell the two apart.
|
||||
func TestServiceFailureIsAuditedDistinctly(t *testing.T) {
|
||||
var events []string
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
//
|
||||
// The plaintext is shown once at creation and never stored: only TokenHash,
|
||||
// which is sha256 hex of the value, exactly as servers.agent_token_hash and the
|
||||
// ESO read token already are. bcrypt is deliberately not used — the value is
|
||||
// ESO read token already are. bcrypt is deliberately not used - the value is
|
||||
// full-entropy random rather than a chosen password, and a per-token salt would
|
||||
// force a collection scan where an indexed lookup is wanted.
|
||||
//
|
||||
|
||||
@@ -22,7 +22,7 @@ const RedactedSecret = "••••••••"
|
||||
// channelSecretKeys names, per channel type, the config entries that are
|
||||
// credentials rather than settings. A Slack or Discord webhook URL is on this
|
||||
// list because possession of the URL *is* the authorisation to post to that
|
||||
// channel — there is nothing else to steal.
|
||||
// channel - there is nothing else to steal.
|
||||
var channelSecretKeys = map[string][]string{
|
||||
ChannelWebhook: {"url"},
|
||||
ChannelSlack: {"url"},
|
||||
|
||||
@@ -23,8 +23,8 @@ const RunnerServer = "server"
|
||||
|
||||
// RunnerRestricted replaces a monitor's runner in an API response when the
|
||||
// real value is a server ID the acting token's scope does not admit. The
|
||||
// monitor itself is still returned — a restricted operator may legitimately
|
||||
// need to see its name and state — only where it runs is hidden, the same
|
||||
// monitor itself is still returned - a restricted operator may legitimately
|
||||
// need to see its name and state - only where it runs is hidden, the same
|
||||
// way a workflow's target list can omit an ID without the whole workflow
|
||||
// disappearing from a list.
|
||||
const RunnerRestricted = "restricted"
|
||||
|
||||
@@ -63,7 +63,7 @@ type VulnFinding struct {
|
||||
PackageName string `bson:"package_name" json:"package_name"`
|
||||
Installed string `bson:"installed_version" json:"installed_version"`
|
||||
// FixedIn empty means no vendor fix has been published. That is a real and
|
||||
// common state and must never be conflated with "not vulnerable" — it is
|
||||
// common state and must never be conflated with "not vulnerable" - it is
|
||||
// the finding most in need of acceptance, since there is nothing to patch.
|
||||
FixedIn string `bson:"fixed_in,omitempty" json:"fixed_in,omitempty"`
|
||||
Severity string `bson:"severity" json:"severity"`
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// dispatchSMTP delivers a state change over one channel's own SMTP settings.
|
||||
//
|
||||
// The transport, the envelope and the look of the message all live in
|
||||
// shared/mail, which admin and sitesvc use too — a Vantage alert and a Vantage
|
||||
// shared/mail, which admin and sitesvc use too - a Vantage alert and a Vantage
|
||||
// licence email should not look like they came from different products. This
|
||||
// function only turns a channel document into a Sender.
|
||||
func dispatchSMTP(ch models.NotificationChannel, ev Event) error {
|
||||
|
||||
@@ -16,7 +16,7 @@ const TypeVuln = "vulnerability"
|
||||
//
|
||||
// One per rule per scan, never one per finding: a database refresh can open
|
||||
// several hundred at once, and a message each would rate-limit the webhook or
|
||||
// get the channel muted — either way the alerts stop being read.
|
||||
// get the channel muted - either way the alerts stop being read.
|
||||
type VulnDigest struct {
|
||||
InstanceName string
|
||||
RuleName string
|
||||
@@ -74,7 +74,7 @@ func vulnLines(d VulnDigest) string {
|
||||
if r.FixedIn != "" {
|
||||
fix = "fixed in " + r.FixedIn
|
||||
}
|
||||
s += fmt.Sprintf("\n• %s (%s) — %s on %s, %s", r.CVEID, r.Severity, r.PackageName, r.ServerName, fix)
|
||||
s += fmt.Sprintf("\n• %s (%s) - %s on %s, %s", r.CVEID, r.Severity, r.PackageName, r.ServerName, fix)
|
||||
}
|
||||
if d.More > 0 {
|
||||
s += fmt.Sprintf("\n…and %d more.", d.More)
|
||||
|
||||
@@ -34,4 +34,4 @@ func NewID() (string, error) {
|
||||
//
|
||||
// The pending record lives in Redis instead (bus.SetPendingProxy /
|
||||
// ClaimPendingProxy), and the listener is bound by whichever pod the stream
|
||||
// actually reaches — see services.ClaimProxyStream.
|
||||
// actually reaches - see services.ClaimProxyStream.
|
||||
|
||||
@@ -90,7 +90,7 @@ func (s *Session) Reason() string {
|
||||
// a no-op once a deliberate teardown (Close) has begun: a local Close closing
|
||||
// the conn out from under the relay goroutines produces exactly the kind of
|
||||
// error (net.ErrClosed, a broken pipe on write, ...) that looks like a remote
|
||||
// failure but is not one, and must not overwrite — or race to set — the real
|
||||
// failure but is not one, and must not overwrite - or race to set - the real
|
||||
// reason, or invent one where a clean local close has none.
|
||||
func (s *Session) setReason(r string) {
|
||||
s.mu.Lock()
|
||||
@@ -103,7 +103,7 @@ func (s *Session) setReason(r string) {
|
||||
// Close tears the session down once. A non-empty reason is recorded only if no
|
||||
// reason has been recorded already, and only before teardown begins. It closes
|
||||
// both the listener and, if a connection has already been accepted, that
|
||||
// connection too — an unconditional kill for the whole relay chain regardless
|
||||
// connection too - an unconditional kill for the whole relay chain regardless
|
||||
// of which stage it is in.
|
||||
func (s *Session) Close(reason string) {
|
||||
if reason != "" {
|
||||
|
||||
@@ -35,15 +35,15 @@ func LogEvent(instanceID, eventType, actor, serverID, keyID, details string) {
|
||||
// AuditFilter narrows a page of the audit log.
|
||||
//
|
||||
// Filtering is done here rather than in the browser because the audit log is
|
||||
// the one collection deliberately kept for months — audit_retention_days is a
|
||||
// licensed entitlement — and it is read to answer questions about the past
|
||||
// the one collection deliberately kept for months - audit_retention_days is a
|
||||
// licensed entitlement - and it is read to answer questions about the past
|
||||
// ("who removed that key in March"). A browser filtering the most recent 200
|
||||
// rows would answer "no results" for an event that exists, which is worse than
|
||||
// having no search at all.
|
||||
type AuditFilter struct {
|
||||
// Search matches actor, details or event type, case-insensitively.
|
||||
Search string
|
||||
// Category matches the segment before the first dot in an event type —
|
||||
// Category matches the segment before the first dot in an event type -
|
||||
// "workflow", "key", "server". Event types are named consistently enough
|
||||
// that the prefix is a real grouping rather than a guess.
|
||||
Category string
|
||||
|
||||
@@ -44,7 +44,7 @@ func StartAuditSweeper(ctx context.Context) {
|
||||
// process restarts.
|
||||
//
|
||||
// It skips an instance whose licence is not valid. A lapsed instance must not
|
||||
// have its history trimmed on the expired term's allowance — expiry degrades to
|
||||
// have its history trimmed on the expired term's allowance - expiry degrades to
|
||||
// read-only, and deleting more of somebody's audit trail is not read-only.
|
||||
//
|
||||
// It skips Unlimited and any non-positive value. A licence that decodes as zero
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
// ErrLockout is returned when a change would leave an instance with neither
|
||||
// local password login nor an enabled provider — nobody could sign in, and no
|
||||
// local password login nor an enabled provider - nobody could sign in, and no
|
||||
// endpoint exists to undo it without database access.
|
||||
var ErrLockout = errors.New("that would leave nobody able to sign in")
|
||||
|
||||
@@ -36,8 +36,8 @@ func authProviderCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 5*time.Second)
|
||||
}
|
||||
|
||||
// CheckLockout is pure so the two endpoints that can reach this condition —
|
||||
// saving settings and changing a provider — share one answer.
|
||||
// CheckLockout is pure so the two endpoints that can reach this condition -
|
||||
// saving settings and changing a provider - share one answer.
|
||||
func CheckLockout(localEnabled bool, enabledProviders int) error {
|
||||
if localEnabled || enabledProviders > 0 {
|
||||
return nil
|
||||
@@ -244,8 +244,8 @@ func IsLocalLoginEnabled(instanceID string) bool {
|
||||
|
||||
// LocalLoginPermitted answers whether password sign-in must be accepted for
|
||||
// this instance, which is not the same question as whether an administrator
|
||||
// turned it on. An instance whose only providers have become unusable — a
|
||||
// lapsed licence, or every provider disabled — has to keep its password form,
|
||||
// turned it on. An instance whose only providers have become unusable - a
|
||||
// lapsed licence, or every provider disabled - has to keep its password form,
|
||||
// or nobody can sign in and there is no endpoint left to fix it with.
|
||||
func LocalLoginPermitted(instanceID string) bool {
|
||||
if IsLocalLoginEnabled(instanceID) {
|
||||
|
||||
@@ -110,7 +110,7 @@ func UpdateChannel(instanceID, channelID string, upd bson.M) error {
|
||||
// save arrives carrying the sentinel in place of the password. Writing it
|
||||
// through would replace the credential with eight bullet characters and break
|
||||
// delivery on the next alert. A value that is not the sentinel is written
|
||||
// verbatim — including the empty string, which is how a credential is cleared.
|
||||
// verbatim - including the empty string, which is how a credential is cleared.
|
||||
func mergeChannelSecrets(instanceID, channelID string, upd bson.M, cfg map[string]string) (map[string]string, error) {
|
||||
stored, err := GetChannel(instanceID, channelID)
|
||||
if err != nil {
|
||||
|
||||
@@ -30,7 +30,7 @@ var ErrAgentOffline = errors.New("agent is not connected")
|
||||
//
|
||||
// That third line is the one that is easy to get wrong. A ProxyStream is a
|
||||
// separate HTTP/2 request, and an L7 proxy (Traefik, which the chart's gRPC
|
||||
// ingress uses) balances requests rather than connections — so it does not
|
||||
// ingress uses) balances requests rather than connections - so it does not
|
||||
// follow the command stream. Binding the relay listener on the command
|
||||
// stream's pod therefore fails roughly (n-1)/n of the time with "proxy session
|
||||
// not found": the stream arrives at a pod whose registry is empty.
|
||||
@@ -48,7 +48,7 @@ var ErrAgentOffline = errors.New("agent is not connected")
|
||||
//
|
||||
// Teardown needs no message of its own. When the browser goes away guac closes
|
||||
// its connection to the relay, the relay sees the read end, and the session
|
||||
// closes itself — the same path a single-process deployment always took. Only
|
||||
// closes itself - the same path a single-process deployment always took. Only
|
||||
// the *reason* has to cross back, because the pod that writes the audit event
|
||||
// is not the pod that observed the failure.
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
//
|
||||
// An agent's CommandStream terminates on exactly one process, and with several
|
||||
// replicas that is almost never the process handling the REST request that
|
||||
// wants to talk to it. Publishing unconditionally — rather than checking for a
|
||||
// local stream first and falling back — means one code path, exercised on every
|
||||
// wants to talk to it. Publishing unconditionally - rather than checking for a
|
||||
// local stream first and falling back - means one code path, exercised on every
|
||||
// deployment including the single-replica ones, instead of a rare cross-pod
|
||||
// path that only fails in production.
|
||||
const (
|
||||
@@ -48,7 +48,7 @@ type CommandEnvelope struct {
|
||||
|
||||
// Node names the pod this envelope is for: the presence holder at the time
|
||||
// it was published. The command channel is a fan-out, so during a reconnect
|
||||
// two pods can be subscribed for one agent — the pod with the live stream,
|
||||
// two pods can be subscribed for one agent - the pod with the live stream,
|
||||
// and a pod whose stream is half-open and has not yet noticed. Both would
|
||||
// receive the envelope, and the first to ack wins the request. If that is
|
||||
// the stale one, the command is queued onto a dead stream and acked OK: the
|
||||
@@ -161,7 +161,7 @@ func (d *commandDispatcher) Serve(ctx context.Context, serverID string) (<-chan
|
||||
//
|
||||
// A Redis failure returns true. It is tempting to read an error as loss and
|
||||
// give up, but nothing is known in that moment about who holds the claim, and
|
||||
// the stream this pod is serving is demonstrably alive — the caller is either a
|
||||
// the stream this pod is serving is demonstrably alive - the caller is either a
|
||||
// ticker on that stream or a beat that just succeeded on it. Standing down on a
|
||||
// blip is precisely how an agent ends up connected, beating, and unreachable
|
||||
// until it happens to reconnect.
|
||||
@@ -174,7 +174,7 @@ func renewPresence(ctx context.Context, serverID string) bool {
|
||||
log.Printf("dispatch: presence for %s is held elsewhere, standing down", serverID)
|
||||
return false
|
||||
case bus.RenewedClaim:
|
||||
// Nobody held the key — Redis restarted, failed over, evicted it, or was
|
||||
// Nobody held the key - Redis restarted, failed over, evicted it, or was
|
||||
// unreachable for longer than the TTL. Worth a line: it is the only
|
||||
// evidence that presence was lost and recovered rather than never lost.
|
||||
log.Printf("dispatch: reclaimed presence for %s", serverID)
|
||||
|
||||
@@ -30,7 +30,7 @@ func findingKey(cveID, pkg string) string { return cveID + "\x00" + pkg }
|
||||
// DiffFindings computes the state changes for one server's scan.
|
||||
//
|
||||
// Pure by design: no database, no clock of its own. The ordering below is
|
||||
// load-bearing — see the comment above the second loop.
|
||||
// load-bearing - see the comment above the second loop.
|
||||
func DiffFindings(existing []models.VulnFinding, results []vulndb.Result, now time.Time) FindingDiff {
|
||||
var d FindingDiff
|
||||
|
||||
@@ -104,7 +104,7 @@ func DiffFindings(existing []models.VulnFinding, results []vulndb.Result, now ti
|
||||
}
|
||||
|
||||
// ErrFindingNotFound is returned for a finding that does not exist in this
|
||||
// instance. Callers turn it into a 404 — never a 403, which would confirm the
|
||||
// instance. Callers turn it into a 404 - never a 403, which would confirm the
|
||||
// finding exists in someone else's instance.
|
||||
var ErrFindingNotFound = errors.New("finding not found")
|
||||
|
||||
@@ -116,7 +116,7 @@ type FindingFilter struct {
|
||||
ServerID string
|
||||
Tags map[string]string
|
||||
// HasFix nil is no filter. true is "a vendor fix exists, this is
|
||||
// patchable"; false is the unfixable set — remove the package, disable the
|
||||
// patchable"; false is the unfixable set - remove the package, disable the
|
||||
// service, or accept it, but do not wait for an update.
|
||||
HasFix *bool
|
||||
// TokenScope is the acting credential's tag restriction, nil meaning
|
||||
@@ -172,7 +172,7 @@ func ListInstanceFindings(instanceID string, f FindingFilter) ([]models.VulnFind
|
||||
}
|
||||
|
||||
// The token restriction is applied the same way the Tags selector above
|
||||
// is — by narrowing server_id — rather than by a post-pass, so the two
|
||||
// is - by narrowing server_id - rather than by a post-pass, so the two
|
||||
// cannot disagree and the query keeps one shape. IntersectSelectors is
|
||||
// not used here because Tags has already been resolved to IDs by this
|
||||
// point; intersecting the ID sets is the same operation one level down.
|
||||
@@ -370,7 +370,7 @@ func ListFindings(ctx context.Context, instanceID, serverID string) ([]models.Vu
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ApplyFindingDiff writes a diff. Thin on purpose — the logic worth reading
|
||||
// ApplyFindingDiff writes a diff. Thin on purpose - the logic worth reading
|
||||
// twice is all in DiffFindings.
|
||||
func ApplyFindingDiff(ctx context.Context, instanceID, serverID string, d FindingDiff, now time.Time) error {
|
||||
col := db.Col("vuln_findings")
|
||||
|
||||
@@ -122,7 +122,7 @@ type KeyWithCount struct {
|
||||
// count, narrowed by tokenScope: AssignedCount only counts assignments on
|
||||
// servers ServerInTokenScope admits. Without this, a restricted token reading
|
||||
// the key list would see a nonzero count for a key it cannot see a single
|
||||
// assignment of in its own scope — the same hostname-existence leak
|
||||
// assignment of in its own scope - the same hostname-existence leak
|
||||
// getKey's scope filter closes on the detail route, reachable here through a
|
||||
// count instead of a server object.
|
||||
//
|
||||
@@ -148,7 +148,7 @@ func ListKeys(instanceID string, tokenScope map[string]string) ([]KeyWithCount,
|
||||
|
||||
// Resolve the visible fleet once, outside the per-key loop, so a
|
||||
// restricted token's count costs one extra query total rather than one
|
||||
// per key — the same reasoning ResolveTargetsScoped already applies to
|
||||
// per key - the same reasoning ResolveTargetsScoped already applies to
|
||||
// target resolution.
|
||||
scoped := len(tokenScope) > 0
|
||||
var visibleIDs []string
|
||||
|
||||
@@ -22,7 +22,7 @@ type LicenseState struct {
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
Limits license.Limits `json:"limits"`
|
||||
Features map[string]bool `json:"features"`
|
||||
// Source is "stored", "env" or "none" — useful when a self-hosted operator
|
||||
// Source is "stored", "env" or "none" - useful when a self-hosted operator
|
||||
// asks why the licence they pasted is not the one in effect.
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func limitCtx() (context.Context, context.CancelFunc) {
|
||||
// CheckServerLimit refuses a new server when the instance is at its cap.
|
||||
//
|
||||
// Counts live rows only. An instance already over its cap keeps every server it
|
||||
// has — nothing is truncated — it simply cannot add another.
|
||||
// has - nothing is truncated - it simply cannot add another.
|
||||
func CheckServerLimit(instanceID string) error {
|
||||
st := GetLicenseState(instanceID)
|
||||
ctx, cancel := limitCtx()
|
||||
@@ -92,7 +92,7 @@ func CheckChannelLimit(instanceID string) error {
|
||||
// CheckMonitorLimit refuses a new monitor when the instance is at its cap.
|
||||
//
|
||||
// Counts live rows only, like every other check here. An instance already over
|
||||
// its cap keeps every monitor it has and they keep executing — the licence
|
||||
// its cap keeps every monitor it has and they keep executing - the licence
|
||||
// expiry story is that monitoring never stops, so truncating here would
|
||||
// contradict it.
|
||||
func CheckMonitorLimit(instanceID string) error {
|
||||
|
||||
@@ -27,7 +27,7 @@ type legacyInstanceOIDC struct {
|
||||
// migration that needs KEY_ENCRYPTION_KEY fails on an instance that has none
|
||||
// and strands the SSO configuration it was supposed to preserve.
|
||||
//
|
||||
// instance_oidc is left in place and no longer read. Nothing deletes it — a
|
||||
// instance_oidc is left in place and no longer read. Nothing deletes it - a
|
||||
// migration that drops the only copy of a client secret has no undo.
|
||||
func MigrateAuthProviders() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
//
|
||||
// Migration 0004 renames org_id to instance_id in each. A collection missing
|
||||
// from this list keeps the old field name and becomes invisible to every scoped
|
||||
// query — so this list is load-bearing, not documentation.
|
||||
// query - so this list is load-bearing, not documentation.
|
||||
//
|
||||
// AssertNoScopedCollectionMissed checks at boot that nothing outside this list
|
||||
// holds an org_id.
|
||||
@@ -63,7 +63,7 @@ var collectionRenames = []struct{ from, to string }{
|
||||
// bad deploy is recovered by running the inverse rename (cmd/rename-rollback)
|
||||
// rather than by restoring a backup.
|
||||
//
|
||||
// The steps are not atomic across collections — multi-document transactions
|
||||
// The steps are not atomic across collections - multi-document transactions
|
||||
// would require a replica set, which self-hosted installs do not guarantee.
|
||||
// Instead every step is safely repeatable: a collection rename is skipped when
|
||||
// the source is already gone, and $rename matches nothing on a document that
|
||||
|
||||
@@ -55,8 +55,8 @@ func SpecFor(m *models.Monitor) checker.Spec {
|
||||
|
||||
// RedactMonitorRunner replaces m.Runner with models.RunnerRestricted when it
|
||||
// names a server outside the caller's scope, so GET /monitors and GET
|
||||
// /monitors/:id can keep listing the monitor itself — name, type, state,
|
||||
// whether it exists at all — as the first-class object it is, without
|
||||
// /monitors/:id can keep listing the monitor itself - name, type, state,
|
||||
// whether it exists at all - as the first-class object it is, without
|
||||
// disclosing which out-of-scope server it happens to run on. Omitting the
|
||||
// monitor entirely was considered and rejected: a restricted operator has a
|
||||
// legitimate reason to see that a monitor exists and is up or down even when
|
||||
@@ -293,7 +293,7 @@ const MaxMonitorSamples = 6000
|
||||
|
||||
// MonitorSamples returns individual check results since a point in time,
|
||||
// oldest first. Samples older than MonitorSampleTTL have expired, so an early
|
||||
// `since` silently returns a shorter window rather than an error — the caller
|
||||
// `since` silently returns a shorter window rather than an error - the caller
|
||||
// draws the gap.
|
||||
func MonitorSamples(instanceID, monitorID string, since time.Time) ([]models.MonitorSample, error) {
|
||||
ctx, cancel := monCtx()
|
||||
|
||||
@@ -21,7 +21,7 @@ const MonitorSampleTTL = 48 * time.Hour
|
||||
|
||||
// EnsureMonitorSampleIndexes declares the sample range index and its TTL.
|
||||
//
|
||||
// Warn rather than fatal, like the other history indexes — but note the TTL is
|
||||
// Warn rather than fatal, like the other history indexes - but note the TTL is
|
||||
// not an optimisation: without it nothing ever removes a sample, and the
|
||||
// collection grows at the fleet's total check rate forever. A boot that logs
|
||||
// this warning needs following up.
|
||||
|
||||
@@ -87,7 +87,7 @@ type PackageHit struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// SearchPackages answers "which servers run package X" across the fleet — the
|
||||
// SearchPackages answers "which servers run package X" across the fleet - the
|
||||
// question people actually ask during an incident.
|
||||
//
|
||||
// The Mongo filter narrows to documents containing the name; the second pass is
|
||||
@@ -96,8 +96,8 @@ type PackageHit struct {
|
||||
//
|
||||
// tokenScope is the acting credential's tag restriction, nil meaning
|
||||
// unrestricted; a hit on a server outside it is dropped before it is returned.
|
||||
// The filtering is done with VisibleServerIDs — one membership set resolved
|
||||
// once — rather than by resolving each hit's server individually the way
|
||||
// The filtering is done with VisibleServerIDs - one membership set resolved
|
||||
// once - rather than by resolving each hit's server individually the way
|
||||
// search_fleet does, because a package search can return one hit per host in
|
||||
// the fleet and the query shape must not depend on how many matched. The Mongo
|
||||
// query itself is unchanged: server_packages carries no tags to filter on, so
|
||||
|
||||
@@ -62,7 +62,7 @@ func reapAfter() time.Duration {
|
||||
//
|
||||
// Unexported and unguarded: it trusts its caller completely and performs an
|
||||
// irreversible delete on whatever instance ID it is handed. The tier and expiry
|
||||
// gate — Free tier, an expiry that exists, an expiry past the window — lives in
|
||||
// gate - Free tier, an expiry that exists, an expiry past the window - lives in
|
||||
// ReapFreeInstances, which is the only caller. Do not export this.
|
||||
//
|
||||
// Idempotent: re-running over a half-deleted instance completes it. The instance
|
||||
@@ -93,8 +93,8 @@ func purgeInstance(ctx context.Context, instanceID string) (map[string]int64, er
|
||||
// ago than the configured window.
|
||||
//
|
||||
// Eligibility requires ALL of:
|
||||
// - license_tier == "free" — a paid instance is never eligible
|
||||
// - license_expiry present — an instance that was never licensed, or whose
|
||||
// - license_tier == "free" - a paid instance is never eligible
|
||||
// - license_expiry present - an instance that was never licensed, or whose
|
||||
// issuance failed, has no expiry and is never eligible whatever its age
|
||||
// - license_expiry older than now minus the window
|
||||
//
|
||||
@@ -136,7 +136,7 @@ func ReapFreeInstances(ctx context.Context) (checked, purged int, err error) {
|
||||
// audit entry written here would delete itself moments later. It is
|
||||
// written anyway, because an operator reading audit during the window
|
||||
// should see it coming.
|
||||
log.Printf("REAPING instance %s (%s, slug=%s) — Free licence expired %s, past the %s window",
|
||||
log.Printf("REAPING instance %s (%s, slug=%s) - Free licence expired %s, past the %s window",
|
||||
d.InstanceID, d.Name, d.Slug, d.Expiry.Format(time.RFC3339), window)
|
||||
LogEvent(d.InstanceID, "instance.reaped", "system", "", "",
|
||||
fmt.Sprintf("free licence expired %s, window %s", d.Expiry.Format(time.RFC3339), window))
|
||||
@@ -157,7 +157,7 @@ func ReapFreeInstances(ctx context.Context) (checked, purged int, err error) {
|
||||
//
|
||||
// The pass at boot follows inject.StartReconciler's precedent and earns its keep
|
||||
// the same way: it makes a restart a supported way to force a sweep, which is
|
||||
// the only way this code can be exercised on demand — the ticker is hourly and
|
||||
// the only way this code can be exercised on demand - the ticker is hourly and
|
||||
// deletion is measured in days.
|
||||
func StartReaper(ctx context.Context) {
|
||||
window := reapAfter()
|
||||
@@ -165,7 +165,7 @@ func StartReaper(ctx context.Context) {
|
||||
log.Printf("reaper: DISABLED (FREE_INSTANCE_REAP_AFTER is unset or zero)")
|
||||
return
|
||||
}
|
||||
log.Printf("reaper: ENABLED — Free instances are deleted %s after their licence expires", window)
|
||||
log.Printf("reaper: ENABLED - Free instances are deleted %s after their licence expires", window)
|
||||
|
||||
go func() {
|
||||
reapOnce(ctx)
|
||||
|
||||
@@ -76,7 +76,7 @@ func GetServer(instanceID, serverID string) (*models.Server, error) {
|
||||
// a restricted token must not be able to enumerate the fleet it cannot see by
|
||||
// noticing which IDs answer differently.
|
||||
//
|
||||
// mongo.ErrNoDocuments is GetServer's own not-found identifier — reused here
|
||||
// mongo.ErrNoDocuments is GetServer's own not-found identifier - reused here
|
||||
// rather than introducing a second one, so a caller checking for one keeps
|
||||
// working against a server that exists but is out of the token's scope.
|
||||
func GetServerScoped(instanceID, serverID string, tokenScope map[string]string) (*models.Server, error) {
|
||||
@@ -360,7 +360,7 @@ func markOfflineForFilter(scope bson.M, instanceID string) error {
|
||||
}
|
||||
|
||||
// notifyServerOffline delivers an agent-offline alert over the instance's
|
||||
// chosen notification channels — the same destinations monitors dispatch to,
|
||||
// chosen notification channels - the same destinations monitors dispatch to,
|
||||
// so a webhook or SMTP destination is configured and tested in exactly one
|
||||
// place. No channels selected means the alert is audited but not sent.
|
||||
func notifyServerOffline(instanceID string, channelIDs []string, s models.Server) {
|
||||
@@ -393,7 +393,7 @@ func notifyServerOffline(instanceID string, channelIDs []string, s models.Server
|
||||
}
|
||||
|
||||
// ListServersFiltered is ListServers with an optional tag selector. An empty
|
||||
// selector returns the whole fleet — unlike MatchesTags, where empty means
|
||||
// selector returns the whole fleet - unlike MatchesTags, where empty means
|
||||
// "nothing", because here the caller is a list view whose default is
|
||||
// "everything", not a run about to touch machines.
|
||||
func ListServersFiltered(instanceID string, sel map[string]string) ([]models.Server, error) {
|
||||
|
||||
@@ -74,7 +74,7 @@ func validateIncident(inc *models.StatusIncident) error {
|
||||
// fleet's monitors: publishing "api-gateway is degraded" on a page that never
|
||||
// listed api-gateway names a machine to the public that the page deliberately
|
||||
// does not, which is the same leak assembleSnapshot's redaction boundary exists
|
||||
// to prevent — reached from the authoring side instead of the read side.
|
||||
// to prevent - reached from the authoring side instead of the read side.
|
||||
//
|
||||
// It is a separate pass rather than part of validateIncident because it reads
|
||||
// the database, and validateIncident is a pure function of the document. The
|
||||
@@ -289,7 +289,7 @@ func AppendStatusIncidentUpdate(instanceID, incidentID, status, body, author str
|
||||
set["resolved_at"] = upd.At
|
||||
} else {
|
||||
// Reopening via an appended update must clear a previously-set
|
||||
// resolved_at the same way UpdateStatusIncident does — otherwise a
|
||||
// resolved_at the same way UpdateStatusIncident does - otherwise a
|
||||
// resolved incident reopened to "monitoring" keeps a stale resolved_at
|
||||
// and silently drops off ListStatusIncidentsForPage once started_at
|
||||
// ages past the since cutoff, because none of its $or clauses match.
|
||||
|
||||
@@ -47,7 +47,7 @@ func spCtx() (context.Context, context.CancelFunc) {
|
||||
//
|
||||
// All three are attempted and the failures joined, rather than returning on
|
||||
// the first. The three are independent, and two of them are uniqueness
|
||||
// constraints — bailing out on the status_pages index meant a transient
|
||||
// constraints - bailing out on the status_pages index meant a transient
|
||||
// failure there silently left status_incidents with no unique
|
||||
// (instance_id, incident_id) index at all.
|
||||
func EnsureStatusPageIndexes() error {
|
||||
@@ -86,7 +86,7 @@ var (
|
||||
ErrPageIDTaken = errors.New("that page id is already in use")
|
||||
|
||||
// ErrPageInvalid is the sentinel for validation failures on a page or
|
||||
// incident body — anything the caller can fix by sending a different
|
||||
// incident body - anything the caller can fix by sending a different
|
||||
// request. statusPageError maps it to 400; wrap it rather than returning a
|
||||
// bare error, or a bad request answers 500.
|
||||
ErrPageInvalid = errors.New("status page request invalid")
|
||||
|
||||
@@ -58,7 +58,7 @@ type PublicIncidentUpdate struct {
|
||||
}
|
||||
|
||||
// PublicIncident covers both authored incidents and derived monitor outages.
|
||||
// A derived one carries no updates and no impact — and never a cause, which is
|
||||
// A derived one carries no updates and no impact - and never a cause, which is
|
||||
// where internal hostnames live.
|
||||
type PublicIncident struct {
|
||||
ID string `json:"id"`
|
||||
@@ -138,7 +138,7 @@ func assembleSnapshot(in snapshotInput) StatusSnapshot {
|
||||
names[entry.MonitorID] = name
|
||||
|
||||
// Uptime is computed from the days as reported by rollups, before
|
||||
// any maintenance repaint — a no_data day must never be counted as
|
||||
// any maintenance repaint - a no_data day must never be counted as
|
||||
// zero uptime just because it is later redrawn as "maintenance".
|
||||
days := buildDays(in.Rollups[entry.MonitorID], in.Now)
|
||||
comp := PublicComponent{
|
||||
@@ -294,7 +294,7 @@ func publicFromAuthored(inc models.StatusIncident, names map[string]string) Publ
|
||||
// already read the true no_data/up/down state of each day. Folding the
|
||||
// repaint in here would let a today cell with no rollups yet flip from
|
||||
// no_data to maintenance before its uptime contribution was decided, and
|
||||
// uptimeFromDays skips no_data days by their State — so that day would stop
|
||||
// uptimeFromDays skips no_data days by their State - so that day would stop
|
||||
// being skipped and start counting as a zero.
|
||||
func buildDays(rollups []models.Rollup, now time.Time) []PublicDay {
|
||||
type bucket struct{ checks, up int }
|
||||
@@ -335,7 +335,7 @@ func buildDays(rollups []models.Rollup, now time.Time) []PublicDay {
|
||||
// computation, not before: repainting first would turn a today cell with no
|
||||
// rollups yet from no_data (skipped) into maintenance (a 0% day counted in
|
||||
// the average), and repainting a day that DOES have rollups must still leave
|
||||
// that day's real up/down contribution in the average — maintenance changes
|
||||
// that day's real up/down contribution in the average - maintenance changes
|
||||
// how a day is drawn, never what the numbers say.
|
||||
func applyMaintenanceRepaint(days []PublicDay, inMaintenance bool) []PublicDay {
|
||||
if inMaintenance && len(days) > 0 {
|
||||
|
||||
@@ -165,7 +165,7 @@ func TestAssembleSnapshotMaintenanceDoesNotChangeUptime(t *testing.T) {
|
||||
|
||||
// TestAssembleSnapshotMaintenanceRepaintDoesNotCountNoDataAsZero guards
|
||||
// against the maintenance repaint corrupting Uptime90d for a component whose
|
||||
// today rollup has not landed yet — an in-progress maintenance window on a
|
||||
// today rollup has not landed yet - an in-progress maintenance window on a
|
||||
// young component, or one that simply started before today's hourly rollup
|
||||
// was written. Repainting today's no_data cell to "maintenance" must never
|
||||
// make uptimeFromDays stop skipping it: doing so would turn a component with
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
// process: step output arrives on whichever pod holds the agent's stream, the
|
||||
// run's markers are written by whichever pod started the run, and the browser
|
||||
// asks for the log through whichever pod the load balancer picked. Three pods,
|
||||
// one file, one local disk — two of them see an empty log.
|
||||
// one file, one local disk - two of them see an empty log.
|
||||
//
|
||||
// Mongo makes every pod an equal reader and writer, which is the property that
|
||||
// matters. It costs writes on the hot path, so the writer batches (see
|
||||
@@ -144,7 +144,7 @@ type stepLogRegistry struct {
|
||||
|
||||
// The registry stays process-local, and correctly so: a step's output arrives
|
||||
// on the pod holding that agent's stream, and that is the same pod the
|
||||
// dispatch envelope asked to open the writer. Nothing here crosses pods —
|
||||
// dispatch envelope asked to open the writer. Nothing here crosses pods -
|
||||
// only the lines it produces do, by virtue of landing in Mongo.
|
||||
var StepLogs = &stepLogRegistry{writers: make(map[string]*stepLogWriter)}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ type stepResultRegistry struct{}
|
||||
var StepResults = &stepResultRegistry{}
|
||||
|
||||
// Await subscribes to a command's result channel. The returned cancel function
|
||||
// must be called once the caller is done, whether a result arrived or not —
|
||||
// must be called once the caller is done, whether a result arrived or not -
|
||||
// it is what releases the Redis subscription.
|
||||
func (r *stepResultRegistry) Await(commandID string) (<-chan *pb.StepResult, func()) {
|
||||
out := make(chan *pb.StepResult, 1)
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
// ErrInvalidTag is returned for any tag the rules below reject. Handlers map
|
||||
// it to 400 — a malformed tag is the caller's mistake, not a server fault.
|
||||
// it to 400 - a malformed tag is the caller's mistake, not a server fault.
|
||||
var ErrInvalidTag = errors.New("invalid tag")
|
||||
|
||||
const (
|
||||
@@ -43,8 +43,8 @@ func validTagRunes(s string) bool {
|
||||
}
|
||||
|
||||
// ValidateTags enforces the shape of a whole tag map. It lives in the service
|
||||
// layer rather than a handler so that every write path — the tags endpoint,
|
||||
// server create, anything added later — agrees on what a valid tag is.
|
||||
// layer rather than a handler so that every write path - the tags endpoint,
|
||||
// server create, anything added later - agrees on what a valid tag is.
|
||||
func ValidateTags(tags map[string]string) error {
|
||||
if len(tags) > maxTagsPerHost {
|
||||
return fmt.Errorf("%w: at most %d tags per server", ErrInvalidTag, maxTagsPerHost)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
// report success over zero servers.
|
||||
var ErrNoTargets = errors.New("workflow has no target servers")
|
||||
|
||||
// MatchesTags reports whether srv carries every pair in sel — AND across keys.
|
||||
// MatchesTags reports whether srv carries every pair in sel - AND across keys.
|
||||
// An empty selector matches nothing. That is deliberate: the alternative,
|
||||
// "matches everything", turns a cleared field in the workflow designer into a
|
||||
// fleet-wide run.
|
||||
|
||||
@@ -129,7 +129,7 @@ func CreateAPIToken(instanceID, userID, name, role string, scopes []string, tagS
|
||||
InstanceID: instanceID,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
// Hint is "vt_" plus 5 hex characters of the secret (20 bits) — enough
|
||||
// Hint is "vt_" plus 5 hex characters of the secret (20 bits) - enough
|
||||
// for a user to recognise their own token in a list, not enough to be
|
||||
// useful to anyone who only has the hint. Considered and accepted.
|
||||
Hint: plaintext[:8],
|
||||
@@ -197,7 +197,7 @@ var (
|
||||
)
|
||||
|
||||
// ShouldLogExpiredTokenUse reports whether an expired token's use is worth a
|
||||
// fresh audit row, throttled to once per token per minute — the same window
|
||||
// fresh audit row, throttled to once per token per minute - the same window
|
||||
// TouchAPIToken uses for last-used, kept here rather than in the auth package
|
||||
// because the storage concern (what counts as "recent") belongs beside the
|
||||
// token's other storage-backed state, not scattered into the request layer.
|
||||
|
||||
@@ -25,13 +25,13 @@ func ServerInTokenScope(srv models.Server, sel map[string]string) bool {
|
||||
|
||||
// VisibleServerIDs resolves the servers tokenScope admits into a membership
|
||||
// set, for a caller that needs to test many IDs against the caller's scope in
|
||||
// one pass — redacting a monitor's runner, filtering a workflow's target list
|
||||
// — rather than resolving one server at a time the way GetServerScoped does.
|
||||
// one pass - redacting a monitor's runner, filtering a workflow's target list
|
||||
// - rather than resolving one server at a time the way GetServerScoped does.
|
||||
//
|
||||
// restricted is false for an empty tokenScope, matching ServerInTokenScope's
|
||||
// own rule that an empty selector is unrestricted rather than "sees nothing".
|
||||
// ids is then nil, and callers must treat (nil, false) as "everything
|
||||
// visible", never as "nothing visible" — the zero value of a map read is
|
||||
// visible", never as "nothing visible" - the zero value of a map read is
|
||||
// false, which would silently invert the rule for every unrestricted caller
|
||||
// if this contract were not honoured.
|
||||
func VisibleServerIDs(instanceID string, tokenScope map[string]string) (ids map[string]bool, restricted bool, err error) {
|
||||
@@ -55,7 +55,7 @@ func VisibleServerIDs(instanceID string, tokenScope map[string]string) (ids map[
|
||||
// (ids, restricted) pair VisibleServerIDs returns. An unrestricted caller
|
||||
// (restricted false) gets ids back unchanged and hidden is always false.
|
||||
//
|
||||
// hidden reports only whether at least one id was dropped — never how many —
|
||||
// hidden reports only whether at least one id was dropped - never how many -
|
||||
// because the point of surfacing it at all is to let a caller say "some
|
||||
// targets are not visible to you" without the count itself becoming the leak
|
||||
// this exists to close. A workflow that targets both an in-scope and an
|
||||
|
||||
@@ -136,7 +136,7 @@ func TestScopedRunOfOutOfScopeWorkflowReachesNothing(t *testing.T) {
|
||||
t.Errorf("staging-scoped run resolved %v, want nothing", got)
|
||||
}
|
||||
// ResolveTargetsScoped turns that empty set into ErrNoTargets, which is
|
||||
// the same answer a workflow targeting no servers at all gives — so the
|
||||
// the same answer a workflow targeting no servers at all gives - so the
|
||||
// refusal does not tell the caller that production hosts exist.
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ var ErrLastOwner = errors.New("this is the organization's last owner promote ano
|
||||
// An hq-sourced row is projected from a Vantage HQ account: HQ owns its role,
|
||||
// its password and its existence. A role editable in two places is a role with
|
||||
// two answers, and the loser is whichever writer ran first. Refusing here
|
||||
// rather than merely hiding the control in web/ is the point — the API is the
|
||||
// rather than merely hiding the control in web/ is the point - the API is the
|
||||
// boundary, the UI is a courtesy.
|
||||
var ErrHQManaged = errors.New("this member is managed in Vantage HQ; change their role or remove them from the HQ portal")
|
||||
|
||||
@@ -77,7 +77,7 @@ func CreateUser(instanceID, email, password, role, authSource string) (*models.U
|
||||
//
|
||||
// There is deliberately no unscoped lookup by email. users is unique on
|
||||
// (instance_id, email), not on email alone, so an unscoped FindOne would return
|
||||
// an arbitrary one of several matching users — which on the login path means
|
||||
// an arbitrary one of several matching users - which on the login path means
|
||||
// signing someone into a tenant that is not theirs.
|
||||
func GetUserInInstanceByEmail(instanceID, email string) (*models.User, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
|
||||
@@ -131,7 +131,7 @@ func validateVulnRule(instanceID string, r *models.VulnAlertRule) error {
|
||||
return validateChannelIDs(instanceID, r.ChannelIDs)
|
||||
}
|
||||
|
||||
// SendVulnDigest delivers one message per rule per tick — never one per
|
||||
// SendVulnDigest delivers one message per rule per tick - never one per
|
||||
// finding. See vulnsched for why the tick is the batch boundary.
|
||||
func SendVulnDigest(instanceID string, newly []models.VulnFinding) {
|
||||
rules, err := ListVulnRules(instanceID)
|
||||
|
||||
@@ -26,7 +26,7 @@ const stepDispatchGrace = 15 * time.Second
|
||||
// trigger would leave the dispatch reaching further than the readout.
|
||||
//
|
||||
// A run whose configured targets fall entirely outside the caller's scope
|
||||
// resolves to nothing and returns ErrNoTargets — the same answer a workflow
|
||||
// resolves to nothing and returns ErrNoTargets - the same answer a workflow
|
||||
// targeting no servers at all gives, so an out-of-scope host stays
|
||||
// indistinguishable from one that does not exist.
|
||||
func TriggerWorkflow(instanceID, workflowID, actor string, tokenScope map[string]string) (string, error) {
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestSaveTargetsRestrictedCallerCannotReachOutsideScope(t *testing.T) {
|
||||
func TestSaveTargetsUnrestrictedCallerUnaffected(t *testing.T) {
|
||||
// validateWorkflowTargetScope short-circuits before ever resolving the
|
||||
// fleet when tokenScope is nil, so an unrestricted caller keeps today's
|
||||
// behaviour exactly — including saving a tag selector matching nothing.
|
||||
// behaviour exactly - including saving a tag selector matching nothing.
|
||||
// listServersForScope is stubbed to fail the test if called at all, so
|
||||
// this proves the short-circuit, not just that the decision would allow
|
||||
// it.
|
||||
@@ -66,7 +66,7 @@ func TestSaveTargetsEqualOrNarrowerSelectorAllowed(t *testing.T) {
|
||||
}
|
||||
|
||||
// The time-of-write/time-of-fire gap: a restricted caller naming IDs or tags
|
||||
// that match no server at all today must be refused, not passed through —
|
||||
// that match no server at all today must be refused, not passed through -
|
||||
// otherwise the caller could save a selector for an environment that does
|
||||
// not exist yet, arm the schedule, and have it fire the moment a server picks
|
||||
// up the tag. This is distinct from "no targets at all" below.
|
||||
@@ -98,7 +98,7 @@ func TestSaveTargetsMatchingNothingIsRefusedForRestrictedCaller(t *testing.T) {
|
||||
// ...
|
||||
// FAIL .../internal/services 0.006s
|
||||
//
|
||||
// rather than a clean assertion failure, which is still a failure — the test
|
||||
// rather than a clean assertion failure, which is still a failure - the test
|
||||
// no longer passes silently once the enforcement is removed.
|
||||
func TestCreateAndUpdateWorkflowBindToTargetScopeCheck(t *testing.T) {
|
||||
restore := listServersForScope
|
||||
@@ -126,7 +126,7 @@ func TestCreateAndUpdateWorkflowBindToTargetScopeCheck(t *testing.T) {
|
||||
}
|
||||
|
||||
// A workflow with no targets at all (no IDs, no tags) must stay creatable
|
||||
// for a restricted caller — there is nothing to escalate through, and this
|
||||
// for a restricted caller - there is nothing to escalate through, and this
|
||||
// must not become collateral damage from the fix above.
|
||||
func TestSaveTargetsNoTargetsAtAllIsUnaffected(t *testing.T) {
|
||||
restore := listServersForScope
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// ErrDefaultStep is returned when a caller tries to edit or delete a step that
|
||||
// came from the image's default library. Those rows are re-seeded from disk on
|
||||
// every boot, so an edit would be silently reverted and a delete would come
|
||||
// back — refusing is honest about who owns them.
|
||||
// back - refusing is honest about who owns them.
|
||||
var ErrDefaultStep = errors.New("this step ships with Vantage and cannot be edited or deleted; duplicate it to make your own copy")
|
||||
|
||||
func isDefaultStep(ctx context.Context, instanceID, stepID string) (bool, error) {
|
||||
@@ -304,7 +304,7 @@ func UpdateWorkflow(instanceID, id string, w models.Workflow, tokenScope map[str
|
||||
// The first is escalation: without it a token restricted to staging could save
|
||||
// a workflow targeting production and then reach those hosts through the
|
||||
// scheduler, which fires as the system with no restriction of its own. The
|
||||
// second is enumeration — "target server X not found" versus a successful save
|
||||
// second is enumeration - "target server X not found" versus a successful save
|
||||
// is a yes/no oracle over the whole fleet, and the design forbids a restricted
|
||||
// token learning which IDs exist outside its scope.
|
||||
//
|
||||
@@ -347,15 +347,15 @@ var listServersForScope = ListServers
|
||||
// A nil tokenScope is unrestricted and always passes: an unrestricted caller
|
||||
// may save any selector, including one matching nothing today, exactly as
|
||||
// before this fix. A workflow with no targets at all (empty IDs and empty
|
||||
// tags) is also left alone regardless of scope — there is nothing for it to
|
||||
// tags) is also left alone regardless of scope - there is nothing for it to
|
||||
// fire on, and refusing it would break the existing, unrelated ability to
|
||||
// save a workflow before wiring up its targets.
|
||||
//
|
||||
// What IS refused, for a restricted caller only, is a workflow that names IDs
|
||||
// or tags which resolve to no server at all. Without this, a token restricted
|
||||
// to env=staging could save target_tags {env: production} while no server yet
|
||||
// carries that pair — a not-yet-provisioned environment, a tag rollout in
|
||||
// progress, a guessed value — pass validation on an empty set, arm the
|
||||
// carries that pair - a not-yet-provisioned environment, a tag rollout in
|
||||
// progress, a guessed value - pass validation on an empty set, arm the
|
||||
// schedule, and have the scheduler execute on those hosts the moment someone
|
||||
// tags them. That is the same escalation as the out-of-scope case, just
|
||||
// deferred to whenever the fleet catches up to the selector, so it is
|
||||
|
||||
@@ -23,7 +23,7 @@ const workloadResultTimeout = 120 * time.Second
|
||||
|
||||
// MaxWorkloadLogLines mirrors the agent's own cap. It is declared again here
|
||||
// rather than imported: agent/ is a separate module with an internal/ tree, so
|
||||
// the two cannot share a constant. Change one, change the other — the same
|
||||
// the two cannot share a constant. Change one, change the other - the same
|
||||
// shape of hazard as the mirrored token blocks in the web apps.
|
||||
const MaxWorkloadLogLines = 500
|
||||
|
||||
@@ -33,7 +33,7 @@ const MaxWorkloadLogLines = 500
|
||||
const workloadProtectedMarker = "workload is protected"
|
||||
|
||||
// IsWorkloadProtected reports whether an agent refused because the target is
|
||||
// protected — the agent's own guard, which is the boundary. Nothing failed, so
|
||||
// protected - the agent's own guard, which is the boundary. Nothing failed, so
|
||||
// the API answers 409 rather than an error status.
|
||||
func IsWorkloadProtected(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), workloadProtectedMarker)
|
||||
@@ -91,14 +91,14 @@ type WorkloadHit struct {
|
||||
Workload models.Workload `json:"workload"`
|
||||
}
|
||||
|
||||
// SearchWorkloads answers "which servers run image X" — the reason the snapshot
|
||||
// SearchWorkloads answers "which servers run image X" - the reason the snapshot
|
||||
// is stored rather than fetched on demand and discarded.
|
||||
//
|
||||
// tokenScope is the acting credential's tag restriction, nil meaning
|
||||
// unrestricted. A WorkloadHit names a server ID, so an unfiltered fleet-wide
|
||||
// search enumerates hosts a restricted token must not see. server_workloads
|
||||
// carries no tags of its own, so the narrowing is a membership test against
|
||||
// VisibleServerIDs resolved once — the same shape SearchPackages uses.
|
||||
// VisibleServerIDs resolved once - the same shape SearchPackages uses.
|
||||
func SearchWorkloads(instanceID, image, stack, state string, tokenScope map[string]string) ([]WorkloadHit, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ type Advisory struct {
|
||||
// state, not an absence of data, and callers must treat it as vulnerable.
|
||||
FixedVersion string
|
||||
Severity string
|
||||
// Status is filled by trivy-db ONLY when FixedVersion is empty — when there
|
||||
// Status is filled by trivy-db ONLY when FixedVersion is empty - when there
|
||||
// is a fix, "fixed" is the obvious state and the field is left zero. It is
|
||||
// what separates "the vendor confirms this package is affected and has not
|
||||
// fixed it" from "nobody has looked yet".
|
||||
|
||||
@@ -8,7 +8,7 @@ type VulnSource interface {
|
||||
// MetaCache enriches match results with the CVE's own metadata.
|
||||
//
|
||||
// This is not an optimisation, it is where severity comes from. Debian, Ubuntu
|
||||
// and Alpine advisories carry no severity of their own — trivy-db leaves
|
||||
// and Alpine advisories carry no severity of their own - trivy-db leaves
|
||||
// Advisory.Severity zero for those buckets and keeps the rating in the
|
||||
// vulnerability bucket's VendorSeverity map instead. Taking the advisory's
|
||||
// value alone reported an entire fleet as "unknown".
|
||||
@@ -27,7 +27,7 @@ func NewMetaCache(src VulnSource) *MetaCache {
|
||||
// Enrich fills severity, title, score and references in place.
|
||||
//
|
||||
// The advisory's severity is kept when the vulnerability bucket has nothing
|
||||
// better to say — RHEL does publish it per advisory — so this can only raise
|
||||
// better to say - RHEL does publish it per advisory - so this can only raise
|
||||
// the quality of the answer, never lower it.
|
||||
func (m *MetaCache) Enrich(results []Result) {
|
||||
if m == nil {
|
||||
|
||||
@@ -16,7 +16,7 @@ type AdvisorySource interface {
|
||||
// Result is one vulnerable package on one server, before it becomes a finding.
|
||||
//
|
||||
// Severity, Title, CVSSScore and References are only as good as the advisory
|
||||
// until MetaCache.Enrich has run over them — for the Debian-family buckets the
|
||||
// until MetaCache.Enrich has run over them - for the Debian-family buckets the
|
||||
// advisory carries no severity at all, so an unenriched Result reads "unknown".
|
||||
type Result struct {
|
||||
CVEID string
|
||||
@@ -33,7 +33,7 @@ type Result struct {
|
||||
//
|
||||
// Vulnerable means: no fix has been published, or the installed version sorts
|
||||
// strictly before the fixed version under the distribution's own ordering.
|
||||
// Equal is NOT vulnerable — that is the backported-fix case, where a
|
||||
// Equal is NOT vulnerable - that is the backported-fix case, where a
|
||||
// distribution patches in place without changing the upstream version, and
|
||||
// treating it as vulnerable reports a patched fleet as exposed.
|
||||
func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPackage) ([]Result, error) {
|
||||
@@ -47,7 +47,7 @@ func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPacka
|
||||
var advisoryCount, skipped, unactionable, noFix int
|
||||
|
||||
// Advisories are keyed on the SOURCE package, and several hundred binary
|
||||
// packages on a host resolve to the same few hundred sources — linux-modules,
|
||||
// packages on a host resolve to the same few hundred sources - linux-modules,
|
||||
// linux-image and linux-headers all ask about "linux", whose advisory list is
|
||||
// thousands long. Without this the same bolt read is repeated once per binary
|
||||
// package, which is most of what made a single Ubuntu host take minutes.
|
||||
@@ -78,7 +78,7 @@ func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPacka
|
||||
|
||||
for _, a := range advs {
|
||||
// No published fix. Whether that is a finding depends entirely on the
|
||||
// status the vendor attached to it — see actionable().
|
||||
// status the vendor attached to it - see actionable().
|
||||
if a.FixedVersion == "" {
|
||||
if !actionable(a.Status) {
|
||||
unactionable++
|
||||
@@ -97,7 +97,7 @@ func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPacka
|
||||
if err != nil {
|
||||
// Skip this one advisory rather than failing the whole server:
|
||||
// one unparseable version must not blind us to every other CVE
|
||||
// on the host. Log it — a silent skip is a silent false
|
||||
// on the host. Log it - a silent skip is a silent false
|
||||
// negative, which is the direction that hurts.
|
||||
log.Printf("vulndb: compare %s %s vs %s: %v", p.Name, p.Version, a.FixedVersion, err)
|
||||
skipped++
|
||||
@@ -127,8 +127,8 @@ func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPacka
|
||||
// necessary: Ubuntu encodes the ABI in the binary name, so an upgrade INSTALLS
|
||||
// linux-headers-6.8.0-137 beside linux-headers-6.8.0-124 rather than replacing
|
||||
// it, and the old one lingers until an autoremove. Matched per binary package,
|
||||
// a fully patched host reports every superseded ABI package as vulnerable —
|
||||
// which is the noise this exists to stop — and reports it twice over, once for
|
||||
// a fully patched host reports every superseded ABI package as vulnerable -
|
||||
// which is the noise this exists to stop - and reports it twice over, once for
|
||||
// linux-headers-6.8.0-124 and again for its -generic sibling.
|
||||
//
|
||||
// The version, not the name, decides. There is no kernel special case here: a
|
||||
@@ -176,7 +176,7 @@ func newestPerSource(family string, pkgs []models.InstalledPackage) []models.Ins
|
||||
// actionable decides whether an advisory with no fixed version is a finding.
|
||||
//
|
||||
// trivy-db fills Status only when FixedVersion is empty, and Ubuntu publishes a
|
||||
// status for every CVE against every source package it ships — the vast
|
||||
// status for every CVE against every source package it ships - the vast
|
||||
// majority being "under_investigation" (the tracker's needs-triage), meaning
|
||||
// nobody has yet established that the package is affected at all. Reporting
|
||||
// those produced ~24,000 findings for a single 797-package host, which is not a
|
||||
@@ -185,11 +185,11 @@ func newestPerSource(family string, pkgs []models.InstalledPackage) []models.Ins
|
||||
//
|
||||
// What survives is what the vendor has confirmed: affected, will_not_fix,
|
||||
// fix_deferred, end_of_life. Those are exactly the findings the CLAUDE.md rule
|
||||
// is about — an empty fixed_in that means "no fix exists", the one most in need
|
||||
// is about - an empty fixed_in that means "no fix exists", the one most in need
|
||||
// of acceptance rather than patching.
|
||||
// Only the two statuses that positively say "this is not a finding" are
|
||||
// dropped. "unknown" is kept: a feed that sets no status at all must not become
|
||||
// a silent false negative, and it is not what generates the noise — Ubuntu
|
||||
// a silent false negative, and it is not what generates the noise - Ubuntu
|
||||
// states under_investigation explicitly.
|
||||
func actionable(status string) bool {
|
||||
switch status {
|
||||
|
||||
@@ -25,7 +25,7 @@ const DefaultRef = "ghcr.io/aquasecurity/trivy-db:2"
|
||||
// SupportedSchema is the trivy-db schema version this code understands.
|
||||
//
|
||||
// A different version is refused rather than parsed on the assumption it is
|
||||
// close enough. Mis-reading the schema would not fail loudly — it would return
|
||||
// close enough. Mis-reading the schema would not fail loudly - it would return
|
||||
// no advisories, which is indistinguishable from a clean fleet.
|
||||
const SupportedSchema = 2
|
||||
|
||||
@@ -54,7 +54,7 @@ func Disabled() bool {
|
||||
// DebugEnabled turns on per-package and per-advisory tracing.
|
||||
//
|
||||
// It is a switch rather than always-on because a single scan asks the store one
|
||||
// question per installed package — ~2000 lines per server, per tick — which
|
||||
// question per installed package - ~2000 lines per server, per tick - which
|
||||
// would bury every other subsystem's logs on a fleet of any size. The lifecycle
|
||||
// logs (pull, tick, per-server totals) are unconditional; only the inner loop
|
||||
// is gated.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// epochs and sorts "~" before the empty string, rpmvercmp has its own segment
|
||||
// rules and treats "~" and "^" differently again, and any ordering that falls
|
||||
// back on string comparison puts 1.10 before 1.9. Every one of those mistakes
|
||||
// produces a false negative — a vulnerable host reported clean — which is the
|
||||
// produces a false negative - a vulnerable host reported clean - which is the
|
||||
// failure nobody notices.
|
||||
package vulndb
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ func (s *scheduler) tick(ctx context.Context) {
|
||||
}
|
||||
|
||||
// ensureDB pulls a fresh database when the local copy is stale, and marks the
|
||||
// whole fleet for rescanning when the version changes — which is what makes a
|
||||
// whole fleet for rescanning when the version changes - which is what makes a
|
||||
// newly published CVE flag existing servers within a minute rather than at the
|
||||
// next agent report.
|
||||
func (s *scheduler) ensureDB(ctx context.Context) error {
|
||||
@@ -188,7 +188,7 @@ func (s *scheduler) scanPending(ctx context.Context) {
|
||||
for _, sp := range pending {
|
||||
if ctx.Err() != nil {
|
||||
// Leadership lost. scan_pending is still set, so the next leader
|
||||
// picks these up — which is why it lives on the document.
|
||||
// picks these up - which is why it lives on the document.
|
||||
return
|
||||
}
|
||||
opened := s.scanOne(ctx, sp, meta)
|
||||
@@ -215,7 +215,7 @@ func (s *scheduler) scanOne(ctx context.Context, sp models.ServerPackages, meta
|
||||
results, err := vulndb.Match(s.store, sp.OS, sp.Packages)
|
||||
if err != nil {
|
||||
// We hold no feed for this distribution, so we cannot answer whether it
|
||||
// is vulnerable. Say "unsupported" — reporting zero findings here would
|
||||
// is vulnerable. Say "unsupported" - reporting zero findings here would
|
||||
// be indistinguishable from reporting a clean host, and one of those is
|
||||
// a lie.
|
||||
status := models.ScanStatusUnsupported
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package workflowsched fires workflow runs on a cron schedule.
|
||||
//
|
||||
// Only robfig/cron's parser is used — Parse and Next. Its own scheduler is
|
||||
// Only robfig/cron's parser is used - Parse and Next. Its own scheduler is
|
||||
// not, because this work runs under the housekeeping leader lock and has to
|
||||
// stop the moment leadership is lost.
|
||||
package workflowsched
|
||||
@@ -14,12 +14,12 @@ import (
|
||||
)
|
||||
|
||||
// ErrBadSchedule covers both a malformed expression and an unknown timezone.
|
||||
// Handlers map it to 400 — both are the caller's mistake, and both are much
|
||||
// Handlers map it to 400 - both are the caller's mistake, and both are much
|
||||
// cheaper to find at save time than at 2am.
|
||||
var ErrBadSchedule = errors.New("invalid schedule")
|
||||
|
||||
// Standard 5-field cron: minute hour dom month dow. Deliberately no seconds
|
||||
// field and no descriptors — a schedule a person cannot read back is a
|
||||
// field and no descriptors - a schedule a person cannot read back is a
|
||||
// schedule nobody can audit.
|
||||
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ func process(ctx context.Context, deps Deps, wf models.Workflow, now time.Time)
|
||||
|
||||
// The claim. Matching on the current next_run_at as well as the id means a
|
||||
// second process reaching this document after another has claimed it
|
||||
// matches nothing and does nothing. This — not the leader lock — is what
|
||||
// matches nothing and does nothing. This - not the leader lock - is what
|
||||
// makes a double fire impossible; the lock only keeps it cheap.
|
||||
res, err := db.Col("workflows").UpdateOne(ctx,
|
||||
bson.M{"workflow_id": wf.WorkflowID, "next_run_at": dueAt},
|
||||
@@ -107,7 +107,7 @@ func process(ctx context.Context, deps Deps, wf models.Workflow, now time.Time)
|
||||
case Fire:
|
||||
// nil tokenScope: the scheduler acts as the system, not as any user.
|
||||
// A schedule fires the workflow's own saved targets, and there is no
|
||||
// acting credential whose tag restriction could narrow them — the
|
||||
// acting credential whose tag restriction could narrow them - the
|
||||
// person who armed the schedule is not present at fire time, and
|
||||
// inheriting a restriction from whoever last saved the workflow would
|
||||
// make a run's reach depend on an editor's credential.
|
||||
|
||||
Reference in New Issue
Block a user