Suppressions and baselines¶
This page covers what a team can configure about which findings count:
ignore rules in .upgradescope.yaml, the upgradescope.basit.engineer/ignore
object annotations, baselines for CI, the server's gate, and the agent's
ClusterReadiness spec.ignore. The file format, field by field, is in the
configuration file reference; the flags in
upgradescope scan. The GitHub
Action passes them as its config, baseline and write-baseline inputs
(action/README.md).
Contents:
- Ignore rules (
.upgradescope.yaml) - Object annotations
- Baselines
- How the gate decides
- The server gate
- The agent:
spec.ignore - Finding keys
- Code scanning and accepted findings
- Who can turn the gate off
Ignore rules (.upgradescope.yaml)¶
An ignore rule accepts a finding that the team knows about and has decided to live with for now, such as an EOL ingress-nginx that is being migrated, or a removed API in manifests that are never deployed. A suppressed finding:
- does not count toward the score, the verdict or the
--fail-ongate; - is still reported, with its reason, in the table (a
SUPPRESSEDsection and anN suppressedline), in JSON (asuppressedarray), in the Markdown step summary and in JUnit (skipped test cases). It is not a SARIF result: see Code scanning and accepted findings.
# .upgradescope.yaml
ignore:
# A whole finding, by key, until a date.
- key: eol-addon/ingress-nginx
reason: migrating to Gateway API, tracked in PLAT-123
expires: 2099-12-31 # a date inside your migration window
# Every removed-API finding, but only for objects under legacy/.
- category: removed-api
file: legacy/**
reason: legacy/ is kept for reference and never deployed
# One object.
- key: removed-api/networking.k8s.io/v1beta1/Ingress
namespace: shop
name: admin
reason: admin ingress is deleted in the next release
Every field, the categories a rule can name, and what makes a file invalid are in the configuration file reference.
Which findings a rule takes. A rule without namespace, name or
file takes the whole finding, including objects beyond the 100 that a
finding lists. With selectors it takes only the listed objects that match
all of them. If every object is taken, and none were left unlisted, the
finding leaves the scored list. Otherwise it stays, with the remaining
objects and a note saying how many were suppressed. A finding that lists no
objects (add-ons, version skew) matches a namespace selector only when
every namespace it names matches, and never matches name or file.
Such a finding names at most 100 namespaces and counts the rest, which
cannot be shown to match, so past 100 a namespace rule no longer takes
it, even with a glob such as * that would match them all; use a rule
without selectors (by key) for it.
An add-on install in no named namespace cannot be shown to match either: a
manifest object without metadata.namespace (helm template output usually
has none, and the install can land in any namespace, production included),
or a cluster-scoped IngressClass. A finding that covers one is marked
unnamespaced in the JSON report, and a namespace rule, a glob such as
* included, never takes it: an ingress-nginx end-of-life blocker for an
install in sandbox and another with no namespace stays a blocker under a
rule for sandbox. A finding whose installs are all in named namespaces
the rule matches is suppressed as before.
Rules apply in order, and the first one that matches an object takes it.
Deprecated-API callers. When the apiserver's
apiserver_requested_deprecated_apis metric shows requests to the API of
an object finding, the request is folded into that finding (its callers
in JSON). It is evidence about some client, not about the listed objects,
so it fails closed: when annotations or rules with namespace, name or
file take every object of the finding, each caller comes back as its own
deprecated-api-in-use finding, at its own severity, and still counts. The
client still calling a removed API may not be the one that wrote the
objects. Only a rule without selectors that takes the object finding, or a
rule without selectors for the caller's own deprecated-api-in-use key or
category, also suppresses the caller: a caller lists no objects or
namespaces, so a rule for its key with namespace, name or file matches
nothing.
Expiry. On the day after expires, the rule stops applying. The finding
counts again, and the scan prints a warning naming the rule, so an
acceptance cannot quietly outlive its reason.
Errors and discovery. An invalid file makes the scan exit 1 before it
scans anything, so a typo fails loudly instead of suppressing nothing.
--config <path> names the file; otherwise .upgradescope.yaml is looked
up in the scan root, then at the repository root
(details).
Object annotations¶
An object can opt out of findings itself:
metadata:
annotations:
upgradescope.basit.engineer/ignore: removed-api, deprecated-api # categories or keys, comma-separated
upgradescope.basit.engineer/ignore-reason: replaced by the HTTPRoute in routes.yaml
The annotation works in --files mode (rendered manifests) and on live
objects, which the collector reads from object metadata. It applies to
findings that list objects, which today are the API-usage findings. The
reason is required: an ignore annotation without ignore-reason is not
applied, and the scan prints a warning. Annotations have no expiry.
Annotations only work on the objects that a finding lists, at most 100 per
API. On a live cluster the collector keeps at most 16 KiB of each
annotation (of ignore, the categories and keys that fit whole), as the
server takes it.
Where annotations apply¶
Every view that judges objects applies the annotations, with the same code:
scan, live or--files, and the MCP server's scan tools;- the agent, in each
status.targets[]entry of itsClusterReadiness(The agent); /gate, to the posted manifests and, with?cluster=, to the cluster's stored objects (The server gate);- the server's stored evaluations, into which the agent's pushes carry
each object's annotations: the dashboard (its fleet cells and the cluster
page's "N suppressed"), the fleet and team rollups, history,
notifications, the auditor exports and
GET /api/v1/clusters/{id}/report, read-time what-ifs included.
On the server, an accepted finding counts toward neither the score, the
verdict, blockers nor a notification. Annotating a blocked cluster's last
blocker sends became-ready; a new object that arrives already annotated
sends no new-blocker; removing the annotation makes the finding a
new-blocker again. GET /report lists the accepted finding in
suppressed, with source: annotation and the reason, and /findings
lists only the open ones. The CSV export has one suppressed row per
accepted finding (its severity, what accepted it, the reason and the
objects, in the detail) and counts them in the summary row; the HTML export
lists them in a suppressed section. An audit never loses an accepted
finding.
The server applies the annotations only, never the agent's spec.ignore,
which the push does not carry (see The agent).
An annotation is as trusted as write access to its object: whoever can edit
the object can accept its finding in every view, the fleet's included. The
exports and GET /report name each acceptance with its reason.
An evaluation stored by an earlier server version picks the annotations up when it is next re-evaluated: on the next push that changes the inventory, when the knowledge base changes, or in the first background pass after UTC midnight.
Baselines¶
A baseline lets CI adopt the gate on an existing estate: the gate fails only on problems that are new since the baseline, and everything already known stays visible.
# Once, on the main branch: record what exists today.
upgradescope scan --files rendered/ --target 1.36 --fail-on never --write-baseline upgradescope-baseline.json
# On every PR: fail only on what the change adds.
upgradescope scan --files rendered/ --target 1.36 --baseline upgradescope-baseline.json
--write-baseline <path> writes the scan's JSON report, the same document
that --output json produces, after suppression. --baseline <path> reads
such a report. Any --output json report also works as a baseline. Each
finding is then marked:
unchangedif the baseline has a finding with the same key, every object the current finding lists appears in it (matched by namespace, name and file, not line, so edits around an object do not matter), the finding has no more objects in total than the baseline had, and its severity has not risen since the baseline;newotherwise.
The mark is baselineState in JSON and SARIF, (in baseline) after the
title in the table, and a BASELINE N unchanged, M new line under the
score. Only the baseline's findings count. Its suppressed entries do
not, so when a suppression expires, the finding is new again.
Baseline matching relies on finding keys staying stable across runs and versions (see Finding keys).
How the gate decides¶
--fail-on blocker|warning exits 2 when, after suppression, a finding at or
above the threshold remains that is not unchanged against the baseline.
If none does, it still exits 2 when a required check was not assessed,
unless --allow-incomplete is given: a new blocker could be hiding behind
the gap, even when every known blocker is in the baseline. A --target
that is not an upgrade of the cluster (a downgrade, the same minor, or a
typo) exits 2 even with --allow-incomplete. --fail-on never always
exits 0. Operational errors, including an invalid config file
or baseline, exit 1.
Score and verdict exclude suppressed findings, but they include baseline findings. A baseline only changes what fails the gate. It does not change how ready the cluster is.
The server gate¶
POST /api/v1/gate on upgradescope serve suppresses as scan does,
with the same code. It applies the upgradescope.basit.engineer/ignore annotations of
the posted objects (and, with ?cluster=, of the cluster's stored
objects), and the ignore rules of a .upgradescope.yaml sent, URL-encoded,
in the config query parameter:
curl -sS --fail-with-body -X POST \
"$SERVER/api/v1/gate?target=1.37&cluster=prod-eu-1&path=rendered.yaml" \
--url-query "config@.upgradescope.yaml" \
-H "Authorization: Bearer $READ_TOKEN" \
-H "Content-Type: application/x-yaml" \
--data-binary @rendered.yaml
--url-query needs curl 7.87 or later; with an older curl, append
&config= and the file's URL-encoded text to the URL yourself. Rule file
globs match the path parameter. The config is parsed and validated by
the same code as scan --config: an invalid config (an unknown field, a
rule without a reason, an expires that is not a date), one over 32 KiB,
or config given twice is refused with 422 before anything is judged.
32 KiB is a few hundred rules.
The parameter travels in the URL, so two limits come first for a large
config. URL-encoding expands YAML (a colon or a line break becomes three
bytes, and so may each space of indentation), so a config under 32 KiB can exceed the server's
64 KiB limit on the request line and headers once encoded; Go's HTTP
server then answers 431 with a plain-text body, not the JSON 422. A
reverse proxy in front of serve usually allows far less: ingress-nginx's
default large-client-header-buffers (8 KiB) answers 414 for a request
line longer than that. Keep the config small, or raise the proxy's limit.
Suppressed findings count toward neither the verdict nor fail-on. With
?cluster=, what the manifests introduce is decided after suppression: a
finding whose posted objects are all suppressed is not the pull request's,
and objects the cluster already has at that key stay the cluster's. The
JSON answer lists them in suppressed with a suppressedCount, and names
expired rules and annotations without a reason in warnings. SARIF has no result
for them (only a note that counts them; see Code scanning and accepted
findings), JUnit lists them as
skipped test cases, and GitLab Code Quality leaves them out, as scan
does. With ?cluster=, a suppressed finding that is the pull
request's only because the gate fails closed (for example a posted CRD
that drops a stored version) is in the JSON suppressed list but not in
the SARIF or JUnit answer.
The server gate has no baseline input. With ?cluster=, the cluster's
stored state is its baseline: findings the cluster already has are tagged
source: cluster and do not fail the gate, and only what the manifests
introduce counts. Debt in the manifests themselves (a removed API that every
render still contains) is accepted with scan --files --baseline (the
GitHub Action's baseline input) or with ignore rules.
The agent: spec.ignore¶
The in-cluster agent reads the same rules from its ClusterReadiness
object and applies them, along with the object annotations, on every tick:
apiVersion: upgradescope.basit.engineer/v1alpha1
kind: ClusterReadiness
metadata:
name: cluster
spec:
ignore:
- key: eol-addon/ingress-nginx
reason: migrating to Gateway API, tracked in PLAT-123
expires: "2099-12-31" # a date inside your migration window
Each status.targets[] entry counts suppressed findings in suppressed.
They are not included in blockers, warnings and infos, in the score or
in the verdict. Expired or invalid rules, and annotations without a reason,
are listed in status.notAssessed and are not applied. The CRD schema
already rejects a rule without a reason or with a malformed expires. The
agent catches the rest (for example, both key and category). file
never matches live objects.
The agent pushes the raw inventory to the server: each object's
annotations, but not spec.ignore. So the server applies the object
annotations and never spec.ignore, both in its stored evaluations (the
dashboard, the fleet, history, notifications, exports and GET /report;
see Where annotations apply) and in its gate,
which applies the rules sent with each request instead
(The server gate). A finding that spec.ignore alone
accepts is suppressed in the ClusterReadiness status and open on the
server. When the finding lists objects (API usage), annotating them
accepts it in both; an add-on or version-skew finding lists none, so no
server view can accept it yet.
Finding keys¶
Every finding has a key: its category, a slash, and a stable
discriminator. The key never includes counts, so the same problem keeps the
same key from run to run while the title changes (for example, "3 objects"
becomes "2 objects"). Examples:
| Finding | Key |
|---|---|
| Removed or deprecated API | removed-api/networking.k8s.io/v1beta1/Ingress, deprecated-api/core/v1/ComponentStatus (the core group is core). The same key is the removal blocker (the API is gone at the target) and the warning for a removal in the next minor |
API the target does not serve yet (--files and the gate: "not served until 1.31") |
removed-api/<group>/<version>/<kind>/unserved, e.g. removed-api/networking.k8s.io/v1beta1/ServiceCIDR/unserved; a key without /unserved never accepts it (see One API, two keys) |
| Objects of a flagged API nothing can be attributed to | deprecated-api/<group>/<version>/<kind>/authorship-unknown (info) |
| Deprecated API still requested | deprecated-api-in-use/<group>/<version>/<resource> |
| EOL add-on | eol-addon/ingress-nginx, or eol-addon/<id>/<cycle> for a release line |
| Version skew | version-skew/kubelet-post-upgrade, version-skew/<component>-newer or -behind, version-skew/upgrade-path |
| Unknown built-in API | unknown-api/<group>/<version>/<kind> |
| CRD version | crd-version/unserved/<group>/<version>/<kind>, crd-version/deprecated/…, crd-version/stored-unserved/… |
| Managed-provider support | support-lifecycle/<provider>/<minor>/<phase>, e.g. support-lifecycle/eks/1.34/extended; the phase is ending, extended or ended, so accepting one never accepts a later one (a key without the phase, as written before, matches nothing: see Upgrade) |
| Knowledge base behind the target | kb-stale |
--output json shows the key of every finding, and the SARIF output uses
it as the rule id.
One API, two keys¶
An API in a manifest can be a problem in two opposite ways, and each has its own key, so accepting one never accepts the other:
- Not served yet. The target is older than the release that introduces
the API version (
networking.k8s.io/v1beta1ServiceCIDR is served from 1.31;--target 1.30reads "not served until 1.31"). Applying the manifest fails today and works after the upgrade. Key:removed-api/networking.k8s.io/v1beta1/ServiceCIDR/unserved. - Removed. The target no longer serves the API version (the same
ServiceCIDR is removed in 1.37;
--target 1.37reads "removed in 1.37"). Key:removed-api/networking.k8s.io/v1beta1/ServiceCIDR.
A rule written for "apply this once the cluster is on 1.31" (the first key)
therefore stops applying at --target 1.37, where the manifest fails for
the opposite reason, and the removal blocker is back. A baseline written at
--target 1.30 marks the removal at --target 1.37 as new for the same
reason.
The warning for a removal in the next minor and the removal blocker itself
share the key removed-api/<group>/<version>/<kind>: it is the same
API about to go and gone. An ignore rule accepts a key whatever the
finding's severity, so a rule for the key written while the finding is the
warning (--target 1.36) goes on accepting the blocker at --target 1.37.
To accept only the warning, give the rule an expires date before you
move the target to the removal release; a baseline is stricter, since it
marks a finding as new when its severity rises.
A rule with the key of an API whose finding is the not-served-yet one
(without /unserved) matches nothing, and scan prints a warning naming
the key to write. That warning is printed only while the finding exists: a
bare-key rule that is waiting for a later target draws none, and when that
target arrives it accepts the removal.
A baseline written by a release before the /unserved key held the
not-served-yet blocker under the removal's key. Reading it, upgradescope
drops those entries (it recognises them by the "is not served until"
title), so the blocker comes back once as new under its own key; write the
baseline again (--write-baseline) after you have decided to accept it.
Code scanning and accepted findings¶
GitHub code scanning does not read the SARIF suppressions property: it
opens an alert for every located result at its level, whatever the gate
decided. A result for an accepted finding would therefore show as an open
alert in the Security tab (and on the pull request's diff) although the
gate passed. So scan --output sarif, /gate?format=sarif and the
Action's sarif-file leave suppressed findings out of the results
altogether. Instead the run carries one note, "N findings accepted by
ignore rules or annotations are not uploaded; see the JSON report", and
run.properties.suppressed holds the count. The accepted findings, their
reasons and expiry stay in the JSON report, the step summary and JUnit.
What does stay a result:
- Baseline-unchanged findings. A finding the baseline already had is
still a result, marked
baselineState: unchanged, and code scanning opens an alert for it like any other.--baselinechanges the gate, not what code scanning shows. To keep it out of the Security tab, accept the finding with an ignore rule or annotation instead. - Unsuppressed findings, as always.
To dismiss an alert that is already open, use the code-scanning alerts API
or GitHub's advanced-security/dismiss-alerts action, which reads
non-empty suppressions[] from a SARIF file you produce yourself; the
SARIF written here does not carry any.
Who can turn the gate off¶
Everything on this page that makes a finding stop counting is a file in the
repository being judged: the ignore rules, the annotations in the manifests
and the baseline. In CI on a pull_request those files come from the pull
request's own tree, so a pull request can suppress its own findings and
the gate then passes. Nothing is hidden (the suppressed findings, their
reasons and the rule's file are in the table, the step summary and the
JSON, and the change is in the diff), but the gate does not stop an
author who wants past it unless you pin its inputs:
- Ignore rules.
.upgradescope.yamlis found in the scan root, then at the repository root, so a pull request that adds one next to a manifest with a removed API turns exit 2 into exit 0.--config <path>(the Action'sconfig) names the one file to read and stops that search. - Annotations.
upgradescope.basit.engineer/ignorewithignore-reasonon an object always applies; no flag turns it off. Review the suppressed table, or fail the job when a suppression came from an annotation (jq -e '[.suppressed[]? | select(.source == "annotation")] | length == 0'on the JSON report). - The baseline.
--baseline <path>(the Action'sbaseline) reads whatever file it is given, so a pull request can commit a baseline that holds its own findings.
To pin the config and the baseline, take both from the base commit: check it
out into another directory, copy the two files over the pull request's, and
name them in the Action. The files must exist at the base (ignore: [] is a
valid empty config), and a pull request that changes them is judged by the
old rules, so accepting a finding takes a pull request of its own that
changes only those files. The copy comes last, right before the gate, and
removes the destination first (rm -f --): a pull request can commit
.upgradescope.yaml as a symlink to a file an earlier step writes, a plain
cp would write through it, and that step would then overwrite the trusted
content. config names the copy, not trusted/..., because the config's
file globs resolve relative to the config's directory. CODEOWNERS with
required code-owner review on the two files and on .github/workflows/
covers what a checkout cannot, and the workflow can be edited by a pull
request too:
on: pull_request
jobs:
upgrade-gate:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7 # the pull request (its merge commit)
with:
persist-credentials: false
- name: Clear the path the base commit goes to
run: rm -rf -- trusted # a pull request may have committed one
- uses: actions/checkout@v7 # the base commit, only the two files
with:
ref: ${{ github.event.pull_request.base.sha }}
path: trusted
sparse-checkout: |
/.upgradescope.yaml
/upgradescope-baseline.json
sparse-checkout-cone-mode: false
persist-credentials: false
- run: helm template my-release ./chart --output-dir rendered
- name: Take the config and baseline from the base commit, last
run: |
rm -f -- .upgradescope.yaml upgradescope-baseline.json
cp trusted/.upgradescope.yaml trusted/upgradescope-baseline.json .
- uses: abd-ulbasit/upgradescope@v0.3.0
id: gate
with:
path: rendered
target: "1.37"
version: v0.3.0
config: .upgradescope.yaml # named, so no other config is looked for
baseline: upgradescope-baseline.json
The CI gate page has the full trust table and the annotation guard.