凭据一旦提交到 Git,就成为版本化对象关系的一部分,协作者和自动化系统都有可能复制它。从最新目录树删除文件,并不会删除先前包含它的提交。实际应建立的边界,是让凭据值不进入源码历史,再通过获得授权的运行时机制供应。仓库配置可以说明怎样取得秘密,而不需要把秘密本身写进可复制的源文件。

坏掉的样子

一个看起来干净的检出,可能掩盖历史版本中已经暴露的值。新克隆通常会收到可达历史,包括已删除文件的旧版本。不能说 Git 在任何清理策略下都会永远保留所有对象,但“删掉今天的文件”确实不等于清理历史。即使重写一个仓库,也无法召回已经独立分发出去的克隆、分叉、缓存和构建产物,清理某一位置不能代表所有副本都已消失。

面对真实泄漏,应先撤销或替换相关凭据,并确认哪些系统会接受它。删除一行改变的是发布内容,不是认证状态。历史重写应单独协调,因为它会改变提交标识,也可能打断协作者的工作。说明事件时,不能再把原值复制到问题单、提交说明、扫描报告或事故笔记里,否则处理过程本身又制造了新的传播位置。

运行时注入也不会自动保密。环境值可能被子进程继承或被诊断工具采集;文件可能被具有足够访问权限的身份读取。应限制消费服务的权限,控制交付通道,避免把真实值写入日志和生成结果。即使文件位于仓库外,也仍然需要明确的拥有者与生命周期,不能把“没有提交到 Git”当作全部秘密管理工作已经完成。

快速验证

在安装了 Git 的 POSIX 系统上运行下列 Python 脚本。它使用明确的虚构标记,绝不使用任何有效凭据;两个仓库和运行时文件都位于同一个临时目录,退出后一起清理。夹具排除继承的 GIT_* 覆盖值、系统及全局配置和模板钩子,记录中的 Git 版本为 2.50.1:

from pathlib import Path
from tempfile import TemporaryDirectory
import io,os,stat,subprocess,sys,tarfile

env={key:value for key,value in os.environ.items() if not key.startswith('GIT_')}
env.update(GIT_CONFIG_NOSYSTEM='1',GIT_CONFIG_GLOBAL=os.devnull)
with TemporaryDirectory(prefix='credential-repo-lab-') as directory:
    base=Path(directory);repo=base/'source';repo.mkdir()
    def git(*args,cwd=repo,binary=False):
        result=subprocess.run(['git',*args],cwd=cwd,env=env,capture_output=True,text=not binary)
        assert result.returncode==0,result.stderr
        return result.stdout
    git('init','-q','--template=');git('config','user.name','Credential Lab')
    git('config','user.email','credentials@example.invalid')
    git('config','commit.gpgsign','false')
    dummy='EXAMPLE-ONLY-NOT-A-REAL-SECRET\n'
    (repo/'fixture.env').write_text(dummy)
    (repo/'app.txt').write_text('read the configured credential file at runtime\n')
    git('add','--','fixture.env','app.txt');git('commit','-qm','Synthetic exposure fixture')
    original=git('rev-parse','HEAD').strip()
    git('rm','--','fixture.env');git('commit','-qm','Remove fixture from current tree')
    assert not (repo/'fixture.env').exists()
    assert git('show',original+':fixture.env')==dummy
    clone=base/'clone';git('clone','-q','--no-local',str(repo),str(clone))
    assert not (clone/'fixture.env').exists()
    assert git('show',original+':fixture.env',cwd=clone)==dummy
    print('current file absent; historical fixture readable in source and clone')
    archive=git('archive','--format=tar','HEAD',binary=True)
    with tarfile.open(fileobj=io.BytesIO(archive),mode='r:') as bundle:
        assert bundle.getnames()==['app.txt']
    print('current-tree archive excludes fixture; Git history still contains it')
    runtime=base/'runtime';runtime.mkdir(mode=0o700)
    secret_file=runtime/'credential';secret_file.touch(mode=0o600)
    secret_file.write_text(dummy)
    assert stat.S_IMODE(secret_file.stat().st_mode)==0o600
    child_env=dict(env,CREDENTIAL_FILE=str(secret_file))
    child="from pathlib import Path; import os; p=Path(os.environ['CREDENTIAL_FILE']); assert p.read_text(); print('credential loaded without printing its value')"
    result=subprocess.run([sys.executable,'-c',child],cwd=clone,env=child_env,text=True,capture_output=True)
    assert result.returncode==0,result.stderr
    assert dummy.strip() not in result.stdout+result.stderr
    print(result.stdout.strip())

第一项确认,两个当前工作区都没有样本文件,但都能通过早先的提交读取它的原始内容。克隆使用普通 Git 传输路径,关闭本地硬链接优化,因此验证的是另一个实际建立起来的对象数据库,不只是两个路径恰巧指向同一份文件。脚本在内部比较虚构标记,结果只说明是否保留,没有打印它的值,保留了检查与泄漏输出之间的区别。

当前树的归档只包含应用说明。这说明导出一个干净的当前目录树,可以不带上已删除文件,同时源仓库历史仍然保留该文件。但这个结果不能证明任何打包方式都安全:构建脚本可能接收其他输入、复制仓库元数据,或者把编译时传入的秘密写进产物。应检查实际发布路径,不能把某一次安全的归档命令外推成所有构建步骤的保证。

运行时示例创建私有目录,并将凭据文件设置为仅拥有者可读写。子进程通过环境取得的是文件路径,读取后只打印一条确认消息;断言会拒绝意外输出虚构标记。这里证明了源码与运行时文件分离,以及普通 POSIX 权限设置成立,并没有实现完整秘密服务、静态加密,也不能保护它免受相同操作系统身份读取。实验边界需要与真实部署需求分开理解。

明确交付与恢复路径

仓库中可以提交配置结构或带有无害占位值的示例,实际值由部署平台或访问范围受限的秘密服务供应。如果平台支持成熟的工作负载身份,可优先沿用,避免为了获取一种凭据,又永久分发另一把权限更大的启动凭据。同时要决定应用怎样重新载入替换值,以及旧访问怎样被撤销;仅把新值写入存储,不代表运行中的进程已经开始使用它。

在能够减少常见失误的位置加入秘密检测,例如提交前和持续集成中,并使用合适的虚构样本验证规则是否真的生效。模式和熵检测可能漏掉不认识的凭据,也可能把合法示例识别为问题。检测结果应作为调查证据,不能把没有告警视为仓库绝对安全,也不能为了消除一个误报就不加区分地屏蔽整类风险。

按照实际交付链检查发布归档、容器层、构建日志、缓存和诊断附件。Git 的忽略规则不会自动控制这些输出。记录受影响凭据的标识和撤销证据即可,不要复制真实值。完整修复需要旧访问确实失效、替换过程受控、传播范围得到理解;一个删除提交只能证明最新目录树发生了变化,无法单独证明凭据已经失去作用。

延伸阅读

来源

  • GitHub 敏感数据清理,先撤销凭据、再协调历史清理,以及分布副本的限制,已核验 2026-09-12。
  • OWASP 秘密管理,交付、最小权限、日志与凭据生命周期,已核验 2026-09-12。
  • git archive,导出选定的仓库目录树,已核验 2026-09-12。