@stll/oxlint-config 0.12.0 → 0.12.2
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/README.md +84 -10
- package/dist/toolchain-guard.cjs +343 -114
- package/dist/toolchain-guard.cjs.map +1 -1
- package/dist/toolchain-guard.mjs +343 -114
- package/dist/toolchain-guard.mjs.map +1 -1
- package/package.json +1 -1
package/dist/toolchain-guard.mjs
CHANGED
|
@@ -267,19 +267,28 @@ const parseCloudSetup = (input) => {
|
|
|
267
267
|
if (input === void 0) return void 0;
|
|
268
268
|
if (typeof input !== "object" || input === null || Array.isArray(input)) throw new Error("cloud must be an object");
|
|
269
269
|
for (const key of Object.keys(input)) if (key !== "services" && key !== "install" && key !== "envFile") throw new Error(`unsupported cloud key: ${key}`);
|
|
270
|
-
|
|
270
|
+
const selected = "services" in input ? input.services : [];
|
|
271
|
+
if (!Array.isArray(selected)) throw new Error("cloud.services must be an array when provided");
|
|
271
272
|
if (!("install" in input) || input.install !== "bun install --frozen-lockfile") throw new Error(`cloud.install must be exactly ${cloudInstallCommand}`);
|
|
273
|
+
const seen = /* @__PURE__ */ new Set();
|
|
274
|
+
const services = Array.from(selected, (service) => {
|
|
275
|
+
if (!cloudService(service)) throw new Error("cloud.services contains an unsupported service");
|
|
276
|
+
if (seen.has(service)) throw new Error(`cloud.services contains duplicate service: ${service}`);
|
|
277
|
+
seen.add(service);
|
|
278
|
+
return service;
|
|
279
|
+
});
|
|
280
|
+
if (services.length === 0) {
|
|
281
|
+
if ("envFile" in input) throw new Error("cloud.envFile is not allowed without services");
|
|
282
|
+
return {
|
|
283
|
+
services,
|
|
284
|
+
install: cloudInstallCommand
|
|
285
|
+
};
|
|
286
|
+
}
|
|
272
287
|
if (!("envFile" in input) || typeof input.envFile !== "string") throw new Error("cloud.envFile must be a canonical repository-relative path");
|
|
273
288
|
const envFile = input.envFile;
|
|
274
289
|
if (envFile.startsWith("/") || /^[A-Za-z]:/.test(envFile) || envFile.includes("\\") || /\p{Cc}/u.test(envFile) || envFile.split("/").some((part) => part === "" || part === "." || part === "..")) throw new Error("cloud.envFile must be a canonical repository-relative path");
|
|
275
|
-
const seen = /* @__PURE__ */ new Set();
|
|
276
290
|
return {
|
|
277
|
-
services
|
|
278
|
-
if (!cloudService(service)) throw new Error("cloud.services contains an unsupported service");
|
|
279
|
-
if (seen.has(service)) throw new Error(`cloud.services contains duplicate service: ${service}`);
|
|
280
|
-
seen.add(service);
|
|
281
|
-
return service;
|
|
282
|
-
}),
|
|
291
|
+
services,
|
|
283
292
|
install: cloudInstallCommand,
|
|
284
293
|
envFile
|
|
285
294
|
};
|
|
@@ -491,8 +500,7 @@ NODE_DIR="/opt/stll-cloud/node/$NODE_VERSION"
|
|
|
491
500
|
BUN_DIR="/opt/stll-cloud/bun/$BUN_VERSION"
|
|
492
501
|
export PATH="$NODE_DIR/bin:$BUN_DIR:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
|
493
502
|
BUN_CACHE="/var/cache/stll-cloud/bun/$OUTPUT_UID"
|
|
494
|
-
|
|
495
|
-
ENV_MARKER='@ENV_MARKER@'
|
|
503
|
+
@ENV_ASSIGNMENTS@
|
|
496
504
|
[[ -f "$REPO_ROOT/.node-version" && ! -L "$REPO_ROOT/.node-version" ]] || fail 'A regular root .node-version is required'
|
|
497
505
|
[[ "$(awk 'NF { if (++count > 1 || NF != 1) exit 1; value=$1 } END { if (count != 1) exit 1; print value }' "$REPO_ROOT/.node-version")" == "$NODE_VERSION" ]] || fail 'Node selector changed; regenerate cloud-setup.sh'
|
|
498
506
|
cd -- "$REPO_ROOT"
|
|
@@ -561,6 +569,21 @@ dependency_install() {
|
|
|
561
569
|
@SERVICE_INSTALL@
|
|
562
570
|
@SERVICE_START@
|
|
563
571
|
|
|
572
|
+
@ENV_FUNCTIONS@
|
|
573
|
+
|
|
574
|
+
if [[ "$MODE" == install ]]; then
|
|
575
|
+
install_runtimes
|
|
576
|
+
install_services
|
|
577
|
+
dependency_install
|
|
578
|
+
exit 0
|
|
579
|
+
fi
|
|
580
|
+
runtime_ready
|
|
581
|
+
@ENV_VALIDATE@
|
|
582
|
+
prepare_state
|
|
583
|
+
start_services
|
|
584
|
+
@ENV_WRITE@
|
|
585
|
+
`;
|
|
586
|
+
const environmentFunctions = String.raw`
|
|
564
587
|
validate_environment() {
|
|
565
588
|
local part target="$REPO_ROOT" env_path="$REPO_ROOT/$ENV_FILE" first_line temporary
|
|
566
589
|
while IFS= read -r part; do
|
|
@@ -587,20 +610,6 @@ write_environment() {
|
|
|
587
610
|
chown "$OUTPUT_UID:$OUTPUT_GID" "$temporary"
|
|
588
611
|
mv -f -- "$temporary" "$env_path"
|
|
589
612
|
}
|
|
590
|
-
|
|
591
|
-
if [[ "$MODE" == install ]]; then
|
|
592
|
-
install_runtimes
|
|
593
|
-
install_services
|
|
594
|
-
dependency_install
|
|
595
|
-
exit 0
|
|
596
|
-
fi
|
|
597
|
-
runtime_ready
|
|
598
|
-
DATABASE_URL=''
|
|
599
|
-
REDIS_URL=''
|
|
600
|
-
validate_environment
|
|
601
|
-
prepare_state
|
|
602
|
-
start_services
|
|
603
|
-
write_environment
|
|
604
613
|
`;
|
|
605
614
|
const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
606
615
|
const generateCloudSetup = ({ policy, cloud, nodeVersion }) => {
|
|
@@ -612,8 +621,10 @@ const generateCloudSetup = ({ policy, cloud, nodeVersion }) => {
|
|
|
612
621
|
const replacements = /* @__PURE__ */ new Map([
|
|
613
622
|
["@BUN@", policy.bun],
|
|
614
623
|
["@NODE@", exactNode],
|
|
615
|
-
["@
|
|
616
|
-
["@
|
|
624
|
+
["@ENV_ASSIGNMENTS@", selected.envFile === void 0 ? "" : `ENV_FILE=${shellQuote(selected.envFile)}\nENV_MARKER=${shellQuote(cloudEnvironmentMarker)}`],
|
|
625
|
+
["@ENV_FUNCTIONS@", selected.envFile === void 0 ? "" : environmentFunctions],
|
|
626
|
+
["@ENV_VALIDATE@", selected.envFile === void 0 ? "" : "DATABASE_URL=''\nREDIS_URL=''\nvalidate_environment"],
|
|
627
|
+
["@ENV_WRITE@", selected.envFile === void 0 ? "" : "write_environment"],
|
|
617
628
|
["@SERVICE_INSTALL@", renderCloudServiceInstall({
|
|
618
629
|
services,
|
|
619
630
|
postgres: policy.postgres,
|
|
@@ -634,7 +645,7 @@ const generateCloudSetup = ({ policy, cloud, nodeVersion }) => {
|
|
|
634
645
|
};
|
|
635
646
|
//#endregion
|
|
636
647
|
//#region src/toolchain-container-inputs.ts
|
|
637
|
-
const record$
|
|
648
|
+
const record$7 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
638
649
|
/** Filename class recognized by Dependabot's Docker Compose fetcher. */
|
|
639
650
|
const isComposeDefinitionPath = (file) => /^\.?(?:docker-)?compose(?:-[\w]+)?(?:\.[\w-]+)?\.ya?ml$/i.test(file.split("/").at(-1) ?? "");
|
|
640
651
|
/** Candidate YAML files are classified by their document shape after reading. */
|
|
@@ -681,11 +692,11 @@ const kubernetesPodSpecPaths = {
|
|
|
681
692
|
};
|
|
682
693
|
/** Shared semantic image enumeration for runtime checks and update-root generation. */
|
|
683
694
|
const containerDocumentImages = (document, file) => {
|
|
684
|
-
if (!record$
|
|
695
|
+
if (!record$7(document)) return void 0;
|
|
685
696
|
const services = document["services"];
|
|
686
|
-
if (isComposeDefinitionPath(file) && record$
|
|
697
|
+
if (isComposeDefinitionPath(file) && record$7(services)) {
|
|
687
698
|
const images = [];
|
|
688
|
-
for (const [name, service] of Object.entries(services)) if (record$
|
|
699
|
+
for (const [name, service] of Object.entries(services)) if (record$7(service) && service["image"] !== void 0) images.push({
|
|
689
700
|
image: service["image"],
|
|
690
701
|
path: [
|
|
691
702
|
"services",
|
|
@@ -706,7 +717,7 @@ const containerDocumentImages = (document, file) => {
|
|
|
706
717
|
visited.add(resource);
|
|
707
718
|
if (typeof resource["apiVersion"] !== "string" || typeof resource["kind"] !== "string") return;
|
|
708
719
|
if (resource["kind"] === "List" && Array.isArray(resource["items"])) {
|
|
709
|
-
for (const [index, item] of resource["items"].entries()) if (record$
|
|
720
|
+
for (const [index, item] of resource["items"].entries()) if (record$7(item)) walkResource(item, [
|
|
710
721
|
...prefix,
|
|
711
722
|
"items",
|
|
712
723
|
index
|
|
@@ -716,8 +727,8 @@ const containerDocumentImages = (document, file) => {
|
|
|
716
727
|
const specPath = Object.entries(kubernetesPodSpecPaths).find(([kind]) => kind === resource["kind"])?.[1];
|
|
717
728
|
if (specPath === void 0) return;
|
|
718
729
|
let spec = resource;
|
|
719
|
-
for (const key of specPath) spec = record$
|
|
720
|
-
if (!record$
|
|
730
|
+
for (const key of specPath) spec = record$7(spec) ? spec[key] : void 0;
|
|
731
|
+
if (!record$7(spec)) return;
|
|
721
732
|
for (const key of [
|
|
722
733
|
"containers",
|
|
723
734
|
"initContainers",
|
|
@@ -725,7 +736,7 @@ const containerDocumentImages = (document, file) => {
|
|
|
725
736
|
]) {
|
|
726
737
|
const containers = spec[key];
|
|
727
738
|
if (!Array.isArray(containers)) continue;
|
|
728
|
-
for (const [index, container] of containers.entries()) if (record$
|
|
739
|
+
for (const [index, container] of containers.entries()) if (record$7(container) && container["image"] !== void 0) images.push({
|
|
729
740
|
image: container["image"],
|
|
730
741
|
path: [
|
|
731
742
|
...prefix,
|
|
@@ -828,7 +839,7 @@ const workspaceContains = ({ directory, file, patterns, rootMembership }) => {
|
|
|
828
839
|
//#endregion
|
|
829
840
|
//#region src/toolchain-dependabot.ts
|
|
830
841
|
const dependabotRules = ["dependabot-policy"];
|
|
831
|
-
const record$
|
|
842
|
+
const record$6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
832
843
|
const strings = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
833
844
|
const sameStrings = (value, expected) => {
|
|
834
845
|
if (!strings(value) || value.length !== expected.length) return false;
|
|
@@ -850,8 +861,8 @@ const workspaceTable = (parsed, ecosystem) => {
|
|
|
850
861
|
case "cargo": return parsed["workspace"];
|
|
851
862
|
case "uv": {
|
|
852
863
|
const tool = parsed["tool"];
|
|
853
|
-
const uv = record$
|
|
854
|
-
return record$
|
|
864
|
+
const uv = record$6(tool) ? tool["uv"] : void 0;
|
|
865
|
+
return record$6(uv) ? uv["workspace"] : void 0;
|
|
855
866
|
}
|
|
856
867
|
default: throw new Error("unknown workspace ecosystem", { cause: ecosystem });
|
|
857
868
|
}
|
|
@@ -871,7 +882,7 @@ const workspaceRoots = (files, ecosystem) => {
|
|
|
871
882
|
const document = parseDocument(text);
|
|
872
883
|
if (document.errors.length > 0) throw new Error(`${workspaceFile}:1: invalid pnpm workspace YAML`);
|
|
873
884
|
const parsed = document.toJS({ maxAliasCount: 100 });
|
|
874
|
-
if (!record$
|
|
885
|
+
if (!record$6(parsed)) throw new Error(`${workspaceFile}:1: pnpm workspace must contain an object`);
|
|
875
886
|
const patterns = parsed["packages"] ?? [];
|
|
876
887
|
if (!strings(patterns)) throw new Error(`${workspaceFile}:1: pnpm packages must be a string array`);
|
|
877
888
|
workspaces.set(directory, patterns.map((pattern) => pattern.startsWith("!") ? `!${path.posix.normalize(pattern.slice(1))}` : path.posix.normalize(pattern)));
|
|
@@ -883,13 +894,13 @@ const workspaceRoots = (files, ecosystem) => {
|
|
|
883
894
|
}
|
|
884
895
|
try {
|
|
885
896
|
const parsed = ecosystem === "npm" ? JSON.parse(files[file] ?? "") : parse(files[file] ?? "");
|
|
886
|
-
if (!record$
|
|
897
|
+
if (!record$6(parsed)) continue;
|
|
887
898
|
if (ecosystem === "npm" && typeof parsed["packageManager"] === "string" && parsed["packageManager"].startsWith("pnpm@")) continue;
|
|
888
899
|
const value = workspaceTable(parsed, ecosystem);
|
|
889
|
-
const patterns = record$
|
|
890
|
-
const members = ecosystem === "cargo" && record$
|
|
900
|
+
const patterns = record$6(value) ? value[ecosystem === "npm" ? "packages" : "members"] : value;
|
|
901
|
+
const members = ecosystem === "cargo" && record$6(value) && patterns === void 0 ? [] : patterns;
|
|
891
902
|
if (strings(members)) {
|
|
892
|
-
const excluded = ecosystem !== "npm" && record$
|
|
903
|
+
const excluded = ecosystem !== "npm" && record$6(value) && strings(value["exclude"]) ? value["exclude"].map((pattern) => `!${pattern}`) : [];
|
|
893
904
|
workspaces.set(path.posix.dirname(file), [...members, ...excluded]);
|
|
894
905
|
}
|
|
895
906
|
} catch {}
|
|
@@ -940,11 +951,11 @@ const workspaceRoots = (files, ecosystem) => {
|
|
|
940
951
|
for (const directory of memberDirectories) try {
|
|
941
952
|
const parsed = parse(files[path.posix.join(directory, "Cargo.toml")] ?? "");
|
|
942
953
|
const workspace = parse(files[path.posix.join(root, "Cargo.toml")] ?? "")["workspace"];
|
|
943
|
-
const inherited = record$
|
|
954
|
+
const inherited = record$6(workspace) ? workspace["dependencies"] : void 0;
|
|
944
955
|
const dependencyOwners = [parsed];
|
|
945
956
|
const targets = parsed["target"];
|
|
946
|
-
if (record$
|
|
947
|
-
for (const target of Object.values(targets)) if (record$
|
|
957
|
+
if (record$6(targets)) {
|
|
958
|
+
for (const target of Object.values(targets)) if (record$6(target)) dependencyOwners.push(target);
|
|
948
959
|
}
|
|
949
960
|
for (const owner of dependencyOwners) for (const key of [
|
|
950
961
|
"dependencies",
|
|
@@ -952,12 +963,12 @@ const workspaceRoots = (files, ecosystem) => {
|
|
|
952
963
|
"build-dependencies"
|
|
953
964
|
]) {
|
|
954
965
|
const dependencies = owner[key];
|
|
955
|
-
if (!record$
|
|
966
|
+
if (!record$6(dependencies)) continue;
|
|
956
967
|
for (const [name, dependency] of Object.entries(dependencies)) {
|
|
957
|
-
if (!record$
|
|
968
|
+
if (!record$6(dependency)) continue;
|
|
958
969
|
const isInherited = dependency["workspace"] === true;
|
|
959
|
-
const resolved = isInherited && record$
|
|
960
|
-
if (!record$
|
|
970
|
+
const resolved = isInherited && record$6(inherited) ? inherited[name] : dependency;
|
|
971
|
+
if (!record$6(resolved) || typeof resolved["path"] !== "string") continue;
|
|
961
972
|
if (path.posix.isAbsolute(resolved["path"]) || /\\|\$|^[A-Za-z]:/.test(resolved["path"])) continue;
|
|
962
973
|
const target = path.posix.normalize(path.posix.join(isInherited ? root : directory, resolved["path"]));
|
|
963
974
|
const relative = path.posix.relative(root, target);
|
|
@@ -985,7 +996,7 @@ const javascriptEcosystem = (directory, files) => {
|
|
|
985
996
|
if (files[path.posix.join(root, "bun.lock")] !== void 0) return "bun";
|
|
986
997
|
try {
|
|
987
998
|
const manifest = JSON.parse(files[path.posix.join(root, "package.json")] ?? "{}");
|
|
988
|
-
if (record$
|
|
999
|
+
if (record$6(manifest) && typeof manifest["packageManager"] === "string" && manifest["packageManager"].startsWith("bun@")) return "bun";
|
|
989
1000
|
} catch {}
|
|
990
1001
|
return "npm";
|
|
991
1002
|
};
|
|
@@ -997,7 +1008,7 @@ const pythonEcosystem = (file, files) => {
|
|
|
997
1008
|
const pyproject = files[path.posix.join(root, "pyproject.toml")];
|
|
998
1009
|
if (pyproject !== void 0) try {
|
|
999
1010
|
const tool = parse(pyproject)["tool"];
|
|
1000
|
-
if (record$
|
|
1011
|
+
if (record$6(tool) && record$6(tool["uv"])) return "uv";
|
|
1001
1012
|
} catch {}
|
|
1002
1013
|
return "pip";
|
|
1003
1014
|
};
|
|
@@ -1068,7 +1079,7 @@ const checkDependabot = ({ files, policy }) => {
|
|
|
1068
1079
|
add("cannot resolve Dependabot YAML aliases");
|
|
1069
1080
|
return diagnostics;
|
|
1070
1081
|
}
|
|
1071
|
-
if (!record$
|
|
1082
|
+
if (!record$6(parsed) || parsed["version"] !== 2 || !Array.isArray(parsed["updates"])) {
|
|
1072
1083
|
add("Dependabot must declare version: 2 and an updates array");
|
|
1073
1084
|
return diagnostics;
|
|
1074
1085
|
}
|
|
@@ -1081,7 +1092,7 @@ const checkDependabot = ({ files, policy }) => {
|
|
|
1081
1092
|
});
|
|
1082
1093
|
if (legacyDocument.errors.length === 0) legacy = legacyDocument.toJS({ maxAliasCount: 100 });
|
|
1083
1094
|
} catch {}
|
|
1084
|
-
const legacyUpdates = record$
|
|
1095
|
+
const legacyUpdates = record$6(legacy) && Array.isArray(legacy["updates"]) ? legacy["updates"] : [];
|
|
1085
1096
|
const rootClaims = /* @__PURE__ */ new Map();
|
|
1086
1097
|
const directoryClaims = /* @__PURE__ */ new Map();
|
|
1087
1098
|
for (const [index, update] of parsed["updates"].entries()) {
|
|
@@ -1095,7 +1106,7 @@ const checkDependabot = ({ files, policy }) => {
|
|
|
1095
1106
|
return offset === void 0 ? 1 : lineCounter.linePos(offset).line;
|
|
1096
1107
|
};
|
|
1097
1108
|
const report = (key, message) => add(message, lineOf(key));
|
|
1098
|
-
if (!record$
|
|
1109
|
+
if (!record$6(update) || typeof update["package-ecosystem"] !== "string") {
|
|
1099
1110
|
report("package-ecosystem", "each Dependabot update must declare a package ecosystem");
|
|
1100
1111
|
continue;
|
|
1101
1112
|
}
|
|
@@ -1129,26 +1140,75 @@ const checkDependabot = ({ files, policy }) => {
|
|
|
1129
1140
|
covered.set(ecosystem, matches);
|
|
1130
1141
|
const schedule = update["schedule"];
|
|
1131
1142
|
const legacyUpdate = legacyUpdates.at(index);
|
|
1132
|
-
const legacySchedule = record$
|
|
1133
|
-
if (record$
|
|
1134
|
-
if (!record$
|
|
1143
|
+
const legacySchedule = record$6(legacyUpdate) ? legacyUpdate["schedule"] : void 0;
|
|
1144
|
+
if (record$6(schedule) && schedule["time"] === policy.schedule.time && (!record$6(legacySchedule) || legacySchedule["time"] !== policy.schedule.time)) report("schedule", `${ecosystem}: quote schedule time to preserve its string value in YAML 1.1`);
|
|
1145
|
+
if (!record$6(schedule) || Object.keys(schedule).length !== Object.keys(policy.schedule).length || Object.entries(policy.schedule).some(([key, value]) => schedule[key] !== value)) report("schedule", `${ecosystem}: schedule must match the shared policy`);
|
|
1135
1146
|
const cooldown = update["cooldown"];
|
|
1136
|
-
if (!record$
|
|
1147
|
+
if (!record$6(cooldown) || Object.keys(cooldown).length !== 1 || cooldown["default-days"] !== policy.cooldown.defaultDays) report("cooldown", `${ecosystem}: cooldown must contain only default-days: ${policy.cooldown.defaultDays}`);
|
|
1137
1148
|
const groups = update["groups"];
|
|
1138
|
-
if (!record$
|
|
1149
|
+
if (!record$6(groups) || Object.keys(groups).length !== Object.keys(policy.groups).length || Object.entries(policy.groups).some(([name, expected]) => {
|
|
1139
1150
|
const group = groups[name];
|
|
1140
|
-
return !record$
|
|
1151
|
+
return !record$6(group) || Object.keys(group).length !== 2 || !sameStrings(group["patterns"], expected.patterns) || !sameStrings(group["update-types"], expected.updateTypes);
|
|
1141
1152
|
})) report("groups", `${ecosystem}: groups must match the shared policy`);
|
|
1142
1153
|
const ignored = ignoredFor(ecosystem, policy);
|
|
1143
1154
|
const ignore = update["ignore"];
|
|
1144
1155
|
if (ignore !== void 0 && (!Array.isArray(ignore) || ignore.length === 0)) report("ignore", `${ecosystem}: omit ignore or provide a nonempty array`);
|
|
1145
|
-
if (Array.isArray(ignore) && ignore.some((entry) => !record$
|
|
1146
|
-
for (const name of ignored) if (!Array.isArray(ignore) || !ignore.some((entry) => record$
|
|
1156
|
+
if (Array.isArray(ignore) && ignore.some((entry) => !record$6(entry) || typeof entry["dependency-name"] !== "string" || !ignored.includes(entry["dependency-name"]))) report("ignore", `${ecosystem}: ignore only packages owned by the shared policy`);
|
|
1157
|
+
for (const name of ignored) if (!Array.isArray(ignore) || !ignore.some((entry) => record$6(entry) && entry["dependency-name"] === name && Object.keys(entry).length === 1)) report("ignore", `${ecosystem}: ignore all updates for shared pin ${name}`);
|
|
1147
1158
|
}
|
|
1148
1159
|
for (const [ecosystem, directories] of roots) for (const directory of directories) if (!covered.get(ecosystem)?.has(directory)) add(`add a ${ecosystem} update entry covering ${directory}`);
|
|
1149
1160
|
return diagnostics;
|
|
1150
1161
|
};
|
|
1151
1162
|
//#endregion
|
|
1163
|
+
//#region src/toolchain-dynamic-selectors.ts
|
|
1164
|
+
const locatorValidators = {
|
|
1165
|
+
image: /^(?:jobs\.[\w-]+\.(?:container|services\.[\w-]+)|services\.[\w.-]+\.image)$/,
|
|
1166
|
+
"bun-source": /^(?:jobs\.[\w-]+|runs)\.steps\.[\w-]+$/
|
|
1167
|
+
};
|
|
1168
|
+
const knownKind = (value) => typeof value === "string" && Object.hasOwn(locatorValidators, value);
|
|
1169
|
+
const record$5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1170
|
+
/** One reviewed declaration identifies one unresolved selector, never a rule. */
|
|
1171
|
+
const parseDynamicSelectors = (input) => {
|
|
1172
|
+
if (input === void 0) return [];
|
|
1173
|
+
if (!Array.isArray(input)) throw new Error("dynamicSelectors must be an array");
|
|
1174
|
+
const result = [];
|
|
1175
|
+
const keys = /* @__PURE__ */ new Set();
|
|
1176
|
+
for (const entry of input) {
|
|
1177
|
+
if (!record$5(entry) || Object.keys(entry).some((key) => ![
|
|
1178
|
+
"path",
|
|
1179
|
+
"kind",
|
|
1180
|
+
"reason",
|
|
1181
|
+
"at",
|
|
1182
|
+
"line"
|
|
1183
|
+
].includes(key)) || typeof entry["path"] !== "string" || entry["path"] === "" || /[\\:$*?]|[^\S ]/.test(entry["path"]) || entry["path"].split("/").some((part) => part === "" || part === "." || part === "..") || typeof entry["reason"] !== "string" || entry["reason"].trim() === "") throw new Error("each dynamic selector requires an exact repository path, known kind and nonempty reason");
|
|
1184
|
+
const kind = entry["kind"];
|
|
1185
|
+
if (!knownKind(kind)) throw new Error("unknown dynamic selector kind");
|
|
1186
|
+
let selector;
|
|
1187
|
+
if (Object.hasOwn(entry, "line") && !Object.hasOwn(entry, "at") && typeof entry["line"] === "number" && Number.isSafeInteger(entry["line"]) && entry["line"] > 0) selector = {
|
|
1188
|
+
path: entry["path"],
|
|
1189
|
+
kind,
|
|
1190
|
+
reason: entry["reason"],
|
|
1191
|
+
line: entry["line"]
|
|
1192
|
+
};
|
|
1193
|
+
else if (!Object.hasOwn(entry, "line") && typeof entry["at"] === "string" && locatorValidators[kind].test(entry["at"])) selector = {
|
|
1194
|
+
path: entry["path"],
|
|
1195
|
+
kind,
|
|
1196
|
+
reason: entry["reason"],
|
|
1197
|
+
at: entry["at"]
|
|
1198
|
+
};
|
|
1199
|
+
else throw new Error("dynamic selector requires one positive line or a kind-specific job/service/step ID locator");
|
|
1200
|
+
const key = JSON.stringify([
|
|
1201
|
+
selector.path,
|
|
1202
|
+
selector.kind,
|
|
1203
|
+
selector.at ?? selector.line
|
|
1204
|
+
]);
|
|
1205
|
+
if (keys.has(key)) throw new Error("dynamic selector locations must be unique");
|
|
1206
|
+
keys.add(key);
|
|
1207
|
+
result.push(selector);
|
|
1208
|
+
}
|
|
1209
|
+
return result;
|
|
1210
|
+
};
|
|
1211
|
+
//#endregion
|
|
1152
1212
|
//#region src/toolchain-engine-floors.ts
|
|
1153
1213
|
const record$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1154
1214
|
const repositoryPath = (value) => typeof value === "string" && value !== "" && !/^[A-Za-z]:/.test(value) && !/[\\\s]/.test(value) && !Array.from(value).some((character) => character.charCodeAt(0) < 32) && value.split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
|
@@ -1960,7 +2020,7 @@ const pythonRequirementMatches = (requirement, version) => {
|
|
|
1960
2020
|
});
|
|
1961
2021
|
};
|
|
1962
2022
|
/** Check runtime declarations only in the files that declare or configure them. */
|
|
1963
|
-
const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, repository, onDelegated, engineFloors = [], onEngineFloor }) => {
|
|
2023
|
+
const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, repository, onDelegated, dynamicSelectors = [], onDynamicSelector, engineFloors = [], onEngineFloor }) => {
|
|
1964
2024
|
const diagnostics = [];
|
|
1965
2025
|
const name = path.posix.basename(file);
|
|
1966
2026
|
const lines = text.split(/\r?\n/);
|
|
@@ -1972,6 +2032,16 @@ const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, reposito
|
|
|
1972
2032
|
message
|
|
1973
2033
|
});
|
|
1974
2034
|
};
|
|
2035
|
+
let currentLocation;
|
|
2036
|
+
const dynamic = (value) => typeof value === "string" && value.includes("$");
|
|
2037
|
+
const authorizeDynamic = (kind, line, at = currentLocation) => {
|
|
2038
|
+
const matches = dynamicSelectors.filter((entry) => entry.path === file && entry.kind === kind && (entry.at !== void 0 ? entry.at === at : entry.line === line));
|
|
2039
|
+
if (matches.length !== 1) return false;
|
|
2040
|
+
const entry = matches.at(0);
|
|
2041
|
+
if (entry === void 0) return false;
|
|
2042
|
+
onDynamicSelector?.(entry);
|
|
2043
|
+
return true;
|
|
2044
|
+
};
|
|
1975
2045
|
const lineOf = (key) => Math.max(1, lines.findIndex((line) => line.includes(key)) + 1);
|
|
1976
2046
|
const pin = ({ rule, value, expected, label, line = 1, selector }) => {
|
|
1977
2047
|
let matches = value === expected;
|
|
@@ -2173,32 +2243,73 @@ const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, reposito
|
|
|
2173
2243
|
line: index + 1
|
|
2174
2244
|
});
|
|
2175
2245
|
});
|
|
2176
|
-
const checkRuntimeImage = ({ image, line, label }) => {
|
|
2177
|
-
|
|
2246
|
+
const checkRuntimeImage = ({ image, line, label, at }) => {
|
|
2247
|
+
const unknownRuntime = () => {
|
|
2248
|
+
if (dynamic(image) && authorizeDynamic("image", line, at)) return;
|
|
2178
2249
|
add({
|
|
2179
2250
|
rule: "runtime-docker",
|
|
2180
2251
|
line,
|
|
2181
|
-
message: `cannot determine ${label} runtime; use a static image or a
|
|
2252
|
+
message: `cannot determine ${label} runtime; use a static image or a scoped dynamicSelectors declaration`
|
|
2182
2253
|
});
|
|
2254
|
+
};
|
|
2255
|
+
if (typeof image !== "string") {
|
|
2256
|
+
unknownRuntime();
|
|
2257
|
+
return;
|
|
2258
|
+
}
|
|
2259
|
+
const canonical = canonicalDockerRuntime(image);
|
|
2260
|
+
const structuralImage = canonical.replace(/\$\{[^}]*\}|\$[A-Za-z_][A-Za-z_\d]*/g, (variable) => "x".repeat(variable.length));
|
|
2261
|
+
const digestSeparator = structuralImage.indexOf("@");
|
|
2262
|
+
const reference = digestSeparator < 0 ? canonical : canonical.slice(0, digestSeparator);
|
|
2263
|
+
const structuralReference = digestSeparator < 0 ? structuralImage : structuralImage.slice(0, digestSeparator);
|
|
2264
|
+
const managedFamily = /^(node|python|oven\/bun)(?=:|$)/i.exec(structuralReference)?.[1];
|
|
2265
|
+
const tagSeparator = managedFamily === void 0 ? structuralReference.lastIndexOf(":") : structuralReference.indexOf(":", managedFamily.length);
|
|
2266
|
+
const hasTag = managedFamily === void 0 ? tagSeparator > structuralReference.lastIndexOf("/") : tagSeparator !== -1;
|
|
2267
|
+
const imageRepository = hasTag ? reference.slice(0, tagSeparator) : reference;
|
|
2268
|
+
if (imageRepository.length === 0 || imageRepository.includes("$")) {
|
|
2269
|
+
unknownRuntime();
|
|
2270
|
+
return;
|
|
2271
|
+
}
|
|
2272
|
+
if (!/^(node|python|oven\/bun)$/i.test(imageRepository)) return;
|
|
2273
|
+
const tool = imageRepository.toLowerCase();
|
|
2274
|
+
if (image.includes("$")) {
|
|
2275
|
+
unknownRuntime();
|
|
2183
2276
|
return;
|
|
2184
2277
|
}
|
|
2185
|
-
const
|
|
2186
|
-
if (
|
|
2187
|
-
const tool = runtime[1]?.toLowerCase();
|
|
2188
|
-
if (image.includes("@")) {
|
|
2278
|
+
const digest = digestSeparator < 0 ? void 0 : canonical.slice(digestSeparator + 1);
|
|
2279
|
+
if (digest !== void 0 && !/^sha256:[a-fA-F\d]{64}$/.test(digest)) {
|
|
2189
2280
|
add({
|
|
2190
2281
|
rule: tool === "oven/bun" ? "bun-pins" : "runtime-docker",
|
|
2191
2282
|
line,
|
|
2192
|
-
message: `${label} ${tool} digest
|
|
2283
|
+
message: `${label} ${tool} digest must be sha256 followed by 64 hexadecimal characters`
|
|
2193
2284
|
});
|
|
2194
2285
|
return;
|
|
2195
2286
|
}
|
|
2196
|
-
const tag =
|
|
2287
|
+
const tag = hasTag ? reference.slice(tagSeparator + 1) : void 0;
|
|
2197
2288
|
const variantSuffix = tool === "oven/bun" ? /-(?:alpine|slim|debian)$/ : /-(?:alpine(?:\d+(?:\.\d+)*)?|(?:bookworm|bullseye|trixie|buster)(?:-slim)?|slim)$/;
|
|
2198
2289
|
const version = tag?.replace(variantSuffix, "");
|
|
2199
2290
|
let expected = policy.bun;
|
|
2200
2291
|
if (tool === "node") expected = policy.node;
|
|
2201
2292
|
else if (tool === "python") expected = policy.python;
|
|
2293
|
+
if (digest !== void 0) {
|
|
2294
|
+
if (version === void 0 || !/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(version)) {
|
|
2295
|
+
add({
|
|
2296
|
+
rule: tool === "oven/bun" ? "bun-pins" : "runtime-docker",
|
|
2297
|
+
line,
|
|
2298
|
+
message: `${label} ${tool} digest requires an exact stable patch tag matching ${expected}`
|
|
2299
|
+
});
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
if (tool === "node" && trackedFiles.has(".node-version")) {
|
|
2303
|
+
if (readFile(".node-version")?.trim() !== version) {
|
|
2304
|
+
add({
|
|
2305
|
+
rule: "runtime-docker",
|
|
2306
|
+
line,
|
|
2307
|
+
message: `${label} node digest tag must match tracked .node-version, found ${version}`
|
|
2308
|
+
});
|
|
2309
|
+
return;
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2202
2313
|
pin({
|
|
2203
2314
|
rule: tool === "oven/bun" ? "bun-pins" : "runtime-docker",
|
|
2204
2315
|
value: version,
|
|
@@ -2311,14 +2422,6 @@ const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, reposito
|
|
|
2311
2422
|
if (from === null || from[1] === void 0) return;
|
|
2312
2423
|
argumentScope = "stage";
|
|
2313
2424
|
const image = expand(from[1]);
|
|
2314
|
-
if (image.includes("$")) {
|
|
2315
|
-
add({
|
|
2316
|
-
rule: "runtime-docker",
|
|
2317
|
-
line,
|
|
2318
|
-
message: "cannot determine FROM runtime; declare an image ARG default or a reasoned runtime-docker opt-out"
|
|
2319
|
-
});
|
|
2320
|
-
return;
|
|
2321
|
-
}
|
|
2322
2425
|
const stage = from[2]?.toLowerCase();
|
|
2323
2426
|
const previousStage = stages.has(image.toLowerCase());
|
|
2324
2427
|
if (stage !== void 0) stages.add(stage);
|
|
@@ -2375,6 +2478,7 @@ const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, reposito
|
|
|
2375
2478
|
checkRuntimeImage({
|
|
2376
2479
|
image: entry.image,
|
|
2377
2480
|
line,
|
|
2481
|
+
at: entry.path.join("."),
|
|
2378
2482
|
label: classified?.ecosystem === "docker-compose" ? "Compose image" : "Kubernetes image"
|
|
2379
2483
|
});
|
|
2380
2484
|
}
|
|
@@ -2409,8 +2513,22 @@ const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, reposito
|
|
|
2409
2513
|
let checkoutBindings = [];
|
|
2410
2514
|
let checkoutDestinations = /* @__PURE__ */ new Map();
|
|
2411
2515
|
let unknownCheckoutDestination = false;
|
|
2516
|
+
let unreviewableCheckoutSource = false;
|
|
2412
2517
|
const staticRepositoryPath = (value) => typeof value === "string" && value !== "" && !value.includes("${{") && !value.includes("\\") && !value.includes(":") && !path.posix.isAbsolute(value) && !value.split("/").includes("..");
|
|
2413
2518
|
const normalizeCheckoutPath = (prefix) => path.posix.normalize(prefix).replace(/\/$/, "") || ".";
|
|
2519
|
+
const checkoutProvidersFor = (selector) => {
|
|
2520
|
+
if (selector === void 0) return checkoutBindings;
|
|
2521
|
+
let deepest;
|
|
2522
|
+
for (const entry of checkoutBindings) {
|
|
2523
|
+
if (entry.path !== "." && selector !== entry.path && !selector.startsWith(`${entry.path}/`)) continue;
|
|
2524
|
+
if (deepest === void 0 || entry.path !== "." && (deepest.path === "." || entry.path.length > deepest.path.length)) deepest = entry;
|
|
2525
|
+
}
|
|
2526
|
+
return checkoutBindings.filter((entry) => entry.path === deepest?.path);
|
|
2527
|
+
};
|
|
2528
|
+
const hasAmbiguousCheckoutWriters = (selector, providers) => {
|
|
2529
|
+
if (providers.length > 0) return providers.some((entry) => (checkoutDestinations.get(entry.path) ?? 0) > 1);
|
|
2530
|
+
return [...checkoutDestinations].some(([prefix, count]) => count > 1 && (selector === void 0 || prefix === "." || selector === prefix || selector.startsWith(`${prefix}/`)));
|
|
2531
|
+
};
|
|
2414
2532
|
const checkReference = ({ value, tool, line }) => {
|
|
2415
2533
|
const allowed = {
|
|
2416
2534
|
node: [".node-version", ".nvmrc"],
|
|
@@ -2419,8 +2537,28 @@ const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, reposito
|
|
|
2419
2537
|
}[tool];
|
|
2420
2538
|
const rule = tool === "bun" ? "bun-pins" : "runtime-workflow";
|
|
2421
2539
|
const selector = staticRepositoryPath(value) ? path.posix.normalize(value) : void 0;
|
|
2422
|
-
const
|
|
2540
|
+
const providers = checkoutProvidersFor(selector);
|
|
2541
|
+
const binding = selector === void 0 ? void 0 : providers.at(-1);
|
|
2542
|
+
const dynamicSource = providers.some((entry) => entry.dynamicSource);
|
|
2543
|
+
const sparseFailure = providers.find((entry) => {
|
|
2544
|
+
if (entry.sparse.mode === "invalid") return true;
|
|
2545
|
+
if (entry.sparse.mode !== "files" && entry.sparse.mode !== "mixed") return false;
|
|
2546
|
+
if (selector === void 0) return true;
|
|
2547
|
+
const selectedFile = entry.path === "." ? selector : path.posix.relative(entry.path, selector);
|
|
2548
|
+
return !entry.sparse.paths.has(selectedFile);
|
|
2549
|
+
});
|
|
2550
|
+
if (sparseFailure !== void 0) {
|
|
2551
|
+
let missingFile = "the selected version file";
|
|
2552
|
+
if (selector !== void 0) missingFile = sparseFailure.path === "." ? selector : path.posix.relative(sparseFailure.path, selector);
|
|
2553
|
+
add({
|
|
2554
|
+
rule,
|
|
2555
|
+
line,
|
|
2556
|
+
message: `checkout sparse-checkout must explicitly list ${missingFile} without invalid literal patterns`
|
|
2557
|
+
});
|
|
2558
|
+
return;
|
|
2559
|
+
}
|
|
2423
2560
|
if (selector === void 0) {
|
|
2561
|
+
if (tool === "bun" && dynamic(value) && !unreviewableCheckoutSource && authorizeDynamic("bun-source", line)) return;
|
|
2424
2562
|
add({
|
|
2425
2563
|
rule,
|
|
2426
2564
|
line,
|
|
@@ -2428,7 +2566,8 @@ const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, reposito
|
|
|
2428
2566
|
});
|
|
2429
2567
|
return;
|
|
2430
2568
|
}
|
|
2431
|
-
if (unknownCheckoutDestination ||
|
|
2569
|
+
if (unknownCheckoutDestination || hasAmbiguousCheckoutWriters(selector, providers)) {
|
|
2570
|
+
if (tool === "bun" && dynamicSource && !unreviewableCheckoutSource && authorizeDynamic("bun-source", line)) return;
|
|
2432
2571
|
add({
|
|
2433
2572
|
rule,
|
|
2434
2573
|
line,
|
|
@@ -2437,6 +2576,7 @@ const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, reposito
|
|
|
2437
2576
|
return;
|
|
2438
2577
|
}
|
|
2439
2578
|
if (binding?.source === "untrusted") {
|
|
2579
|
+
if (tool === "bun" && dynamicSource && !unreviewableCheckoutSource && authorizeDynamic("bun-source", line)) return;
|
|
2440
2580
|
add({
|
|
2441
2581
|
rule,
|
|
2442
2582
|
line: binding.line,
|
|
@@ -2453,13 +2593,15 @@ const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, reposito
|
|
|
2453
2593
|
});
|
|
2454
2594
|
return;
|
|
2455
2595
|
}
|
|
2456
|
-
if (binding !== void 0 && (binding.sparse.mode === "
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2596
|
+
if (binding !== void 0 && (binding.sparse.mode === "dynamic" || binding.sparse.mode === "mixed")) {
|
|
2597
|
+
if (tool !== "bun" || unreviewableCheckoutSource || !authorizeDynamic("bun-source", line)) {
|
|
2598
|
+
add({
|
|
2599
|
+
rule,
|
|
2600
|
+
line,
|
|
2601
|
+
message: `checkout sparse-checkout must explicitly list ${target} without dynamic, glob, or negation patterns`
|
|
2602
|
+
});
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2463
2605
|
}
|
|
2464
2606
|
if (binding?.source === "delegated") {
|
|
2465
2607
|
onDelegated?.({
|
|
@@ -2581,10 +2723,10 @@ const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, reposito
|
|
|
2581
2723
|
return inputs;
|
|
2582
2724
|
};
|
|
2583
2725
|
const getInput = (node, key) => inputsOf(node).get(key.toUpperCase());
|
|
2584
|
-
const
|
|
2585
|
-
if (
|
|
2726
|
+
const expressionAt = (value, start) => {
|
|
2727
|
+
if (!value.startsWith("${{", start)) return void 0;
|
|
2586
2728
|
let quote;
|
|
2587
|
-
for (let index = 3; index < value.length; index++) {
|
|
2729
|
+
for (let index = start + 3; index < value.length; index++) {
|
|
2588
2730
|
const character = value.at(index);
|
|
2589
2731
|
if (quote !== void 0) {
|
|
2590
2732
|
if (character === quote) if (quote === "'" && value.at(index + 1) === "'") index++;
|
|
@@ -2594,9 +2736,33 @@ const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, reposito
|
|
|
2594
2736
|
}
|
|
2595
2737
|
if (character === "'" || character === "\"") quote = character;
|
|
2596
2738
|
else if (value.startsWith("${{", index)) return void 0;
|
|
2597
|
-
else if (character === "}" && value.at(index + 1) === "}") return
|
|
2739
|
+
else if (character === "}" && value.at(index + 1) === "}") return {
|
|
2740
|
+
body: value.slice(start + 3, index).trim(),
|
|
2741
|
+
end: index + 2
|
|
2742
|
+
};
|
|
2598
2743
|
}
|
|
2599
2744
|
};
|
|
2745
|
+
const dynamicRefBody = (value) => {
|
|
2746
|
+
if (typeof value !== "string") return void 0;
|
|
2747
|
+
const expression = expressionAt(value, 0);
|
|
2748
|
+
return expression?.end === value.length ? expression.body : void 0;
|
|
2749
|
+
};
|
|
2750
|
+
const sparseEntryIsValid = (entry) => {
|
|
2751
|
+
let literalPath = "";
|
|
2752
|
+
let position = 0;
|
|
2753
|
+
while (position < entry.length) {
|
|
2754
|
+
const start = entry.indexOf("${{", position);
|
|
2755
|
+
const literal = entry.slice(position, start < 0 ? entry.length : start);
|
|
2756
|
+
if (/[!$*?[\]\\:]/.test(literal) || literal.split("/").includes("..")) return false;
|
|
2757
|
+
literalPath += literal;
|
|
2758
|
+
if (start < 0) break;
|
|
2759
|
+
const expression = expressionAt(entry, start);
|
|
2760
|
+
if (expression === void 0 || expression.body === "") return false;
|
|
2761
|
+
literalPath += "expression";
|
|
2762
|
+
position = expression.end;
|
|
2763
|
+
}
|
|
2764
|
+
return staticRepositoryPath(literalPath);
|
|
2765
|
+
};
|
|
2600
2766
|
const checkoutPrefixOf = (node) => {
|
|
2601
2767
|
const destination = getInput(node, "path");
|
|
2602
2768
|
if (destination === void 0) return ".";
|
|
@@ -2609,11 +2775,19 @@ const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, reposito
|
|
|
2609
2775
|
const value = getInput(node, "sparse-checkout");
|
|
2610
2776
|
const cone = getInput(node, "sparse-checkout-cone-mode");
|
|
2611
2777
|
if (!isScalar(value) || typeof value.value !== "string" || cone !== void 0 && (!isScalar(cone) || cone.value !== true && cone.value !== false && cone.value !== "true" && cone.value !== "false")) return { mode: "invalid" };
|
|
2612
|
-
const paths = value.value.split(/\r?\n/).map((entry) => entry.trim()).filter((entry) => entry !== "");
|
|
2613
|
-
if (paths.length === 0 || paths.some((entry) => !
|
|
2778
|
+
const paths = value.value.split(/\r?\n/).map((entry) => entry.trim().replace(/^\//, "")).filter((entry) => entry !== "");
|
|
2779
|
+
if (paths.length === 0 || paths.some((entry) => !sparseEntryIsValid(entry))) return { mode: "invalid" };
|
|
2780
|
+
const literalPaths = new Set(paths.filter((entry) => !dynamic(entry)));
|
|
2781
|
+
if (paths.some(dynamic)) {
|
|
2782
|
+
if (literalPaths.size === 0) return { mode: "dynamic" };
|
|
2783
|
+
return {
|
|
2784
|
+
mode: "mixed",
|
|
2785
|
+
paths: literalPaths
|
|
2786
|
+
};
|
|
2787
|
+
}
|
|
2614
2788
|
return {
|
|
2615
2789
|
mode: "files",
|
|
2616
|
-
paths:
|
|
2790
|
+
paths: literalPaths
|
|
2617
2791
|
};
|
|
2618
2792
|
};
|
|
2619
2793
|
let currentEngineFloor;
|
|
@@ -2643,8 +2817,9 @@ const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, reposito
|
|
|
2643
2817
|
}
|
|
2644
2818
|
if (value.startsWith("./")) {
|
|
2645
2819
|
const selector = staticRepositoryPath(value) ? path.posix.normalize(value) : void 0;
|
|
2646
|
-
const
|
|
2647
|
-
|
|
2820
|
+
const providers = checkoutProvidersFor(selector);
|
|
2821
|
+
const binding = selector === void 0 ? void 0 : providers.at(-1);
|
|
2822
|
+
if (selector === void 0 || unknownCheckoutDestination || binding !== void 0 && binding.source !== "tracked" || hasAmbiguousCheckoutWriters(selector, providers)) add({
|
|
2648
2823
|
rule: "action-pins",
|
|
2649
2824
|
line,
|
|
2650
2825
|
message: "local actions require an unambiguous checkout of the current source snapshot"
|
|
@@ -2700,24 +2875,55 @@ const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, reposito
|
|
|
2700
2875
|
const checkoutRepository = getInput(node, "repository");
|
|
2701
2876
|
const checkoutRef = getInput(node, "ref");
|
|
2702
2877
|
const prefix = checkoutPrefixOf(node);
|
|
2878
|
+
const clean = getInput(node, "clean");
|
|
2879
|
+
const cleanIsResolved = clean === void 0 || isScalar(clean) && !dynamic(clean.value);
|
|
2703
2880
|
let repo;
|
|
2704
2881
|
if (checkoutRepository !== void 0) repo = isScalar(checkoutRepository) ? checkoutRepository.value : null;
|
|
2705
2882
|
const self = repo === void 0 || repo === "${{ github.repository }}" || typeof repo === "string" && repository !== void 0 && repo.toLowerCase() === repository.toLowerCase();
|
|
2706
2883
|
const workflowSource = repo === "${{ job.workflow_repository }}" && isScalar(checkoutRef) && checkoutRef.value === "${{ job.workflow_sha }}";
|
|
2707
|
-
const
|
|
2708
|
-
const
|
|
2884
|
+
const triggers = getNode(document.contents, "on");
|
|
2885
|
+
const callerContext = (() => {
|
|
2886
|
+
if (isMap(triggers)) return [...keysOf(triggers)];
|
|
2887
|
+
if (isSeq(triggers)) return triggers.items.map((event) => isScalar(event) ? event.value : void 0);
|
|
2888
|
+
if (isScalar(triggers)) return [triggers.value];
|
|
2889
|
+
return [];
|
|
2890
|
+
})().includes("workflow_call");
|
|
2891
|
+
let refValue = isScalar(checkoutRef) ? checkoutRef.value : void 0;
|
|
2892
|
+
if (callerContext && (repo === void 0 || repo === "${{ github.repository }}") && checkoutRef === void 0) refValue = "${{ github.sha }}";
|
|
2893
|
+
const currentRef = dynamicRefBody(refValue);
|
|
2894
|
+
const currentSource = self && !callerContext && (checkoutRef === void 0 || currentRef === "github.sha");
|
|
2709
2895
|
const dynamicRef = dynamicRefBody(refValue);
|
|
2710
|
-
const delegatedSource = (self || repo === "${{ job.workflow_repository }}") && dynamicRef !== void 0 && dynamicRef !== "" && !/^(?:'(?:[^']|'')*'|"(?:[^"\\]|\\.)*"|(?:true|false|null)|-?(?:0x[\da-f]+|(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?))$/i.test(dynamicRef) && !/^(?:github\s*(?:\.\s*sha|\[\s*['"]sha['"]\s*\])|job\s*(?:\.\s*workflow_sha|\[\s*['"]workflow_sha['"]\s*\]))$/i.test(dynamicRef);
|
|
2896
|
+
const delegatedSource = !currentSource && (self || repo === "${{ job.workflow_repository }}") && dynamicRef !== void 0 && dynamicRef !== "" && !/^(?:'(?:[^']|'')*'|"(?:[^"\\]|\\.)*"|(?:true|false|null)|-?(?:0x[\da-f]+|(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?))$/i.test(dynamicRef) && (!/^(?:github\s*(?:\.\s*sha|\[\s*['"]sha['"]\s*\])|job\s*(?:\.\s*workflow_sha|\[\s*['"]workflow_sha['"]\s*\]))$/i.test(dynamicRef) || callerContext && currentRef === "github.sha");
|
|
2897
|
+
const validSource = cleanIsResolved && ref === approved?.sha && sourceMap({
|
|
2898
|
+
node,
|
|
2899
|
+
key: "if"
|
|
2900
|
+
}) === void 0 && sourceMap({
|
|
2901
|
+
node,
|
|
2902
|
+
key: "continue-on-error"
|
|
2903
|
+
}) === void 0 && (currentSource || workflowSource || delegatedSource);
|
|
2904
|
+
if (!validSource) unreviewableCheckoutSource = true;
|
|
2711
2905
|
if (!staticRepositoryPath(prefix)) checkoutBindings.push({
|
|
2712
2906
|
path: ".",
|
|
2907
|
+
dynamicSource: validSource && dynamic(prefix),
|
|
2713
2908
|
source: "untrusted",
|
|
2714
2909
|
reason: "checkout destination must be a static repository path",
|
|
2715
2910
|
line,
|
|
2716
|
-
sparse:
|
|
2911
|
+
sparse: sparseCheckoutOf(node)
|
|
2717
2912
|
});
|
|
2718
2913
|
else {
|
|
2719
2914
|
const destination = normalizeCheckoutPath(prefix);
|
|
2720
|
-
|
|
2915
|
+
checkoutBindings = checkoutBindings.map((entry) => {
|
|
2916
|
+
if (entry.path === destination || destination !== "." && !entry.path.startsWith(`${destination}/`)) return entry;
|
|
2917
|
+
return {
|
|
2918
|
+
path: entry.path,
|
|
2919
|
+
sparse: { mode: "all" },
|
|
2920
|
+
dynamicSource: false,
|
|
2921
|
+
source: "untrusted",
|
|
2922
|
+
reason: "a later ancestor checkout invalidates this descendant destination",
|
|
2923
|
+
line
|
|
2924
|
+
};
|
|
2925
|
+
});
|
|
2926
|
+
const trustedWriter = cleanIsResolved && ref === approved?.sha && sourceMap({
|
|
2721
2927
|
node,
|
|
2722
2928
|
key: "if"
|
|
2723
2929
|
}) === void 0 && sourceMap({
|
|
@@ -2726,18 +2932,21 @@ const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, reposito
|
|
|
2726
2932
|
}) === void 0 && checkoutDestinations.get(destination) === 1 && !unknownCheckoutDestination;
|
|
2727
2933
|
if (trustedWriter && delegatedSource && typeof refValue === "string") checkoutBindings.push({
|
|
2728
2934
|
path: destination,
|
|
2935
|
+
dynamicSource: validSource && delegatedSource,
|
|
2729
2936
|
sparse: sparseCheckoutOf(node),
|
|
2730
2937
|
source: "delegated",
|
|
2731
2938
|
ref: refValue
|
|
2732
2939
|
});
|
|
2733
2940
|
else if (trustedWriter && (workflowSource || currentSource)) checkoutBindings.push({
|
|
2734
2941
|
path: destination,
|
|
2942
|
+
dynamicSource: validSource && delegatedSource,
|
|
2735
2943
|
sparse: sparseCheckoutOf(node),
|
|
2736
2944
|
source: "tracked"
|
|
2737
2945
|
});
|
|
2738
2946
|
else {
|
|
2739
2947
|
let reason = "checkout does not select a trusted repository source snapshot";
|
|
2740
|
-
if (
|
|
2948
|
+
if (!cleanIsResolved) reason = "checkout clean must be statically resolved";
|
|
2949
|
+
else if (ref !== approved?.sha) reason = "actions/checkout must use the approved action SHA";
|
|
2741
2950
|
else if (sourceMap({
|
|
2742
2951
|
node,
|
|
2743
2952
|
key: "if"
|
|
@@ -2748,6 +2957,7 @@ const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, reposito
|
|
|
2748
2957
|
else if (checkoutDestinations.get(destination) !== 1 || unknownCheckoutDestination) reason = "checkout destination must have exactly one static writer";
|
|
2749
2958
|
checkoutBindings.push({
|
|
2750
2959
|
path: destination,
|
|
2960
|
+
dynamicSource: validSource && delegatedSource,
|
|
2751
2961
|
sparse: sparseCheckoutOf(node),
|
|
2752
2962
|
source: "untrusted",
|
|
2753
2963
|
reason,
|
|
@@ -2796,21 +3006,23 @@ const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, reposito
|
|
|
2796
3006
|
line: reference === void 0 ? line : nodeLine(reference)
|
|
2797
3007
|
});
|
|
2798
3008
|
};
|
|
2799
|
-
const checkContainer = (node) => {
|
|
3009
|
+
const checkContainer = (node, at) => {
|
|
2800
3010
|
node = resolveNode(node);
|
|
2801
3011
|
if (node === void 0) return;
|
|
2802
3012
|
const image = isMap(node) ? getNode(node, "image") : node;
|
|
2803
3013
|
checkRuntimeImage({
|
|
2804
3014
|
image: isScalar(image) ? image.value : void 0,
|
|
2805
3015
|
line: nodeLine(image ?? node),
|
|
2806
|
-
label: "container image"
|
|
3016
|
+
label: "container image",
|
|
3017
|
+
at
|
|
2807
3018
|
});
|
|
2808
3019
|
};
|
|
2809
|
-
const checkSteps = (node, floor) => {
|
|
3020
|
+
const checkSteps = (node, locationPrefix, floor) => {
|
|
2810
3021
|
currentEngineFloor = void 0;
|
|
2811
3022
|
checkoutBindings = [];
|
|
2812
3023
|
checkoutDestinations = /* @__PURE__ */ new Map();
|
|
2813
3024
|
unknownCheckoutDestination = false;
|
|
3025
|
+
unreviewableCheckoutSource = false;
|
|
2814
3026
|
node = resolveNode(node);
|
|
2815
3027
|
if (!isSeq(node)) return;
|
|
2816
3028
|
for (const step of node.items) {
|
|
@@ -2826,9 +3038,14 @@ const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, reposito
|
|
|
2826
3038
|
}
|
|
2827
3039
|
currentEngineFloor = floor;
|
|
2828
3040
|
try {
|
|
2829
|
-
for (const step of node.items)
|
|
3041
|
+
for (const step of node.items) {
|
|
3042
|
+
const id = getNode(step, "id");
|
|
3043
|
+
currentLocation = isScalar(id) && typeof id.value === "string" ? `${locationPrefix}.steps.${id.value}` : void 0;
|
|
3044
|
+
checkAction(step);
|
|
3045
|
+
}
|
|
2830
3046
|
} finally {
|
|
2831
3047
|
currentEngineFloor = void 0;
|
|
3048
|
+
currentLocation = void 0;
|
|
2832
3049
|
}
|
|
2833
3050
|
};
|
|
2834
3051
|
try {
|
|
@@ -2842,15 +3059,15 @@ const checkRuntimeFile = ({ file, text, policy, trackedFiles, readFile, reposito
|
|
|
2842
3059
|
currentEngineFloor = void 0;
|
|
2843
3060
|
const job = getNode(jobs, key);
|
|
2844
3061
|
checkAction(job);
|
|
2845
|
-
checkContainer(getNode(job, "container"));
|
|
3062
|
+
checkContainer(getNode(job, "container"), `jobs.${key}.container`);
|
|
2846
3063
|
const services = getNode(job, "services");
|
|
2847
|
-
if (isMap(services)) for (const service of keysOf(services)) checkContainer(getNode(services, service));
|
|
2848
|
-
checkSteps(getNode(job, "steps"), engineFloors.find((entry) => entry.job === key));
|
|
3064
|
+
if (isMap(services)) for (const service of keysOf(services)) checkContainer(getNode(services, service), `jobs.${key}.services.${service}`);
|
|
3065
|
+
checkSteps(getNode(job, "steps"), `jobs.${key}`, engineFloors.find((entry) => entry.job === key));
|
|
2849
3066
|
}
|
|
2850
3067
|
} else {
|
|
2851
3068
|
const runs = getNode(document.contents, "runs");
|
|
2852
3069
|
const using = getNode(runs, "using");
|
|
2853
|
-
if (isScalar(using) && using.value === "composite") checkSteps(getNode(runs, "steps"));
|
|
3070
|
+
if (isScalar(using) && using.value === "composite") checkSteps(getNode(runs, "steps"), "runs");
|
|
2854
3071
|
if (isScalar(using) && using.value === "docker") {
|
|
2855
3072
|
const image = getNode(runs, "image");
|
|
2856
3073
|
const value = isScalar(image) ? image.value : void 0;
|
|
@@ -3079,7 +3296,7 @@ const record = (value) => typeof value === "object" && value !== null && !Array.
|
|
|
3079
3296
|
const array = (value) => Array.isArray(value);
|
|
3080
3297
|
/** Repository-owned declarations are explicit; malformed decisions fail closed. */
|
|
3081
3298
|
const parseToolchainConfiguration = (input) => {
|
|
3082
|
-
if (!record(input) || Object.keys(input).some((key) => key !== "optOuts" && key !== "cloud" && key !== "engineFloors") || input["optOuts"] !== void 0 && !array(input["optOuts"])) throw new Error("stll-toolchain.json accepts only optOuts, cloud and
|
|
3299
|
+
if (!record(input) || Object.keys(input).some((key) => key !== "optOuts" && key !== "cloud" && key !== "engineFloors" && key !== "dynamicSelectors") || input["optOuts"] !== void 0 && !array(input["optOuts"])) throw new Error("stll-toolchain.json accepts only optOuts, cloud, engineFloors and dynamicSelectors declarations");
|
|
3083
3300
|
const disabled = /* @__PURE__ */ new Set();
|
|
3084
3301
|
const optOuts = input["optOuts"] ?? [];
|
|
3085
3302
|
if (!array(optOuts)) throw new Error("optOuts must be an array");
|
|
@@ -3092,7 +3309,8 @@ const parseToolchainConfiguration = (input) => {
|
|
|
3092
3309
|
return {
|
|
3093
3310
|
disabled,
|
|
3094
3311
|
cloud: parseCloudSetup(input["cloud"]),
|
|
3095
|
-
engineFloors: parseEngineFloors(input["engineFloors"])
|
|
3312
|
+
engineFloors: parseEngineFloors(input["engineFloors"]),
|
|
3313
|
+
dynamicSelectors: parseDynamicSelectors(input["dynamicSelectors"])
|
|
3096
3314
|
};
|
|
3097
3315
|
};
|
|
3098
3316
|
/** Read one tracked configuration snapshot for guards and policy generation. */
|
|
@@ -3158,10 +3376,12 @@ const checkToolchain = ({ root, policy, onDelegated }) => {
|
|
|
3158
3376
|
let disabled = /* @__PURE__ */ new Set();
|
|
3159
3377
|
let cloud;
|
|
3160
3378
|
const engineFloors = [];
|
|
3379
|
+
let dynamicSelectors = [];
|
|
3161
3380
|
if (files["stll-toolchain.json"] !== void 0) try {
|
|
3162
3381
|
const configuration = parseToolchainConfiguration(JSON.parse(files["stll-toolchain.json"]));
|
|
3163
3382
|
disabled = configuration.disabled;
|
|
3164
3383
|
cloud = configuration.cloud;
|
|
3384
|
+
dynamicSelectors = configuration.dynamicSelectors;
|
|
3165
3385
|
for (const entry of configuration.engineFloors) engineFloors.push(resolveEngineFloor(entry, files));
|
|
3166
3386
|
} catch (error) {
|
|
3167
3387
|
diagnostics.push({
|
|
@@ -3201,6 +3421,7 @@ const checkToolchain = ({ root, policy, onDelegated }) => {
|
|
|
3201
3421
|
}
|
|
3202
3422
|
const exercisedFloors = /* @__PURE__ */ new Set();
|
|
3203
3423
|
const mismatchedFloors = /* @__PURE__ */ new Set();
|
|
3424
|
+
const selectorMatches = /* @__PURE__ */ new Map();
|
|
3204
3425
|
diagnostics.push(...checkPackageFiles({
|
|
3205
3426
|
files,
|
|
3206
3427
|
policy
|
|
@@ -3216,6 +3437,8 @@ const checkToolchain = ({ root, policy, onDelegated }) => {
|
|
|
3216
3437
|
}
|
|
3217
3438
|
},
|
|
3218
3439
|
trackedFiles,
|
|
3440
|
+
dynamicSelectors: dynamicSelectors.filter((entry) => entry.path === file),
|
|
3441
|
+
onDynamicSelector: (entry) => selectorMatches.set(entry, (selectorMatches.get(entry) ?? 0) + 1),
|
|
3219
3442
|
repository,
|
|
3220
3443
|
onDelegated,
|
|
3221
3444
|
engineFloors: engineFloors.filter((entry) => entry.workflow === file),
|
|
@@ -3231,6 +3454,12 @@ const checkToolchain = ({ root, policy, onDelegated }) => {
|
|
|
3231
3454
|
line: 1,
|
|
3232
3455
|
message: `engine floor ${entry.workflow}:${entry.job} requires a setup-node exact patch satisfying ${entry.package} engines.node minimum major ${entry.major}`
|
|
3233
3456
|
});
|
|
3457
|
+
for (const entry of dynamicSelectors) if (selectorMatches.get(entry) !== 1) diagnostics.push({
|
|
3458
|
+
rule: "configuration",
|
|
3459
|
+
path: "stll-toolchain.json",
|
|
3460
|
+
line: 1,
|
|
3461
|
+
message: `dynamic selector ${entry.path}:${entry.at ?? entry.line} must match exactly one unresolved ${entry.kind} selector`
|
|
3462
|
+
});
|
|
3234
3463
|
diagnostics.push(...checkDependabot({
|
|
3235
3464
|
files,
|
|
3236
3465
|
policy: policy.dependabot
|