@natjswenson/shipflow 0.2.2 → 0.2.4
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/SKILL.md +1 -0
- package/bin/shipflow.js +17 -3
- package/lib/detect.mjs +18 -9
- package/lib/gh.mjs +17 -0
- package/lib/render.mjs +40 -1
- package/package.json +1 -1
- package/skill-invariants.json +5 -0
package/SKILL.md
CHANGED
|
@@ -115,6 +115,7 @@ This is a **separate, later invocation** from the one that ran the promotion's `
|
|
|
115
115
|
- All `gh`/`git` invocations in the CLI are argv-style (`spawnSync` with an args array, no shell) — never construct a shell command string from user input when extending this skill.
|
|
116
116
|
- `.github/shipflow.json` is committed policy, not secrets — never write credential *values* into it, only the *name* of a secret (`release.releaseCredential`).
|
|
117
117
|
- Never write shipflow's config anywhere other than `.github/shipflow.json` in the target repo.
|
|
118
|
+
- **`renderTemplate` validates every substituted value before writing YAML, and this must never be weakened.** `config.branches.dev`/`main` and `release.releaseCredential` are editable by anyone with repo *write* access (not just the admin who ran setup), yet they land in single-quoted YAML string comparisons and a `${{ secrets.X }}` expression with pure string substitution. An unvalidated branch name containing a quote (e.g. `dev' || 'x'=='x`) makes the auto-merge job's `if:` condition unconditionally true — auto-merge would enable on *any* PR to main, not just genuine dev-branch promotions; a value containing a newline can inject arbitrary new YAML steps into the committed, then-executed workflow. If you add a new substitution token, it needs a validator in `TOKEN_VALIDATORS` before it ships — never assume a config field is pre-sanitized.
|
|
118
119
|
|
|
119
120
|
## Edge cases
|
|
120
121
|
|
package/bin/shipflow.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
dispatchReleaseWorkflow,
|
|
15
15
|
renameDefaultBranch,
|
|
16
16
|
} from '../lib/apply.mjs';
|
|
17
|
+
import { readFileCapped } from '../lib/gh.mjs';
|
|
17
18
|
|
|
18
19
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
20
|
const TEMPLATE_PATH = join(PACKAGE_ROOT, 'templates', 'dev-to-main-automerge.yml.tmpl');
|
|
@@ -24,7 +25,10 @@ function readPackageVersion() {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
function readConfig(path) {
|
|
27
|
-
|
|
28
|
+
// .github/shipflow.json lives in whatever repo --repo points at — a
|
|
29
|
+
// repo-write-editable file, not admin-only — so cap its size before
|
|
30
|
+
// parsing (see lib/gh.mjs's readFileCapped for why).
|
|
31
|
+
return JSON.parse(readFileCapped(path));
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
function defaultConfigPath(repoPath) {
|
|
@@ -85,7 +89,12 @@ function cmdPlan(args) {
|
|
|
85
89
|
releaseCredentialName: config.release?.releaseCredential ?? null,
|
|
86
90
|
});
|
|
87
91
|
const templateSource = readFileSync(TEMPLATE_PATH, 'utf8');
|
|
88
|
-
|
|
92
|
+
let plan;
|
|
93
|
+
try {
|
|
94
|
+
plan = computePlan(repoState, config, templateSource);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return fail(`plan: ${e.message}`);
|
|
97
|
+
}
|
|
89
98
|
printJson({ plan, stateHash: repoState.stateHash });
|
|
90
99
|
}
|
|
91
100
|
|
|
@@ -122,7 +131,12 @@ function cmdApply(args) {
|
|
|
122
131
|
releaseCredentialName: config.release?.releaseCredential ?? null,
|
|
123
132
|
});
|
|
124
133
|
const templateSource = readFileSync(TEMPLATE_PATH, 'utf8');
|
|
125
|
-
|
|
134
|
+
let plan;
|
|
135
|
+
try {
|
|
136
|
+
plan = computePlan(repoState, config, templateSource);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
return fail(`apply: ${e.message}`);
|
|
139
|
+
}
|
|
126
140
|
|
|
127
141
|
// The CLI-level TOCTOU gate: compare the freshly-detected state against
|
|
128
142
|
// what the user confirmed at plan time (--expect-state-hash, captured
|
package/lib/detect.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { existsSync
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { spawnArgs, ghApiJson, git, sha256 } from './gh.mjs';
|
|
3
|
+
import { spawnArgs, ghApiJson, git, sha256, readFileCapped } from './gh.mjs';
|
|
4
4
|
|
|
5
5
|
const TEMPLATE_RELATIVE_PATH = '.github/workflows/dev-to-main-automerge.yml';
|
|
6
6
|
const CONFIG_RELATIVE_PATH = '.github/shipflow.json';
|
|
@@ -14,11 +14,20 @@ const SETTINGS_AS_CODE_NAME_RE = /repo-settings\.(sh|js|mjs|py)$/i;
|
|
|
14
14
|
const SETTINGS_AS_CODE_CONTENT_RE =
|
|
15
15
|
/github_branch_protection|github_repository_ruleset|branches\/[\w-]+\/protection/;
|
|
16
16
|
|
|
17
|
+
// A segment made of only dots ("." / ".." / "...") matches [\w.-]+ but is
|
|
18
|
+
// never a real GitHub owner or repo name — reject it rather than build an
|
|
19
|
+
// ownerRepo string that could normalize away the intended repos/<owner>/<repo>
|
|
20
|
+
// prefix once interpolated into gh api paths downstream.
|
|
21
|
+
const ALL_DOTS_RE = /^\.+$/;
|
|
22
|
+
|
|
17
23
|
export function resolveOwnerRepo(repoPath) {
|
|
18
24
|
const r = git(['remote', 'get-url', 'origin'], { cwd: repoPath });
|
|
19
25
|
if (r.status !== 0) return null;
|
|
20
26
|
const m = r.stdout.match(/github\.com[:/]([\w.-]+)\/([\w.-]+?)(\.git)?$/);
|
|
21
|
-
|
|
27
|
+
if (!m) return null;
|
|
28
|
+
const [, owner, repo] = m;
|
|
29
|
+
if (ALL_DOTS_RE.test(owner) || ALL_DOTS_RE.test(repo)) return null;
|
|
30
|
+
return `${owner}/${repo}`;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
export function listTrackedFiles(repoPath) {
|
|
@@ -42,7 +51,7 @@ export function findSettingsAsCodeArtifact(repoPath, trackedFiles) {
|
|
|
42
51
|
if (!existsSync(full)) continue;
|
|
43
52
|
let content;
|
|
44
53
|
try {
|
|
45
|
-
content =
|
|
54
|
+
content = readFileCapped(full);
|
|
46
55
|
} catch {
|
|
47
56
|
continue;
|
|
48
57
|
}
|
|
@@ -66,7 +75,7 @@ export function listWorkflowJobNames(repoPath, trackedFiles) {
|
|
|
66
75
|
if (!existsSync(full)) continue;
|
|
67
76
|
let content;
|
|
68
77
|
try {
|
|
69
|
-
content =
|
|
78
|
+
content = readFileCapped(full);
|
|
70
79
|
} catch {
|
|
71
80
|
continue;
|
|
72
81
|
}
|
|
@@ -87,7 +96,7 @@ export function listWorkflowJobNames(repoPath, trackedFiles) {
|
|
|
87
96
|
export function readTemplateFileHash(repoPath) {
|
|
88
97
|
const full = join(repoPath, TEMPLATE_RELATIVE_PATH);
|
|
89
98
|
if (!existsSync(full)) return { exists: false, sha256: null };
|
|
90
|
-
const content =
|
|
99
|
+
const content = readFileCapped(full);
|
|
91
100
|
return { exists: true, sha256: sha256(content) };
|
|
92
101
|
}
|
|
93
102
|
|
|
@@ -95,14 +104,14 @@ export function readExistingConfig(repoPath) {
|
|
|
95
104
|
const full = join(repoPath, CONFIG_RELATIVE_PATH);
|
|
96
105
|
if (!existsSync(full)) return null;
|
|
97
106
|
try {
|
|
98
|
-
return JSON.parse(
|
|
107
|
+
return JSON.parse(readFileCapped(full));
|
|
99
108
|
} catch {
|
|
100
109
|
return null;
|
|
101
110
|
}
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
export function fetchBranchProtection(ownerRepo, branch) {
|
|
105
|
-
const r = ghApiJson(`repos/${ownerRepo}/branches/${branch}/protection`);
|
|
114
|
+
const r = ghApiJson(`repos/${ownerRepo}/branches/${encodeURIComponent(branch)}/protection`);
|
|
106
115
|
if (!r.ok) return null;
|
|
107
116
|
const checks = r.data?.required_status_checks?.contexts ?? [];
|
|
108
117
|
return { requiredChecks: checks, raw: r.data };
|
|
@@ -133,7 +142,7 @@ export function fetchRulesetRequiredChecks(ownerRepo, rulesetId) {
|
|
|
133
142
|
}
|
|
134
143
|
|
|
135
144
|
export function checkSecretPresent(ownerRepo, secretName) {
|
|
136
|
-
const r = ghApiJson(`repos/${ownerRepo}/actions/secrets/${secretName}`);
|
|
145
|
+
const r = ghApiJson(`repos/${ownerRepo}/actions/secrets/${encodeURIComponent(secretName)}`);
|
|
137
146
|
return r.ok;
|
|
138
147
|
}
|
|
139
148
|
|
package/lib/gh.mjs
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { statSync, readFileSync } from 'node:fs';
|
|
2
3
|
import { createHash } from 'node:crypto';
|
|
3
4
|
|
|
5
|
+
// Every file shipflow reads under a target repo (.github/shipflow.json,
|
|
6
|
+
// candidate settings-as-code artifacts, workflow YAML, the rendered
|
|
7
|
+
// template on disk) is repo-write-controlled, not admin-only — a
|
|
8
|
+
// maliciously huge or pathologically nested file could exhaust memory on
|
|
9
|
+
// an unbounded readFileSync/JSON.parse. 1 MB is generous for any
|
|
10
|
+
// legitimate config/workflow/IaC file shipflow actually needs to read.
|
|
11
|
+
const MAX_READ_BYTES = 1_000_000;
|
|
12
|
+
|
|
13
|
+
export function readFileCapped(path, encoding = 'utf8') {
|
|
14
|
+
const size = statSync(path).size;
|
|
15
|
+
if (size > MAX_READ_BYTES) {
|
|
16
|
+
throw new Error(`refusing to read ${path}: ${size} bytes exceeds the ${MAX_READ_BYTES}-byte safety cap`);
|
|
17
|
+
}
|
|
18
|
+
return readFileSync(path, encoding);
|
|
19
|
+
}
|
|
20
|
+
|
|
4
21
|
// argv-style invocation; no shell, so user-supplied args cannot inject.
|
|
5
22
|
// Returns { status, stdout, stderr } so callers can distinguish failure modes
|
|
6
23
|
// (e.g. a 404 from gh vs. a network error) instead of collapsing to a boolean.
|
package/lib/render.mjs
CHANGED
|
@@ -14,6 +14,34 @@
|
|
|
14
14
|
|
|
15
15
|
const TOKEN_RE = /\{\{(\w+)\}\}/g;
|
|
16
16
|
|
|
17
|
+
// DEV_BRANCH/MAIN_BRANCH land inside single-quoted YAML string comparisons
|
|
18
|
+
// (`... == '{{DEV_BRANCH}}'`) and RELEASE_CREDENTIAL_SECRET lands inside a
|
|
19
|
+
// `${{ secrets.X }}` GitHub Actions expression — this function does pure
|
|
20
|
+
// string substitution with NO awareness of YAML or GHA-expression grammar,
|
|
21
|
+
// so any of these three params can break out of their quoting context if
|
|
22
|
+
// not validated first. Concretely: devBranch = "dev' || 'x'=='x" renders
|
|
23
|
+
// the auto-merge job's `if:` condition to `... == 'dev' || 'x'=='x'`,
|
|
24
|
+
// which is unconditionally true — enabling auto-merge on ANY pull request
|
|
25
|
+
// to main, not just genuine dev-branch promotions. A value containing a
|
|
26
|
+
// newline in any of the three can inject entirely new YAML keys/steps into
|
|
27
|
+
// the committed, then-executed workflow file. This is not a theoretical
|
|
28
|
+
// input: config.branches.{main,dev} and config.release.releaseCredential
|
|
29
|
+
// come from .github/shipflow.json, a file anyone with repo WRITE access
|
|
30
|
+
// (not just the admin who ran shipflow's setup) can edit — a strictly
|
|
31
|
+
// lower trust level than the admin-scoped `gh` credential the rendered
|
|
32
|
+
// workflow runs with. Found via a Siege security audit (2026-07-15).
|
|
33
|
+
const UNSAFE_YAML_STRING_RE = /['\r\n]/;
|
|
34
|
+
// GitHub Actions secret names: letters, digits, underscore; cannot start
|
|
35
|
+
// with a digit (case-insensitivity aside, this is the full safe charset).
|
|
36
|
+
const SAFE_SECRET_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
37
|
+
|
|
38
|
+
const TOKEN_VALIDATORS = Object.freeze({
|
|
39
|
+
DEV_BRANCH: (v) => !UNSAFE_YAML_STRING_RE.test(v),
|
|
40
|
+
MAIN_BRANCH: (v) => !UNSAFE_YAML_STRING_RE.test(v),
|
|
41
|
+
MERGE_FLAG: () => true, // closed enum from mergeMethodToFlag — never attacker-shaped
|
|
42
|
+
RELEASE_CREDENTIAL_SECRET: (v) => SAFE_SECRET_NAME_RE.test(v),
|
|
43
|
+
});
|
|
44
|
+
|
|
17
45
|
// params: { devBranch, mainBranch, mergeFlag, releaseCredentialSecret }
|
|
18
46
|
// mergeFlag is one of "--merge" | "--squash" | "--rebase", derived from
|
|
19
47
|
// config.mergeMethod.devToMainMethod by the caller (not this function —
|
|
@@ -22,17 +50,28 @@ const TOKEN_RE = /\{\{(\w+)\}\}/g;
|
|
|
22
50
|
// config shape, only of the template's token names).
|
|
23
51
|
export function renderTemplate(templateSource, params) {
|
|
24
52
|
const missing = [];
|
|
53
|
+
const unsafe = [];
|
|
25
54
|
const rendered = templateSource.replace(TOKEN_RE, (_, name) => {
|
|
26
55
|
const key = TOKEN_TO_PARAM[name];
|
|
27
56
|
if (!key || !(key in params)) {
|
|
28
57
|
missing.push(name);
|
|
29
58
|
return `{{${name}}}`;
|
|
30
59
|
}
|
|
31
|
-
|
|
60
|
+
const value = String(params[key]);
|
|
61
|
+
const validate = TOKEN_VALIDATORS[name];
|
|
62
|
+
if (validate && !validate(value)) {
|
|
63
|
+
unsafe.push(name);
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
32
66
|
});
|
|
33
67
|
if (missing.length > 0) {
|
|
34
68
|
throw new Error(`renderTemplate: missing param(s) for token(s): ${missing.join(', ')}`);
|
|
35
69
|
}
|
|
70
|
+
if (unsafe.length > 0) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`renderTemplate: unsafe value for token(s): ${unsafe.join(', ')} — branch names must not contain a quote or newline, and the release-credential secret name must match GitHub's secret-naming rules (letters/digits/underscore, not starting with a digit)`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
36
75
|
return rendered;
|
|
37
76
|
}
|
|
38
77
|
|
package/package.json
CHANGED
package/skill-invariants.json
CHANGED
|
@@ -60,6 +60,11 @@
|
|
|
60
60
|
"id": "release-credential-never-github-token",
|
|
61
61
|
"pattern": "never default it to `GITHUB_TOKEN`",
|
|
62
62
|
"rationale": "Found by dogfooding on claude-skills itself (2026-07-15): a PR auto-merged under secrets.GITHUB_TOKEN completes attributed to github-actions[bot], and GitHub's loop-prevention rule means that bot-attributed merge's pull_request:closed event never triggers label-release-pending — the entire manual-gate release-ask flow silently never has anything to find unless releaseCredential names a real PAT."
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"id": "render-template-validates-substitutions",
|
|
66
|
+
"pattern": "never assume a config field is pre-sanitized",
|
|
67
|
+
"rationale": "Found by a Siege security audit (2026-07-15): renderTemplate did pure string substitution with zero escaping, so a repo-write-level (not admin-level) edit to .github/shipflow.json's branch names or release.releaseCredential could break out of the rendered YAML's quoting and make the auto-merge job's if: condition unconditionally true, or inject arbitrary new workflow steps — a privilege escalation via the credential the rendered workflow runs with."
|
|
63
68
|
}
|
|
64
69
|
],
|
|
65
70
|
"cli_commands_referenced": ["detect", "plan", "apply", "releases", "release-dispatch", "rename-default-branch"]
|