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
73 changes: 73 additions & 0 deletions .github/workflows/sync-psl.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: Sync Public Suffix List

on:
schedule:
# 12:00 UTC on the first day of each month.
- cron: "0 12 1 * *"
workflow_dispatch:

permissions:
contents: write
pull-requests: write

env:
PSL: source/public-suffix-list/public_suffix_list.dat

jobs:
sync:
name: Sync PSL and open a PR
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
- uses: actions/checkout@v4

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Semgrep identified an issue in your code:

actions/checkout@v4 uses a movable tag, so a repointed v4 release would run attacker-controlled code in this write-enabled workflow.

More details about this

actions/checkout@v4 pulls a GitHub Action by a movable tag, not a fixed commit. If the v4 tag is ever repointed, this scheduled workflow would run the new code automatically before python3 source/public-suffix-list/etc/sync-psl.py, with contents: write and pull-requests: write permissions.

A plausible attack looks like this:

  1. An attacker compromises the actions/checkout release process or gains control of the account that can move the v4 tag.
  2. They repoint v4 to a malicious commit while leaving the action name unchanged, so this step still says uses: actions/checkout@v4.
  3. On the next monthly run or any manual workflow_dispatch, GitHub resolves v4 to the attacker's code and executes it in the sync job.
  4. That code runs with this workflow's token permissions and workspace access, so it can modify the checked-out repository, read $GITHUB_OUTPUT, or use git push origin "$branch" behavior to push attacker-controlled changes.
  5. It can then abuse the later PR flow to open a trusted-looking pull request from the automation branch, making the malicious change appear to come from your normal PSL sync job.

To resolve this comment:

✨ Commit fix suggestion
  1. Replace the mutable action reference actions/checkout@v4 with a full 40-character commit SHA for the same trusted release, for example uses: actions/checkout@<full-commit-sha>.
  2. Keep the version in a comment if it helps with maintenance, such as # actions/checkout v4, but do not use the tag in uses:.
  3. Choose the SHA from the official actions/checkout repository for the v4 release you intend to keep using. Pinning to a commit SHA prevents the action owner from silently changing what runs in this workflow.

Alternatively, if you need an easier update path, use Dependabot or Renovate to keep pinned GitHub Action SHAs updated automatically while still keeping uses: pinned to a full commit SHA.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by github-actions-mutable-action-tag.

🛟 Help? Slack #semgrep-help or go/semgrep-help.

Resolution Options:

  • Fix the code
  • Reply /fp $reason (if security gap doesn’t exist)
  • Reply /ar $reason (if gap is valid but intentional; add mitigations/monitoring)
  • Reply /other $reason (e.g., test-only)

You can view more details about this finding in the Semgrep AppSec Platform.

