An AWS access key in the Git history

A key deleted from a file a year earlier was still in the repository history and opened all of the company's S3. Rotation, narrower permissions, checks in CI.

Company
Software studio, 9 people, a booking system for gyms
Team
Three developers, nobody responsible for infrastructure or security
Environment
Application on a VPS outside AWS, attachments and backups in Amazon S3
Development
Private GitHub repository, deployment through GitHub Actions

01Starting point

The application runs on a VPS at another provider, so it cannot be given an IAM role the way a server inside AWS can, and it signs in to S3 with the long-lived access key of the IAM user app-uploads. That user had the managed policy AmazonS3FullAccess, meaning full access to every bucket in the account, including the one holding database backups.

  1. Developer
  2. GitHub (private repository)
  3. GitHub Actions
  4. VPS: application
  5. Amazon S3: attachments and backups

02The problem

While the repository was being handed to a new external developer, the full history was scanned with gitleaks (gitleaks git -v .). It found an AWS access key in an .env file committed by mistake more than a year earlier and deleted by another commit two days later. Deleting it from the file did not remove it from history. Anyone with a clone had it on disk.

The key was still active and production was using it.

Who could have it: current and former developers, two freelancers, their laptops and any service with access to the repository. With it one could read, overwrite and delete customer files and database backups.

Assumption: the repository was never public. Had it been, one would have to assume a stranger already has the key, because public repositories are scanned by bots looking for exactly this.

03Technical root cause

The secret was in a file that was not in .gitignore, and nothing checked commits before they were pushed. Git is designed to keep history. Deleting a file in a later commit removes nothing from the earlier ones.

A second cause widened the impact: the application needed to upload and read attachments in one bucket, but the key had permissions over all of S3. Leaking one key therefore also meant access to the backups.

04Investigation

The IAM console shows, for each key, when, in which region and for which service it was last used. The last use matched the production application.

CloudTrail keeps management events, such as listing buckets or changing permissions, for 90 days without any setup, and its event history can be filtered by access key. There were no calls from IP addresses other than the production server.

Reads and writes of individual S3 objects are data events, which CloudTrail does not record by default, and they were not enabled. It therefore cannot be shown that nobody downloaded files. The incident record says exactly that, not that no leak occurred.

05Remediation

1. Rotation without downtime: create a new key, deploy it to the server, confirm the application works, deactivate the old key and delete it after a few days without errors. A deactivated key can be switched back on if something breaks; a deleted one cannot.

2. The new key belongs to a user whose policy allows only what the application does: upload and read an object under one prefix of one bucket. It has no access to the backups at all.

IAM policy: app-uploads
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject"],
      "Resource": "arn:aws:s3:::rezervacie-prilohy/uploads/*"
    }
  ]
}

3. Secrets out of the repository: .env is in .gitignore and only an .env.example with empty values stays in the repository. On the server the file is readable only by the user the application runs as. Deployment secrets are in GitHub Actions secrets.

4. Checks before and after push: gitleaks runs as a pre-commit hook for developers and again in CI on every pull request, where it cannot be skipped by disabling the hook. The old, already invalid key is listed by its finding fingerprint in .gitleaksignore, or the history scan in CI would fail forever.

5. History: after rotation the key in the history is invalid and therefore harmless. Rewriting history (with git filter-repo, for example) is possible, but everyone would have to re-clone, and copies that already exist would not be affected. The company left the history alone and relies on rotation.

Alternatives considered: running in AWS, the application would need no long-lived key at all and would receive short-lived credentials through an IAM role. Outside AWS there is IAM Roles Anywhere, but it requires your own certificate authority, which was needlessly complex for this team. Blocking secrets at push time (push protection) for private repositories is a paid GitHub feature.

06Why the fix works

Rotation makes the leaked key worthless: AWS rejects a deactivated or deleted key wherever it is stored. That is why rotation is the main measure and rewriting history would be cosmetic.

The narrower policy limits the impact of any future leak. A key that may only s3:PutObject and s3:GetObject under one prefix cannot list buckets, delete anything or read the backups.

gitleaks looks for the patterns of known secret types in changes. The hook catches a mistake before it leaves the machine; CI catches the case where someone does not have the hook or bypasses it.

What it does not address: the key on the server is still long-lived, and whoever controls the server gets it. Detection is pattern based, so a secret in an unusual format can slip through.

07Validation

  • Calling aws sts get-caller-identity with the old key failed with InvalidClientTokenId.
  • With the new key, uploading and reading attachments works, while aws s3 ls and reading from the backup bucket end in AccessDenied.
  • A test commit with a fake key was stopped by both the pre-commit hook and the CI check.
  • A new gitleaks run over the full history found no other secret.

08Results

Fixed

  • The leaked key is invalidated and deleted.
  • Secrets are out of the repository, and new ones cannot get in without a warning.

Risk reduced

  • The impact of a leak of the application key shrank from all of S3 to attachments under one prefix.

Still open

  • It cannot be shown that nobody downloaded files from S3 during that year.
  • The application still uses a long-lived key.

09Limitations

  • Secret detection is not complete: it catches known formats, not every password.
  • It cannot reach copies of the repository that already exist outside the company.
  • Without CloudTrail data events, the company would again not know which files were read after a future leak.

10Lessons

  • Deleting from a file is not deleting from Git. A leaked secret is dealt with by rotation.
  • An application key should be allowed exactly what the application does, nothing more.
  • Backups must not be reachable with the key the application works with.
  • The CI check is mandatory; the hook is a convenience for the developer.
  • An incident record should say what can be shown and what cannot.

11Next steps for a small company

  1. 1.Enable CloudTrail data events at least for the backup bucket (they are billed, so be selective).
  2. 2.Turn on two-factor authentication for everyone with access to the AWS console and GitHub.
  3. 3.At the next hosting change, consider running in AWS or IAM Roles Anywhere to drop the long-lived key.
  4. 4.Once a quarter, review IAM users and keys and delete the unused ones.
Get in touch

Recognise your own company in this?

Tell us what it involves. We reply within one business day and say whether it is work for us, even when the answer is no.