git-pkgs/pin
GitHub: git-pkgs/pin
一个用 Go 编写的浏览器前端资源固定工具,无需 npm 即可安全地将 JS/CSS 等供应商文件锁定到代码库中,同时生成符合 CycloneDX 标准的 SBOM 并提供多层供应链安全保障。
Stars: 6 | Forks: 0
# pin
无需 npm 即可固定(pin)供应商(vendored)的浏览器资产:这是一个单一的静态二进制文件,它从已发布的包中获取文件,将其完整性锚定到 registry 的 tarball 上,将它们提交到你的代码库,并写入一个同时也是有效 CycloneDX SBOM 的 lockfile。
如果你的服务端渲染应用需要 htmx、一个 CSS 套件和一个图标集,那就是三个依赖项。为它们运行 `npm install` 会给你带来一个包含数百个传递依赖包的 `node_modules`、一个你平时根本不会用到的 lockfile 格式、CI 中的 Node runtime,以及每次安装时通过生命周期钩子执行的任意代码。`pin` 会在你固定的版本下获取你指定的文件,根据 npm 发布的内容对它们进行哈希处理,并将它们写入磁盘,期间不运行任何 install 脚本、钩子或插件加载器。
## 安装说明
Homebrew:
```
brew tap git-pkgs/git-pkgs
brew install pin
```
Go:
```
go install github.com/git-pkgs/pin/cmd/pin@latest
```
或者一旦发布,从发布页面获取二进制文件。
## 快速开始
编写 `pin.yaml`:
```
out: "internal/web/static/vendor"
assets:
- name: "htmx.org"
version: "^2.0"
files: ["dist/htmx.min.js"]
- name: "@tailwindcss/browser"
version: "4.1.13"
- name: "lucide"
version: "^0.545"
files: ["dist/umd/lucide.min.js"]
- name: "highlight.js"
version: "11.11.1"
source: "github:highlightjs/cdn-release"
files:
- "build/highlight.min.js"
- "build/styles/github.min.css"
```
运行 `pin sync` 以获取:
```
internal/web/static/vendor/
htmx.org/htmx.min.js
tailwindcss__browser/index.global.js
lucide/lucide.min.js
highlightjs__cdn-release/highlight.min.js
highlightjs__cdn-release/github.min.css
pin.lock
```
version 字段接受精确的固定版本(`2.0.6`)、semver 范围(`^2.0`, `~0.3.11`)或 npm dist-tags(`latest`, `next`)。一旦版本被锁定,它就会保持锁定状态:只要 manifest 约束仍然允许,`pin sync` 就会重新使用锁定的版本,而 `pin update` 会在一个范围内进行升级。当 npm source 省略 `files:` 时,`pin` 会读取包的 `package.json`,并从 `jsdelivr || unpkg || browser || module || main` 中选择入口点。
## 来源类型
```
- name: "htmx.org" # npm (default)
version: "^2.0"
- name: "highlight.js" # GitHub release
version: "11.11.1"
source: "github:highlightjs/cdn-release"
files: ["build/highlight.min.js"]
- name: "my-asset" # Raw URL (TOFU)
version: "1.0.0"
source: "url:https://example.com/dist/asset.js"
```
`github:` 源将 tag 解析为 commit SHA,通过 jsdelivr 的 `/gh/` 镜像进行获取,并将 SHA 记录在 lockfile 中作为完整性锚点。`url:` 源在首次获取时对字节进行哈希处理,并在随后的每次同步中根据记录的哈希值进行验证。两者都通过相同的 `source.Resolver` 接口,因此以后添加 gitlab/codeberg/bitbucket 只需增加单个新文件即可。
`--registry`(或 `SyncOptions.RegistryURL`)会覆盖整个同步过程的 npm registry。对于单个条目,请将该资产添加 `registry_url:`:
```
assets:
- name: "private-pkg"
version: "1.0.0"
files: ["dist/x.js"]
registry_url: "https://npm.private.example/"
```
pin 将覆盖记录作为 `repository_url` 限定符写在 `pin.lock` 中该资产的 purl 上,因此 lockfile 可以忠实地进行往返转换。系统不会读取 `~/.npmrc` 和 registry-auth tokens;pin 目前能访问的私有 registry 是那些不需要凭证或其凭证存在于 URL 中的 registry。
## 命令
```
pin sync resolve manifest, fetch assets, write lockfile (alias: pin install)
pin sync --frozen fail before any network if manifest and lockfile disagree (CI)
pin sync --no-fetch --frozen plus re-hash on-disk files against the lockfile; no network, no writes
pin sync --concurrency=N cap parallel resolves (default 8)
pin sync --dry-run [--json] resolve and report, write nothing
pin update [NAME...] re-resolve to highest satisfying version, ignoring the lock
pin verify [--strict] [--json] re-hash files on disk against the lockfile (exit 4 on drift)
pin outdated [--json] compare locked versions against the registry's latest
pin add NAME[@SPEC] [FILE...] append to the manifest at alphabetic position and sync
pin rm NAME... remove entries from the manifest and sync
pin list [--json] print the lockfile contents
pin path NAME print the on-disk paths for a locked package
pin init write a starter pin.yaml in the current directory
pin sbom [-f spdx|cyclonedx-xml] [-o FILE] emit the lockfile as an SBOM
```
当 `pin sync` 检测到 CI 环境(`CI`, `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`, `JENKINS_URL`)且未设置 `--frozen` 时,它会打印一行 stderr 提示信息。
## 安全默认值
冷却窗口(`min_release_age`)默认开启,时长为 48 小时。大多数恶意的 npm 版本会在 24 到 48 小时内被发现,该窗口可以阻止大多数新鲜发布的供应链攻击。范围会回退到该窗口之外满足条件的次高版本;如果 `latest` 太新,dist-tags 会以明确的错误提示失败;精确的固定版本会绕过该窗口,因为你已经显式指定了版本。可以在 manifest 顶层或每个条目中使用 `min_release_age: 0` 来选择退出。
`--frozen` 是 CI 安全标志:如果 manifest 和 lockfile 不一致,它会在进行任何网络操作之前中止。`--no-fetch` 在 `--frozen` 的基础上,增加了对每个 vendor 文件针对 lockfile 记录的完整性进行的重新哈希校验,适用于那些在镜像构建时进行 vendor 并且想要断言在 `git checkout` 之后没有任何内容被篡改的 CI 作业,而无需进行任何网络操作或写入。
`pin sync` 仅在 manifest 发生更改时重写 lockfile;如果字节完全相同则跳过写入。pin 不会运行获取包中的任何代码,这使得[包安装阶段](https://nesbitt.io/2026/04/27/the-stages-of-package-installation.html)的第 5 和第 6 阶段超出了范围。
## 来源与可信发布
对于 npm 和 GitHub forge 源,当发布者使用可信发布时,`pin sync` 会在 lockfile 中记录 SLSA Provenance v1 证明:`builder_id`(CI workflow URI)、`source_repository`、`source_revision`、`signer_identity`(OIDC SAN)以及 bundle URL。
三个可选标志用于建立信任断言层级:
```
pin sync --strict-provenance
fail if any entry resolves to a version with no attestation.
pin sync --require-publisher-matches-repository
fail if an attestation's source repository differs from the package's declared
repository.url. Catches leaked-token attacks: a stolen publish token can sign
a valid bundle from the attacker's CI, but the source_repository field then
won't match the legitimate package's repo.
pin sync --verify-provenance
cryptographically verify the sigstore bundle against the live Sigstore TUF
trust root: Fulcio cert chain, Rekor inclusion proof, DSSE signature,
subject digest matches the fetched artifact. Composes with the other two.
Trust root is cached at $XDG_CACHE_HOME/pin/sigstore-tuf/ after first use.
pin sync --signature-mode {warn|enforce|off}
verify npm dist.signatures (ECDSA P-256 over {name}@{version}:{integrity},
keys fetched from /-/npm/v1/keys). warn (default) fails on bad sigs but
tolerates absent ones; enforce additionally fails on absent.
```
这些每次调用标志的持久形式是 manifest 中的 `trust:` 块,可在顶层或按条目设置:
```
trust:
require_provenance: true
require_publisher_matches_repository: true
trusted_workflows:
- https://github.com/builder-org/builder/.github/workflows/release.yml
assets:
- name: monorepo-pkg
version: ^1.0.0
trust:
require_publisher_matches_repository: false # entry-level override
```
`trusted_workflows` 是 monorepo 包的逃生舱口,这些包的合法构建 workflow 所在的 repository 与包声明的 `repository.url` 不同。CLI 标志始终优先于 manifest 条目:`--strict-provenance` 会强制对已选择退出的条目进行检查。
当锁定版本具有证明而最新版本没有时,`pin outdated` 会标记一个 `provenance-downgrade` 严重性(高于 deprecated,低于 yanked),这揭示了维护者(或现在控制发布 token 的任何人)禁用了可信发布的情况。
## Lockfile
`pin.lock` 是一个有效的 CycloneDX 1.6 SBOM。每个包都成为一个带有 registry tarball 哈希的 `library` 组件;每个 vendor 文件都成为一个嵌套的 `file` 组件,带有自己的 SHA-384、CDN URL 以及 `pin:` property 命名空间下的 pin 特定元数据。任何 CycloneDX 消费者(Dependency-Track、GUAC、OSV-scanner、`git-pkgs sbom`)都可以直接读取它。刻意省略了 `serialNumber` 和 `metadata.timestamp`,以便重复运行在字节级别保持稳定,并且并行分支不会在文件上发生冲突。
schema 在 [docs/SPEC.md](docs/SPEC.md) 中,防御机制在 [docs/SECURITY.md](docs/SECURITY.md) 中,按资产划分的对手模型在 [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md) 中。
## 完整性
在首次同步 npm 包版本时,`pin` 会获取 registry 元数据,下载已发布的 tarball,根据 npm 的 `dist.integrity` 对其进行验证,提取请求的文件,并计算每个文件的 SHA-384。相同版本的后续同步会根据记录的哈希值进行验证,因此 lockfile 中的 CDN URL 只是传输提示,而不是完整性锚点。
GitHub 源锚定到 commit SHA(记录为 library 组件上的 `SHA-1` 哈希加上 purl 上的 `vcs_revision` 限定符);url 源锚定到每个文件的 SHA-384,建立在首次使用时信任的基础上。
## 格式嗅探
对于每个 vendor 脚本,`pin` 通过使用注释和字符串感知的正则表达式传递扫描字节来检测模块格式(`esm`、`umd`、`iife`、`cjs`、`amd`、`system` 或 `unknown`)。结果保存在 lockfile 的 `pin:format` 属性中,以便 importmap 消费者可以筛选出 ESM 条目。在 manifest 中使用 `format:` 可按条目进行覆盖。
## 不适用的情况
`pin` 适用于自包含的可分发文件:UMD bundle、IIFE 构建、没有裸说明符导入的 ESM 模块、CSS 文件。它不适用于期望在运行时具有模块图的包,也不运行 install 脚本。如果包的实际有效负载是通过 `postinstall` 钩子到达的(tarball 落地后下载的平台二进制文件),`pin` 将只会 vendor 这个存根。如果包附带了预打包的 CDN 发行版,请将 `files:` 指向它;如果它没有附带预打包的发行版并且依赖于 bundler 或 `postinstall` 来组装自身,那就超出了范围。
## 作为 Go 库使用
对于一次性脚本,包级别的函数接受与 CLI 标志封装的相同选项(CLI 本身就是它们之上的一个薄 shim):
```
import "github.com/git-pkgs/pin"
res, err := pin.Sync(ctx, pin.SyncOptions{Dir: "."})
```
对于长期运行的进程(Rails gem、CI 服务、自定义集成器),`pin.Client` 模式允许单个实例在跨调用中复用其 HTTP 连接池和源解析器:
```
c := pin.New(pin.ClientOptions{RegistryURL: "https://registry.npmjs.org"})
c.Sync(ctx, pin.SyncOptions{Dir: "./app-a"})
c.Sync(ctx, pin.SyncOptions{Dir: "./app-b"})
c.Verify(pin.VerifyOptions{Dir: "./app-a"})
```
源解析器可以通过 purl 类型进行插拔。为任何前缀(`pkg:ipfs/...`、内部 artifact registry 等)注册一个新的 resolver,`Sync` 就会将带有该 purl 的 manifest 条目分派给它:
```
c.RegisterResolver("ipfs", myIPFSResolver{})
```
完整的 Client 接口:`Sync`、`Verify`、`Outdated`、`Add`、`Remove`,加上包级别的 `List`、`Path`、`Init`、`SBOM`、`EncodeLock`。`manifest`、`lock`、`pinfs`、`integrity`、`cdn`、`sniff`、`source`(包含 `source/npm`、`source/forge`、`source/rawurl`)和 `assets` 子包都是公开的。
`SyncOptions.FS` 将 pin 的输出(vendor 文件 + `pin.lock`)重定向到任何实现 `pinfs.Writer` 的目标中。默认情况下,它写入 `SyncOptions.Dir` 下的本地路径;`pinfs.NewMemory()` 将所有内容保留在进程中,而自定义实现可以将写入管道输送到 tarball、archive 或内存中的构建 artifact。
Provenance 处理存在于两个同级模块中:[`github.com/git-pkgs/attestation`](https://github.com/git-pkgs/attestation)(仅限 stdlib 的 SLSA Provenance v1 bundle 解析器)和 [`github.com/git-pkgs/sigstore`](https://github.com/git-pkgs/sigstore)(sigstore-go wrapper,针对 Sigstore TUF 信任根验证任何 `(digestAlg, digest)` 对)。两者都可以独立于 pin 导入。
`assets` 包是 Go Web 应用用来消费 `pin` 输出的 runtime 辅助工具:解析 lockfile、通过 `fs.FS` 提供 vendor 文件,并从模板发出带有 `integrity` 和 `crossorigin` 属性的 HTML 标签。
失败模式以包装的哨兵错误形式出现:`errors.Is(err, pin.ErrFrozenDrift)`、`pin.ErrVerifyFailed`、`pin.ErrProvenanceMissing`、`pin.ErrPublisherMismatch`、`pin.ErrPathEscape`、`pin.ErrPathCollision`、`pin.ErrNoLockfile`。
## 框架集成
`assets` 包只导入 `lock` 和标准库,因此任何接受 `fs.FS`(或目录)的 Go Web 框架和任何接受 `template.HTML` 的模板引擎都可以在无需特定框架适配器的情况下工作。
| 框架 | 服务 | 标签输出 |
|-------------------|--------------------------------------------------|------------------------------------------------|
| `net/http` | `http.FileServer(http.FS(afs))` | `html/template` 中的 `assets.Tag` / `Tags` |
| Chi | `r.Handle("/vendor/*", http.FileServer(...))` | 相同 |
| Gin | `r.StaticFS("/vendor", http.FS(afs))` | 返回 `template.HTML` 的模板辅助函数 |
| Echo | `e.StaticFS("/vendor", afs)` | 接受 `template.HTML` 的渲染器 |
| Fiber | `app.Use("/vendor", filesystem.New(...))` | 特定于引擎的 Raw 辅助函数 |
| [Templ](https://templ.guide) | `http.FileServer(http.FS(afs))` | `@templ.Raw(assets.Tag(lock, name, opts)[0])` |
| Wails | 嵌入式 UI 旁边的 bundle | 内联在嵌入的 HTML 中 |
无论框架如何,常见的结构为:
```
import (
"bytes"
"embed"
"github.com/git-pkgs/pin/assets"
)
//go:embed static/vendor pin.lock
var vendored embed.FS
lockBytes, _ := vendored.ReadFile("pin.lock")
lock, _ := assets.Parse(bytes.NewReader(lockBytes))
afs, _ := assets.FS(vendored, lock)
// afs implements fs.FS — pass to http.FileServer(http.FS(afs)) or any
// framework's static-file handler. Render tags from your template with
// assets.Tag(lock, "htmx.org", assets.Options{Prefix: "/vendor/"}).
```
## 在二进制文件中嵌入 vendor 字节
对于单一二进制文件分发,将 `pin sync` 指向你的模块中的一个目录,并将其与 lockfile 一起 `//go:embed`:
```
# pin.yaml
out: "internal/web/static/vendor"
```
```
//go:embed internal/web/static/vendor pin.lock
var vendored embed.FS
```
`assets.Parse` + `assets.FS` 从相同的 `embed.FS` 中读取,因此二进制文件没有运行时文件系统依赖,也没有需要发布的单独 `static/vendor` 目录。`pin verify --no-fetch` 在构建之前针对磁盘上的副本运行,以确认嵌入的字节与 lockfile 声明的一致。
## 稳定性
位于 `github.com/git-pkgs/pin` 的 Go API 涵盖了函数 `Sync`、`Add`、`Outdated`、`Verify`、`Remove`、`List`、`Path`、`Init`、`SBOM`、`EncodeLock` 和 `New`,加上 `Client` 可复用客户端模式以及它们接受的选项、结果和类型。`lock`、`manifest`、`pinfs` 和 `assets` 子包也包含在内,连同哨兵错误也是如此。移除或重命名其中的任何一个都需要一个新的主要版本(`/v2`、`/v3`)。选项 struct 上的新字段是附加的,不会增加主要版本号。
`pin.lock` 在 CycloneDX 元数据下包含一个 `pin:lockfile_version` 属性。二进制文件会拒绝任何它无法识别其版本的 lockfile。新字段作为 `pin:` 命名空间下的附加属性出现,不会增加版本号。版本提升仅在出现不兼容的 schema 更改时发生,并作为带有迁移说明的独立版本发布。
`pin.yaml` 遵循相同的约定:新字段是附加的,现有字段在各个版本中保持其含义,移除或语义更改将在带有明确迁移步骤的新的主要版本中发布。
`pinfs.Writer` 接口和 `source.Resolver` 接口在形状上是稳定的:向其中任何一个添加方法都是破坏性更改。新行为将放在并行接口或选项 struct 中。
`api_stability_test.go` 引用了每一个公共符号,因此被移除或重命名的导出会立即使 `go build` 中断;pkg.go.dev 是实时记录。
## 许可证
MIT
标签:CycloneDX, DevSecOps, EVTX分析, Go语言, htmx, SBOM, Semver, Tailwind CSS, Webpack替代, 上游代理, 前端依赖管理, 前端安全, 前端工程化, 包管理工具, 哈希校验, 安全可观测性, 安全左移, 完整性校验, 无npm, 日志审计, 服务端渲染, 浏览器资源管理, 版本控制, 硬件无关, 程序破解, 网络信息收集, 资源引入, 锁定文件, 静态二进制文件, 静态资源