Skip to content

Modernize and secure PastureStack node agent #7

Modernize and secure PastureStack node agent

Modernize and secure PastureStack node agent #7

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