Secrets In Internal Repositories And Scripts
How Secrets In Internal Repositories And Scripts works
Secrets in internal repositories are live credentials stored as text where engineers keep their work: Git servers, wikis, tickets and scripts. A self-hosted GitLab or Gitea instance, a Confluence space, a Jira attachment, the Scripts folder on the IT share, NETLOGON logon scripts, SCCM and Intune package sources, unattend.xml files left in a build share, and the .env or web.config that shipped with the application. The value is usually a service account password, an API key, a connection string or a private key, and it usually still works.
The attacker who lands on any domain-joined workstation reads these with the access they already have, which converts a foothold into a service account without touching a login prompt. It is easy to miss because the working tree looks clean: someone noticed the password, deleted the line, committed the fix and closed the ticket, while the original blob sits in the pack file forever and the credential was never rotated. Scanners pointed at a checkout report nothing. Group Policy Preferences cpassword in SYSVOL belongs to the Group Policy Preferences (GPP) Passwords in SYSVOL page in the Active Directory section; the interest here is plaintext in the script and package bodies themselves.
Secrets In Internal Repositories And Scripts in practice
Enumerate every code and runbook store the test account can reach
Pull the full project list before cloning anything, including archived and personal namespaces, because the abandoned projects are the ones nobody cleaned.
# GitLab: every project visible to the test token, archived included
curl -s --header "PRIVATE-TOKEN: <GITLAB_TOKEN>" \
"https://git.lab.internal/api/v4/projects?per_page=100&archived=true&simple=true" \
| jq -r '.[] | "\(.path_with_namespace)\t\(.visibility)\t\(.last_activity_at)"'
# Gitea or Forgejo equivalent
curl -s -H "Authorization: token <GITEA_TOKEN>" \
"https://git.lab.internal/api/v1/repos/search?limit=50" | jq -r '.data[].full_name'
# Mirror clones keep all refs and all history, which is what you actually scan
mkdir -p /opt/audit/repos
git clone --mirror https://git.lab.internal/infra/deploy-scripts.git \
/opt/audit/repos/deploy-scripts.git
Do the same for the wiki and the tracker: a Confluence CQL search for text ~ “password” and a Jira search across attachments finds more than the code usually does.
Scan the commit graph, not the working tree
A checkout scan proves nothing. Point the scanner at all refs and full history so deleted-but-present blobs surface.
gitleaks detect --source /opt/audit/repos/deploy-scripts.git \
--log-opts="--all --full-history" --redact \
--report-format json --report-path /opt/audit/out/deploy-scripts.json
trufflehog git file:///opt/audit/repos/deploy-scripts.git \
--json --only-verified > /opt/audit/out/deploy-scripts-verified.json
# Which commit introduced a pattern, and whether it was ever really removed
git -C /opt/audit/repos/deploy-scripts.git log --all --oneline -S 'PASSWORD='
git -C /opt/audit/repos/deploy-scripts.git log --all --oneline -S 'BEGIN RSA PRIVATE KEY'
The observable is a commit hash where the secret enters and no rotation record for that credential afterwards.
Crawl the non-Git stores
Most internal secrets are not in Git at all. Logon scripts, deployment shares and build artefacts hold the rest, and they are readable to any authenticated user.
$paths = @(
'\\dc01.lab.internal\NETLOGON',
'\\sccm01.lab.internal\SMSPKGD$',
'\\fs01.lab.internal\IT$\Scripts'
)
$patterns = @(
'password\s*=', 'net use .*/user:', '-AsPlainText', '<Password>',
'client_secret', 'Data Source=.*Password=', 'BEGIN [A-Z ]*PRIVATE KEY'
)
Get-ChildItem -Path $paths -Recurse -ErrorAction SilentlyContinue `
-Include *.ps1,*.bat,*.cmd,*.vbs,*.xml,*.ini,*.config,*.json,unattend.xml |
Select-String -Pattern $patterns |
Select-Object Path, LineNumber, Line |
Export-Csv C:\audit\share-secrets.csv -NoTypeInformation
Record the file path and line number in the report, never the value.
Prove the secret is live without using it
A dead credential is a hygiene note; a live one is a finding. Validate with the cheapest read-only call the credential supports.
Finding Safe live-check Verdict
---------------------------- ------------------------------------- -----------
svc_backup / <PASSWORD> LDAP simple bind, then read own user live
in a NETLOGON .bat object only. No writes, no group edits.
Azure app <CLIENT_SECRET> POST /oauth2/v2.0/token with the live
.default scope. Token issued = valid.
Vendor SaaS API key GET the /whoami or /v1/me endpoint. live
Root password on a host Do not test. Confirm the host is gone unverified
named in a 2019 commit in the CMDB, report as historical.
Log the first four characters and a hash of the value as evidence. Pasting the full secret into the ticket system creates a second copy of the leak.
How to fix and prevent Secrets In Internal Repositories And Scripts
- Rotate first, clean second
- Every secret that reached a repository, share or wiki is disclosed. Rotate it before rewriting anything, because clones and backups already carry the old value.
- Record the rotation against the finding so the scanner result and the credential lifecycle line up.
- Broker secrets at runtime
- Replace the literal with a lookup: a vault reference, a gMSA for Windows service accounts, or a managed identity for cloud calls, so the script holds a name and not a value.
- Partial control: the vault token or the identity binding then becomes the thing worth stealing, so scope it per host and per job.
- Block the commit rather than audit it
- Pre-receive secret scanning on the internal Git server plus a local pre-commit hook, so the credential never reaches the pack file.
- Scheduled full-history scans catch what predates the hook.
- Purge history and force a re-clone
- git filter-repo or BFG on the mirrored copy, then expire reflogs, run aggressive gc and require every fork and local clone to be re-created.
- Partial control: forks, CI caches and developer laptops keep the old objects until they are re-cloned, which is why rotation comes first.
- Put the non-Git stores on the same schedule
- Crawl NETLOGON, deployment shares, package sources, wiki spaces and ticket attachments on the same cadence as the repositories, and tighten who may write to those shares.
Last updated