Files
prowler/ui/actions/registry/registry.adapter.ts
T

479 lines
16 KiB
TypeScript

import { z } from "zod";
import { isActiveRegistryCredential } from "@/lib/registry/credential-task";
import {
REGISTRY_ARTIFACT_REMOVAL,
REGISTRY_CATALOG,
REGISTRY_CATALOG_INCOMPLETE_REASON,
REGISTRY_ENDPOINT,
REGISTRY_FAILURE,
REGISTRY_MUTATION,
REGISTRY_SUBMISSION,
type RegistryArtifactRemovalConflict,
type RegistryCatalogArtifact,
type RegistryCatalogResult,
type RegistryCredentialStatus,
type RegistryTaskSubmissionResult,
type RegistryEndpoint,
type RegistryFailureResult,
type RegistryMutationResult,
type RegistryTenantArtifact,
} from "@/types/registry";
const REGISTRY_TASK_PATH_PREFIX = "/api/v1/tasks/";
const REGISTRY_ERROR_CODE = {
KEY_REJECTED: "registry_key_rejected",
UNAVAILABLE: "registry_unavailable",
} as const;
// Opposite remedies, so a 409 is never read without its code.
const REGISTRY_REMOVAL_CONFLICT_CODE = {
IN_USE: "registry_artifact_in_use",
BUSY: "registry_artifact_busy",
} as const;
const REGISTRY_MUTATION_REFUSAL_COPY = {
no_installable_version: "No available version can be added.",
registry_artifact_not_found: "This artifact is no longer available.",
version_not_found: "This version is not available.",
version_not_processed: "This version is not ready to add yet.",
version_not_verified: "This version is not verified and cannot be added.",
version_yanked: "This version is no longer available.",
} as const;
const registryDiscoveryEndpoints = new Set<RegistryEndpoint>([
REGISTRY_ENDPOINT.PROVIDERS,
REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS,
]);
const credentialStatusSchema = z.object({
data: z.object({
attributes: z.object({
configured: z.boolean(),
is_valid: z.boolean(),
scopes: z.array(z.string()),
last_validated_at: z.string().nullish(),
validation_status: z.string().nullish(),
validation_pending: z.boolean(),
}),
}),
});
const taskSubmissionSchema = z.object({
data: z.object({
type: z.literal("tasks"),
id: z.string().min(1),
}),
});
const registryCollectionSchema = z.object({ data: z.array(z.unknown()) });
const tenantArtifactsSchema = z.object({
data: z.array(
z.object({
type: z.string().trim().min(1),
id: z.string().trim().min(1),
attributes: z.object({
version_spec: z.string().trim().min(1),
resolved_version: z.string().trim().nullish(),
extends_provider_slugs: z.array(z.string()).nullish(),
inserted_at: z.string().optional(),
updated_at: z.string().optional(),
}),
}),
),
});
const errorDocumentSchema = z.object({
errors: z.array(z.object({ code: z.string().min(1) })).min(1),
});
export function adaptRegistryCredentialStatus(
payload: unknown,
): RegistryCredentialStatus | null {
const parsed = credentialStatusSchema.safeParse(payload);
if (!parsed.success) return null;
const { attributes } = parsed.data.data;
return {
configured: attributes.configured,
isValid: attributes.is_valid,
scopes: attributes.scopes,
lastValidatedAt: attributes.last_validated_at ?? undefined,
validationStatus: attributes.validation_status ?? undefined,
validationPending: attributes.validation_pending,
};
}
export function adaptRegistryTenantArtifacts(
payload: unknown,
): RegistryTenantArtifact[] | null {
const parsed = tenantArtifactsSchema.safeParse(payload);
if (!parsed.success) return null;
return parsed.data.data.map(({ attributes, id }) => ({
normalizedName: id,
versionSpec: attributes.version_spec,
resolvedVersion: attributes.resolved_version || undefined,
extendsProviderSlugs: unique(
(attributes.extends_provider_slugs ?? [])
.map((slug) => slug.trim().toLowerCase())
.filter(Boolean),
),
insertedAt: attributes.inserted_at,
updatedAt: attributes.updated_at,
}));
}
export function isRegistryCollection(payload: unknown) {
return registryCollectionSchema.safeParse(payload).success;
}
export class RegistryCatalogPageError extends Error {
constructor(readonly failure: RegistryFailureResult) {
super("Registry catalog page request failed");
}
}
export const parseRegistryCredentialSubmission = (
response: Response,
): Promise<RegistryTaskSubmissionResult> =>
parseRegistryTaskSubmission(response);
export const parseRegistryArtifactSubmission = (
response: Response,
): Promise<RegistryTaskSubmissionResult> =>
parseRegistryTaskSubmission(response);
async function parseRegistryTaskSubmission(
response: Response,
): Promise<RegistryTaskSubmissionResult> {
if (response.status !== 202) return { status: REGISTRY_SUBMISSION.ERROR };
const parsed = taskSubmissionSchema.safeParse(
await response.json().catch(() => undefined),
);
const taskId = parsed.success ? parsed.data.data.id : undefined;
const location = response.headers.get("Content-Location");
if (
!taskId ||
location !== `${REGISTRY_TASK_PATH_PREFIX}${encodeURIComponent(taskId)}`
) {
return { status: REGISTRY_SUBMISSION.ERROR };
}
return { status: REGISTRY_SUBMISSION.PENDING, taskId };
}
export async function classifyRegistryMutationRefusal(
response: Response,
): Promise<Extract<RegistryMutationResult, { status: "refused" }> | null> {
const code = await getRegistryErrorCode(response);
const message = code
? REGISTRY_MUTATION_REFUSAL_COPY[
code as keyof typeof REGISTRY_MUTATION_REFUSAL_COPY
]
: undefined;
return message ? { status: REGISTRY_MUTATION.REFUSED, message } : null;
}
export async function classifyRegistryRemovalConflict(
response: Response,
): Promise<RegistryArtifactRemovalConflict | null> {
const code = await getRegistryErrorCode(response);
if (code === REGISTRY_REMOVAL_CONFLICT_CODE.IN_USE)
return { status: REGISTRY_ARTIFACT_REMOVAL.IN_USE };
if (code === REGISTRY_REMOVAL_CONFLICT_CODE.BUSY)
return { status: REGISTRY_ARTIFACT_REMOVAL.BUSY };
return null;
}
export async function classifyRegistryFailure(
response: Response,
endpoint: RegistryEndpoint,
credentialStatus: RegistryCredentialStatus | null,
): Promise<RegistryFailureResult> {
if (response.status === 401 || response.status === 403) {
return { status: REGISTRY_FAILURE.ACCESS_DENIED };
}
if (!isRegistryDiscoveryEndpoint(endpoint)) {
return { status: REGISTRY_FAILURE.ERROR };
}
if (
response.status === 409 &&
credentialStatus !== null &&
!isActiveRegistryCredential(credentialStatus)
) {
return { status: REGISTRY_FAILURE.ONBOARDING };
}
const code = await getRegistryErrorCode(response);
if (response.status === 502 && code === REGISTRY_ERROR_CODE.KEY_REJECTED) {
return { status: REGISTRY_FAILURE.RECONNECT };
}
if (response.status === 503 && code === REGISTRY_ERROR_CODE.UNAVAILABLE) {
return { status: REGISTRY_FAILURE.UNAVAILABLE };
}
return { status: REGISTRY_FAILURE.ERROR };
}
function isRegistryDiscoveryEndpoint(endpoint: RegistryEndpoint) {
return registryDiscoveryEndpoints.has(endpoint);
}
async function getRegistryErrorCode(response: Response) {
const parsed = errorDocumentSchema.safeParse(
await response
.clone()
.json()
.catch(() => undefined),
);
return parsed.success ? parsed.data.errors[0]?.code : undefined;
}
const REGISTRY_CATALOG_PAGE_SIZE = 100;
const REGISTRY_CATALOG_MAX_PAGES = 1000;
const safeInteger = z.number().int().nonnegative().safe();
const catalogPageSchema = z.object({
data: z.array(z.unknown()),
meta: z.object({
pagination: z.object({
page: safeInteger,
pages: safeInteger,
count: safeInteger,
}),
}),
});
const catalogAttributesSchema = z.object({
name: z.string().optional(),
description: z.string().optional(),
latest_version: z.string().optional(),
providers: z.array(z.string().trim().min(1)).optional(),
owner_name: z.string().optional(),
owner_type: z.string().optional(),
owner_logo_url: z.string().nullable().optional(),
is_verified: z.boolean().optional(),
is_official: z.boolean().optional(),
is_builtin: z.boolean().optional(),
is_meta: z.boolean().optional(),
has_provider: z.boolean().optional(),
has_checks: z.boolean().optional(),
has_compliance: z.boolean().optional(),
is_installable: z.boolean().optional(),
not_installable_reason: z.string().nullish(),
check_count: safeInteger.nullish(),
compliance_count: safeInteger.nullish(),
version_count: safeInteger.optional(),
total_downloads: safeInteger.optional(),
});
const catalogResourceSchema = z.object({
type: z.string().trim().min(1),
id: z.string().trim().min(1),
attributes: catalogAttributesSchema,
});
type RegistryCatalogPageFetcher = (
page: number,
searchParams: URLSearchParams,
) => Promise<unknown>;
export async function collectCompleteRegistryCatalog(
fetchPage: RegistryCatalogPageFetcher,
): Promise<RegistryCatalogResult> {
const resources: unknown[] = [];
let expectedPages: number | undefined;
let expectedCount: number | undefined;
for (let page = 1; ; page += 1) {
let payload: unknown;
try {
payload = await fetchPage(
page,
new URLSearchParams({
"page[number]": String(page),
"page[size]": String(REGISTRY_CATALOG_PAGE_SIZE),
}),
);
} catch (error) {
if (error instanceof RegistryCatalogPageError) throw error;
return incomplete("PAGE_FAILED", resources.length);
}
const parsed = catalogPageSchema.safeParse(payload);
if (!parsed.success) return incomplete("INVALID_PAGE", resources.length);
const { count, page: responsePage, pages } = parsed.data.meta.pagination;
if (
responsePage !== page ||
(expectedPages !== undefined &&
(pages !== expectedPages || count !== expectedCount))
)
return incomplete("INVALID_PAGE", resources.length);
expectedPages ??= pages;
expectedCount ??= count;
if (page === 1 && pages > 1 && count === 0 && parsed.data.data.length === 0)
return incomplete("INVALID_PAGE", resources.length);
if (pages === 0)
return page === 1 && count === 0 && parsed.data.data.length === 0
? { status: REGISTRY_CATALOG.COMPLETE, artifacts: [] }
: incomplete("INVALID_PAGE", resources.length);
resources.push(...parsed.data.data);
if (pages > REGISTRY_CATALOG_MAX_PAGES)
return incomplete("GUARD_EXHAUSTED", resources.length);
if (page === pages) break;
if (page > pages) return incomplete("INVALID_PAGE", resources.length);
}
const merged = mergeCatalogResources(resources);
return merged.status === REGISTRY_CATALOG.INCOMPLETE ||
resources.length === expectedCount
? merged
: incomplete("COUNT_MISMATCH", resources.length);
}
function mergeCatalogResources(resources: unknown[]): RegistryCatalogResult {
const artifacts = new Map<string, RegistryCatalogArtifact>();
for (const resource of resources) {
const artifact = adaptCatalogArtifact(resource);
if (!artifact) return incomplete("INVALID_RESOURCE", resources.length);
const prior = artifacts.get(artifact.normalizedName);
const next = prior ? mergeArtifacts(prior, artifact) : artifact;
if (!next) return incomplete("CONFLICTING_DUPLICATE", resources.length);
artifacts.set(next.normalizedName, next);
}
return {
status: REGISTRY_CATALOG.COMPLETE,
artifacts: Array.from(artifacts.values()).sort((left, right) =>
compare(left.normalizedName, right.normalizedName),
),
};
}
function adaptCatalogArtifact(
resource: unknown,
): RegistryCatalogArtifact | null {
const parsed = catalogResourceSchema.safeParse(resource);
if (!parsed.success) return null;
const { attributes: a, id } = parsed.data;
const notInstallableReason =
a.is_installable === true
? undefined
: text(a.not_installable_reason ?? undefined);
return {
normalizedName: id,
name: text(a.name),
description: text(a.description),
latestVersion: text(a.latest_version),
providers: unique(
a.providers?.map((provider) => provider.toLowerCase()) ?? [],
),
...(a.has_provider === true && a.providers?.[0]
? { providerSlug: a.providers[0].toLowerCase() }
: {}),
owners: flatOwner(a),
isVerified: a.is_verified ?? false,
isOfficial: a.is_official ?? false,
isBuiltin: a.is_builtin ?? false,
isMeta: a.is_meta ?? false,
hasProvider: a.has_provider ?? false,
hasChecks: a.has_checks ?? false,
hasCompliance: a.has_compliance ?? false,
// An older API sends no verdict; never offer an install it did not confirm.
isInstallable: a.is_installable ?? false,
...(notInstallableReason ? { notInstallableReason } : {}),
checkCount: a.check_count ?? undefined,
complianceCount: a.compliance_count ?? undefined,
versionCount: a.version_count ?? 0,
totalDownloads: a.total_downloads ?? 0,
};
}
function mergeArtifacts(
left: RegistryCatalogArtifact,
right: RegistryCatalogArtifact,
): RegistryCatalogArtifact | null {
const [name, description, latestVersion, providerSlug] = [
mergeText(left.name, right.name),
mergeText(left.description, right.description),
mergeText(left.latestVersion, right.latestVersion),
mergeText(left.providerSlug, right.providerSlug),
];
if (
[name, description, latestVersion, providerSlug].some(
(value) => value === null,
)
)
return null;
return {
...left,
name: name ?? undefined,
description: description ?? undefined,
latestVersion: latestVersion ?? undefined,
providerSlug: providerSlug ?? undefined,
providers: unique([...left.providers, ...right.providers]),
owners: uniqueOwners([...left.owners, ...right.owners]),
isVerified: left.isVerified || right.isVerified,
isOfficial: left.isOfficial || right.isOfficial,
isBuiltin: left.isBuiltin || right.isBuiltin,
isMeta: left.isMeta || right.isMeta,
hasProvider: left.hasProvider || right.hasProvider,
hasChecks: left.hasChecks || right.hasChecks,
hasCompliance: left.hasCompliance || right.hasCompliance,
// Any page refusing the install wins, and its reason travels with it.
isInstallable: left.isInstallable && right.isInstallable,
notInstallableReason:
left.notInstallableReason ?? right.notInstallableReason,
checkCount: mergeCount(left.checkCount, right.checkCount),
complianceCount: mergeCount(left.complianceCount, right.complianceCount),
versionCount: Math.max(left.versionCount, right.versionCount),
totalDownloads: Math.max(left.totalDownloads, right.totalDownloads),
};
}
function incomplete(
reason: keyof typeof REGISTRY_CATALOG_INCOMPLETE_REASON,
collectedCount: number,
): RegistryCatalogResult {
return {
status: REGISTRY_CATALOG.INCOMPLETE,
reason: REGISTRY_CATALOG_INCOMPLETE_REASON[reason],
collectedCount,
};
}
function text(value: string | undefined) {
return value?.trim() || undefined;
}
function mergeText(left: string | undefined, right: string | undefined) {
return left && right && left !== right ? null : (left ?? right);
}
function mergeCount(left: number | undefined, right: number | undefined) {
if (left === undefined) return right;
if (right === undefined) return left;
return Math.max(left, right);
}
function unique(values: string[]) {
return Array.from(new Set(values)).sort(compare);
}
function flatOwner(
a: z.infer<typeof catalogAttributesSchema>,
): RegistryCatalogArtifact["owners"] {
const name = text(a.owner_name);
if (!name) return [];
return [
{
name,
type: text(a.owner_type) ?? "",
logoUrl: text(a.owner_logo_url ?? undefined),
},
];
}
function uniqueOwners(owners: RegistryCatalogArtifact["owners"]) {
return Array.from(
new Map(
owners.map((owner) => [`${owner.type}\u0000${owner.name}`, owner]),
).values(),
).sort((left, right) =>
compare(
`${left.type}\u0000${left.name}`,
`${right.type}\u0000${right.name}`,
),
);
}
function compare(left: string, right: string) {
return left < right ? -1 : left > right ? 1 : 0;
}