mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
259 lines
7.6 KiB
TypeScript
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 };
|
|
}
|