An SLSA provenance is a signed attestation that states where, from which source and by which builder an artifact was built. It protects nothing as long as nobody verifies it: security comes from the decision that refuses a non-compliant artifact, in CI then at Kubernetes admission. This lesson is for a DevOps engineer who already produces attestations and wants them to really block.
Everything is measured on 1 October 2026 on public artifacts: slsa-verifier
2.7.1 accepts the kyverno:v1.19.1 image designated by digest and
refuses it by its tag ("the image is mutable"), a Kyverno 1.19.1
policy replayed on kind admits that image and refuses a provenance from
another repository, and a static pod slips past admission without being
blocked.
What you will learn
- Read what a provenance proves, and which SLSA Build level it reaches.
- Verify a provenance in CI with
slsa-verifier,gh attestation verifyorcosign verify-attestation, constraining source and builder. - Block at Kubernetes admission an image without compliant provenance.
- Spot the path that bypasses admission: the static pod.
- Control a break-glass exception, disabled by default in Kyverno.
- Produce the evidence for SOCLE-REL-PRV-1 (in French), a provenance describing builder, source and parameters, and for SOCLE-REL-VER-1, its verification before use.
What does an SLSA provenance prove?
SLSA (Supply-chain Levels for Software Artifacts) describes levels of assurance. Its version 1.2 sorts them into two tracks: Build, from L0 to L3, and Source, from L1 to L4. A provenance belongs to the Build track: it names the builder, the source repository, the reference (tag, commit) and the build parameters, all signed.
The level depends on who produces the provenance. An attestation issued
by the project's own workflow, such as actions/attest-build-provenance,
reaches Build L2: the platform is hosted and the provenance is signed, but
the builder is the workflow the project controls. The isolated generators of
slsa-github-generator aim for Build L3, because the provenance is
produced by a reusable workflow the project cannot modify.
| Measured provenance | Recorded builder | Targeted level |
|---|---|---|
gh_2.102.0_linux_amd64.tar.gz (GitHub CLI) | https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk | Build L2 |
kyverno:v1.19.1 | .../slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.1.0 | Build L3 |
The first row shows it: the builder of a GitHub attestation is the repository's workflow, which anyone who can push to it could modify. The second is a third-party, isolated workflow. That difference also changes the identity to verify, and it is the main trap of this lesson.
When should provenance be verified?
Verification applies at two complementary checkpoints. The diagram places them on the artifact's path, with the route that escapes the second, the static pod:
| Checkpoint | Goal | Tools |
|---|---|---|
| CI pipeline | Prevent the promotion of an artifact without valid provenance | slsa-verifier, gh, cosign |
| Kubernetes admission | Block the deployment of a non-compliant image | Kyverno, Sigstore Policy Controller |
Verification in the CI pipeline
The pipeline is the first place to refuse an artifact, because it knows the context: source repository, expected tag, produced digest. Three tools cover this need depending on how the provenance was produced. In every case, verification comes before the image's promotion to a release tag: publishing first and finding out afterwards protects nobody.
With slsa-verifier
slsa-verifier, from the SLSA project, verifies provenances produced by
the slsa-github-generator generators. It checks the signature, the
Rekor entry, the builder, then compares source and tag with those you
give it. Measured on its own release binary and on the Kyverno image:
slsa-verifier verify-artifact slsa-verifier-linux-amd64 \ --provenance-path slsa-verifier-linux-amd64.intoto.jsonl \ --source-uri github.com/slsa-framework/slsa-verifier --source-tag v2.7.1# Verified build using builder ".../builder_go_slsa3.yml@refs/tags/v2.0.0" at commit ea584f45...# PASSED: SLSA verification passed
slsa-verifier verify-image \ ghcr.io/kyverno/kyverno:v1.19.1@sha256:b31d8511ae5fd6010e2a01ea72ebae08eb82fa51d91af14a1d9fe989949b4edb \ --source-uri github.com/kyverno/kyverno --source-tag v1.19.1# Verified build using builder ".../generator_container_slsa3.yml@refs/tags/v2.1.0" at commit 40ec788d...# PASSED: SLSA verification passedFailures are as telling as successes, and they all exit with code 1:
| Measured attempt | Message |
|---|---|
--source-uri github.com/attaquant/slsa-verifier | expected source 'attaquant/slsa-verifier', got 'slsa-framework/slsa-verifier' |
--source-tag v2.7.0 instead of v2.7.1 | expected tag 'v2.7.0', got 'v2.7.1' |
image designated by :v1.19.1, without digest | the image is mutable: 'ghcr.io/kyverno/kyverno:v1.19.1' |
The last row matters: slsa-verifier verify-image refuses a tag. An
example that verifies "$IMAGE:$TAG" therefore does not work; you must
pass the digest produced by the build.
name: verify-provenance
on: workflow_dispatch: inputs: digest: description: "Digest of the image to promote (sha256:...)" required: true type: string tag: description: "Git tag the image must come from (v1.4.0)" required: true type: string
permissions: {}
jobs: verify: runs-on: ubuntu-24.04 permissions: {} steps: - name: Install slsa-verifier v2.7.1, hash checked run: | curl -sSfLo slsa-verifier https://github.com/slsa-framework/slsa-verifier/releases/download/v2.7.1/slsa-verifier-linux-amd64 echo "946dbec729094195e88ef78e1734324a27869f03e2c6bd2f61cbc06bd5350339 slsa-verifier" | sha256sum -c - sudo install -m 0755 slsa-verifier /usr/local/bin/slsa-verifier
- name: Verify provenance before any promotion env: IMAGE: ghcr.io/${{ github.repository }}@${{ inputs.digest }} SOURCE: github.com/${{ github.repository }} TAG: ${{ inputs.tag }} run: | slsa-verifier verify-image "${IMAGE}" \ --source-uri "${SOURCE}" \ --source-tag "${TAG}"verifier-provenance-amont: stage: verify image: alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 variables: IMAGE_AMONT: "ghcr.io/kyverno/kyverno:v1.19.1@sha256:b31d8511ae5fd6010e2a01ea72ebae08eb82fa51d91af14a1d9fe989949b4edb" before_script: - apk add --no-cache curl - curl -sSfLo slsa-verifier https://github.com/slsa-framework/slsa-verifier/releases/download/v2.7.1/slsa-verifier-linux-amd64 - echo "946dbec729094195e88ef78e1734324a27869f03e2c6bd2f61cbc06bd5350339 slsa-verifier" | sha256sum -c - - install -m 0755 slsa-verifier /usr/local/bin/slsa-verifier script: - slsa-verifier verify-image "$IMAGE_AMONT" --source-uri github.com/kyverno/kyverno --source-tag v1.19.1The GitHub workflow passes actionlint 1.7.12, zizmor 1.30.0 and
poutine 1.1.6 without findings; inputs go through env:, never straight
into the script. The binary is installed by a hash-checked download
rather than through the slsa-verifier/actions/installer action, which
poutine flags as coming from an unverified publisher. The GitLab job, which
verifies an upstream image before copying it internally, was replayed as
is in an alpine:3.22 container: slsa-verifier: OK, then PASSED.
plumber could not be run on it, for lack of a test GitLab instance.
With gh attestation verify
GitHub attestations (actions/attest-build-provenance) are verified with
the gh CLI. It queries GitHub's attestations API, not the
registry. Measured with gh 2.100.0 on the release archive of the GitHub
CLI itself:
gh attestation verify gh_2.102.0_linux_amd64.tar.gz --repo cli/cli \ --signer-workflow cli/cli/.github/workflows/deployment.ymlecho $?# 0
gh attestation verify gh_2.102.0_linux_amd64.tar.gz --repo cli/cli \ --signer-workflow cli/cli/.github/workflows/autre.yml# Error: verifying with issuer "sigstore.dev" (code 1)Without --signer-workflow, verification accepts any repository
workflow, a consequence of the Build L2 level described above: the option
is therefore essential, since it restricts the provenance to the single
expected release workflow. A missing attestation returns HTTP 404 from the
API, measured on the slsa-verifier binary, whose provenance goes through the
generator and not through GitHub attestations.
With cosign verify-attestation
cosign also verifies a provenance attached to the image, with --type slsaprovenance. The identity to constrain is that of the provenance
signer, and that is where examples often go wrong: for an image produced by
slsa-github-generator, the signer is the generator, not the project's
workflow.
cosign verify-attestation --type slsaprovenance \ --certificate-identity-regexp '^https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ ghcr.io/kyverno/kyverno:v1.19.1@sha256:b31d8511ae5fd6010e2a01ea72ebae08eb82fa51d91af14a1d9fe989949b4edb# Certificate subject: https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.1.0# GitHub Workflow Repository: kyverno/kyverno# GitHub Workflow Ref: refs/tags/v1.19.1The output, measured with Cosign v3.1.3, gives the certificate subject
(the generator) and the repository that called it
(kyverno/kyverno). The decoded provenance carries the predicateType
https://slsa.dev/provenance/v0.2 and the source
git+https://github.com/kyverno/kyverno@refs/tags/v1.19.1. An identity
pattern that is too broad, such as .*, cancels the verification.
Verification at Kubernetes deployment
The CI check only covers images that went through the official
pipeline. A manual kubectl apply or a parallel pipeline bypasses it. The
admission controller closes that door: it intercepts every Pod creation
request before it is stored in etcd, and refuses those whose image has no
valid provenance. There is, however, a path that does not go through the API,
covered in "Can an admission controller be
bypassed".
Kyverno ImageValidatingPolicy
Kyverno 1.19 verifies attestations with an ImageValidatingPolicy. The
following policy was replayed on a disposable kind cluster (Kubernetes
1.37): it requires a provenance signed by the SLSA generator, then checks
in its content that the source is a tag of the expected repository.
apiVersion: policies.kyverno.io/v1kind: ImageValidatingPolicymetadata: name: verify-slsa-provenancespec: validationActions: [Deny] webhookConfiguration: timeoutSeconds: 30 matchConstraints: resourceRules: - apiGroups: [""] apiVersions: ["v1"] operations: ["CREATE", "UPDATE"] resources: ["pods"] matchImageReferences: - glob: "ghcr.io/kyverno/*" attestors: - name: generateur cosign: keyless: identities: - issuer: "https://token.actions.githubusercontent.com" subjectRegExp: "^https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v" ctlog: url: "https://rekor.sigstore.dev" attestations: - name: provenance intoto: type: https://slsa.dev/provenance/v0.2 validations: - expression: >- images.containers.map(image, verifyAttestationSignatures(image, attestations.provenance, [attestors.generateur])).all(e, e > 0) message: "Aucune provenance SLSA signée par le générateur slsa-github-generator." - expression: >- images.containers.map(image, extractPayload(image, attestations.provenance).predicate.invocation.configSource.uri.startsWith('git+https://github.com/kyverno/kyverno@refs/tags/')).all(e, e) message: "La provenance ne désigne pas un tag du dépôt attendu."Four attempts, made with the same kyverno:v1.19.1 image designated by
digest, show what each part of the policy brings:
| Policy variant | Measured result |
|---|---|
| Policy above | pod/kyverno-avec-provenance created |
Expected source github.com/attaquant/kyverno | Refused: La provenance ne désigne pas un tag du dépôt attendu. |
Expected identity ^https://github.com/kyverno/.*/\.github/workflows/ | Refused: Aucune provenance SLSA signée par le générateur slsa-github-generator. |
Path extractPayload(...).invocation without .predicate | Refused: failed to evaluate policy: no such key: invocation |
The third row is the classic trap: a policy that expects the project's
workflow as signer refuses a perfectly valid provenance, since the generator
is the one that signs. The fourth is a reminder that extractPayload()
returns the whole in-toto statement, whose provenance is the predicate
field. And, as with signatures, a validation reduced to expression: "true"
checks nothing and blocks everything.
Progressive rollout of the policy
Switching straight to blocking on an existing cluster breaks the
deployments of every team that has no provenance yet. The four steps below
vary only two settings: validationActions, which decides between
observing and refusing, and matchConditions, which narrows the scope
to the chosen namespaces.
-
Audit mode: observe without blocking.
spec:validationActions: [Audit]Violations accumulate in the
PolicyReportobjects for one to two weeks. -
Analyse the violations.
Terminal window kubectl get policyreport -A -o json | jq '.items[].results[] | select(.result == "fail")' -
Deny mode on staging, with a
matchConditiontargeting the namespace.spec:validationActions: [Deny]matchConditions:- name: staging-onlyexpression: "object.metadata.namespace == 'staging'" -
Progressive extension to production.
spec:matchConditions:- name: staging-and-prodexpression: "object.metadata.namespace in ['staging', 'production']"
Can an admission controller be bypassed?
Yes, through a documented Kubernetes mechanism: the static pod. The
kubelet on each node watches a local directory,
/etc/kubernetes/manifests by default, and starts any Pod it finds there
without consulting the Kubernetes API. No admission request is issued, so
no Kyverno policy or ValidatingAdmissionPolicy has a say. Provenance
verification is not bypassed by a trick: it is never called. The exact
path appears in the kubelet configuration, under the staticPodPath key.
This behaviour is not a defect, it is what lets the control plane start.
On a cluster installed with kubeadm, etcd, kube-apiserver,
kube-controller-manager and kube-scheduler are themselves static
pods: requiring validation by the API server to start the API server
would be circular.
Why the Pod still ends up showing in kubectl
Once the container has started, the kubelet creates a mirror pod in the API, a read-only copy meant for visibility. That mirror pod does go through admission, and that is where things become misleading: the verdict applies to the copy, never to the container, which is already running. The table gives the two possible outcomes for a manifest whose image violates the policy.
| Policy engine | Verdict on the mirror pod | What kubectl get pods shows | Container on the node |
|---|---|---|---|
ValidatingAdmissionPolicy (Kubernetes 1.35, earlier measurement) | Refused | No Pod, the namespace looks empty | Running |
| Kyverno 1.19.1 (kind, Kubernetes 1.37, replayed for this lesson) | Accepted | statique-verif-m06-control-plane, Running, source file | Running |
Both outcomes mislead the operator, in opposite directions. With the
native policy, the refusal only appears in the kubelet logs, as
Failed creating a mirror pod, while the container serves traffic. With
Kyverno, the Pod shows like any other and only the PolicyReport carries the
verdict: measured, a FAIL 1 row for statique-verif-m06-control-plane. In
both cases, kubectl get pods does not describe what is really running.
How do you spot a Pod that did not go through the API?
The kubelet marks these Pods with an annotation,
kubernetes.io/config.source, which is file for a static pod and api
for an ordinary Pod. This command lists the Pods concerned across the cluster:
kubectl get pods -A \ -o custom-columns='NAMESPACE:.metadata.namespace,NOM:.metadata.name,SOURCE:.metadata.annotations.kubernetes\.io/config\.source,NOEUD:.spec.nodeName' \ | grep -v '<none>'The output normally contains the control plane components, and nothing
else. Any file row outside kube-system, or on a worker node, deserves
immediate inspection. This check queries the API: it will therefore not
see a static pod whose mirror pod was refused. The exhaustive list of what
runs is read on the node, with crictl pods.
Managing exceptions (break-glass)
Even the best policy needs an exception mechanism for emergencies. The goal is to allow the bypass without normalising it: a narrow, traced, approved exception, deleted after use.
A PolicyException for an ImageValidatingPolicy
A PolicyException designates the target policy through policyRefs and
the exempted scope through CEL matchConditions. Two facts measured on
Kyverno 1.19.1 shape everything else. Exceptions are disabled by default:
the chart sets the --enablePolicyException=false argument, and kubectl apply warns "PolicyException resources would not be processed until it is
enabled". And Kyverno does not expire an exception: a date annotation is
purely informative.
apiVersion: policies.kyverno.io/v1kind: PolicyExceptionmetadata: name: breakglass-inc-12345 namespace: default labels: exception-type: break-glass incident-id: "INC-12345" annotations: expiry: "2026-10-02T12:00:00Z" # informative: Kyverno deletes nothing reason: "Correctif urgent, provenance ajoutée après l'incident"spec: policyRefs: - name: verify-slsa-provenance kind: ImageValidatingPolicy matchConditions: - name: pods-hotfix-uniquement expression: "object.metadata.namespace == 'default' && object.metadata.name.startsWith('hotfix-')"Enabled through features.policyExceptions.enabled=true and
features.policyExceptions.namespace=default in Helm chart 3.9.1, exceptions
apply: measured, hotfix-api is created, autre-api stays refused,
and Kyverno's metric counts the former with result="skip". Restricting
namespace to a single exceptions namespace prevents a team from creating
its own anywhere.
Automated exception workflow
Creating the exception by hand in the middle of an on-call shift produces an
object that is too broad and untraced. The workflow below enforces a form,
a protected environment that requires approval, validation of the
inputs before writing them into the manifest, and a notification to the
security channel. The cluster access step depends on your platform and is not
shown.
name: breakglass
on: workflow_dispatch: inputs: incident_id: description: "Incident identifier (INC-12345)" required: true type: string prefixe_pod: description: "Prefix of exempted pods (hotfix-)" required: true type: string raison: description: "Reason for the exception" required: true type: string
permissions: {}
jobs: exception: runs-on: ubuntu-24.04 environment: production-breakglass # approval required permissions: contents: read steps: # Cluster access step specific to your platform (not shown)
- name: Create the PolicyException env: INCIDENT_ID: ${{ inputs.incident_id }} PREFIXE: ${{ inputs.prefixe_pod }} RAISON: ${{ inputs.raison }} ACTEUR: ${{ github.actor }} run: | [[ "${INCIDENT_ID}" =~ ^INC-[0-9]+$ ]] || { echo "incident_id invalide"; exit 1; } [[ "${PREFIXE}" =~ ^[a-z0-9-]+-$ ]] || { echo "prefixe_pod invalide"; exit 1; } cat > exception.yaml <<EOF apiVersion: policies.kyverno.io/v1 kind: PolicyException metadata: name: breakglass-${INCIDENT_ID,,} namespace: production labels: exception-type: break-glass incident-id: "${INCIDENT_ID}" annotations: created-by: "${ACTEUR}" reason: "${RAISON//\"/}" spec: policyRefs: - name: verify-slsa-provenance kind: ImageValidatingPolicy matchConditions: - name: prefixe-autorise expression: "object.metadata.name.startsWith('${PREFIXE}')" EOF kubectl apply -f exception.yaml
- name: Notify the security team uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} payload: | channel: ${{ secrets.SLACK_SECURITY_CHANNEL_ID }} text: "Exception break-glass ${{ inputs.incident_id }} créée par ${{ github.actor }}"This workflow passes actionlint, zizmor and poutine without
findings. Inputs go through env: and are validated by regular expression
before entering the manifest: the previous version, which injected
${{ inputs.reason }} directly into the script, offered a command
injection to anyone able to trigger the workflow. The notification assumes
two secrets, SLACK_BOT_TOKEN (chat:write scope) and
SLACK_SECURITY_CHANNEL_ID.
Recording and auditing decisions
Every verification decision must leave an exploitable trace, for
compliance as well as diagnosis. Kyverno produces two: the PolicyReport
objects, in the cluster, and Prometheus metrics. PolicyReport objects
follow the life cycle of resources: a deleted Pod takes its report with it,
hence the value of exporting them to external storage, for instance with
the Kyverno project's Policy Reporter tool.
Verification metrics
Metric names are not those of the old ClusterPolicy. Measured on the
kyverno-svc-metrics service of Kyverno 1.19.1, those that concern an
ImageValidatingPolicy are the following; the
kyverno_policy_exception_total metric, quoted in some examples, is not
among them:
| Metric | What it counts | Alert if |
|---|---|---|
kyverno_image_validating_policy_results_total{result="fail"} | Refusals per policy and namespace | Sudden spike |
kyverno_image_validating_policy_results_total{result="skip"} | Passes under exception | Non-zero outside an incident |
kyverno_image_validating_policy_execution_duration_seconds | Verification duration | Close to the webhook timeout |
kyverno_admission_review_duration_seconds | Admission response time | Several seconds |
Decision dashboard
Three queries are enough for a useful dashboard: the refusal rate per policy, the breakdown by result and namespace, and the volume of passes under exception. The last one is the most telling in review: if it never goes back to zero, exceptions have become permanent and the policy no longer protects anything.
# Refusals per policysum by (policy_name) (rate(kyverno_image_validating_policy_results_total{result="fail"}[1h]))
# Results per namespacesum by (resource_namespace, result) (rate(kyverno_image_validating_policy_results_total[24h]))
# Passes under exceptionsum(increase(kyverno_image_validating_policy_results_total{result="skip"}[24h]))Decision matrix per environment
The same policy is not tuned the same way in every environment: the cost of an unjustified block is zero in dev and high in production. This matrix gives a starting point, to be read as a contract between the platform team and the application teams. The Notification column matters as much as the Mode column: a silent refusal turns into an incident ticket within the hour.
| Environment | Mode | Exceptions | Notification |
|---|---|---|---|
| dev | Audit | All allowed | Log only |
| staging | Deny | Through a reviewed PR | Deployment channel |
| production | Strict Deny | Break-glass and security approval | On-call and SIEM |
Common troubleshooting
Three symptoms cover most calls after switching to blocking. The first comes from the artifact, the second from the network, the third from the Kyverno installation. Work out which one before touching the policy.
Image refused: attestation not found or different identity
Before suspecting the policy, check what the registry exposes for the
targeted digest, and who signed the attestation. cosign tree lists
the attached attestations; cosign verify-attestation with a wrong identity
shows the actual identity after got.
cosign tree ghcr.io/kyverno/kyverno:v1.19.1@sha256:b31d8511ae5fd6010e2a01ea72ebae08eb82fa51d91af14a1d9fe989949b4edb# └── Attestations for an image tag: ghcr.io/kyverno/kyverno:sha256-b31d8511....attCommon causes: the pipeline produced no provenance, the policy expects the project's workflow while the generator signs, or the attestation covers a different digest from the deployed image.
Verification timeout exceeded
Verification queries the registry and the transparency log during
admission, on the critical path of Pod creation. The timeout is set in the
policy, under webhookConfiguration.timeoutSeconds, and Kubernetes caps it
at 30 seconds for an admission webhook: a value of 60, still seen in
examples, is not accepted.
spec: webhookConfiguration: timeoutSeconds: 30Common causes: slow Rekor or registry, cluster egress to the Internet filtered by a proxy.
Policy has no effect
A policy that lets everything through almost always signals that the
webhook is no longer registered, that the admission controller is not
responding, or that the images do not match the glob pattern of
matchImageReferences. These three commands check Kyverno's Pods, the
webhook registration, then the controller logs.
kubectl get pods -n kyvernokubectl get validatingwebhookconfigurations | grep kyvernokubectl logs -n kyverno -l app.kubernetes.io/component=admission-controller --tail=100Key points
- A provenance protects nothing without a decision: you verify in CI and at admission.
- SLSA 1.2 distinguishes Build L0-L3 and Source L1-L4; a GitHub attestation alone reaches Build L2, an isolated generator aims for Build L3.
slsa-verifier verify-imagerequires a digest: a tag is refused as mutable.- The signer of an
slsa-github-generatorprovenance is the generator, not the project's workflow: the policy must expect it. - With Kyverno, the validation calls
verifyAttestationSignatures(), andextractPayload()returns the whole in-toto statement (.predicate). - A static pod starts without admission: watch
kubernetes.io/config.source: fileoutsidekube-system. - PolicyException objects are disabled by default and never expire on their own.
- The evidence expected by SOCLE-REL-PRV-1 is a provenance describing builder, source and parameters; SOCLE-REL-VER-1 expects its verification before use.
Knowledge check
Check that the essentials of this lesson are understood. The questions only cover what has just been explained here.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Package manager attacks in CI/CD
Continue the course: Pulp: the 100% open source artifact manager
A new full quiz with random questions
Work again only on the questions you missed