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.
- package/.agents/plugins/marketplace.json +23 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +26 -0
- package/CHANGELOG.md +68 -0
- package/CODE_OF_CONDUCT.md +76 -0
- package/CONTRIBUTING.md +49 -0
- package/INSTALL.md +182 -0
- package/LICENSE +21 -0
- package/NOTICE +25 -0
- package/README.md +501 -0
- package/README.zh-CN.md +463 -0
- package/SECURITY.md +48 -0
- package/adapters/claude/.claude-plugin/marketplace.json +16 -0
- package/adapters/claude/.claude-plugin/plugin.json +10 -0
- package/adapters/claude/skills/release-assess/SKILL.md +52 -0
- package/adapters/claude/skills/release-help/SKILL.md +60 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
- package/adapters/claude/skills/release-publish/SKILL.md +55 -0
- package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/claude/skills/release-verify/SKILL.md +70 -0
- package/adapters/codex/.codex-plugin/plugin.json +26 -0
- package/adapters/codex/skills/release-assess/SKILL.md +52 -0
- package/adapters/codex/skills/release-help/SKILL.md +60 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
- package/adapters/codex/skills/release-publish/SKILL.md +55 -0
- package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/codex/skills/release-verify/SKILL.md +70 -0
- package/bin/release-skill.mjs +743 -0
- package/native/safe-write/binding.gyp +40 -0
- package/native/safe-write/prebuilds.json +4 -0
- package/native/safe-write/src/safe_write.cc +2023 -0
- package/package.json +75 -0
- package/references/.render-manifest.json +33 -0
- package/references/00-target-state.md +124 -0
- package/references/01-state-machine.md +155 -0
- package/references/02-project-config.md +217 -0
- package/references/03-readme-quality.md +136 -0
- package/references/04-supply-chain.md +147 -0
- package/references/05-evidence-and-errors.md +164 -0
- package/references/06-adapter-contract.md +178 -0
- package/schemas/.render-manifest.json +37 -0
- package/schemas/approval-record.schema.json +115 -0
- package/schemas/artifact-lock.schema.json +111 -0
- package/schemas/artifact-plan.schema.json +52 -0
- package/schemas/artifact-policy.schema.json +76 -0
- package/schemas/evidence-event.schema.json +89 -0
- package/schemas/release-plan.schema.json +369 -0
- package/schemas/release-project.schema.json +359 -0
- package/schemas/release-run.schema.json +195 -0
- package/skills/release-assess/SKILL.md +52 -0
- package/skills/release-help/SKILL.md +60 -0
- package/skills/release-prepare/SKILL.md +71 -0
- package/skills/release-publish/SKILL.md +55 -0
- package/skills/release-reconcile/SKILL.md +73 -0
- package/skills/release-verify/SKILL.md +70 -0
- package/skills-src/release-assess/SKILL.md +52 -0
- package/skills-src/release-help/SKILL.md +60 -0
- package/skills-src/release-prepare/SKILL.md +71 -0
- package/skills-src/release-publish/SKILL.md +55 -0
- package/skills-src/release-reconcile/SKILL.md +73 -0
- package/skills-src/release-verify/SKILL.md +70 -0
- package/src/adapters/contract.mjs +214 -0
- package/src/adapters/git-github.mjs +214 -0
- package/src/adapters/npm.mjs +947 -0
- package/src/adapters/plugin-marketplace.mjs +1365 -0
- package/src/adapters/push-snapshot.mjs +216 -0
- package/src/artifacts/adoption.mjs +743 -0
- package/src/artifacts/artifact-plan.mjs +162 -0
- package/src/artifacts/entry.mjs +240 -0
- package/src/artifacts/git-authority.mjs +637 -0
- package/src/artifacts/graph.mjs +189 -0
- package/src/artifacts/inspect.mjs +520 -0
- package/src/artifacts/inventory.mjs +192 -0
- package/src/artifacts/merge/binary.mjs +77 -0
- package/src/artifacts/merge/entry-merge.mjs +228 -0
- package/src/artifacts/merge/json.mjs +641 -0
- package/src/artifacts/merge/markdown.mjs +246 -0
- package/src/artifacts/merge/regions.mjs +156 -0
- package/src/artifacts/merge/text.mjs +432 -0
- package/src/artifacts/merge/tree.mjs +202 -0
- package/src/artifacts/merge/yaml.mjs +669 -0
- package/src/artifacts/path-key.mjs +94 -0
- package/src/artifacts/policy.mjs +319 -0
- package/src/artifacts/producer-registry.mjs +439 -0
- package/src/artifacts/project-lock.mjs +732 -0
- package/src/artifacts/resolution.mjs +658 -0
- package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
- package/src/artifacts/safe-fs.mjs +72 -0
- package/src/artifacts/state.mjs +495 -0
- package/src/artifacts/transaction-journal.mjs +983 -0
- package/src/artifacts/transaction.mjs +1361 -0
- package/src/commands/approve.mjs +280 -0
- package/src/commands/artifacts.mjs +627 -0
- package/src/commands/assess.mjs +838 -0
- package/src/commands/prepare.mjs +1377 -0
- package/src/commands/publish.mjs +883 -0
- package/src/commands/reconcile.mjs +1255 -0
- package/src/commands/verify.mjs +915 -0
- package/src/core/approval.mjs +332 -0
- package/src/core/baseline.mjs +272 -0
- package/src/core/blackbox-hard-gates.mjs +142 -0
- package/src/core/config.mjs +448 -0
- package/src/core/digest.mjs +90 -0
- package/src/core/errors.mjs +113 -0
- package/src/core/evidence.mjs +167 -0
- package/src/core/hooks.mjs +241 -0
- package/src/core/node-version.mjs +64 -0
- package/src/core/plan.mjs +735 -0
- package/src/core/previous-public-baseline.mjs +204 -0
- package/src/core/run.mjs +681 -0
- package/src/core/state-machine.mjs +76 -0
- package/src/core/version-consistency.mjs +111 -0
- package/src/producers/build-adapters.mjs +231 -0
- package/src/producers/render-public-assets.mjs +152 -0
- package/src/producers/sync-skills.mjs +96 -0
- package/src/readme/contract.mjs +297 -0
- package/src/readme/examples.mjs +288 -0
- package/src/readme/parity.mjs +122 -0
- package/src/snapshot/export.mjs +99 -0
- package/src/snapshot/frozen.mjs +401 -0
- package/src/snapshot/manifest.mjs +207 -0
- package/src/snapshot/public-map.mjs +1459 -0
- package/src/snapshot/public-path.mjs +110 -0
- 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
|
+
}
|