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 |
Category
276 |
Score
277 |
Max Score
278 |
Grade
279 |
Badge
280 |
281 |
282 |
283 |
284 |
Minimalism
285 |
$minimalism_score
286 |
4
287 |
$minimalism_grade
288 |
289 |
290 |
291 |
Provenance
292 |
$provenance_score
293 |
8
294 |
$provenance_grade
295 |
296 |
297 |
298 |
Configuration
299 |
$config_score
300 |
4
301 |
$config_grade
302 |
303 |
304 |
305 |
CVE
306 |
$cve_score
307 |
4
308 |
$cve_grade
309 |
310 |
311 |
312 |
313 |
314 |
Overall
315 |
$total_score
316 |
$max_score
317 |
$grade
318 |
319 |
320 |
321 |
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 ""
461 | echo ""
462 | echo ""
463 | echo ""
464 | echo ""
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 ""
490 | echo ""
491 | echo ""
492 | echo ""
493 | echo ""
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 |
--------------------------------------------------------------------------------