mandrel-platform 1.13.0 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/package.json +4 -2
- package/scripts/audit-check.mjs +210 -47
- package/scripts/audit-check.test.mjs +479 -49
- package/scripts/check-action-download-retries.mjs +155 -11
- package/scripts/check-action-download-retries.test.mjs +168 -3
- package/scripts/check-advisory-scan-setup.test.mjs +186 -5
- package/scripts/check-coverage-threshold.mjs +112 -17
- package/scripts/check-coverage-threshold.test.mjs +335 -0
- package/scripts/check-husky-hook-modes.test.mjs +134 -0
- package/scripts/check-runner-runs-on.test.mjs +223 -2
- package/scripts/check-semgrep-lockfile.test.mjs +238 -7
- package/scripts/check-workflow-lint-tier.test.mjs +176 -0
- package/scripts/check-workflow-portability.mjs +6 -3
- package/scripts/check-workflow-portability.test.mjs +1 -1
- package/scripts/env-doctor.mjs +493 -62
- package/scripts/env-doctor.test.mjs +529 -3
- package/scripts/fixtures/npm-audit-v2.json +66 -0
- package/scripts/install-git-hooks.mjs +123 -0
- package/scripts/install-git-hooks.test.mjs +160 -0
- package/scripts/issue-intake.test.mjs +351 -21
- package/scripts/runner-toggle.test.mjs +407 -0
- package/scripts/select-semgrep-python.sh +98 -17
- package/scripts/select-semgrep-python.test.mjs +139 -12
- package/scripts/update-semgrep-rules.mjs +83 -5
- package/scripts/update-semgrep-rules.test.mjs +55 -1
- package/scripts/workflow-lint-gate.test.mjs +128 -0
package/scripts/env-doctor.mjs
CHANGED
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
*
|
|
14
14
|
* The five surfaces:
|
|
15
15
|
*
|
|
16
|
-
* 1. local — `.env
|
|
16
|
+
* 1. local — `.env.example` in the caller repo (the onboarding
|
|
17
|
+
* contract). The doctor never reads a developer's real
|
|
18
|
+
* local file: it holds live values, and a surface this
|
|
19
|
+
* script reports on must be one every run can see.
|
|
17
20
|
* 2. wrangler — `[vars]` in each Worker's wrangler config
|
|
18
21
|
* 3. github — Actions secret/variable NAMES (repo + environment scope)
|
|
19
22
|
* 4. cloudflare— Worker secret NAMES per resolved script name
|
|
@@ -666,27 +669,181 @@ export function checkShape({ value, shape, placeholderPattern = null }) {
|
|
|
666
669
|
// ---------------------------------------------------------------------------
|
|
667
670
|
|
|
668
671
|
/**
|
|
669
|
-
*
|
|
670
|
-
*
|
|
672
|
+
* Production request bounds. Every one is a CLIENT-CONSTRUCTOR option so the
|
|
673
|
+
* sibling suite can pass millisecond-scale values: a suite that had to wait
|
|
674
|
+
* out the real budget would simply not assert the timeout at all.
|
|
675
|
+
*
|
|
676
|
+
* A nightly drift gate hanging on one unresponsive store is the fail-open this
|
|
677
|
+
* module exists to refuse in a slower disguise — the job burns its runner
|
|
678
|
+
* minutes and reports nothing, which reads in the Actions UI as a run that has
|
|
679
|
+
* not finished rather than a probe that failed.
|
|
680
|
+
*/
|
|
681
|
+
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
682
|
+
export const DEFAULT_RETRY_DELAY_MS = 500;
|
|
683
|
+
|
|
684
|
+
/** Attempts per request, INCLUDING the first. */
|
|
685
|
+
export const MAX_ATTEMPTS = 3;
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Pages a single listing may follow before the probe fails closed. A store
|
|
689
|
+
* that keeps handing back a `rel="next"` is malfunctioning, and truncating its
|
|
690
|
+
* listing silently would report every un-fetched name as an orphan-free match
|
|
691
|
+
* — the same "no drift because we stopped looking" this module refuses.
|
|
692
|
+
*/
|
|
693
|
+
export const MAX_PAGES = 50;
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Which HTTP failures are worth a second attempt. 429 and 5xx are transient by
|
|
697
|
+
* definition; everything else is a statement about the request itself. Retrying
|
|
698
|
+
* a 401 just spends the budget three times to learn what the first attempt said,
|
|
699
|
+
* and retrying a 404 would fight the one degradation rule this module allows.
|
|
700
|
+
*
|
|
701
|
+
* @param {number} status
|
|
702
|
+
* @returns {boolean}
|
|
703
|
+
*/
|
|
704
|
+
export function isRetryableStatus(status) {
|
|
705
|
+
return status === 429 || (status >= 500 && status <= 599);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* @param {number} ms
|
|
710
|
+
* @returns {Promise<void>}
|
|
711
|
+
*/
|
|
712
|
+
function sleep(ms) {
|
|
713
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Fetch with a deadline that does not depend on the fetch honouring it.
|
|
718
|
+
*
|
|
719
|
+
* An `AbortSignal` alone is a REQUEST to stop, and it is only as good as the
|
|
720
|
+
* implementation reading it — a stub, a polyfill, or a wrapper that rebuilds
|
|
721
|
+
* `init` can drop `init.signal` without any error, and the await then never
|
|
722
|
+
* returns. So the signal is passed (real `fetch` uses it to release the socket)
|
|
723
|
+
* AND raced against a timer, and the timer is what actually bounds the call.
|
|
724
|
+
*
|
|
725
|
+
* @param {typeof fetch} fetchImpl
|
|
726
|
+
* @param {string} url
|
|
727
|
+
* @param {RequestInit} init
|
|
728
|
+
* @param {number} timeoutMs
|
|
729
|
+
* @returns {Promise<Response>}
|
|
730
|
+
*/
|
|
731
|
+
async function fetchWithTimeout(fetchImpl, url, init, timeoutMs) {
|
|
732
|
+
const controller = new AbortController();
|
|
733
|
+
let timer = null;
|
|
734
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
735
|
+
timer = setTimeout(() => {
|
|
736
|
+
controller.abort();
|
|
737
|
+
const err = new Error(`${init.method ?? "GET"} ${redactUrl(url)} timed out after ${timeoutMs}ms`);
|
|
738
|
+
err.timedOut = true;
|
|
739
|
+
reject(err);
|
|
740
|
+
}, timeoutMs);
|
|
741
|
+
});
|
|
742
|
+
// Resolve.then keeps a fetchImpl that THROWS synchronously on the same
|
|
743
|
+
// rejection path as one that returns a rejected promise.
|
|
744
|
+
const pending = Promise.resolve().then(() => fetchImpl(url, { ...init, signal: controller.signal }));
|
|
745
|
+
// The loser of the race still settles. Absorbing its rejection here is what
|
|
746
|
+
// keeps a post-timeout abort from surfacing as an unhandled rejection and
|
|
747
|
+
// tearing down a process that has already handled the timeout.
|
|
748
|
+
pending.catch(() => {});
|
|
749
|
+
try {
|
|
750
|
+
return await Promise.race([pending, deadline]);
|
|
751
|
+
} finally {
|
|
752
|
+
if (timer !== null) clearTimeout(timer);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Perform one bounded, retried request and return the raw `Response`.
|
|
758
|
+
*
|
|
759
|
+
* Any HTTP failure is tagged with `.httpStatus`, so a caller can apply the
|
|
760
|
+
* 404-only degradation rule (see the module docblock).
|
|
761
|
+
*
|
|
762
|
+
* **A timeout is never retried.** Retrying it would multiply the wall clock by
|
|
763
|
+
* the attempt count, and the surface's whole contract is that it fails within
|
|
764
|
+
* its budget rather than eventually.
|
|
671
765
|
*
|
|
672
766
|
* @param {typeof fetch} fetchImpl
|
|
673
767
|
* @param {string} url
|
|
674
768
|
* @param {RequestInit} [init]
|
|
675
|
-
* @
|
|
769
|
+
* @param {{timeoutMs?: number, retryDelayMs?: number, maxAttempts?: number}} [options]
|
|
770
|
+
* @returns {Promise<Response>}
|
|
676
771
|
*/
|
|
677
|
-
export async function
|
|
678
|
-
const
|
|
679
|
-
|
|
772
|
+
export async function requestResponse(fetchImpl, url, init = {}, options = {}) {
|
|
773
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
774
|
+
const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
|
775
|
+
const maxAttempts = options.maxAttempts ?? MAX_ATTEMPTS;
|
|
776
|
+
|
|
777
|
+
let lastError = null;
|
|
778
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
779
|
+
let res;
|
|
780
|
+
try {
|
|
781
|
+
res = await fetchWithTimeout(fetchImpl, url, init, timeoutMs);
|
|
782
|
+
} catch (err) {
|
|
783
|
+
if (err?.timedOut) throw err;
|
|
784
|
+
lastError = err;
|
|
785
|
+
if (attempt === maxAttempts) throw err;
|
|
786
|
+
await sleep(retryDelayMs * 2 ** (attempt - 1));
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (res.ok) return res;
|
|
680
790
|
// The body can echo request context; it is never a secret VALUE (these are
|
|
681
791
|
// name-listing endpoints), but it is also not needed — the status is what
|
|
682
792
|
// routes the decision, so only the status and a redacted URL are surfaced.
|
|
683
793
|
const err = new Error(`${init.method ?? "GET"} ${redactUrl(url)} failed: ${res.status} ${res.statusText}`);
|
|
684
794
|
err.httpStatus = res.status;
|
|
685
|
-
|
|
795
|
+
lastError = err;
|
|
796
|
+
if (!isRetryableStatus(res.status) || attempt === maxAttempts) throw err;
|
|
797
|
+
await sleep(retryDelayMs * 2 ** (attempt - 1));
|
|
686
798
|
}
|
|
799
|
+
/* c8 ignore next 2 -- unreachable: every loop exit above returns or throws. */
|
|
800
|
+
throw lastError ?? new Error(`${redactUrl(url)} failed with no attempt recorded`);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* `requestResponse`, decoded as JSON — what every non-paginating call wants.
|
|
805
|
+
*
|
|
806
|
+
* @param {typeof fetch} fetchImpl
|
|
807
|
+
* @param {string} url
|
|
808
|
+
* @param {RequestInit} [init]
|
|
809
|
+
* @param {{timeoutMs?: number, retryDelayMs?: number, maxAttempts?: number}} [options]
|
|
810
|
+
* @returns {Promise<unknown>}
|
|
811
|
+
*/
|
|
812
|
+
export async function requestJson(fetchImpl, url, init = {}, options = {}) {
|
|
813
|
+
const res = await requestResponse(fetchImpl, url, init, options);
|
|
687
814
|
return res.json();
|
|
688
815
|
}
|
|
689
816
|
|
|
817
|
+
/**
|
|
818
|
+
* The `rel="next"` URL of an RFC 8288 `Link` header, or `null`.
|
|
819
|
+
*
|
|
820
|
+
* Parsed by splitting rather than by regex, deliberately twice over: a pattern
|
|
821
|
+
* over a header carrying a URL is the shape CodeQL flags as an unanchored host
|
|
822
|
+
* match, and the grammar here — comma-separated `<uri>; param=value` — is
|
|
823
|
+
* cleanly separable without one.
|
|
824
|
+
*
|
|
825
|
+
* @param {string | null | undefined} header
|
|
826
|
+
* @returns {string | null}
|
|
827
|
+
*/
|
|
828
|
+
export function linkNextUrl(header) {
|
|
829
|
+
if (typeof header !== "string" || header.length === 0) return null;
|
|
830
|
+
for (const part of header.split(",")) {
|
|
831
|
+
const segment = part.trim();
|
|
832
|
+
if (!segment.startsWith("<")) continue;
|
|
833
|
+
const close = segment.indexOf(">");
|
|
834
|
+
if (close === -1) continue;
|
|
835
|
+
const url = segment.slice(1, close);
|
|
836
|
+
for (const param of segment.slice(close + 1).split(";")) {
|
|
837
|
+
const [rawName, ...rest] = param.split("=");
|
|
838
|
+
if (rawName.trim().toLowerCase() !== "rel") continue;
|
|
839
|
+
let value = rest.join("=").trim();
|
|
840
|
+
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
|
|
841
|
+
if (value.toLowerCase() === "next" && url.length > 0) return url;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
|
|
690
847
|
/**
|
|
691
848
|
* Strip the query string from a URL before it reaches a log line. Query
|
|
692
849
|
* parameters carry project ids and environment slugs, and a future caller
|
|
@@ -718,8 +875,9 @@ export function isAbsentStatus(err) {
|
|
|
718
875
|
|
|
719
876
|
/**
|
|
720
877
|
* Parse a dotenv-style file into a name -> value map. Used for the local
|
|
721
|
-
* surface
|
|
722
|
-
*
|
|
878
|
+
* surface, whose only input is the committed `.env.example` — a placeholder
|
|
879
|
+
* file by construction. Values are parsed because the format has them, and
|
|
880
|
+
* only names cross the boundary.
|
|
723
881
|
*
|
|
724
882
|
* @param {string} text
|
|
725
883
|
* @returns {Record<string, string>}
|
|
@@ -745,26 +903,98 @@ export function parseDotenv(text) {
|
|
|
745
903
|
return out;
|
|
746
904
|
}
|
|
747
905
|
|
|
906
|
+
/**
|
|
907
|
+
* Reduce a JSONC document to JSON: drop `//` and block comments, drop trailing
|
|
908
|
+
* commas, and leave everything inside a string literal untouched.
|
|
909
|
+
*
|
|
910
|
+
* A character scanner rather than a substitution, for two independent reasons.
|
|
911
|
+
* The correctness one: a pattern cannot tell a `//` that opens a comment from
|
|
912
|
+
* one inside `"https://example.test"`, and the line-comment substitution this
|
|
913
|
+
* replaces truncated exactly that value — quietly, since the result usually
|
|
914
|
+
* still parsed. The policy one: this repo's SAST refuses a dynamically built
|
|
915
|
+
* `RegExp` outright, so the parsing rules a config like this needs are written
|
|
916
|
+
* as code or not at all.
|
|
917
|
+
*
|
|
918
|
+
* `wrangler.jsonc` is a real shape, not a hypothetical: create-cloudflare's own
|
|
919
|
+
* template emits trailing commas, and until Story #487 every one of them made
|
|
920
|
+
* this function return an empty set — which the caller then read as "this
|
|
921
|
+
* worker declares no vars", the false no-drift verdict.
|
|
922
|
+
*
|
|
923
|
+
* @param {string} text
|
|
924
|
+
* @returns {string}
|
|
925
|
+
*/
|
|
926
|
+
export function stripJsonc(text) {
|
|
927
|
+
let out = "";
|
|
928
|
+
let inString = false;
|
|
929
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
930
|
+
const ch = text[i];
|
|
931
|
+
if (inString) {
|
|
932
|
+
out += ch;
|
|
933
|
+
if (ch === "\\") {
|
|
934
|
+
out += text[i + 1] ?? "";
|
|
935
|
+
i += 1;
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
if (ch === '"') inString = false;
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
if (ch === '"') {
|
|
942
|
+
inString = true;
|
|
943
|
+
out += ch;
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
947
|
+
while (i < text.length && text[i] !== "\n") i += 1;
|
|
948
|
+
// Keep the newline: JSON ignores it, but a preserved line count keeps a
|
|
949
|
+
// `JSON.parse` position error pointing at the author's own line.
|
|
950
|
+
out += "\n";
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
if (ch === "/" && text[i + 1] === "*") {
|
|
954
|
+
i += 2;
|
|
955
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) {
|
|
956
|
+
if (text[i] === "\n") out += "\n";
|
|
957
|
+
i += 1;
|
|
958
|
+
}
|
|
959
|
+
i += 1;
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
if (ch === ",") {
|
|
963
|
+
let j = i + 1;
|
|
964
|
+
while (j < text.length && (text[j] === " " || text[j] === "\t" || text[j] === "\n" || text[j] === "\r")) j += 1;
|
|
965
|
+
if (text[j] === "}" || text[j] === "]") continue;
|
|
966
|
+
}
|
|
967
|
+
out += ch;
|
|
968
|
+
}
|
|
969
|
+
return out;
|
|
970
|
+
}
|
|
971
|
+
|
|
748
972
|
/**
|
|
749
973
|
* Extract the `[vars]` block key names from a wrangler config. Supports both
|
|
750
974
|
* TOML (`[vars]` / `[env.<name>.vars]`) and JSON/JSONC (`"vars": {…}`) — the
|
|
751
975
|
* two shapes wrangler accepts.
|
|
752
976
|
*
|
|
977
|
+
* **Throws** when a `.json`/`.jsonc` config cannot be parsed even after the
|
|
978
|
+
* JSONC reduction. Returning `[]` there — as this did until Story #487 — is
|
|
979
|
+
* indistinguishable from a config that genuinely declares nothing, so the
|
|
980
|
+
* caller reported no drift precisely because it could not read the file. The
|
|
981
|
+
* caller turns the throw into one `fail` finding on the `wrangler` surface.
|
|
982
|
+
*
|
|
753
983
|
* @param {string} text
|
|
754
984
|
* @param {string} path Used only to pick the parser by extension.
|
|
755
985
|
* @returns {string[]} Sorted var names.
|
|
986
|
+
* @throws {Error} With `.wranglerParseFailure === true` on unparseable JSONC.
|
|
756
987
|
*/
|
|
757
988
|
export function parseWranglerVars(text, path) {
|
|
758
989
|
const names = new Set();
|
|
759
990
|
if (/\.jsonc?$/.test(path)) {
|
|
760
|
-
// Strip line comments so JSONC parses; block comments are not used by
|
|
761
|
-
// wrangler's own generated configs.
|
|
762
|
-
const stripped = text.replace(/^\s*\/\/.*$/gm, "");
|
|
763
991
|
let doc;
|
|
764
992
|
try {
|
|
765
|
-
doc = JSON.parse(
|
|
766
|
-
} catch {
|
|
767
|
-
|
|
993
|
+
doc = JSON.parse(stripJsonc(text));
|
|
994
|
+
} catch (err) {
|
|
995
|
+
const failure = new Error(`is not parseable as JSON/JSONC even after comment and trailing-comma removal: ${err.message}`);
|
|
996
|
+
failure.wranglerParseFailure = true;
|
|
997
|
+
throw failure;
|
|
768
998
|
}
|
|
769
999
|
collectJsonVars(doc, names);
|
|
770
1000
|
return [...names].sort();
|
|
@@ -809,41 +1039,73 @@ function collectJsonVars(node, names) {
|
|
|
809
1039
|
* access to either collection, so the default token can never serve this probe
|
|
810
1040
|
* (verified against the workflow-syntax permissions reference, 2026-09).
|
|
811
1041
|
*
|
|
1042
|
+
* `per_page=100` bounds a PAGE, not the collection. A repository with more
|
|
1043
|
+
* than a hundred Actions secrets returns the first hundred and a `Link` header
|
|
1044
|
+
* naming the rest, and reading only page one reports every name beyond it as
|
|
1045
|
+
* `missing` while every genuine orphan past the boundary goes unseen — drift
|
|
1046
|
+
* invented and drift hidden by the same omission. So every listing follows
|
|
1047
|
+
* `rel="next"` to completion, and a listing that will not end fails closed.
|
|
1048
|
+
*
|
|
812
1049
|
* @param {object} opts
|
|
813
1050
|
* @param {string} opts.token
|
|
814
1051
|
* @param {string} opts.repo "owner/name"
|
|
815
1052
|
* @param {typeof fetch} [opts.fetchImpl]
|
|
816
1053
|
* @param {string} [opts.apiBase]
|
|
1054
|
+
* @param {number} [opts.timeoutMs]
|
|
1055
|
+
* @param {number} [opts.retryDelayMs]
|
|
817
1056
|
*/
|
|
818
|
-
export function createGitHubClient({
|
|
1057
|
+
export function createGitHubClient({
|
|
1058
|
+
token,
|
|
1059
|
+
repo,
|
|
1060
|
+
fetchImpl = fetch,
|
|
1061
|
+
apiBase = GITHUB_API_BASE,
|
|
1062
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
1063
|
+
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
|
|
1064
|
+
}) {
|
|
819
1065
|
const headers = {
|
|
820
1066
|
Authorization: `Bearer ${token}`,
|
|
821
1067
|
Accept: "application/vnd.github+json",
|
|
822
1068
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
823
1069
|
};
|
|
824
|
-
const
|
|
1070
|
+
const bounds = { timeoutMs, retryDelayMs };
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* @param {string} path
|
|
1074
|
+
* @param {(body: any) => Array<{name: string}>} pick
|
|
1075
|
+
* @returns {Promise<string[]>}
|
|
1076
|
+
*/
|
|
1077
|
+
async function listAll(path, pick) {
|
|
1078
|
+
const names = [];
|
|
1079
|
+
let url = `${apiBase}${path}`;
|
|
1080
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
1081
|
+
const res = await requestResponse(fetchImpl, url, { headers }, bounds);
|
|
1082
|
+
const body = await res.json();
|
|
1083
|
+
for (const entry of pick(body) ?? []) if (entry?.name) names.push(entry.name);
|
|
1084
|
+
const next = linkNextUrl(typeof res.headers?.get === "function" ? res.headers.get("link") : null);
|
|
1085
|
+
if (!next) return names.sort();
|
|
1086
|
+
url = next;
|
|
1087
|
+
}
|
|
1088
|
+
throw new Error(`${redactUrl(`${apiBase}${path}`)} still offered a rel="next" after ${MAX_PAGES} pages`);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* @param {string} prefix
|
|
1093
|
+
* @returns {Promise<{secret: string[], var: string[]}>}
|
|
1094
|
+
*/
|
|
1095
|
+
async function namesUnder(prefix) {
|
|
1096
|
+
const [secret, vars] = await Promise.all([
|
|
1097
|
+
listAll(`${prefix}/secrets?per_page=100`, (b) => b.secrets ?? []),
|
|
1098
|
+
listAll(`${prefix}/variables?per_page=100`, (b) => b.variables ?? []),
|
|
1099
|
+
]);
|
|
1100
|
+
return { secret, var: vars };
|
|
1101
|
+
}
|
|
825
1102
|
|
|
826
1103
|
return {
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
get(`/repos/${repo}/actions/secrets?per_page=100`),
|
|
830
|
-
get(`/repos/${repo}/actions/variables?per_page=100`),
|
|
831
|
-
]);
|
|
832
|
-
return {
|
|
833
|
-
secret: (secrets.secrets ?? []).map((s) => s.name).sort(),
|
|
834
|
-
var: (variables.variables ?? []).map((v) => v.name).sort(),
|
|
835
|
-
};
|
|
1104
|
+
repositoryNames() {
|
|
1105
|
+
return namesUnder(`/repos/${repo}/actions`);
|
|
836
1106
|
},
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
const [secrets, variables] = await Promise.all([
|
|
840
|
-
get(`/repos/${repo}/environments/${env}/secrets?per_page=100`),
|
|
841
|
-
get(`/repos/${repo}/environments/${env}/variables?per_page=100`),
|
|
842
|
-
]);
|
|
843
|
-
return {
|
|
844
|
-
secret: (secrets.secrets ?? []).map((s) => s.name).sort(),
|
|
845
|
-
var: (variables.variables ?? []).map((v) => v.name).sort(),
|
|
846
|
-
};
|
|
1107
|
+
environmentNames(environment) {
|
|
1108
|
+
return namesUnder(`/repos/${repo}/environments/${encodeURIComponent(environment)}`);
|
|
847
1109
|
},
|
|
848
1110
|
};
|
|
849
1111
|
}
|
|
@@ -857,21 +1119,71 @@ export function createGitHubClient({ token, repo, fetchImpl = fetch, apiBase = G
|
|
|
857
1119
|
* @param {string} opts.accountId
|
|
858
1120
|
* @param {typeof fetch} [opts.fetchImpl]
|
|
859
1121
|
* @param {string} [opts.apiBase]
|
|
1122
|
+
* @param {number} [opts.timeoutMs]
|
|
1123
|
+
* @param {number} [opts.retryDelayMs]
|
|
860
1124
|
*/
|
|
861
|
-
export function createCloudflareClient({
|
|
1125
|
+
export function createCloudflareClient({
|
|
1126
|
+
token,
|
|
1127
|
+
accountId,
|
|
1128
|
+
fetchImpl = fetch,
|
|
1129
|
+
apiBase = CLOUDFLARE_API_BASE,
|
|
1130
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
1131
|
+
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
|
|
1132
|
+
}) {
|
|
862
1133
|
const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
|
1134
|
+
const bounds = { timeoutMs, retryDelayMs };
|
|
863
1135
|
return {
|
|
864
1136
|
async secretNames(scriptName) {
|
|
865
1137
|
const body = await requestJson(
|
|
866
1138
|
fetchImpl,
|
|
867
1139
|
`${apiBase}/accounts/${encodeURIComponent(accountId)}/workers/scripts/${encodeURIComponent(scriptName)}/secrets`,
|
|
868
|
-
{ headers }
|
|
1140
|
+
{ headers },
|
|
1141
|
+
bounds
|
|
869
1142
|
);
|
|
870
1143
|
return (body.result ?? []).map((s) => s.name).sort();
|
|
871
1144
|
},
|
|
872
1145
|
};
|
|
873
1146
|
}
|
|
874
1147
|
|
|
1148
|
+
/**
|
|
1149
|
+
* Flatten a v4 secrets response into the secrets RESIDENT AT THE REQUESTED
|
|
1150
|
+
* FOLDER — its own entries plus everything reaching it through an import.
|
|
1151
|
+
*
|
|
1152
|
+
* The v4 list endpoint answers in two parts: `secrets[]` holds what the queried
|
|
1153
|
+
* folder defines itself, and a separate top-level `imports[]` holds one group
|
|
1154
|
+
* per import, each carrying the SOURCE folder in `secretPath`. Reading only the
|
|
1155
|
+
* first part is what made a folder that imports its whole content report every
|
|
1156
|
+
* key `missing` — the store had the secret, the Worker would resolve it, and
|
|
1157
|
+
* the doctor said it was absent.
|
|
1158
|
+
*
|
|
1159
|
+
* `imports[].secretPath` is deliberately DISCARDED rather than used to
|
|
1160
|
+
* re-attribute the name. The manifest declares where a key must be RESOLVABLE,
|
|
1161
|
+
* which is the folder the deploy reads; attributing an imported key back to
|
|
1162
|
+
* `/shared` would report it missing from the folder that legitimately resolves
|
|
1163
|
+
* it and orphaned in a folder the manifest never asked about.
|
|
1164
|
+
*
|
|
1165
|
+
* A name defined directly in the queried folder wins over an imported one of
|
|
1166
|
+
* the same name, matching Infisical's own precedence — so the shape stage
|
|
1167
|
+
* checks the value the deploy would actually see.
|
|
1168
|
+
*
|
|
1169
|
+
* @param {unknown} body
|
|
1170
|
+
* @returns {Array<{secretKey: string, secretValue?: string}>}
|
|
1171
|
+
*/
|
|
1172
|
+
export function collectInfisicalSecrets(body) {
|
|
1173
|
+
const merged = new Map();
|
|
1174
|
+
const add = (entry) => {
|
|
1175
|
+
if (entry && typeof entry.secretKey === "string" && entry.secretKey.length > 0) {
|
|
1176
|
+
merged.set(entry.secretKey, entry);
|
|
1177
|
+
}
|
|
1178
|
+
};
|
|
1179
|
+
const imports = Array.isArray(body?.imports) ? body.imports : [];
|
|
1180
|
+
for (const group of imports) {
|
|
1181
|
+
for (const entry of Array.isArray(group?.secrets) ? group.secrets : []) add(entry);
|
|
1182
|
+
}
|
|
1183
|
+
for (const entry of Array.isArray(body?.secrets) ? body.secrets : []) add(entry);
|
|
1184
|
+
return [...merged.values()];
|
|
1185
|
+
}
|
|
1186
|
+
|
|
875
1187
|
/**
|
|
876
1188
|
* Infisical client. Authenticates with a pre-issued access token when one is
|
|
877
1189
|
* supplied, otherwise with a Universal Auth machine identity.
|
|
@@ -891,6 +1203,8 @@ export function createCloudflareClient({ token, accountId, fetchImpl = fetch, ap
|
|
|
891
1203
|
* @param {string} opts.projectId
|
|
892
1204
|
* @param {string} [opts.siteUrl]
|
|
893
1205
|
* @param {typeof fetch} [opts.fetchImpl]
|
|
1206
|
+
* @param {number} [opts.timeoutMs]
|
|
1207
|
+
* @param {number} [opts.retryDelayMs]
|
|
894
1208
|
*/
|
|
895
1209
|
export function createInfisicalClient({
|
|
896
1210
|
token = null,
|
|
@@ -899,17 +1213,25 @@ export function createInfisicalClient({
|
|
|
899
1213
|
projectId,
|
|
900
1214
|
siteUrl = INFISICAL_DEFAULT_SITE,
|
|
901
1215
|
fetchImpl = fetch,
|
|
1216
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
1217
|
+
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
|
|
902
1218
|
}) {
|
|
903
1219
|
const base = siteUrl.replace(/\/+$/, "");
|
|
1220
|
+
const bounds = { timeoutMs, retryDelayMs };
|
|
904
1221
|
let accessToken = token;
|
|
905
1222
|
|
|
906
1223
|
async function auth() {
|
|
907
1224
|
if (accessToken) return accessToken;
|
|
908
|
-
const body = await requestJson(
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1225
|
+
const body = await requestJson(
|
|
1226
|
+
fetchImpl,
|
|
1227
|
+
`${base}/api/v1/auth/universal-auth/login`,
|
|
1228
|
+
{
|
|
1229
|
+
method: "POST",
|
|
1230
|
+
headers: { "Content-Type": "application/json" },
|
|
1231
|
+
body: JSON.stringify({ clientId, clientSecret }),
|
|
1232
|
+
},
|
|
1233
|
+
bounds
|
|
1234
|
+
);
|
|
913
1235
|
if (!body.accessToken) throw new Error("Infisical universal-auth login returned no accessToken");
|
|
914
1236
|
accessToken = body.accessToken;
|
|
915
1237
|
return accessToken;
|
|
@@ -922,11 +1244,19 @@ export function createInfisicalClient({
|
|
|
922
1244
|
environment,
|
|
923
1245
|
secretPath: folder || "/",
|
|
924
1246
|
viewSecretValue: withValues ? "true" : "false",
|
|
1247
|
+
// Sent EXPLICITLY. The upstream default is documented as true, and a
|
|
1248
|
+
// default is not a contract — a server-side change to it would silently
|
|
1249
|
+
// hide every imported secret and report each one `missing`, which is the
|
|
1250
|
+
// false-drift twin of the false no-drift this module is built against.
|
|
1251
|
+
includeImports: "true",
|
|
925
1252
|
});
|
|
926
|
-
const body = await requestJson(
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1253
|
+
const body = await requestJson(
|
|
1254
|
+
fetchImpl,
|
|
1255
|
+
`${base}/api/v4/secrets?${params.toString()}`,
|
|
1256
|
+
{ headers: { Authorization: `Bearer ${t}` } },
|
|
1257
|
+
bounds
|
|
1258
|
+
);
|
|
1259
|
+
return collectInfisicalSecrets(body);
|
|
930
1260
|
}
|
|
931
1261
|
|
|
932
1262
|
return {
|
|
@@ -954,17 +1284,35 @@ export function createInfisicalClient({
|
|
|
954
1284
|
* parse: a reference can appear anywhere an expression can, including inside
|
|
955
1285
|
* a `run:` block's shell, and a structural walk would miss those.
|
|
956
1286
|
*
|
|
1287
|
+
* Two narrowings keep that reach from over-claiming, and each one had produced
|
|
1288
|
+
* a manifest key that had to exist for a variable that does not:
|
|
1289
|
+
*
|
|
1290
|
+
* - **A whole-line YAML comment is prose, not a reference.** The `#` line
|
|
1291
|
+
* documenting which secret a caller should pass is the single most common
|
|
1292
|
+
* place either token appears, and demanding a manifest entry for it makes
|
|
1293
|
+
* the doctor fail on its own documentation.
|
|
1294
|
+
* - **`vars` reached as a property of something else is not the `vars`
|
|
1295
|
+
* context.** `steps.build.outputs.vars.PROFILE` is a step output that
|
|
1296
|
+
* happens to be named `vars`; `\b` matched it, because a `.` is a word
|
|
1297
|
+
* boundary. The lookbehind refuses any match preceded by a `.` or an
|
|
1298
|
+
* identifier character, which is exactly the set of ways a longer path
|
|
1299
|
+
* can end just before this one starts.
|
|
1300
|
+
*
|
|
957
1301
|
* @param {string} text
|
|
958
1302
|
* @returns {{secrets: string[], vars: string[]}}
|
|
959
1303
|
*/
|
|
960
1304
|
export function collectWorkflowReferences(text) {
|
|
961
1305
|
const secrets = new Set();
|
|
962
1306
|
const vars = new Set();
|
|
963
|
-
const
|
|
964
|
-
|
|
1307
|
+
const scannable = text
|
|
1308
|
+
.split(/\r?\n/)
|
|
1309
|
+
.map((line) => (line.trimStart().startsWith("#") ? "" : line))
|
|
1310
|
+
.join("\n");
|
|
1311
|
+
const re = /(?<![\w.])(secrets|vars)\.([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
1312
|
+
let m = re.exec(scannable);
|
|
965
1313
|
while (m !== null) {
|
|
966
1314
|
(m[1] === "secrets" ? secrets : vars).add(m[2]);
|
|
967
|
-
m = re.exec(
|
|
1315
|
+
m = re.exec(scannable);
|
|
968
1316
|
}
|
|
969
1317
|
return { secrets: [...secrets].sort(), vars: [...vars].sort() };
|
|
970
1318
|
}
|
|
@@ -1061,8 +1409,24 @@ export function runOfflineChecks({ manifest, repoRoot }) {
|
|
|
1061
1409
|
});
|
|
1062
1410
|
continue;
|
|
1063
1411
|
}
|
|
1412
|
+
let present;
|
|
1413
|
+
try {
|
|
1414
|
+
present = new Set(parseWranglerVars(readFileSync(configPath, "utf8"), configPath));
|
|
1415
|
+
} catch (err) {
|
|
1416
|
+
// One finding, and the worker's expected/orphan reconciliation is
|
|
1417
|
+
// skipped entirely: reporting every declared var as `missing` from a
|
|
1418
|
+
// file nobody could read blames the manifest for the config's syntax.
|
|
1419
|
+
findings.push({
|
|
1420
|
+
severity: "fail",
|
|
1421
|
+
kind: "unreadable",
|
|
1422
|
+
key: null,
|
|
1423
|
+
surface: "wrangler",
|
|
1424
|
+
environment: null,
|
|
1425
|
+
detail: `manifest.workers["${id}"].config ${worker.config} ${err.message}`,
|
|
1426
|
+
});
|
|
1427
|
+
continue;
|
|
1428
|
+
}
|
|
1064
1429
|
checked.push(`wrangler:${id}`);
|
|
1065
|
-
const present = new Set(parseWranglerVars(readFileSync(configPath, "utf8"), configPath));
|
|
1066
1430
|
const expected = manifest.keys.filter(
|
|
1067
1431
|
// Environment-agnostic by design: this check reports `environment: null`
|
|
1068
1432
|
// and `parseWranglerVars` flattens `[env.X.vars]` into one set, so there
|
|
@@ -1170,6 +1534,13 @@ export function reconcileNames({ expected, present, surface, environment, scope
|
|
|
1170
1534
|
* lapsing quietly would re-raise a finding the operator already chose to defer
|
|
1171
1535
|
* without anyone noticing the deferral had run out.
|
|
1172
1536
|
*
|
|
1537
|
+
* `severity` selects WHICH finding an entry silences and defaults to `"fail"`.
|
|
1538
|
+
* The `"orphan"` form exists because the alternative was worse: the only way
|
|
1539
|
+
* to silence one known orphan under `--strict-orphans` was to add a manifest
|
|
1540
|
+
* key for a secret the project does not actually declare, which buys quiet by
|
|
1541
|
+
* making the manifest lie — and a lying manifest is the exact false no-drift
|
|
1542
|
+
* this whole module refuses. An orphan exception still expires on its date.
|
|
1543
|
+
*
|
|
1173
1544
|
* @param {unknown} raw
|
|
1174
1545
|
* @returns {object[]}
|
|
1175
1546
|
*/
|
|
@@ -1186,10 +1557,18 @@ export function parseExceptions(raw) {
|
|
|
1186
1557
|
if (Number.isNaN(Date.parse(`${revisit}T00:00:00Z`))) {
|
|
1187
1558
|
throw new Error(`exceptions[${i}] ("${e.key}") has an unparseable revisit-date "${revisit}"`);
|
|
1188
1559
|
}
|
|
1560
|
+
const severity = e.severity ?? "fail";
|
|
1561
|
+
if (severity !== "fail" && severity !== "orphan") {
|
|
1562
|
+
throw new Error(
|
|
1563
|
+
`exceptions[${i}] ("${e.key}").severity must be "fail" or "orphan" — got "${severity}". ` +
|
|
1564
|
+
`Omit it to default to "fail".`
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1189
1567
|
return {
|
|
1190
1568
|
key: e.key,
|
|
1191
1569
|
surface: e.surface ?? null,
|
|
1192
1570
|
environment: e.environment ?? null,
|
|
1571
|
+
severity,
|
|
1193
1572
|
reason: typeof e.reason === "string" ? e.reason : "",
|
|
1194
1573
|
revisitDate: revisit,
|
|
1195
1574
|
};
|
|
@@ -1216,10 +1595,11 @@ export function applyExceptions({ findings, exceptions, now = new Date() }) {
|
|
|
1216
1595
|
const match = active.find(
|
|
1217
1596
|
(e) =>
|
|
1218
1597
|
e.key === f.key &&
|
|
1598
|
+
e.severity === f.severity &&
|
|
1219
1599
|
(e.surface === null || e.surface === f.surface) &&
|
|
1220
1600
|
(e.environment === null || e.environment === f.environment)
|
|
1221
1601
|
);
|
|
1222
|
-
if (match
|
|
1602
|
+
if (match) {
|
|
1223
1603
|
suppressed.push({ ...f, exception: match });
|
|
1224
1604
|
} else {
|
|
1225
1605
|
kept.push(f);
|
|
@@ -1232,6 +1612,48 @@ export function applyExceptions({ findings, exceptions, now = new Date() }) {
|
|
|
1232
1612
|
// Exit contract
|
|
1233
1613
|
// ---------------------------------------------------------------------------
|
|
1234
1614
|
|
|
1615
|
+
/**
|
|
1616
|
+
* Resolve the environments to check, failing CLOSED on one the manifest does
|
|
1617
|
+
* not declare.
|
|
1618
|
+
*
|
|
1619
|
+
* Nothing downstream can catch a misspelling. `--environments prodcution`
|
|
1620
|
+
* against a manifest declaring `production` narrows every reconcile to a slug
|
|
1621
|
+
* no key claims, so `expected` is empty everywhere, the stores are asked for
|
|
1622
|
+
* folders and environments that do not exist, and the run exits 0 with every
|
|
1623
|
+
* surface `checked` and zero findings — the most convincing possible report
|
|
1624
|
+
* that nothing is wrong, produced by a run that examined nothing. A typo in a
|
|
1625
|
+
* cron-scheduled workflow input can hold that state indefinitely.
|
|
1626
|
+
*
|
|
1627
|
+
* An empty request is not an error: it means "use the manifest's own list",
|
|
1628
|
+
* which is exactly what the reusable workflow's empty `environments` input
|
|
1629
|
+
* interpolates to.
|
|
1630
|
+
*
|
|
1631
|
+
* @param {object} opts
|
|
1632
|
+
* @param {string | null | undefined} opts.requested Raw comma-separated CLI/input value.
|
|
1633
|
+
* @param {{environments: string[]}} opts.manifest
|
|
1634
|
+
* @returns {string[]}
|
|
1635
|
+
* @throws {Error} When a requested slug is not in `manifest.environments`.
|
|
1636
|
+
*/
|
|
1637
|
+
export function resolveEnvironments({ requested, manifest }) {
|
|
1638
|
+
const wanted =
|
|
1639
|
+
typeof requested === "string"
|
|
1640
|
+
? requested
|
|
1641
|
+
.split(",")
|
|
1642
|
+
.map((slug) => slug.trim())
|
|
1643
|
+
.filter(Boolean)
|
|
1644
|
+
: [];
|
|
1645
|
+
if (wanted.length === 0) return manifest.environments;
|
|
1646
|
+
const unknown = wanted.filter((slug) => !manifest.environments.includes(slug));
|
|
1647
|
+
if (unknown.length > 0) {
|
|
1648
|
+
throw new Error(
|
|
1649
|
+
`--environments requested ${unknown.map((slug) => `"${slug}"`).join(", ")}, which manifest.environments ` +
|
|
1650
|
+
`does not declare. Declared environments: ${manifest.environments.join(", ")}. ` +
|
|
1651
|
+
`Nothing was probed — an undeclared environment would report zero findings on every surface.`
|
|
1652
|
+
);
|
|
1653
|
+
}
|
|
1654
|
+
return wanted;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1235
1657
|
/**
|
|
1236
1658
|
* Decide the process exit code from a completed report.
|
|
1237
1659
|
*
|
|
@@ -1292,7 +1714,8 @@ export function renderReport(report) {
|
|
|
1292
1714
|
lines.push("Suppressed by an active exception:");
|
|
1293
1715
|
for (const s of report.suppressed) {
|
|
1294
1716
|
lines.push(
|
|
1295
|
-
` - ${s.key} [${s.surface}${s.environment ? `/${s.environment}` : ""}]
|
|
1717
|
+
` - ${s.key} [${s.surface}${s.environment ? `/${s.environment}` : ""}] (${s.severity}) — ` +
|
|
1718
|
+
`revisit ${s.exception.revisitDate}${s.exception.reason ? `: ${s.exception.reason}` : ""}`
|
|
1296
1719
|
);
|
|
1297
1720
|
}
|
|
1298
1721
|
}
|
|
@@ -1873,12 +2296,13 @@ async function main() {
|
|
|
1873
2296
|
}
|
|
1874
2297
|
}
|
|
1875
2298
|
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
2299
|
+
let environments;
|
|
2300
|
+
try {
|
|
2301
|
+
environments = resolveEnvironments({ requested: opts.environments, manifest });
|
|
2302
|
+
} catch (err) {
|
|
2303
|
+
process.stderr.write(`[env-doctor] ERROR: ${err.message}\n`);
|
|
2304
|
+
process.exit(1);
|
|
2305
|
+
}
|
|
1882
2306
|
|
|
1883
2307
|
// Offline builds no clients at all; `runDoctor` marks the live surfaces
|
|
1884
2308
|
// skipped-because-offline rather than reaching for an unavailability reason.
|
|
@@ -1906,12 +2330,19 @@ async function main() {
|
|
|
1906
2330
|
process.stdout.write(renderReport(report));
|
|
1907
2331
|
}
|
|
1908
2332
|
|
|
2333
|
+
// Annotations go to STDERR in every mode, not just under `--json`. Actions
|
|
2334
|
+
// reads workflow commands from both streams, so nothing is lost — but stdout
|
|
2335
|
+
// is the machine channel, and `--json` promising one JSON document while
|
|
2336
|
+
// appending `::notice` lines to it made `JSON.parse(stdout)` throw for every
|
|
2337
|
+
// consumer whose run had an unchecked surface, which is most of them.
|
|
2338
|
+
// Splitting by mode would leave the text mode's stdout un-pipeable for the
|
|
2339
|
+
// same reason, so the rule is unconditional.
|
|
1909
2340
|
for (const s of report.surfaces) {
|
|
1910
2341
|
if (s.status === "unchecked") {
|
|
1911
|
-
process.
|
|
2342
|
+
process.stderr.write(`::notice title=env-doctor surface unchecked::${s.surface}: ${s.notice}\n`);
|
|
1912
2343
|
}
|
|
1913
2344
|
if (s.status === "error") {
|
|
1914
|
-
process.
|
|
2345
|
+
process.stderr.write(`::error title=env-doctor probe failed::${s.surface}: ${s.notice}\n`);
|
|
1915
2346
|
}
|
|
1916
2347
|
}
|
|
1917
2348
|
|