Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
276 changes: 276 additions & 0 deletions .github/workflows/nightly-security.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
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-replay/*|crates/registry-platform-sdjwt/*)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include cache changes in the security sweep

When a scheduled run only changes crates/registry-platform-cache/..., this path set falls through and the sweep is skipped, even though registry-platform-replay builds its nonce/replay stores directly on CacheStore/compare_and_set from that crate and OID4VCI pulls replay into the fuzz dependency graph. In that case a cache API or behavior change that affects nonce replay protection can land without even rebuilding/running the nightly OID4VCI/Notary smoke for that SHA; add crates/registry-platform-cache/* to this platform group or otherwise cover it.

Useful? React with 👍 / 👎.

return 0
;;
products/notary/*|products/platform/*|release/docker/*)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scan release Dockerfiles before marking them covered

When the only relevant change is under release/docker/, this classifier schedules the nightly sweep, but the assurance job only runs dockerfile-secrets in crates/registry-relay and products/notary. Those checkers scan only their local Dockerfiles (crates/registry-relay/scripts/check_security_assurance.py:357-358 and products/notary/scripts/check_security_assurance.py:353-354), while the release workflow builds release/docker/Dockerfile.registry-* (.github/workflows/release.yml:363-365), so secret-copy regressions in the release images are never checked by the new sweep. Add those release Dockerfiles to a scan or stop treating this path as covered.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. Fixed in 5bfe3ef: the nightly sweep now scans release/docker/Dockerfile.registry-* with the release-image Dockerfile secret-copy checks. Verified the release Dockerfile scan plus the Notary security assurance manifest/unit tests locally.

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
Comment on lines +125 to +126

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run Relay OpenAPI strategy checks

When the only relevant change is a Relay OpenAPI/security-assurance doc update such as crates/registry-relay/openapi/registry-relay.openapi.json, this classifier schedules the nightly sweep, but the Relay step only runs manifest and dockerfile-secrets. The Relay assurance script's default set also runs openapi-strategy (crates/registry-relay/scripts/check_security_assurance.py:493-502), which validates OpenAPI coverage against the exposure manifest, so static API exposure drift is not checked by the new sweep. Add python3 scripts/check_security_assurance.py openapi-strategy here or stop treating those Relay paths as covered.

Useful? React with 👍 / 👎.


- 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
Comment on lines +131 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run Notary workflow hardening checks

When the only relevant change is under products/notary/.github/workflows/, the classifier marks products/notary/* as relevant, but this step only invokes manifest, dockerfile-secrets, and openapi-baseline. The Notary assurance script's default check set also includes workflow-external-refs and workflow-pull-request-hardening (products/notary/scripts/check_security_assurance.py:541-546), so a product workflow change can bypass the REGISTRY_PLATFORM_REF pin and PR credential/permission hardening ratchets in the nightly sweep. Add those two checks here or stop treating those workflow paths as covered.

Useful? React with 👍 / 👎.


- 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
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 }}
16 changes: 11 additions & 5 deletions products/notary/scripts/check_security_assurance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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")
Expand All @@ -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()
)
Expand All @@ -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"))
Expand Down
3 changes: 3 additions & 0 deletions products/notary/tests/security_assurance_check_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading