Modernize and secure PastureStack node agent #7
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Security release gate | |
| on: | |
| push: | |
| branches: | |
| - 'verification/node-agent-*' | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: node-agent-security-${{ github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| build-test-scan: | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 120 | |
| env: | |
| DAPPER_IMAGE: pasturestack/node-agent-dapper:${{ github.sha }} | |
| RUNTIME_IMAGE: pasturestack/node-agent:v0.13.23 | |
| TRIVY_IMAGE: aquasec/trivy:0.73.0@sha256:7cced7cae583819fc7806d4cbc0dbbc7cad18b99f7d3e235192e6da8c091045c | |
| VERSION_OVERRIDE: v0.13.23 | |
| steps: | |
| - name: Check out candidate | |
| uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Record immutable source identity | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| test -z "$(git status --porcelain)" | |
| test "$(git rev-list --count c8663d12dd253ef13258750dca056d4b1219fc10..HEAD)" -eq 1 | |
| mkdir -p evidence | |
| git rev-parse HEAD > evidence/source-revision.txt | |
| sha256sum Dockerfile.dapper package/Dockerfile tests/requirements.lock \ | |
| .github/workflows/codeql-verification.yml \ | |
| .github/workflows/security-release-gate.yml \ | |
| security/dapper.openvex.json \ | |
| > evidence/source-locks.sha256 | |
| - name: Run complete CI and package runtime image | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| CROSS=1 VERSION_OVERRIDE="$VERSION_OVERRIDE" make DAPPER_IMAGE="$DAPPER_IMAGE" ci | |
| make DAPPER_IMAGE="$DAPPER_IMAGE" IMAGE_NAME=pasturestack/node-agent \ | |
| TAG=v0.13.23 VERSION_OVERRIDE="$VERSION_OVERRIDE" package-image | |
| test -s dist/artifacts/node-agent-0.13.23.tar.gz | |
| test -s dist/artifacts/node-agent-0.13.23-windows-amd64.zip | |
| test "$(cat dist/image)" = "$RUNTIME_IMAGE" | |
| gzip -t dist/artifacts/node-agent-0.13.23.tar.gz | |
| unzip -t dist/artifacts/node-agent-0.13.23-windows-amd64.zip | |
| sha256sum dist/artifacts/node-agent-0.13.23.tar.gz \ | |
| dist/artifacts/node-agent-0.13.23-windows-amd64.zip \ | |
| bin/node-agent > evidence/product-artifacts.sha256 | |
| docker image inspect "$RUNTIME_IMAGE" > evidence/runtime-image-inspect.json | |
| docker run --rm --entrypoint /usr/bin/node-agent "$RUNTIME_IMAGE" --version \ | |
| > evidence/runtime-version.txt | |
| grep -Fxq 'node-agent version v0.13.23' evidence/runtime-version.txt | |
| - name: Verify reproducible Linux binary | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| cp bin/node-agent /tmp/node-agent.first | |
| rm -f bin/node-agent | |
| VERSION_OVERRIDE="$VERSION_OVERRIDE" make DAPPER_IMAGE="$DAPPER_IMAGE" build | |
| cmp /tmp/node-agent.first bin/node-agent | |
| docker run --rm --entrypoint go -v "$PWD:/work:ro" "$DAPPER_IMAGE" \ | |
| version -m /work/bin/node-agent > evidence/product-go-version.txt | |
| grep -F $'build\tCGO_ENABLED=0' evidence/product-go-version.txt >/dev/null | |
| - name: Scan source, product, runtime, and build image | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| docker pull "$TRIVY_IMAGE" | |
| cache="$RUNNER_TEMP/trivy-cache" | |
| source_tree="$(mktemp -d)" | |
| product_tree="$(mktemp -d)" | |
| mkdir -p "$cache" "$product_tree" | |
| trap 'rm -rf "$source_tree" "$product_tree"' EXIT | |
| git archive --format=tar HEAD | tar -xf - -C "$source_tree" | |
| cp bin/node-agent "$product_tree/node-agent" | |
| docker run --rm -v "$cache:/root/.cache/trivy" "$TRIVY_IMAGE" image --download-db-only | |
| docker run --rm -v "$source_tree:/scan:ro" -v "$PWD/evidence:/evidence" \ | |
| -v "$cache:/root/.cache/trivy" "$TRIVY_IMAGE" fs \ | |
| --skip-db-update --offline-scan --scanners vuln,secret --format json \ | |
| --output /evidence/source-security.json /scan | |
| docker run --rm -v "$product_tree:/scan:ro" -v "$PWD/evidence:/evidence" \ | |
| -v "$cache:/root/.cache/trivy" "$TRIVY_IMAGE" rootfs \ | |
| --skip-db-update --offline-scan --scanners vuln,secret --format json \ | |
| --output /evidence/product-security.json /scan | |
| docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ | |
| -v "$PWD:/work" -w /work -v "$cache:/root/.cache/trivy" "$TRIVY_IMAGE" image \ | |
| --skip-db-update --offline-scan --scanners vuln,secret --format json \ | |
| --output /work/evidence/runtime-security.json "$RUNTIME_IMAGE" | |
| docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ | |
| -v "$PWD:/work" -w /work -v "$cache:/root/.cache/trivy" "$TRIVY_IMAGE" image \ | |
| --skip-db-update --offline-scan --scanners vuln,secret --format json \ | |
| --output /work/evidence/dapper-security.json "$DAPPER_IMAGE" | |
| docker run --rm --entrypoint sh "$DAPPER_IMAGE" -lc \ | |
| "printf 'package\\tversion\\n'; dpkg-query -W -f='\${binary:Package}\\t\${Version}\\n' | LC_ALL=C sort" \ | |
| > evidence/dapper-dpkg.tsv | |
| jq -r ' | |
| .Results[]?.Vulnerabilities[]? | |
| | select(.Severity == "CRITICAL" or .Severity == "HIGH") | |
| | [.VulnerabilityID, .PkgIdentifier.PURL] | |
| | @tsv | |
| ' evidence/dapper-security.json | LC_ALL=C sort -u \ | |
| > /tmp/dapper-critical-high.tsv | |
| jq -r ' | |
| .statements[]? | |
| | select( | |
| .status == "not_affected" | |
| and .justification == "vulnerable_code_not_present" | |
| ) | |
| | .vulnerability.name as $id | |
| | .products[]? | |
| | [$id, .["@id"]] | |
| | @tsv | |
| ' security/dapper.openvex.json | LC_ALL=C sort -u \ | |
| > /tmp/dapper-vex.tsv | |
| test -s /tmp/dapper-critical-high.tsv | |
| diff -u /tmp/dapper-critical-high.tsv /tmp/dapper-vex.tsv | |
| test "$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL" or .Severity == "HIGH")] | length' evidence/dapper-security.json)" \ | |
| -eq "$(wc -l < /tmp/dapper-critical-high.tsv)" | |
| test "$(jq '.statements | length' security/dapper.openvex.json)" \ | |
| -eq "$(wc -l < /tmp/dapper-vex.tsv)" | |
| jq -e ' | |
| all( | |
| .statements[]; | |
| .status == "not_affected" | |
| and .justification == "vulnerable_code_not_present" | |
| and ((.impact_statement | type) == "string") | |
| and ((.impact_statement | length) > 0) | |
| ) | |
| ' security/dapper.openvex.json >/dev/null | |
| grep -F $'linux-libc-dev:amd64\t7.0.0-29.29' evidence/dapper-dpkg.tsv >/dev/null | |
| if grep -Eq '^linux-(image|modules)([-:]|[[:space:]])' evidence/dapper-dpkg.tsv; then | |
| printf 'Unexpected Linux kernel runtime package in Dapper image\n' >&2 | |
| exit 1 | |
| fi | |
| test -z "$(docker run --rm --entrypoint sh "$DAPPER_IMAGE" -lc \ | |
| "find /boot /lib/modules /usr/lib/modules -type f 2>/dev/null | head -n 1")" | |
| cp /tmp/dapper-critical-high.tsv evidence/dapper-critical-high.tsv | |
| cp /tmp/dapper-vex.tsv evidence/dapper-openvex.tsv | |
| docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ | |
| -v "$PWD:/work" -w /work -v "$cache:/root/.cache/trivy" "$TRIVY_IMAGE" image \ | |
| --skip-db-update --offline-scan --format cyclonedx \ | |
| --output /work/evidence/runtime.cdx.json "$RUNTIME_IMAGE" | |
| docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ | |
| -v "$PWD:/work" -w /work -v "$cache:/root/.cache/trivy" "$TRIVY_IMAGE" image \ | |
| --skip-db-update --offline-scan --format cyclonedx \ | |
| --output /work/evidence/dapper.cdx.json "$DAPPER_IMAGE" | |
| - name: Enforce zero applicable Critical, High, or secrets | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| : > evidence/security-summary.txt | |
| for scope in source product runtime; do | |
| report="evidence/${scope}-security.json" | |
| secrets=$(jq '[.Results[]?.Secrets[]?] | length' "$report") | |
| critical=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' "$report") | |
| high=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH")] | length' "$report") | |
| printf '%s_secrets=%s\n%s_critical=%s\n%s_high=%s\n' \ | |
| "$scope" "$secrets" "$scope" "$critical" "$scope" "$high" \ | |
| | tee -a evidence/security-summary.txt | |
| test "$secrets" -eq 0 | |
| test "$critical" -eq 0 | |
| test "$high" -eq 0 | |
| done | |
| dapper_secrets=$(jq '[.Results[]?.Secrets[]?] | length' evidence/dapper-security.json) | |
| dapper_raw_critical=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' evidence/dapper-security.json) | |
| dapper_raw_high=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH")] | length' evidence/dapper-security.json) | |
| dapper_reviewed_critical=$(jq -n \ | |
| --rawfile pairs evidence/dapper-critical-high.tsv \ | |
| --slurpfile report evidence/dapper-security.json \ | |
| '[($pairs | split("\n")[] | select(length > 0) | split("\t") as $pair | {id:$pair[0],purl:$pair[1]})] as $pairs | |
| | [$report[0].Results[]?.Vulnerabilities[]? | |
| | select(.Severity == "CRITICAL") | |
| | select(.VulnerabilityID as $id | .PkgIdentifier.PURL as $purl | $pairs | any(.id == $id and .purl == $purl))] | |
| | length') | |
| dapper_reviewed_high=$(jq -n \ | |
| --rawfile pairs evidence/dapper-critical-high.tsv \ | |
| --slurpfile report evidence/dapper-security.json \ | |
| '[($pairs | split("\n")[] | select(length > 0) | split("\t") as $pair | {id:$pair[0],purl:$pair[1]})] as $pairs | |
| | [$report[0].Results[]?.Vulnerabilities[]? | |
| | select(.Severity == "HIGH") | |
| | select(.VulnerabilityID as $id | .PkgIdentifier.PURL as $purl | $pairs | any(.id == $id and .purl == $purl))] | |
| | length') | |
| dapper_applicable_critical=$((dapper_raw_critical - dapper_reviewed_critical)) | |
| dapper_applicable_high=$((dapper_raw_high - dapper_reviewed_high)) | |
| jq -n \ | |
| --arg method 'exact-id-purl-set-match' \ | |
| --arg source 'evidence/dapper-security.json' \ | |
| --arg vex 'security/dapper.openvex.json' \ | |
| --argjson raw_critical "$dapper_raw_critical" \ | |
| --argjson raw_high "$dapper_raw_high" \ | |
| --argjson reviewed_critical "$dapper_reviewed_critical" \ | |
| --argjson reviewed_high "$dapper_reviewed_high" \ | |
| --argjson applicable_critical "$dapper_applicable_critical" \ | |
| --argjson applicable_high "$dapper_applicable_high" \ | |
| '{method:$method,source_report:$source,vex_document:$vex,raw:{critical:$raw_critical,high:$raw_high},reviewed_not_affected:{critical:$reviewed_critical,high:$reviewed_high},applicable:{critical:$applicable_critical,high:$applicable_high}}' \ | |
| > evidence/dapper-openvex-review.json | |
| printf 'dapper_secrets=%s\ndapper_raw_critical=%s\ndapper_raw_high=%s\ndapper_applicable_critical=%s\ndapper_applicable_high=%s\n' \ | |
| "$dapper_secrets" "$dapper_raw_critical" "$dapper_raw_high" \ | |
| "$dapper_applicable_critical" "$dapper_applicable_high" \ | |
| | tee -a evidence/security-summary.txt | |
| test "$dapper_secrets" -eq 0 | |
| test "$dapper_applicable_critical" -eq 0 | |
| test "$dapper_applicable_high" -eq 0 | |
| python3 - <<'PY' | |
| import json | |
| for scope in ('runtime', 'dapper'): | |
| with open('evidence/{}.cdx.json'.format(scope), encoding='utf-8') as stream: | |
| bom = json.load(stream) | |
| assert bom.get('bomFormat') == 'CycloneDX' | |
| assert str(bom.get('specVersion')) == '1.7' | |
| assert len(bom.get('components', [])) > 0 | |
| print('{}_components={}'.format(scope, len(bom['components']))) | |
| PY | |
| - name: Upload reviewed evidence | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 | |
| with: | |
| name: node-agent-security-${{ github.sha }} | |
| path: evidence/ | |
| if-no-files-found: error | |
| retention-days: 30 | |
| compression-level: 9 | |
| - name: Clean runner resources | |
| if: always() | |
| shell: bash | |
| run: | | |
| set +e | |
| docker rm -f $(docker ps -aq --filter ancestor="$DAPPER_IMAGE") 2>/dev/null | |
| docker image rm -f "$DAPPER_IMAGE" "$RUNTIME_IMAGE" "$TRIVY_IMAGE" 2>/dev/null | |
| docker builder prune --all --force >/dev/null 2>&1 | |
| sudo rm -rf bin build dist |