Skip to content
Merged
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
32 changes: 32 additions & 0 deletions .github/workflows/publication-security.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Publication Security

on:
push:
pull_request:

permissions:
contents: read

jobs:
publication-security:
name: Whole-tree publication security
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Verify publication guard regression tests
run: |
python3 deploy/test_publication_security.py
python3 deploy/test_publish_public_repo.py
python3 deploy/test_sync_public_repo.py
- name: Reject runtime artifacts and key material across the entire tree
run: python3 deploy/publication_security.py --tree HEAD
- name: Install pinned secret scanner
run: go install github.com/zricethezav/gitleaks/v8@v8.26.0
- name: Scan complete current source distribution for secrets
run: gitleaks dir . --redact=100 --no-banner
18 changes: 18 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
blockchain/aperod-node
explorer-indexer
aperod-explorer-indexer

# Runtime state and credentials never belong in a source distribution.
data/testnet/
data/mainnet/
data/devnet/
snapshots/
*.key
*.keyfile
*.pem
.env
.env.*
!.env.example
!.env.sample
aperod
cli
aperod-node
build/
__pycache__/
Binary file removed aperod
Binary file not shown.
Binary file removed cli
Binary file not shown.
Binary file removed data/testnet/chain.db/000038.ldb
Binary file not shown.
Binary file removed data/testnet/chain.db/000039.ldb
Binary file not shown.
Binary file removed data/testnet/chain.db/000040.ldb
Binary file not shown.
Binary file removed data/testnet/chain.db/000041.ldb
Binary file not shown.
Binary file removed data/testnet/chain.db/000042.ldb
Binary file not shown.
Binary file removed data/testnet/chain.db/000043.ldb
Binary file not shown.
Binary file removed data/testnet/chain.db/000044.ldb
Binary file not shown.
Binary file removed data/testnet/chain.db/000045.ldb
Binary file not shown.
Binary file removed data/testnet/chain.db/000046.ldb
Binary file not shown.
Binary file removed data/testnet/chain.db/000047.ldb
Binary file not shown.
1 change: 0 additions & 1 deletion data/testnet/chain.db/CURRENT

This file was deleted.

1 change: 0 additions & 1 deletion data/testnet/chain.db/CURRENT.bak

This file was deleted.

Empty file removed data/testnet/chain.db/LOCK
Empty file.
164 changes: 0 additions & 164 deletions data/testnet/chain.db/LOG

This file was deleted.

Binary file removed data/testnet/chain.db/MANIFEST-000037
Binary file not shown.
115 changes: 115 additions & 0 deletions deploy/publication_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Fail closed on forbidden artifacts in a complete publication candidate."""
import argparse
import re
import subprocess
import sys
from pathlib import Path, PurePosixPath


PRIVATE_BLOCK = re.compile(
rb"-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----"
)
MAX_SOURCE_BYTES = 5 * 1024 * 1024


def path_problem(path, mode="100644"):
p = PurePosixPath(path)
if not path or any(ord(c) < 32 for c in path) or p.is_absolute() or ".." in p.parts or "\\" in path:
return "unsafe publication path"
if path == "deploy/sync-public-repo.sh":
return "private publication wrapper"
if mode in ("120000", "160000"):
return "symlinks and submodules require separate security approval"
name = p.name.lower()
if name.endswith((".key", ".keyfile", ".pem", ".p12", ".pfx", ".keystore")):
return "key or credential file"
if name == ".env" or (name.startswith(".env.") and not name.endswith((".example", ".sample"))):
return "environment credential file"
if path.startswith(("data/testnet/", "data/mainnet/", "data/devnet/", "snapshots/")):
return "node runtime data"
if any(part in ("chain.db", "node_modules", ".git", "__pycache__") for part in p.parts):
return "runtime, dependency or repository-internal data"
if name.endswith((".db", ".sqlite", ".sqlite3", ".ldb", ".sst", ".snapshot", ".snap")):
return "database or snapshot"
if path in ("aperod", "cli", "node", "aperod-node") or name.endswith((".exe", ".dll", ".so")):
return "compiled executable"
return None


def content_problem(path, content):
if len(content) > MAX_SOURCE_BYTES:
return "oversized source-distribution artifact"
if content.startswith((b"\x7fELF", b"MZ", b"SQLite format 3\x00",
b"\xcf\xfa\xed\xfe", b"\xfe\xed\xfa\xcf")):
return "executable or database content under a disguised filename"
if PRIVATE_BLOCK.search(content):
return "private-key PEM block"
if re.fullmatch(rb"[0-9a-fA-F]{64}|[0-9a-fA-F]{128}", content.strip()):
return "unlabelled key-sized hex material"
if len(content) in (32, 64):
try:
content.decode("utf-8")
except UnicodeDecodeError:
return "raw key-sized binary"
return None


def git(repo, *args):
return subprocess.check_output(["git", "-C", str(repo), *args])


def inspect(repo, tree="HEAD", worktree=False):
failures = []
checked = 0
if worktree:
tracked = git(repo, "ls-files", "-z").split(b"\0")
untracked = git(repo, "ls-files", "--others", "--exclude-standard", "-z").split(b"\0")
entries = [(p.decode("utf-8"), None, None) for p in sorted(set(tracked + untracked)) if p]
else:
entries = []
for item in git(repo, "ls-tree", "-rz", "--full-tree", tree).split(b"\0"):
if item:
metadata, path = item.split(b"\t", 1)
mode, kind, oid = metadata.decode("ascii").split()
entries.append((path.decode("utf-8"), mode, oid if kind == "blob" else None))
for path, mode, oid in entries:
if worktree:
target = repo / path
# An overlay can contain explicit deletions from the candidate.
if not target.exists() and not target.is_symlink():
continue
if target.is_symlink():
mode = "120000"
checked += 1
reason = path_problem(path, mode)
if reason:
failures.append((path, reason))
continue
content = (repo / path).read_bytes() if worktree else git(repo, "cat-file", "blob", oid)
reason = content_problem(path, content)
if reason:
failures.append((path, reason))
return checked, failures


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", type=Path, default=Path("."))
parser.add_argument("--tree", default="HEAD")
parser.add_argument("--worktree", action="store_true")
args = parser.parse_args()
try:
checked, failures = inspect(args.repo.resolve(), args.tree, args.worktree)
except (OSError, UnicodeError, subprocess.CalledProcessError) as error:
print(f"Publication security could not complete: {type(error).__name__}", file=sys.stderr)
return 2
for path, reason in failures:
# Never print file contents, matches, seeds or credential values.
print(f"REFUSED: {path}: {reason}", file=sys.stderr)
print(f"Publication policy checked {checked} files; {len(failures)} forbidden artifacts.")
return 1 if failures else 0


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