apiVersion: tekton.dev/v1 kind: Task metadata: labels: app.kubernetes.io/version: "0.3.4" annotations: tekton.dev/pipelines.minVersion: "0.12.1" tekton.dev/tags: "virus, konflux" name: clamav-scan spec: description: >- Scans the content of container images and OCI artifacts for viruses, malware, and other malicious content using ClamAV antivirus scanner. results: - name: TEST_OUTPUT description: Tekton task test output. - name: IMAGES_PROCESSED description: Images processed in the task. params: - name: image-digest description: Image digest to scan. - name: image-url description: Image URL. - name: image-arch description: Image arch. default: "" - name: docker-auth description: unused default: "" - name: ca-trust-config-map-name type: string description: The name of the ConfigMap to read CA bundle data from. default: trusted-ca - name: ca-trust-config-map-key type: string description: The name of the key in the ConfigMap that contains the CA bundle data. default: ca-bundle.crt - name: clamd-max-threads description: Maximum number of threads clamd runs. default: "8" - name: skip-upload description: If true, skips uploading the results to the image registry. Useful for read-only tests. default: "false" steps: - name: extract-and-scan-image # This image receives daily builds, ensuring we always have access to the latest virus definitions image: quay.io/konflux-ci/clamav-db:latest # per https://kubernetes.io/docs/concepts/containers/images/#imagepullpolicy-defaulting # the cluster will set imagePullPolicy to IfNotPresent workingDir: /work # need to change user since 'oc image extract' requires more privileges when running as root permissions # https://bugzilla.redhat.com/show_bug.cgi?id=1969929 securityContext: capabilities: add: ["SETFCAP"] env: - name: HOME value: /work - name: IMAGE_URL value: $(params.image-url) - name: IMAGE_DIGEST value: $(params.image-digest) - name: IMAGE_ARCH value: $(params.image-arch) - name: MAX_THREADS value: $(params.clamd-max-threads) computeResources: limits: memory: 12Gi cpu: 7300m requests: memory: 12Gi cpu: 7300m script: | #!/usr/bin/env bash set -euo pipefail # shellcheck disable=SC1091 # /utils.sh is only available in the container image at runtime . /utils.sh trap 'handle_error $(results.TEST_OUTPUT.path)' EXIT # Recursively unpack nested archives into a loose file tree so clamd # scans each file directly instead of recursing through archive layers, # which is faster. bsdtar detects archives by content, not extension # (the scanned payload can be an extension-less blob). extract_archives() { local dir="$1" find "$dir" -type f -print0 | while IFS= read -r -d "" f; do # Treat as an archive only if bsdtar can list at least one entry. if bsdtar -tf "$f" 2>/dev/null | grep -q .; then out="${f}.d" mkdir -p "$out" if bsdtar -xf "$f" -C "$out" >/dev/null 2>&1; then rm -f "$f" extract_archives "$out" else # Extraction failed: drop the dir if empty and leave the # original file in place for clamd to scan. rmdir "$out" 2>/dev/null || true fi fi done } # Start clamd in background /start-clamd.sh # Bootstrap .docker config in overridden HOME. # This prevents 'oc' CLI failures in clean environments where ~/.docker does not exist. if [ ! -d ~/.docker ]; then mkdir -p ~/.docker echo '{}' > ~/.docker/config.json fi imagewithouttag=$(echo "$IMAGE_URL" | sed "s/\(.*\):.*/\1/" | tr -d '\n') # strip new-line escape symbol from parameter and save it to variable imageanddigest="${imagewithouttag}@${IMAGE_DIGEST}" # check if image is attestation one, skip the clamav scan in such case if [[ $imageanddigest == *.att ]] then echo "$imageanddigest is an attestation image. Skipping ClamAV scan." exit 0 fi images_processed_template='{"image": {"pullspec": "'"$IMAGE_URL"'", "digests": [%s]}}' digests_processed=() mkdir logs mkdir content cd content echo "Detecting artifact type for ${imageanddigest}." echo '{"artifact":{"pullspec":"'"${imageanddigest}"'","type":"unknown","mediaType":""}}' > /work/logs/artifact-meta.json # Function to scan content and process results with ClamAV and EC # Parameters: # $1: destination - path to the content to scan # $2: suffix - suffix for log file names (e.g., "oci", "amd64") # $3: digest - digest to add to digests_processed array # $4: scan_message - optional message describing what is being scanned scan_and_process() { local destination="$1" local suffix="$2" local digest="$3" local scan_message="${4:-Scanning content}" db_version=$(clamdscan --version | sed 's|.*/\(.*\)/.*|\1|') echo "$scan_message. This operation may take a while." # Unpack nested archives into a loose file tree so clamd scans loose # files directly rather than recursing into archives (see extract_archives). echo "Pre-extracting archives under ${destination}." extract_archives "${destination}" 2>&1 \ | tee "/work/logs/extract-${suffix}.log" || true clamdscan "${destination}" -vi --multiscan --fdpass \ | tee "/work/logs/clamscan-result-${suffix}.log" || true echo "Executed-on: Scan was executed on clamsdcan version - $(clamdscan --version) Database version: $db_version" | tee -a "/work/logs/clamscan-result-${suffix}.log" digests_processed+=("\"$digest\"") if [[ -e "/work/logs/clamscan-result-${suffix}.log" ]]; then # OPA/EC requires structured data input, add clamAV log into json jq -Rs '{ output: . }' "/work/logs/clamscan-result-${suffix}.log" > "/work/logs/clamscan-result-log-${suffix}.json" EC_EXPERIMENTAL=1 ec test \ --namespace required_checks \ --policy /project/clamav/virus-check.rego \ -o json \ "/work/logs/clamscan-result-log-${suffix}.json" || true # workaround: due to a bug in ec-cli, we cannot generate json and appstudio output at the same time, running it again EC_EXPERIMENTAL=1 ec test \ --namespace required_checks \ --policy /project/clamav/virus-check.rego \ -o appstudio \ "/work/logs/clamscan-result-log-${suffix}.json" | tee "/work/logs/clamscan-ec-test-${suffix}.json" || true cat "/work/logs/clamscan-ec-test-${suffix}.json" fi } # Skip extract only when every real file is a known weight type. is_weight_filename() { local base="$1" case "$base" in *.safetensors|*.gguf|*.ggml|*.pt|*.pth|*.onnx|*.onnx_data|*.onnx_data_*) return 0 ;; *) return 1 ;; esac } # 2000MiB: slightly under ClamAV's ~2GiB MaxFileSize (0.2 used --max-filesize=2000M). CLAMAV_SKIP_BLOB_BYTES=2097152000 # listing = stdout of: oc image extract --dry-run is_weight_only_layer() { local listing="$1" local mode entry base # Fail closed: empty stdout is not evidence the layer is weight-only. [[ -z "${listing}" ]] && return 1 while IFS= read -r line; do [ -z "$line" ] && continue mode=$(awk '{print $2}' <<< "$line") [[ "$mode" == d* ]] && continue [[ "$line" == *" -> "* ]] && return 1 entry=$(awk '{print $NF}' <<< "$line") base=$(basename "$entry") [[ "$base" == .wh.* ]] && continue is_weight_filename "$base" || return 1 done <<< "$listing" return 0 } # title/path from the OCI layer descriptor (olot ModelCars). Empty is not a skip. layer_annotation_is_weight() { local title="$1" inpath="$2" base candidate for candidate in "$title" "$inpath"; do [ -z "$candidate" ] && continue base=$(basename "$candidate") is_weight_filename "$base" && return 0 done return 1 } # Detect artifact type: container image vs OCI artifact # First, try to get image manifests (works for container images) # Use subshell to prevent get_image_manifests() from exiting the main script if it fails # (get_image_manifests uses exit 1 when Architecture field is missing, which happens for OCI artifacts) image_manifests=$(bash -c '. /utils.sh; get_image_manifests -i "'"${imageanddigest}"'"' 2>/dev/null || echo "") # If get_image_manifests failed, check if it's an OCI artifact by inspecting manifest media type if [ -z "$image_manifests" ]; then echo "get_image_manifests returned empty, checking if this is an OCI artifact..." raw_manifest=$(skopeo inspect --raw --authfile ~/.docker/config.json "docker://${imageanddigest}" 2>/dev/null || true) if [ -s /work/logs/artifact-meta.json ]; then tmp=$(mktemp) if jq '.artifact.type = "inspected"' /work/logs/artifact-meta.json > "$tmp"; then mv "$tmp" /work/logs/artifact-meta.json || true fi fi if [ -n "$raw_manifest" ]; then media_type=$(echo "$raw_manifest" | jq -r '.mediaType // .config.mediaType // empty' 2>/dev/null || echo "") artifact_type=$(echo "$raw_manifest" | jq -r '.artifactType // empty' 2>/dev/null || echo "") config_media_type=$(echo "$raw_manifest" | jq -r '.config.mediaType // empty' 2>/dev/null || echo "") # Determine if this is an OCI artifact (not a container image) # OCI artifacts typically have: # - An empty/scratch config (config.mediaType contains "empty" or "scratch") # - An explicit artifactType field that is not a container image type is_oci_artifact=false # Check if config is empty/scratch (typical for OCI artifacts like python wheels, helm charts, etc.) if echo "$config_media_type" | grep -qiE "(empty|scratch)"; then is_oci_artifact=true fi # Check if artifactType is set and is not a container image type if [ -n "$artifact_type" ] && ! echo "$artifact_type" | grep -qE "application/vnd\.(oci|docker)\.(image|container)"; then is_oci_artifact=true fi if [ "$is_oci_artifact" = true ]; then # This is an OCI artifact (e.g., python wheels, helm charts, etc.) echo "Detected OCI artifact (artifactType: ${artifact_type:-unset}, config.mediaType: ${config_media_type:-unset}). Downloading for scanning..." if [ -s /work/logs/artifact-meta.json ]; then tmp=$(mktemp) if jq '.artifact.mediaType = '"\"${media_type:-unknown}\""' | .artifact.artifactType = '"\"${artifact_type:-unknown}\""' | .artifact.type = "oci"' /work/logs/artifact-meta.json > "$tmp"; then mv "$tmp" /work/logs/artifact-meta.json || true fi fi destination="content-oci" mkdir -p "$destination" # Download OCI artifact using skopeo copy echo "Downloading OCI artifact using skopeo copy" if ! retry skopeo copy --authfile ~/.docker/config.json "docker://${imageanddigest}" "dir:${destination}" 2>&1; then echo "Failed to download OCI artifact \"$imageanddigest\". Skipping ClamAV scan!" note="Task $(context.task.name) failed: Failed to download OCI artifact \"$imageanddigest\". For details, check Tekton task log." ERROR_OUTPUT=$(make_result_json -r "ERROR" -t "$note") echo "${ERROR_OUTPUT}" | tee "$(results.TEST_OUTPUT.path)" exit 0 fi # Scan and process OCI artifact scan_and_process "${destination}" "oci" "$IMAGE_DIGEST" "Scanning OCI artifact" # Skip the container image processing path image_manifests="" elif echo "$media_type" | grep -qE "(application/vnd\.(docker|oci)\.(distribution|image)\.manifest|application/vnd\.docker\.distribution\.manifest)"; then # This looks like a container image manifest, but get_image_manifests failed echo "Detected container image manifest type: $media_type, but get_image_manifests failed. This may indicate an error." if [ -s /work/logs/artifact-meta.json ]; then tmp=$(mktemp) if jq '.artifact.mediaType = '"\"$media_type\""' | .artifact.type = "image"' /work/logs/artifact-meta.json > "$tmp"; then mv "$tmp" /work/logs/artifact-meta.json || true fi fi note="Task $(context.task.name) failed: Failed to get image manifests from image \"$imageanddigest\". For details, check Tekton task log." ERROR_OUTPUT=$(make_result_json -r "ERROR" -t "$note") echo "${ERROR_OUTPUT}" | tee "$(results.TEST_OUTPUT.path)" exit 0 else # Likely an OCI artifact with non-standard media type echo "Detected OCI artifact (media type: ${media_type:-unknown}). Downloading for scanning..." if [ -s /work/logs/artifact-meta.json ]; then tmp=$(mktemp) if jq '.artifact.mediaType = '"\"${media_type:-unknown}\""' | .artifact.type = "oci"' /work/logs/artifact-meta.json > "$tmp"; then mv "$tmp" /work/logs/artifact-meta.json || true fi fi destination="content-oci" mkdir -p "$destination" # Download OCI artifact using skopeo copy echo "Downloading OCI artifact using skopeo copy" if ! retry skopeo copy --authfile ~/.docker/config.json "docker://${imageanddigest}" "dir:${destination}" 2>&1; then echo "Failed to download OCI artifact \"$imageanddigest\". Skipping ClamAV scan!" note="Task $(context.task.name) failed: Failed to download OCI artifact \"$imageanddigest\". For details, check Tekton task log." ERROR_OUTPUT=$(make_result_json -r "ERROR" -t "$note") echo "${ERROR_OUTPUT}" | tee "$(results.TEST_OUTPUT.path)" exit 0 fi # Scan and process OCI artifact scan_and_process "${destination}" "oci" "$IMAGE_DIGEST" "Scanning OCI artifact" # Skip the container image processing path image_manifests="" fi else echo "Failed to inspect artifact \"$imageanddigest\". Unable to determine type." note="Task $(context.task.name) failed: Failed to inspect artifact \"$imageanddigest\". For details, check Tekton task log." ERROR_OUTPUT=$(make_result_json -r "ERROR" -t "$note") echo "${ERROR_OUTPUT}" | tee "$(results.TEST_OUTPUT.path)" exit 0 fi fi # Process container images (existing logic) if [ -n "$image_manifests" ]; then echo "Detected container image. Processing image manifests." if [ -s /work/logs/artifact-meta.json ]; then tmp=$(mktemp) if jq '.artifact.type = "image"' /work/logs/artifact-meta.json > "$tmp"; then mv "$tmp" /work/logs/artifact-meta.json || true fi fi # Proceed only if a specific arch is provided. # This typically occurs when using Tekton Matrix to launch multiple TaskRuns to scan all architectures of a multi-arch image in parallel. if [ -n "$IMAGE_ARCH" ]; then arch="${IMAGE_ARCH#*/}" if [ "${arch}" = "x86_64" ]; then arch="amd64" fi # Check if arch is supported; if not (e.g., it's 'local', see link below), default to amd64. # https://github.com/redhat-appstudio/infra-deployments/blob/main/components/multi-platform-controller/production/stone-prd-rh01/host-config.yaml#L9-L14 case "$arch" in amd64|ppc64le|arm64|s390x) ;; *) arch="amd64" ;; esac image_manifests=$(echo "$image_manifests" | jq -c --arg arch "$arch" '{($arch): .[$arch]}') fi while read -r arch arch_sha; do destination="content-${arch}" mkdir -p "$destination" arch_imageanddigest="${imagewithouttag}@${arch_sha}" echo "Inspecting layers for arch $arch" skip_log="" layers=$(skopeo inspect --raw --authfile ~/.docker/config.json \ "docker://${arch_imageanddigest}" | jq -c ' .layers[]? | { digest: (.digest // ""), size: ((.size // 0) | if type == "number" then . else 0 end), title: (.annotations["org.opencontainers.image.title"] // ""), inpath: (.annotations["olot.layer.content.inlayerpath"] // "") }') || layers="" if [ -z "${layers}" ]; then echo "Could not list layers; extracting full image" if ! retry oc image extract --confirm --only-files=true \ --registry-config ~/.docker/config.json "$arch_imageanddigest" \ --path="/:${destination}" --filter-by-os="linux/${arch}"; then echo "Unable to extract image for arch $arch. Skipping ClamAV scan!" exit 0 fi else skip_log="/work/logs/skipped-layers-${arch}.log" : > "$skip_log" while IFS= read -r layer_json; do [ -z "$layer_json" ] && continue digest=$(jq -r '.digest' <<< "$layer_json") size=$(jq -r '.size' <<< "$layer_json") title=$(jq -r '.title' <<< "$layer_json") inpath=$(jq -r '.inpath' <<< "$layer_json") [ -z "$digest" ] && continue if layer_annotation_is_weight "$title" "$inpath"; then echo "Skipping unscannable weight layer ${digest} (manifest ${title:-$inpath})" | tee -a "$skip_log" continue fi if { [ -n "$title" ] || [ -n "$inpath" ]; } && [ "${size}" -ge "${CLAMAV_SKIP_BLOB_BYTES}" ]; then echo "Skipping unscannable layer ${digest} (manifest ${title:-$inpath}, size ${size})" | tee -a "$skip_log" continue fi if [ -z "$title" ] && [ -z "$inpath" ]; then echo "Listing layer ${digest}" if listing=$(retry oc image extract --dry-run --confirm \ --registry-config ~/.docker/config.json \ "${arch_imageanddigest}[~${digest}]" \ --path="/:${destination}" --filter-by-os="linux/${arch}"); then if is_weight_only_layer "${listing}"; then echo "Skipping unscannable weight layer ${digest}" | tee -a "$skip_log" continue fi else echo "Failed to list layer ${digest}; extracting it" fi fi if ! retry oc image extract --confirm --only-files=true \ --registry-config ~/.docker/config.json \ "${arch_imageanddigest}[~${digest}]" \ --path="/:${destination}" --filter-by-os="linux/${arch}"; then echo "Unable to extract layer ${digest} for arch $arch. Skipping ClamAV scan!" exit 0 fi done <<< "$layers" fi # Scan and process container image for this architecture scan_and_process "${destination}" "$arch" "$arch_sha" "Scanning image for arch $arch" if [ -s "${skip_log}" ]; then cat "$skip_log" >> "/work/logs/clamscan-result-${arch}.log" fi done < <(echo "$image_manifests" | jq -r 'to_entries[] | "\(.key) \(.value)"') fi jq -s -rce ' reduce .[] as $item ({"timestamp":"0","namespace":"","successes":0,"failures":0,"warnings":0,"result":"","note":""}; { "timestamp" : (if .timestamp < $item.timestamp then $item.timestamp else .timestamp end), "namespace" : $item.namespace, "successes" : (.successes + $item.successes), "failures" : (.failures + $item.failures), "warnings" : (.warnings + $item.warnings), "result" : (if .result == "" or ($item.result == "SKIPPED" and .result == "SUCCESS") or ($item.result == "WARNING" and (.result == "SUCCESS" or .result == "SKIPPED")) or ($item.result == "FAILURE" and .result != "ERROR") or $item.result == "ERROR" then $item.result else .result end), "note" : (if .result == "" or ($item.result == "SKIPPED" and .result == "SUCCESS") or ($item.result == "WARNING" and (.result == "SUCCESS" or .result == "SKIPPED")) or ($item.result == "FAILURE" and .result != "ERROR") or $item.result == "ERROR" then $item.note else .note end) })' /work/logs/clamscan-ec-test-*.json | tee "$(results.TEST_OUTPUT.path)" # If the image is an Image Index, also add the Image Index digest to the list. if [[ "${digests_processed[*]}" != *"$IMAGE_DIGEST"* ]]; then digests_processed+=("\"$IMAGE_DIGEST\"") fi digests_processed_string=$(IFS=,; echo "${digests_processed[*]}") echo "${images_processed_template/\[%s]/[$digests_processed_string]}" | tee "$(results.IMAGES_PROCESSED.path)" volumeMounts: - mountPath: /work name: work - name: trusted-ca mountPath: /etc/pki/tls/certs/ca-custom-bundle.crt subPath: ca-bundle.crt readOnly: true - name: upload image: quay.io/konflux-ci/task-runner:3.2.0@sha256:ba5789d2bc04146431e492083c67b86de26d5b17891dc27f67db850a397b66d3 computeResources: limits: memory: 256Mi cpu: 100m requests: memory: 256Mi cpu: 100m env: - name: SKIP_UPLOAD value: $(params.skip-upload) - name: IMAGE_URL value: $(params.image-url) - name: IMAGE_DIGEST value: $(params.image-digest) workingDir: /work script: | #!/usr/bin/env bash set -e # Skip upload if requested e.g. read-only CI tests where push access is denied if [ "$SKIP_UPLOAD" == "true" ]; then echo "Upload skipped by parameter." exit 0 fi # Don't return a glob expression when no matches are found shopt -s nullglob cd logs for UPLOAD_FILE in clamscan-result*.log; do MEDIA_TYPE=text/vnd.clamav args+=("${UPLOAD_FILE}:${MEDIA_TYPE}") done for UPLOAD_FILE in clamscan-ec-test*.json; do MEDIA_TYPE=application/vnd.konflux.test_output+json args+=("${UPLOAD_FILE}:${MEDIA_TYPE}") done if [ ${#args[@]} -eq 0 ]; then echo "No files found. Skipping upload." exit 0; fi echo "Selecting auth" select-oci-auth "$IMAGE_URL" > "$HOME/auth.json" echo "Attaching to ${IMAGE_URL}" retry oras attach --no-tty --registry-config "$HOME/auth.json" --artifact-type application/vnd.clamav "${IMAGE_URL}@${IMAGE_DIGEST}" "${args[@]}" volumeMounts: - mountPath: /work name: work - name: trusted-ca mountPath: /etc/pki/tls/certs/ca-custom-bundle.crt subPath: ca-bundle.crt readOnly: true volumes: - name: dbfolder emptyDir: {} - name: work emptyDir: {} - name: trusted-ca configMap: name: $(params.ca-trust-config-map-name) items: - key: $(params.ca-trust-config-map-key) path: ca-bundle.crt optional: true