Source Code in a Public Bucket: When .git/ Survives Deployment
✓ Human-authored analysis; AI used for formatting and proofreading. A static-site S3 bucket is, by design, public. Marketing copy, infographics, downloadable assets, anyone on the internet can read the contents. The bucket policy admits Principal: "*" for s3:GetObject, the CDN serves the URLs, the t

✓ Human-authored analysis; AI used for formatting and proofreading. A static-site S3 bucket is, by design, public. Marketing copy, infographics, downloadable assets, anyone on the internet can read the contents. The bucket policy admits Principal: "*" for s3:GetObject, the CDN serves the URLs, the team treats this as a feature. The bug Mozilla shipped to production in HackerOne 2383486 was that the .git/ directory shipped with the marketing content. $ curl -s https://acme-marketing-site.example/.git/HEAD ref: refs/heads/main $ curl -s https://acme-marketing-site.example/.git/config [core] repositoryformatversion = 0 filemode = true [remote "origin"] url = git@github.com:acme/marketing-site.git [user] email = alice@acme.example .git/HEAD is two lines. .git/config is more interesting. It leaks the git remote, the developer's email, and the path of the credential store that was committed once and git rm'd later. From there: $ wget --mirror https://acme-marketing-site.example/.git/ $ cd acme-marketing-site.example/.git/.. $ git checkout HEAD -- . # Full repository history, every branch, every commit. $ git log --all --full-history -- '.env' 'credentials*' 'config/*.yml' # Every secret ever committed, even the ones that were # "removed" in a later commit. Three commands turn a public bucket into a source-code disclosure. Plus the credential set the team thought they had rotated when they git rm'd the original commit. The deployment pipeline looks like: # In the build server / CI runner git clone https://github.com/acme/marketing-site.git build/ # Build steps run inside build/... aws s3 sync build/ s3://acme-marketing-site/ --delete build/ is a clone, not an export. It contains .git/. aws s3 sync faithfully uploads every file under build/, including the .git/ subdirectory. The team's mental model is "we sync the build output to S3"; reality is "we sync the build directory plus whatever non-build files are in it." The fix is one line. The bug ships because nobody's testing "is .git/ in the published artefacts?" before the aws s3 sync runs. This pattern is broader than a single Mozilla report. Searching public S3 endpoints for .git/HEAD returns hits across every industry; the count tracks how many teams use git clone as their deployment-source step. Most of those teams' S3 buckets are public for the same reason as Mozilla's. The site is intentionally public and the same mistake exposes the same artefacts. The same shape applies to: .svn/ — older, but recurring on legacy infrastructure. .env — environment files committed by a developer for local convenience, never rewritten out of history. node_modules/.cache/, __pycache__/ — sometimes leak module names not intended for public consumption. .aws/credentials, .ssh/id_rsa — the developer-machine artefacts that occasionally end up in a build directory through a misconfigured Dockerfile. The .git/ is the most catastrophic and the source of the failure is the most generic. Article on (s3-public-read-policy) was about buckets that should not be public. This bucket is supposed to be public. The bucket policy is correct; making the bucket private would break the marketing site. The fix is at the deployment pipeline layer: # In the build server / CI runner git clone https://github.com/acme/marketing-site.git build/ # Build steps run inside build/... +rm -rf build/.git aws s3 sync build/ s3://acme-marketing-site/ --delete …or use git archive (which has no .git/): git archive --format=tar HEAD | tar -xC build/ …or have the build pipeline write to a separate dist/ directory and sync only that. The bucket policy is the right layer for "is this bucket public?" The deployment pipeline is the right layer for "are sensitive paths in the published artefact set?" Conflating them produced the bug. A public bucket must not contain version-control .git/, .svn/) or developer-machine .env, .aws/credentials). In Stave's observation schema, the bucket's content is modelled with two fields under storage.content: { "storage": { "access": { "public_read": true }, "content": { "exposed_repo_artifacts": true, "exposed_paths": [ ".git/HEAD", ".git/config", ".git/objects/pack/pack-abc123.pack", ".env" ] } } } exposed_repo_artifacts is the engine's verdict (a collector inspects the bucket's object listing for the known unsafe path patterns). exposed_paths is the evidence which is the key set the collector found. id: CTL.S3.REPO.ARTIFACT.001 name: Public Buckets Must Not Expose VCS Artifacts severity: medium unsafe_predicate: all: - any: - field: properties.storage.access.public_read op: eq value: true - field: properties.storage.access.public_list op: eq value: true - field: properties.storage.content.exposed_repo_artifacts op: eq value: true Two clauses, both required. Either alone is a non-finding: A private bucket with .git/ in it is fine (IaC backups can be stored that way deliberately). A public bucket with no repo artefacts is the intended static-site case. The combination of public and repo artefacts present is the one that fires. Severity is medium because the intended content of the public bucket is by definition public. The escalation comes from artefact paths revealing content the team didn't intend to publish. Same answer as bucket name dangling: this is a presence check at the collector layer, not a reachability question. The collector observes the bucket's object inventory, applies a known-unsafe-paths filter, and emits a boolean. CEL evaluates two booleans. A reachability question would look like "given this set of admitted requests, is there one that produces a different intended output?". Here the unsafe state is: "the set of objects in the public bucket contains a key matching a known unsafe pattern." That's a deterministic look-up, not a search. The article's value is in the layer-boundary framing (the bucket policy is the wrong layer to fix this), not in solver mechanics. Some bugs are small predicates with big stories. The example is at stave/examples/s3-dotgit-readable/: go run ./examples/s3-dotgit-readable before Captured stdout (expected/before-output.txt): === before (.git/ exposed) === status: NON_COMPLIANT total_assets=1 violations=1 CTL.S3.REPO.ARTIFACT.001 fired on 1 asset(s): - arn:aws:s3:::acme-marketing-site severity=medium exposure_score=51.10 assertion: fires=true (expected) ✓ After the deployment pipeline is fixed: === after (artefacts removed) === status: COMPLIANT total_assets=1 violations=0 CTL.S3.REPO.ARTIFACT.001: no findings assertion: fires=false (expected) ✓ public_read is still true in the after fixture. The bucket is still a public marketing site. The remediation is at the deployment-pipeline layer. Three options, escalating in robustness: A. rm -rf .git before s3 sync. Smallest change. git clone "$REPO" build/ # build steps... rm -rf build/.git aws s3 sync build/ s3://acme-marketing-site/ --delete B. Use git archive instead of git clone. Stronger — the source has no .git/ directory at all: mkdir build/ git archive --format=tar --remote="$REPO" HEAD | tar -xC build/ # build steps... aws s3 sync build/ s3://acme-marketing-site/ --delete C. Two-directory build pipeline. Strongest — the build output goes to dist/, the bucket only sees dist/: git clone "$REPO" src/ cd src && build && cd .. # build/ is the source-of-truth checkout # dist/ is the artefact-only output aws s3 sync dist/ s3://acme-marketing-site/ --delete Option C is the right pattern for any non-trivial pipeline because it forces the team to be explicit about what is and isn't deployable. The artefact set is a separate output, not a filtered view of the source tree. Three layers prevent recurrence: Pre-deploy artefact scan. Before aws s3 sync runs, the deployment script scans the build directory for known-unsafe paths and aborts if any are present. A small helper, ~20 lines: unsafe_paths=$(find dist/ -type d -name '.git' -o -name '.svn' \ -o -name '.env' -o -name '.aws') if [ -n "$unsafe_paths" ]; then echo "ERROR: unsafe artefacts in dist/:" >&2 echo "$unsafe_paths" >&2 exit 1 fi aws s3 sync dist/ s3://... The check runs in the same script that does the deploy, so the gate is in the right place. Post-deploy invariant check. stave apply runs against the post-deploy observation snapshot. The observation collector includes a step that probes the bucket for known-unsafe path patterns and emits exposed_repo_artifacts: true/false. The example shipped with this article is the template with the same predicate, same exit code 3 for any regression. .gitignore + secret scanning. Upstream of all of this: a .gitignore that includes .env, *.key, credentials*, and a pre-commit secret-scanner that prevents the secrets from ever reaching .git/ in the first place. This is the layer that handles the "secrets in old commits even after git rm" failure mode. If the secret was never committed, the .git/ disclosure has nothing of value to reveal. [ ] Deployment scripts use git archive or a separate dist/ directory; git clone output never reaches aws s3 sync [ ] Pre-deploy script scans the artefact directory for .git/, .svn/, .env, .aws/, .ssh/ and aborts on any hit [ ] stave apply runs in CI against post-deploy observations; PRs that introduce a public bucket with exposed_repo_artifacts: true fail the gate [ ] Repo-level secret scanning prevents .env / credential files from being committed in the first place [ ] Quarterly bucket inventory check looks for any public bucket with .git/ paths in its key set (catches drift introduced outside the deployment pipeline) The bucket was always going to be public. The marketing site is for the public. The bug was that public got applied to one more directory than the team meant. The example at stave/examples/s3-dotgit-readable/ is a self-contained Go program that loads two fixture snapshots, runs pkg/stave.Apply, asserts that CTL.S3.REPO.ARTIFACT.001 fires on the .git-exposed fixture and is silent on the cleaned one, and exits zero when both assertions hold. The remediation in the after fixture leaves public_read: true on purpose where the bucket is intentionally public; only the artefact paths needed removal. Stave detects this pattern and 31 other H1-grounded scenarios from local AWS configuration snapshots, with no cloud credentials.
Key Takeaways
- •✓ Human-authored analysis; AI used for formatting and proofreading. A static-site S3 bucket is, by design, public
- •This story was reported by Dev.to, covering developments in the dev space.
- •AI advancements continue to reshape industries — read the full article on Dev.to for complete coverage.
📖 Continue reading the full article:
Read Full Article on Dev.to →


