mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
479 lines
16 KiB
TypeScript
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;
|
|
}
|