From 04b3df8a734f3f13f7da84ef63b2cac6c93fbe7e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 4 Jul 2026 23:50:46 +0700 Subject: [PATCH 1/2] ci: add nightly security sweep Signed-off-by: Jeremi Joslin --- .github/workflows/nightly-security.yml | 250 +++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 .github/workflows/nightly-security.yml diff --git a/.github/workflows/nightly-security.yml b/.github/workflows/nightly-security.yml new file mode 100644 index 00000000..968beb02 --- /dev/null +++ b/.github/workflows/nightly-security.yml @@ -0,0 +1,250 @@ +name: Nightly Security + +on: + schedule: + - cron: "41 2 * * *" + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_FUZZ_VERSION: "0.13.2" + CARGO_TERM_COLOR: always + +jobs: + changes: + name: Changed security surfaces + runs-on: ubuntu-24.04 + outputs: + run: ${{ steps.classify.outputs.run }} + head_sha: ${{ steps.classify.outputs.head_sha }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + persist-credentials: false + submodules: false + + - name: Restore last successful nightly state + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: .nightly-security-state + key: nightly-security-state-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + nightly-security-state- + + - name: Classify changed paths + id: classify + shell: bash + run: | + set -euo pipefail + + head_sha="$(git rev-parse HEAD)" + run=false + + changed_files="${RUNNER_TEMP}/registry-stack-nightly-security-files" + : > "${changed_files}" + + is_relevant_path() { + local path="$1" + case "${path}" in + .github/workflows/*|.github/dependabot.yml) + return 0 + ;; + Cargo.toml|Cargo.lock|deny.toml|rust-toolchain*) + return 0 + ;; + crates/registry-relay/*|crates/registry-notary*/*) + return 0 + ;; + crates/registry-platform-authcommon/*|crates/registry-platform-crypto/*) + return 0 + ;; + crates/registry-platform-oid4vci/*|crates/registry-platform-sdjwt/*) + return 0 + ;; + products/notary/*|products/platform/*|release/docker/*) + return 0 + ;; + *) + return 1 + ;; + esac + } + + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + run=true + elif [[ ! -f .nightly-security-state/last-success-sha ]]; then + run=true + else + base_sha="$(tr -d '[:space:]' < .nightly-security-state/last-success-sha)" + if [[ -z "${base_sha}" ]] || + ! git cat-file -e "${base_sha}^{commit}" 2>/dev/null; then + run=true + elif [[ "${base_sha}" != "${head_sha}" ]]; then + git diff --name-only -z "${base_sha}" "${head_sha}" > "${changed_files}" + while IFS= read -r -d '' path; do + if is_relevant_path "${path}"; then + run=true + break + fi + done < "${changed_files}" + fi + fi + + { + echo "run=${run}" + echo "head_sha=${head_sha}" + } >> "${GITHUB_OUTPUT}" + + assurance: + name: Security assurance manifests + needs: changes + if: needs.changes.outputs.run == 'true' + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false + submodules: false + + - name: Test assurance ratchets + run: | + python3 -m unittest \ + crates/registry-relay/tests/security_assurance_check_test.py \ + crates/registry-relay/tests/advisory_baseline_check_test.py \ + products/notary/tests/security_assurance_check_test.py \ + products/notary/tests/advisory_baseline_check_test.py + + - name: Relay exposure and container checks + working-directory: crates/registry-relay + run: | + python3 scripts/check_security_assurance.py manifest + python3 scripts/check_security_assurance.py dockerfile-secrets + + - name: Notary container and OpenAPI checks + working-directory: products/notary + run: | + python3 scripts/check_security_assurance.py dockerfile-secrets + python3 scripts/check_security_assurance.py openapi-baseline + + notary-fuzz: + name: Notary fuzz smoke + needs: changes + if: needs.changes.outputs.run == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false + submodules: false + + - name: Install Rust nightly + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # nightly + with: + toolchain: nightly + + - name: Cache Cargo registry and build artifacts + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: Install cargo-fuzz + uses: taiki-e/install-action@25435dc8dd3baed7417e0c96d3fe89013a5b2e09 # v2.81.3 + with: + tool: cargo-fuzz@${{ env.CARGO_FUZZ_VERSION }} + + - name: Run request parser fuzz smoke + working-directory: products/notary + run: | + set -euo pipefail + mkdir -p fuzz/artifacts/core_request_bodies + cargo +nightly fuzz run --fuzz-dir fuzz --target x86_64-unknown-linux-gnu core_request_bodies -- \ + -max_total_time=120 \ + -rss_limit_mb=1024 \ + -artifact_prefix=fuzz/artifacts/core_request_bodies/ \ + -print_final_stats=1 + + - name: Upload notary fuzz artifacts + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: nightly-notary-fuzz-artifacts + path: products/notary/fuzz/artifacts + if-no-files-found: ignore + + platform-fuzz: + name: Platform fuzz smoke + needs: changes + if: needs.changes.outputs.run == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false + submodules: false + + - name: Install Rust nightly + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # nightly + with: + toolchain: nightly + + - name: Cache Cargo registry and build artifacts + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: Install cargo-fuzz + uses: taiki-e/install-action@25435dc8dd3baed7417e0c96d3fe89013a5b2e09 # v2.81.3 + with: + tool: cargo-fuzz@${{ env.CARGO_FUZZ_VERSION }} + + - name: Run parser and credential fuzz smoke + working-directory: products/platform + run: | + set -euo pipefail + for target in \ + authcommon_parsers \ + oid4vci_request_and_proof \ + sdjwt_holder_proof \ + sdjwt_issuance + do + mkdir -p "fuzz/artifacts/${target}" + cargo +nightly fuzz run --fuzz-dir fuzz --target x86_64-unknown-linux-gnu "${target}" -- \ + -max_total_time=60 \ + -rss_limit_mb=1024 \ + -artifact_prefix="fuzz/artifacts/${target}/" \ + -print_final_stats=1 + done + + - name: Upload platform fuzz artifacts + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: nightly-platform-fuzz-artifacts + path: products/platform/fuzz/artifacts + if-no-files-found: ignore + + save-state: + name: Save successful nightly state + needs: + - changes + - assurance + - notary-fuzz + - platform-fuzz + if: needs.changes.outputs.run == 'true' + runs-on: ubuntu-24.04 + steps: + - name: Write successful head + run: | + mkdir -p .nightly-security-state + printf '%s\n' "${{ needs.changes.outputs.head_sha }}" > .nightly-security-state/last-success-sha + + - name: Save last successful nightly state + uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: .nightly-security-state + key: nightly-security-state-${{ github.run_id }}-${{ github.run_attempt }} From 5bfe3ef2654217640ddd2f25396d4df4d5477322 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 7 Jul 2026 10:34:27 +0700 Subject: [PATCH 2/2] fix(ci): expand nightly security sweep coverage Signed-off-by: Jeremi Joslin --- .github/workflows/nightly-security.yml | 28 ++++++++++++++++++- .../scripts/check_security_assurance.py | 16 +++++++---- .../tests/security_assurance_check_test.py | 3 ++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/.github/workflows/nightly-security.yml b/.github/workflows/nightly-security.yml index 968beb02..c050a7b1 100644 --- a/.github/workflows/nightly-security.yml +++ b/.github/workflows/nightly-security.yml @@ -62,7 +62,7 @@ jobs: crates/registry-platform-authcommon/*|crates/registry-platform-crypto/*) return 0 ;; - crates/registry-platform-oid4vci/*|crates/registry-platform-sdjwt/*) + crates/registry-platform-oid4vci/*|crates/registry-platform-replay/*|crates/registry-platform-sdjwt/*) return 0 ;; products/notary/*|products/platform/*|release/docker/*) @@ -128,9 +128,35 @@ jobs: - name: Notary container and OpenAPI checks working-directory: products/notary run: | + python3 scripts/check_security_assurance.py manifest python3 scripts/check_security_assurance.py dockerfile-secrets python3 scripts/check_security_assurance.py openapi-baseline + - name: Release image Dockerfile checks + run: | + python3 - <<'PY' + import re + import sys + from pathlib import Path + + secret_copy_re = re.compile( + r"\b(?:COPY|ADD)\b(?=.*(?:\.env|\.pem|\.key|\.p12|jwk|secret|credential))|\"d\"\s*:", + re.IGNORECASE, + ) + paths = sorted(Path("release/docker").glob("Dockerfile.registry-*")) + if not paths: + print("security assurance check failed: no release Dockerfiles found", file=sys.stderr) + sys.exit(1) + for path in paths: + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if secret_copy_re.search(line): + print( + f"security assurance check failed: {path}:{lineno} may copy secret or private JWK material", + file=sys.stderr, + ) + sys.exit(1) + PY + notary-fuzz: name: Notary fuzz smoke needs: changes diff --git a/products/notary/scripts/check_security_assurance.py b/products/notary/scripts/check_security_assurance.py index 90560ce6..b71b9226 100755 --- a/products/notary/scripts/check_security_assurance.py +++ b/products/notary/scripts/check_security_assurance.py @@ -13,6 +13,12 @@ ROOT = Path(__file__).resolve().parents[1] +WORKSPACE_ROOT = ( + ROOT.parents[1] + if (ROOT.parents[1] / "products" / "notary").resolve() == ROOT + and (ROOT.parents[1] / "crates").is_dir() + else ROOT +) SECURITY_DIR = ROOT / "security" REQUIRED_ENTRY_FIELDS = { @@ -182,7 +188,7 @@ def ensure_test_ref_exists(ref: str) -> None: file_part, _, symbol = ref.partition("::") if not symbol: fail(f"enforcement test reference must include ::test_name: {ref}") - path = ROOT / file_part + path = WORKSPACE_ROOT / file_part if not path.exists(): fail(f"referenced enforcement test file does not exist: {file_part}") text = path.read_text(encoding="utf-8") @@ -198,8 +204,8 @@ def validate_route_sources(inventory: object | None = None) -> None: inventory_by_source: dict[str, set[tuple[str, str]]] = {} source_files = sorted( - str(path.relative_to(ROOT)) - for crate in (ROOT / "crates").glob("*") + str(path.relative_to(WORKSPACE_ROOT)) + for crate in (WORKSPACE_ROOT / "crates").glob("registry-notary*") for path in (crate / "src").rglob("*.rs") if path.is_file() ) @@ -210,12 +216,12 @@ def validate_route_sources(inventory: object | None = None) -> None: for method in route["methods"]: inventory_by_source[source].add((route["path"], method)) for source in inventory_by_source: - if source not in source_files and (ROOT / source).exists(): + if source not in source_files and (WORKSPACE_ROOT / source).exists(): source_files.append(source) extracted_by_source: dict[str, set[tuple[str, str]]] = {} for source in sorted(set(source_files)): - path = ROOT / source + path = WORKSPACE_ROOT / source if not path.exists(): fail(f"route inventory references missing source file: {source}") routes = extract_axum_routes(path.read_text(encoding="utf-8")) diff --git a/products/notary/tests/security_assurance_check_test.py b/products/notary/tests/security_assurance_check_test.py index f4c69fc0..27c1d648 100644 --- a/products/notary/tests/security_assurance_check_test.py +++ b/products/notary/tests/security_assurance_check_test.py @@ -35,12 +35,15 @@ def setUp(self): "paths": {"/x": {"get": {}}}, })) self.old_root = self.module.ROOT + self.old_workspace_root = self.module.WORKSPACE_ROOT self.old_security = self.module.SECURITY_DIR self.module.ROOT = self.root + self.module.WORKSPACE_ROOT = self.root self.module.SECURITY_DIR = self.root / "security" def tearDown(self): self.module.ROOT = self.old_root + self.module.WORKSPACE_ROOT = self.old_workspace_root self.module.SECURITY_DIR = self.old_security self.tmp.cleanup()