- name: Sync the Public Suffix List
run: python3 source/public-suffix-list/etc/sync-psl.py
- name: Check whether the list changed
id: changed
run: |
if git diff --quiet -- "$PSL"; then
echo "The list is unchanged; nothing to do."
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Commit and push
id: push
if: steps.changed.outputs.changed == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Each run gets its own branch, so nothing is ever force-pushed over. A run that
# collides with an earlier branch from the same day is rejected rather than
# overwriting it.
today=$(date -u +%Y-%m-%d)
branch="sync-psl-$today"
git switch -c "$branch"
git add -- "$PSL"
git commit -m "[$today] Sync the Public Suffix List"
git push origin "$branch"
echo "branch=$branch" >> "$GITHUB_OUTPUT"
echo "today=$today" >> "$GITHUB_OUTPUT"
- name: Open a pull request
if: steps.changed.outputs.changed == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH: ${{ steps.push.outputs.branch }}
TODAY: ${{ steps.push.outputs.today }}
run: |
body=$(cat <<'EOF'
The upstream [Public Suffix List](https://publicsuffix.org/list/) has changed.

This pull request was opened automatically by the `sync-psl` workflow, which regenerates
`source/public-suffix-list/public_suffix_list.dat` via `source/public-suffix-list/etc/sync-psl.py`.

Please review the diff before merging. If an earlier sync pull request is still open,
merge or close this one and that one together -- they change the same file.
EOF
)
gh pr create \
--base master \
--head "$BRANCH" \
--title "[$TODAY] Sync the Public Suffix List" \
--body "$body"
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,5 @@ repos:
args: ["-L", "fle,re-use,merchantibility,synching,crate,nin,infinit,te,checkin,aks"]
exclude: |
(?x)^(.*\.rst
|source/public-suffix-list/public_suffix_list\.dat
)$
1 change: 1 addition & 0 deletions source/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
- [OpenTelemetry](open-telemetry/open-telemetry.md)
- [Performance Benchmarking](benchmarking/benchmarking.md)
- [Polling SRV Records for mongos Discovery](polling-srv-records-for-mongos-discovery/polling-srv-records-for-mongos-discovery.md)
- [Public Suffix List](public-suffix-list/public-suffix-list.md)
- [Read and Write Concern](read-write-concern/read-write-concern.md)
- [Retryable Reads](retryable-reads/retryable-reads.md)
- [Retryable Writes](retryable-writes/retryable-writes.md)
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# "10gen.cc" is not in the Public Suffix List -- only its parent "cc" is -- so it is
# not a public suffix and must be accepted. The SRV hosts end with it, so resolution
# succeeds.
uri: "mongodb+srv://test1.test.build.10gen.cc/?srvAllowedHostsSuffix=10gen.cc"
seeds:
- localhost.test.build.10gen.cc:27017
- localhost.test.build.10gen.cc:27018
hosts:
- localhost:27017
- localhost:27018
- localhost:27019
options:
ssl: true
srvAllowedHostsSuffix: "10gen.cc"
ping: true

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# "cc" is an ordinary rule in the Public Suffix List, so it is itself a public suffix
# and must be rejected. The SRV hosts do end with "cc", so the host suffix check
# passes and the public suffix check is the only thing that can fail here.
uri: "mongodb+srv://test1.test.build.10gen.cc/?srvAllowedHostsSuffix=cc"
seeds: []
hosts: []
error: true
91 changes: 91 additions & 0 deletions source/public-suffix-list/etc/sync-psl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Sync the Public Suffix List from publicsuffix.org into this specification.

Usage:

python source/public-suffix-list/etc/sync-psl.py [--check]

Downloads the upstream list, strips comment and blank lines, and writes the result
to source/public-suffix-list/public_suffix_list.dat. With --check, does not write
anything and exits non-zero if the committed file is out of date.
"""

import argparse
import sys
import urllib.request
from pathlib import Path

PSL_URL = "https://publicsuffix.org/list/public_suffix_list.dat"

# source/public-suffix-list/etc/sync-psl.py -> source/public-suffix-list
SPEC_DIR = Path(__file__).resolve().parent.parent
DEST = SPEC_DIR / "public_suffix_list.dat"


def fetch():
request = urllib.request.Request(PSL_URL, headers={"User-Agent": "mongodb-specifications-sync-psl"})
with urllib.request.urlopen(request) as response:
data = response.read()

text = data.decode("utf-8")

# Sanity check: the upstream file always carries these section markers.
for marker in ("// ===END ICANN DOMAINS===", "// ===END PRIVATE DOMAINS==="):
if marker not in text:
sys.exit(f"Downloaded file is missing expected markers {marker!r}; refusing to write.")

return text


def preprocess(text):
"""Reduce the upstream list to one rule per line.

Comment lines (those beginning with "//") and blank lines are both removed, so
every line will be a rule.
"""
rules = []
for line in text.splitlines():
# Upstream rules are not indented, but strip anyway so a stray trailing \r or
# space does not end up inside a rule.
line = line.strip()
if not line or line.startswith("//"):
continue
rules.append(line)

if not rules:
sys.exit("No rules found after stripping comments; refusing to write.")

# End the file with exactly one newline.
return "\n".join(rules) + "\n"


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--check",
action="store_true",
help="exit non-zero if the committed list differs from upstream, without writing",
)
args = parser.parse_args()

new_text = preprocess(fetch())

old_text = DEST.read_text(encoding="utf-8") if DEST.exists() else None

if args.check:
if old_text is None:
sys.exit(f"{DEST} does not exist; run this script without --check.")
if old_text != new_text:
sys.exit(f"{DEST} is out of date; run source/public-suffix-list/etc/sync-psl.py.")
print(f"{DEST.name} is up to date.")
return

if old_text == new_text:
print(f"{DEST.name} is already up to date ({len(new_text.splitlines())} lines).")
return

DEST.write_text(new_text, encoding="utf-8")
print(f"Wrote {DEST} ({len(new_text.splitlines())} lines).")


if __name__ == "__main__":
main()
Loading
Loading