mandrel-platform 1.12.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 +370 -1
- 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 +647 -90
- package/scripts/env-doctor.test.mjs +772 -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 +215 -0
- package/scripts/select-semgrep-python.test.mjs +344 -0
- 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
|
|
@@ -133,7 +136,7 @@ export const KEY_SCHEMA = Object.freeze({
|
|
|
133
136
|
kind: "'var' | 'secret'",
|
|
134
137
|
sensitivity: "'public' | 'secret'",
|
|
135
138
|
residency:
|
|
136
|
-
"object — {local: 'var'|'secret'|'file'|null, github: G|G[]|null where G = {scope,kind,environments?}, cloudflare: {workers,kind}|null}",
|
|
139
|
+
"object — {local: 'var'|'secret'|'file'|null, github: G|G[]|null where G = {scope,kind,environments?}, cloudflare: {workers: (string | {worker, environments})[], kind}|null}",
|
|
137
140
|
infisical:
|
|
138
141
|
"{folder, environments} | {folders: (string | {folder, environments})[]} | 'unmanaged'",
|
|
139
142
|
shape: `string? — one of ${SHAPE_NAMES.join(", ")}`,
|
|
@@ -456,6 +459,103 @@ function normalizeInfisicalResidency(raw, { at, name, environments }) {
|
|
|
456
459
|
return { folders };
|
|
457
460
|
}
|
|
458
461
|
|
|
462
|
+
/**
|
|
463
|
+
* Normalize `residency.cloudflare` to its canonical
|
|
464
|
+
* `{workers: [{worker, environments}], kind}` form.
|
|
465
|
+
*
|
|
466
|
+
* `workers` accepts a bare worker id — the shape every manifest written before
|
|
467
|
+
* Story #483 uses — or a `{worker, environments}` object, because a key can be
|
|
468
|
+
* deliberately resident on one Worker in one environment only: a peer-database
|
|
469
|
+
* credential scoped that tightly to bound its blast radius, or a recipient
|
|
470
|
+
* allowlist that exists only where non-production sending is gated. With no
|
|
471
|
+
* per-entry `environments`, `probeCloudflare` reconciled ONE expected-name list
|
|
472
|
+
* against EVERY environment, so a deliberate single-environment placement had
|
|
473
|
+
* to report `missing` from the others — ten findings on one consumer's correct
|
|
474
|
+
* manifest, every one of them false (Story #481).
|
|
475
|
+
*
|
|
476
|
+
* A bare entry keeps meaning "every environment". That is the load-bearing
|
|
477
|
+
* constraint rather than a convenience: every manifest in existence declares
|
|
478
|
+
* `workers` as a bare string array, so any other reading would break them all.
|
|
479
|
+
*
|
|
480
|
+
* Both authored shapes normalize to one array of `{worker, environments}` with
|
|
481
|
+
* `environments` defaulted and materialized to `manifest.environments`, so
|
|
482
|
+
* `probeCloudflare` has exactly one shape to read — the same
|
|
483
|
+
* normalize-at-parse treatment `residency.github` received in Story #459 and
|
|
484
|
+
* `infisical` in Story #464. Cloudflare is the surface that never got it, and
|
|
485
|
+
* matching them matters more than the field shape itself: three expressive
|
|
486
|
+
* residencies with one idiom, not three.
|
|
487
|
+
*
|
|
488
|
+
* One deliberate divergence from those two: an **empty** `environments` array
|
|
489
|
+
* is rejected rather than read as "resident nowhere". That state is
|
|
490
|
+
* indistinguishable from omitting the residency altogether, and silently
|
|
491
|
+
* accepting it is precisely how a manifest author comes to believe they have
|
|
492
|
+
* scoped something they have not — the same fail-closed posture this module
|
|
493
|
+
* takes on an unknown `shape`.
|
|
494
|
+
*
|
|
495
|
+
* @param {unknown} raw
|
|
496
|
+
* @param {{at: string, name: string, workers: Record<string, object>, environments: string[]}} ctx
|
|
497
|
+
* @returns {{workers: Array<{worker: string, environments: string[]}>, kind: string} | null}
|
|
498
|
+
*/
|
|
499
|
+
function normalizeCloudflareResidency(raw, { at, name, workers, environments }) {
|
|
500
|
+
if (raw === undefined || raw === null) return null;
|
|
501
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
502
|
+
throw new Error(`${at}.residency.cloudflare must be an object with {workers, kind} (key ${name})`);
|
|
503
|
+
}
|
|
504
|
+
if (!Array.isArray(raw.workers) || raw.workers.length === 0) {
|
|
505
|
+
throw new Error(`${at}.residency.cloudflare.workers must be a non-empty array of worker ids (key ${name})`);
|
|
506
|
+
}
|
|
507
|
+
if (raw.kind !== "secret" && raw.kind !== "var") {
|
|
508
|
+
throw new Error(`${at}.residency.cloudflare.kind must be "secret" or "var" (key ${name})`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const seenWorkers = new Set();
|
|
512
|
+
const normalized = raw.workers.map((entry, j) => {
|
|
513
|
+
const where = `${at}.residency.cloudflare.workers[${j}]`;
|
|
514
|
+
let worker;
|
|
515
|
+
let authoredEnvs;
|
|
516
|
+
if (typeof entry === "string") {
|
|
517
|
+
worker = entry;
|
|
518
|
+
} else if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
|
519
|
+
worker = entry.worker;
|
|
520
|
+
authoredEnvs = entry.environments;
|
|
521
|
+
} else {
|
|
522
|
+
throw new Error(`${where} must be a worker id string or {worker, environments} (key ${name})`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (typeof worker !== "string" || !Object.hasOwn(workers, worker)) {
|
|
526
|
+
throw new Error(
|
|
527
|
+
`${at}.residency.cloudflare.workers references unknown worker id ${JSON.stringify(worker)} (key ${name})`
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
if (seenWorkers.has(worker)) {
|
|
531
|
+
throw new Error(
|
|
532
|
+
`${at}.residency.cloudflare repeats the worker "${worker}" — declare one entry per worker (key ${name})`
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
seenWorkers.add(worker);
|
|
536
|
+
|
|
537
|
+
if (authoredEnvs !== undefined) {
|
|
538
|
+
if (!Array.isArray(authoredEnvs) || !authoredEnvs.every((e) => typeof e === "string")) {
|
|
539
|
+
throw new Error(`${where}.environments must be an array of environment slugs (key ${name})`);
|
|
540
|
+
}
|
|
541
|
+
if (authoredEnvs.length === 0) {
|
|
542
|
+
throw new Error(
|
|
543
|
+
`${where}.environments must not be empty — omit it to mean every environment, or drop the entry (key ${name})`
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
for (const e of authoredEnvs) {
|
|
547
|
+
if (!environments.includes(e)) {
|
|
548
|
+
throw new Error(`${where}.environments names "${e}", absent from manifest.environments (key ${name})`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return { worker, environments: authoredEnvs ? [...authoredEnvs] : [...environments] };
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
return { workers: normalized, kind: raw.kind };
|
|
557
|
+
}
|
|
558
|
+
|
|
459
559
|
/**
|
|
460
560
|
* @param {unknown} entry
|
|
461
561
|
* @param {number} index
|
|
@@ -494,20 +594,7 @@ function validateKeyEntry(entry, index, workers, environments, seen) {
|
|
|
494
594
|
|
|
495
595
|
const github = normalizeGitHubResidency(residency.github, { at, name, environments });
|
|
496
596
|
|
|
497
|
-
const cloudflare = residency.cloudflare
|
|
498
|
-
if (cloudflare !== null) {
|
|
499
|
-
if (!Array.isArray(cloudflare.workers) || cloudflare.workers.length === 0) {
|
|
500
|
-
throw new Error(`${at}.residency.cloudflare.workers must be a non-empty array of worker ids (key ${name})`);
|
|
501
|
-
}
|
|
502
|
-
for (const id of cloudflare.workers) {
|
|
503
|
-
if (!Object.hasOwn(workers, id)) {
|
|
504
|
-
throw new Error(`${at}.residency.cloudflare.workers references unknown worker id "${id}" (key ${name})`);
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
if (cloudflare.kind !== "secret" && cloudflare.kind !== "var") {
|
|
508
|
-
throw new Error(`${at}.residency.cloudflare.kind must be "secret" or "var" (key ${name})`);
|
|
509
|
-
}
|
|
510
|
-
}
|
|
597
|
+
const cloudflare = normalizeCloudflareResidency(residency.cloudflare, { at, name, workers, environments });
|
|
511
598
|
|
|
512
599
|
const infisical = normalizeInfisicalResidency(entry.infisical, { at, name, environments });
|
|
513
600
|
|
|
@@ -530,7 +617,7 @@ function validateKeyEntry(entry, index, workers, environments, seen) {
|
|
|
530
617
|
residency: {
|
|
531
618
|
local,
|
|
532
619
|
github,
|
|
533
|
-
cloudflare
|
|
620
|
+
cloudflare,
|
|
534
621
|
},
|
|
535
622
|
infisical,
|
|
536
623
|
shape: entry.shape ?? null,
|
|
@@ -582,27 +669,181 @@ export function checkShape({ value, shape, placeholderPattern = null }) {
|
|
|
582
669
|
// ---------------------------------------------------------------------------
|
|
583
670
|
|
|
584
671
|
/**
|
|
585
|
-
*
|
|
586
|
-
*
|
|
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.
|
|
587
765
|
*
|
|
588
766
|
* @param {typeof fetch} fetchImpl
|
|
589
767
|
* @param {string} url
|
|
590
768
|
* @param {RequestInit} [init]
|
|
591
|
-
* @
|
|
769
|
+
* @param {{timeoutMs?: number, retryDelayMs?: number, maxAttempts?: number}} [options]
|
|
770
|
+
* @returns {Promise<Response>}
|
|
592
771
|
*/
|
|
593
|
-
export async function
|
|
594
|
-
const
|
|
595
|
-
|
|
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;
|
|
596
790
|
// The body can echo request context; it is never a secret VALUE (these are
|
|
597
791
|
// name-listing endpoints), but it is also not needed — the status is what
|
|
598
792
|
// routes the decision, so only the status and a redacted URL are surfaced.
|
|
599
793
|
const err = new Error(`${init.method ?? "GET"} ${redactUrl(url)} failed: ${res.status} ${res.statusText}`);
|
|
600
794
|
err.httpStatus = res.status;
|
|
601
|
-
|
|
795
|
+
lastError = err;
|
|
796
|
+
if (!isRetryableStatus(res.status) || attempt === maxAttempts) throw err;
|
|
797
|
+
await sleep(retryDelayMs * 2 ** (attempt - 1));
|
|
602
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);
|
|
603
814
|
return res.json();
|
|
604
815
|
}
|
|
605
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
|
+
|
|
606
847
|
/**
|
|
607
848
|
* Strip the query string from a URL before it reaches a log line. Query
|
|
608
849
|
* parameters carry project ids and environment slugs, and a future caller
|
|
@@ -634,8 +875,9 @@ export function isAbsentStatus(err) {
|
|
|
634
875
|
|
|
635
876
|
/**
|
|
636
877
|
* Parse a dotenv-style file into a name -> value map. Used for the local
|
|
637
|
-
* surface
|
|
638
|
-
*
|
|
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.
|
|
639
881
|
*
|
|
640
882
|
* @param {string} text
|
|
641
883
|
* @returns {Record<string, string>}
|
|
@@ -661,26 +903,98 @@ export function parseDotenv(text) {
|
|
|
661
903
|
return out;
|
|
662
904
|
}
|
|
663
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
|
+
|
|
664
972
|
/**
|
|
665
973
|
* Extract the `[vars]` block key names from a wrangler config. Supports both
|
|
666
974
|
* TOML (`[vars]` / `[env.<name>.vars]`) and JSON/JSONC (`"vars": {…}`) — the
|
|
667
975
|
* two shapes wrangler accepts.
|
|
668
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
|
+
*
|
|
669
983
|
* @param {string} text
|
|
670
984
|
* @param {string} path Used only to pick the parser by extension.
|
|
671
985
|
* @returns {string[]} Sorted var names.
|
|
986
|
+
* @throws {Error} With `.wranglerParseFailure === true` on unparseable JSONC.
|
|
672
987
|
*/
|
|
673
988
|
export function parseWranglerVars(text, path) {
|
|
674
989
|
const names = new Set();
|
|
675
990
|
if (/\.jsonc?$/.test(path)) {
|
|
676
|
-
// Strip line comments so JSONC parses; block comments are not used by
|
|
677
|
-
// wrangler's own generated configs.
|
|
678
|
-
const stripped = text.replace(/^\s*\/\/.*$/gm, "");
|
|
679
991
|
let doc;
|
|
680
992
|
try {
|
|
681
|
-
doc = JSON.parse(
|
|
682
|
-
} catch {
|
|
683
|
-
|
|
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;
|
|
684
998
|
}
|
|
685
999
|
collectJsonVars(doc, names);
|
|
686
1000
|
return [...names].sort();
|
|
@@ -725,41 +1039,73 @@ function collectJsonVars(node, names) {
|
|
|
725
1039
|
* access to either collection, so the default token can never serve this probe
|
|
726
1040
|
* (verified against the workflow-syntax permissions reference, 2026-09).
|
|
727
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
|
+
*
|
|
728
1049
|
* @param {object} opts
|
|
729
1050
|
* @param {string} opts.token
|
|
730
1051
|
* @param {string} opts.repo "owner/name"
|
|
731
1052
|
* @param {typeof fetch} [opts.fetchImpl]
|
|
732
1053
|
* @param {string} [opts.apiBase]
|
|
1054
|
+
* @param {number} [opts.timeoutMs]
|
|
1055
|
+
* @param {number} [opts.retryDelayMs]
|
|
733
1056
|
*/
|
|
734
|
-
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
|
+
}) {
|
|
735
1065
|
const headers = {
|
|
736
1066
|
Authorization: `Bearer ${token}`,
|
|
737
1067
|
Accept: "application/vnd.github+json",
|
|
738
1068
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
739
1069
|
};
|
|
740
|
-
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
|
+
}
|
|
741
1102
|
|
|
742
1103
|
return {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
get(`/repos/${repo}/actions/secrets?per_page=100`),
|
|
746
|
-
get(`/repos/${repo}/actions/variables?per_page=100`),
|
|
747
|
-
]);
|
|
748
|
-
return {
|
|
749
|
-
secret: (secrets.secrets ?? []).map((s) => s.name).sort(),
|
|
750
|
-
var: (variables.variables ?? []).map((v) => v.name).sort(),
|
|
751
|
-
};
|
|
1104
|
+
repositoryNames() {
|
|
1105
|
+
return namesUnder(`/repos/${repo}/actions`);
|
|
752
1106
|
},
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
const [secrets, variables] = await Promise.all([
|
|
756
|
-
get(`/repos/${repo}/environments/${env}/secrets?per_page=100`),
|
|
757
|
-
get(`/repos/${repo}/environments/${env}/variables?per_page=100`),
|
|
758
|
-
]);
|
|
759
|
-
return {
|
|
760
|
-
secret: (secrets.secrets ?? []).map((s) => s.name).sort(),
|
|
761
|
-
var: (variables.variables ?? []).map((v) => v.name).sort(),
|
|
762
|
-
};
|
|
1107
|
+
environmentNames(environment) {
|
|
1108
|
+
return namesUnder(`/repos/${repo}/environments/${encodeURIComponent(environment)}`);
|
|
763
1109
|
},
|
|
764
1110
|
};
|
|
765
1111
|
}
|
|
@@ -773,21 +1119,71 @@ export function createGitHubClient({ token, repo, fetchImpl = fetch, apiBase = G
|
|
|
773
1119
|
* @param {string} opts.accountId
|
|
774
1120
|
* @param {typeof fetch} [opts.fetchImpl]
|
|
775
1121
|
* @param {string} [opts.apiBase]
|
|
1122
|
+
* @param {number} [opts.timeoutMs]
|
|
1123
|
+
* @param {number} [opts.retryDelayMs]
|
|
776
1124
|
*/
|
|
777
|
-
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
|
+
}) {
|
|
778
1133
|
const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
|
1134
|
+
const bounds = { timeoutMs, retryDelayMs };
|
|
779
1135
|
return {
|
|
780
1136
|
async secretNames(scriptName) {
|
|
781
1137
|
const body = await requestJson(
|
|
782
1138
|
fetchImpl,
|
|
783
1139
|
`${apiBase}/accounts/${encodeURIComponent(accountId)}/workers/scripts/${encodeURIComponent(scriptName)}/secrets`,
|
|
784
|
-
{ headers }
|
|
1140
|
+
{ headers },
|
|
1141
|
+
bounds
|
|
785
1142
|
);
|
|
786
1143
|
return (body.result ?? []).map((s) => s.name).sort();
|
|
787
1144
|
},
|
|
788
1145
|
};
|
|
789
1146
|
}
|
|
790
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
|
+
|
|
791
1187
|
/**
|
|
792
1188
|
* Infisical client. Authenticates with a pre-issued access token when one is
|
|
793
1189
|
* supplied, otherwise with a Universal Auth machine identity.
|
|
@@ -807,6 +1203,8 @@ export function createCloudflareClient({ token, accountId, fetchImpl = fetch, ap
|
|
|
807
1203
|
* @param {string} opts.projectId
|
|
808
1204
|
* @param {string} [opts.siteUrl]
|
|
809
1205
|
* @param {typeof fetch} [opts.fetchImpl]
|
|
1206
|
+
* @param {number} [opts.timeoutMs]
|
|
1207
|
+
* @param {number} [opts.retryDelayMs]
|
|
810
1208
|
*/
|
|
811
1209
|
export function createInfisicalClient({
|
|
812
1210
|
token = null,
|
|
@@ -815,17 +1213,25 @@ export function createInfisicalClient({
|
|
|
815
1213
|
projectId,
|
|
816
1214
|
siteUrl = INFISICAL_DEFAULT_SITE,
|
|
817
1215
|
fetchImpl = fetch,
|
|
1216
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
1217
|
+
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
|
|
818
1218
|
}) {
|
|
819
1219
|
const base = siteUrl.replace(/\/+$/, "");
|
|
1220
|
+
const bounds = { timeoutMs, retryDelayMs };
|
|
820
1221
|
let accessToken = token;
|
|
821
1222
|
|
|
822
1223
|
async function auth() {
|
|
823
1224
|
if (accessToken) return accessToken;
|
|
824
|
-
const body = await requestJson(
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
+
);
|
|
829
1235
|
if (!body.accessToken) throw new Error("Infisical universal-auth login returned no accessToken");
|
|
830
1236
|
accessToken = body.accessToken;
|
|
831
1237
|
return accessToken;
|
|
@@ -838,11 +1244,19 @@ export function createInfisicalClient({
|
|
|
838
1244
|
environment,
|
|
839
1245
|
secretPath: folder || "/",
|
|
840
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",
|
|
841
1252
|
});
|
|
842
|
-
const body = await requestJson(
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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);
|
|
846
1260
|
}
|
|
847
1261
|
|
|
848
1262
|
return {
|
|
@@ -870,17 +1284,35 @@ export function createInfisicalClient({
|
|
|
870
1284
|
* parse: a reference can appear anywhere an expression can, including inside
|
|
871
1285
|
* a `run:` block's shell, and a structural walk would miss those.
|
|
872
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
|
+
*
|
|
873
1301
|
* @param {string} text
|
|
874
1302
|
* @returns {{secrets: string[], vars: string[]}}
|
|
875
1303
|
*/
|
|
876
1304
|
export function collectWorkflowReferences(text) {
|
|
877
1305
|
const secrets = new Set();
|
|
878
1306
|
const vars = new Set();
|
|
879
|
-
const
|
|
880
|
-
|
|
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);
|
|
881
1313
|
while (m !== null) {
|
|
882
1314
|
(m[1] === "secrets" ? secrets : vars).add(m[2]);
|
|
883
|
-
m = re.exec(
|
|
1315
|
+
m = re.exec(scannable);
|
|
884
1316
|
}
|
|
885
1317
|
return { secrets: [...secrets].sort(), vars: [...vars].sort() };
|
|
886
1318
|
}
|
|
@@ -977,10 +1409,32 @@ export function runOfflineChecks({ manifest, repoRoot }) {
|
|
|
977
1409
|
});
|
|
978
1410
|
continue;
|
|
979
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
|
+
}
|
|
980
1429
|
checked.push(`wrangler:${id}`);
|
|
981
|
-
const present = new Set(parseWranglerVars(readFileSync(configPath, "utf8"), configPath));
|
|
982
1430
|
const expected = manifest.keys.filter(
|
|
983
|
-
|
|
1431
|
+
// Environment-agnostic by design: this check reports `environment: null`
|
|
1432
|
+
// and `parseWranglerVars` flattens `[env.X.vars]` into one set, so there
|
|
1433
|
+
// is no environment axis to narrow against. A var declared for ANY
|
|
1434
|
+
// environment stays expected in that worker's config.
|
|
1435
|
+
(k) =>
|
|
1436
|
+
k.residency.cloudflare?.kind === "var" &&
|
|
1437
|
+
k.residency.cloudflare.workers.some((w) => w.worker === id)
|
|
984
1438
|
);
|
|
985
1439
|
for (const key of expected) {
|
|
986
1440
|
if (!present.has(key.name)) {
|
|
@@ -1080,6 +1534,13 @@ export function reconcileNames({ expected, present, surface, environment, scope
|
|
|
1080
1534
|
* lapsing quietly would re-raise a finding the operator already chose to defer
|
|
1081
1535
|
* without anyone noticing the deferral had run out.
|
|
1082
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
|
+
*
|
|
1083
1544
|
* @param {unknown} raw
|
|
1084
1545
|
* @returns {object[]}
|
|
1085
1546
|
*/
|
|
@@ -1096,10 +1557,18 @@ export function parseExceptions(raw) {
|
|
|
1096
1557
|
if (Number.isNaN(Date.parse(`${revisit}T00:00:00Z`))) {
|
|
1097
1558
|
throw new Error(`exceptions[${i}] ("${e.key}") has an unparseable revisit-date "${revisit}"`);
|
|
1098
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
|
+
}
|
|
1099
1567
|
return {
|
|
1100
1568
|
key: e.key,
|
|
1101
1569
|
surface: e.surface ?? null,
|
|
1102
1570
|
environment: e.environment ?? null,
|
|
1571
|
+
severity,
|
|
1103
1572
|
reason: typeof e.reason === "string" ? e.reason : "",
|
|
1104
1573
|
revisitDate: revisit,
|
|
1105
1574
|
};
|
|
@@ -1126,10 +1595,11 @@ export function applyExceptions({ findings, exceptions, now = new Date() }) {
|
|
|
1126
1595
|
const match = active.find(
|
|
1127
1596
|
(e) =>
|
|
1128
1597
|
e.key === f.key &&
|
|
1598
|
+
e.severity === f.severity &&
|
|
1129
1599
|
(e.surface === null || e.surface === f.surface) &&
|
|
1130
1600
|
(e.environment === null || e.environment === f.environment)
|
|
1131
1601
|
);
|
|
1132
|
-
if (match
|
|
1602
|
+
if (match) {
|
|
1133
1603
|
suppressed.push({ ...f, exception: match });
|
|
1134
1604
|
} else {
|
|
1135
1605
|
kept.push(f);
|
|
@@ -1142,6 +1612,48 @@ export function applyExceptions({ findings, exceptions, now = new Date() }) {
|
|
|
1142
1612
|
// Exit contract
|
|
1143
1613
|
// ---------------------------------------------------------------------------
|
|
1144
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
|
+
|
|
1145
1657
|
/**
|
|
1146
1658
|
* Decide the process exit code from a completed report.
|
|
1147
1659
|
*
|
|
@@ -1202,7 +1714,8 @@ export function renderReport(report) {
|
|
|
1202
1714
|
lines.push("Suppressed by an active exception:");
|
|
1203
1715
|
for (const s of report.suppressed) {
|
|
1204
1716
|
lines.push(
|
|
1205
|
-
` - ${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}` : ""}`
|
|
1206
1719
|
);
|
|
1207
1720
|
}
|
|
1208
1721
|
}
|
|
@@ -1417,10 +1930,37 @@ async function probeGitHub({ manifest, environments, github, surfaces, findings,
|
|
|
1417
1930
|
}
|
|
1418
1931
|
|
|
1419
1932
|
/**
|
|
1933
|
+
* Reconcile the Cloudflare surface per `(worker, environment)` pair.
|
|
1934
|
+
*
|
|
1935
|
+
* `expected` is narrowed to the keys whose residency names BOTH this worker
|
|
1936
|
+
* and this environment, so a deliberate single-environment placement no longer
|
|
1937
|
+
* reports `missing` from the environments it never claimed (Story #481).
|
|
1938
|
+
*
|
|
1939
|
+
* Two consequences of that narrowing are load-bearing, and neither is
|
|
1940
|
+
* incidental:
|
|
1941
|
+
*
|
|
1942
|
+
* 1. **A worker is still probed in an environment it expects nothing in**,
|
|
1943
|
+
* as long as it expects something SOMEWHERE. Skipping it would take the
|
|
1944
|
+
* surface's most interesting finding with it: a production-only key
|
|
1945
|
+
* turning up in staging is undeclared presence, and only an
|
|
1946
|
+
* empty-`expected` reconcile against a non-empty `present` reports it.
|
|
1947
|
+
* Cross-environment orphans go unsuppressed here exactly as they do on
|
|
1948
|
+
* the GitHub and Infisical surfaces. A worker that declares nothing in
|
|
1949
|
+
* any environment is still skipped entirely — that is the manifest
|
|
1950
|
+
* saying it has no opinion, which is not the same statement.
|
|
1951
|
+
* 2. **A 404 is only a finding where something WAS expected.** A worker
|
|
1952
|
+
* deployed to one environment by design 404s in the other, and with
|
|
1953
|
+
* nothing declared there that agrees with the manifest rather than
|
|
1954
|
+
* contradicting it. Reporting it would re-introduce, one layer down, the
|
|
1955
|
+
* same false failure this narrowing removes.
|
|
1956
|
+
*
|
|
1420
1957
|
* @param {object} ctx
|
|
1421
1958
|
*/
|
|
1422
1959
|
async function probeCloudflare({ manifest, environments, cloudflare, surfaces, findings, unavailability = {} }) {
|
|
1423
1960
|
const cfKeys = manifest.keys.filter((k) => k.residency.cloudflare?.kind === "secret");
|
|
1961
|
+
/** Does any key declare this worker in any environment at all? */
|
|
1962
|
+
const declaresWorker = (id) =>
|
|
1963
|
+
cfKeys.some((k) => k.residency.cloudflare.workers.some((w) => w.worker === id));
|
|
1424
1964
|
if (!cloudflare) {
|
|
1425
1965
|
surfaces.push({
|
|
1426
1966
|
surface: "cloudflare",
|
|
@@ -1432,8 +1972,12 @@ async function probeCloudflare({ manifest, environments, cloudflare, surfaces, f
|
|
|
1432
1972
|
try {
|
|
1433
1973
|
for (const environment of environments) {
|
|
1434
1974
|
for (const [id, worker] of Object.entries(manifest.workers)) {
|
|
1435
|
-
const expected = cfKeys
|
|
1436
|
-
|
|
1975
|
+
const expected = cfKeys
|
|
1976
|
+
.filter((k) =>
|
|
1977
|
+
k.residency.cloudflare.workers.some((w) => w.worker === id && w.environments.includes(environment))
|
|
1978
|
+
)
|
|
1979
|
+
.map((k) => k.name);
|
|
1980
|
+
if (!declaresWorker(id)) continue;
|
|
1437
1981
|
const scriptName = resolveScriptName(worker.scriptName, environment);
|
|
1438
1982
|
let present;
|
|
1439
1983
|
try {
|
|
@@ -1441,15 +1985,20 @@ async function probeCloudflare({ manifest, environments, cloudflare, surfaces, f
|
|
|
1441
1985
|
} catch (err) {
|
|
1442
1986
|
if (!isAbsentStatus(err)) throw err;
|
|
1443
1987
|
// A 404 is the one status that legitimately means "absent": the
|
|
1444
|
-
// Worker has not been deployed to this environment yet.
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1988
|
+
// Worker has not been deployed to this environment yet. That is only
|
|
1989
|
+
// drift where the manifest expected something here; a worker
|
|
1990
|
+
// deliberately absent from an environment it declares nothing in is
|
|
1991
|
+
// agreement, not a finding.
|
|
1992
|
+
if (expected.length > 0) {
|
|
1993
|
+
findings.push({
|
|
1994
|
+
severity: "fail",
|
|
1995
|
+
kind: "missing",
|
|
1996
|
+
key: null,
|
|
1997
|
+
surface: "cloudflare",
|
|
1998
|
+
environment,
|
|
1999
|
+
detail: `Worker script "${scriptName}" does not exist (404) — ${expected.length} declared secret(s) cannot be verified`,
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
1453
2002
|
continue;
|
|
1454
2003
|
}
|
|
1455
2004
|
findings.push(
|
|
@@ -1747,12 +2296,13 @@ async function main() {
|
|
|
1747
2296
|
}
|
|
1748
2297
|
}
|
|
1749
2298
|
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
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
|
+
}
|
|
1756
2306
|
|
|
1757
2307
|
// Offline builds no clients at all; `runDoctor` marks the live surfaces
|
|
1758
2308
|
// skipped-because-offline rather than reaching for an unavailability reason.
|
|
@@ -1780,12 +2330,19 @@ async function main() {
|
|
|
1780
2330
|
process.stdout.write(renderReport(report));
|
|
1781
2331
|
}
|
|
1782
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.
|
|
1783
2340
|
for (const s of report.surfaces) {
|
|
1784
2341
|
if (s.status === "unchecked") {
|
|
1785
|
-
process.
|
|
2342
|
+
process.stderr.write(`::notice title=env-doctor surface unchecked::${s.surface}: ${s.notice}\n`);
|
|
1786
2343
|
}
|
|
1787
2344
|
if (s.status === "error") {
|
|
1788
|
-
process.
|
|
2345
|
+
process.stderr.write(`::error title=env-doctor probe failed::${s.surface}: ${s.notice}\n`);
|
|
1789
2346
|
}
|
|
1790
2347
|
}
|
|
1791
2348
|
|