├── .github ├── dependabot.yml ├── chainguard │ └── digestabot.sts.yaml └── workflows │ ├── lint-shell.yaml │ ├── digestabot.yml │ └── build-and-push.yaml ├── Dockerfile ├── entrypoint.sh ├── cve_checks.sh ├── README.md ├── config_checks.sh ├── minimalism_checks.sh ├── LICENSE ├── provenance_checks.sh ├── chps-scorer.sh └── example.svg /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | open-pull-requests-limit: 10 9 | groups: 10 | actions: 11 | update-types: 12 | - "minor" 13 | - "patch" 14 | 15 | -------------------------------------------------------------------------------- /.github/chainguard/digestabot.sts.yaml: -------------------------------------------------------------------------------- 1 | issuer: https://token.actions.githubusercontent.com 2 | subject: repo:chps-dev/chps-scorer:ref:refs/heads/main 3 | claim_pattern: 4 | job_workflow_ref: chps-dev/chps-scorer/.github/workflows/digestabot.yml@refs/heads/main 5 | 6 | permissions: 7 | contents: write 8 | pull_requests: write 9 | workflows: write 10 | issues: write 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2025 The CHPs-dev Authors 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | FROM cgr.dev/chainguard/docker-dind:latest-dev@sha256:1599562dddf2e97296392f63be70b5afc7e3dad106f1d1eb327c01a916f41e69 5 | 6 | LABEL org.opencontainers.image.source="https://github.com/chps-dev/chps-scorer" 7 | 8 | RUN apk add trufflehog jq curl cosign grype crane 9 | COPY *.sh . 10 | 11 | ENTRYPOINT ["/entrypoint.sh"] 12 | -------------------------------------------------------------------------------- /.github/workflows/lint-shell.yaml: -------------------------------------------------------------------------------- 1 | name: shellcheck 2 | 3 | on: 4 | pull_request: 5 | 6 | permissions: {} 7 | 8 | jobs: 9 | shellcheck: 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: read 14 | 15 | steps: 16 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 17 | 18 | - name: shellcheck 19 | uses: reviewdog/action-shellcheck@4c07458293ac342d477251099501a718ae5ef86e # v1.32.0 20 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Copyright 2025 The CHPs-dev Authors 4 | # SPDX-License-Identifier: Apache-2.0 5 | 6 | /usr/bin/dockerd-entrypoint.sh >/dev/null 2>/dev/null & 7 | 8 | # Wait for Docker daemon to be ready (max 10 seconds) 9 | timeout=10 10 | while ! docker info >/dev/null 2>&1; do 11 | if [ $timeout -le 0 ]; then 12 | echo "Error: Docker daemon failed to start within 10 seconds" 13 | exit 1 14 | fi 15 | sleep 1 16 | timeout=$((timeout - 1)) 17 | done 18 | 19 | /chps-scorer.sh "$@" 20 | -------------------------------------------------------------------------------- /.github/workflows/digestabot.yml: -------------------------------------------------------------------------------- 1 | name: Image digest update 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | # At the end of every day 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | image-update: 11 | name: Image digest update 12 | runs-on: ubuntu-latest 13 | if: github.repository == 'chps-dev/chps-scorer' 14 | 15 | permissions: 16 | contents: read # the read the repo 17 | id-token: write # used to sign the commits using gitsign 18 | pull-requests: write # to add labels to prs 19 | 20 | steps: 21 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 22 | 23 | - uses: octo-sts/action@d6c70ad3b9ac85df6da6b9749014d7283987cfec # v1.0.3 24 | id: octo-sts 25 | with: 26 | scope: ${{ github.repository }} 27 | identity: digestabot 28 | 29 | - uses: chainguard-dev/digestabot@a3b776c1ca57d3127c85578cde8fef6eed3c75d3 # v1.2.3 30 | with: 31 | token: ${{ steps.octo-sts.outputs.token }} 32 | author: "octo-sts[bot] <157150467+octo-sts[bot]@users.noreply.github.com>" 33 | committer: "octo-sts[bot] <157150467+octo-sts[bot]@users.noreply.github.com>" 34 | signoff: 'true' 35 | labels-for-pr: "automated pr" 36 | branch-for-pr: update-digests 37 | title-for-pr: Update images digests # optional 38 | description-for-pr: Update images digests 39 | commit-message: Update images digests 40 | -------------------------------------------------------------------------------- /.github/workflows/build-and-push.yaml: -------------------------------------------------------------------------------- 1 | name: Build, Sign and Push CHPs Scorer Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | docker: 10 | permissions: 11 | id-token: write # write seems weird, but it is correct per docs 12 | attestations: write 13 | contents: read 14 | packages: write # for pushing to ghcr 15 | 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - 20 | name: Install Cosign 21 | uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2 22 | - 23 | name: Set up QEMU 24 | uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 25 | - 26 | name: Set up Docker Buildx 27 | uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 28 | - 29 | name: Login to GHCR 30 | uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 31 | with: 32 | registry: ghcr.io 33 | username: ${{github.actor}} 34 | password: ${{secrets.GITHUB_TOKEN}} 35 | - 36 | name: Extract metadata (tags, labels) for Docker 37 | id: meta 38 | uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f 39 | with: 40 | images: | 41 | ghcr.io/chps-dev/chps-scorer 42 | labels: | 43 | org.opencontainers.image.description="CHPs Scorer" 44 | org.opencontainers.image.source="https://github.com/chps-dev/chps-scorer" 45 | tags: | 46 | type=raw,value=latest,enable={{is_default_branch}} 47 | - 48 | name: Build and push 49 | id: build 50 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 51 | with: 52 | file: Dockerfile 53 | platforms: linux/amd64,linux/arm64 54 | push: true 55 | sbom: true 56 | provenance: mode=max 57 | tags: ${{ steps.meta.outputs.tags }} 58 | labels: ${{ steps.meta.outputs.labels }} 59 | - 60 | name: Attest 61 | uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v1 62 | id: attest 63 | with: 64 | subject-name: ghcr.io/chps-dev/chps-scorer 65 | subject-digest: ${{ steps.build.outputs.digest }} 66 | push-to-registry: true 67 | - 68 | name: Sign the images with GitHub OIDC Token 69 | env: 70 | DIGEST: ${{ steps.build.outputs.digest }} 71 | TAGS: ${{ steps.meta.outputs.tags }} 72 | run: | 73 | images="" 74 | for tag in ${TAGS}; do 75 | images+="${tag}@${DIGEST} " 76 | done 77 | cosign sign --yes ${images} 78 | -------------------------------------------------------------------------------- /cve_checks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Copyright 2025 The CHPs-dev Authors 4 | # SPDX-License-Identifier: Apache-2.0 5 | 6 | # Function to check for critical CVEs using Grype 7 | check_cves() { 8 | local image=$1 9 | if [ "$SKIP_CVES" = "true" ]; then 10 | echo "0 0 0 0" # Return all zeros when CVEs are skipped 11 | return 0 12 | fi 13 | 14 | if command_exists grype; then 15 | # Count vulnerabilities by severity 16 | local grype_output=$(grype "$image" -o table 2>/dev/null) 17 | local critical=$(echo "$grype_output" | grep -c "Critical") 18 | local high=$(echo "$grype_output" | grep -c "High") 19 | local medium=$(echo "$grype_output" | grep -c "Medium") 20 | local any=$(if echo "$grype_output" | grep -q "No vulnerabilities found"; then echo "0"; else echo "1"; fi) 21 | 22 | # Return as space-separated values 23 | echo "$critical $high $medium $any" 24 | else 25 | echo "Grype not installed. Skipping CVE checks." >&2 26 | return 1 27 | fi 28 | } 29 | 30 | 31 | # Function to run all CVE checks 32 | run_cve_checks() { 33 | local image=$1 34 | local cve_score=0 35 | local results=() 36 | 37 | echo -e "\nChecking CVE criteria..." >&2 38 | local vulns=($(check_cves "$image")) 39 | 40 | if [ ${#vulns[@]} -eq 4 ]; then 41 | if [ "${vulns[0]}" -eq 0 ]; then 42 | echo -e "${GREEN}✓ No critical vulnerabilities (Level 2)${NC}" >&2 43 | ((cve_score++)) 44 | results+=("critical_vulns:pass") 45 | else 46 | echo -e "${RED}✗ Found ${vulns[0]} critical vulnerabilities${NC}" >&2 47 | results+=("critical_vulns:fail") 48 | fi 49 | 50 | if [ "${vulns[1]}" -eq 0 ]; then 51 | echo -e "${GREEN}✓ No high vulnerabilities (Level 3)${NC}" >&2 52 | ((cve_score++)) 53 | results+=("high_vulns:pass") 54 | else 55 | echo -e "${RED}✗ Found ${vulns[1]} high vulnerabilities${NC}" >&2 56 | results+=("high_vulns:fail") 57 | fi 58 | 59 | if [ "${vulns[2]}" -eq 0 ]; then 60 | echo -e "${GREEN}✓ No medium vulnerabilities (Level 4)${NC}" >&2 61 | ((cve_score++)) 62 | results+=("medium_vulns:pass") 63 | else 64 | echo -e "${RED}✗ Found ${vulns[2]} medium vulnerabilities${NC}" >&2 65 | results+=("medium_vulns:fail") 66 | fi 67 | 68 | if [ "${vulns[3]}" -eq 0 ]; then 69 | echo -e "${GREEN}✓ No vulnerabilities found (Level 5)${NC}" >&2 70 | ((cve_score++)) 71 | results+=("any_vulns:pass") 72 | else 73 | echo -e "${RED}✗ Vulnerabilities found (possibly negligible or unknown)${NC}" >&2 74 | results+=("any_vulns:fail") 75 | fi 76 | else 77 | echo -e "${YELLOW}⚠ Could not check vulnerabilities${NC}" >&2 78 | results+=("critical_vulns:skip") 79 | results+=("high_vulns:skip") 80 | results+=("medium_vulns:skip") 81 | results+=("any_vulns:skip") 82 | fi 83 | 84 | # Output JSON 85 | echo "{" 86 | echo " \"score\": $cve_score," 87 | echo " \"checks\": {" 88 | for ((i=0; i<${#results[@]}; i++)); do 89 | local check=${results[$i]} 90 | local name=${check%%:*} 91 | local result=${check##*:} 92 | echo -n " \"$name\": \"$result\"" 93 | if [ $i -lt $((${#results[@]}-1)) ]; then 94 | echo "," 95 | else 96 | echo "" 97 | fi 98 | done 99 | echo " }" 100 | echo "}" 101 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CHPs Scorer 2 | 3 | ![Minimalism Badge](https://img.shields.io/badge/minimalism-B-gold?style=flat-square&labelColor=%233443F4&color=%23FFB000) 4 | ![Provenance Badge](https://img.shields.io/badge/provenance-A-gold?style=flat-square&labelColor=%233443F4&color=%2304B45F) 5 | ![Configuration Badge](https://img.shields.io/badge/configuration-A-gold?style=flat-square&labelColor=%233443F4&color=%2304B45F) 6 | ![CVE Badge](https://img.shields.io/badge/cves-B-gold?style=flat-square&labelColor=%233443F4&color=%23FFB000) 7 | ![Overall Badge](https://img.shields.io/badge/overall-B-gold?style=flat-square&labelColor=%233443F4&color=%23FFB000) 8 | 9 | This tool implements automated checks for the [CHPs specification](https://github.com/chps-dev/chps). 10 | 11 |

