Compare commits
9
Commits
agent/v1.2.12
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1769fc886 | ||
|
|
6dced22499 | ||
|
|
5cee53dc5f | ||
|
|
81248bb159 | ||
|
|
6354d54de8 | ||
|
|
da6d64f95c | ||
|
|
9ba3d4a61f | ||
|
|
eee236a072 | ||
|
|
9df89e2db4 |
@@ -147,7 +147,7 @@ with `"schedule"` as the actor, so there is no second dispatch path and the run
|
||||
detail page needed no changes.
|
||||
|
||||
`main.go` imports `_ "time/tzdata"`, and it is load-bearing: `server/Dockerfile`
|
||||
builds on Alpine, which ships no zone database, so without it
|
||||
runs on `scratch`, which ships no zone database, so without it
|
||||
`time.LoadLocation("Europe/London")` fails and every schedule silently falls
|
||||
back to UTC — an hour wrong for half the year, in the direction nobody notices
|
||||
until a maintenance window lands in business hours. It works on a developer
|
||||
@@ -350,6 +350,12 @@ is where risk goes to be forgotten. An unsupported distribution reports
|
||||
unknown is the same lie as a silently stale database, which is why
|
||||
`vulndb_meta.pulled_at` is on screen rather than only in a log.
|
||||
|
||||
**`server/Dockerfile`'s runtime stage is `scratch`, so it carries an explicitly
|
||||
copied `/tmp`.** The scheduler unpacks the database to a temporary directory,
|
||||
and a scratch image has none — the failure is `vulnsched: temp dir: stat /tmp:
|
||||
no such file or directory`, logged once at boot while every other subsystem
|
||||
runs normally, so the only symptom is a fleet that never reports a finding.
|
||||
|
||||
Two environment variables: `VANTAGE_TRIVY_DB_REF` mirrors the artifact for
|
||||
air-gapped installs, and `VANTAGE_VULNDB_DISABLED` switches the puller and
|
||||
scheduler off entirely.
|
||||
|
||||
@@ -67,7 +67,8 @@ func (r CatalogueRow) Priced(env string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// SeedCatalogue inserts the sixteen rows the four PAID plans need.
|
||||
// SeedCatalogue inserts the twenty rows the four PAID plans need: a base, a
|
||||
// server limit, and one row per feature key.
|
||||
//
|
||||
// The two Free plans get no rows at all, and that absence is what keeps Free
|
||||
// outside Paddle: with nothing to price, no checkout can be built for it. Do not
|
||||
|
||||
@@ -12,6 +12,7 @@ import { StatePill } from "@/components/StatePill";
|
||||
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { formatDate, licenceState, limitLabel } from "@/lib/format";
|
||||
import { featureLabel } from "@/lib/features";
|
||||
|
||||
export default function InstancePage() {
|
||||
const id = String(useParams().id);
|
||||
@@ -104,7 +105,11 @@ export default function InstancePage() {
|
||||
},
|
||||
{
|
||||
label: "Features",
|
||||
value: lic.features.join(", ") || "none",
|
||||
// Labelled, not raw keys: this is
|
||||
// the customer's own licence, and
|
||||
// "vuln_scanning" is not a name
|
||||
// anyone bought.
|
||||
value: lic.features.map(featureLabel).join(", ") || "none",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -6,25 +6,16 @@ import Link from "next/link";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { ApiError, api, lineItemsFor, type CatalogueRow, type CheckoutOptions, type Deployment, type Plan, type Term, type Tier } from "@/lib/api";
|
||||
import { initPaddle, previewPrices, type PricePreview } from "@/lib/paddle";
|
||||
import { featureDesc, featureLabel } from "@/lib/features";
|
||||
|
||||
/* Tiers in the order a customer reads them, cheapest first. */
|
||||
const TIER_ORDER: Tier[] = ["free", "professional", "enterprise"];
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/* Human labels for feature keys. The catalogue names them by key; this is the
|
||||
* one place the customer-facing wording lives. */
|
||||
const FEATURE_LABEL: Record<string, string> = {
|
||||
console: "Browser console",
|
||||
oidc: "Single sign-on",
|
||||
};
|
||||
const FEATURE_DESC: Record<string, string> = {
|
||||
console: "In-browser SSH, RDP and VNC sessions",
|
||||
oidc: "OIDC sign-in for your whole team",
|
||||
};
|
||||
function featureLabel(key: string) {
|
||||
return FEATURE_LABEL[key] ?? key;
|
||||
}
|
||||
/* Feature wording lives in lib/features.ts, shared with the staff
|
||||
* configurator. It was duplicated here and there, and the two copies had
|
||||
* already drifted. */
|
||||
|
||||
interface Choice {
|
||||
tier: Tier;
|
||||
@@ -294,7 +285,7 @@ export function PurchaseForm() {
|
||||
{featureKeys.map((key) => {
|
||||
const st = featureStateFor(plan, rows, options.env, choice.term, key);
|
||||
return (
|
||||
<Row key={key} title={featureLabel(key)} desc={FEATURE_DESC[key] ?? ""} dim={st === "absent"}>
|
||||
<Row key={key} title={featureLabel(key)} desc={featureDesc(key)} dim={st === "absent"}>
|
||||
{st === "included" ? (
|
||||
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-valid">Included</span>
|
||||
) : st === "absent" ? (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api";
|
||||
import { featureLabel } from "@/lib/features";
|
||||
|
||||
export interface PlanChoice {
|
||||
tier: Tier;
|
||||
@@ -159,7 +160,7 @@ export default function PlanConfigurator({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{key === "console" ? "Browser console" : "Single sign-on"}</span>
|
||||
<span>{featureLabel(key)}</span>
|
||||
<span className="text-[0.72rem] text-ink-3">
|
||||
{priced ? "paid add-on" : "included"}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/* Human wording for licence feature keys.
|
||||
*
|
||||
* One place, because there were two and they disagreed: the staff configurator
|
||||
* rendered every key that was not "console" as "Single sign-on", so adding a
|
||||
* third feature silently mislabelled the checkbox that grants it. A map with a
|
||||
* fallback degrades to the raw key, which is ugly but never wrong.
|
||||
*
|
||||
* Keys must match shared/license/license.go. */
|
||||
export const FEATURE_LABEL: Record<string, string> = {
|
||||
console: "Browser console",
|
||||
oidc: "Single sign-on",
|
||||
vuln_scanning: "Vulnerability scanning",
|
||||
};
|
||||
|
||||
export const FEATURE_DESC: Record<string, string> = {
|
||||
console: "In-browser SSH, RDP and VNC sessions",
|
||||
oidc: "OIDC sign-in for your whole team",
|
||||
vuln_scanning: "Package inventory matched against distribution security advisories",
|
||||
};
|
||||
|
||||
export function featureLabel(key: string): string {
|
||||
return FEATURE_LABEL[key] ?? key;
|
||||
}
|
||||
|
||||
export function featureDesc(key: string): string {
|
||||
return FEATURE_DESC[key] ?? "";
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
package agentsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
|
||||
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
|
||||
@@ -20,6 +23,38 @@ import (
|
||||
// are not paying for.
|
||||
var collectPackagesFlag atomic.Bool
|
||||
|
||||
// firstPoll closes once a SyncKeys response has set the flag above.
|
||||
//
|
||||
// Without it the boot-time package report loses a race it can only lose: the
|
||||
// hourly loop starts before the first poll, reads a flag that is still false by
|
||||
// construction, and skips — so a freshly installed agent reports no packages for
|
||||
// an hour and the server shows nothing to scan.
|
||||
// How long the boot package report waits for that first poll. Two poll
|
||||
// intervals plus slack: long enough to cover one failed attempt, short enough
|
||||
// that a dead control plane does not hold the OS-update report hostage.
|
||||
const firstPollWait = 90 * time.Second
|
||||
|
||||
var (
|
||||
firstPoll = make(chan struct{})
|
||||
firstPollOnce sync.Once
|
||||
)
|
||||
|
||||
func markFirstPoll() { firstPollOnce.Do(func() { close(firstPoll) }) }
|
||||
|
||||
// waitFirstPoll blocks until the flag is known, or gives up. The wait is
|
||||
// bounded because this loop also reports OS updates, which do not depend on the
|
||||
// flag at all — a control plane that cannot be polled must not silence those too.
|
||||
func waitFirstPoll(ctx context.Context, limit time.Duration) {
|
||||
t := time.NewTimer(limit)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-firstPoll:
|
||||
case <-t.C:
|
||||
log.Printf("package collection: no SyncKeys response within %s, collecting nothing this round", limit)
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
func collectPackagesEnabled() bool { return collectPackagesFlag.Load() }
|
||||
|
||||
// reportPackages offers a hash of the installed package set and sends the full
|
||||
|
||||
@@ -101,6 +101,7 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
|
||||
// goroutine. Absent on the wire decodes as false, so an older server leaves
|
||||
// collection off rather than on.
|
||||
collectPackagesFlag.Store(resp.CollectPackages)
|
||||
markFirstPoll()
|
||||
|
||||
desired := resp.PublicKeys
|
||||
|
||||
@@ -409,6 +410,10 @@ func runUpdateCheck(ctx context.Context, cfg *config.Config) {
|
||||
reportPackages(client, cfg)
|
||||
}
|
||||
|
||||
// The boot round only: after this the flag has long been set, and every
|
||||
// later tick is an hour past a poll that runs every 30s.
|
||||
waitFirstPoll(ctx, firstPollWait)
|
||||
|
||||
doCheck()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -18,10 +18,19 @@ ARG VERSION=dev
|
||||
RUN cd server && CGO_ENABLED=0 GOOS=linux go build \
|
||||
-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.
|
||||
RUN mkdir -p /staging/tmp && chmod 1777 /staging/tmp
|
||||
|
||||
# Runtime stage
|
||||
FROM scratch
|
||||
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
|
||||
# vulndb unpacks the ~50MB trivy-db here. Without it the scheduler stops at
|
||||
# "temp dir: stat /tmp: no such file or directory" and no scanning happens,
|
||||
# while everything else in the process runs perfectly well.
|
||||
COPY --from=builder /staging/tmp /tmp
|
||||
COPY --from=builder /vantage-server /vantage-server
|
||||
|
||||
COPY default_steps/ /opt/default-steps/
|
||||
|
||||
+17
-7
@@ -3,27 +3,37 @@ module gitea.hostxtra.co.uk/mrhid6/vantage/server
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f
|
||||
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23
|
||||
github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f
|
||||
github.com/opencontainers/image-spec v1.1.1
|
||||
github.com/redis/go-redis/v9 v9.20.1
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/wwt/guac v1.3.2
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
google.golang.org/grpc v1.64.0
|
||||
oras.land/oras-go/v2 v2.6.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
|
||||
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f // indirect
|
||||
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23 // indirect
|
||||
github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f // indirect
|
||||
github.com/oklog/ulid/v2 v2.1.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
oras.land/oras-go/v2 v2.6.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/samber/lo v1.50.0 // indirect
|
||||
github.com/samber/oops v1.18.1 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
go.etcd.io/bbolt v1.4.3 // indirect
|
||||
go.opentelemetry.io/otel v1.34.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.34.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
+24
-1
@@ -1,3 +1,5 @@
|
||||
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986 h1:2a30xLN2sUZcMXl50hg+PJCIDdJgIvIbVcKqLJ/ZrtM=
|
||||
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986/go.mod h1:NT+jyeCzXk6vXR5MTkdn4z64TgGfE5HMLC8qfj5unl8=
|
||||
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54 h1:4CZNoDkNfcuACevZeDraACGmP1+L0nKkRY52+jV8k1M=
|
||||
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54/go.mod h1:iIEV2oGuZScvfyX2SMIn78iVMNnepgo0QuJJh/srgVI=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
@@ -37,6 +39,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
|
||||
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -63,6 +67,8 @@ github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f/go.mod h1:
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -72,10 +78,15 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -84,12 +95,17 @@ github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsT
|
||||
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY=
|
||||
github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc=
|
||||
github.com/samber/oops v1.18.1 h1:qjhZbqbdyhWBKntkY8sxrDNKA8b4c5VHlmI1rli7X7M=
|
||||
github.com/samber/oops v1.18.1/go.mod h1:xYqvimigkKV70HyLXiBZJFpIWi2CGcc6Xx7eV+2HycI=
|
||||
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
@@ -119,8 +135,14 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
|
||||
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
|
||||
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
|
||||
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
@@ -169,8 +191,9 @@ google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
|
||||
google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
+14
-20
@@ -21,6 +21,7 @@ const COMPARISON: [string, string, string, string][] = [
|
||||
["Audit history", "30 days", "365 days", "Forever"],
|
||||
["Browser console", "Not Available", "Add-on", "Add-on"],
|
||||
["Single sign-on", "Not Available", "Add-on", "Add-on"],
|
||||
["Vulnerability scanning", "Not Available", "Add-on", "Add-on"],
|
||||
["Support", "Community", "Email, 24/5", "Email and phone, 24/7"],
|
||||
["Cloud term", "Annual, £0", "Monthly or annual", "Monthly or annual"],
|
||||
["Self-hosted term", "Annual, £0", "Annual", "Annual"],
|
||||
@@ -39,10 +40,7 @@ export default function PricingPage() {
|
||||
>
|
||||
Pick a tier, then pay per server.
|
||||
</h1>
|
||||
<p className="lede">
|
||||
You pay for the number of servers you manage, and nothing else. Adding people costs nothing, and neither does adding keys, scripts, uptime checks or stored passwords. Every tier costs
|
||||
the same whether we host it or you do.
|
||||
</p>
|
||||
<p className="lede">You pay for the number of servers you manage, and nothing else. Adding people costs nothing, and neither does adding keys, scripts, uptime checks or stored passwords. Every tier costs the same whether we host it or you do.</p>
|
||||
|
||||
<div className="plans">
|
||||
<div className="plan">
|
||||
@@ -79,7 +77,7 @@ export default function PricingPage() {
|
||||
<li>3 servers included, add as many as you like</li>
|
||||
<li>Unlimited monitors, secrets and channels</li>
|
||||
<li>365 days of audit history</li>
|
||||
<li>Browser console and single sign-on as add-ons</li>
|
||||
<li>Browser console, single sign-on and vulnerability scanning as add-ons</li>
|
||||
<li>Email support, 24/5</li>
|
||||
</ul>
|
||||
<Link className="btn btn--solid" href="/start">
|
||||
@@ -125,6 +123,13 @@ export default function PricingPage() {
|
||||
<p>Connect your own OIDC provider to an instance. £90 a year.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">£7 / MO</span>
|
||||
<div>
|
||||
<h3>Vulnerability Scanning</h3>
|
||||
<p>Agents will scan and report known Vulnerabilites. £70 a year.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="scroll__hint">Scroll the table sideways for all three tiers</p>
|
||||
@@ -179,39 +184,28 @@ export default function PricingPage() {
|
||||
<span className="spec__k">LIMITS</span>
|
||||
<div>
|
||||
<h3>Reaching your limit</h3>
|
||||
<p>
|
||||
Nothing gets deleted. A server past the limit still checks in and still shows up in your list, but access changes stop reaching it until you raise the limit or remove
|
||||
one.
|
||||
</p>
|
||||
<p>Nothing gets deleted. A server past the limit still checks in and still shows up in your list, but access changes stop reaching it until you raise the limit or remove one.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">CANCELLING</span>
|
||||
<div>
|
||||
<h3>What happens when you stop paying</h3>
|
||||
<p>
|
||||
You keep everything until the end of the period you paid for. After that it becomes read-only: your uptime checks carry on running, your alerts still arrive and your
|
||||
servers keep the access they have. You just can't change anything until you renew.
|
||||
</p>
|
||||
<p>You keep everything until the end of the period you paid for. After that it becomes read-only: your uptime checks carry on running, your alerts still arrive and your servers keep the access they have. You just can't change anything until you renew.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">SELF-HOSTED</span>
|
||||
<div>
|
||||
<h3>Why self-hosted is yearly only</h3>
|
||||
<p>
|
||||
When you host it yourself, your licence is a file your installation checks on its own. It never contacts us, which is the point but it also means we can't switch
|
||||
one off partway through, so we sell it a year at a time.
|
||||
</p>
|
||||
<p>When you host it yourself, your licence is a file your installation checks on its own. It never contacts us, which is the point but it also means we can't switch one off partway through, so we sell it a year at a time.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">EXIT</span>
|
||||
<div>
|
||||
<h3>Leaving</h3>
|
||||
<p>
|
||||
You can export your servers, keys, scripts and stored passwords at any time. Your servers keep the access they already have, so nobody gets locked out while you move.
|
||||
</p>
|
||||
<p>You can export your servers, keys, scripts and stored passwords at any time. Your servers keep the access they already have, so nobody gets locked out while you move.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,14 +104,7 @@ function Allowance({ label, used, limit }: { label: string; used: number; limit:
|
||||
{unlimited ? (
|
||||
<p className="mt-3 font-mono text-[0.62rem] uppercase tracking-[0.14em] text-text-secondary">No limit</p>
|
||||
) : (
|
||||
<div
|
||||
className="mt-3 h-1.5 overflow-hidden rounded-full bg-surface-2"
|
||||
role="meter"
|
||||
aria-valuenow={used}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={limit}
|
||||
aria-label={`${label}: ${used} of ${limit} used`}
|
||||
>
|
||||
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-surface-2" role="meter" aria-valuenow={used} aria-valuemin={0} aria-valuemax={limit} aria-label={`${label}: ${used} of ${limit} used`}>
|
||||
<div className={`h-full rounded-full ${fill}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
@@ -122,12 +115,7 @@ function Allowance({ label, used, limit }: { label: string; used: number; limit:
|
||||
function Feature({ label, included }: { label: string; included: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm border text-[0.6rem] font-bold ${
|
||||
included ? "border-success text-success" : "border-border text-text-tertiary"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm border text-[0.6rem] font-bold ${included ? "border-success text-success" : "border-border text-text-tertiary"}`}>
|
||||
{included ? "✓" : "–"}
|
||||
</span>
|
||||
<span className="text-sm text-text-primary">{label}</span>
|
||||
@@ -183,11 +171,7 @@ function RecordPanel({ license }: { license: LicenseInfo }) {
|
||||
<Keyed label="Instance ID">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="truncate font-mono text-xs text-text-primary">{license.instance_id}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyId}
|
||||
className="flex-shrink-0 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[0.6rem] uppercase tracking-[0.1em] text-text-secondary transition-colors hover:border-text-tertiary hover:text-text-primary"
|
||||
>
|
||||
<button type="button" onClick={copyId} className="flex-shrink-0 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[0.6rem] uppercase tracking-[0.1em] text-text-secondary transition-colors hover:border-text-tertiary hover:text-text-primary">
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -265,6 +249,7 @@ export default function LicensePage() {
|
||||
<div className="divide-y divide-border-soft">
|
||||
<Feature label="Browser console" included={Boolean(license.features.console)} />
|
||||
<Feature label="Single sign-on" included={Boolean(license.features.oidc)} />
|
||||
<Feature label="Vulnerability Scanning" included={Boolean(license.features.vuln_scanning)} />
|
||||
</div>
|
||||
</Card>
|
||||
</Group>
|
||||
@@ -272,9 +257,7 @@ export default function LicensePage() {
|
||||
{isCloud ? (
|
||||
<Group label="Where this licence comes from">
|
||||
<Card>
|
||||
<p className="max-w-prose text-sm text-text-secondary">
|
||||
This is a cloud instance, so its licence is issued and renewed in Vantage HQ and applied here automatically. There is nothing to paste.
|
||||
</p>
|
||||
<p className="max-w-prose text-sm text-text-secondary">This is a cloud instance, so its licence is issued and renewed in Vantage HQ and applied here automatically. There is nothing to paste.</p>
|
||||
{hqUrl && (
|
||||
<div className="mt-5 border-t border-border-soft pt-5">
|
||||
<a href={hqUrl} target="_blank" rel="noreferrer">
|
||||
|
||||
@@ -4,19 +4,24 @@ import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, vulnerabilities, type FindingState, type Severity, type VulnFinding } from "@/lib/api";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { Button } from "@/components/ui";
|
||||
import { Button, Card } from "@/components/ui";
|
||||
import { AcceptDialog } from "@/components/vulnerabilities/AcceptDialog";
|
||||
import { DBFreshness } from "@/components/vulnerabilities/DBFreshness";
|
||||
import { FindingRow } from "@/components/vulnerabilities/FindingRow";
|
||||
import { PackageRow } from "@/components/vulnerabilities/PackageRow";
|
||||
import { SEVERITY_ORDER, SeverityBadge } from "@/components/vulnerabilities/SeverityVisuals";
|
||||
import { groupByPackage } from "@/lib/vulnPackages";
|
||||
|
||||
/*
|
||||
* The fleet vulnerability board.
|
||||
*
|
||||
* Grouped by CVE, defaulting to open findings, with database freshness always
|
||||
* on screen. The three things this page must never do: imply freshness it does
|
||||
* not have, present an unsupported distribution as clean, or make one CVE on
|
||||
* forty servers look like forty problems.
|
||||
* Grouped by package, defaulting to open findings, with database freshness
|
||||
* always on screen. The three things this page must never do: imply freshness
|
||||
* it does not have, present an unsupported distribution as clean, or make one
|
||||
* upgrade look like several problems.
|
||||
*
|
||||
* The API groups by CVE; the rollup to packages happens here, in
|
||||
* `lib/vulnPackages`, because a finding is still per-CVE everywhere it is
|
||||
* stored, accepted or remediated.
|
||||
*/
|
||||
|
||||
const STATES: FindingState[] = ["open", "accepted", "fixed"];
|
||||
@@ -41,6 +46,8 @@ export default function VulnerabilitiesPage() {
|
||||
|
||||
const servers = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
|
||||
|
||||
const packages = useMemo(() => groupByPackage(groups.data ?? []), [groups.data]);
|
||||
|
||||
const serverName = useMemo(() => {
|
||||
const byId = new Map((servers.data ?? []).map((s) => [s.server_id, s.hostname]));
|
||||
// Falls back to the raw id rather than an empty cell: an unnamed row is
|
||||
@@ -78,12 +85,12 @@ export default function VulnerabilitiesPage() {
|
||||
const total = SEVERITY_ORDER.reduce((n, s) => n + (counts[s] ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-text-primary">Vulnerabilities</h1>
|
||||
<h1 className="text-2xl font-bold text-text-primary">Vulnerabilities</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
Installed packages matched against distribution security advisories.
|
||||
{total} open finding{total !== 1 ? "s" : ""} · installed packages matched against distribution security advisories
|
||||
</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
@@ -93,32 +100,36 @@ export default function VulnerabilitiesPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DBFreshness summary={summary.data} />
|
||||
<div className="mb-4">
|
||||
<DBFreshness summary={summary.data} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 rounded-lg border border-border bg-surface px-5 py-4">
|
||||
{/* Severity counts double as the filter. They are always the whole
|
||||
fleet's open counts, never the filtered view's, so switching
|
||||
state cannot make the fleet look better than it is. */}
|
||||
<div className="mb-4 flex flex-wrap gap-2 rounded-lg border border-border bg-surface px-4 py-3 sm:px-5">
|
||||
{SEVERITY_ORDER.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSeverity(severity === s ? "" : s)}
|
||||
className={`flex items-center gap-2 rounded px-2 py-1 text-left transition-colors ${
|
||||
severity === s ? "bg-surface-2" : "hover:bg-surface-2"
|
||||
aria-pressed={severity === s}
|
||||
className={`flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-left transition-colors ${
|
||||
severity === s ? "border-accent bg-surface-2" : "border-transparent hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<SeverityBadge severity={s} />
|
||||
<span className="font-mono text-lg font-semibold tabular-nums text-text-primary">{counts[s] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-auto self-center font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">
|
||||
{total} open
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="mb-4 flex gap-2">
|
||||
{STATES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setState(s)}
|
||||
className={`rounded border px-3 py-1.5 text-sm capitalize transition-colors ${
|
||||
aria-pressed={state === s}
|
||||
className={`rounded-lg border px-3 py-1.5 text-sm capitalize transition-colors ${
|
||||
state === s ? "border-accent text-accent" : "border-border text-text-secondary hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
@@ -127,33 +138,42 @@ export default function VulnerabilitiesPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{groups.isLoading && <p className="text-sm text-text-secondary">Loading…</p>}
|
||||
{groups.error && <p className="text-sm text-danger">{(groups.error as Error).message}</p>}
|
||||
|
||||
{groups.data && groups.data.length === 0 && (
|
||||
<div className="rounded-lg border border-border bg-surface px-5 py-8 text-center">
|
||||
<p className="text-sm text-text-secondary">No {state} findings.</p>
|
||||
<p className="mt-1 text-xs text-text-tertiary">
|
||||
Servers report packages hourly. A server whose distribution has no advisory feed is reported as unsupported on its own
|
||||
page rather than counted here.
|
||||
</p>
|
||||
{groups.error && (
|
||||
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
|
||||
{(groups.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{groups.data?.map((g) => (
|
||||
<FindingRow
|
||||
key={g.cve_id}
|
||||
group={g}
|
||||
serverName={serverName}
|
||||
canAct={isAdmin}
|
||||
onAccept={setAccepting}
|
||||
onUnaccept={(f) => unaccept.mutate(f.id)}
|
||||
onApplyUpdates={(serverId) => applyUpdates.mutate(serverId)}
|
||||
applying={applyUpdates.isPending ? (applyUpdates.variables as string) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Card padding={false}>
|
||||
{groups.isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
) : packages.length > 0 ? (
|
||||
packages.map((g) => (
|
||||
<PackageRow
|
||||
key={g.package_name}
|
||||
group={g}
|
||||
serverName={serverName}
|
||||
canAct={isAdmin}
|
||||
onAccept={setAccepting}
|
||||
onUnaccept={(f) => unaccept.mutate(f.id)}
|
||||
onApplyUpdates={(serverId) => applyUpdates.mutate(serverId)}
|
||||
applying={applyUpdates.isPending ? (applyUpdates.variables as string) : undefined}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="px-6 py-14 text-center">
|
||||
<p className="text-[15px] font-semibold text-text-primary">
|
||||
No {state} findings{severity ? ` at ${severity} severity` : ""}.
|
||||
</p>
|
||||
<p className="mx-auto mt-2 max-w-[52ch] text-sm text-text-secondary">
|
||||
Servers report their packages hourly. A server whose distribution has no advisory feed is reported as unsupported
|
||||
on its own page rather than counted as clean here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{accepting && (
|
||||
<AcceptDialog
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui";
|
||||
import type { VulnFinding, VulnGroup } from "@/lib/api";
|
||||
import { SeverityBadge, StateBadge, relativeTime } from "./SeverityVisuals";
|
||||
|
||||
/*
|
||||
* One row per CVE, expandable to the servers it affects.
|
||||
*
|
||||
* The grouping is the point. The same CVE across forty servers is one decision
|
||||
* — patch it, or accept it and say why — and a flat list of forty findings
|
||||
* makes it look like forty decisions, which is how a board stops being read.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
group: VulnGroup;
|
||||
serverName: (serverId: string) => string;
|
||||
canAct: boolean;
|
||||
onAccept: (f: VulnFinding) => void;
|
||||
onUnaccept: (f: VulnFinding) => void;
|
||||
onApplyUpdates: (serverId: string) => void;
|
||||
applying?: string;
|
||||
}
|
||||
|
||||
export function FindingRow({ group, serverName, canAct, onAccept, onUnaccept, onApplyUpdates, applying }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// A CVE with no fix anywhere cannot be patched, only accepted. Saying so on
|
||||
// the collapsed row saves opening it to find there is nothing to do.
|
||||
const anyFix = group.findings.some((f) => f.fixed_in);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-surface-2"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="font-mono text-xs text-text-tertiary">{open ? "▾" : "▸"}</span>
|
||||
<SeverityBadge severity={group.severity} />
|
||||
<span className="font-mono text-sm font-medium text-text-primary">{group.cve_id}</span>
|
||||
{group.title && <span className="hidden truncate text-sm text-text-secondary sm:block">{group.title}</span>}
|
||||
<span className="ml-auto whitespace-nowrap text-xs text-text-secondary">
|
||||
{group.server_count} {group.server_count === 1 ? "server" : "servers"}
|
||||
</span>
|
||||
{!anyFix && <span className="whitespace-nowrap text-xs text-text-tertiary">no fix published</span>}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-border">
|
||||
{group.findings.map((f) => (
|
||||
<div key={f.id} className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border-soft px-4 py-3 last:border-b-0">
|
||||
<Link href={`/servers/${f.server_id}`} className="text-sm text-accent hover:underline">
|
||||
{serverName(f.server_id)}
|
||||
</Link>
|
||||
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
{f.package_name} {f.installed_version}
|
||||
</span>
|
||||
|
||||
<span className="font-mono text-xs text-text-tertiary">
|
||||
{f.fixed_in ? `→ ${f.fixed_in}` : "no fix published"}
|
||||
</span>
|
||||
|
||||
<StateBadge state={f.state} />
|
||||
|
||||
{f.state === "accepted" && f.accepted && (
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{f.accepted.reason} · reopens {new Date(f.accepted.until).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
{f.state !== "accepted" && <span className="text-xs text-text-tertiary">first seen {relativeTime(f.first_seen)}</span>}
|
||||
|
||||
{canAct && (
|
||||
<div className="ml-auto flex gap-2">
|
||||
{/* Remediation is the existing endpoint, not a new
|
||||
mechanism: see it, patch it, one place. */}
|
||||
{f.fixed_in && f.state !== "fixed" && (
|
||||
<Button size="sm" variant="secondary" loading={applying === f.server_id} onClick={() => onApplyUpdates(f.server_id)}>
|
||||
Apply updates
|
||||
</Button>
|
||||
)}
|
||||
{f.state === "accepted" ? (
|
||||
<Button size="sm" variant="ghost" onClick={() => onUnaccept(f)}>
|
||||
Reopen
|
||||
</Button>
|
||||
) : (
|
||||
f.state === "open" && (
|
||||
<Button size="sm" variant="ghost" onClick={() => onAccept(f)}>
|
||||
Accept
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui";
|
||||
import type { VulnFinding } from "@/lib/api";
|
||||
import type { PackageGroup, PackageServer } from "@/lib/vulnPackages";
|
||||
import { SeverityBadge, StateBadge, relativeTime } from "./SeverityVisuals";
|
||||
|
||||
/*
|
||||
* One row per package, expandable to the servers carrying it and the CVEs on
|
||||
* each.
|
||||
*
|
||||
* The grouping is the point, and it is the same argument as the CVE grouping it
|
||||
* replaced, one level in: an operator upgrades a package, not a CVE. Two CVEs
|
||||
* on one apache2 are one upgrade to the higher of the two fix versions, and
|
||||
* showing them as two rows with two different targets is how a fleet gets
|
||||
* patched to the lower one.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
group: PackageGroup;
|
||||
serverName: (serverId: string) => string;
|
||||
canAct: boolean;
|
||||
onAccept: (f: VulnFinding) => void;
|
||||
onUnaccept: (f: VulnFinding) => void;
|
||||
onApplyUpdates: (serverId: string) => void;
|
||||
applying?: string;
|
||||
}
|
||||
|
||||
export function PackageRow({ group, serverName, canAct, onAccept, onUnaccept, onApplyUpdates, applying }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// A package with no fix anywhere cannot be patched, only accepted. Saying so
|
||||
// on the collapsed row saves opening it to find there is nothing to do.
|
||||
const anyFix = group.servers.some((s) => s.target);
|
||||
|
||||
return (
|
||||
// A row inside the page's one bordered container, not a card of its
|
||||
// own — the same stack idiom as the monitors and workflows lists.
|
||||
<div className="border-t border-border-soft first:border-t-0">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent sm:px-5"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="font-mono text-xs text-text-tertiary">{open ? "▾" : "▸"}</span>
|
||||
<SeverityBadge severity={group.severity} />
|
||||
<span className="font-mono text-sm font-medium text-text-primary">{group.package_name}</span>
|
||||
<span className="whitespace-nowrap font-mono text-[11px] text-text-tertiary">
|
||||
{group.cve_count} {group.cve_count === 1 ? "CVE" : "CVEs"}
|
||||
</span>
|
||||
<span className="ml-auto whitespace-nowrap font-mono text-[11px] text-text-tertiary">
|
||||
{group.server_count} {group.server_count === 1 ? "server" : "servers"}
|
||||
</span>
|
||||
{!anyFix && (
|
||||
<span className="hidden whitespace-nowrap rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-tertiary sm:block">
|
||||
no fix
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-border-soft bg-surface-2/40">
|
||||
{group.servers.map((s) => (
|
||||
<ServerBlock
|
||||
key={`${s.package_name}:${s.server_id}`}
|
||||
row={s}
|
||||
serverName={serverName}
|
||||
canAct={canAct}
|
||||
onAccept={onAccept}
|
||||
onUnaccept={onUnaccept}
|
||||
onApplyUpdates={onApplyUpdates}
|
||||
applying={applying}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerBlock({
|
||||
row,
|
||||
serverName,
|
||||
canAct,
|
||||
onAccept,
|
||||
onUnaccept,
|
||||
onApplyUpdates,
|
||||
applying,
|
||||
}: Omit<Props, "group"> & { row: PackageServer }) {
|
||||
const patchable = row.target && row.findings.some((f) => f.state !== "fixed");
|
||||
|
||||
return (
|
||||
<div className="border-b border-border-soft px-4 py-3 last:border-b-0 sm:px-5">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<Link href={`/servers/${row.server_id}`} className="text-sm text-accent hover:underline">
|
||||
{serverName(row.server_id)}
|
||||
</Link>
|
||||
|
||||
<span className="font-mono text-xs text-text-secondary">{row.installed_version}</span>
|
||||
|
||||
{/* One target, the highest across every CVE on this package.
|
||||
A lower fix version does not remediate a higher one, so it
|
||||
is never the number offered. */}
|
||||
<span className="font-mono text-xs text-text-tertiary">{row.target ? `→ ${row.target}` : "no fix published"}</span>
|
||||
|
||||
{row.superseded && (
|
||||
<span
|
||||
className="whitespace-nowrap rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-tertiary"
|
||||
title="Several CVEs name different fix versions; the highest is shown and covers the rest."
|
||||
>
|
||||
supersedes lower fixes
|
||||
</span>
|
||||
)}
|
||||
|
||||
{canAct && patchable && (
|
||||
<div className="ml-auto">
|
||||
{/* Remediation is the existing endpoint, not a new
|
||||
mechanism: see it, patch it, one place. */}
|
||||
<Button size="sm" variant="secondary" loading={applying === row.server_id} onClick={() => onApplyUpdates(row.server_id)}>
|
||||
Apply updates
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{row.findings.map((f) => (
|
||||
<div key={f.id} className="flex flex-wrap items-center gap-x-3 gap-y-1.5 pl-1">
|
||||
<SeverityBadge severity={f.severity} />
|
||||
<span className="font-mono text-xs text-text-secondary">{f.cve_id}</span>
|
||||
{f.title && <span className="hidden truncate text-xs text-text-tertiary sm:block">{f.title}</span>}
|
||||
|
||||
{/* The per-CVE fix stays visible when it differs from
|
||||
the row's target, so the rollup can be checked
|
||||
rather than taken on trust. */}
|
||||
{f.fixed_in && f.fixed_in !== row.target && (
|
||||
<span className="font-mono text-[11px] text-text-tertiary">fixed in {f.fixed_in}</span>
|
||||
)}
|
||||
{!f.fixed_in && <span className="font-mono text-[11px] text-text-tertiary">no fix</span>}
|
||||
|
||||
<StateBadge state={f.state} />
|
||||
|
||||
{f.state === "accepted" && f.accepted && (
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{f.accepted.reason} · reopens {new Date(f.accepted.until).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
{f.state !== "accepted" && <span className="text-xs text-text-tertiary">first seen {relativeTime(f.first_seen)}</span>}
|
||||
|
||||
{canAct && (
|
||||
<div className="ml-auto">
|
||||
{f.state === "accepted" ? (
|
||||
<Button size="sm" variant="ghost" onClick={() => onUnaccept(f)}>
|
||||
Reopen
|
||||
</Button>
|
||||
) : (
|
||||
f.state === "open" && (
|
||||
<Button size="sm" variant="ghost" onClick={() => onAccept(f)}>
|
||||
Accept
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export function ServerVulnerabilities({ serverId }: { serverId: string }) {
|
||||
<h2 className="text-lg font-semibold text-text-primary">Vulnerabilities</h2>
|
||||
<Link href="/vulnerabilities">
|
||||
<Button variant="ghost" size="sm">
|
||||
Fleet board →
|
||||
View All
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -52,18 +52,13 @@ export function ServerVulnerabilities({ serverId }: { serverId: string }) {
|
||||
{packages.isLoading || findings.isLoading ? (
|
||||
<p className="text-sm text-text-secondary">Loading…</p>
|
||||
) : !pkg ? (
|
||||
<p className="text-sm text-text-secondary">
|
||||
No package inventory yet. Agents report hourly, and only when vulnerability scanning is included in this instance's
|
||||
licence.
|
||||
</p>
|
||||
<p className="text-sm text-text-secondary">No package inventory yet. Agents report hourly, and only when vulnerability scanning is included in this instance's licence.</p>
|
||||
) : pkg.status === "unsupported" ? (
|
||||
<>
|
||||
<p className="text-sm text-warning">
|
||||
{pkg.os.family} {pkg.os.version_id} has no advisory feed, so this server cannot be scanned.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-text-tertiary">
|
||||
This is not the same as having no vulnerabilities — it means we cannot answer the question for this distribution.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-text-tertiary">This is not the same as having no vulnerabilities — it means we cannot answer the question for this distribution.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -94,15 +89,11 @@ export function ServerVulnerabilities({ serverId }: { serverId: string }) {
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
{f.package_name} {f.installed_version}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-text-tertiary">
|
||||
{f.fixed_in ? `→ ${f.fixed_in}` : "no fix published"}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-text-tertiary">{f.fixed_in ? `→ ${f.fixed_in}` : "no fix published"}</span>
|
||||
<StateBadge state={f.state} />
|
||||
</li>
|
||||
))}
|
||||
{open.length > 10 && (
|
||||
<li className="pt-1 text-xs text-text-tertiary">…and {open.length - 10} more on the fleet board.</li>
|
||||
)}
|
||||
{open.length > 10 && <li className="pt-1 text-xs text-text-tertiary">…and {open.length - 10} more on the fleet board.</li>}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user