fix: harden provider and promotion evidence
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
} from "../lib/release-candidate.ts";
|
||||
import { validatePackageScriptGraph } from "../lib/package-script-graph.ts";
|
||||
import { PROMOTED_STAGING_PATHS } from "./promotion-artifacts.ts";
|
||||
import { PROMOTED_UPLOAD_PATHS } from "./promotion-artifacts.ts";
|
||||
|
||||
const ciActionRegistrationSchema = z
|
||||
.object({
|
||||
@@ -332,7 +332,11 @@ const extractStep = z
|
||||
})
|
||||
.strict();
|
||||
const providerStep = z
|
||||
.object({ kind: z.literal("run-provider"), provider: z.enum(["vulnerability", "provenance"]) })
|
||||
.object({
|
||||
kind: z.literal("run-provider"),
|
||||
provider: z.enum(["vulnerability", "provenance"]),
|
||||
stepId: id,
|
||||
})
|
||||
.strict();
|
||||
const validateProviderStep = z
|
||||
.object({
|
||||
@@ -340,7 +344,12 @@ const validateProviderStep = z
|
||||
provider: z.enum(["vulnerability", "provenance"]),
|
||||
})
|
||||
.strict();
|
||||
const promotionStep = z.object({ kind: z.literal("verify-promotion") }).strict();
|
||||
const promotionStep = z
|
||||
.object({ kind: z.literal("verify-promotion"), stepId: id })
|
||||
.strict();
|
||||
const cleanupPromotionStep = z
|
||||
.object({ kind: z.literal("cleanup-promotion"), finalizerStepId: id })
|
||||
.strict();
|
||||
|
||||
const jobStepSchema = z.discriminatedUnion("kind", [
|
||||
checkoutStep,
|
||||
@@ -356,6 +365,7 @@ const jobStepSchema = z.discriminatedUnion("kind", [
|
||||
providerStep,
|
||||
validateProviderStep,
|
||||
promotionStep,
|
||||
cleanupPromotionStep,
|
||||
]);
|
||||
|
||||
const jobSchema = z
|
||||
@@ -756,9 +766,9 @@ function validateContractSemantics(
|
||||
merge_gate: ["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"],
|
||||
release_gate: ["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"],
|
||||
immutable_build: ["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"],
|
||||
vulnerability_provider: ["checkout", "setup-node", "frozen-install", "download", "extract", "run-provider", "validate-provider-evidence", "upload"],
|
||||
provenance_provider: ["checkout", "setup-node", "frozen-install", "download", "extract", "run-provider", "validate-provider-evidence", "upload"],
|
||||
promotion: ["checkout", "setup-node", "frozen-install", "download", "download", "download", "extract", "verify-promotion", "upload"],
|
||||
vulnerability_provider: ["checkout", "setup-node", "frozen-install", "download", "run-provider", "validate-provider-evidence", "upload"],
|
||||
provenance_provider: ["checkout", "setup-node", "frozen-install", "download", "run-provider", "validate-provider-evidence", "upload"],
|
||||
promotion: ["checkout", "setup-node", "frozen-install", "download", "download", "download", "verify-promotion", "upload", "cleanup-promotion"],
|
||||
production_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"],
|
||||
field_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"],
|
||||
documentation_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"],
|
||||
@@ -776,8 +786,11 @@ function validateContractSemantics(
|
||||
vulnerability_provider: [
|
||||
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
|
||||
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
|
||||
{ name: "CANDIDATE_DIST_SHA256", value: "${{ needs.immutable_build.outputs.dist_sha256 }}" },
|
||||
{ name: "CANDIDATE_LOCKFILE_PATH", value: ".release/verified-vulnerability/pnpm-lock.yaml" },
|
||||
{ name: "CI_RUN_ID", value: "${{ gitea.run_id }}" },
|
||||
{ name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" },
|
||||
{ name: "EXPECTED_SOURCE_REVISION", value: "${{ gitea.sha }}" },
|
||||
{ name: "VULNERABILITY_PUBLIC_KEY_PATH", value: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" },
|
||||
{ name: "VULNERABILITY_KEY_ID", value: "${{ vars.VULNERABILITY_KEY_ID }}" },
|
||||
{ name: "VULNERABILITY_PROVIDER_COMMAND", value: "${{ vars.VULNERABILITY_PROVIDER_COMMAND }}" },
|
||||
{ name: "VULNERABILITY_REPORT_PATH", value: "provider-evidence/untrusted/vulnerability-report.json" },
|
||||
{ name: "VALIDATED_PROVIDER_REPORT_PATH", value: "provider-evidence/vulnerability-report.json" },
|
||||
@@ -785,8 +798,11 @@ function validateContractSemantics(
|
||||
provenance_provider: [
|
||||
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
|
||||
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
|
||||
{ name: "CANDIDATE_DIST_SHA256", value: "${{ needs.immutable_build.outputs.dist_sha256 }}" },
|
||||
{ name: "CANDIDATE_LOCKFILE_PATH", value: ".release/verified-provenance/pnpm-lock.yaml" },
|
||||
{ name: "CI_RUN_ID", value: "${{ gitea.run_id }}" },
|
||||
{ name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" },
|
||||
{ name: "EXPECTED_SOURCE_REVISION", value: "${{ gitea.sha }}" },
|
||||
{ name: "PROVENANCE_PUBLIC_KEY_PATH", value: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" },
|
||||
{ name: "PROVENANCE_KEY_ID", value: "${{ vars.PROVENANCE_KEY_ID }}" },
|
||||
{ name: "PROVENANCE_PROVIDER_COMMAND", value: "${{ vars.PROVENANCE_PROVIDER_COMMAND }}" },
|
||||
{ name: "PROVENANCE_ATTESTATION_PATH", value: "provider-evidence/untrusted/provenance-attestation.json" },
|
||||
{ name: "VALIDATED_PROVIDER_REPORT_PATH", value: "provider-evidence/provenance-attestation.json" },
|
||||
@@ -794,13 +810,16 @@ function validateContractSemantics(
|
||||
promotion: [
|
||||
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
|
||||
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
|
||||
{ name: "CANDIDATE_ROOT", value: "${{ gitea.workspace }}/.release/verified-candidate" },
|
||||
{ name: "CI_RUN_ID", value: "${{ gitea.run_id }}" },
|
||||
{ name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" },
|
||||
{ name: "VULNERABILITY_REPORT_PATH", value: "${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json" },
|
||||
{ name: "PROVENANCE_ATTESTATION_PATH", value: "${{ gitea.workspace }}/.release/provenance/provenance-attestation.json" },
|
||||
{ name: "VULNERABILITY_PUBLIC_KEY_PATH", value: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" },
|
||||
{ name: "VULNERABILITY_KEY_ID", value: "${{ vars.VULNERABILITY_KEY_ID }}" },
|
||||
{ name: "PROVENANCE_PUBLIC_KEY_PATH", value: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" },
|
||||
{ name: "PROVENANCE_KEY_ID", value: "${{ vars.PROVENANCE_KEY_ID }}" },
|
||||
{ name: "VULNERABILITY_INVOCATION_NONCE", value: "${{ needs.vulnerability_provider.outputs.invocation_nonce }}" },
|
||||
{ name: "PROVENANCE_INVOCATION_NONCE", value: "${{ needs.provenance_provider.outputs.invocation_nonce }}" },
|
||||
],
|
||||
production_gate: [],
|
||||
field_gate: [
|
||||
@@ -875,12 +894,14 @@ function validateContractSemantics(
|
||||
if (upload?.kind === "upload" && upload.always) {
|
||||
issue("promotion upload must not use always");
|
||||
}
|
||||
const cleanupIndex = order.indexOf("cleanup-promotion");
|
||||
if (
|
||||
order.indexOf("extract") < order.lastIndexOf("download") ||
|
||||
order.indexOf("verify-promotion") < order.indexOf("extract") ||
|
||||
order.indexOf("upload") < order.indexOf("verify-promotion")
|
||||
order.includes("extract") ||
|
||||
verificationIndex < order.lastIndexOf("download") ||
|
||||
uploadIndex < verificationIndex ||
|
||||
cleanupIndex !== uploadIndex + 1
|
||||
) {
|
||||
issue("promotion formula order must download, verify, then upload");
|
||||
issue("promotion formula order must download, finalize, upload, then cleanup without extraction");
|
||||
}
|
||||
}
|
||||
const immutable = contract.jobs.find(({ id }) => id === "immutable_build");
|
||||
@@ -905,7 +926,7 @@ function validateContractSemantics(
|
||||
const promotionUpload = promotion?.steps.find(
|
||||
(step) => step.kind === "upload" && step.transferId === "promoted-release",
|
||||
);
|
||||
if (!promotionUpload || promotionUpload.kind !== "upload" || JSON.stringify(promotionUpload.paths) !== JSON.stringify(PROMOTED_STAGING_PATHS)) {
|
||||
if (!promotionUpload || promotionUpload.kind !== "upload" || JSON.stringify(promotionUpload.paths) !== JSON.stringify(PROMOTED_UPLOAD_PATHS)) {
|
||||
issue("promotion upload bundle must contain the exact five typed paths");
|
||||
}
|
||||
|
||||
@@ -934,10 +955,9 @@ function validateCanonicalStepFields(
|
||||
const providerExpectations = {
|
||||
vulnerability_provider: {
|
||||
provider: "vulnerability",
|
||||
stepId: "supervise_vulnerability",
|
||||
downloadPath: ".release/vulnerability-candidate",
|
||||
archivePath: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz",
|
||||
targetRoot: ".release/verified-vulnerability",
|
||||
lockfilePath: ".release/verified-vulnerability/pnpm-lock.yaml",
|
||||
rawPath: "provider-evidence/untrusted/vulnerability-report.json",
|
||||
rawName: "VULNERABILITY_REPORT_PATH",
|
||||
sealedPath: "provider-evidence/vulnerability-report.json",
|
||||
@@ -945,10 +965,9 @@ function validateCanonicalStepFields(
|
||||
},
|
||||
provenance_provider: {
|
||||
provider: "provenance",
|
||||
stepId: "supervise_provenance",
|
||||
downloadPath: ".release/provenance-candidate",
|
||||
archivePath: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz",
|
||||
targetRoot: ".release/verified-provenance",
|
||||
lockfilePath: ".release/verified-provenance/pnpm-lock.yaml",
|
||||
rawPath: "provider-evidence/untrusted/provenance-attestation.json",
|
||||
rawName: "PROVENANCE_ATTESTATION_PATH",
|
||||
sealedPath: "provider-evidence/provenance-attestation.json",
|
||||
@@ -959,7 +978,6 @@ function validateCanonicalStepFields(
|
||||
const job = contract.jobs.find(({ id }) => id === jobId);
|
||||
const environment = new Map(job?.environment.map(({ name, value }) => [name, value]));
|
||||
const download = job?.steps.find(({ kind }) => kind === "download");
|
||||
const extract = job?.steps.find(({ kind }) => kind === "extract");
|
||||
const runProvider = job?.steps.find(({ kind }) => kind === "run-provider");
|
||||
const validateProvider = job?.steps.find(({ kind }) => kind === "validate-provider-evidence");
|
||||
const upload = job?.steps.find(
|
||||
@@ -967,11 +985,9 @@ function validateCanonicalStepFields(
|
||||
);
|
||||
if (
|
||||
!download || download.kind !== "download" || download.transferId !== "release-candidate" || download.path !== expected.downloadPath ||
|
||||
!extract || extract.kind !== "extract" || extract.archivePath !== expected.archivePath || extract.targetRoot !== expected.targetRoot ||
|
||||
!runProvider || runProvider.kind !== "run-provider" || runProvider.provider !== expected.provider ||
|
||||
!runProvider || runProvider.kind !== "run-provider" || runProvider.provider !== expected.provider || runProvider.stepId !== expected.stepId ||
|
||||
!validateProvider || validateProvider.kind !== "validate-provider-evidence" || validateProvider.provider !== expected.provider ||
|
||||
environment.get("CANDIDATE_ARCHIVE_PATH") !== expected.archivePath ||
|
||||
environment.get("CANDIDATE_LOCKFILE_PATH") !== expected.lockfilePath ||
|
||||
environment.get(expected.rawName) !== expected.rawPath ||
|
||||
environment.get("VALIDATED_PROVIDER_REPORT_PATH") !== expected.sealedPath ||
|
||||
!upload || upload.kind !== "upload" || JSON.stringify(upload.paths) !== JSON.stringify([expected.sealedPath])
|
||||
@@ -987,15 +1003,18 @@ function validateCanonicalStepFields(
|
||||
{ kind: "download", transferId: "vulnerability-provider-evidence", path: ".release/vulnerability" },
|
||||
{ kind: "download", transferId: "provenance-provider-evidence", path: ".release/provenance" },
|
||||
];
|
||||
const promotionExtract = promotion?.steps.find(({ kind }) => kind === "extract");
|
||||
const promotionFinalizer = promotion?.steps.find(({ kind }) => kind === "verify-promotion");
|
||||
const promotionCleanup = promotion?.steps.find(({ kind }) => kind === "cleanup-promotion");
|
||||
if (
|
||||
JSON.stringify(promotionDownloads) !== JSON.stringify(expectedDownloads) ||
|
||||
!promotionExtract ||
|
||||
promotionExtract.kind !== "extract" ||
|
||||
promotionExtract.archivePath !== ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" ||
|
||||
promotionExtract.targetRoot !== ".release/verified-candidate"
|
||||
!promotionFinalizer ||
|
||||
promotionFinalizer.kind !== "verify-promotion" ||
|
||||
promotionFinalizer.stepId !== "finalize" ||
|
||||
!promotionCleanup ||
|
||||
promotionCleanup.kind !== "cleanup-promotion" ||
|
||||
promotionCleanup.finalizerStepId !== "finalize"
|
||||
) {
|
||||
issue("promotion download and extraction fields must remain linked");
|
||||
issue("promotion download fields and finalizer/cleanup step identities must remain linked");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1008,7 +1027,7 @@ function validateJobStepKinds(
|
||||
"gate-single": new Set(["checkout", "setup-node", "frozen-install", "run-gate", "upload"]),
|
||||
immutable: new Set(["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"]),
|
||||
provider: new Set(["checkout", "setup-node", "frozen-install", "download", "validate-candidate-archive", "extract", "run-provider", "validate-provider-evidence", "upload"]),
|
||||
promotion: new Set(["checkout", "setup-node", "frozen-install", "download", "validate-candidate-archive", "extract", "verify-promotion", "upload"]),
|
||||
promotion: new Set(["checkout", "setup-node", "frozen-install", "download", "verify-promotion", "upload", "cleanup-promotion"]),
|
||||
};
|
||||
for (const step of job.steps) {
|
||||
if (!allowed[job.kind].has(step.kind)) {
|
||||
@@ -1016,16 +1035,12 @@ function validateJobStepKinds(
|
||||
}
|
||||
}
|
||||
const kinds = job.steps.map(({ kind }) => kind);
|
||||
const extractIndex = kinds.indexOf("extract");
|
||||
if ((job.kind === "provider" || job.kind === "promotion") && extractIndex < 0) {
|
||||
issue(`verified extraction step is missing: ${job.id}`);
|
||||
}
|
||||
if (job.kind === "provider") {
|
||||
const providerIndex = kinds.indexOf("run-provider");
|
||||
const validateProviderIndex = kinds.indexOf("validate-provider-evidence");
|
||||
const uploadIndex = kinds.indexOf("upload");
|
||||
if (
|
||||
providerIndex < extractIndex ||
|
||||
providerIndex < kinds.lastIndexOf("download") ||
|
||||
validateProviderIndex < providerIndex ||
|
||||
uploadIndex < validateProviderIndex
|
||||
) {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
export const PROMOTED_STAGING_PATHS = Object.freeze([
|
||||
".release/promoted-staging/release-candidate.tar.gz",
|
||||
".release/promoted-staging/vulnerability-report.json",
|
||||
".release/promoted-staging/provenance-attestation.json",
|
||||
".release/promoted-staging/provider-verification.json",
|
||||
".release/promoted-staging/promotion-verification.json",
|
||||
export const PROMOTED_FILE_NAMES = Object.freeze([
|
||||
"release-candidate.tar.gz",
|
||||
"vulnerability-report.json",
|
||||
"provenance-attestation.json",
|
||||
"provider-verification.json",
|
||||
"promotion-verification.json",
|
||||
] as const);
|
||||
|
||||
export type PromotedFileName = (typeof PROMOTED_FILE_NAMES)[number];
|
||||
|
||||
export const PROMOTED_UPLOAD_PATHS = Object.freeze(
|
||||
PROMOTED_FILE_NAMES.map((name) => `\${{ steps.finalize.outputs.staging_root }}/${name}`),
|
||||
);
|
||||
|
||||
@@ -7,6 +7,131 @@ const timestamp = z.iso.datetime();
|
||||
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const jsonObject = z.record(z.string(), z.json());
|
||||
|
||||
const canonicalTimestamp = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u)
|
||||
.refine((value) => new Date(value).toISOString() === value, {
|
||||
message: "must be a canonical ISO-8601 UTC timestamp",
|
||||
});
|
||||
const safeRepositoryPath = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(1_024)
|
||||
.refine(
|
||||
(value) =>
|
||||
!value.startsWith("-") &&
|
||||
!value.startsWith("/") &&
|
||||
!value.includes("\\") &&
|
||||
!value.split("/").some((segment) => segment === "" || segment === "." || segment === "..") &&
|
||||
![...value].some((character) => {
|
||||
const codePoint = character.codePointAt(0)!;
|
||||
return codePoint <= 0x1f || codePoint === 0x7f;
|
||||
}),
|
||||
{ message: "must be a safe canonical repository-relative path" },
|
||||
);
|
||||
const assessmentInputRowSchema = z
|
||||
.object({
|
||||
path: safeRepositoryPath,
|
||||
bytes: z.int().nonnegative().max(268_435_456),
|
||||
sha256,
|
||||
})
|
||||
.strict();
|
||||
const assessmentStatusSchema = z.enum(["PASS", "FAIL"]);
|
||||
|
||||
function addCanonicalInputIssues(
|
||||
rows: readonly Readonly<{ path: string }>[],
|
||||
pathPrefix: "policyInputs" | "evidenceInputs",
|
||||
context: z.RefinementCtx,
|
||||
): void {
|
||||
const paths = rows.map(({ path }) => path);
|
||||
const canonical = [...paths].sort((left, right) =>
|
||||
left < right ? -1 : left > right ? 1 : 0,
|
||||
);
|
||||
if (JSON.stringify(paths) !== JSON.stringify(canonical)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: [pathPrefix],
|
||||
message: "must be in canonical ASCII path order",
|
||||
});
|
||||
}
|
||||
if (new Set(paths).size !== paths.length) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: [pathPrefix],
|
||||
message: "must not contain duplicate paths",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const localEvidenceAssessmentArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
artifactType: z.literal("local-evidence-assessment"),
|
||||
generatedAt: canonicalTimestamp,
|
||||
status: assessmentStatusSchema,
|
||||
verifier: z
|
||||
.object({
|
||||
id: nonEmptyString,
|
||||
version: nonEmptyString,
|
||||
sourceSha256: sha256,
|
||||
})
|
||||
.strict(),
|
||||
source: z
|
||||
.object({
|
||||
revision: z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u),
|
||||
sourceSetSha256: sha256,
|
||||
})
|
||||
.strict(),
|
||||
candidate: z
|
||||
.object({ distSha256: sha256, lockfileSha256: sha256, sbomSha256: sha256 })
|
||||
.strict(),
|
||||
policyInputs: z.array(assessmentInputRowSchema).min(1).max(256),
|
||||
evidenceInputs: z.array(assessmentInputRowSchema).min(1).max(4_096),
|
||||
checks: z
|
||||
.object({
|
||||
release: assessmentStatusSchema,
|
||||
supplyChain: assessmentStatusSchema,
|
||||
dependencyPolicy: assessmentStatusSchema,
|
||||
licensePolicy: assessmentStatusSchema,
|
||||
vulnerabilityPolicy: assessmentStatusSchema,
|
||||
secretScan: assessmentStatusSchema,
|
||||
})
|
||||
.strict(),
|
||||
failures: z.array(z.string()),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((assessment, context) => {
|
||||
addCanonicalInputIssues(assessment.policyInputs, "policyInputs", context);
|
||||
addCanonicalInputIssues(assessment.evidenceInputs, "evidenceInputs", context);
|
||||
const failedChecks = Object.values(assessment.checks).filter(
|
||||
(status) => status === "FAIL",
|
||||
);
|
||||
if (
|
||||
assessment.status === "PASS" &&
|
||||
(failedChecks.length > 0 || assessment.failures.length > 0)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["status"],
|
||||
message: "PASS requires all six checks PASS and no failures",
|
||||
});
|
||||
}
|
||||
if (
|
||||
assessment.status === "FAIL" &&
|
||||
(failedChecks.length === 0 || assessment.failures.length === 0)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["status"],
|
||||
message: "FAIL requires a failed check and a failure diagnostic",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type LocalEvidenceAssessment = z.infer<
|
||||
typeof localEvidenceAssessmentArtifactSchema
|
||||
>;
|
||||
|
||||
export const moduleInventoryArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
|
||||
Reference in New Issue
Block a user