release-skill 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.agents/plugins/marketplace.json +23 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/CHANGELOG.md +68 -0
  6. package/CODE_OF_CONDUCT.md +76 -0
  7. package/CONTRIBUTING.md +49 -0
  8. package/INSTALL.md +182 -0
  9. package/LICENSE +21 -0
  10. package/NOTICE +25 -0
  11. package/README.md +501 -0
  12. package/README.zh-CN.md +463 -0
  13. package/SECURITY.md +48 -0
  14. package/adapters/claude/.claude-plugin/marketplace.json +16 -0
  15. package/adapters/claude/.claude-plugin/plugin.json +10 -0
  16. package/adapters/claude/skills/release-assess/SKILL.md +52 -0
  17. package/adapters/claude/skills/release-help/SKILL.md +60 -0
  18. package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
  19. package/adapters/claude/skills/release-publish/SKILL.md +55 -0
  20. package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
  21. package/adapters/claude/skills/release-verify/SKILL.md +70 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +26 -0
  23. package/adapters/codex/skills/release-assess/SKILL.md +52 -0
  24. package/adapters/codex/skills/release-help/SKILL.md +60 -0
  25. package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
  26. package/adapters/codex/skills/release-publish/SKILL.md +55 -0
  27. package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
  28. package/adapters/codex/skills/release-verify/SKILL.md +70 -0
  29. package/bin/release-skill.mjs +743 -0
  30. package/native/safe-write/binding.gyp +40 -0
  31. package/native/safe-write/prebuilds.json +4 -0
  32. package/native/safe-write/src/safe_write.cc +2023 -0
  33. package/package.json +75 -0
  34. package/references/.render-manifest.json +33 -0
  35. package/references/00-target-state.md +124 -0
  36. package/references/01-state-machine.md +155 -0
  37. package/references/02-project-config.md +217 -0
  38. package/references/03-readme-quality.md +136 -0
  39. package/references/04-supply-chain.md +147 -0
  40. package/references/05-evidence-and-errors.md +164 -0
  41. package/references/06-adapter-contract.md +178 -0
  42. package/schemas/.render-manifest.json +37 -0
  43. package/schemas/approval-record.schema.json +115 -0
  44. package/schemas/artifact-lock.schema.json +111 -0
  45. package/schemas/artifact-plan.schema.json +52 -0
  46. package/schemas/artifact-policy.schema.json +76 -0
  47. package/schemas/evidence-event.schema.json +89 -0
  48. package/schemas/release-plan.schema.json +369 -0
  49. package/schemas/release-project.schema.json +359 -0
  50. package/schemas/release-run.schema.json +195 -0
  51. package/skills/release-assess/SKILL.md +52 -0
  52. package/skills/release-help/SKILL.md +60 -0
  53. package/skills/release-prepare/SKILL.md +71 -0
  54. package/skills/release-publish/SKILL.md +55 -0
  55. package/skills/release-reconcile/SKILL.md +73 -0
  56. package/skills/release-verify/SKILL.md +70 -0
  57. package/skills-src/release-assess/SKILL.md +52 -0
  58. package/skills-src/release-help/SKILL.md +60 -0
  59. package/skills-src/release-prepare/SKILL.md +71 -0
  60. package/skills-src/release-publish/SKILL.md +55 -0
  61. package/skills-src/release-reconcile/SKILL.md +73 -0
  62. package/skills-src/release-verify/SKILL.md +70 -0
  63. package/src/adapters/contract.mjs +214 -0
  64. package/src/adapters/git-github.mjs +214 -0
  65. package/src/adapters/npm.mjs +947 -0
  66. package/src/adapters/plugin-marketplace.mjs +1365 -0
  67. package/src/adapters/push-snapshot.mjs +216 -0
  68. package/src/artifacts/adoption.mjs +743 -0
  69. package/src/artifacts/artifact-plan.mjs +162 -0
  70. package/src/artifacts/entry.mjs +240 -0
  71. package/src/artifacts/git-authority.mjs +637 -0
  72. package/src/artifacts/graph.mjs +189 -0
  73. package/src/artifacts/inspect.mjs +520 -0
  74. package/src/artifacts/inventory.mjs +192 -0
  75. package/src/artifacts/merge/binary.mjs +77 -0
  76. package/src/artifacts/merge/entry-merge.mjs +228 -0
  77. package/src/artifacts/merge/json.mjs +641 -0
  78. package/src/artifacts/merge/markdown.mjs +246 -0
  79. package/src/artifacts/merge/regions.mjs +156 -0
  80. package/src/artifacts/merge/text.mjs +432 -0
  81. package/src/artifacts/merge/tree.mjs +202 -0
  82. package/src/artifacts/merge/yaml.mjs +669 -0
  83. package/src/artifacts/path-key.mjs +94 -0
  84. package/src/artifacts/policy.mjs +319 -0
  85. package/src/artifacts/producer-registry.mjs +439 -0
  86. package/src/artifacts/project-lock.mjs +732 -0
  87. package/src/artifacts/resolution.mjs +658 -0
  88. package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
  89. package/src/artifacts/safe-fs.mjs +72 -0
  90. package/src/artifacts/state.mjs +495 -0
  91. package/src/artifacts/transaction-journal.mjs +983 -0
  92. package/src/artifacts/transaction.mjs +1361 -0
  93. package/src/commands/approve.mjs +280 -0
  94. package/src/commands/artifacts.mjs +627 -0
  95. package/src/commands/assess.mjs +838 -0
  96. package/src/commands/prepare.mjs +1377 -0
  97. package/src/commands/publish.mjs +883 -0
  98. package/src/commands/reconcile.mjs +1255 -0
  99. package/src/commands/verify.mjs +915 -0
  100. package/src/core/approval.mjs +332 -0
  101. package/src/core/baseline.mjs +272 -0
  102. package/src/core/blackbox-hard-gates.mjs +142 -0
  103. package/src/core/config.mjs +448 -0
  104. package/src/core/digest.mjs +90 -0
  105. package/src/core/errors.mjs +113 -0
  106. package/src/core/evidence.mjs +167 -0
  107. package/src/core/hooks.mjs +241 -0
  108. package/src/core/node-version.mjs +64 -0
  109. package/src/core/plan.mjs +735 -0
  110. package/src/core/previous-public-baseline.mjs +204 -0
  111. package/src/core/run.mjs +681 -0
  112. package/src/core/state-machine.mjs +76 -0
  113. package/src/core/version-consistency.mjs +111 -0
  114. package/src/producers/build-adapters.mjs +231 -0
  115. package/src/producers/render-public-assets.mjs +152 -0
  116. package/src/producers/sync-skills.mjs +96 -0
  117. package/src/readme/contract.mjs +297 -0
  118. package/src/readme/examples.mjs +288 -0
  119. package/src/readme/parity.mjs +122 -0
  120. package/src/snapshot/export.mjs +99 -0
  121. package/src/snapshot/frozen.mjs +401 -0
  122. package/src/snapshot/manifest.mjs +207 -0
  123. package/src/snapshot/public-map.mjs +1459 -0
  124. package/src/snapshot/public-path.mjs +110 -0
  125. package/src/snapshot/scan.mjs +419 -0
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Canonical public-path helpers shared between config loader and snapshot mapper.
3
+ *
4
+ * Delegates to the canonical artifact path implementation in
5
+ * `artifacts/path-key.mjs` to ensure snapshot and artifact inventory
6
+ * share one collision semantics. The `allowDot` option (for standalone `.`)
7
+ * is handled here as a thin wrapper.
8
+ *
9
+ * @module snapshot/public-path
10
+ */
11
+
12
+ import { ReleaseError, PUBLIC_PATH_FORBIDDEN, PATH_UNSAFE } from '../core/errors.mjs';
13
+ import { canonicalArtifactPath } from '../artifacts/path-key.mjs';
14
+ import { isAbsolute, relative, resolve } from 'node:path';
15
+
16
+ const SAFE_UNIT_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9][A-Za-z0-9._-]*$/;
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Public API
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Canonicalise a public file path.
24
+ *
25
+ * Normalises separators and rejects:
26
+ * - POSIX absolute paths (starting with `/`)
27
+ * - Windows drive-letter paths (e.g. `C:\<path>`)
28
+ * - Windows UNC paths (e.g. `\\server\share`)
29
+ * - Raw `..` segments (before normalization collapses them)
30
+ * - Empty segments (from consecutive `/` or normalisation)
31
+ * - Backslash separators
32
+ * - NUL bytes
33
+ *
34
+ * @param {string} raw - Raw path string.
35
+ * @param {object} [options]
36
+ * @param {boolean} [options.allowDot=false] - Allow standalone `.` as a valid path
37
+ * (used for `unit.source: .`).
38
+ * @returns {{ path: string }} Object with canonical `path`.
39
+ * @throws {ReleaseError} PUBLIC_PATH_FORBIDDEN on traversal or absolute path.
40
+ */
41
+ export function canonicalPublicPath(raw, { allowDot = false } = {}) {
42
+ // Allow standalone `.` for unit.source (backward compatibility).
43
+ if (allowDot && raw === '.') {
44
+ return { path: '.' };
45
+ }
46
+
47
+ // Delegate to canonicalArtifactPath; convert PATH_UNSAFE to PUBLIC_PATH_FORBIDDEN
48
+ // for backward compatibility with existing callers.
49
+ try {
50
+ const result = canonicalArtifactPath(raw);
51
+ return { path: result.path };
52
+ } catch (err) {
53
+ if (err.code === PATH_UNSAFE) {
54
+ throw new ReleaseError(
55
+ PUBLIC_PATH_FORBIDDEN,
56
+ err.message,
57
+ { path: raw },
58
+ );
59
+ }
60
+ throw err;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Compute a stable collision key for a public file target path.
66
+ *
67
+ * Normalises to NFC then applies stable case-fold (lowercase).
68
+ * Used for detecting target collisions in both config loading and runtime staging.
69
+ *
70
+ * @param {string} target - The target path string.
71
+ * @returns {string} A stable collision key (NFC + lowercase).
72
+ */
73
+ export function publicPathCollisionKey(target) {
74
+ return target.normalize('NFC').toLowerCase();
75
+ }
76
+
77
+ /**
78
+ * Resolve one release-unit-owned child under a trusted base directory.
79
+ * Unit ids are identifiers, never paths: they must be a single safe segment.
80
+ * The resolved containment check is retained as defense in depth.
81
+ *
82
+ * @param {string} baseDir absolute or relative trusted base directory
83
+ * @param {string} unitId release unit identifier
84
+ * @param {object} [options]
85
+ * @param {string} [options.suffix=''] deterministic filename suffix
86
+ * @returns {string} absolute contained path
87
+ * @throws {ReleaseError} PUBLIC_PATH_FORBIDDEN for unsafe ids or containment failure
88
+ */
89
+ export function resolveUnitScopedPath(baseDir, unitId, { suffix = '' } = {}) {
90
+ if (typeof unitId !== 'string' || !SAFE_UNIT_ID_PATTERN.test(unitId)) {
91
+ throw new ReleaseError(
92
+ PUBLIC_PATH_FORBIDDEN,
93
+ `release unit id must be a safe single path segment: "${String(unitId)}"`,
94
+ { unitId },
95
+ );
96
+ }
97
+
98
+ const base = resolve(baseDir);
99
+ const candidate = resolve(base, `${unitId}${suffix}`);
100
+ const rel = relative(base, candidate);
101
+ const separator = process.platform === 'win32' ? '\\' : '/';
102
+ if (rel === '' || isAbsolute(rel) || rel === '..' || rel.startsWith(`..${separator}`)) {
103
+ throw new ReleaseError(
104
+ PUBLIC_PATH_FORBIDDEN,
105
+ `release unit path escapes its trusted base: "${unitId}"`,
106
+ { unitId, baseDir: base },
107
+ );
108
+ }
109
+ return candidate;
110
+ }
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Snapshot leakage scanner.
3
+ *
4
+ * Scans text files in a snapshot directory for:
5
+ * 1. Configured `forbiddenPaths` entries.
6
+ * 2. `/Users/` absolute paths (and Windows drive letters).
7
+ * 3. Common token prefixes (`ghp_`, `github_pat_`, `npm_`, `AKIA`, etc.).
8
+ * 4. PEM private key headers.
9
+ * 5. Stale build artifacts: files in `dist/` whose hash no longer matches the
10
+ * manifest recorded in `dist/manifest.json`.
11
+ *
12
+ * **Critical security contract:** Finding messages and details MUST NOT contain
13
+ * the raw matched secret value. Only the kind, location, and a generic
14
+ * description are emitted.
15
+ *
16
+ * @module snapshot/scan
17
+ */
18
+
19
+ import { readFile, readdir, stat } from 'node:fs/promises';
20
+ import { join, relative, extname, posix, win32 } from 'node:path';
21
+ import { createHash } from 'node:crypto';
22
+
23
+ import { SECRET_DETECTED, STALE_BUILD_ARTIFACT, FORBIDDEN_CONTENT_DETECTED, CONFIG_INVALID } from '../core/errors.mjs';
24
+ import { ReleaseError } from '../core/errors.mjs';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Constants
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** Extensions of known binary files -- never read or scanned. */
31
+ const BINARY_EXTENSIONS = new Set([
32
+ '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.svg',
33
+ '.wasm', '.bin', '.exe', '.dll', '.so', '.dylib', '.o', '.obj',
34
+ '.zip', '.tar', '.gz', '.bz2', '.xz', '.7z', '.rar',
35
+ '.woff', '.woff2', '.ttf', '.eot', '.otf',
36
+ '.mp3', '.mp4', '.wav', '.avi', '.mov', '.mkv', '.webm',
37
+ '.pdf', '.psd', '.ai', '.sketch',
38
+ '.db', '.sqlite', '.sqlite3',
39
+ '.node',
40
+ ]);
41
+
42
+ /** Number of initial bytes to inspect for null-byte binary detection. */
43
+ const BINARY_PROBE_SIZE = 8192;
44
+
45
+ /**
46
+ * Regex: platform absolute paths.
47
+ * Matches concrete user, home, and temporary paths while leaving documentation placeholders
48
+ * such as `/Users/...` alone.
49
+ */
50
+ const ABSOLUTE_PATH_PATTERNS = [
51
+ /\/(?:Users|home)\/[A-Za-z0-9_-][A-Za-z0-9._-]*(?:\/[^\s"'`<>]*)?/,
52
+ /\/(?:root|tmp)\/[A-Za-z0-9_-][A-Za-z0-9._-]*(?:\/[^\s"'`<>]*)?/,
53
+ /(?:^|[^A-Za-z0-9_])[A-Za-z]:\\[A-Za-z0-9_$-][A-Za-z0-9._$-]*(?:\\[^\s"'`<>]*)?/,
54
+ ];
55
+
56
+ /**
57
+ * Regex list: common secret token prefixes.
58
+ * Each pattern matches a prefix followed by at least one non-whitespace
59
+ * character. Word boundaries prevent false positives from substrings.
60
+ */
61
+ const TOKEN_PATTERNS = [
62
+ { name: 'ghp_', re: /\bghp_[A-Za-z0-9_]+/g },
63
+ { name: 'github_pat_', re: /\bgithub_pat_[A-Za-z0-9_]+/g },
64
+ // Granular npm config environment names (for example npm_config_registry)
65
+ // are public identifiers, not credentials. Modern granular access tokens
66
+ // have a long alphanumeric payload, so require enough payload characters to
67
+ // avoid classifying those identifiers as secrets.
68
+ { name: 'npm_', re: /\bnpm_[A-Za-z0-9]{20,}\b/g },
69
+ { name: 'AKIA', re: /\bAKIA[A-Z0-9]{16,}/g },
70
+ { name: 'sk-', re: /\bsk-[A-Za-z0-9_-]{10,}/g },
71
+ { name: 'xoxb-', re: /\bxoxb-[A-Za-z0-9-]+/g },
72
+ { name: 'xoxp-', re: /\bxoxp-[A-Za-z0-9-]+/g },
73
+ { name: 'glpat-', re: /\bglpat-[A-Za-z0-9_-]+/g },
74
+ ];
75
+
76
+ /**
77
+ * Regex: PEM-encoded private key header.
78
+ */
79
+ const PRIVATE_KEY_RE = /-----BEGIN (?:RSA |DSA |EC |OPENSSH |ENCRYPTED )?PRIVATE KEY-----/;
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Helpers
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * Recursively collect all file paths under a directory.
87
+ *
88
+ * @param {string} dir - Absolute directory path.
89
+ * @param {string} [base] - Base path for computing relative paths.
90
+ * @returns {Promise<string[]>} Relative file paths (POSIX separators).
91
+ */
92
+ async function collectFiles(dir, base = dir) {
93
+ const results = [];
94
+ let entries;
95
+ try {
96
+ entries = await readdir(dir, { withFileTypes: true });
97
+ } catch {
98
+ return results;
99
+ }
100
+
101
+ for (const entry of entries) {
102
+ // Skip .git directories
103
+ if (entry.name === '.git') continue;
104
+
105
+ const abs = join(dir, entry.name);
106
+ if (entry.isDirectory()) {
107
+ const sub = await collectFiles(abs, base);
108
+ results.push(...sub);
109
+ } else if (entry.isFile()) {
110
+ results.push(relative(base, abs));
111
+ }
112
+ }
113
+
114
+ return results;
115
+ }
116
+
117
+ /**
118
+ * Determine whether a file should be skipped because it is binary.
119
+ * Uses extension allowlist and null-byte probing.
120
+ *
121
+ * @param {string} relPath - Relative file path.
122
+ * @param {string} absPath - Absolute file path for content probing.
123
+ * @returns {Promise<boolean>} `true` if the file should be skipped.
124
+ */
125
+ async function isBinaryFile(relPath, absPath) {
126
+ const ext = extname(relPath).toLowerCase();
127
+ if (BINARY_EXTENSIONS.has(ext)) return true;
128
+
129
+ // Probe for null bytes in the first chunk
130
+ let handle;
131
+ try {
132
+ const { open } = await import('node:fs/promises');
133
+ handle = await open(absPath, 'r');
134
+ const buf = Buffer.alloc(BINARY_PROBE_SIZE);
135
+ const { bytesRead } = await handle.read(buf, 0, BINARY_PROBE_SIZE, 0);
136
+ for (let i = 0; i < bytesRead; i++) {
137
+ if (buf[i] === 0) return true;
138
+ }
139
+ } catch {
140
+ // If we cannot read the file, skip it (treat as binary)
141
+ return true;
142
+ } finally {
143
+ if (handle) await handle.close();
144
+ }
145
+
146
+ return false;
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Per-file scanning
151
+ // ---------------------------------------------------------------------------
152
+
153
+ /**
154
+ * Scan a single text file for all leakage patterns.
155
+ *
156
+ * @param {string} relPath - Relative path of the file.
157
+ * @param {string} absPath - Absolute path of the file.
158
+ * @param {string[]} forbiddenPaths - Policy forbidden paths.
159
+ * @param {string[]} forbiddenContentPatterns - Policy forbidden content patterns.
160
+ * @param {Set<string>} seenKinds - Dedup set of `kind:file` strings.
161
+ * @returns {Promise<Finding[]>}
162
+ */
163
+ async function scanFile(relPath, absPath, forbiddenPaths, forbiddenContentPatterns, seenKinds) {
164
+ /** @type {Finding[]} */
165
+ const findings = [];
166
+
167
+ const normRel = relPath.replaceAll(win32.sep, posix.sep);
168
+ const lowerRel = normRel.toLowerCase();
169
+ const separators = [posix.sep, win32.sep];
170
+
171
+ // 1. Configured forbidden paths
172
+ for (const fp of forbiddenPaths) {
173
+ const normFp = fp.replaceAll(win32.sep, posix.sep)
174
+ .replace(/\/+$/, '')
175
+ .toLowerCase();
176
+ if (!normFp) continue;
177
+
178
+ for (const sep of separators) {
179
+ const prefix = normFp.endsWith(sep.toLowerCase())
180
+ ? normFp
181
+ : normFp + sep.toLowerCase();
182
+ const checkPath = lowerRel + sep.toLowerCase();
183
+
184
+ if (lowerRel === normFp || checkPath.startsWith(prefix)) {
185
+ const dedupKey = `FORBIDDEN_PATH:${normFp}:${normRel}`;
186
+ if (!seenKinds.has(dedupKey)) {
187
+ seenKinds.add(dedupKey);
188
+ findings.push({
189
+ kind: 'PUBLIC_PATH_FORBIDDEN',
190
+ file: normRel,
191
+ message: `File is under a forbidden path "${fp}"`,
192
+ });
193
+ }
194
+ break;
195
+ }
196
+ }
197
+ }
198
+
199
+ // Read file content
200
+ let content;
201
+ try {
202
+ content = await readFile(absPath, 'utf8');
203
+ } catch {
204
+ return findings;
205
+ }
206
+
207
+ const lines = content.split('\n');
208
+
209
+ for (let i = 0; i < lines.length; i++) {
210
+ const line = lines[i];
211
+ const lineNum = i + 1;
212
+
213
+ // 2. Absolute paths
214
+ if (ABSOLUTE_PATH_PATTERNS.some((pattern) => pattern.test(line))) {
215
+ const key = `ABSOLUTE_PATH:${normRel}`;
216
+ if (!seenKinds.has(key)) {
217
+ seenKinds.add(key);
218
+ findings.push({
219
+ kind: 'PUBLIC_PATH_FORBIDDEN',
220
+ file: normRel,
221
+ line: lineNum,
222
+ message: `Absolute path detected`,
223
+ });
224
+ }
225
+ }
226
+
227
+ // 3. Token prefixes
228
+ for (const { name } of TOKEN_PATTERNS) {
229
+ // Build a fresh regex for each check (stateful `g` flag)
230
+ const tokenRe = TOKEN_PATTERNS.find(t => t.name === name).re;
231
+ // Reset lastIndex
232
+ tokenRe.lastIndex = 0;
233
+ if (tokenRe.test(line)) {
234
+ const key = `TOKEN:${name}:${normRel}`;
235
+ if (!seenKinds.has(key)) {
236
+ seenKinds.add(key);
237
+ findings.push({
238
+ kind: SECRET_DETECTED,
239
+ file: normRel,
240
+ line: lineNum,
241
+ message: `Token pattern "${name}" detected`,
242
+ });
243
+ }
244
+ }
245
+ }
246
+
247
+ // 4. Private key
248
+ if (PRIVATE_KEY_RE.test(line)) {
249
+ const key = `PRIVATE_KEY:${normRel}`;
250
+ if (!seenKinds.has(key)) {
251
+ seenKinds.add(key);
252
+ findings.push({
253
+ kind: SECRET_DETECTED,
254
+ file: normRel,
255
+ line: lineNum,
256
+ message: 'Private key header detected',
257
+ });
258
+ }
259
+ }
260
+
261
+ // 5. Forbidden content patterns
262
+ // Patterns are validated at scan entry point; all are valid regex.
263
+ for (const pattern of forbiddenContentPatterns) {
264
+ if (!pattern) continue;
265
+ const re = new RegExp(pattern);
266
+ if (re.test(line)) {
267
+ const key = `FORBIDDEN_CONTENT:${pattern}:${normRel}`;
268
+ if (!seenKinds.has(key)) {
269
+ seenKinds.add(key);
270
+ findings.push({
271
+ kind: FORBIDDEN_CONTENT_DETECTED,
272
+ file: normRel,
273
+ line: lineNum,
274
+ message: `Forbidden content pattern detected`,
275
+ });
276
+ }
277
+ }
278
+ }
279
+ }
280
+
281
+ return findings;
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Stale dist detection
286
+ // ---------------------------------------------------------------------------
287
+
288
+ /**
289
+ * Check dist/ files against `dist/manifest.json` for hash mismatches.
290
+ *
291
+ * @param {string} snapshotDir - Absolute snapshot directory.
292
+ * @returns {Promise<Finding[]>}
293
+ */
294
+ async function scanForStaleDist(snapshotDir) {
295
+ /** @type {Finding[]} */
296
+ const findings = [];
297
+ const manifestPath = join(snapshotDir, 'dist', 'manifest.json');
298
+
299
+ let manifest;
300
+ try {
301
+ const raw = await readFile(manifestPath, 'utf8');
302
+ manifest = JSON.parse(raw);
303
+ } catch {
304
+ // No manifest -- cannot perform stale detection
305
+ return findings;
306
+ }
307
+
308
+ if (!manifest.files || typeof manifest.files !== 'object') {
309
+ return findings;
310
+ }
311
+
312
+ const distDir = join(snapshotDir, 'dist');
313
+
314
+ for (const [fileRel, expectedHash] of Object.entries(manifest.files)) {
315
+ if (typeof expectedHash !== 'string') continue;
316
+
317
+ const fileAbs = join(distDir, fileRel);
318
+ let actualContent;
319
+ try {
320
+ actualContent = await readFile(fileAbs);
321
+ } catch {
322
+ // File referenced in manifest but missing from dist
323
+ findings.push({
324
+ kind: STALE_BUILD_ARTIFACT,
325
+ file: `dist/${fileRel}`,
326
+ message: 'Build artifact referenced in manifest is missing',
327
+ });
328
+ continue;
329
+ }
330
+
331
+ const actualHash = createHash('sha256').update(actualContent).digest('hex');
332
+ if (actualHash !== expectedHash) {
333
+ findings.push({
334
+ kind: STALE_BUILD_ARTIFACT,
335
+ file: `dist/${fileRel}`,
336
+ message: 'Build artifact hash does not match manifest',
337
+ });
338
+ }
339
+ }
340
+
341
+ return findings;
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // Public API
346
+ // ---------------------------------------------------------------------------
347
+
348
+ /**
349
+ * @typedef {Object} Finding
350
+ * @property {string} kind - Error code (e.g. SECRET_DETECTED, STALE_BUILD_ARTIFACT, PUBLIC_PATH_FORBIDDEN).
351
+ * @property {string} file - Relative path of the affected file.
352
+ * @property {number} [line] - 1-based line number where the issue was found.
353
+ * @property {string} message - Human-readable description (MUST NOT contain raw secret values).
354
+ */
355
+
356
+ /**
357
+ * @typedef {Object} ScanPolicy
358
+ * @property {string[]} [forbiddenPaths] - Paths that must not appear in the snapshot.
359
+ */
360
+
361
+ /**
362
+ * Scan a snapshot directory for leakage patterns and stale build artifacts.
363
+ *
364
+ * @param {Object} options
365
+ * @param {string} options.snapshotDir - Absolute path to the snapshot directory.
366
+ * @param {ScanPolicy} [options.policy] - Scan policy configuration.
367
+ * @returns {Promise<Finding[]>} Array of findings. Empty if the snapshot is clean.
368
+ */
369
+ export async function scanSnapshot({ snapshotDir, policy = {} } = {}) {
370
+ if (!snapshotDir || typeof snapshotDir !== 'string') {
371
+ throw new TypeError('snapshotDir must be a non-empty string');
372
+ }
373
+
374
+ const { forbiddenPaths = [], forbiddenContentPatterns = [] } = policy;
375
+
376
+ // Validate all forbiddenContentPatterns upfront — fail closed on invalid regex.
377
+ // Patterns should also be validated at config load time, but scanSnapshot
378
+ // must not silently skip invalid patterns when called directly.
379
+ for (const pattern of forbiddenContentPatterns) {
380
+ if (!pattern) continue;
381
+ try {
382
+ new RegExp(pattern);
383
+ } catch (err) {
384
+ throw new ReleaseError(
385
+ CONFIG_INVALID,
386
+ `invalid regex in forbiddenContentPatterns: "${pattern}": ${err.message}`,
387
+ { pattern, cause: err.message },
388
+ );
389
+ }
390
+ }
391
+
392
+ /** @type {Finding[]} */
393
+ const allFindings = [];
394
+
395
+ // Collect all files in the snapshot
396
+ const relFiles = await collectFiles(snapshotDir);
397
+
398
+ // Dedup sets to avoid reporting the same kind+file combination twice
399
+ /** @type {Set<string>} */
400
+ const seenKinds = new Set();
401
+ for (const relPath of relFiles) {
402
+ const absPath = join(snapshotDir, relPath);
403
+ const normRel = relPath.replaceAll(win32.sep, posix.sep);
404
+
405
+ // Skip binary files
406
+ if (await isBinaryFile(relPath, absPath)) continue;
407
+
408
+ // Per-file leakage scan
409
+ const fileFindings = await scanFile(normRel, absPath, forbiddenPaths, forbiddenContentPatterns, seenKinds);
410
+ allFindings.push(...fileFindings);
411
+
412
+ }
413
+
414
+ // Stale dist artifact detection
415
+ const staleFindings = await scanForStaleDist(snapshotDir);
416
+ allFindings.push(...staleFindings);
417
+
418
+ return allFindings;
419
+ }