12 | Example run of CHPs Scorer 13 |

14 | 15 | ## Installation 16 | 17 | As the scorer has several requirements and is at its heart a funky bash script, it's best to run 18 | using the container image i.e: 19 | 20 | ```bash 21 | docker run --rm --privileged ghcr.io/chps-dev/chps-scorer:latest 22 | ``` 23 | 24 | For scoring a local container, use the following command to mount the container engine socket from the host into the chps-scorer container: 25 | 26 | ```bash 27 | docker run --rm --privileged \ 28 | --volume /var/run/docker.sock:/var/run/docker.sock \ 29 | ghcr.io/chps-dev/chps-scorer:latest \ 30 | --local 31 | ``` 32 | 33 | Unfortunately, the `--privileged` is required as we're using docker-in-docker. 34 | 35 | ## Badges 36 | 37 | The script will output markdown at the end for creating badges similar to those at the top of 38 | this page. You can then include these in your project pages. 39 | 40 | In the future we'd like to create a service that automates badge creation similar to 41 | https://goreportcard.com/ or create an online score card like [OpenSSF Scorecard](https://github.com/ossf/scorecard). 42 | 43 | ## CHPs Scorer GitHub Action 44 | 45 | Checkout the [CHPs Scorer GitHub Action](https://github.com/chps-dev/chps-scorer-github-action/) to automatically generate CHPs scores for your container and create GitHub issues to triage issues effectively. 46 | 47 | ## Dependencies 48 | 49 | If you want to run the script locally, you will need the following software installed for full 50 | functionality. The scripts have been tested on MacOS, let me know of any issues running in Linux. 51 | 52 | - bash 53 | - Docker 54 | - jq (for JSON processing) 55 | - curl (for API requests) 56 | - [crane](https://github.com/google/go-containerregistry/tree/main/cmd/crane) (for size check) 57 | - [cosign](https://github.com/sigstore/cosign) (for signature verification) 58 | - [grype](https://github.com/anchore/grype) (optional, for CVE scanning) 59 | - [trufflehog](https://github.com/trufflesecurity/trufflehog) (for secret scanning) 60 | 61 | 62 | ## Usage 63 | 64 | Basic usage: 65 | ```bash 66 | ./chps-scorer.sh [options] 67 | ``` 68 | 69 | Options: 70 | - `-o json`: Output results in JSON format 71 | - `--skip-cves`: Skip CVE scanning 72 | - `-d `: Provide a Dockerfile for additional checks 73 | - `--local`: Use a local image 74 | 75 | Example: 76 | ```bash 77 | # Basic scoring 78 | ./chps-scorer.sh nginx:latest 79 | 80 | # JSON output with CVE scanning disabled 81 | ./chps-scorer.sh -o json --skip-cves nginx:latest 82 | 83 | # With Dockerfile for additional checks 84 | ./chps-scorer.sh -d Dockerfile myapp:latest 85 | 86 | # Locally available image 87 | ./chps-scorer.sh --local myapp:latest 88 | ``` 89 | 90 | ## Scoring System 91 | 92 | The total maximum score is 20 points, broken down as follows: 93 | 94 | - Minimalism: 4 points 95 | - Provenance: 8 points 96 | - Configuration: 4 points 97 | - CVEs: 4 points 98 | 99 | Grades are assigned based on the percentage of points achieved. 100 | 101 | ## Output 102 | 103 | The tool provides both human-readable and JSON output formats. The JSON output includes: 104 | - Individual scores for each category 105 | - Detailed check results 106 | - Overall score and grade 107 | - Badge URLs for visual representation 108 | 109 | Example JSON output: 110 | ```json 111 | { 112 | "image": "nginx:latest", 113 | "digest": "nginx@sha256:...", 114 | "scores": { 115 | "minimalism": { 116 | "score": 1, 117 | "max": 4, 118 | "grade": "D", 119 | "checks": { 120 | "minimal_base": "fail", 121 | "build_tooling": "pass", 122 | "shell": "fail", 123 | "package_manager": "fail" 124 | } 125 | }, 126 | ... 127 | }, 128 | "overall": { 129 | "score": 10, 130 | "max": 20, 131 | "percentage": 50, 132 | "grade": "C" 133 | } 134 | } 135 | ``` 136 | -------------------------------------------------------------------------------- /config_checks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Copyright 2025 The CHPs-dev Authors 4 | # SPDX-License-Identifier: Apache-2.0 5 | 6 | # Function to check if user is root 7 | check_root_user() { 8 | local image=$1 9 | local user=$(docker inspect "$image" --format '{{.Config.User}}' 2>/dev/null) 10 | if [ -z "$user" ] || [ "$user" = "root" ]; then 11 | return 0 # root user 12 | else 13 | return 1 # non-root user 14 | fi 15 | } 16 | 17 | # Function to check for files with elevated privileges 18 | check_elevated_privileges() { 19 | local image=$1 20 | # Check for SUID/SGID files 21 | # First check if find command exists 22 | if ! docker run --rm --entrypoint which "$image" find >/dev/null 2>&1; then 23 | echo "Warning: 'find' command not available in image - cannot check for elevated privileges" >&2 24 | return 0 25 | fi 26 | 27 | if docker run --rm --user root --entrypoint find "$image" / -type f -perm /6000 2>/dev/null | grep -q .; then 28 | return 1 29 | fi 30 | return 0 31 | } 32 | 33 | # Function to check for secrets in image using Trufflehog 34 | check_secrets() { 35 | local image=$1 36 | local dockerfile=$2 37 | local found_secrets=0 38 | 39 | if ! crane manifest --platform linux/amd64 $image > /dev/null 2>&1; then 40 | echo "Skipping trufflehog secret check as no linux/amd64 image was found" >&2 41 | return 0 42 | else 43 | echo "Checking for secrets using Trufflehog..." >&2 44 | fi 45 | 46 | # Check we have trufflehog installed 47 | if command -v trufflehog >/dev/null 2>&1; then 48 | 49 | # Check Docker image 50 | if trufflehog docker --detector-timeout=20s --image "$image" --only-verified 2>/dev/null | grep -q "Found"; then 51 | found_secrets=1 52 | echo -e "${RED}✗ Trufflehog found verified secrets in the image${NC}" >&2 53 | fi 54 | else 55 | echo "Trufflehog not found, skipping secret check" >&2 56 | fi 57 | 58 | if [ $found_secrets -eq 1 ]; then 59 | return 1 60 | fi 61 | 62 | return 0 63 | } 64 | 65 | # Function to check for annotations 66 | check_annotations() { 67 | local image=$1 68 | # Check for org.opencontainers.image annotations 69 | if docker inspect "$image" | jq '.[].Config.Labels | with_entries(select(.key | startswith("org.opencontainers.image")))' >/dev/null 2>&1; then 70 | return 0 71 | fi 72 | 73 | return 1 74 | } 75 | 76 | # Function to run all configuration checks 77 | run_config_checks() { 78 | local image=$1 79 | local dockerfile=$2 80 | local config_score=0 81 | local results=() 82 | 83 | echo -e "\nChecking Configuration criteria..." >&2 84 | 85 | if check_secrets "$image" "$dockerfile"; then 86 | echo -e "${GREEN}✓ No obvious secrets found in image metadata or Dockerfile (Level 1)${NC}" >&2 87 | ((config_score++)) 88 | results+=("secrets:pass") 89 | else 90 | echo -e "${RED}✗ Secrets found${NC}" >&2 91 | results+=("secrets:fail") 92 | fi 93 | 94 | if check_elevated_privileges "$image"; then 95 | echo -e "${GREEN}✓ No files with elevated privileges (Level 2)${NC}" >&2 96 | ((config_score++)) 97 | results+=("elevated_privileges:pass") 98 | else 99 | echo -e "${RED}✗ Files with elevated privileges found${NC}" >&2 100 | results+=("elevated_privileges:fail") 101 | fi 102 | 103 | if ! check_root_user "$image"; then 104 | echo -e "${GREEN}✓ Non-root user (Level 2)${NC}" >&2 105 | ((config_score++)) 106 | results+=("root_user:pass") 107 | else 108 | echo -e "${RED}✗ Running as root${NC}" >&2 109 | results+=("root_user:fail") 110 | fi 111 | 112 | echo -e "${YELLOW}✓ Not practical to check for file mounts for secret (Level 3)${NC}" >&2 113 | results+=("file_mounts:skip") 114 | 115 | if check_annotations "$image"; then 116 | echo -e "${GREEN}✓ Annotations found (Level 3)${NC}" >&2 117 | ((config_score++)) 118 | results+=("annotations:pass") 119 | else 120 | echo -e "${RED}✗ No annotations found${NC}" >&2 121 | results+=("annotations:fail") 122 | fi 123 | 124 | echo -e "${YELLOW}✓ Not practical to check for security profiles (Level 5)${NC}" >&2 125 | results+=("security_profiles:skip") 126 | 127 | # Output JSON 128 | echo "{" 129 | echo " \"score\": $config_score," 130 | echo " \"checks\": {" 131 | for ((i=0; i<${#results[@]}; i++)); do 132 | local check=${results[$i]} 133 | local name=${check%%:*} 134 | local result=${check##*:} 135 | echo -n " \"$name\": \"$result\"" 136 | if [ $i -lt $((${#results[@]}-1)) ]; then 137 | echo "," 138 | else 139 | echo "" 140 | fi 141 | done 142 | echo " }" 143 | echo "}" 144 | } -------------------------------------------------------------------------------- /minimalism_checks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Copyright 2025 The CHPs-dev Authors 4 | # SPDX-License-Identifier: Apache-2.0 5 | 6 | # Function to check for shell 7 | check_shell() { 8 | local image=$1 9 | local shells=("/bin/sh" "/bin/bash" "/bin/ash" "/bin/zsh") 10 | for shell in "${shells[@]}"; do 11 | if check_file_exists "$image" "$shell"; then 12 | return 1 13 | fi 14 | done 15 | return 0 16 | } 17 | 18 | # Function to check if a file exists in the image 19 | check_file_exists() { 20 | local image=$1 21 | local file=$2 22 | local container_id=$(docker create "$image") 23 | local result=1 24 | if docker cp "$container_id:$file" - >/dev/null 2>&1; then 25 | result=0 26 | fi 27 | docker rm "$container_id" >/dev/null 2>&1 28 | return $result 29 | } 30 | 31 | # Function to check if a package exists in the image 32 | check_package_exists() { 33 | local image=$1 34 | local package=$2 35 | # Big assumption here that "which" exists 36 | docker run --rm --entrypoint which "$image" "$package" >/dev/null 2>&1 37 | } 38 | 39 | # Function to check for package managers 40 | check_package_manager() { 41 | local image=$1 42 | local package_managers=("apt" "apk" "yum" "dnf" "pip" "npm") 43 | for pm in "${package_managers[@]}"; do 44 | if check_package_exists "$image" "$pm"; then 45 | return 1 46 | fi 47 | done 48 | return 0 49 | } 50 | 51 | # Function to check for build and debug tooling 52 | check_build_tooling() { 53 | local image=$1 54 | local build_tools=("gcc" "g++" "make" "cmake" "gdb" "lldb" "strace" "ltrace" "perf" "maven" "javac" "cargo" "npm" "yarn" "pip" "pip3") 55 | for tool in "${build_tools[@]}"; do 56 | if check_package_exists "$image" "$tool"; then 57 | return 1 58 | fi 59 | done 60 | return 0 61 | } 62 | 63 | # Function to check for minimal base image 64 | check_minimal_base() { 65 | local image=$1 66 | local dockerfile=$2 67 | local use_local_image=$3 68 | 69 | # If Dockerfile is provided, check the final FROM statement (normally production build) 70 | if [ -n "$dockerfile" ]; then 71 | local base_image=$(grep -i "^FROM" "$dockerfile" | tail -n1 | awk '{print $2}') 72 | if [[ "$base_image" =~ ^(cgr\.dev/|alpine|slim|scratch) ]]; then 73 | echo "Dockerfile uses a minimal base image $base_image" >&2 74 | return 0 75 | fi 76 | fi 77 | 78 | # Fall back to size check 79 | arch=$(uname -m) 80 | case "$arch" in 81 | x86_64) arch="amd64" ;; 82 | aarch64) arch="arm64" ;; 83 | esac 84 | # If local image is provided, use docker to prevent crane from reaching out to remote registry 85 | if [ ! "$use_local_image" = "false" ]; then 86 | local size_bytes=$(docker image inspect --platform linux/${arch} "$image" --format '{{.Size}}') 87 | else 88 | local size_bytes=$(crane manifest --platform linux/${arch} "$image" | jq '.config.size + ([.layers[].size] | add)') 89 | fi 90 | 91 | if [ "$size_bytes" -lt 40000000 ]; then 92 | echo "Compressed image is $size_bytes bytes, assuming minimal base image" >&2 93 | return 0 94 | fi 95 | return 1 96 | } 97 | 98 | # Function to run all minimalism checks 99 | run_minimalism_checks() { 100 | local image=$1 101 | local dockerfile=$2 102 | local use_local_image=$3 103 | local minimalism_score=0 104 | local results=() 105 | 106 | echo -e "\nChecking Minimalism criteria..." >&2 107 | 108 | if check_minimal_base "$image" "$dockerfile" "$use_local_image"; then 109 | echo -e "${GREEN}✓ Using minimal base image (compressed image <40MB) (Level 1)${NC}" >&2 110 | ((minimalism_score++)) 111 | results+=("minimal_base:pass") 112 | else 113 | echo -e "${RED}✗ Not using minimal base image (compressed image >40MB) (Level 1)${NC}" >&2 114 | results+=("minimal_base:fail") 115 | fi 116 | 117 | if check_build_tooling "$image"; then 118 | echo -e "${GREEN}✓ No build/debug tooling found (Level 2)${NC}" >&2 119 | ((minimalism_score++)) 120 | results+=("build_tooling:pass") 121 | else 122 | echo -e "${RED}✗ Build/debug tooling found${NC}" >&2 123 | results+=("build_tooling:fail") 124 | fi 125 | 126 | if check_shell "$image"; then 127 | echo -e "${GREEN}✓ No shell found (Level 3)${NC}" >&2 128 | ((minimalism_score++)) 129 | results+=("shell:pass") 130 | else 131 | echo -e "${RED}✗ Shell found${NC}" >&2 132 | results+=("shell:fail") 133 | fi 134 | 135 | if check_package_manager "$image"; then 136 | echo -e "${GREEN}✓ No package manager found (Level 3)${NC}" >&2 137 | ((minimalism_score++)) 138 | results+=("package_manager:pass") 139 | else 140 | echo -e "${RED}✗ Package manager found${NC}" >&2 141 | results+=("package_manager:fail") 142 | fi 143 | 144 | # Output JSON 145 | echo "{" 146 | echo " \"score\": $minimalism_score," 147 | echo " \"checks\": {" 148 | for ((i=0; i<${#results[@]}; i++)); do 149 | local check=${results[$i]} 150 | local name=${check%%:*} 151 | local result=${check##*:} 152 | echo -n " \"$name\": \"$result\"" 153 | if [ $i -lt $((${#results[@]}-1)) ]; then 154 | echo "," 155 | else 156 | echo "" 157 | fi 158 | done 159 | echo " }" 160 | echo "}" 161 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /provenance_checks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Copyright 2025 The CHPs-dev Authors 4 | # SPDX-License-Identifier: Apache-2.0 5 | 6 | # Function to check if image is signed 7 | check_image_is_signed() { 8 | local image=$1 9 | local has_signature=0 10 | 11 | # Check for Cosign signatures 12 | if command_exists cosign; then 13 | # Note we don't care who signed the image, we just want to know if it is signed 14 | if cosign verify --certificate-identity-regexp=".*" --certificate-oidc-issuer-regexp=".*" "$image" >/dev/null 2>&1; then 15 | has_signature=1 16 | fi 17 | fi 18 | 19 | # Check for Docker Content Trust signatures 20 | if [ $has_signature -eq 0 ]; then 21 | if docker trust inspect "$image" >/dev/null 2>&1; then 22 | has_signature=1 23 | fi 24 | fi 25 | 26 | if [ $has_signature -eq 1 ]; then 27 | return 0 28 | else 29 | echo "No signatures found (neither Cosign nor Docker Content Trust)" >&2 30 | return 1 31 | fi 32 | } 33 | 34 | # Function to check for SBOM 35 | check_sbom() { 36 | local image=$1 37 | local has_sbom=0 38 | 39 | # Check for Cosign SBOM attestations 40 | if command_exists cosign; then 41 | # Check for SPDX and CycloneDX SBOM attestations 42 | if cosign verify-attestation \ 43 | --type spdx \ 44 | --certificate-identity-regexp=".*" \ 45 | --certificate-oidc-issuer-regexp=".*" \ 46 | "$image" >/dev/null 2>&1; then 47 | has_sbom=1 48 | elif cosign verify-attestation \ 49 | --type spdxjson \ 50 | --certificate-identity-regexp=".*" \ 51 | --certificate-oidc-issuer-regexp=".*" \ 52 | "$image" >/dev/null 2>&1; then 53 | has_sbom=1 54 | elif cosign verify-attestation \ 55 | --type cyclonedx \ 56 | --certificate-identity-regexp=".*" \ 57 | --certificate-oidc-issuer-regexp=".*" \ 58 | "$image" >/dev/null 2>&1; then 59 | has_sbom=1 60 | fi 61 | fi 62 | 63 | if [ $has_sbom -eq 0 ]; then 64 | if [ "$(docker buildx imagetools inspect --format '{{ json .SBOM.SPDX }}' "$image")" != "null" ]; then 65 | has_sbom=1 66 | # if it's a multi-arch image, let's assume there is a linux/amd64 SBOM 67 | elif [ docker buildx imagetools inspect --format '{{ json (index .SBOM "linux/amd64").SPDX }}' "$image" > /dev/null 2>&1 ]; then 68 | if [ "$(docker buildx imagetools inspect --format '{{ json (index .SBOM "linux/amd64").SPDX }}' "$image")" != "null" ]; then 69 | has_sbom=1 70 | fi 71 | fi 72 | fi 73 | if [ $has_sbom -eq 1 ]; then 74 | return 0 75 | else 76 | echo "No SBOM attestations found (checked for SPDX and CycloneDX formats)" >&2 77 | return 1 78 | fi 79 | } 80 | 81 | # Function to check for pinned images 82 | check_pinned_images() { 83 | local image=$1 84 | local dockerfile=$2 85 | 86 | # If Dockerfile is provided, check FROM statements in it 87 | if [ -n "$dockerfile" ]; then 88 | local unpinned_refs=$(grep -i "^FROM" "$dockerfile" | grep -v '@sha256:') 89 | 90 | if [ -n "$unpinned_refs" ]; then 91 | echo "Found unpinned image references in Dockerfile:" >&2 92 | echo "$unpinned_refs" | while read -r line; do 93 | echo -e "${RED}✗ $line${NC}" >&2 94 | done 95 | return 1 96 | fi 97 | return 0 98 | 99 | else 100 | echo -e "${YELLOW}No Dockerfile provided, skipping pinned image check${NC}" >&2 101 | return 0 102 | fi 103 | 104 | } 105 | 106 | # Function to check for pinned packages 107 | check_pinned_packages() { 108 | local image=$1 109 | local dockerfile=$2 110 | 111 | if [ -z "$dockerfile" ]; then 112 | echo -e "${YELLOW}No Dockerfile provided, skipping package pinning check${NC}" >&2 113 | return 0 114 | fi 115 | 116 | local has_unpinned=0 117 | local unpinned_packages="" 118 | 119 | # Read the Dockerfile and check for package installations 120 | while IFS= read -r line; do 121 | # Skip comments and empty lines 122 | [[ "$line" =~ ^[[:space:]]*# ]] && continue 123 | [ -z "$line" ] && continue 124 | 125 | # Check apt-get install without version pins 126 | if echo "$line" | grep -E 'apt-get.*install' >/dev/null; then 127 | if ! echo "$line" | grep -E '[=><][0-9]' >/dev/null; then 128 | has_unpinned=1 129 | unpinned_packages+="✗ Found unpinned apt packages: $line\n" 130 | fi 131 | fi 132 | 133 | # Check apk add without version pins 134 | if echo "$line" | grep -E 'apk add' >/dev/null; then 135 | if ! echo "$line" | grep -E '=[0-9]' >/dev/null; then 136 | has_unpinned=1 137 | unpinned_packages+="✗ Found unpinned apk packages: $line\n" 138 | fi 139 | fi 140 | 141 | # Check pip install without version pins 142 | if echo "$line" | grep -E 'pip.*install' >/dev/null; then 143 | if ! echo "$line" | grep -E '[=><][0-9]' >/dev/null && ! echo "$line" | grep -E 'pip.*install.*-r' >/dev/null; then 144 | has_unpinned=1 145 | unpinned_packages+="✗ Found unpinned pip packages: $line\n" 146 | fi 147 | fi 148 | 149 | # Check npm install without version pins 150 | if echo "$line" | grep -E 'npm.*install' >/dev/null; then 151 | if ! echo "$line" | grep -E '@[0-9]' >/dev/null; then 152 | has_unpinned=1 153 | unpinned_packages+="✗ Found unpinned npm packages: $line\n" 154 | fi 155 | fi 156 | 157 | # Check gem install without version pins 158 | if echo "$line" | grep -E 'gem.*install' >/dev/null; then 159 | if ! echo "$line" | grep -E ':[0-9]|-v' >/dev/null; then 160 | has_unpinned=1 161 | unpinned_packages+="✗ Found unpinned gem packages: $line\n" 162 | fi 163 | fi 164 | done < "$dockerfile" 165 | 166 | if [ $has_unpinned -eq 1 ]; then 167 | echo -e "${RED}Found unpinned package installations:${NC}" >&2 168 | echo -e "$unpinned_packages" >&2 169 | return 1 170 | fi 171 | 172 | return 0 173 | } 174 | 175 | # Function to check for provenance attestations 176 | check_attestations() { 177 | local image=$1 178 | local has_attestations=0 179 | 180 | # Check for Cosign attestations 181 | if command_exists cosign; then 182 | # Check for SLSA provenance attestations with specific predicate type 183 | if cosign verify-attestation \ 184 | --type slsaprovenance1 \ 185 | --certificate-identity-regexp=".*" \ 186 | --certificate-oidc-issuer-regexp=".*" \ 187 | "$image" >/dev/null 2>&1; then 188 | has_attestations=1 189 | elif cosign verify-attestation \ 190 | --type slsaprovenance \ 191 | --certificate-identity-regexp=".*" \ 192 | --certificate-oidc-issuer-regexp=".*" \ 193 | "$image" >/dev/null 2>&1; then 194 | has_attestations=1 195 | elif cosign verify-attestation \ 196 | --type slsaprovenance02 \ 197 | --certificate-identity-regexp=".*" \ 198 | --certificate-oidc-issuer-regexp=".*" \ 199 | "$image" >/dev/null 2>&1; then 200 | has_attestations=1 201 | fi 202 | fi 203 | 204 | if [ $has_attestations -eq 0 ]; then 205 | if [ "$(docker buildx imagetools inspect --format '{{ json .Provenance.SLSA }}' "$image")" != "null" ]; then 206 | has_attestations=1 207 | # if it's a multi-arch image, let's assume there is linux/amd64 208 | elif [ "$(docker buildx imagetools inspect --format '{{ json (index .Provenance "linux/amd64").SLSA }}' "$image")" != "null" ]; then 209 | has_attestations=1 210 | fi 211 | fi 212 | 213 | if [ $has_attestations -eq 1 ]; then 214 | return 0 215 | else 216 | echo "No SLSA provenance attestations found" >&2 217 | return 1 218 | fi 219 | } 220 | 221 | # Function to check for download verification 222 | check_download_verification() { 223 | local image=$1 224 | local dockerfile=$2 225 | 226 | # If Dockerfile is provided, check it 227 | if [ -n "$dockerfile" ]; then 228 | local has_downloads=0 229 | local has_verification=0 230 | 231 | # Read the Dockerfile and check for downloads and verification 232 | while IFS= read -r line; do 233 | # Skip apk add lines 234 | if echo "$line" | grep -iE 'apk.*add' >/dev/null; then 235 | continue 236 | fi 237 | 238 | if echo "$line" | grep -iE 'wget|curl' >/dev/null; then 239 | has_downloads=1 240 | fi 241 | 242 | if echo "$line" | grep -iE 'gpg|md5sum|sha256sum|sha512sum' >/dev/null; then 243 | has_verification=1 244 | fi 245 | done < "$dockerfile" 246 | 247 | # If we found downloads in Dockerfile, check for verification 248 | if [ $has_downloads -eq 1 ]; then 249 | if [ $has_verification -eq 1 ]; then 250 | return 0 251 | else 252 | echo "Found download commands but no verification commands in Dockerfile" >&2 253 | return 1 254 | fi 255 | fi 256 | # If no downloads found in Dockerfile, that's good 257 | return 0 258 | fi 259 | 260 | # Fall back to checking docker history if no Dockerfile 261 | local history=$(docker history --no-trunc "$image" --format '{{.CreatedBy}}') 262 | 263 | # First check if there are any downloads at all 264 | if ! echo "$history" | grep -iE 'wget|curl' >/dev/null; then 265 | return 0 # No downloads found, so verification is not needed 266 | fi 267 | 268 | # If we found downloads, check for any verification commands 269 | if echo "$history" | grep -iE 'gpg|md5sum|sha256sum|sha512sum' >/dev/null; then 270 | return 0 # Found verification commands 271 | fi 272 | 273 | # We found downloads but no verification 274 | echo "Found download commands but no verification commands in image history" >&2 275 | return 1 276 | } 277 | 278 | # Function to check for trusted source 279 | check_trusted_source() { 280 | local image=$1 281 | local dockerfile=$2 282 | 283 | # If we have a Dockerfile, check the FROMs 284 | if [ -z "$dockerfile" ]; then 285 | return 0 286 | else 287 | echo -e "Checking FROMs in Dockerfile" >&2 288 | fi 289 | 290 | # Get all FROM statements 291 | local from_images=$(grep -i "^FROM" "$dockerfile" | awk '{print $2}') 292 | 293 | # Check each FROM statement 294 | while IFS= read -r base_image; do 295 | # Skip empty lines 296 | [ -z "$base_image" ] && continue 297 | 298 | # Check if base image has a domain name (contains a dot) or is from a user repository 299 | # Extract everything before first slash (or full string if no slash) 300 | local prefix=${base_image%%/*} 301 | 302 | if [[ "$prefix" =~ \. ]]; then 303 | # Has domain name before slash, consider trusted 304 | return 0 305 | elif [[ "$base_image" =~ / ]]; then 306 | # No domain but has slash - likely user repo 307 | echo "Base image $base_image appears to be from a user repository" >&2 308 | return 1 309 | else 310 | # Official Docker Hub image 311 | return 0 312 | fi 313 | done <<< "$from_images" 314 | 315 | return 0 316 | } 317 | 318 | # Function to check if image was built in the last 30 days 319 | check_recent_build() { 320 | local image_sha=$1 321 | local image=$ORIGINAL_IMAGE 322 | local current_time=$(date +%s) 323 | local thirty_days_ago=$((current_time - 2592000)) # 30 days in seconds 324 | 325 | # If this is a Docker Hub image, check the push date 326 | if [[ "$image" != *"."* || "$image" == "docker.io/"* ]]; then 327 | #echo "Have docker hub image" >&2 328 | 329 | # Remove docker.io/ and library/ from the image name 330 | local repo_tag="${image#docker.io/}" 331 | repo_tag="${repo_tag#library/}" 332 | 333 | # Handle images without tags (default to 'latest') 334 | if [[ "$repo_tag" != *":"* ]]; then 335 | repo_tag="${repo_tag}:latest" 336 | fi 337 | 338 | local repo="${repo_tag%:*}" 339 | local tag="${repo_tag##*:}" 340 | 341 | # Handle official images (library/...) 342 | if [[ ! "$repo" =~ "/" ]]; then 343 | repo="library/$repo" 344 | fi 345 | 346 | #echo "Checking last updated date for Docker Hub image: $repo:$tag" >&2 347 | 348 | # Query Docker Hub API for last updated date 349 | local auth_token 350 | local last_updated 351 | 352 | # Get tags list and extract last updated date for our tag 353 | last_updated=$(curl -s "https://hub.docker.com/v2/repositories/$repo/tags/$tag" | jq -r '.last_updated') 354 | #echo "Last updated date: $last_updated" >&2 355 | if [ -n "$last_updated" ]; then 356 | # Convert last updated date to timestamp 357 | local update_timestamp 358 | 359 | # Try GNU date (Linux) 360 | if date --version >/dev/null 2>&1; then 361 | update_timestamp=$(date -d "$last_updated" +%s 2>/dev/null) 362 | else 363 | # Try BSD date (macOS) 364 | # First standardize the format 365 | last_updated=$(echo "$last_updated" | sed -E 's/([0-9]{4}-[0-9]{2}-[0-9]{2})T([0-9]{2}:[0-9]{2}:[0-9]{2})\.[0-9]+Z/\1 \2/') 366 | update_timestamp=$(date -j -f "%Y-%m-%d %H:%M:%S" "$last_updated" +%s 2>/dev/null) 367 | fi 368 | 369 | if [ -n "$update_timestamp" ] && [ "$update_timestamp" -gt 0 ]; then 370 | if [ "$update_timestamp" -ge "$thirty_days_ago" ]; then 371 | local days_old=$(( (current_time - update_timestamp) / 86400 )) 372 | echo "Image tag was last updated $days_old days ago (within last 30 days)" >&2 373 | return 0 # Image was updated less than 30 days ago 374 | else 375 | local days_old=$(( (current_time - update_timestamp) / 86400 )) 376 | echo "Image tag was last updated $days_old days ago" >&2 377 | return 1 # Image was updated more than 30 days ago 378 | fi 379 | fi 380 | fi 381 | 382 | fi 383 | 384 | # For non-Docker Hub images or if Docker Hub API fails, use creation date 385 | local created_date=$(docker inspect --format '{{.Created}}' "$image" 2>/dev/null) 386 | 387 | if [ -z "$created_date" ]; then 388 | echo "Failed to get image creation date" >&2 389 | return 1 390 | fi 391 | 392 | # Extract the date portion for safer parsing 393 | created_date=${created_date%%.*} 394 | if [[ "$created_date" == *Z ]]; then 395 | created_date=${created_date%Z} 396 | fi 397 | 398 | # Try BusyBox date first 399 | if date --help 2>&1 | grep -q "BusyBox"; then 400 | # BusyBox date uses -D for input format 401 | created_timestamp=$(date -D "%Y-%m-%dT%H:%M:%S" -d "$created_date" +%s 2>/dev/null) 402 | # Try GNU date (Linux) 403 | elif date --version >/dev/null 2>&1; then 404 | created_timestamp=$(date -d "$created_date" +%s 2>/dev/null) 405 | else 406 | # Try BSD date (macOS) 407 | created_timestamp=$(date -j -f "%Y-%m-%dT%H:%M:%S" "$created_date" +%s 2>/dev/null) 408 | fi 409 | 410 | if [ -z "$created_timestamp" ] || [ "$created_timestamp" -eq 0 ]; then 411 | echo "Unable to parse image creation date: $created_date" >&2 412 | return 1 413 | fi 414 | 415 | if [ "$created_timestamp" -ge "$thirty_days_ago" ]; then 416 | local days_old=$(( (current_time - created_timestamp) / 86400 )) 417 | echo "Image was built $days_old days ago (within last 30 days)" >&2 418 | return 0 # Image is less than 30 days old 419 | else 420 | local days_old=$(( (current_time - created_timestamp) / 86400 )) 421 | echo "Image was built $days_old days ago" >&2 422 | return 1 # Image is more than 30 days old 423 | fi 424 | } 425 | 426 | # Function to run all provenance checks 427 | run_provenance_checks() { 428 | local image=$1 429 | local dockerfile=$2 430 | local use_local_image=$3 431 | local provenance_score=0 432 | local results=() 433 | 434 | echo -e "\nChecking Provenance criteria..." >&2 435 | 436 | if check_trusted_source "$image" "$dockerfile"; then 437 | echo -e "${GREEN}✓ Image from trusted source (Level 1)${NC}" >&2 438 | ((provenance_score++)) 439 | results+=("trusted_source:pass") 440 | else 441 | echo -e "${RED}✗ Image not from trusted source${NC}" >&2 442 | results+=("trusted_source:fail") 443 | fi 444 | 445 | if check_download_verification "$image" "$dockerfile"; then 446 | echo -e "${GREEN}✓ No unverified downloads (Level 1)${NC}" >&2 447 | ((provenance_score++)) 448 | results+=("download_verification:pass") 449 | else 450 | echo -e "${RED}✗ Unverified downloads found${NC}" >&2 451 | results+=("download_verification:fail") 452 | fi 453 | 454 | if [ ! "$use_local_image" = "false" ]; then 455 | # Skip remote checks but still increment score 456 | echo -e "${YELLOW}✓ Local image provided, skipping signature check${NC}" >&2 457 | 458 | ((provenance_score++)) 459 | results+=("image_signed:pass") 460 | else 461 | if check_image_is_signed "$image"; then 462 | echo -e "${GREEN}✓ Image is signed (Level 2)${NC}" >&2 463 | ((provenance_score++)) 464 | results+=("image_signed:pass") 465 | else 466 | echo -e "${RED}✗ Image is not signed${NC}" >&2 467 | results+=("image_signed:fail") 468 | fi 469 | fi 470 | 471 | if check_recent_build "$image"; then 472 | echo -e "${GREEN}✓ Image was built within the last 30 days (Level 2)${NC}" >&2 473 | ((provenance_score++)) 474 | results+=("recent_build:pass") 475 | else 476 | echo -e "${RED}✗ Image is older than 30 days${NC}" >&2 477 | results+=("recent_build:fail") 478 | fi 479 | 480 | if [ x"$dockerfile" = x ]; then 481 | # Skip Dockerfile checks but still increment score 482 | echo -e "${YELLOW}✓ No Dockerfile provided, skipping digests in FROM statements check${NC}" >&2 483 | echo -e "${YELLOW}✓ No Dockerfile provided, skipping pinned packages check${NC}" >&2 484 | 485 | ((provenance_score+=2)) 486 | results+=("pinned_images:pass") 487 | results+=("pinned_packages:pass") 488 | else 489 | if check_pinned_images "$image" "$dockerfile"; then 490 | echo -e "${GREEN}✓ Uses digests in FROM statements (Level 2)${NC}" >&2 491 | ((provenance_score++)) 492 | results+=("pinned_images:pass") 493 | else 494 | echo -e "${RED}✗ Does not use digests in FROM statements${NC}" >&2 495 | results+=("pinned_images:fail") 496 | fi 497 | 498 | if check_pinned_packages "$image" "$dockerfile"; then 499 | echo -e "${GREEN}✓ Uses pinned packages (Level 2)${NC}" >&2 500 | ((provenance_score++)) 501 | results+=("pinned_packages:pass") 502 | else 503 | echo -e "${RED}✗ Does not use pinned packages${NC}" >&2 504 | results+=("pinned_packages:fail") 505 | fi 506 | fi 507 | 508 | if [ ! "$use_local_image" = "false" ]; then 509 | # Skip remote checks but still increment score 510 | echo -e "${YELLOW}✓ Local image provided, skipping SLSA provenance attestations check${NC}" >&2 511 | 512 | ((provenance_score++)) 513 | results+=("attestations:pass") 514 | else 515 | if check_attestations "$image" "$use_local_image"; then 516 | echo -e "${GREEN}✓ Has provenance attestations (Level 3)${NC}" >&2 517 | ((provenance_score++)) 518 | results+=("attestations:pass") 519 | else 520 | echo -e "${RED}✗ No provenance attestations${NC}" >&2 521 | results+=("attestations:fail") 522 | fi 523 | fi 524 | 525 | if [ ! "$use_local_image" = "false" ]; then 526 | # Skip remote checks but still increment score 527 | echo -e "${YELLOW}✓ Local image provided, skipping SBOM attestations check${NC}" >&2 528 | 529 | ((provenance_score++)) 530 | results+=("sbom:pass") 531 | else 532 | if check_sbom "$image" "$use_local_image"; then 533 | echo -e "${GREEN}✓ Has SBOM (Level 3)${NC}" >&2 534 | ((provenance_score++)) 535 | results+=("sbom:pass") 536 | else 537 | echo -e "${RED}✗ No SBOM${NC}" >&2 538 | results+=("sbom:fail") 539 | fi 540 | fi 541 | 542 | # Output JSON 543 | echo "{" 544 | echo " \"score\": $provenance_score," 545 | echo " \"checks\": {" 546 | for ((i=0; i<${#results[@]}; i++)); do 547 | local check=${results[$i]} 548 | local name=${check%%:*} 549 | local result=${check##*:} 550 | echo -n " \"$name\": \"$result\"" 551 | if [ $i -lt $((${#results[@]}-1)) ]; then 552 | echo "," 553 | else 554 | echo "" 555 | fi 556 | done 557 | echo " }" 558 | echo "}" 559 | } -------------------------------------------------------------------------------- /chps-scorer.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Copyright 2025 The CHPs-dev Authors 4 | # SPDX-License-Identifier: Apache-2.0 5 | 6 | # CHPs Scorer - Container Hardening Priorities Scoring Tool 7 | # This script evaluates OCI container images against the CHPs criteria 8 | 9 | # Displays help 10 | display_help() { 11 | echo "CHPs Scorer" 12 | echo 13 | echo "Container Hardening Priorities Scoring Tool evaluates OCI container images" 14 | echo "against the Container Hardening Priorities (CHPs) criteria." 15 | echo 16 | echo "CHPs home page: https://github.com/chps-dev/chps" 17 | echo "Project home page: https://github.com/chps-dev/chps-scorer" 18 | echo 19 | echo "Usage:" 20 | echo " $0 [options] " 21 | echo 22 | echo "Options:" 23 | echo " --skip-cves Skip CVE checks, defaults to false" 24 | echo " --dockerfile Use Dockerfile for additional checks" 25 | echo " -o, --output Set output format: text (default), json, html, badges" 26 | echo " --local Use local image instead of pulling from registry" 27 | echo " -h, --help Display this help message and exit" 28 | } 29 | 30 | # Colors for output 31 | RED='\033[0;31m' 32 | GREEN='\033[0;32m' 33 | YELLOW='\033[1;33m' 34 | BOLD='\033[1m' 35 | NC='\033[0m' # No Color 36 | 37 | # Grade colors 38 | A_PLUS_COLOR='\033[1;32m' # Bright green 39 | A_COLOR='\033[0;32m' # Green 40 | B_COLOR='\033[1;33m' # Bright yellow 41 | C_COLOR='\033[0;33m' # Yellow 42 | D_COLOR='\033[0;31m' # Red 43 | E_COLOR='\033[1;31m' # Bright red 44 | 45 | # Function to calculate grade based on score 46 | get_grade() { 47 | 48 | # Played with these grades as many vectors only have 4 max points 49 | local score=$1 50 | local max_score=$2 51 | local percentage=$((score * 100 / max_score)) 52 | 53 | if [ "$score" -eq 0 ]; then 54 | echo "E" 55 | elif [ "$score" -eq "$max_score" ]; then 56 | echo "A+" 57 | elif [ $percentage -ge 75 ]; then 58 | echo "A" 59 | elif [ $percentage -ge 50 ]; then 60 | echo "B" 61 | elif [ $percentage -ge 40 ]; then 62 | echo "C" 63 | else 64 | echo "D" 65 | fi 66 | } 67 | 68 | # Source the check modules 69 | source "$(dirname "$0")/minimalism_checks.sh" 70 | source "$(dirname "$0")/provenance_checks.sh" 71 | source "$(dirname "$0")/config_checks.sh" 72 | source "$(dirname "$0")/cve_checks.sh" 73 | 74 | # Function to check if a command exists 75 | command_exists() { 76 | command -v "$1" >/dev/null 2>&1 77 | } 78 | 79 | # Parse command line arguments 80 | SKIP_CVES=false 81 | DOCKERFILE="" 82 | OUTPUT_FORMAT="text" 83 | USE_LOCAL_IMAGE=false 84 | while [[ $# -gt 0 ]]; do 85 | case $1 in 86 | --skip-cves) 87 | SKIP_CVES=true 88 | shift 89 | ;; 90 | -d|--dockerfile) 91 | DOCKERFILE="$2" 92 | shift 2 93 | ;; 94 | -o|--output) 95 | OUTPUT_FORMAT="$2" 96 | shift 2 97 | ;; 98 | -h|--help) 99 | display_help 100 | exit 0 101 | ;; 102 | --local) 103 | USE_LOCAL_IMAGE=true 104 | shift 105 | ;; 106 | *) 107 | break 108 | ;; 109 | esac 110 | done 111 | 112 | # Check if image name is provided 113 | if [ $# -eq 0 ]; then 114 | display_help 115 | exit 1 116 | fi 117 | 118 | # Function to generate badge URL 119 | get_badge_url() { 120 | local label=$1 121 | local grade=$2 122 | local label_color="%233443F4" # Blue label background 123 | local color 124 | 125 | # Set color based on grade 126 | case $grade in 127 | "A+") color="%2301A178";; # Green 128 | "A") color="%2304B45F";; # Light green 129 | "B") color="%23FFB000";; # Yellow 130 | "C") color="%23FF8C00";; # Orange 131 | "D") color="%23FF4400";; # Light red 132 | "E") color="%23FF0000";; # Red 133 | *) color="%23808080";; # Gray for unknown 134 | esac 135 | 136 | # URL encode the grade (replace + with %2B) 137 | local encoded_grade=${grade//+/%2B} 138 | 139 | echo "https://img.shields.io/badge/${label}-${encoded_grade}-gold?style=flat-square&labelColor=${label_color}&color=${color}" 140 | } 141 | 142 | # Function to check if curl exists 143 | check_curl() { 144 | if ! command -v curl &> /dev/null; then 145 | echo "curl is required for image display but not found" >&2 146 | return 1 147 | fi 148 | return 0 149 | } 150 | 151 | # Function to detect terminal image support 152 | detect_term_img_support() { 153 | # Check for iTerm2 154 | if [[ -n "$ITERM_SESSION_ID" ]]; then 155 | if [[ -n "$TERM_PROGRAM_VERSION" ]]; then 156 | echo "iterm" 157 | return 0 158 | fi 159 | fi 160 | 161 | # Check for Kitty 162 | if [[ -n "$KITTY_WINDOW_ID" ]]; then 163 | echo "kitty" 164 | return 0 165 | fi 166 | 167 | # Check for terminals that support sixel 168 | if [[ "$TERM" =~ "xterm" ]] && command -v img2sixel &> /dev/null; then 169 | echo "sixel" 170 | return 0 171 | fi 172 | 173 | echo "none" 174 | return 1 175 | } 176 | 177 | # Function to display image in terminal 178 | display_badge() { 179 | local url="$1" 180 | local term_type="$2" 181 | local tmp_file="/tmp/chps-badge-$RANDOM.png" 182 | 183 | # Download the badge 184 | if ! curl -s "$url" -o "$tmp_file"; then 185 | echo "Failed to download badge" >&2 186 | return 1 187 | fi 188 | 189 | case "$term_type" in 190 | "iterm") 191 | # For iTerm2, we can specify width in pixels or cells 192 | printf '\033]1337;File=inline=1;width=auto;height=auto:' 193 | base64 < "$tmp_file" 194 | printf '\a\n' 195 | ;; 196 | "kitty") 197 | # For Kitty, we can specify scale factor 198 | kitty +kitten icat --scale-up --transfer-mode=file --align=left "$tmp_file" 199 | ;; 200 | "sixel") 201 | # For Sixel, we can specify width 202 | img2sixel "$tmp_file" 203 | ;; 204 | esac 205 | 206 | rm -f "$tmp_file" 207 | } 208 | 209 | # Function to get grade color 210 | get_grade_color() { 211 | local grade=$1 212 | case $grade in 213 | "A+") echo -e "${A_PLUS_COLOR}";; 214 | "A") echo -e "${A_COLOR}";; 215 | "B") echo -e "${B_COLOR}";; 216 | "C") echo -e "${C_COLOR}";; 217 | "D") echo -e "${D_COLOR}";; 218 | "E") echo -e "${E_COLOR}";; 219 | *) echo -e "${NC}";; 220 | esac 221 | } 222 | output_html() { 223 | local image=$1 224 | local digest=$2 225 | local minimalism_score=$3 226 | local provenance_score=$4 227 | local config_score=$5 228 | local cve_score=$6 229 | local total_score=$7 230 | local max_score=$8 231 | local percentage=$9 232 | local grade=${10} 233 | local minimalism_json=${11} 234 | local provenance_json=${12} 235 | local config_json=${13} 236 | local cve_json=${14} 237 | 238 | # Calculate individual section grades 239 | local -r minimalism_grade=$(get_grade "$minimalism_score" 4) 240 | local -r provenance_grade=$(get_grade "$provenance_score" 8) 241 | local -r config_grade=$(get_grade "$config_score" 4) 242 | local -r cve_grade=$(get_grade "$cve_score" 4) 243 | 244 | # Generate badge URLs 245 | local -r overall_badge=$(get_badge_url "overall" "$grade") 246 | local -r minimalism_badge=$(get_badge_url "minimalism" "$minimalism_grade") 247 | local -r provenance_badge=$(get_badge_url "provenance" "$provenance_grade") 248 | local -r config_badge=$(get_badge_url "configuration" "$config_grade") 249 | local -r cve_badge=$(get_badge_url "cves" "$cve_grade") 250 | 251 | cat << EOF 252 | 253 | 254 | 255 | 256 | 257 | CHPs Scorer Report 258 | 267 | 268 | 269 |

