feat: judge, sign and verify netOrk's images in one place (NetOrk/netork#1390)
CI / Tests, lint (push) Successful in 18s
CI / Tests, lint (push) Successful in 18s
The CIS gate, the SBOM, the cosign signature and the matcher's verdict lived in NetOrk/base-os, the head of the chain. Every link below it -- base-python, engine, ui, satellite, the service images -- needs the same, so the tools move here, and every image repository pins them by commit. - image_tools: cis.py, cis_os.sh, sbom.py and verify.py from base-os. The image's own repository holds its cis.toml and packages.allow; both default to the working directory. - bin/sign.sh signs whatever platforms a reference holds: base-os's index of amd64 and arm64 (EXPECT_PLATFORMS) or a dev image's single manifest (image_tools/platforms.py). - bin/verify-base.sh checks every FROM image of a Dockerfile against cosign.pub before a build; one without a digest is refused (image_tools/dockerfile.py). - bin/install-cosign.sh installs cosign v3.1.3 for amd64 and arm64 runners, checked against the release checksums. - actions/verify-base, judge, sign wrap them for the workflows. - cosign.pub is the public half of the COSIGN_PRIVATE_KEY_B64 org secret, the key rotated on 2026-10-22.
This commit is contained in:
commit
d139d66c3d
27 files changed
+2085
No files matched your search
@@ -0,0 +1,26 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['main']
|
||||||
|
pull_request:
|
||||||
|
branches: ['**']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Tests, lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
- name: Install
|
||||||
|
# Pinned: a newer ruff or shellcheck can turn an untouched tree red.
|
||||||
|
run: pip install pytest==8.4.2 ruff==0.14.0 pyyaml==6.0.2 shellcheck-py==0.11.0.1
|
||||||
|
- name: ruff
|
||||||
|
run: ruff format --check . && ruff check .
|
||||||
|
- name: shellcheck
|
||||||
|
run: shellcheck bin/*.sh image_tools/cis_os.sh
|
||||||
|
- name: pytest
|
||||||
|
run: pytest -q
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
build/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Christian Manivong
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# image-tools
|
||||||
|
|
||||||
|
Judges, signs and verifies the container images of [netOrk](https://git.netork.io/NetOrk/netork).
|
||||||
|
Every image repository of NetOrk uses these tools at a pinned commit, so the same rules apply
|
||||||
|
to every link of the chain:
|
||||||
|
|
||||||
|
```
|
||||||
|
netork/base-os ──► netork/base-python ──► netork/engine, netork/satellite
|
||||||
|
└─► netork/ui
|
||||||
|
└─► postgres, valkey, signal-api, apt-cacher-ng, unbound
|
||||||
|
```
|
||||||
|
|
||||||
|
Each link checks the signature of the image it builds on, is judged against CIS, and is signed
|
||||||
|
with its CIS result and SBOM attached (NetOrk/netork#1390).
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **CIS gate** (`image_tools/cis.py`) | CIS Docker Benchmark 1.6.0 §4 and the CIS Debian Linux 12 controls that make sense in a container. Trivy measures what it automates; `image_tools/cis_os.sh` measures from inside the image. The image's repository decides the rest in its `cis.toml` and names the packages it may hold in `packages.allow`. |
|
||||||
|
| **SBOM** (`image_tools/sbom.py`) | The image's CycloneDX SBOM, as Trivy reads it. |
|
||||||
|
| **Signature** (`bin/sign.sh`) | A cosign signature on the reference and on every platform image behind it, with the CIS result and the SBOM as in-toto attestations. Nothing goes to a public transparency log. |
|
||||||
|
| **Verification** (`bin/verify-base.sh`, `bin/verify-signature.sh`) | Every `FROM` image of a Dockerfile is pinned by digest and signed with the key in `cosign.pub`. |
|
||||||
|
| **Verdict** (`image_tools/verify.py`) | Whether a rebuild improves on the pinned image, by netOrk's matcher (kb-server), for a base's nightly. |
|
||||||
|
|
||||||
|
## Using it in a workflow
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Verify the base images
|
||||||
|
uses: https://git.netork.io/NetOrk/image-tools/actions/verify-base@<commit>
|
||||||
|
with:
|
||||||
|
dockerfiles: docker/engine/Dockerfile
|
||||||
|
|
||||||
|
# build the image, then:
|
||||||
|
- name: Judge it
|
||||||
|
uses: https://git.netork.io/NetOrk/image-tools/actions/judge@<commit>
|
||||||
|
with:
|
||||||
|
image: registry.netork.io/netork/engine:main-abc1234
|
||||||
|
arch: amd64
|
||||||
|
|
||||||
|
# push it, then:
|
||||||
|
- name: Sign it
|
||||||
|
uses: https://git.netork.io/NetOrk/image-tools/actions/sign@<commit>
|
||||||
|
env:
|
||||||
|
COSIGN_PRIVATE_KEY_B64: ${{ secrets.COSIGN_PRIVATE_KEY_B64 }}
|
||||||
|
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||||
|
with:
|
||||||
|
ref: registry.netork.io/netork/engine@sha256:…
|
||||||
|
```
|
||||||
|
|
||||||
|
The judge action reads `cis.toml` and `packages.allow` from the working directory by default.
|
||||||
|
The runner needs Docker, Python 3.11 or newer, and `curl`; cosign is installed by the actions,
|
||||||
|
pinned and checked against its release checksum (`bin/install-cosign.sh`).
|
||||||
|
|
||||||
|
## Verifying an image yourself
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cosign verify --key cosign.pub --insecure-ignore-tlog registry.netork.io/netork/base-os@sha256:…
|
||||||
|
cosign verify-attestation --key cosign.pub --insecure-ignore-tlog \
|
||||||
|
--type https://netork.io/attestations/cis/v1 registry.netork.io/netork/base-os@sha256:…
|
||||||
|
```
|
||||||
|
|
||||||
|
`cosign.pub` is the public half of the `COSIGN_PRIVATE_KEY_B64` organization secret. The private
|
||||||
|
key comes base64 in one line: the runner masks a secret only where it appears whole.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pytest ruff pyyaml shellcheck-py
|
||||||
|
pytest -q
|
||||||
|
ruff format --check . && ruff check .
|
||||||
|
shellcheck bin/*.sh image_tools/cis_os.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT, see [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: Judge an image
|
||||||
|
description: >-
|
||||||
|
The CIS gate (image_tools/cis.py) and the CycloneDX SBOM (image_tools/sbom.py)
|
||||||
|
of one platform image, kept as <out>/cis-<arch>.json, cis-<arch>.md and
|
||||||
|
sbom-<arch>.cdx.json for bin/sign.sh. The step fails when CIS is red.
|
||||||
|
inputs:
|
||||||
|
image:
|
||||||
|
description: The image to judge, as Docker can run it (local tag or reference)
|
||||||
|
required: true
|
||||||
|
arch:
|
||||||
|
description: Its platform, amd64 or arm64
|
||||||
|
required: true
|
||||||
|
decisions:
|
||||||
|
description: The image repository's CIS decisions
|
||||||
|
default: cis.toml
|
||||||
|
packages:
|
||||||
|
description: The packages the image may hold
|
||||||
|
default: packages.allow
|
||||||
|
out:
|
||||||
|
description: Where the results go
|
||||||
|
default: attest
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: CIS
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
IMAGE: ${{ inputs.image }}
|
||||||
|
ARCH: ${{ inputs.arch }}
|
||||||
|
DECISIONS: ${{ inputs.decisions }}
|
||||||
|
PACKAGES: ${{ inputs.packages }}
|
||||||
|
OUT: ${{ inputs.out }}
|
||||||
|
PYTHONPATH: ${{ github.action_path }}/../..
|
||||||
|
run: |
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
python3 -m image_tools.cis "$IMAGE" --decisions "$DECISIONS" --packages "$PACKAGES" \
|
||||||
|
--json "$OUT/cis-$ARCH.json" --report "$OUT/cis-$ARCH.md"
|
||||||
|
- name: SBOM
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
IMAGE: ${{ inputs.image }}
|
||||||
|
ARCH: ${{ inputs.arch }}
|
||||||
|
OUT: ${{ inputs.out }}
|
||||||
|
PYTHONPATH: ${{ github.action_path }}/../..
|
||||||
|
run: python3 -m image_tools.sbom "$IMAGE" "$OUT/sbom-$ARCH.cdx.json"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: Sign an image
|
||||||
|
description: >-
|
||||||
|
Sign a pushed image and every platform image behind it with netOrk's key, and
|
||||||
|
attach what each was judged by (bin/sign.sh). The caller passes the secrets
|
||||||
|
COSIGN_PRIVATE_KEY_B64 and COSIGN_PASSWORD as env of this step.
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: The pushed image, <image>@<digest>
|
||||||
|
required: true
|
||||||
|
dir:
|
||||||
|
description: The judge action's results, cis-<arch>.json and sbom-<arch>.cdx.json
|
||||||
|
default: attest
|
||||||
|
expect-platforms:
|
||||||
|
description: The platforms the reference must hold, e.g. "amd64 arm64"; empty takes what is there
|
||||||
|
default: ""
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Install cosign
|
||||||
|
shell: bash
|
||||||
|
run: bash "${{ github.action_path }}/../../bin/install-cosign.sh"
|
||||||
|
- name: Sign and attest
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REF: ${{ inputs.ref }}
|
||||||
|
DIR: ${{ inputs.dir }}
|
||||||
|
EXPECT_PLATFORMS: ${{ inputs.expect-platforms }}
|
||||||
|
run: bash "${{ github.action_path }}/../../bin/sign.sh" "$REF" "$DIR"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
name: Verify the base images
|
||||||
|
description: >-
|
||||||
|
Every image the Dockerfiles build on is signed with netOrk's key, checked
|
||||||
|
before the build (NetOrk/netork#1390, bin/verify-base.sh).
|
||||||
|
inputs:
|
||||||
|
dockerfiles:
|
||||||
|
description: The Dockerfiles, separated by spaces, relative to the workspace
|
||||||
|
required: true
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Install cosign
|
||||||
|
shell: bash
|
||||||
|
run: bash "${{ github.action_path }}/../../bin/install-cosign.sh"
|
||||||
|
- name: Verify
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
DOCKERFILES: ${{ inputs.dockerfiles }}
|
||||||
|
# shellcheck disable=SC2086 # the list splits on purpose
|
||||||
|
run: bash "${{ github.action_path }}/../../bin/verify-base.sh" $DOCKERFILES
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# install-cosign.sh [dir]: cosign, pinned, checked against its release checksum.
|
||||||
|
#
|
||||||
|
# Bump the version and both checksums together, from the release's
|
||||||
|
# cosign_checksums.txt.
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION=v3.1.3
|
||||||
|
SHA256_AMD64=4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71
|
||||||
|
SHA256_ARM64=c5d324e091826b0d7a78eb16fef316450b4eb9aaec045611c08ba06f5e73220a
|
||||||
|
dir=${1:-/usr/local/bin}
|
||||||
|
|
||||||
|
case $(uname -m) in
|
||||||
|
x86_64) arch=amd64 sha=$SHA256_AMD64 ;;
|
||||||
|
aarch64) arch=arm64 sha=$SHA256_ARM64 ;;
|
||||||
|
*) echo "no cosign checksum for $(uname -m)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
if [ -x "$dir/cosign" ] && echo "$sha $dir/cosign" | sha256sum -c - >/dev/null 2>&1; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
tmp=$(mktemp)
|
||||||
|
curl -fsSLo "$tmp" "https://github.com/sigstore/cosign/releases/download/$VERSION/cosign-linux-$arch"
|
||||||
|
echo "$sha $tmp" | sha256sum -c - >/dev/null
|
||||||
|
install -m 0755 "$tmp" "$dir/cosign"
|
||||||
|
rm -f "$tmp"
|
||||||
|
echo "cosign $VERSION ($arch) in $dir"
|
||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# sign.sh <image>@<digest> <dir>: sign a published image and attach what it was judged by.
|
||||||
|
#
|
||||||
|
# The reference and every platform image behind it get a cosign signature made
|
||||||
|
# with the key in COSIGN_PRIVATE_KEY_B64 (its password in COSIGN_PASSWORD). Each
|
||||||
|
# platform image gets its CIS result (<dir>/cis-<arch>.json, image_tools/cis.py)
|
||||||
|
# and its CycloneDX SBOM (<dir>/sbom-<arch>.cdx.json, image_tools/sbom.py) as
|
||||||
|
# in-toto attestations. This takes the place of Docker Content Trust (CIS Docker
|
||||||
|
# 4.5), as cosign does for Docker's Hardened Images.
|
||||||
|
#
|
||||||
|
# The platforms are whatever the reference holds (image_tools/platforms.py): an
|
||||||
|
# index of amd64 and arm64, or one manifest. EXPECT_PLATFORMS ("amd64 arm64")
|
||||||
|
# makes any other set an error.
|
||||||
|
#
|
||||||
|
# Nothing goes to a public transparency log: cosign-signing.json names none, so
|
||||||
|
# the names in this registry stay inside. Everything is verified against
|
||||||
|
# cosign.pub before it returns.
|
||||||
|
set -euo pipefail
|
||||||
|
: "${COSIGN_PRIVATE_KEY_B64:?is empty: the secret is missing}" "${COSIGN_PASSWORD:?is empty: the secret is missing}"
|
||||||
|
# The key comes base64 in one line: the runner prints a step's env and masks a
|
||||||
|
# secret only where it appears whole, and a PEM key's lines did not (2026-10-22).
|
||||||
|
COSIGN_PRIVATE_KEY=$(printf '%s' "$COSIGN_PRIVATE_KEY_B64" | base64 -d)
|
||||||
|
export COSIGN_PRIVATE_KEY
|
||||||
|
ref=$1
|
||||||
|
dir=$(cd "$2" && pwd)
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
CIS_TYPE=https://netork.io/attestations/cis/v1
|
||||||
|
# quietly <command>: cosign's chatter only when it fails.
|
||||||
|
quietly() { local out; out=$("$@" 2>&1) || { echo "$out" >&2; return 1; }; }
|
||||||
|
|
||||||
|
case $ref in *@sha256:*) ;; *) echo "$ref is not pinned by digest" >&2; exit 1 ;; esac
|
||||||
|
image=${ref%@*}
|
||||||
|
case ${image##*/} in *:*) image=${image%:*} ;; esac # the tag, not a registry port
|
||||||
|
|
||||||
|
quietly cosign sign --yes --recursive --key env://COSIGN_PRIVATE_KEY \
|
||||||
|
--signing-config cosign-signing.json "$ref"
|
||||||
|
quietly cosign verify --key cosign.pub --insecure-ignore-tlog "$ref"
|
||||||
|
|
||||||
|
raw=$(mktemp) config=$(mktemp)
|
||||||
|
trap 'rm -f "$raw" "$config"' EXIT
|
||||||
|
docker buildx imagetools inspect "$ref" --raw >"$raw"
|
||||||
|
docker buildx imagetools inspect "$ref" --format '{{json .Image}}' >"$config"
|
||||||
|
platforms=$(python3 -m image_tools.platforms "${ref#*@}" "$raw" "$config")
|
||||||
|
if [ -n "${EXPECT_PLATFORMS:-}" ]; then
|
||||||
|
got=$(echo "$platforms" | cut -d' ' -f1 | sort | xargs)
|
||||||
|
want=$(echo "$EXPECT_PLATFORMS" | tr ' ' '\n' | sort | xargs)
|
||||||
|
[ "$got" = "$want" ] || { echo "expected $want in $ref, found $got" >&2; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
while read -r arch digest; do
|
||||||
|
for attestation in "$CIS_TYPE cis-$arch.json" "cyclonedx sbom-$arch.cdx.json"; do
|
||||||
|
read -r type file <<<"$attestation"
|
||||||
|
quietly cosign attest --yes --key env://COSIGN_PRIVATE_KEY --signing-config cosign-signing.json \
|
||||||
|
--type "$type" --predicate "$dir/$file" "$image@$digest"
|
||||||
|
quietly cosign verify-attestation --key cosign.pub --insecure-ignore-tlog \
|
||||||
|
--type "$type" "$image@$digest"
|
||||||
|
done
|
||||||
|
echo "signed and attested: $image@$digest ($arch)"
|
||||||
|
done <<<"$platforms"
|
||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# verify-base.sh <Dockerfile>...: every image the files build on is signed with
|
||||||
|
# netOrk's key, checked before the build (NetOrk/netork#1390).
|
||||||
|
#
|
||||||
|
# The images are the FROM lines' references (image_tools/dockerfile.py): stages
|
||||||
|
# of the same file and scratch are none, and one without a digest is refused.
|
||||||
|
set -euo pipefail
|
||||||
|
tools=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
[ $# -gt 0 ] || { echo "usage: verify-base.sh <Dockerfile>..." >&2; exit 2; }
|
||||||
|
files=()
|
||||||
|
for f in "$@"; do files+=("$(cd "$(dirname "$f")" && pwd)/$(basename "$f")"); done
|
||||||
|
mapfile -t bases < <(cd "$tools" && python3 -m image_tools.dockerfile "${files[@]}")
|
||||||
|
[ ${#bases[@]} -gt 0 ] || { echo "no image to verify in $*" >&2; exit 1; }
|
||||||
|
"$tools/bin/verify-signature.sh" "${bases[@]}"
|
||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# verify-signature.sh <image>@<digest>...: each one is signed with netOrk's key.
|
||||||
|
#
|
||||||
|
# The key is cosign.pub beside this script's repository, the one public half of
|
||||||
|
# the COSIGN_PRIVATE_KEY_B64 secret every image of NetOrk is signed with. A
|
||||||
|
# reference without a digest is refused: a tag can move after the check.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
[ $# -gt 0 ] || { echo "usage: verify-signature.sh <image>@<digest>..." >&2; exit 2; }
|
||||||
|
for ref in "$@"; do
|
||||||
|
case $ref in *@sha256:*) ;; *) echo "$ref is not pinned by digest" >&2; exit 1 ;; esac
|
||||||
|
if ! out=$(cosign verify --key cosign.pub --insecure-ignore-tlog "$ref" 2>&1); then
|
||||||
|
echo "$out" >&2
|
||||||
|
echo "not signed with netOrk's key: $ref" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "verified: $ref"
|
||||||
|
done
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"mediaType": "application/vnd.dev.sigstore.signingconfig.v0.2+json",
|
||||||
|
"caUrls": [],
|
||||||
|
"oidcUrls": [],
|
||||||
|
"rekorTlogUrls": [],
|
||||||
|
"tsaUrls": [],
|
||||||
|
"rekorTlogConfig": {"selector": "ANY"},
|
||||||
|
"tsaConfig": {"selector": "ANY"}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE6CqcdpadZWHM2pmd2QgmXAUSPX6u
|
||||||
|
ih4JMspCDo+hye3cccnVZx1fNlyyP0NRQhLlEk+czT5eX9HqPnNFD84i4Q==
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
Whitespace-only changes.
@@ -0,0 +1,297 @@
|
|||||||
|
"""Is the image what CIS asks of it? (CIS Docker Benchmark 1.6.0 §4, CIS Debian Linux 12)
|
||||||
|
|
||||||
|
Two measurements, one verdict:
|
||||||
|
|
||||||
|
- Trivy's ``docker-cis-1.6.0`` spec, for the controls it automates (4.1, 4.4,
|
||||||
|
4.6, 4.7, 4.9, 4.10); the rest it calls "Manual";
|
||||||
|
- ``image_tools/cis_os.sh``, run inside the image as root, for what of that can be
|
||||||
|
seen from inside (4.3, 4.8, 4.11) and the CIS Debian controls that make sense
|
||||||
|
in a container.
|
||||||
|
|
||||||
|
The image's own repository decides the rest in ``cis.toml``: ``[[manual]]`` is
|
||||||
|
met by how the image is made, ``[[exception]]`` fails on purpose, with a reason,
|
||||||
|
until it expires. ``packages.allow`` there names the packages it may hold. The gate is red when a control fails without an exception, an
|
||||||
|
exception has expired, a control is neither measured nor decided, a decision
|
||||||
|
names a control nothing knows, or a measurement could not be taken.
|
||||||
|
|
||||||
|
python -m image_tools.cis <ref> [--decisions cis.toml] [--packages packages.allow] \
|
||||||
|
--json cis.json --report cis.md
|
||||||
|
|
||||||
|
Both files default to the working directory: the image's repository.
|
||||||
|
|
||||||
|
Standard library only; Trivy runs as the container image_tools/verify.py pins.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import tomllib
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from image_tools.verify import TIMEOUT, TRIVY_IMAGE
|
||||||
|
|
||||||
|
OS_CHECKS = Path(__file__).with_name("cis_os.sh")
|
||||||
|
SPEC = "docker-cis-1.6.0"
|
||||||
|
|
||||||
|
PASS, FAIL, UNMEASURED = "pass", "fail", "unmeasured"
|
||||||
|
#: What the gate makes of a control; the first three keep it green.
|
||||||
|
GREEN = frozenset({"pass", "manual", "excepted"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Control:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
result: str
|
||||||
|
detail: str = ""
|
||||||
|
state: str = ""
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Decision:
|
||||||
|
kind: str
|
||||||
|
reason: str
|
||||||
|
expires: datetime.date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Report:
|
||||||
|
ok: bool
|
||||||
|
controls: list[Control] = field(default_factory=list)
|
||||||
|
stale: list[str] = field(default_factory=list)
|
||||||
|
unknown: list[str] = field(default_factory=list)
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
def to_json(self) -> str:
|
||||||
|
return json.dumps(asdict(self), indent=2, default=str)
|
||||||
|
|
||||||
|
def failing(self) -> set[str]:
|
||||||
|
return {c.id for c in self.controls if c.result == FAIL}
|
||||||
|
|
||||||
|
|
||||||
|
def from_trivy(summary: dict) -> list[Control]:
|
||||||
|
"""Trivy's compliance summary: no ``TotalFail`` means it did not measure."""
|
||||||
|
controls = []
|
||||||
|
for c in summary.get("SummaryControls") or []:
|
||||||
|
fails = c.get("TotalFail")
|
||||||
|
result = UNMEASURED if fails is None else (FAIL if fails else PASS)
|
||||||
|
detail = f"{fails} failed (Trivy)" if fails else ""
|
||||||
|
controls.append(Control(f"docker:{c['ID']}", c.get("Name", ""), result, detail))
|
||||||
|
return controls
|
||||||
|
|
||||||
|
|
||||||
|
def from_os(text: str) -> list[Control]:
|
||||||
|
"""cis_os.sh's lines: id, PASS|FAIL, title, what fails."""
|
||||||
|
controls = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
cid, result, title, detail = (line.split("\t") + ["", "", ""])[:4]
|
||||||
|
if result not in ("PASS", "FAIL"):
|
||||||
|
raise ValueError(f"cis_os.sh said: {line!r}")
|
||||||
|
controls.append(Control(cid, title, result.lower(), detail))
|
||||||
|
return controls
|
||||||
|
|
||||||
|
|
||||||
|
def merge(*sources: list[Control]) -> list[Control]:
|
||||||
|
"""One control per id, in order of first appearance; a failure wins, then a pass."""
|
||||||
|
rank = {FAIL: 2, PASS: 1, UNMEASURED: 0}
|
||||||
|
merged: dict[str, Control] = {}
|
||||||
|
for controls in sources:
|
||||||
|
for c in controls:
|
||||||
|
known = merged.get(c.id)
|
||||||
|
if known is None or rank[c.result] > rank[known.result]:
|
||||||
|
if known is not None and not c.title:
|
||||||
|
c.title = known.title
|
||||||
|
merged[c.id] = c
|
||||||
|
return list(merged.values())
|
||||||
|
|
||||||
|
|
||||||
|
def load_decisions(text: str) -> dict[str, Decision]:
|
||||||
|
data = tomllib.loads(text)
|
||||||
|
decisions = {}
|
||||||
|
for kind in ("manual", "exception"):
|
||||||
|
for entry in data.get(kind) or []:
|
||||||
|
if entry["id"] in decisions:
|
||||||
|
raise ValueError(f"{entry['id']} is decided twice")
|
||||||
|
if not entry.get("reason", "").strip():
|
||||||
|
raise ValueError(f"{entry['id']} has no reason")
|
||||||
|
if not isinstance(entry.get("expires", datetime.date.min), datetime.date):
|
||||||
|
raise ValueError(f"{entry['id']}: expires is a date, not {entry['expires']!r}")
|
||||||
|
decisions[entry["id"]] = Decision(kind, entry["reason"], entry.get("expires"))
|
||||||
|
return decisions
|
||||||
|
|
||||||
|
|
||||||
|
def judge(controls: list[Control], decisions: dict[str, Decision], today: datetime.date) -> Report:
|
||||||
|
"""The gate on the measured *controls* and what ``cis.toml`` decided."""
|
||||||
|
stale = []
|
||||||
|
for c in controls:
|
||||||
|
d = decisions.get(c.id)
|
||||||
|
c.reason = d.reason if d else ""
|
||||||
|
if d and d.kind == "manual":
|
||||||
|
c.state = "manual"
|
||||||
|
elif c.result == PASS:
|
||||||
|
c.state = "pass"
|
||||||
|
if d:
|
||||||
|
stale.append(c.id)
|
||||||
|
elif d and d.expires and d.expires < today:
|
||||||
|
c.state = "expired"
|
||||||
|
elif d:
|
||||||
|
c.state = "excepted"
|
||||||
|
else:
|
||||||
|
c.state = "fail" if c.result == FAIL else "undecided"
|
||||||
|
unknown = sorted(set(decisions) - {c.id for c in controls})
|
||||||
|
ok = bool(controls) and not unknown and all(c.state in GREEN for c in controls)
|
||||||
|
return Report(ok, controls, stale, unknown)
|
||||||
|
|
||||||
|
|
||||||
|
def measure(
|
||||||
|
ref: str,
|
||||||
|
packages: list[str],
|
||||||
|
*,
|
||||||
|
trivy: Callable[[str], dict] | None = None,
|
||||||
|
os_checks: Callable[[str, list[str]], str] | None = None,
|
||||||
|
) -> list[Control]:
|
||||||
|
trivy = trivy or trivy_compliance
|
||||||
|
os_checks = os_checks or run_os_checks
|
||||||
|
return merge(from_trivy(trivy(ref)), from_os(os_checks(ref, packages)))
|
||||||
|
|
||||||
|
|
||||||
|
def check(
|
||||||
|
ref: str,
|
||||||
|
decisions: dict[str, Decision],
|
||||||
|
packages: list[str],
|
||||||
|
today: datetime.date,
|
||||||
|
**seams,
|
||||||
|
) -> Report:
|
||||||
|
"""Measure *ref* and judge it; never raises."""
|
||||||
|
try:
|
||||||
|
return judge(measure(ref, packages, **seams), decisions, today)
|
||||||
|
except (OSError, ValueError, KeyError, subprocess.SubprocessError) as exc:
|
||||||
|
return Report(False, error=f"{type(exc).__name__}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
STATE_MARK = {
|
||||||
|
"pass": "✅ pass",
|
||||||
|
"manual": "📝 manual",
|
||||||
|
"excepted": "⚠️ exception",
|
||||||
|
"fail": "❌ fail",
|
||||||
|
"expired": "❌ exception expired",
|
||||||
|
"undecided": "❌ not measured, not decided",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render(report: Report, ref: str) -> str:
|
||||||
|
head = "**CIS: green**" if report.ok else "**CIS: red**"
|
||||||
|
lines = [f"{head} for `{ref}` (CIS Docker 1.6.0 §4, CIS Debian Linux 12).", ""]
|
||||||
|
if report.error:
|
||||||
|
lines += [f"The measurement could not be taken: {report.error}", ""]
|
||||||
|
if report.unknown:
|
||||||
|
lines += [f"Decided in cis.toml but measured by nothing: {', '.join(report.unknown)}", ""]
|
||||||
|
if report.stale:
|
||||||
|
lines += [f"Exceptions for controls that pass now: {', '.join(report.stale)}", ""]
|
||||||
|
if report.controls:
|
||||||
|
lines += ["| Control | | Title | Detail |", "|---|---|---|---|"]
|
||||||
|
for c in report.controls:
|
||||||
|
detail = c.reason if c.state in ("manual", "excepted") else c.detail
|
||||||
|
detail = detail.replace("|", "\\|").replace("\n", " ")
|
||||||
|
lines.append(f"| {c.id} | {STATE_MARK.get(c.state, c.state)} | {c.title} | {detail} |")
|
||||||
|
return "\n".join(lines).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def load_packages(text: str) -> list[str]:
|
||||||
|
return [ln.strip() for ln in text.splitlines() if ln.strip() and not ln.startswith("#")]
|
||||||
|
|
||||||
|
|
||||||
|
def trivy_compliance(ref: str) -> dict: # pragma: no cover - container seam
|
||||||
|
"""Trivy's summary of *ref* against the spec, with the checks it was released with."""
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"-v",
|
||||||
|
"/var/run/docker.sock:/var/run/docker.sock",
|
||||||
|
"-e",
|
||||||
|
"TRIVY_USERNAME",
|
||||||
|
"-e",
|
||||||
|
"TRIVY_PASSWORD",
|
||||||
|
TRIVY_IMAGE,
|
||||||
|
"image",
|
||||||
|
"--quiet",
|
||||||
|
"--disable-telemetry",
|
||||||
|
"--skip-version-check",
|
||||||
|
"--skip-check-update",
|
||||||
|
# 4.4 is the matcher's (cis.toml); no vulnerability DB to fetch
|
||||||
|
"--scanners",
|
||||||
|
"misconfig,secret",
|
||||||
|
"--compliance",
|
||||||
|
SPEC,
|
||||||
|
"--format",
|
||||||
|
"json",
|
||||||
|
ref,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return json.loads(proc.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def run_os_checks(ref: str, packages: list[str]) -> str: # pragma: no cover - container seam
|
||||||
|
"""cis_os.sh inside *ref*, as root, fed on stdin: nothing to mount."""
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"-i",
|
||||||
|
"--user",
|
||||||
|
"0",
|
||||||
|
"--network",
|
||||||
|
"none",
|
||||||
|
"-e",
|
||||||
|
"CIS_PACKAGES=" + " ".join(packages),
|
||||||
|
"--entrypoint",
|
||||||
|
"sh",
|
||||||
|
ref,
|
||||||
|
"-s",
|
||||||
|
],
|
||||||
|
input=OS_CHECKS.read_text(),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return proc.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int: # pragma: no cover - CLI seam
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("ref")
|
||||||
|
parser.add_argument("--decisions", default="cis.toml")
|
||||||
|
parser.add_argument("--packages", default="packages.allow")
|
||||||
|
parser.add_argument("--json")
|
||||||
|
parser.add_argument("--report")
|
||||||
|
args = parser.parse_args()
|
||||||
|
decisions = load_decisions(Path(args.decisions).read_text())
|
||||||
|
packages = load_packages(Path(args.packages).read_text())
|
||||||
|
report = check(args.ref, decisions, packages, datetime.date.today())
|
||||||
|
text = render(report, args.ref)
|
||||||
|
if args.json:
|
||||||
|
Path(args.json).write_text(report.to_json())
|
||||||
|
if args.report:
|
||||||
|
Path(args.report).write_text(text)
|
||||||
|
print(text)
|
||||||
|
return 0 if report.ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# cis_os.sh: the CIS controls image_tools/cis.py measures from inside the image.
|
||||||
|
#
|
||||||
|
# Runs as root in the image, fed on stdin (docker run -i --user 0 ... sh -s), so
|
||||||
|
# nothing has to be mounted from a runner whose paths the host daemon does not
|
||||||
|
# know. Reads nothing from outside but CIS_PACKAGES, the package names the image
|
||||||
|
# may hold. Prints one line per control:
|
||||||
|
#
|
||||||
|
# <id> TAB PASS|FAIL TAB <title> TAB <what fails>
|
||||||
|
#
|
||||||
|
# docker:* are the CIS Docker Benchmark 1.6.0 controls Trivy leaves to "Manual"
|
||||||
|
# and that can be seen from inside; debian:* are the CIS Debian Linux 12 v1.1.0
|
||||||
|
# controls that make sense in a container. Boot, partitions, kernel parameters,
|
||||||
|
# services, logging, audit and sshd belong to the host (infrastructure
|
||||||
|
# BAUSTANDARD §F) and are not measured here.
|
||||||
|
set -u
|
||||||
|
|
||||||
|
# check <id> <title> <function>: PASS when the function names no offender.
|
||||||
|
check() {
|
||||||
|
out=$("$3" 2>/dev/null | head -n 20 | tr '\n' ' ')
|
||||||
|
if [ -z "$out" ]; then
|
||||||
|
printf '%s\tPASS\t%s\t\n' "$1" "$2"
|
||||||
|
else
|
||||||
|
printf '%s\tFAIL\t%s\t%s\n' "$1" "$2" "${out% }"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
login_defs() { awk -v k="$1" '$1 == k { v = $2 } END { print v }' /etc/login.defs; }
|
||||||
|
|
||||||
|
# Shells one can log in with: /etc/shells without the nologin ones.
|
||||||
|
SHELLS=$(grep '^/' /etc/shells | grep -v nologin)
|
||||||
|
|
||||||
|
# --- CIS Docker Benchmark 1.6.0, section 4 -----------------------------------
|
||||||
|
|
||||||
|
extra_packages() {
|
||||||
|
dpkg-query -W -f '${Package}\n' | while read -r p; do
|
||||||
|
case " ${CIS_PACKAGES:-} " in *" $p "*) ;; *) echo "$p" ;; esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
setid_files() { find / -xdev -type f -perm /6000; }
|
||||||
|
|
||||||
|
unverified_sources() {
|
||||||
|
grep -rEil 'trusted=yes|Trusted: *yes|AllowUnauthenticated|AllowInsecureRepositories|AllowDowngradeToInsecureRepositories' /etc/apt
|
||||||
|
for f in /etc/apt/sources.list.d/*.sources; do
|
||||||
|
[ -e "$f" ] || continue
|
||||||
|
awk -v f="$f" 'BEGIN { RS = "" } index($0, "Signed-By:") == 0 { print f }' "$f"
|
||||||
|
done
|
||||||
|
grep -sE '^[[:space:]]*deb[[:space:]]' /etc/apt/sources.list /etc/apt/sources.list.d/*.list \
|
||||||
|
| grep -v 'signed-by='
|
||||||
|
}
|
||||||
|
|
||||||
|
check docker:4.3 "Unnecessary packages are not installed (packages.allow)" extra_packages
|
||||||
|
check docker:4.8 "setuid and setgid permissions are removed" setid_files
|
||||||
|
check docker:4.11 "Only verified packages are installed" unverified_sources
|
||||||
|
|
||||||
|
# --- CIS Debian Linux 12 v1.1.0 ----------------------------------------------
|
||||||
|
|
||||||
|
# in_range <key> <min> [max]: names the key unless min <= its value <= max.
|
||||||
|
in_range() {
|
||||||
|
v=$(login_defs "$1")
|
||||||
|
case $v in '' | *[!0-9]*) echo "$1=${v:-unset}"; return ;; esac
|
||||||
|
if [ "$v" -lt "$2" ] || [ "$v" -gt "${3:-$v}" ]; then echo "$1=$v"; fi
|
||||||
|
}
|
||||||
|
pass_max_days() { in_range PASS_MAX_DAYS 1 365; }
|
||||||
|
pass_min_days() { in_range PASS_MIN_DAYS 1; }
|
||||||
|
pass_warn_age() { in_range PASS_WARN_AGE 7; }
|
||||||
|
weak_hashing() {
|
||||||
|
v=$(login_defs ENCRYPT_METHOD)
|
||||||
|
case $v in SHA512 | YESCRYPT) ;; *) echo "ENCRYPT_METHOD=${v:-unset}" ;; esac
|
||||||
|
}
|
||||||
|
weak_umask() {
|
||||||
|
v=$(login_defs UMASK)
|
||||||
|
case $v in '' | *[!0-7]*) echo "UMASK=${v:-unset}"; return ;; esac
|
||||||
|
# 027 or stricter: group may not write, others get nothing
|
||||||
|
if [ $(((0$v >> 3) & 2)) -eq 0 ] || [ $((0$v & 7)) -ne 7 ]; then echo "UMASK=$v"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
other_uid0() { awk -F: '$3 == "0" && $1 != "root" { print $1 }' /etc/passwd; }
|
||||||
|
other_gid0_accounts() {
|
||||||
|
awk -F: '$4 == "0" && $1 !~ /^(root|sync|shutdown|halt|operator)$/ { print $1 }' /etc/passwd
|
||||||
|
}
|
||||||
|
other_gid0_groups() { awk -F: '$3 == "0" && $1 != "root" { print $1 }' /etc/group; }
|
||||||
|
root_unlocked() { awk -F: '$1 == "root" && $2 !~ /^[!*]/ { print "root" }' /etc/shadow; }
|
||||||
|
system_login_shells() {
|
||||||
|
min=$(login_defs UID_MIN)
|
||||||
|
echo "$SHELLS" | awk -F: -v min="${min:-1000}" '
|
||||||
|
NR == FNR { if ($0 != "") shell[$0] = 1; next }
|
||||||
|
$1 != "root" && ($3 + 0 < min + 0 || $3 == "65534") && ($7 in shell) { print $1 }
|
||||||
|
' - /etc/passwd
|
||||||
|
}
|
||||||
|
unlocked_without_shell() {
|
||||||
|
echo "$SHELLS" | awk -F: '
|
||||||
|
FILENAME == "-" { if ($0 != "") shell[$0] = 1; next }
|
||||||
|
FILENAME == "/etc/passwd" { if ($1 != "root" && !($7 in shell)) nologin[$1] = 1; next }
|
||||||
|
($1 in nologin) && $2 !~ /^[!*]/ { print $1 }
|
||||||
|
' - /etc/passwd /etc/shadow
|
||||||
|
}
|
||||||
|
|
||||||
|
# too_open <file> <max mode> <owner> <group regex>: names the file when it is
|
||||||
|
# more open than the mode allows or owned by someone else; a missing file is fine.
|
||||||
|
too_open() {
|
||||||
|
[ -e "$1" ] || return 0
|
||||||
|
# shellcheck disable=SC2046
|
||||||
|
set -- "$1" "$2" "$3" "$4" $(stat -Lc '%a %U %G' "$1")
|
||||||
|
if [ $((0$5 & ~0$2 & 07777)) -ne 0 ] || [ "$6" != "$3" ] || ! echo "$7" | grep -qxE "$4"; then
|
||||||
|
echo "$1=$5:$6:$7"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
perm_passwd() { too_open /etc/passwd 644 root root; }
|
||||||
|
perm_passwd_() { too_open /etc/passwd- 644 root root; }
|
||||||
|
perm_group() { too_open /etc/group 644 root root; }
|
||||||
|
perm_group_() { too_open /etc/group- 644 root root; }
|
||||||
|
perm_shadow() { too_open /etc/shadow 640 root 'root|shadow'; }
|
||||||
|
perm_shadow_() { too_open /etc/shadow- 640 root 'root|shadow'; }
|
||||||
|
perm_gshadow() { too_open /etc/gshadow 640 root 'root|shadow'; }
|
||||||
|
perm_gshadow_() { too_open /etc/gshadow- 640 root 'root|shadow'; }
|
||||||
|
perm_shells() { too_open /etc/shells 644 root root; }
|
||||||
|
perm_opasswd() { too_open /etc/security/opasswd 600 root root; }
|
||||||
|
|
||||||
|
world_writable() {
|
||||||
|
find / -xdev -type f -perm -0002
|
||||||
|
find / -xdev -type d -perm -0002 ! -perm -1000
|
||||||
|
}
|
||||||
|
unowned() { find / -xdev \( -nouser -o -nogroup \); }
|
||||||
|
|
||||||
|
not_shadowed() { awk -F: '$2 != "x" { print $1 }' /etc/passwd; }
|
||||||
|
empty_passwords() { awk -F: '$2 == "" { print $1 }' /etc/shadow; }
|
||||||
|
missing_groups() { awk -F: 'NR == FNR { g[$3] = 1; next } !($4 in g) { print $1 ":" $4 }' /etc/group /etc/passwd; }
|
||||||
|
shadow_members() {
|
||||||
|
awk -F: '
|
||||||
|
NR == FNR { if ($1 == "shadow") { gid = $3; if ($4 != "") print "shadow:" $4 } next }
|
||||||
|
gid != "" && $4 == gid { print $1 }
|
||||||
|
' /etc/group /etc/passwd
|
||||||
|
}
|
||||||
|
dup_uids() { cut -d: -f3 /etc/passwd | sort | uniq -d; }
|
||||||
|
dup_gids() { cut -d: -f3 /etc/group | sort | uniq -d; }
|
||||||
|
dup_users() { cut -d: -f1 /etc/passwd | sort | uniq -d; }
|
||||||
|
dup_groups() { cut -d: -f1 /etc/group | sort | uniq -d; }
|
||||||
|
|
||||||
|
check debian:5.4.1.1 "Password expiration is configured" pass_max_days
|
||||||
|
check debian:5.4.1.2 "Minimum password days are configured" pass_min_days
|
||||||
|
check debian:5.4.1.3 "Password expiration warning days are configured" pass_warn_age
|
||||||
|
check debian:5.4.1.4 "Strong password hashing algorithm is configured" weak_hashing
|
||||||
|
check debian:5.4.2.1 "root is the only UID 0 account" other_uid0
|
||||||
|
check debian:5.4.2.2 "root is the only GID 0 account" other_gid0_accounts
|
||||||
|
check debian:5.4.2.3 "group root is the only GID 0 group" other_gid0_groups
|
||||||
|
check debian:5.4.2.4 "root account access is controlled (locked)" root_unlocked
|
||||||
|
check debian:5.4.2.7 "System accounts do not have a valid login shell" system_login_shells
|
||||||
|
check debian:5.4.2.8 "Accounts without a valid login shell are locked" unlocked_without_shell
|
||||||
|
check debian:5.4.3.3 "Default user umask is configured" weak_umask
|
||||||
|
check debian:7.1.1 "Permissions on /etc/passwd are configured" perm_passwd
|
||||||
|
check debian:7.1.2 "Permissions on /etc/passwd- are configured" perm_passwd_
|
||||||
|
check debian:7.1.3 "Permissions on /etc/group are configured" perm_group
|
||||||
|
check debian:7.1.4 "Permissions on /etc/group- are configured" perm_group_
|
||||||
|
check debian:7.1.5 "Permissions on /etc/shadow are configured" perm_shadow
|
||||||
|
check debian:7.1.6 "Permissions on /etc/shadow- are configured" perm_shadow_
|
||||||
|
check debian:7.1.7 "Permissions on /etc/gshadow are configured" perm_gshadow
|
||||||
|
check debian:7.1.8 "Permissions on /etc/gshadow- are configured" perm_gshadow_
|
||||||
|
check debian:7.1.9 "Permissions on /etc/shells are configured" perm_shells
|
||||||
|
check debian:7.1.10 "Permissions on /etc/security/opasswd are configured" perm_opasswd
|
||||||
|
check debian:7.1.11 "World writable files and directories are secured" world_writable
|
||||||
|
check debian:7.1.12 "No files or directories without an owner and a group exist" unowned
|
||||||
|
check debian:7.2.1 "Accounts in /etc/passwd use shadowed passwords" not_shadowed
|
||||||
|
check debian:7.2.2 "/etc/shadow password fields are not empty" empty_passwords
|
||||||
|
check debian:7.2.3 "All groups in /etc/passwd exist in /etc/group" missing_groups
|
||||||
|
check debian:7.2.4 "The shadow group is empty" shadow_members
|
||||||
|
check debian:7.2.5 "No duplicate UIDs exist" dup_uids
|
||||||
|
check debian:7.2.6 "No duplicate GIDs exist" dup_gids
|
||||||
|
check debian:7.2.7 "No duplicate user names exist" dup_users
|
||||||
|
check debian:7.2.8 "No duplicate group names exist" dup_groups
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""The images a Dockerfile builds on, each pinned by digest.
|
||||||
|
|
||||||
|
bin/verify-base.sh checks their signatures before the build. A FROM naming a
|
||||||
|
stage of the same file is no image, ``scratch`` is none either, and an image
|
||||||
|
without ``@sha256:`` cannot be verified: a tag can move after the check.
|
||||||
|
|
||||||
|
python -m image_tools.dockerfile <Dockerfile>...
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_FROM = re.compile(r"^\s*FROM\s+(.*)$", re.IGNORECASE)
|
||||||
|
_DIGEST = re.compile(r"@sha256:[0-9a-f]{64}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _instructions(text: str) -> list[str]:
|
||||||
|
return re.sub(r"\\\s*\n", " ", text).splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
def external_bases(text: str) -> list[str]:
|
||||||
|
"""Every image the file's FROM lines name, once, in order."""
|
||||||
|
stages: set[str] = set()
|
||||||
|
bases: list[str] = []
|
||||||
|
for line in _instructions(text):
|
||||||
|
match = _FROM.match(line)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
words = [w for w in match.group(1).split() if not w.startswith("--")]
|
||||||
|
ref = words[0]
|
||||||
|
if len(words) >= 3 and words[1].lower() == "as":
|
||||||
|
stages.add(words[2].lower())
|
||||||
|
if ref.lower() in stages or ref == "scratch":
|
||||||
|
continue
|
||||||
|
if not _DIGEST.search(ref):
|
||||||
|
raise ValueError(f"FROM {ref} is not pinned by digest")
|
||||||
|
if ref not in bases:
|
||||||
|
bases.append(ref)
|
||||||
|
return bases
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int: # pragma: no cover - CLI seam
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("dockerfiles", nargs="+")
|
||||||
|
args = parser.parse_args()
|
||||||
|
seen: list[str] = []
|
||||||
|
for path in args.dockerfiles:
|
||||||
|
for ref in external_bases(Path(path).read_text()):
|
||||||
|
if ref not in seen:
|
||||||
|
seen.append(ref)
|
||||||
|
print(ref)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""The platform images of a published reference, for bin/sign.sh.
|
||||||
|
|
||||||
|
An index (base-os, a released netork image) lists them; a single manifest (a
|
||||||
|
netork dev image) is one itself, its architecture in its config. Each line out
|
||||||
|
is ``<arch> <digest>``. Only amd64 and arm64 are built, so anything else, or
|
||||||
|
nothing at all, is an error rather than a quiet skip.
|
||||||
|
|
||||||
|
python -m image_tools.platforms <digest> <raw.json> <image.json>
|
||||||
|
|
||||||
|
``raw.json`` is ``docker buildx imagetools inspect --raw``, ``image.json`` its
|
||||||
|
``--format '{{json .Image}}'``, and ``<digest>`` the reference's own.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BUILT = ("amd64", "arm64")
|
||||||
|
INDEXES = (
|
||||||
|
"application/vnd.oci.image.index.v1+json",
|
||||||
|
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _checked(arch: str) -> str:
|
||||||
|
if arch not in BUILT:
|
||||||
|
raise ValueError(f"{arch} is not a platform built here ({', '.join(BUILT)})")
|
||||||
|
return arch
|
||||||
|
|
||||||
|
|
||||||
|
def platforms(raw: str, image: str, digest: str) -> list[tuple[str, str]]:
|
||||||
|
"""``(arch, digest)`` of every platform image behind the reference."""
|
||||||
|
manifest = json.loads(raw)
|
||||||
|
if manifest.get("mediaType") in INDEXES or "manifests" in manifest:
|
||||||
|
found = []
|
||||||
|
for entry in manifest.get("manifests") or []:
|
||||||
|
platform = entry.get("platform") or {}
|
||||||
|
if platform.get("os") != "linux":
|
||||||
|
continue # buildx's attestation manifests say "unknown"
|
||||||
|
found.append((_checked(platform.get("architecture", "")), entry["digest"]))
|
||||||
|
else:
|
||||||
|
arch = (json.loads(image) or {}).get("architecture")
|
||||||
|
found = [(_checked(arch), digest)] if arch else []
|
||||||
|
if not found:
|
||||||
|
raise ValueError("the reference holds no platform image to sign")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int: # pragma: no cover - CLI seam
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("digest")
|
||||||
|
parser.add_argument("raw")
|
||||||
|
parser.add_argument("image")
|
||||||
|
args = parser.parse_args()
|
||||||
|
found = platforms(Path(args.raw).read_text(), Path(args.image).read_text(), args.digest)
|
||||||
|
for arch, digest in found:
|
||||||
|
print(arch, digest)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""The CycloneDX SBOM of an image, as Trivy reads it.
|
||||||
|
|
||||||
|
bin/sign.sh attaches it to each published platform image as an attestation,
|
||||||
|
next to the CIS result, so what base-os holds can be read from the registry.
|
||||||
|
|
||||||
|
python -m image_tools.sbom <ref> <out.cdx.json>
|
||||||
|
|
||||||
|
Standard library only; Trivy runs as the container image_tools/verify.py pins.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from image_tools.verify import TIMEOUT, TRIVY_IMAGE
|
||||||
|
|
||||||
|
|
||||||
|
def cyclonedx(ref: str) -> dict: # pragma: no cover - container seam
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"-v",
|
||||||
|
"/var/run/docker.sock:/var/run/docker.sock",
|
||||||
|
"-e",
|
||||||
|
"TRIVY_USERNAME",
|
||||||
|
"-e",
|
||||||
|
"TRIVY_PASSWORD",
|
||||||
|
TRIVY_IMAGE,
|
||||||
|
"image",
|
||||||
|
"--quiet",
|
||||||
|
"--disable-telemetry",
|
||||||
|
"--skip-version-check",
|
||||||
|
"--format",
|
||||||
|
"cyclonedx",
|
||||||
|
ref,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return json.loads(proc.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int: # pragma: no cover - CLI seam
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("ref")
|
||||||
|
parser.add_argument("out")
|
||||||
|
args = parser.parse_args()
|
||||||
|
Path(args.out).write_text(json.dumps(cyclonedx(args.ref), indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""Does a candidate image improve on the pinned one? (#1308)
|
||||||
|
|
||||||
|
Trivy lists both images' packages; netOrk's matcher in kb-server judges both in
|
||||||
|
one call, so against one state of its data (kb-server#155). The verdict:
|
||||||
|
|
||||||
|
- **improved** when a critical or high CVE that has a fixed version is closed,
|
||||||
|
Debian made a point release (``/etc/debian_version`` differs), or the
|
||||||
|
candidate passes a CIS control the pin fails (``image_tools/cis.py``, both measured
|
||||||
|
in the same run);
|
||||||
|
- **same** otherwise, and whenever the matcher cannot be asked: no pin moves on
|
||||||
|
a guess.
|
||||||
|
|
||||||
|
New critical or high CVEs in the candidate never block; the report lists them.
|
||||||
|
|
||||||
|
python -m image_tools.verify <current-ref> <candidate-ref> [--packages packages.allow] \
|
||||||
|
--json verdict.json --report report.md
|
||||||
|
|
||||||
|
Standard library only; Trivy runs as a pinned container.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import urllib.request
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
#: Trivy, pinned by its multi-platform index digest.
|
||||||
|
TRIVY_IMAGE = (
|
||||||
|
"aquasec/trivy:0.75.0@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa"
|
||||||
|
)
|
||||||
|
SEVERE = frozenset({"critical", "high"})
|
||||||
|
KB_URL = os.environ.get("KB_URL", "https://kb.netork.io")
|
||||||
|
TIMEOUT = 300
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Verdict:
|
||||||
|
improved: bool
|
||||||
|
closed: list[dict] = field(default_factory=list)
|
||||||
|
new: list[dict] = field(default_factory=list)
|
||||||
|
point_release: tuple[str, str] | None = None
|
||||||
|
cis_closed: list[str] = field(default_factory=list)
|
||||||
|
counts: dict = field(default_factory=dict)
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
def to_json(self) -> str:
|
||||||
|
return json.dumps(asdict(self), indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _patched(finding: dict) -> bool:
|
||||||
|
return (finding.get("severity") or "").lower() in SEVERE and bool(finding.get("fixed"))
|
||||||
|
|
||||||
|
|
||||||
|
def judge(
|
||||||
|
answer: dict,
|
||||||
|
current_debian: str,
|
||||||
|
candidate_debian: str,
|
||||||
|
cis_closed: Iterable[str] = (),
|
||||||
|
) -> Verdict:
|
||||||
|
"""The verdict on the matcher's *answer*, both images' Debian versions and
|
||||||
|
the CIS controls the candidate passes and the pin fails."""
|
||||||
|
closed = [f for f in answer.get("closed") or [] if _patched(f)]
|
||||||
|
new = [f for f in answer.get("new") or [] if (f.get("severity") or "").lower() in SEVERE]
|
||||||
|
point = None
|
||||||
|
if current_debian.strip() != candidate_debian.strip():
|
||||||
|
point = (current_debian.strip(), candidate_debian.strip())
|
||||||
|
counts = {"current": answer.get("current", {}), "candidate": answer.get("candidate", {})}
|
||||||
|
cis = sorted(cis_closed)
|
||||||
|
return Verdict(bool(closed) or point is not None or bool(cis), closed, new, point, cis, counts)
|
||||||
|
|
||||||
|
|
||||||
|
def verify(
|
||||||
|
current: str,
|
||||||
|
candidate: str,
|
||||||
|
*,
|
||||||
|
sbom: Callable[[str], dict] | None = None,
|
||||||
|
match: Callable[[dict, dict], dict] | None = None,
|
||||||
|
debian_version: Callable[[str], str] | None = None,
|
||||||
|
cis_failing: Callable[[str], set[str]] | None = None,
|
||||||
|
) -> Verdict:
|
||||||
|
"""Judge *candidate* against *current*; never raises."""
|
||||||
|
sbom = sbom or trivy_sbom
|
||||||
|
match = match or kb_match
|
||||||
|
debian_version = debian_version or debian_version_of
|
||||||
|
cis_failing = cis_failing or cis_failing_of
|
||||||
|
try:
|
||||||
|
answer = match(sbom(current), sbom(candidate))
|
||||||
|
cis_closed = cis_failing(current) - cis_failing(candidate)
|
||||||
|
return judge(answer, debian_version(current), debian_version(candidate), cis_closed)
|
||||||
|
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
||||||
|
return Verdict(False, error=f"{type(exc).__name__}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def _row(f: dict) -> str:
|
||||||
|
fixed = f.get("fixed") or "–"
|
||||||
|
return f"| {f.get('cve')} | {f.get('package')} | {f.get('severity')} | {f.get('installed')} | {fixed} |"
|
||||||
|
|
||||||
|
|
||||||
|
def render(verdict: Verdict, current: str, candidate: str) -> str:
|
||||||
|
"""The pull request body: what closed, what is new, against what."""
|
||||||
|
head = "**Improved**" if verdict.improved else "**Same** (the pin stays)"
|
||||||
|
lines = [f"{head}: `{candidate}` against the pinned `{current}`.", ""]
|
||||||
|
if verdict.error:
|
||||||
|
lines += [f"The matcher could not be asked: {verdict.error}", ""]
|
||||||
|
if verdict.point_release:
|
||||||
|
old, new = verdict.point_release
|
||||||
|
lines += [f"Debian point release: {old} → {new}", ""]
|
||||||
|
if verdict.cis_closed:
|
||||||
|
lines += [
|
||||||
|
f"CIS controls the pin fails and the candidate passes: {', '.join(verdict.cis_closed)}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
table = ["| CVE | Package | Severity | Installed | Fixed in |", "|---|---|---|---|---|"]
|
||||||
|
if verdict.closed:
|
||||||
|
lines += ["### Closed critical/high", "", *table, *map(_row, verdict.closed), ""]
|
||||||
|
if verdict.new:
|
||||||
|
lines += ["### New critical/high in the candidate", "", *table, *map(_row, verdict.new), ""]
|
||||||
|
if verdict.counts:
|
||||||
|
lines += [
|
||||||
|
f"Counts: current {verdict.counts.get('current')}, candidate {verdict.counts.get('candidate')}."
|
||||||
|
]
|
||||||
|
return "\n".join(lines).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def trivy_sbom(ref: str) -> dict: # pragma: no cover - container seam
|
||||||
|
"""Trivy's package inventory of *ref* (no vulnerability DB)."""
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"-v",
|
||||||
|
"/var/run/docker.sock:/var/run/docker.sock",
|
||||||
|
"-e",
|
||||||
|
"TRIVY_USERNAME",
|
||||||
|
"-e",
|
||||||
|
"TRIVY_PASSWORD",
|
||||||
|
TRIVY_IMAGE,
|
||||||
|
"image",
|
||||||
|
"--quiet",
|
||||||
|
"--disable-telemetry",
|
||||||
|
"--skip-version-check",
|
||||||
|
"--format",
|
||||||
|
"json",
|
||||||
|
"--list-all-pkgs",
|
||||||
|
"--scanners",
|
||||||
|
"license",
|
||||||
|
ref,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return json.loads(proc.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def kb_match(current: dict, candidate: dict) -> dict: # pragma: no cover - network seam
|
||||||
|
"""kb-server's verdict on both inventories, in one call (kb-server#155)."""
|
||||||
|
body = json.dumps({"current": current, "candidate": candidate}).encode()
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{KB_URL}/api/v1/match",
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {os.environ['NETORK_LICENSE_KEY']}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310 - https
|
||||||
|
return json.load(response)
|
||||||
|
|
||||||
|
|
||||||
|
def debian_version_of(ref: str) -> str: # pragma: no cover - container seam
|
||||||
|
proc = subprocess.run(
|
||||||
|
["docker", "run", "--rm", "--entrypoint", "cat", ref, "/etc/debian_version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return proc.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def cis_failing_of(ref: str, packages: str = "packages.allow") -> set[str]: # pragma: no cover
|
||||||
|
"""The CIS controls *ref* fails, measured as image_tools/cis.py measures a build;
|
||||||
|
*packages* is the image repository's allow list."""
|
||||||
|
from image_tools import cis
|
||||||
|
|
||||||
|
allowed = cis.load_packages(Path(packages).read_text())
|
||||||
|
return {c.id for c in cis.measure(ref, allowed) if c.result == cis.FAIL}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int: # pragma: no cover - CLI seam
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("current")
|
||||||
|
parser.add_argument("candidate")
|
||||||
|
parser.add_argument("--json", required=True)
|
||||||
|
parser.add_argument("--report", required=True)
|
||||||
|
parser.add_argument("--packages", default="packages.allow")
|
||||||
|
args = parser.parse_args()
|
||||||
|
verdict = verify(
|
||||||
|
args.current,
|
||||||
|
args.candidate,
|
||||||
|
cis_failing=lambda ref: cis_failing_of(ref, args.packages),
|
||||||
|
)
|
||||||
|
with open(args.json, "w") as fh:
|
||||||
|
fh.write(verdict.to_json())
|
||||||
|
with open(args.report, "w") as fh:
|
||||||
|
fh.write(render(verdict, args.current, args.candidate))
|
||||||
|
print("improved" if verdict.improved else "same")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[project]
|
||||||
|
name = "image-tools"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Judge, sign and verify netOrk container images"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = ["image_tools"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
image_tools = ["cis_os.sh"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["."]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""The composite actions every image repository of NetOrk uses (NetOrk/netork#1390).
|
||||||
|
|
||||||
|
uses: https://git.netork.io/NetOrk/image-tools/actions/<name>@<commit>
|
||||||
|
|
||||||
|
They only call what this repository holds, so a pinned commit fixes what runs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ACTIONS = sorted((ROOT / "actions").glob("*/action.yml"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_three_actions():
|
||||||
|
assert [a.parent.name for a in ACTIONS] == ["judge", "sign", "verify-base"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("action", ACTIONS, ids=lambda a: a.parent.name)
|
||||||
|
def test_an_action_runs_only_what_this_repository_holds(action):
|
||||||
|
spec = yaml.safe_load(action.read_text())
|
||||||
|
|
||||||
|
assert spec["runs"]["using"] == "composite"
|
||||||
|
for step in spec["runs"]["steps"]:
|
||||||
|
for script in re.findall(r"github\.action_path }}/\.\./\.\./(bin/[\w.-]+)", step["run"]):
|
||||||
|
assert (ROOT / script).is_file(), script
|
||||||
|
assert "${{ inputs." not in step["run"], "inputs reach a script through env"
|
||||||
|
|
||||||
|
|
||||||
|
def test_signing_needs_its_secrets_from_the_caller():
|
||||||
|
text = (ROOT / "actions/sign/action.yml").read_text()
|
||||||
|
|
||||||
|
assert "COSIGN_PRIVATE_KEY_B64" in text and "secrets." not in text
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""Whether an image is what CIS asks of it (CIS Docker 1.6.0 §4, CIS Debian Linux 12).
|
||||||
|
|
||||||
|
Trivy measures what it automates, ``image_tools/cis_os.sh`` what can be seen from
|
||||||
|
inside the image, and ``cis.toml`` decides the rest. The gate is red for a
|
||||||
|
failure nobody decided, an expired exception, a control neither measured nor
|
||||||
|
decided, a decision for a control nothing knows, and a measurement not taken.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from image_tools.cis import (
|
||||||
|
Control,
|
||||||
|
Decision,
|
||||||
|
check,
|
||||||
|
from_os,
|
||||||
|
from_trivy,
|
||||||
|
judge,
|
||||||
|
load_decisions,
|
||||||
|
load_packages,
|
||||||
|
merge,
|
||||||
|
render,
|
||||||
|
)
|
||||||
|
|
||||||
|
TODAY = datetime.date(2026, 10, 21)
|
||||||
|
SUMMARY = {
|
||||||
|
"ID": "docker-cis-1.6.0",
|
||||||
|
"SummaryControls": [
|
||||||
|
{"ID": "4.1", "Name": "Ensure a user for the container has been created", "TotalFail": 1},
|
||||||
|
{"ID": "4.3", "Name": "Ensure unnecessary packages are not installed (Manual)"},
|
||||||
|
{"ID": "4.7", "Name": "Ensure update instructions are not used alone", "TotalFail": 0},
|
||||||
|
{"ID": "4.8", "Name": "Ensure setuid and setgid permissions are removed (Manual)"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
OS = (
|
||||||
|
"docker:4.3\tPASS\tUnnecessary packages are not installed (packages.allow)\t\n"
|
||||||
|
"docker:4.8\tFAIL\tsetuid and setgid permissions are removed\t/usr/bin/su /usr/bin/mount\n"
|
||||||
|
"debian:7.2.5\tPASS\tNo duplicate UIDs exist\t\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _c(cid, result, detail=""):
|
||||||
|
return Control(cid, cid, result, detail)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMeasurements:
|
||||||
|
def test_trivy_without_a_count_did_not_measure(self):
|
||||||
|
controls = {c.id: c for c in from_trivy(SUMMARY)}
|
||||||
|
|
||||||
|
assert controls["docker:4.1"].result == "fail"
|
||||||
|
assert controls["docker:4.3"].result == "unmeasured"
|
||||||
|
assert controls["docker:4.7"].result == "pass"
|
||||||
|
|
||||||
|
def test_the_os_checks_say_what_fails(self):
|
||||||
|
controls = {c.id: c for c in from_os(OS)}
|
||||||
|
|
||||||
|
assert controls["docker:4.8"].result == "fail"
|
||||||
|
assert controls["docker:4.8"].detail == "/usr/bin/su /usr/bin/mount"
|
||||||
|
assert controls["debian:7.2.5"].result == "pass"
|
||||||
|
|
||||||
|
def test_a_line_the_os_checks_should_not_print_is_an_error(self):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
from_os("sh: 1: stat: not found\n")
|
||||||
|
|
||||||
|
def test_the_os_checks_settle_what_trivy_leaves_to_manual(self):
|
||||||
|
controls = {c.id: c for c in merge(from_trivy(SUMMARY), from_os(OS))}
|
||||||
|
|
||||||
|
assert controls["docker:4.3"].result == "pass"
|
||||||
|
assert controls["docker:4.8"].result == "fail"
|
||||||
|
assert controls["docker:4.1"].result == "fail"
|
||||||
|
assert list(controls)[:4] == ["docker:4.1", "docker:4.3", "docker:4.7", "docker:4.8"]
|
||||||
|
|
||||||
|
def test_a_failure_wins_over_a_pass(self):
|
||||||
|
(control,) = merge([_c("docker:4.8", "fail")], [_c("docker:4.8", "pass")])
|
||||||
|
|
||||||
|
assert control.result == "fail"
|
||||||
|
|
||||||
|
|
||||||
|
class TestJudge:
|
||||||
|
def test_all_passing_is_green(self):
|
||||||
|
assert judge([_c("docker:4.1", "pass")], {}, TODAY).ok
|
||||||
|
|
||||||
|
def test_a_failure_nobody_decided_is_red(self):
|
||||||
|
report = judge([_c("docker:4.1", "fail")], {}, TODAY)
|
||||||
|
|
||||||
|
assert not report.ok
|
||||||
|
assert report.controls[0].state == "fail"
|
||||||
|
|
||||||
|
def test_an_exception_keeps_a_failure_green(self):
|
||||||
|
decisions = {"docker:4.6": Decision("exception", "no service")}
|
||||||
|
|
||||||
|
report = judge([_c("docker:4.6", "fail")], decisions, TODAY)
|
||||||
|
|
||||||
|
assert report.ok
|
||||||
|
assert report.controls[0].state == "excepted"
|
||||||
|
|
||||||
|
def test_an_expired_exception_is_red(self):
|
||||||
|
decisions = {"docker:4.6": Decision("exception", "for now", datetime.date(2026, 10, 1))}
|
||||||
|
|
||||||
|
report = judge([_c("docker:4.6", "fail")], decisions, TODAY)
|
||||||
|
|
||||||
|
assert not report.ok
|
||||||
|
assert report.controls[0].state == "expired"
|
||||||
|
|
||||||
|
def test_an_exception_expiring_today_still_holds(self):
|
||||||
|
decisions = {"docker:4.6": Decision("exception", "for now", TODAY)}
|
||||||
|
|
||||||
|
assert judge([_c("docker:4.6", "fail")], decisions, TODAY).ok
|
||||||
|
|
||||||
|
def test_a_control_nothing_measures_must_be_decided(self):
|
||||||
|
assert not judge([_c("docker:4.2", "unmeasured")], {}, TODAY).ok
|
||||||
|
|
||||||
|
decisions = {"docker:4.2": Decision("manual", "pinned by digest")}
|
||||||
|
report = judge([_c("docker:4.2", "unmeasured")], decisions, TODAY)
|
||||||
|
assert report.ok
|
||||||
|
assert report.controls[0].state == "manual"
|
||||||
|
|
||||||
|
def test_manual_is_not_the_tools_word(self):
|
||||||
|
"""4.4 belongs to the matcher, whose policy is that new CVEs never block."""
|
||||||
|
decisions = {"docker:4.4": Decision("manual", "judged by the matcher")}
|
||||||
|
|
||||||
|
assert judge([_c("docker:4.4", "fail")], decisions, TODAY).ok
|
||||||
|
|
||||||
|
def test_an_exception_for_a_passing_control_is_named_stale(self):
|
||||||
|
decisions = {"docker:4.6": Decision("exception", "no service")}
|
||||||
|
|
||||||
|
report = judge([_c("docker:4.6", "pass")], decisions, TODAY)
|
||||||
|
|
||||||
|
assert report.ok
|
||||||
|
assert report.stale == ["docker:4.6"]
|
||||||
|
|
||||||
|
def test_a_decision_for_a_control_nothing_knows_is_red(self):
|
||||||
|
"""A typo in cis.toml must not silently decide nothing."""
|
||||||
|
decisions = {"docker:4.66": Decision("exception", "typo")}
|
||||||
|
|
||||||
|
report = judge([_c("docker:4.6", "pass")], decisions, TODAY)
|
||||||
|
|
||||||
|
assert not report.ok
|
||||||
|
assert report.unknown == ["docker:4.66"]
|
||||||
|
|
||||||
|
def test_nothing_measured_is_not_green(self):
|
||||||
|
assert not judge([], {}, TODAY).ok
|
||||||
|
|
||||||
|
|
||||||
|
class TestDecisions:
|
||||||
|
def test_both_kinds_with_their_reasons(self):
|
||||||
|
decisions = load_decisions(
|
||||||
|
"""
|
||||||
|
[[manual]]
|
||||||
|
id = "docker:4.2"
|
||||||
|
reason = "pinned by digest"
|
||||||
|
|
||||||
|
[[exception]]
|
||||||
|
id = "docker:4.6"
|
||||||
|
reason = "no service"
|
||||||
|
expires = 2027-01-31
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert decisions["docker:4.2"] == Decision("manual", "pinned by digest")
|
||||||
|
assert decisions["docker:4.6"].expires == datetime.date(2027, 1, 31)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"text",
|
||||||
|
[
|
||||||
|
'[[manual]]\nid = "docker:4.2"\nreason = " "\n',
|
||||||
|
'[[manual]]\nid = "docker:4.2"\nreason = "a"\n[[exception]]\nid = "docker:4.2"\nreason = "b"\n',
|
||||||
|
'[[exception]]\nid = "docker:4.6"\nreason = "a"\nexpires = "soon"\n',
|
||||||
|
],
|
||||||
|
ids=["no reason", "decided twice", "expires not a date"],
|
||||||
|
)
|
||||||
|
def test_a_decision_without_substance_is_refused(self, text):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
load_decisions(text)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheck:
|
||||||
|
def test_measured_and_judged(self):
|
||||||
|
seams = {"trivy": lambda ref: SUMMARY, "os_checks": lambda ref, pkgs: OS}
|
||||||
|
decisions = {"docker:4.8": Decision("exception", "not yet")}
|
||||||
|
|
||||||
|
report = check("img", decisions, ["apt"], TODAY, **seams)
|
||||||
|
|
||||||
|
assert not report.ok # 4.1 fails undecided
|
||||||
|
assert {c.id: c.state for c in report.controls}["docker:4.8"] == "excepted"
|
||||||
|
|
||||||
|
def test_the_packages_reach_the_os_checks(self):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def os_checks(ref, packages):
|
||||||
|
seen.append(packages)
|
||||||
|
return OS
|
||||||
|
|
||||||
|
check("img", {}, ["apt", "bash"], TODAY, trivy=lambda r: SUMMARY, os_checks=os_checks)
|
||||||
|
|
||||||
|
assert seen == [["apt", "bash"]]
|
||||||
|
|
||||||
|
def test_a_measurement_not_taken_is_red(self):
|
||||||
|
def trivy(ref):
|
||||||
|
raise subprocess.CalledProcessError(1, "trivy")
|
||||||
|
|
||||||
|
report = check("img", {}, [], TODAY, trivy=trivy, os_checks=lambda r, p: OS)
|
||||||
|
|
||||||
|
assert not report.ok
|
||||||
|
assert "CalledProcessError" in report.error
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_report_says_why():
|
||||||
|
decisions = {
|
||||||
|
"docker:4.6": Decision("exception", "base-os runs no service"),
|
||||||
|
"docker:4.2": Decision("manual", "pinned by digest"),
|
||||||
|
}
|
||||||
|
controls = [
|
||||||
|
_c("docker:4.1", "fail", "1 failed (Trivy)"),
|
||||||
|
_c("docker:4.2", "unmeasured"),
|
||||||
|
_c("docker:4.6", "fail", "1 failed (Trivy)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
text = render(judge(controls, decisions, TODAY), "base-os:ci")
|
||||||
|
|
||||||
|
assert "**CIS: red**" in text and "`base-os:ci`" in text
|
||||||
|
assert "| docker:4.1 | ❌ fail |" in text and "1 failed (Trivy)" in text
|
||||||
|
assert "base-os runs no service" in text
|
||||||
|
assert "pinned by digest" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_report_is_json_for_the_attestation():
|
||||||
|
report = judge([_c("docker:4.1", "pass")], {}, TODAY)
|
||||||
|
|
||||||
|
data = json.loads(report.to_json())
|
||||||
|
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert data["controls"][0] == {
|
||||||
|
"id": "docker:4.1",
|
||||||
|
"title": "docker:4.1",
|
||||||
|
"result": "pass",
|
||||||
|
"detail": "",
|
||||||
|
"state": "pass",
|
||||||
|
"reason": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_allow_list_is_names_without_comments_or_blank_lines():
|
||||||
|
text = "# what base-os may hold\napt\n\n bash \n# login.defs comes from login\nlogin\n"
|
||||||
|
|
||||||
|
assert load_packages(text) == ["apt", "bash", "login"]
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""The images a Dockerfile builds on, so their signatures can be checked first.
|
||||||
|
|
||||||
|
Every link of the chain (base-os → base-python → engine; base-os → ui) checks
|
||||||
|
the signature of what it pins before it builds. That starts with the external
|
||||||
|
references of its FROM lines: a stage of the same file is no image, and an
|
||||||
|
image not pinned by digest cannot be verified at all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from image_tools.dockerfile import external_bases
|
||||||
|
|
||||||
|
BASE = "registry.netork.io/netork/base-python:3.12.15-13-20261020.1@sha256:" + "c" * 64
|
||||||
|
OS = "registry.netork.io/netork/base-os:13-20261022.2@sha256:" + "f" * 64
|
||||||
|
|
||||||
|
|
||||||
|
def test_stages_of_the_same_file_are_not_images():
|
||||||
|
text = f"""
|
||||||
|
FROM {BASE} AS builder
|
||||||
|
RUN true
|
||||||
|
FROM builder AS drivers
|
||||||
|
FROM {BASE} AS runtime
|
||||||
|
COPY --from=drivers /opt /opt
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert external_bases(text) == [BASE]
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_distinct_image_once_in_order():
|
||||||
|
text = f"FROM {OS} AS builder\nFROM {BASE} AS runtime\nFROM {OS}\n"
|
||||||
|
|
||||||
|
assert external_bases(text) == [OS, BASE]
|
||||||
|
|
||||||
|
|
||||||
|
def test_flags_and_case_do_not_hide_an_image():
|
||||||
|
text = f"from --platform=$BUILDPLATFORM {OS} as build\n"
|
||||||
|
|
||||||
|
assert external_bases(text) == [OS]
|
||||||
|
|
||||||
|
|
||||||
|
def test_scratch_is_no_image():
|
||||||
|
assert external_bases("FROM scratch\n") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_continuation_lines_are_joined():
|
||||||
|
text = f"FROM \\\n {OS} \\\n AS runtime\n"
|
||||||
|
|
||||||
|
assert external_bases(text) == [OS]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"ref",
|
||||||
|
["debian:trixie-slim", "registry.netork.io/netork/base-os:pinned", "${BASE_IMAGE}"],
|
||||||
|
)
|
||||||
|
def test_an_image_without_a_digest_is_refused(ref):
|
||||||
|
"""A tag can move; only a digest says which image was checked."""
|
||||||
|
with pytest.raises(ValueError, match="digest"):
|
||||||
|
external_bases(f"FROM {ref}\n")
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Which platform images a published reference has, for bin/sign.sh.
|
||||||
|
|
||||||
|
base-os publishes an index of amd64 and arm64; netork's dev images are a single
|
||||||
|
amd64 manifest, and a release joins arm64 to it under a new index. Every
|
||||||
|
platform image gets its own CIS result and SBOM, so sign.sh needs each one's
|
||||||
|
architecture and digest, whatever shape the reference has.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from image_tools.platforms import platforms
|
||||||
|
|
||||||
|
AMD = "sha256:" + "a" * 64
|
||||||
|
ARM = "sha256:" + "b" * 64
|
||||||
|
ATT = "sha256:" + "c" * 64
|
||||||
|
SELF = "sha256:" + "d" * 64
|
||||||
|
|
||||||
|
|
||||||
|
def _index(*manifests: dict) -> str:
|
||||||
|
return json.dumps(
|
||||||
|
{"mediaType": "application/vnd.oci.image.index.v1+json", "manifests": list(manifests)}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(digest: str, arch: str | None, os: str = "linux") -> dict:
|
||||||
|
entry: dict = {"digest": digest, "mediaType": "application/vnd.oci.image.manifest.v1+json"}
|
||||||
|
if arch:
|
||||||
|
entry["platform"] = {"os": os, "architecture": arch}
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_index_names_each_linux_platform():
|
||||||
|
raw = _index(_entry(AMD, "amd64"), _entry(ARM, "arm64"))
|
||||||
|
|
||||||
|
assert platforms(raw, "{}", SELF) == [("amd64", AMD), ("arm64", ARM)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_buildx_attestation_manifests_are_no_platform():
|
||||||
|
raw = _index(_entry(AMD, "amd64"), _entry(ATT, "unknown", os="unknown"), _entry(ATT, None))
|
||||||
|
|
||||||
|
assert platforms(raw, "{}", SELF) == [("amd64", AMD)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_single_manifest_is_its_own_platform():
|
||||||
|
raw = json.dumps({"mediaType": "application/vnd.oci.image.manifest.v1+json", "layers": []})
|
||||||
|
image = json.dumps({"architecture": "amd64", "os": "linux"})
|
||||||
|
|
||||||
|
assert platforms(raw, image, SELF) == [("amd64", SELF)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_docker_v2_manifest_list_counts_as_an_index():
|
||||||
|
raw = json.dumps(
|
||||||
|
{
|
||||||
|
"mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
|
||||||
|
"manifests": [_entry(ARM, "arm64")],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert platforms(raw, "{}", SELF) == [("arm64", ARM)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw",
|
||||||
|
[
|
||||||
|
_index(_entry(ATT, "unknown", os="unknown")),
|
||||||
|
json.dumps({"mediaType": "application/vnd.oci.image.manifest.v1+json"}),
|
||||||
|
],
|
||||||
|
ids=["index without an image", "manifest without an architecture"],
|
||||||
|
)
|
||||||
|
def test_nothing_to_sign_is_an_error(raw):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
platforms(raw, "{}", SELF)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_architecture_not_built_here_is_an_error():
|
||||||
|
"""Only amd64 and arm64 are built (#934); anything else would be signed blind."""
|
||||||
|
with pytest.raises(ValueError, match="riscv64"):
|
||||||
|
platforms(_index(_entry(AMD, "riscv64")), "{}", SELF)
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""Signing and checking netOrk's images with cosign (CIS Docker 4.5, NetOrk/netork#1390).
|
||||||
|
|
||||||
|
Docker Content Trust is deprecated; like Docker's Hardened Images, every image
|
||||||
|
of NetOrk is signed with cosign instead and carries its CIS result and SBOM as
|
||||||
|
attestations. The registry's names stay inside: nothing goes to a public
|
||||||
|
transparency log. Each link of the chain checks the signature of the image it
|
||||||
|
builds on before it builds.
|
||||||
|
|
||||||
|
The scripts run here against a fake ``cosign`` and ``docker`` that record what
|
||||||
|
they are asked.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SIGN = ROOT / "bin/sign.sh"
|
||||||
|
VERIFY = ROOT / "bin/verify-signature.sh"
|
||||||
|
VERIFY_BASE = ROOT / "bin/verify-base.sh"
|
||||||
|
INSTALL = ROOT / "bin/install-cosign.sh"
|
||||||
|
|
||||||
|
IMAGE = "registry.netork.io/netork/engine"
|
||||||
|
AMD = "sha256:" + "a" * 64
|
||||||
|
ARM = "sha256:" + "b" * 64
|
||||||
|
INDEX = "sha256:" + "e" * 64
|
||||||
|
|
||||||
|
_FAKE_COSIGN = """#!/bin/sh
|
||||||
|
echo "cosign $*" >> "$LOG"
|
||||||
|
case "$*" in *"{fail}"*) echo "no matching signatures" >&2; exit 1 ;; esac
|
||||||
|
exit 0
|
||||||
|
"""
|
||||||
|
_FAKE_DOCKER = """#!/bin/sh
|
||||||
|
case "$*" in
|
||||||
|
*--raw*) cat "$RAW" ;;
|
||||||
|
*"{{json .Image}}"*) cat "$CONFIG" ;;
|
||||||
|
*) echo "docker $*" >> "$LOG" ;;
|
||||||
|
esac
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _bin(tmp_path: Path, fail: str = "@@never@@") -> dict[str, str]:
|
||||||
|
bin_dir = tmp_path / "bin"
|
||||||
|
bin_dir.mkdir()
|
||||||
|
(bin_dir / "cosign").write_text(_FAKE_COSIGN.replace("{fail}", fail))
|
||||||
|
(bin_dir / "docker").write_text(_FAKE_DOCKER)
|
||||||
|
for tool in bin_dir.iterdir():
|
||||||
|
tool.chmod(0o755)
|
||||||
|
return {"PATH": f"{bin_dir}:{os.environ['PATH']}", "LOG": str(tmp_path / "log")}
|
||||||
|
|
||||||
|
|
||||||
|
def _log(tmp_path: Path) -> list[str]:
|
||||||
|
log = tmp_path / "log"
|
||||||
|
return log.read_text().splitlines() if log.exists() else []
|
||||||
|
|
||||||
|
|
||||||
|
def _run(script: Path, *args: str, env: dict[str, str]) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["bash", str(script), *args], env=env, capture_output=True, text=True, timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSign:
|
||||||
|
def _attest_dir(self, tmp_path: Path, *arches: str) -> Path:
|
||||||
|
out = tmp_path / "attest"
|
||||||
|
out.mkdir()
|
||||||
|
for arch in arches:
|
||||||
|
(out / f"cis-{arch}.json").write_text("{}")
|
||||||
|
(out / f"sbom-{arch}.cdx.json").write_text("{}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _env(self, tmp_path: Path, raw: dict, config: dict, **extra: str) -> dict[str, str]:
|
||||||
|
(tmp_path / "raw.json").write_text(json.dumps(raw))
|
||||||
|
(tmp_path / "config.json").write_text(json.dumps(config))
|
||||||
|
env = _bin(tmp_path)
|
||||||
|
env.update(
|
||||||
|
RAW=str(tmp_path / "raw.json"),
|
||||||
|
CONFIG=str(tmp_path / "config.json"),
|
||||||
|
COSIGN_PRIVATE_KEY_B64="a2V5Cg==",
|
||||||
|
COSIGN_PASSWORD="pw",
|
||||||
|
**extra,
|
||||||
|
)
|
||||||
|
return env
|
||||||
|
|
||||||
|
def test_a_single_manifest_is_signed_and_attested(self, tmp_path):
|
||||||
|
"""A netork dev image: one amd64 manifest, no index."""
|
||||||
|
env = self._env(tmp_path, {"mediaType": "x", "layers": []}, {"architecture": "amd64"})
|
||||||
|
attest = self._attest_dir(tmp_path, "amd64")
|
||||||
|
|
||||||
|
result = _run(SIGN, f"{IMAGE}:main-abc@{AMD}", str(attest), env=env)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
log = _log(tmp_path)
|
||||||
|
assert any("sign --yes --recursive" in ln and f"@{AMD}" in ln for ln in log)
|
||||||
|
attests = [ln for ln in log if " attest " in ln]
|
||||||
|
assert len(attests) == 2 and all(f"{IMAGE}@{AMD}" in ln for ln in attests)
|
||||||
|
assert f"signed and attested: {IMAGE}@{AMD} (amd64)" in result.stdout
|
||||||
|
|
||||||
|
def test_an_index_attests_every_platform(self, tmp_path):
|
||||||
|
raw = {
|
||||||
|
"mediaType": "application/vnd.oci.image.index.v1+json",
|
||||||
|
"manifests": [
|
||||||
|
{"digest": AMD, "platform": {"os": "linux", "architecture": "amd64"}},
|
||||||
|
{"digest": ARM, "platform": {"os": "linux", "architecture": "arm64"}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
env = self._env(tmp_path, raw, {}, EXPECT_PLATFORMS="amd64 arm64")
|
||||||
|
attest = self._attest_dir(tmp_path, "amd64", "arm64")
|
||||||
|
|
||||||
|
result = _run(SIGN, f"{IMAGE}:0.41.0@{INDEX}", str(attest), env=env)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
verified = [ln for ln in _log(tmp_path) if "verify-attestation" in ln]
|
||||||
|
assert sum(f"@{AMD}" in ln for ln in verified) == 2
|
||||||
|
assert sum(f"@{ARM}" in ln for ln in verified) == 2
|
||||||
|
|
||||||
|
def test_a_missing_expected_platform_stops_it(self, tmp_path):
|
||||||
|
env = self._env(
|
||||||
|
tmp_path, {"mediaType": "x"}, {"architecture": "amd64"}, EXPECT_PLATFORMS="amd64 arm64"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _run(SIGN, f"{IMAGE}@{AMD}", str(self._attest_dir(tmp_path, "amd64")), env=env)
|
||||||
|
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "expected amd64 arm64" in result.stderr
|
||||||
|
assert not any(" attest " in ln for ln in _log(tmp_path))
|
||||||
|
|
||||||
|
def test_a_reference_without_a_digest_is_refused(self, tmp_path):
|
||||||
|
env = self._env(tmp_path, {}, {})
|
||||||
|
|
||||||
|
result = _run(SIGN, f"{IMAGE}:latest", str(self._attest_dir(tmp_path)), env=env)
|
||||||
|
|
||||||
|
assert result.returncode != 0 and "not pinned by digest" in result.stderr
|
||||||
|
|
||||||
|
def test_without_the_secrets_it_says_so(self, tmp_path):
|
||||||
|
env = _bin(tmp_path)
|
||||||
|
|
||||||
|
result = _run(SIGN, f"{IMAGE}@{AMD}", str(tmp_path), env=env)
|
||||||
|
|
||||||
|
assert result.returncode != 0 and "secret is missing" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
class TestVerify:
|
||||||
|
BASE = f"registry.netork.io/netork/base-python:3.12@{AMD}"
|
||||||
|
OS = f"registry.netork.io/netork/base-os:13@{ARM}"
|
||||||
|
|
||||||
|
def _dockerfile(self, tmp_path: Path) -> Path:
|
||||||
|
path = tmp_path / "Dockerfile"
|
||||||
|
path.write_text(
|
||||||
|
f"FROM {self.BASE} AS builder\nFROM builder AS drivers\nFROM {self.OS} AS runtime\n"
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def test_every_base_of_the_dockerfile_is_verified_against_the_public_key(self, tmp_path):
|
||||||
|
result = _run(VERIFY_BASE, str(self._dockerfile(tmp_path)), env=_bin(tmp_path))
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
log = _log(tmp_path)
|
||||||
|
assert log == [
|
||||||
|
f"cosign verify --key cosign.pub --insecure-ignore-tlog {self.BASE}",
|
||||||
|
f"cosign verify --key cosign.pub --insecure-ignore-tlog {self.OS}",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_an_unsigned_base_stops_the_build(self, tmp_path):
|
||||||
|
env = _bin(tmp_path, fail="base-os")
|
||||||
|
|
||||||
|
result = _run(VERIFY_BASE, str(self._dockerfile(tmp_path)), env=env)
|
||||||
|
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert f"not signed with netOrk's key: {self.OS}" in result.stderr
|
||||||
|
|
||||||
|
def test_a_base_without_a_digest_stops_the_build(self, tmp_path):
|
||||||
|
path = tmp_path / "Dockerfile"
|
||||||
|
path.write_text("FROM registry.netork.io/netork/base-os:pinned\n")
|
||||||
|
|
||||||
|
result = _run(VERIFY_BASE, str(path), env=_bin(tmp_path))
|
||||||
|
|
||||||
|
assert result.returncode != 0 and "not pinned by digest" in result.stderr
|
||||||
|
assert _log(tmp_path) == []
|
||||||
|
|
||||||
|
def test_a_reference_without_a_digest_is_never_checked(self, tmp_path):
|
||||||
|
result = _run(VERIFY, "registry.netork.io/netork/base-os:pinned", env=_bin(tmp_path))
|
||||||
|
|
||||||
|
assert result.returncode != 0 and _log(tmp_path) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_goes_to_a_public_transparency_log():
|
||||||
|
config = json.loads((ROOT / "cosign-signing.json").read_text())
|
||||||
|
text = SIGN.read_text()
|
||||||
|
|
||||||
|
for services in ("rekorTlogUrls", "caUrls", "oidcUrls", "tsaUrls"):
|
||||||
|
assert config[services] == []
|
||||||
|
signing = [
|
||||||
|
ln
|
||||||
|
for ln in text.replace("\\\n", " ").splitlines()
|
||||||
|
if re.search(r"cosign (sign|attest) ", ln)
|
||||||
|
]
|
||||||
|
assert len(signing) == 2
|
||||||
|
assert all("--signing-config cosign-signing.json" in ln for ln in signing)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_public_key_is_in_the_repository():
|
||||||
|
"""Whoever pulls an image of NetOrk verifies against it; the private half is
|
||||||
|
the COSIGN_PRIVATE_KEY_B64 secret."""
|
||||||
|
assert (ROOT / "cosign.pub").read_text().startswith("-----BEGIN PUBLIC KEY-----")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_private_key_arrives_as_one_line():
|
||||||
|
"""The runner masks a secret only where it stands whole; the PEM key's lines
|
||||||
|
did not match, and the key stood in a log (2026-10-22)."""
|
||||||
|
text = SIGN.read_text()
|
||||||
|
|
||||||
|
assert '"${COSIGN_PRIVATE_KEY_B64:?' in text and "| base64 -d)" in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("arch", ["AMD64", "ARM64"])
|
||||||
|
def test_cosign_is_pinned_and_checked_for_both_runners(arch):
|
||||||
|
text = INSTALL.read_text()
|
||||||
|
|
||||||
|
assert re.search(r"^VERSION=v\d+\.\d+\.\d+$", text, re.MULTILINE)
|
||||||
|
assert re.search(rf"^SHA256_{arch}=[0-9a-f]{{64}}$", text, re.MULTILINE)
|
||||||
|
assert "sha256sum -c -" in text
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""Whether a candidate image improves on the pinned one (#1308).
|
||||||
|
|
||||||
|
Trivy lists the packages of both, netOrk's matcher in kb-server judges both in
|
||||||
|
one call (so against one state of its data, kb-server#155), and the verdict is
|
||||||
|
"improved" when a critical or high CVE with a fix is closed by a patch, or
|
||||||
|
Debian made a point release. Everything else is "same": the pin stays.
|
||||||
|
|
||||||
|
A matcher that cannot be asked is never an improvement: no pin moves on a guess.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from image_tools.verify import Verdict, judge, render, verify
|
||||||
|
|
||||||
|
CLOSED = {
|
||||||
|
"cve": "CVE-2026-1111",
|
||||||
|
"package": "openssl",
|
||||||
|
"severity": "high",
|
||||||
|
"installed": "3.5.1-1",
|
||||||
|
"fixed": "3.5.1-1+deb13u1",
|
||||||
|
}
|
||||||
|
NEW = {"cve": "CVE-2026-2222", "package": "nginx", "severity": "critical", "installed": "1.26.3-4"}
|
||||||
|
|
||||||
|
|
||||||
|
def _answer(closed=(), new=()):
|
||||||
|
return {
|
||||||
|
"closed": list(closed),
|
||||||
|
"new": list(new),
|
||||||
|
"current": {"critical": 1, "high": 4},
|
||||||
|
"candidate": {"critical": 1, "high": 3},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestJudge:
|
||||||
|
def test_a_closed_cve_is_an_improvement(self):
|
||||||
|
verdict = judge(_answer(closed=[CLOSED]), "13.7", "13.7")
|
||||||
|
|
||||||
|
assert verdict.improved
|
||||||
|
assert verdict.closed == [CLOSED]
|
||||||
|
assert not verdict.point_release
|
||||||
|
|
||||||
|
def test_nothing_closed_is_the_same(self):
|
||||||
|
assert not judge(_answer(), "13.7", "13.7").improved
|
||||||
|
|
||||||
|
def test_a_point_release_is_an_improvement_on_its_own(self):
|
||||||
|
verdict = judge(_answer(), "13.7", "13.8")
|
||||||
|
|
||||||
|
assert verdict.improved
|
||||||
|
assert verdict.point_release == ("13.7", "13.8")
|
||||||
|
|
||||||
|
def test_new_cves_do_not_block_they_are_reported(self):
|
||||||
|
verdict = judge(_answer(closed=[CLOSED], new=[NEW]), "13.7", "13.7")
|
||||||
|
|
||||||
|
assert verdict.improved
|
||||||
|
assert verdict.new == [NEW]
|
||||||
|
|
||||||
|
def test_new_cves_alone_are_no_improvement(self):
|
||||||
|
assert not judge(_answer(new=[NEW]), "13.7", "13.7").improved
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("severity", ["medium", "low", "unknown"])
|
||||||
|
def test_only_critical_and_high_count(self, severity):
|
||||||
|
"""kb-server already filters; a weaker one slipping through moves nothing."""
|
||||||
|
closed = dict(CLOSED, severity=severity)
|
||||||
|
|
||||||
|
assert not judge(_answer(closed=[closed]), "13.7", "13.7").improved
|
||||||
|
|
||||||
|
def test_a_closed_cve_without_a_fix_is_not_a_patch(self):
|
||||||
|
"""Gone without a fixed version means the data changed, not the image."""
|
||||||
|
closed = dict(CLOSED, fixed="")
|
||||||
|
|
||||||
|
assert not judge(_answer(closed=[closed]), "13.7", "13.7").improved
|
||||||
|
|
||||||
|
|
||||||
|
class TestVerify:
|
||||||
|
def _deps(self, answer=None, error=None, versions=("13.7", "13.7"), failing=((), ())):
|
||||||
|
calls = {"sbom": [], "match": []}
|
||||||
|
|
||||||
|
def sbom(ref):
|
||||||
|
calls["sbom"].append(ref)
|
||||||
|
return {"ArtifactName": ref}
|
||||||
|
|
||||||
|
def match(current, candidate):
|
||||||
|
calls["match"].append((current, candidate))
|
||||||
|
if error:
|
||||||
|
raise error
|
||||||
|
return answer
|
||||||
|
|
||||||
|
debian = dict(zip(("cur", "cand"), versions, strict=True))
|
||||||
|
cis = dict(zip(("cur", "cand"), map(set, failing), strict=True))
|
||||||
|
return calls, {
|
||||||
|
"sbom": sbom,
|
||||||
|
"match": match,
|
||||||
|
"debian_version": lambda ref: debian["cur" if ref == "cur" else "cand"],
|
||||||
|
"cis_failing": lambda ref: cis["cur" if ref == "cur" else "cand"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_both_go_to_the_matcher_in_one_call(self):
|
||||||
|
calls, seams = self._deps(answer=_answer(closed=[CLOSED]))
|
||||||
|
|
||||||
|
verdict = verify("cur", "cand", **seams)
|
||||||
|
|
||||||
|
assert calls["sbom"] == ["cur", "cand"]
|
||||||
|
assert calls["match"] == [({"ArtifactName": "cur"}, {"ArtifactName": "cand"})]
|
||||||
|
assert verdict.improved
|
||||||
|
|
||||||
|
def test_a_matcher_that_cannot_be_asked_moves_nothing(self):
|
||||||
|
_, seams = self._deps(error=OSError("connection refused"))
|
||||||
|
|
||||||
|
verdict = verify("cur", "cand", **seams)
|
||||||
|
|
||||||
|
assert not verdict.improved
|
||||||
|
assert "connection refused" in verdict.error
|
||||||
|
|
||||||
|
def test_even_a_point_release_waits_for_the_matcher(self):
|
||||||
|
_, seams = self._deps(error=OSError("down"), versions=("13.7", "13.8"))
|
||||||
|
|
||||||
|
assert not verify("cur", "cand", **seams).improved
|
||||||
|
|
||||||
|
def test_a_cis_control_the_pin_fails_and_the_candidate_passes_is_an_improvement(self):
|
||||||
|
_, seams = self._deps(
|
||||||
|
answer=_answer(), failing=({"docker:4.1", "docker:4.8"}, {"docker:4.8"})
|
||||||
|
)
|
||||||
|
|
||||||
|
verdict = verify("cur", "cand", **seams)
|
||||||
|
|
||||||
|
assert verdict.improved
|
||||||
|
assert verdict.cis_closed == ["docker:4.1"]
|
||||||
|
|
||||||
|
def test_a_cis_control_the_candidate_newly_fails_is_no_improvement(self):
|
||||||
|
"""The gate in check_image.sh stops such a build before; here it closes nothing."""
|
||||||
|
_, seams = self._deps(answer=_answer(), failing=((), {"docker:4.8"}))
|
||||||
|
|
||||||
|
assert not verify("cur", "cand", **seams).improved
|
||||||
|
|
||||||
|
def test_a_cis_measurement_that_cannot_be_taken_moves_nothing(self):
|
||||||
|
_, seams = self._deps(answer=_answer(closed=[CLOSED]))
|
||||||
|
|
||||||
|
def broken(ref):
|
||||||
|
raise subprocess.CalledProcessError(1, "docker")
|
||||||
|
|
||||||
|
seams["cis_failing"] = broken
|
||||||
|
assert not verify("cur", "cand", **seams).improved
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_report_names_what_closed_and_what_is_new():
|
||||||
|
verdict = judge(_answer(closed=[CLOSED], new=[NEW]), "13.7", "13.8", ["docker:4.1"])
|
||||||
|
text = render(verdict, "cur", "cand")
|
||||||
|
|
||||||
|
assert "CVE-2026-1111" in text and "openssl" in text and "3.5.1-1+deb13u1" in text
|
||||||
|
assert "CVE-2026-2222" in text and "nginx" in text
|
||||||
|
assert "13.7 → 13.8" in text
|
||||||
|
assert "docker:4.1" in text
|
||||||
|
assert "improved" in text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_verdict_is_json_for_the_workflow():
|
||||||
|
verdict = judge(_answer(closed=[CLOSED]), "13.7", "13.7")
|
||||||
|
|
||||||
|
data = json.loads(verdict.to_json())
|
||||||
|
|
||||||
|
assert data["improved"] is True
|
||||||
|
assert data["closed"][0]["cve"] == "CVE-2026-1111"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_verdict_has_no_surprise_fields():
|
||||||
|
assert set(Verdict.__dataclass_fields__) == {
|
||||||
|
"improved",
|
||||||
|
"closed",
|
||||||
|
"new",
|
||||||
|
"point_release",
|
||||||
|
"cis_closed",
|
||||||
|
"counts",
|
||||||
|
"error",
|
||||||
|
}
|
||||||
Reference in new issue
Block a user