Issue trackers
When a critical vulnerability appears in an image your cluster is running, Attestkeep opens an issue in your own tracker. When the finding stops being true, it closes that issue and records why.
The issues themselves are the visible half. The half that matters is the history behind them. Until this existed, Attestkeep could declare a remediation window and count critical findings, and had no record of when a finding stopped being one — so the remediation-windows attestation in your evidence pack was permanently partial: it could report the windows and the findings, never whether a fix landed inside one. With a tracker connected, that attestation can say N of M critical findings raised in this period were fixed, and K of those inside the declared window.
Read this before you configure one. This is the first place in Attestkeep where a credential that can write to a system outside your cluster lives inside your cluster. Everything else this product holds is read-only or internal. The minimum permission for each provider is below; grant that and nothing more.
How it is configured
An issue tracker is a notification channel, not a separate subsystem. It lives in the same table, the same editor and the same subscription rules as a Slack or Teams destination, and it subscribes to the same critical_finding event. Settings → Notifications → Add Channel → GitHub Issues / GitLab Issues / Jira.
Subscribing a tracker channel to other events is allowed and does nothing: critical_finding is the only event that produces an issue.
Test on a tracker channel writes nothing at all. It issues a single read — a GET of the repository, project or Jira project — and reports whether the credential works. A "send a test message" button would file a junk ticket in your board every time somebody pressed it, so the console says instead: Connection to … verified … Nothing was written.
One issue per image digest and CVE, forever
The deduplication key is the image digest plus the CVE, with no time window. A digest is immutable: the same sha256: is byte for byte the same image, so the same CVE in the same bytes is one problem however many times it is met. A second scan, a second namespace, a second cluster and a second week all find the row that already exists and add themselves to it rather than opening a second issue.
Cluster names are recorded on the row and are deliberately not part of the key. Three clusters running the same digest do not want three issues about one CVE; they want one issue that says which three. Set clusterName on each installation and the issue body names them.
A different digest carrying the same CVE is a separate issue: it is a separate set of bytes to fix.
The history row is the lock, and it is claimed before the tracker is called. That is what stops two operator replicas both deciding to open the same issue. The cost is that a provider failure can leave a row holding the lock with no issue behind it; such a row is retried on the next critical finding for that image rather than released, because the alternative is a duplicate that nothing afterwards can merge.
The first run files one summary
A first scan of an existing cluster reports a backlog, not an event, and it can easily be two hundred critical findings. An integration that files two hundred tickets on its first afternoon is switched off on its second.
So a channel's very first run, if it has more than five candidates, files a single summary issue listing all of them. Every finding still gets its own history row — the dedup lock and the evidence count are per CVE — and they all point at that one issue. From the second run onwards the behaviour is one issue per finding. Turn it off with Summarise the first run if you would rather have the individual tickets.
The daily cap
Each channel may open at most dailyCap issues — 20 by default, allowed range 1 to 500 — in a rolling 24 hours. Findings beyond the cap are not lost and not marked as handled: they are candidates again as soon as the window rolls. A warning is logged once per day per channel when the cap is reached, not once per finding. The cap counts issues, so a first-run summary of a hundred findings costs one.
A cap of zero is refused. It used to be accepted, and produced a channel that saved cleanly, tested green and opened nothing for the rest of its life. A channel that should file nothing is disabled, not capped at zero.
A disabled channel still closes the issues it opened. Disabling stops the filing; the closing sweep goes on using the stored credential, so an issue already in your tracker is still commented on and closed when its finding is fixed, triaged or retired. That is deliberate — an open ticket about a finding that no longer exists is worse than a closed one, and nobody disables a channel in order to be left with a board full of them.
Deleting the channel stops that too, and there is no other mechanism behind it: the history rows keep the deleted channel's id, the sweep can no longer resolve it and says so once per sweep in the log, the rows stay open forever and their issues have to be closed by hand. Deleting also leaves those findings locked — an open row is the deduplication key, so a new channel will not re-file them.
Where the issue goes
If the image's OCI config carries org.opencontainers.image.source and it points at a repository on this channel's own host, the issue is opened there. Otherwise it goes to the channel's default repository or project.
LABEL org.opencontainers.image.source="https://github.com/acme/api"
The host comparison strips a leading api. from the configured API host, which is the only difference between api.github.com, where Attestkeep talks, and github.com, where your image says it lives. A GitHub Enterprise Server or a self-managed GitLab uses one host for both, so the strip is a no-op there. GitHub takes the first two path segments (owner/name); GitLab takes the whole path, because GitLab groups nest.
Jira always uses its project key. A repository is not a Jira project, and the mapping between the two is yours, not something this product can infer.
Closing, and the three reasons
A background job re-checks every open issue every ten minutes, on the single replica holding the controller lock. It closes an issue by posting a comment saying why and then closing it in the tracker; only after the tracker has confirmed is the history row marked closed. A provider failure leaves the row open with the error recorded and the next sweep tries again — a closure in your evidence pack always corresponds to a closure an auditor can go and look at.
| Reason | What it means | Counts as remediation? |
|---|---|---|
fixed | The newest scan of that digest no longer reports the CVE. | Yes |
triaged | An unexpired triage decision in Attestkeep covers that CVE for that image. | No |
image_retired | No pod has been observed running that digest for 24 hours. | No |
Only fixed is remediation, and the three never merge. A deleted deployment counted as a fix would turn we stopped shipping it into we patched it in an audit document. The evidence pack reports the three separately for exactly this reason, and the closing comment on the ticket says which one it was in the same words.
Two details worth knowing. fixed is read from the findings rows, not from a severity count: a count that dropped because a producer's VEX statement was accepted is not a fix. And image_retired is withheld entirely unless a runtime reconciliation sweep has run within the last 24 hours — "nothing runs this digest" is only a fact if something was looking, and closing on a silence would put a closure in the evidence pack that nothing observed.
The unit of work is the issue, not the finding. Those are the same thing for an ordinary finding and are not for a first-run summary, which is one issue carrying a history row per CVE. So each finding is still judged on its own with its own reason; one comment per issue per sweep lists the findings that ended and the reason each ended for; and the issue is closed once, when the last finding on it ends. Until then it stays open and the comment says how many are still outstanding — closing an issue that still lists live critical findings would tell the team the work is done.
A finding that was closed as triaged and comes back when the triage expires opens a new issue. That is a new decision, and the closed row stays as the history of the first.
Configuration and the minimum permission
GitHub
| Field | Notes |
|---|---|
apiBaseUrl | https://api.github.com, or https://ghe.example.com/api/v3 for GitHub Enterprise Server. |
token | See below. Write-only: never read back through the API. |
defaultRepo | owner/name. |
labels | Applied to every issue this channel opens. |
dailyCap | Default 20. |
firstRunSummary | Default on. |
Minimum permission: a fine-grained personal access token with Issues: Read and write on the target repositories, and nothing else. Select the repositories explicitly rather than granting the token all of them. No contents, no actions, no metadata beyond what selecting a repository implies. A classic PAT with the repo scope also works and grants far more than this integration needs; prefer the fine-grained token.
GitLab
| Field | Notes |
|---|---|
apiBaseUrl | https://gitlab.com/api/v4, or your instance's /api/v4. |
token | See below. Write-only. |
defaultProject | Full path (acme/platform/api) or numeric project id. |
labels, dailyCap, firstRunSummary | As above. |
Minimum permission: a project access token with the api scope on the target project, or a group access token when several projects are targeted. GitLab has no issues-only scope — api is the narrowest thing that can create an issue, and it is broader than we would like. Scope it to the single project where you want the issues to land, and use the Reporter role, which is enough to create and close issues.
Jira
| Field | Notes |
|---|---|
apiBaseUrl | https://your-site.atlassian.net (Cloud), https://api.atlassian.com/ex/jira/<cloudId> (Cloud, scoped token) or your Data Center base URL. |
email + apiToken | Jira Cloud. Basic auth. |
bearerToken | Jira Data Center. A personal access token, used instead of the pair above. |
projectKey | Every issue from this channel is filed here. |
issueType | Default Task. |
doneTransitionId | Optional. See below. |
Configure one authentication mechanism, not both. Which one you configure also selects the REST API version: Cloud is /rest/api/3 with Atlassian Document Format bodies, Data Center is /rest/api/2 with plain text.
Which Cloud base URL: the site, or the gateway. Atlassian now issues two kinds of API token and they look the same — both start with ATATT3, and nothing on the page you copy one from says which you have. A classic token works against your site, https://your-site.atlassian.net. A scoped token does not: the site answers 401 AUTHENTICATED_FAILED no matter how correct the e-mail and token are, and the same pair works only through the API gateway, https://api.atlassian.com/ex/jira/<cloudId>. So a 401 from the Test button with an e-mail and token you are sure of means you hold a scoped token and are pointing at the site. Switch the base URL to the gateway form. Your cloudId is public and takes one request:
curl -s https://your-site.atlassian.net/_edge/tenant_info
{"cloudId":"5c9f0b7e-...."}
Issue links stay usable under the gateway: /browse is not a page there, so Attestkeep asks Jira once per run where the site is (GET /rest/api/3/serverInfo) and links to that instead.
Minimum permission: an API token for an account with Create issues and Add comments on the target project, plus Transition issues if you set a done transition. Nothing else — this integration never reads other people's issues and never touches a board.
doneTransitionId is never guessed. Jira workflows belong to the project, and a transition called "Done" in one is "Resolve Issue" in the next; an integration that guessed would eventually move a security ticket into a state your process does not expect. Without it, closing posts the comment and leaves the issue open — and the comment says so, so nobody is left thinking the tracker and the console disagree. Find yours with:
curl -u "$EMAIL:$API_TOKEN" \
"https://your-site.atlassian.net/rest/api/3/issue/SEC-1/transitions"
The credential, and where it lives
- It is stored encrypted in the operator's own PostgreSQL. See below.
- It is never read back through the API. A read returns the configuration with the secret fields removed and a
tokenSet: trueflag in their place, so the console can say "a credential is stored" without holding one. An update that leaves the field empty keeps the stored value; an update that fills it in replaces it. - Every outbound call goes through the same guard the notification webhooks use: a base URL that resolves to loopback or a link-local address, where every cloud metadata endpoint lives, is refused at save time and again at dial time.
- Rotate by editing the channel and typing the new token. Revoke by deleting the channel; the history rows survive it, because an auditor asking about a closed finding must not be told the record vanished when somebody rotated a credential.
Credentials at rest
A tracker token can open and close issues in your own GitHub, GitLab or Jira, and it lives in the product database. A database copy — a backup, a read-only reporting user, a dump handed to support — would otherwise carry a working write credential for your forge. So it is encrypted before it is written.
What is encrypted. Exactly the tracker credentials: token for GitHub and GitLab, apiToken and bearerToken for the two Jira flavours. They are sealed with AES-256-GCM and stored as enc:v1:<base64>, bound to the channel and the field they belong to, so a value lifted out of one channel's row and pasted into another's does not open. Nothing else changes: the SMTP password, the single sign-on client secret and the Slack and Teams webhook URLs are stored as they always were.
Where the key lives. In a Kubernetes Secret, delivered to the operator as the environment variable DATA_KEY. The Helm chart generates <release>-data-key on first install and preserves it across upgrades; point api.dataKey.existingSecret at your own Secret to supply it from a secret manager instead. It is never written to the product database — a key stored beside the data it protects is not a key — and there is no fallback that would put it there. It belongs in your backup set.
Without the key. Saving or updating an issue-tracker channel is refused with "tracker credentials cannot be stored: DATA_KEY is not set", and the operator logs say so once at startup. A credential already stored that cannot be opened — a replaced key — makes the Test button report that the stored token cannot be read, and findings are skipped with that reason rather than the ciphertext being sent to your tracker as a bearer token. Nothing else about the product depends on the key.
On upgrade. Channels configured before this version hold their token in plain text. One pass at startup, on the leader, seals them in place and logs how many it changed; running it again finds nothing to do. There is nothing to run by hand.
Export. The configuration bundle carries neither the token nor its ciphertext — the sealed value is bound to this installation's key and would not open in the one the bundle is for, and in a file it would only be a liability. An imported tracker channel arrives disabled, waiting for its token to be entered.
Rotation is not offered in this version. Replacing DATA_KEY leaves the credentials already stored unreadable; each tracker channel then needs its token entered again, which also re-seals it under the new key.
What this adds to the evidence pack
A new issues section: how many critical findings were opened in the period, how many are still open, how many closed and under which of the three reasons, how many of the fixes landed inside the declared critical window, and a row per tracked finding — CVE, image, issue URL, opened, closed, reason, hours open — so a figure can be sampled rather than taken on faith.
And remediation-windows changes state:
| State | When |
|---|---|
| gap | No remediation window is declared at all. |
| partial | No tracker is connected — unchanged wording, so previously issued packages still read the same way. |
| partial | A tracker is connected and nothing was raised, or nothing raised has been fixed yet. |
| gap | Findings were fixed and none of the fixes landed inside the declared window. |
| partial | Some fixes landed inside the window and some were late. |
| met | Every critical finding raised in the period that has been fixed was fixed inside the declared window, and at least one was. |
No framework clause changed. The clauses already cite this attestation; what changed is that it now has something to measure.
Channel actions — create, update, delete and test — are recorded in the audit log as user activity, with the actor, the action, the channel and the path. They are not part of evidence packages: they are what an operator did to the installation, not what the control decided about an image.
Metrics
attestkeep_issues_opened_total{provider}
attestkeep_issues_closed_total{provider,reason}
attestkeep_issues_skipped_total{reason="dedup|cap|error"}
The first two count issues, one per write to your tracker: a first-run summary of a hundred findings increments opened by one, not a hundred. When an issue carrying several findings is closed, closed is incremented once with reason set to the majority of its findings' reasons, ties breaking towards fixed — a counter takes one label, and a tie must never make an issue look less remediated than it was. The per-finding truth is not in these metrics and is not meant to be: every history row keeps its own reason, and the evidence pack reports the three from the rows.
skipped counts findings, because a candidate is a finding: six findings that never reached the tracker is six findings untracked, whether or not they were going to share an issue. skipped{reason="dedup"} is the commonest series on a healthy cluster and is not a problem — it is the deduplication rule doing its job on every rescan. skipped{reason="error"} rising means the tracker is refusing; check the channel's stored error, which is also on the history row.