CHPs Scorer Report

270 |

Image: $image

271 |

Digest: $digest

272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 |
CategoryScoreMax ScoreGradeBadge
Minimalism$minimalism_score4$minimalism_gradeMinimalism Badge
Provenance$provenance_score8$provenance_gradeProvenance Badge
Configuration$config_score4$config_gradeConfiguration Badge
CVE$cve_score4$cve_gradeCVE Badge
Overall$total_score$max_score$gradeOverall Badge
322 |

Overall Percentage: $percentage%

323 | 324 | 325 | EOF 326 | } 327 | 328 | # Function to output scores in JSON format 329 | output_json() { 330 | local image=$1 331 | local digest=$2 332 | local minimalism_score=$3 333 | local provenance_score=$4 334 | local config_score=$5 335 | local cve_score=$6 336 | local total_score=$7 337 | local max_score=$8 338 | local percentage=$9 339 | local grade=${10} 340 | local minimalism_json=${11} 341 | local provenance_json=${12} 342 | local config_json=${13} 343 | local cve_json=${14} 344 | 345 | # Calculate individual section grades 346 | local -r minimalism_grade=$(get_grade "$minimalism_score" 4) 347 | local -r provenance_grade=$(get_grade "$provenance_score" 8) 348 | local -r config_grade=$(get_grade "$config_score" 4) 349 | local -r cve_grade=$(get_grade "$cve_score" 4) 350 | 351 | # Generate badge URLs 352 | local -r overall_badge=$(get_badge_url "overall" "$grade") 353 | local -r minimalism_badge=$(get_badge_url "minimalism" "$minimalism_grade") 354 | local -r provenance_badge=$(get_badge_url "provenance" "$provenance_grade") 355 | local -r config_badge=$(get_badge_url "configuration" "$config_grade") 356 | local -r cve_badge=$(get_badge_url "cves" "$cve_grade") 357 | 358 | cat << EOF 359 | { 360 | "image": "$image", 361 | "digest": "$digest", 362 | "scores": { 363 | "minimalism": { 364 | "score": $minimalism_score, 365 | "max": 4, 366 | "grade": "$minimalism_grade", 367 | "badge": "$minimalism_badge", 368 | "checks": $(echo "$minimalism_json" | jq '.checks') 369 | }, 370 | "provenance": { 371 | "score": $provenance_score, 372 | "max": 8, 373 | "grade": "$provenance_grade", 374 | "badge": "$provenance_badge", 375 | "checks": $(echo "$provenance_json" | jq '.checks') 376 | }, 377 | "configuration": { 378 | "score": $config_score, 379 | "max": 4, 380 | "grade": "$config_grade", 381 | "badge": "$config_badge", 382 | "checks": $(echo "$config_json" | jq '.checks') 383 | }, 384 | "cves": { 385 | "score": $cve_score, 386 | "max": 5, 387 | "grade": "$cve_grade", 388 | "badge": "$cve_badge", 389 | "checks": $(echo "$cve_json" | jq '.checks') 390 | } 391 | }, 392 | "overall": { 393 | "score": $total_score, 394 | "max": $max_score, 395 | "percentage": $percentage, 396 | "grade": "$grade", 397 | "badge": "$overall_badge" 398 | } 399 | } 400 | EOF 401 | } 402 | 403 | # Function to output scores in text format 404 | output_text() { 405 | local image=$1 406 | local digest=$2 407 | local minimalism_score=$3 408 | local provenance_score=$4 409 | local config_score=$5 410 | local cve_score=$6 411 | local total_score=$7 412 | local max_score=$8 413 | local percentage=$9 414 | local grade=${10} 415 | 416 | # Calculate individual section grades 417 | local -r minimalism_grade=$(get_grade "$minimalism_score" 4) 418 | local -r provenance_grade=$(get_grade "$provenance_score" 8) 419 | local -r config_grade=$(get_grade "$config_score" 4) 420 | local -r cve_grade=$(get_grade "$cve_score" 4) 421 | 422 | echo -e "${BOLD}Scoring image:${NC} $image" 423 | echo -e "${BOLD}Image digest:${NC} $digest" 424 | echo 425 | 426 | echo -e "${BOLD}Minimalism Score:${NC} $minimalism_score/4 $(get_grade_color "$minimalism_grade")($minimalism_grade)${NC}" 427 | 428 | echo -e "${BOLD}Provenance Score:${NC} $provenance_score/8 $(get_grade_color "$provenance_grade")($provenance_grade)${NC}" 429 | echo -e "${BOLD}Configuration Score:${NC} $config_score/4 $(get_grade_color "$config_grade")($config_grade)${NC}" 430 | echo -e "${BOLD}CVE Score:${NC} $cve_score/4 $(get_grade_color "$cve_grade")($cve_grade)${NC}" 431 | 432 | 433 | echo -e "${BOLD}Overall Score:${NC} $total_score/$max_score ($percentage%)" 434 | echo -e "${BOLD}Grade:${NC} $(get_grade_color "$grade")$grade${NC}" 435 | 436 | echo 437 | 438 | # Generate badge URLs 439 | local -r overall_badge=$(get_badge_url "overall" "$grade") 440 | local -r minimalism_badge=$(get_badge_url "minimalism" "$minimalism_grade") 441 | local -r provenance_badge=$(get_badge_url "provenance" "$provenance_grade") 442 | local -r config_badge=$(get_badge_url "configuration" "$config_grade") 443 | local -r cve_badge=$(get_badge_url "cves" "$cve_grade") 444 | 445 | # Check for terminal image support 446 | local term_support 447 | term_support=$(detect_term_img_support) 448 | local can_show_images=false 449 | 450 | if [[ "$term_support" != "none" ]] && check_curl; then 451 | can_show_images=true 452 | fi 453 | if [[ "$can_show_images" == "true" ]]; then 454 | display_badge "$minimalism_badge" "$term_support" 455 | display_badge "$provenance_badge" "$term_support" 456 | display_badge "$config_badge" "$term_support" 457 | display_badge "$cve_badge" "$term_support" 458 | display_badge "$overall_badge" "$term_support" 459 | else 460 | echo "![Minimalism Badge]($minimalism_badge)" 461 | echo "![Provenance Badge]($provenance_badge)" 462 | echo "![Configuration Badge]($config_badge)" 463 | echo "![CVE Badge]($cve_badge)" 464 | echo "![Overall Badge]($overall_badge)" 465 | fi 466 | 467 | } 468 | 469 | # Badges only 470 | output_badges() { 471 | local image=$1 472 | local digest=$2 473 | local minimalism_score=$3 474 | local provenance_score=$4 475 | local config_score=$5 476 | local cve_score=$6 477 | local total_score=$7 478 | local max_score=$8 479 | local percentage=$9 480 | local grade=${10} 481 | 482 | # Generate badge URLs 483 | local -r overall_badge=$(get_badge_url "overall" "$grade") 484 | local -r minimalism_badge=$(get_badge_url "minimalism" "$minimalism_grade") 485 | local -r provenance_badge=$(get_badge_url "provenance" "$provenance_grade") 486 | local -r config_badge=$(get_badge_url "configuration" "$config_grade") 487 | local -r cve_badge=$(get_badge_url "cves" "$cve_grade") 488 | 489 | echo "![Minimalism Badge]($minimalism_badge)" 490 | echo "![Provenance Badge]($provenance_badge)" 491 | echo "![Configuration Badge]($config_badge)" 492 | echo "![CVE Badge]($cve_badge)" 493 | echo "![Overall Badge]($overall_badge)" 494 | } 495 | 496 | # Main scoring function 497 | score_image() { 498 | local image=$1 499 | local dockerfile=$2 500 | local use_local_image=$3 501 | 502 | echo "Scoring image: $image" >&2 503 | if [ -n "$dockerfile" ]; then 504 | echo "Using Dockerfile: $dockerfile" >&2 505 | fi 506 | echo "----------------------------------------" >&2 507 | 508 | # Run minimalism checks 509 | minimalism_json=$(run_minimalism_checks "$image" "$dockerfile" "$use_local_image") 510 | minimalism_score=$(echo "$minimalism_json" | jq -r '.score') 511 | 512 | # Run provenance checks 513 | provenance_json=$(run_provenance_checks "$image" "$dockerfile" "$use_local_image") 514 | provenance_score=$(echo "$provenance_json" | jq -r '.score') 515 | 516 | # Run configuration checks 517 | config_json=$(run_config_checks "$image" "$dockerfile") 518 | config_score=$(echo "$config_json" | jq -r '.score') 519 | 520 | # Run CVE checks 521 | if [ "$SKIP_CVES" != "true" ]; then 522 | cve_json=$(run_cve_checks "$image") 523 | cve_score=$(echo "$cve_json" | jq -r '.score') 524 | else 525 | cve_score=0 526 | cve_json='{"score": 0, "checks": {}}' 527 | fi 528 | 529 | # Calculate overall score 530 | local total_score=$((minimalism_score + config_score + provenance_score + cve_score)) 531 | local max_score=20 # Updated max score (4 + 4 + 8 + 4) 532 | local percentage=$((total_score * 100 / max_score)) 533 | 534 | # Determine grade based on percentage 535 | local grade 536 | if [ $percentage -ge 94 ]; then # 17-18 points 537 | grade="A+" 538 | elif [ $percentage -ge 75 ]; then # 14-16 points 539 | grade="A" 540 | elif [ $percentage -ge 56 ]; then # 11-13 points 541 | grade="B" 542 | elif [ $percentage -ge 38 ]; then # 8-10 points 543 | grade="C" 544 | elif [ $percentage -ge 19 ]; then # 5-7 points 545 | grade="D" 546 | else # 0-4 points (Level None) 547 | grade="E" 548 | fi 549 | 550 | case "$OUTPUT_FORMAT" in 551 | json) 552 | output_json "$ORIGINAL_IMAGE" "$image" "$minimalism_score" "$provenance_score" "$config_score" "$cve_score" "$total_score" "$max_score" "$percentage" "$grade" "$minimalism_json" "$provenance_json" "$config_json" "$cve_json" 553 | ;; 554 | text) 555 | output_text "$ORIGINAL_IMAGE" "$image" "$minimalism_score" "$provenance_score" "$config_score" "$cve_score" "$total_score" "$max_score" "$percentage" "$grade" 556 | ;; 557 | html) 558 | output_html "$ORIGINAL_IMAGE" "$image" "$minimalism_score" "$provenance_score" "$config_score" "$cve_score" "$total_score" "$max_score" "$percentage" "$grade" "$minimalism_json" "$provenance_json" "$config_json" "$cve_json" 559 | ;; 560 | badges) 561 | output_badges "$ORIGINAL_IMAGE" "$image" "$minimalism_score" "$provenance_score" "$config_score" "$cve_score" "$total_score" "$max_score" "$percentage" "$grade" 562 | ;; 563 | *) 564 | echo "Error: Unknown output format '$OUTPUT_FORMAT'" >&2 565 | echo "Supported formats: text (default), json, html, badges" >&2 566 | exit 1 567 | ;; 568 | esac 569 | } 570 | 571 | # Check if Docker is running 572 | if ! docker info >/dev/null 2>&1; then 573 | echo "Error: Docker is not running" >&2 574 | exit 1 575 | fi 576 | 577 | # Check if Dockerfile exists if provided 578 | if [ -n "$DOCKERFILE" ] && [ ! -f "$DOCKERFILE" ]; then 579 | echo "Error: Dockerfile not found at $DOCKERFILE" >&2 580 | exit 1 581 | fi 582 | 583 | 584 | # Pull image or use a local one 585 | # 586 | # Note that local images do not have a repo digest. 587 | if [ "$USE_LOCAL_IMAGE" == "true" ]; then 588 | echo "Using local image: $1" 589 | # local images do not have digest like remote, but instead they have Id 590 | if [ -n "$(docker inspect "$1" --format '{{.Id}}')" ]; then 591 | IMAGE_WITH_DIGEST=$(docker inspect "$1" --format '{{.Id}}') 592 | else 593 | exit 1 594 | fi 595 | else 596 | echo "Pulling image: $1" >&2 597 | 598 | if ! docker pull "$1" > /dev/null 2>&1; then 599 | echo "Error: Failed to pull image" >&2 600 | exit 1 601 | fi 602 | 603 | # Get the full image name with digest 604 | IMAGE_WITH_DIGEST=$(docker inspect "$1" --format '{{.RepoDigests}}' 2>/dev/null | tr -d '[]' | cut -d' ' -f1) 605 | fi 606 | 607 | if [ -z "$IMAGE_WITH_DIGEST" ]; then 608 | echo "Warning: Could not get image digest, using original image name" >&2 609 | IMAGE_WITH_DIGEST="$1" 610 | fi 611 | 612 | ORIGINAL_IMAGE="$1" 613 | 614 | # Run the scoring with the full image name including digest 615 | score_image "$IMAGE_WITH_DIGEST" "$DOCKERFILE" "$USE_LOCAL_IMAGE" 616 | -------------------------------------------------------------------------------- /example.svg: -------------------------------------------------------------------------------- 1 | ~via🐳desktop-linuxvia🐹v1.23.4viav23.6.0on☁️adrian@chainguard.devdockerrun--privilegedghcr.io/chps-dev/chps-scorercgr.dev/chainguard/nginx:latest-devPullingimage:cgr.dev/chainguard/nginx:latest-devScoringimage:cgr.dev/chainguard/nginx@sha256:b90f3c845c03aceff1c79f0156a80656d99dfc9cbec9455bc4b95addb6887355----------------------------------------CheckingMinimalismcriteria...Compressedimageis25668752bytes,assumingminimalbaseimageUsingminimalbaseimage(compressedimage<40MB)(Level1)Nobuild/debugtoolingfound(Level2)ShellfoundPackagemanagerfoundCheckingProvenancecriteria...Imagefromtrustedsource(Level1)Nounverifieddownloads(Level1)Imageissigned(Level2)Imagewasbuilt4daysago(withinlast30days)Imagewasbuiltwithinthelast30days(Level2)NoDockerfileprovided,skippingdigestsinFROMstatementscheckNoDockerfileprovided,skippingpinnedpackagescheckHasprovenanceattestations(Level3)HasSBOM(Level3)CheckingConfigurationcriteria...CheckingforsecretsusingTrufflehog...UsinglocalTrufflehoginstallation...NoobvioussecretsfoundinimagemetadataorDockerfile(Level1)Nofileswithelevatedprivileges(Level2)Non-rootuser(Level2)Notpracticaltocheckforfilemountsforsecret(Level3)Annotationsfound(Level3)Notpracticaltocheckforsecurityprofiles(Level5)CheckingCVEcriteria...Nocriticalvulnerabilities(Level2)Nohighvulnerabilities(Level3)Nomediumvulnerabilities(Level4)Novulnerabilitiesfound(Level5)Scoringimage:cgr.dev/chainguard/nginx:latest-devImagedigest:cgr.dev/chainguard/nginx@sha256:b90f3c845c03aceff1c79f0156a80656d99dfc9cbec9455bc4b95addb6887355MinimalismScore:2/4(B)![MinimalismBadge](https://img.shields.io/badge/minimalism-B-gold?style=flat-square&labelColor=%233443F4&color=%23FFB000)ProvenanceScore:8/8(A+)![ProvenanceBadge](https://img.shields.io/badge/provenance-A%2B-gold?style=flat-square&labelColor=%233443F4&color=%2301A178)ConfigurationScore:4/4(A+)![ConfigurationBadge](https://img.shields.io/badge/configuration-A%2B-gold?style=flat-square&labelColor=%233443F4&color=%2301A178)CVEScore:4/4(A+)![CVEBadge](https://img.shields.io/badge/cves-A%2B-gold?style=flat-square&labelColor=%233443F4&color=%2301A178)OverallScore:18/20(90%)Grade:A![OverallBadge](https://img.shields.io/badge/overall-A-gold?style=flat-square&labelColor=%233443F4&color=%2304B45F)~via🐳desktop-linuxvia🐹v1.23.4viav23.6.0on☁️adrian@chainguard.devtook49sdocker run --privileged ghcr.io/chps-dev/chps-scorer cgr.dev/chainguard/nginx:latest-dev![OverallBadge](https://img.shields.io/badge/overall-A-gold?style=flat-square&labelColo --------------------------------------------------------------------------------