Files
prowler/ui/lib/provider-credentials/provider-credential-schema.ts

259 lines
7.6 KiB
TypeScript

const FIELD_KIND = {
TEXT: "text",
PASSWORD: "password",
SELECT: "select",
TEXTAREA: "textarea",
CHECKBOX: "checkbox",
INTEGER: "integer",
} as const;
export const REGISTRY_CREDENTIAL_SCHEMA_LIMITS = {
MAX_FIELDS: 12,
MAX_NAME_LENGTH: 50,
MAX_TEXT_LENGTH: 200,
MAX_ENUM_OPTIONS: 20,
} as const;
type FieldKind = (typeof FIELD_KIND)[keyof typeof FIELD_KIND];
export type RegistryCredentialValue = string | boolean | number;
export interface RegistryCredentialField {
readonly name: string;
readonly label: string;
readonly description?: string;
readonly kind: FieldKind;
readonly options?: readonly string[];
readonly required: boolean;
readonly defaultValue?: RegistryCredentialValue;
readonly placeholder?: string;
readonly minimum?: number;
readonly maximum?: number;
}
export interface RegistryCredentialSchema {
readonly fields: readonly RegistryCredentialField[];
}
const ROOT = new Set("type title description properties required".split(" "));
const FIELD = new Set(
"title description type format writeOnly enum default examples x-prowler-widget".split(
" ",
),
);
const BOOLEAN_FIELD = new Set("title description type default".split(" "));
const INTEGER_FIELD = new Set(
"title description type default minimum maximum".split(" "),
);
const FORBIDDEN_NAMES = new Set(["__proto__", "prototype", "constructor"]);
const FIELD_NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
// Some installed artifacts expose API keys as plain strings without secret metadata.
function isApiKeyField(name: string): boolean {
const normalizedName = name
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.replace(/-/g, "_")
.toLowerCase();
return /(?:^|_)api_?key$/.test(normalizedName);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
);
}
function isText(value: unknown, allowEmpty = false): value is string {
return (
typeof value === "string" &&
value.length <= REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_TEXT_LENGTH &&
(allowEmpty || value.length > 0)
);
}
function hasOnly(
record: Record<string, unknown>,
allowed: Set<string>,
): boolean {
for (const key in record) {
if (Object.hasOwn(record, key) && !allowed.has(key)) return false;
}
return true;
}
export function parseRegistryCredentialSchema(
value: unknown,
): RegistryCredentialSchema | null {
if (!isRecord(value) || !hasOnly(value, ROOT) || value.type !== "object") {
return null;
}
if (
(value.title !== undefined && !isText(value.title)) ||
(value.description !== undefined && typeof value.description !== "string")
) {
return null;
}
const properties = value.properties;
if (!isRecord(properties)) return null;
const entries: [string, unknown][] = [];
for (const name in properties) {
if (!Object.hasOwn(properties, name)) continue;
if (entries.length === REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_FIELDS)
return null;
entries.push([name, properties[name]]);
}
const required = value.required ?? [];
if (
!Array.isArray(required) ||
required.length > entries.length ||
!required.every((name) => typeof name === "string")
) {
return null;
}
const requiredNames = new Set(required);
if (
requiredNames.size !== required.length ||
required.some((name) => !Object.hasOwn(properties, name))
) {
return null;
}
const fields: RegistryCredentialField[] = [];
for (const [name, property] of entries) {
if (
FORBIDDEN_NAMES.has(name) ||
!FIELD_NAME.test(name) ||
name.length > REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_NAME_LENGTH ||
!isRecord(property)
) {
return null;
}
const label = property.title ?? name;
const description = property.description;
if (
!isText(label) ||
(description !== undefined && typeof description !== "string")
) {
return null;
}
const baseField = {
name,
label,
...(description ? { description } : {}),
required: requiredNames.has(name),
};
const defaultValue = property.default;
if (property.type === "boolean") {
if (
!hasOnly(property, BOOLEAN_FIELD) ||
(defaultValue !== undefined && typeof defaultValue !== "boolean")
) {
return null;
}
fields.push({
...baseField,
kind: FIELD_KIND.CHECKBOX,
...(typeof defaultValue === "boolean" ? { defaultValue } : {}),
});
continue;
}
if (property.type === "integer") {
const { minimum, maximum } = property;
if (
!hasOnly(property, INTEGER_FIELD) ||
(minimum !== undefined && !Number.isSafeInteger(minimum)) ||
(maximum !== undefined && !Number.isSafeInteger(maximum)) ||
(typeof minimum === "number" &&
typeof maximum === "number" &&
minimum > maximum) ||
(defaultValue !== undefined &&
(typeof defaultValue !== "number" ||
!Number.isSafeInteger(defaultValue) ||
(typeof minimum === "number" && defaultValue < minimum) ||
(typeof maximum === "number" && defaultValue > maximum)))
) {
return null;
}
fields.push({
...baseField,
kind: FIELD_KIND.INTEGER,
...(typeof minimum === "number" ? { minimum } : {}),
...(typeof maximum === "number" ? { maximum } : {}),
...(typeof defaultValue === "number" ? { defaultValue } : {}),
});
continue;
}
if (property.type !== "string" || !hasOnly(property, FIELD)) return null;
const format = property.format;
const widget = property["x-prowler-widget"];
const options = property.enum;
const examples = property.examples;
const password = format === "password" && property.writeOnly === true;
if (
((format !== undefined || property.writeOnly !== undefined) &&
!password) ||
(widget !== undefined && widget !== "textarea") ||
(defaultValue !== undefined && !isText(defaultValue, true)) ||
(examples !== undefined &&
(!Array.isArray(examples) ||
examples.length >
REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_ENUM_OPTIONS ||
!examples.every((example) => isText(example, true))))
) {
return null;
}
if (options !== undefined) {
if (
format !== undefined ||
widget !== undefined ||
property.writeOnly !== undefined ||
!Array.isArray(options) ||
options.length === 0 ||
options.length > REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_ENUM_OPTIONS ||
!options.every((option) => isText(option)) ||
new Set(options).size !== options.length ||
(defaultValue !== undefined && !options.includes(defaultValue))
) {
return null;
}
fields.push({
...baseField,
kind: FIELD_KIND.SELECT,
options,
...(typeof defaultValue === "string" ? { defaultValue } : {}),
});
continue;
}
if (
widget !== undefined &&
(format !== undefined || property.writeOnly !== undefined)
) {
return null;
}
fields.push({
...baseField,
kind: password
? FIELD_KIND.PASSWORD
: widget === "textarea"
? FIELD_KIND.TEXTAREA
: isApiKeyField(name)
? FIELD_KIND.PASSWORD
: FIELD_KIND.TEXT,
...(Array.isArray(examples) && typeof examples[0] === "string"
? { placeholder: examples[0] }
: {}),
...(typeof defaultValue === "string" ? { defaultValue } : {}),
});
}
return { fields };
}