@catalyst-cloud/cli 0.9.4 → 0.13.0
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/CHANGELOG.md +34 -0
- package/README.md +38 -32
- package/dist/args.js +16 -2
- package/dist/ask.js +2 -2
- package/dist/capabilities.js +70 -0
- package/dist/cli.js +42 -29
- package/dist/config.js +1 -1
- package/dist/connections.js +5 -5
- package/dist/env/declaration-rules.js +163 -0
- package/dist/env/group.js +51 -0
- package/dist/env/inventory.js +102 -0
- package/dist/env/render.js +39 -0
- package/dist/env/scan-env-example.js +62 -0
- package/dist/env/scan-source.js +43 -0
- package/dist/env/scan-workflow.js +123 -0
- package/dist/env/scan-wrangler.js +120 -0
- package/dist/env/types.js +1 -0
- package/dist/env/walk.js +87 -0
- package/dist/env.js +76 -0
- package/dist/environment.js +4 -4
- package/dist/events.js +1 -1
- package/dist/execution.js +3 -2
- package/dist/identity.js +3 -3
- package/dist/oauth.js +3 -3
- package/dist/query.js +27 -1
- package/dist/ready.js +3 -3
- package/dist/release.js +1 -1
- package/dist/replica.js +9 -7
- package/dist/runtime-store.js +2 -2
- package/dist/runtime.js +1 -1
- package/dist/secret.js +1 -1
- package/dist/skills.js +1 -1
- package/dist/team.js +329 -0
- package/package.json +4 -4
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +7 -7
- package/skills/catalyst-onboard/references/choosing-a-coding-account.md +2 -2
- package/skills/catalyst-onboard/references/declaring-a-repository.md +1 -1
- package/skills/catalyst-onboard/references/local-sync.md +3 -3
- package/skills/catalyst-onboard/references/skill-sources.md +1 -1
- package/skills/catalyst-onboard/references/the-one-path.md +12 -10
- package/skills/catalyst-onboard/references/what-a-phase-needs.md +5 -5
- package/skills/catalyst-onboard/references/what-the-browser-owns.md +6 -9
- package/skills/catalyst-onboard/references/who-fixes-what.md +35 -16
- package/skills/catalyst-onboard/scripts/local-sync.mjs +6 -6
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +133 -54
- package/skills/catalyst-setup/SKILL.md +1 -1
- package/skills/connect-me/SKILL.md +3 -3
- package/skills/how-catalyst-works/SKILL.md +5 -3
- package/skills/how-catalyst-works/references/settings-and-where-they-live.md +20 -0
- package/skills/how-catalyst-works/references/what-runs-next.md +19 -1
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-needs-me/references/reading-the-inbox.md +4 -0
- package/skills/what-this-repo-needs/SKILL.md +40 -0
- package/skills/what-this-repo-needs/agents/openai.yaml +6 -0
- package/skills/what-this-repo-needs/agents/portability.yaml +4 -0
- package/skills/what-this-repo-needs/references/what-each-group-means.md +68 -0
- package/skills/what-this-repo-needs/scripts/check.mjs +64 -0
- package/skills/what-this-repo-needs/scripts/inventory.mjs +61 -0
- package/skills/what-this-repo-needs/scripts/lib/cli.mjs +114 -0
- package/skills/what-this-repo-needs/scripts/lib/credential.mjs +29 -0
- package/skills/whats-happening/SKILL.md +1 -1
- package/skills/whats-happening/references/reading-the-board.md +1 -1
- package/skills/whats-happening/references/reprioritising.md +1 -1
- package/skills/whats-happening/references/status-reply.md +2 -0
- package/skills/whats-happening/references/why-is-it-stuck.md +41 -33
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// declaration-rules.ts — a vendored, dated copy of catalyst-cloud's rules for a repo-scope
|
|
2
|
+
// `catalyst.env.json`, applied by `validateRepoEnvironmentDeclaration`
|
|
3
|
+
// (packages/environment/src/contract.ts:416-493 on catalyst-cloud `main`; the ticket cites line 488,
|
|
4
|
+
// which falls inside that range). That file lives in the PRIVATE `coalesce-labs/catalyst-cloud`
|
|
5
|
+
// monorepo — no sibling checkout, no npm publication (its one customer, `apps/cli`, is itself
|
|
6
|
+
// "private": true) and no network reachability from this session. Everything below is vendored
|
|
7
|
+
// secondhand from two independent, dated thoughts-pool research passes (2026-09-10 and 2026-09-17)
|
|
8
|
+
// that agreed with each other, following the same idiom this repository's own
|
|
9
|
+
// `test/skills-content.test.ts` already uses to vendor catalyst-cloud's `READINESS_CHECK_IDS` (see
|
|
10
|
+
// that file's `TEAM_CHECK_IDS` and its provenance comment): dated, cited, paired with a test, never
|
|
11
|
+
// hand-copied and trusted on its own.
|
|
12
|
+
//
|
|
13
|
+
// What a parity test built against THIS copy can, and cannot, catch: it can catch this copy DRIFTING
|
|
14
|
+
// FROM ITSELF — a rule dropped, loosened or tightened by a future edit here. It CANNOT notice a new
|
|
15
|
+
// rule landing upstream, because nothing in this repository imports the real function — there is no
|
|
16
|
+
// publish channel for it. The one fixture pinned by a real, run command transcript rather than by
|
|
17
|
+
// prose (`valid-empty-provenance.json`, in `test/fixtures/env-declaration/`) is the strongest anchor
|
|
18
|
+
// this vendored copy has; the rest are pinned by the two agreeing research passes.
|
|
19
|
+
const MAX_ENV_REFS = 128;
|
|
20
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
21
|
+
// ⛔ A PREFIX ONLY COUNTS AT A TOKEN BOUNDARY (validate attempt 29, CR-6). These were matched with
|
|
22
|
+
// `text.includes(p)`, and "sk-" is a SUBSTRING of "task-", "risk-" and "disk-" — so a declaration
|
|
23
|
+
// whose setup step was `["npm","run","task-build"]` was refused as containing secret material. Every
|
|
24
|
+
// prefix here is identifier-shaped, so a match is real only at the start of the text or after a
|
|
25
|
+
// character that cannot be part of a token; PEM armour, which is not identifier-shaped and carries
|
|
26
|
+
// its own unmistakable delimiter, keeps a plain substring match of its own.
|
|
27
|
+
// `gho_`, `sk_live_` and `AIza` are in the 2026-09-10 pass's list of upstream's prefixes
|
|
28
|
+
// (thoughts: repos/catalyst/shared/research/2026-09-10-CTC-2020-laptop-environment-inventory.md:65),
|
|
29
|
+
// and `ghs_` is pinned as an upstream entry by the 2026-09-27 CTC-3378 plan v2 (:238). Validate
|
|
30
|
+
// attempt 31 found the first three missing here (and noted `ghs_`), so `env check` said `valid`
|
|
31
|
+
// where upstream's validator refuses.
|
|
32
|
+
// That upstream list ends "and others": this copy still cannot claim to hold every entry.
|
|
33
|
+
const KNOWN_SECRET_PREFIXES = ["ghp_", "gho_", "ghs_", "github_pat_", "sk-", "sk_live_", "AKIA", "AIza", "xoxb-", "xoxp-"];
|
|
34
|
+
const PEM_ARMOUR = "-----BEGIN";
|
|
35
|
+
const KNOWN_SECRET_PREFIX_RE = new RegExp(`(?:^|[^A-Za-z0-9_])(?:${KNOWN_SECRET_PREFIXES.map(escapeForRegExp).join("|")})`);
|
|
36
|
+
function escapeForRegExp(literal) {
|
|
37
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
38
|
+
}
|
|
39
|
+
const SECRET_ASSIGNMENT_RE = /\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(\S{4,})/g;
|
|
40
|
+
const SECRET_NAME_RE = /(_TOKEN|_SECRET|_KEY|_PASSWORD|_CREDENTIAL)$|^(TOKEN|SECRET|PASSWORD)$/i;
|
|
41
|
+
const REQUIRED_ARRAY_FIELDS = ["toolchains", "systemPackages", "setup", "verify", "services", "environment", "agentAssets", "provenance"];
|
|
42
|
+
const TOP_LEVEL_FIELDS = new Set(["version", ...REQUIRED_ARRAY_FIELDS]);
|
|
43
|
+
/** Empty array = valid. Never reads, and never returns, a value — only field names and paths. */
|
|
44
|
+
export function validateDeclaration(doc) {
|
|
45
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
|
|
46
|
+
return ["the declaration must be a JSON object"];
|
|
47
|
+
}
|
|
48
|
+
const rec = doc;
|
|
49
|
+
const errors = [];
|
|
50
|
+
for (const key of Object.keys(rec))
|
|
51
|
+
if (!TOP_LEVEL_FIELDS.has(key))
|
|
52
|
+
errors.push(`unknown key "${key}"`);
|
|
53
|
+
if (rec.version !== 1)
|
|
54
|
+
errors.push("version must be 1");
|
|
55
|
+
for (const field of REQUIRED_ARRAY_FIELDS)
|
|
56
|
+
if (!Array.isArray(rec[field]))
|
|
57
|
+
errors.push(`${field} must be an array`);
|
|
58
|
+
// The rules below assume the shape above holds; a missing/wrong-typed field is reported on its own.
|
|
59
|
+
if (errors.length > 0)
|
|
60
|
+
return errors;
|
|
61
|
+
const environment = rec.environment;
|
|
62
|
+
if (environment.length > MAX_ENV_REFS)
|
|
63
|
+
errors.push(`environment has ${environment.length} entries; at most ${MAX_ENV_REFS}`);
|
|
64
|
+
for (const [i, e] of environment.entries()) {
|
|
65
|
+
if (typeof e !== "object" || e === null) {
|
|
66
|
+
errors.push(`environment[${i}] must be an object`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const rec2 = e;
|
|
70
|
+
if (typeof rec2.name !== "string" || !ENV_NAME_RE.test(rec2.name))
|
|
71
|
+
errors.push(`environment[${i}].name is not a valid name`);
|
|
72
|
+
if (typeof rec2.required !== "boolean")
|
|
73
|
+
errors.push(`environment[${i}].required must be a boolean`);
|
|
74
|
+
if (!Array.isArray(rec2.provenanceIds))
|
|
75
|
+
errors.push(`environment[${i}].provenanceIds must be an array`);
|
|
76
|
+
}
|
|
77
|
+
for (const field of ["setup", "verify"]) {
|
|
78
|
+
for (const [i, s] of rec[field].entries()) {
|
|
79
|
+
if (typeof s !== "object" || s === null) {
|
|
80
|
+
errors.push(`${field}[${i}] must be an object`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const rec2 = s;
|
|
84
|
+
const command = rec2.command;
|
|
85
|
+
if (!Array.isArray(command) || command.length === 0 || !command.every((c) => typeof c === "string")) {
|
|
86
|
+
errors.push(`${field}[${i}].command must be an argv array (a bare shell string is refused; ["sh","-lc", …] is the explicit escape)`);
|
|
87
|
+
}
|
|
88
|
+
if (typeof rec2.timeoutSeconds !== "number")
|
|
89
|
+
errors.push(`${field}[${i}].timeoutSeconds must be a number`);
|
|
90
|
+
if (!Array.isArray(rec2.provenanceIds))
|
|
91
|
+
errors.push(`${field}[${i}].provenanceIds must be an array`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// C-2: services / provenance / agentAssets had no element check at all, so a non-object element
|
|
95
|
+
// reached the free-text sweep below and threw instead of being reported. The rule is the same one
|
|
96
|
+
// `environment` and `setup`/`verify` already apply: an element that is not an object is an error.
|
|
97
|
+
for (const field of ["services", "provenance", "agentAssets"]) {
|
|
98
|
+
for (const [i, e] of rec[field].entries()) {
|
|
99
|
+
if (typeof e !== "object" || e === null || Array.isArray(e))
|
|
100
|
+
errors.push(`${field}[${i}] must be an object`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// assertNoSecretMaterial, LAST — a value-SHAPE refusal over every free-text field (argv strings,
|
|
104
|
+
// service image names, provenance explanations, agent-asset references), never a classifier over
|
|
105
|
+
// environment[].name: there is nothing to classify there. The refusal names the FIELD, never the
|
|
106
|
+
// matched text, so this function never prints what it found.
|
|
107
|
+
for (const { path, text } of collectFreeText(rec)) {
|
|
108
|
+
if (text.includes(PEM_ARMOUR) || KNOWN_SECRET_PREFIX_RE.test(text)) {
|
|
109
|
+
errors.push(`${path} appears to contain secret material (matches a known credential prefix)`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
SECRET_ASSIGNMENT_RE.lastIndex = 0;
|
|
113
|
+
let m;
|
|
114
|
+
while ((m = SECRET_ASSIGNMENT_RE.exec(text))) {
|
|
115
|
+
if (SECRET_NAME_RE.test(m[1])) {
|
|
116
|
+
errors.push(`${path} appears to contain secret material (an assignment shaped like a secret name)`);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return errors;
|
|
122
|
+
}
|
|
123
|
+
function collectFreeText(rec) {
|
|
124
|
+
const out = [];
|
|
125
|
+
for (const field of ["setup", "verify"]) {
|
|
126
|
+
for (const [i, s] of rec[field].entries()) {
|
|
127
|
+
const rec2 = asRecord(s);
|
|
128
|
+
if (rec2 === null)
|
|
129
|
+
continue;
|
|
130
|
+
if (Array.isArray(rec2.command)) {
|
|
131
|
+
for (const c of rec2.command)
|
|
132
|
+
if (typeof c === "string")
|
|
133
|
+
out.push({ path: `${field}[${i}].command`, text: c });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const [i, s] of rec.services.entries()) {
|
|
138
|
+
const rec2 = asRecord(s);
|
|
139
|
+
if (rec2 === null)
|
|
140
|
+
continue;
|
|
141
|
+
if (typeof rec2.image === "string")
|
|
142
|
+
out.push({ path: `services[${i}].image`, text: rec2.image });
|
|
143
|
+
}
|
|
144
|
+
for (const [i, p] of rec.provenance.entries()) {
|
|
145
|
+
const rec2 = asRecord(p);
|
|
146
|
+
if (rec2 === null)
|
|
147
|
+
continue;
|
|
148
|
+
if (typeof rec2.explanation === "string")
|
|
149
|
+
out.push({ path: `provenance[${i}].explanation`, text: rec2.explanation });
|
|
150
|
+
}
|
|
151
|
+
for (const [i, a] of rec.agentAssets.entries()) {
|
|
152
|
+
const rec2 = asRecord(a);
|
|
153
|
+
if (rec2 === null)
|
|
154
|
+
continue;
|
|
155
|
+
if (typeof rec2.reference === "string")
|
|
156
|
+
out.push({ path: `agentAssets[${i}].reference`, text: rec2.reference });
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
/** An element the caller may safely read properties off, or null — a non-object is reported above. */
|
|
161
|
+
function asRecord(value) {
|
|
162
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
163
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const GROUP_ORDER = ["build/test", "deploy-only", "bindings"];
|
|
2
|
+
export function groupSightings(sightings) {
|
|
3
|
+
const byName = new Map();
|
|
4
|
+
for (const s of sightings) {
|
|
5
|
+
if (!byName.has(s.name))
|
|
6
|
+
byName.set(s.name, []);
|
|
7
|
+
byName.get(s.name).push(s);
|
|
8
|
+
}
|
|
9
|
+
const entries = [];
|
|
10
|
+
for (const [name, list] of byName) {
|
|
11
|
+
const binding = list.find((s) => s.isBinding);
|
|
12
|
+
const wranglerVar = list.find((s) => s.isWranglerVar);
|
|
13
|
+
let group;
|
|
14
|
+
let needsDeclaration = true;
|
|
15
|
+
let wranglerEnvironment;
|
|
16
|
+
if (binding) {
|
|
17
|
+
group = "bindings";
|
|
18
|
+
wranglerEnvironment = binding.wranglerEnvironment;
|
|
19
|
+
}
|
|
20
|
+
else if (wranglerVar) {
|
|
21
|
+
group = "build/test";
|
|
22
|
+
needsDeclaration = false;
|
|
23
|
+
wranglerEnvironment = wranglerVar.wranglerEnvironment;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
const allJobScoped = list.every((s) => s.jobId !== undefined);
|
|
27
|
+
const allDeploy = list.every((s) => s.jobDeploy === true);
|
|
28
|
+
group = allJobScoped && allDeploy ? "deploy-only" : "build/test";
|
|
29
|
+
}
|
|
30
|
+
const localSource = resolveLocalSource(group, list);
|
|
31
|
+
const found = list.map((s) => s.finding).sort((a, b) => (a.file === b.file ? a.line - b.line : a.file.localeCompare(b.file)));
|
|
32
|
+
entries.push({ name, group, localSource, needsDeclaration, wranglerEnvironment, found });
|
|
33
|
+
}
|
|
34
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
35
|
+
const groups = GROUP_ORDER.map((id) => ({
|
|
36
|
+
id,
|
|
37
|
+
names: entries.filter((e) => e.group === id).map((e) => e.name).sort(),
|
|
38
|
+
}));
|
|
39
|
+
return { entries, groups };
|
|
40
|
+
}
|
|
41
|
+
function resolveLocalSource(group, list) {
|
|
42
|
+
if (group === "bindings")
|
|
43
|
+
return "Cloudflare binding";
|
|
44
|
+
if (list.some((s) => s.isWranglerVar))
|
|
45
|
+
return "value committed in wrangler.toml — nothing to declare";
|
|
46
|
+
const hasEnvShell = list.some((s) => s.localSource === ".env file or your shell");
|
|
47
|
+
const hasCiSecret = list.some((s) => s.localSource === "CI secret");
|
|
48
|
+
if (group === "deploy-only")
|
|
49
|
+
return hasCiSecret ? "CI secret" : ".env file or your shell";
|
|
50
|
+
return hasEnvShell ? ".env file or your shell" : hasCiSecret ? "CI secret" : list[0].localSource;
|
|
51
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// inventory.ts — walks a repository once and dispatches each file to the scanner that understands it.
|
|
2
|
+
// D-5: `.env` and `.dev.vars` are never opened — not filtered out after reading, never asked for at
|
|
3
|
+
// all. Nothing in the dispatch table below names them, so the structural guarantee holds without a
|
|
4
|
+
// separate exclusion list that could itself drift.
|
|
5
|
+
//
|
|
6
|
+
// ⛔ D-5 IS KEYED ON THE REAL PATH, NOT ON THE WALKED ONE (validate attempt 29, M-2). A basename is
|
|
7
|
+
// exactly what a symlink controls: a committed `.env.example -> .env` used to route the REAL `.env`
|
|
8
|
+
// into scanEnvExample, because the dispatch read the link's name rather than the file it opens.
|
|
9
|
+
// Chained with the unquoted-PEM defect in scan-env-example.ts, that printed verbatim bytes of an
|
|
10
|
+
// uncommitted private key as a variable NAME. So each file is resolved with `realpathSync` first,
|
|
11
|
+
// the dispatch keys on the resolved basename, and NEVER_READ_BASENAMES refuses the two names D-5
|
|
12
|
+
// promises are never opened — under either name, the link's or the target's.
|
|
13
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
14
|
+
import { basename, join } from "node:path";
|
|
15
|
+
import { groupSightings } from "./group.js";
|
|
16
|
+
import { ENV_EXAMPLE_FILES, scanEnvExample } from "./scan-env-example.js";
|
|
17
|
+
import { MAX_SOURCE_FILE_BYTES, SOURCE_EXTENSIONS, scanSource } from "./scan-source.js";
|
|
18
|
+
import { scanWorkflow } from "./scan-workflow.js";
|
|
19
|
+
import { scanWrangler } from "./scan-wrangler.js";
|
|
20
|
+
import { walkRepo } from "./walk.js";
|
|
21
|
+
/** D-5's promise, as a list: neither name is ever opened, by either route. */
|
|
22
|
+
const NEVER_READ_BASENAMES = new Set([".env", ".dev.vars"]);
|
|
23
|
+
/** Above this a file is not read at all, whatever it is. CR-7: a drop here is always noted. */
|
|
24
|
+
export const HARD_READ_CAP_BYTES = MAX_SOURCE_FILE_BYTES * 4;
|
|
25
|
+
function defaultReadFile(path) {
|
|
26
|
+
try {
|
|
27
|
+
if (statSync(path).size > HARD_READ_CAP_BYTES)
|
|
28
|
+
return undefined;
|
|
29
|
+
return readFileSync(path, "utf8");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** The basename of the file `abs` really opens, or the walked basename when it cannot be resolved. */
|
|
36
|
+
function resolvedBasename(abs, walked) {
|
|
37
|
+
try {
|
|
38
|
+
return basename(realpathSync(abs));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return walked;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function inventoryRepo(root, deps = {}) {
|
|
45
|
+
const readFile = deps.readFile ?? defaultReadFile;
|
|
46
|
+
const listFiles = deps.listFiles ?? walkRepo;
|
|
47
|
+
const files = listFiles(root);
|
|
48
|
+
const sightings = [];
|
|
49
|
+
const jobs = [];
|
|
50
|
+
const notes = [];
|
|
51
|
+
for (const rel of files) {
|
|
52
|
+
const walkedBase = rel.split("/").pop() ?? rel;
|
|
53
|
+
const abs = join(root, rel);
|
|
54
|
+
const base = resolvedBasename(abs, walkedBase);
|
|
55
|
+
// M-2: refused under either name — the link's or the one it really opens.
|
|
56
|
+
if (NEVER_READ_BASENAMES.has(base) || NEVER_READ_BASENAMES.has(walkedBase))
|
|
57
|
+
continue;
|
|
58
|
+
if (ENV_EXAMPLE_FILES.includes(base)) {
|
|
59
|
+
const text = readFile(abs);
|
|
60
|
+
if (text !== undefined)
|
|
61
|
+
sightings.push(...scanEnvExample(rel, text));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (rel.startsWith(".github/workflows/") && (rel.endsWith(".yml") || rel.endsWith(".yaml"))) {
|
|
65
|
+
const text = readFile(abs);
|
|
66
|
+
if (text !== undefined) {
|
|
67
|
+
const res = scanWorkflow(rel, text);
|
|
68
|
+
sightings.push(...res.sightings);
|
|
69
|
+
jobs.push(...res.jobs);
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (base === "wrangler.toml") {
|
|
74
|
+
const text = readFile(abs);
|
|
75
|
+
if (text !== undefined)
|
|
76
|
+
sightings.push(...scanWrangler(rel, text));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (base === "wrangler.json" || base === "wrangler.jsonc") {
|
|
80
|
+
notes.push(`${rel} is a Cloudflare config this scan does not read yet (JSON/JSONC) — its vars and bindings are not reflected below.`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (SOURCE_EXTENSIONS.some((ext) => rel.endsWith(ext))) {
|
|
84
|
+
const text = readFile(abs);
|
|
85
|
+
// CR-7: the 512 KB band below emitted a note while the hard cap above it emitted NOTHING, so a
|
|
86
|
+
// file over 2 MB vanished from the inventory with no trace at all. Every drop is now named.
|
|
87
|
+
if (text === undefined) {
|
|
88
|
+
notes.push(`${rel} could not be read (unreadable, or over the ${HARD_READ_CAP_BYTES} byte hard cap) and was skipped`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// CR-7: measured in BYTES. `text.length` counts UTF-16 code units, so a file of multi-byte
|
|
92
|
+
// characters was compared against the wrong number.
|
|
93
|
+
if (Buffer.byteLength(text, "utf8") > MAX_SOURCE_FILE_BYTES) {
|
|
94
|
+
notes.push(`${rel} is over 512 KB and was skipped`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
sightings.push(...scanSource(rel, text));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const { entries, groups } = groupSightings(sightings);
|
|
101
|
+
return { root, entries, groups, jobs, notes };
|
|
102
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const GROUP_BLURB = {
|
|
2
|
+
"build/test": "needed to install, build and run the tests in a container",
|
|
3
|
+
"deploy-only": "a CI secret used only by a deploy job",
|
|
4
|
+
bindings: "a Cloudflare (or other platform) binding — not an environment value",
|
|
5
|
+
};
|
|
6
|
+
export function renderInventory(inv) {
|
|
7
|
+
const lines = [];
|
|
8
|
+
for (const group of inv.groups) {
|
|
9
|
+
lines.push(`${group.id} — ${GROUP_BLURB[group.id]}`);
|
|
10
|
+
if (group.names.length === 0) {
|
|
11
|
+
lines.push(" (none found)");
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
for (const name of group.names) {
|
|
15
|
+
const entry = inv.entries.find((e) => e.name === name);
|
|
16
|
+
if (!entry)
|
|
17
|
+
continue;
|
|
18
|
+
const where = entry.found.map((f) => `${f.file}:${f.line}`).join(", ");
|
|
19
|
+
const consumers = [...new Set(entry.found.map((f) => f.consumer))].join(", ");
|
|
20
|
+
lines.push(` ${name} — found ${where} · used by ${consumers} · value comes from ${entry.localSource}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
lines.push("");
|
|
24
|
+
}
|
|
25
|
+
if (inv.notes.length > 0) {
|
|
26
|
+
lines.push("Notes:");
|
|
27
|
+
for (const n of inv.notes)
|
|
28
|
+
lines.push(` ${n}`);
|
|
29
|
+
}
|
|
30
|
+
return lines;
|
|
31
|
+
}
|
|
32
|
+
export function inventoryToJson(inv) {
|
|
33
|
+
return { root: inv.root, groups: inv.groups, entries: inv.entries, jobs: inv.jobs, notes: inv.notes };
|
|
34
|
+
}
|
|
35
|
+
export function renderCheck(result, file) {
|
|
36
|
+
if (result.state === "valid")
|
|
37
|
+
return [`valid — ${file} passes this bundle's offline check`];
|
|
38
|
+
return [`invalid — ${file} failed this bundle's offline check:`, ...result.errors.map((e) => ` - ${e}`)];
|
|
39
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export const ENV_EXAMPLE_FILES = [".env.example", ".env.sample", ".env.template", ".env.defaults", ".dev.vars.example"];
|
|
2
|
+
const LINE_RE = /^\s*(#\s*)?(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;
|
|
3
|
+
const PEM_BEGIN = "-----BEGIN";
|
|
4
|
+
const PEM_END = "-----END";
|
|
5
|
+
export function scanEnvExample(file, text) {
|
|
6
|
+
const out = [];
|
|
7
|
+
const lines = text.split("\n");
|
|
8
|
+
// Non-null while a quoted value is still open. Lines inside one are skipped entirely: the name
|
|
9
|
+
// that opened the value was already recorded, and everything after it is value material.
|
|
10
|
+
let openQuote = null;
|
|
11
|
+
// True when the open quote was opened by a COMMENTED assignment — see shape 2 above.
|
|
12
|
+
let openQuoteCommented = false;
|
|
13
|
+
// True while a PEM armour block opened by a value half is still unterminated — see shape 1 above.
|
|
14
|
+
let openPem = false;
|
|
15
|
+
for (let i = 0; i < lines.length; i++) {
|
|
16
|
+
const line = lines[i];
|
|
17
|
+
if (openPem) {
|
|
18
|
+
if (line.includes(PEM_END))
|
|
19
|
+
openPem = false;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (openQuote) {
|
|
23
|
+
if (openQuoteCommented && !line.trimStart().startsWith("#")) {
|
|
24
|
+
// The comment block ended, so the "value" ended with it. Fall through and read this line.
|
|
25
|
+
openQuote = null;
|
|
26
|
+
openQuoteCommented = false;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
if (line.includes(openQuote)) {
|
|
30
|
+
openQuote = null;
|
|
31
|
+
openQuoteCommented = false;
|
|
32
|
+
}
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const m = LINE_RE.exec(line);
|
|
37
|
+
if (!m)
|
|
38
|
+
continue;
|
|
39
|
+
const value = m[3];
|
|
40
|
+
if (value.includes(PEM_BEGIN) && !value.includes(PEM_END)) {
|
|
41
|
+
openPem = true;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
openQuote = unclosedQuote(value);
|
|
45
|
+
openQuoteCommented = openQuote !== null && m[1] !== undefined;
|
|
46
|
+
}
|
|
47
|
+
out.push({
|
|
48
|
+
name: m[2],
|
|
49
|
+
finding: { file, line: i + 1, consumer: `a documented name in ${file}`, consumerKind: "env-example" },
|
|
50
|
+
localSource: ".env file or your shell",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/** The quote character this value half opens and does not close on its own line, or null. */
|
|
56
|
+
function unclosedQuote(value) {
|
|
57
|
+
const v = value.trim();
|
|
58
|
+
const q = v[0];
|
|
59
|
+
if (q !== '"' && q !== "'")
|
|
60
|
+
return null;
|
|
61
|
+
return v.slice(1).includes(q) ? null : q;
|
|
62
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
|
|
2
|
+
export const MAX_SOURCE_FILE_BYTES = 512 * 1024;
|
|
3
|
+
const PATTERNS = [
|
|
4
|
+
/\bprocess\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
5
|
+
/\bprocess\.env\[["']([A-Za-z_][A-Za-z0-9_]*)["']\]/g,
|
|
6
|
+
/\bimport\.meta\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
7
|
+
/\bDeno\.env\.get\(["']([A-Za-z_][A-Za-z0-9_]*)["']\)/g,
|
|
8
|
+
];
|
|
9
|
+
export function scanSource(file, text) {
|
|
10
|
+
const out = [];
|
|
11
|
+
const lineStarts = computeLineStarts(text);
|
|
12
|
+
for (const re of PATTERNS) {
|
|
13
|
+
re.lastIndex = 0;
|
|
14
|
+
let m;
|
|
15
|
+
while ((m = re.exec(text))) {
|
|
16
|
+
out.push({
|
|
17
|
+
name: m[1],
|
|
18
|
+
finding: { file, line: lineOf(lineStarts, m.index), consumer: `read by ${file}`, consumerKind: "source" },
|
|
19
|
+
localSource: ".env file or your shell",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
function computeLineStarts(text) {
|
|
26
|
+
const starts = [0];
|
|
27
|
+
for (let i = 0; i < text.length; i++)
|
|
28
|
+
if (text[i] === "\n")
|
|
29
|
+
starts.push(i + 1);
|
|
30
|
+
return starts;
|
|
31
|
+
}
|
|
32
|
+
function lineOf(starts, offset) {
|
|
33
|
+
let lo = 0;
|
|
34
|
+
let hi = starts.length - 1;
|
|
35
|
+
while (lo < hi) {
|
|
36
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
37
|
+
if (starts[mid] <= offset)
|
|
38
|
+
lo = mid;
|
|
39
|
+
else
|
|
40
|
+
hi = mid - 1;
|
|
41
|
+
}
|
|
42
|
+
return lo + 1;
|
|
43
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// scan-workflow.ts — names a GitHub Actions workflow declares or references, read with the real YAML
|
|
2
|
+
// parser (`yaml`, D-2). A hand-written line scanner silently loses names written in flow style
|
|
3
|
+
// (`env: { A: 1, B: 2 }`); `parseDocument` does not, and it hands back the exact file:line for every
|
|
4
|
+
// key through `LineCounter`, plus the structural path (workflow env / a specific job) for free.
|
|
5
|
+
import { LineCounter, isMap, isScalar, isSeq, parseDocument } from "yaml";
|
|
6
|
+
const DEPLOY_JOB_ID_RE = /^(deploy|publish|release|cd)([-_].*)?$/i;
|
|
7
|
+
const DEPLOY_COMMANDS = ["wrangler deploy", "wrangler versions upload", "npm publish", "pnpm publish", "bun publish", "gh release create", "flyctl deploy", "vercel deploy"];
|
|
8
|
+
const SECRET_VAR_RE = /\$\{\{\s*(secrets|vars)\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
|
|
9
|
+
export function scanWorkflow(file, text) {
|
|
10
|
+
const lc = new LineCounter();
|
|
11
|
+
let doc;
|
|
12
|
+
try {
|
|
13
|
+
doc = parseDocument(text, { lineCounter: lc });
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return { sightings: [], jobs: [] };
|
|
17
|
+
}
|
|
18
|
+
const lineOf = (offset) => lc.linePos(offset).line;
|
|
19
|
+
const root = doc.contents;
|
|
20
|
+
const sightings = [];
|
|
21
|
+
const jobs = [];
|
|
22
|
+
const jobRanges = [];
|
|
23
|
+
if (!isMap(root))
|
|
24
|
+
return { sightings, jobs };
|
|
25
|
+
const topEnv = root.get("env", true);
|
|
26
|
+
if (isMap(topEnv)) {
|
|
27
|
+
for (const pair of topEnv.items) {
|
|
28
|
+
if (!isScalar(pair.key))
|
|
29
|
+
continue;
|
|
30
|
+
const offset = pair.key.range?.[0] ?? 0;
|
|
31
|
+
sightings.push({
|
|
32
|
+
name: String(pair.key.value),
|
|
33
|
+
finding: { file, line: lineOf(offset), consumer: "workflow env", consumerKind: "workflow-env" },
|
|
34
|
+
localSource: ".env file or your shell",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const jobsNode = root.get("jobs", true);
|
|
39
|
+
if (isMap(jobsNode)) {
|
|
40
|
+
for (const jobPair of jobsNode.items) {
|
|
41
|
+
if (!isScalar(jobPair.key) || !isMap(jobPair.value))
|
|
42
|
+
continue;
|
|
43
|
+
const jobId = String(jobPair.key.value);
|
|
44
|
+
const jobMap = jobPair.value;
|
|
45
|
+
const jobKeyOffset = jobPair.key.range?.[0] ?? 0;
|
|
46
|
+
const jobRange = jobMap.range ?? [jobKeyOffset, jobKeyOffset, jobKeyOffset];
|
|
47
|
+
let deploy = jobMap.get("environment", true) !== undefined;
|
|
48
|
+
let why = deploy ? "has an environment:" : "";
|
|
49
|
+
if (!deploy && DEPLOY_JOB_ID_RE.test(jobId)) {
|
|
50
|
+
deploy = true;
|
|
51
|
+
why = "job id matches a deploy pattern";
|
|
52
|
+
}
|
|
53
|
+
const steps = jobMap.get("steps", true);
|
|
54
|
+
if (isSeq(steps)) {
|
|
55
|
+
for (const step of steps.items) {
|
|
56
|
+
if (!isMap(step))
|
|
57
|
+
continue;
|
|
58
|
+
const run = step.get("run", true);
|
|
59
|
+
if (isScalar(run) && typeof run.value === "string") {
|
|
60
|
+
const cmd = DEPLOY_COMMANDS.find((c) => run.value.includes(c));
|
|
61
|
+
if (cmd) {
|
|
62
|
+
deploy = true;
|
|
63
|
+
why = `a step runs ${cmd}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const stepEnv = step.get("env", true);
|
|
67
|
+
if (isMap(stepEnv)) {
|
|
68
|
+
for (const pair of stepEnv.items) {
|
|
69
|
+
if (!isScalar(pair.key))
|
|
70
|
+
continue;
|
|
71
|
+
const offset = pair.key.range?.[0] ?? 0;
|
|
72
|
+
sightings.push({
|
|
73
|
+
name: String(pair.key.value),
|
|
74
|
+
finding: { file, line: lineOf(offset), consumer: `workflow job ${jobId}`, consumerKind: "workflow-job-env" },
|
|
75
|
+
jobId,
|
|
76
|
+
localSource: ".env file or your shell",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const jobEnv = jobMap.get("env", true);
|
|
83
|
+
if (isMap(jobEnv)) {
|
|
84
|
+
for (const pair of jobEnv.items) {
|
|
85
|
+
if (!isScalar(pair.key))
|
|
86
|
+
continue;
|
|
87
|
+
const offset = pair.key.range?.[0] ?? 0;
|
|
88
|
+
sightings.push({
|
|
89
|
+
name: String(pair.key.value),
|
|
90
|
+
finding: { file, line: lineOf(offset), consumer: `workflow job ${jobId}`, consumerKind: "workflow-job-env" },
|
|
91
|
+
jobId,
|
|
92
|
+
localSource: ".env file or your shell",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
jobs.push({ id: jobId, file, line: lineOf(jobKeyOffset), deploy, why: why || "no deploy signal" });
|
|
97
|
+
jobRanges.push({ id: jobId, start: jobRange[0], end: jobRange[2] ?? jobRange[1], deploy });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// ${{ secrets.X }} / ${{ vars.X }} references, over the RAW TEXT rather than the parsed tree: GitHub
|
|
101
|
+
// Actions interpolates these anywhere, including inside a `run: |` block's shell text, and a plain
|
|
102
|
+
// shell assignment inside that same block (e.g. `NOT_A_NAME=1`) must NOT be mined as a name — the
|
|
103
|
+
// raw-text regex only ever matches the `${{ … }}` expression syntax, never bare shell.
|
|
104
|
+
SECRET_VAR_RE.lastIndex = 0;
|
|
105
|
+
let m;
|
|
106
|
+
while ((m = SECRET_VAR_RE.exec(text))) {
|
|
107
|
+
const name = m[2];
|
|
108
|
+
const offset = m.index;
|
|
109
|
+
const job = jobRanges.find((j) => offset >= j.start && offset < j.end);
|
|
110
|
+
sightings.push({
|
|
111
|
+
name,
|
|
112
|
+
finding: { file, line: lineOf(offset), consumer: job ? `workflow job ${job.id}` : "workflow", consumerKind: `${m[1]}-ref` },
|
|
113
|
+
jobId: job?.id,
|
|
114
|
+
jobDeploy: job?.deploy,
|
|
115
|
+
localSource: "CI secret",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
for (const s of sightings) {
|
|
119
|
+
if (s.jobId !== undefined && s.jobDeploy === undefined)
|
|
120
|
+
s.jobDeploy = jobRanges.find((j) => j.id === s.jobId)?.deploy;
|
|
121
|
+
}
|
|
122
|
+
return { sightings, jobs };
|
|
123
|
+
}
|