mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
391 lines
12 KiB
TypeScript
391 lines
12 KiB
TypeScript
import Ajv, { type ErrorObject, type ValidateFunction } from "ajv";
|
|
import yaml from "js-yaml";
|
|
|
|
import { mutedFindingsConfigFormSchema } from "@/types/formSchemas";
|
|
|
|
/**
|
|
* Validates if a string is valid YAML and returns detailed validation result
|
|
*/
|
|
export const validateYaml = (
|
|
val: string,
|
|
): { isValid: boolean; error?: string } => {
|
|
try {
|
|
const parsed = yaml.load(val);
|
|
|
|
if (parsed === null || parsed === undefined) {
|
|
return { isValid: false, error: "YAML content is empty or null" };
|
|
}
|
|
|
|
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
return {
|
|
isValid: false,
|
|
error: "YAML must be an object, not an array or primitive value",
|
|
};
|
|
}
|
|
|
|
return { isValid: true };
|
|
} catch (error: unknown) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : "Unknown YAML parsing error";
|
|
return { isValid: false, error: errorMessage };
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Validates if a YAML string contains a valid mutelist structure and returns detailed validation result
|
|
*/
|
|
export const validateMutelistYaml = (
|
|
val: string,
|
|
): { isValid: boolean; error?: string } => {
|
|
try {
|
|
const parsed = yaml.load(val) as Record<string, any>;
|
|
|
|
// yaml.load() can return null, arrays, or primitives
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
return { isValid: false, error: "YAML content must be a valid object" };
|
|
}
|
|
|
|
// Verify structure using optional chaining
|
|
const accounts = parsed.Mutelist?.Accounts;
|
|
if (!accounts || typeof accounts !== "object" || Array.isArray(accounts)) {
|
|
return {
|
|
isValid: false,
|
|
error: "Missing or invalid 'Mutelist.Accounts' structure",
|
|
};
|
|
}
|
|
|
|
const accountKeys = Object.keys(accounts);
|
|
if (accountKeys.length === 0) {
|
|
return {
|
|
isValid: false,
|
|
error: "At least one account must be defined in 'Mutelist.Accounts'",
|
|
};
|
|
}
|
|
|
|
for (const accountKey of accountKeys) {
|
|
const account = accounts[accountKey];
|
|
if (!account || typeof account !== "object" || Array.isArray(account)) {
|
|
return {
|
|
isValid: false,
|
|
error: `Account '${accountKey}' must be a valid object`,
|
|
};
|
|
}
|
|
|
|
const checks = account.Checks;
|
|
if (!checks || typeof checks !== "object" || Array.isArray(checks)) {
|
|
return {
|
|
isValid: false,
|
|
error: `Missing or invalid 'Checks' structure for account '${accountKey}'`,
|
|
};
|
|
}
|
|
|
|
const checkKeys = Object.keys(checks);
|
|
if (checkKeys.length === 0) {
|
|
return {
|
|
isValid: false,
|
|
error: `At least one check must be defined for account '${accountKey}'`,
|
|
};
|
|
}
|
|
|
|
for (const checkKey of checkKeys) {
|
|
const check = checks[checkKey];
|
|
if (!check || typeof check !== "object" || Array.isArray(check)) {
|
|
return {
|
|
isValid: false,
|
|
error: `Check '${checkKey}' in account '${accountKey}' must be a valid object`,
|
|
};
|
|
}
|
|
|
|
const { Regions: regions, Resources: resources } = check;
|
|
if (!Array.isArray(regions)) {
|
|
return {
|
|
isValid: false,
|
|
error: `'Regions' must be an array in check '${checkKey}' for account '${accountKey}'`,
|
|
};
|
|
}
|
|
if (!Array.isArray(resources)) {
|
|
return {
|
|
isValid: false,
|
|
error: `'Resources' must be an array in check '${checkKey}' for account '${accountKey}'`,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return { isValid: true };
|
|
} catch (error: unknown) {
|
|
const errorMessage =
|
|
error instanceof Error
|
|
? error.message
|
|
: "Unknown error validating mutelist structure";
|
|
return { isValid: false, error: errorMessage };
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Validates YAML using the mutelist schema and returns detailed error information
|
|
*/
|
|
export const parseYamlValidation = (
|
|
yamlString: string,
|
|
): { isValid: boolean; error?: string } => {
|
|
try {
|
|
const result = mutedFindingsConfigFormSchema.safeParse({
|
|
configuration: yamlString,
|
|
});
|
|
|
|
if (result.success) {
|
|
return { isValid: true };
|
|
} else {
|
|
const firstError = result.error.issues[0];
|
|
return {
|
|
isValid: false,
|
|
error: firstError.message,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : "Unknown validation error";
|
|
return { isValid: false, error: errorMessage };
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Converts a configuration (string or object) to YAML format
|
|
*/
|
|
export const convertToYaml = (config: string | object): string => {
|
|
if (!config) return "";
|
|
|
|
try {
|
|
// If it's already an object, convert directly to YAML
|
|
if (typeof config === "object") {
|
|
return yaml.dump(config, { indent: 2 });
|
|
}
|
|
|
|
// If it's a string, try to parse as JSON first
|
|
try {
|
|
const jsonConfig = JSON.parse(config);
|
|
return yaml.dump(jsonConfig, { indent: 2 });
|
|
} catch {
|
|
// If it's not JSON, assume it's already YAML
|
|
return config;
|
|
}
|
|
} catch (_error) {
|
|
return config.toString();
|
|
}
|
|
};
|
|
|
|
export const defaultMutedFindingsConfig = `# If no Mutelist is provided, a default one is used for AWS accounts to exclude certain predefined resources.
|
|
|
|
# The default AWS Mutelist is defined here: https://github.com/prowler-cloud/prowler/blob/master/prowler/config/aws_mutelist.yaml
|
|
|
|
Mutelist:
|
|
Accounts:
|
|
"*":
|
|
########################### AWS CONTROL TOWER ###########################
|
|
### The following entries includes all resources created by AWS Control Tower when setting up a landing zone ###
|
|
# https://docs.aws.amazon.com/controltower/latest/userguide/shared-account-resources.html #
|
|
Checks:
|
|
"awslambda_function_*":
|
|
Regions:
|
|
- "*"
|
|
Resources:
|
|
- "aws-controltower-NotificationForwarder"
|
|
Description: "Checks from AWS lambda functions muted by default"
|
|
"cloudformation_stack*":
|
|
Regions:
|
|
- "*"
|
|
Resources:
|
|
- "StackSet-AWSControlTowerGuardrailAWS-*"
|
|
- "StackSet-AWSControlTowerBP-*"
|
|
- "StackSet-AWSControlTowerSecurityResources-*"
|
|
- "StackSet-AWSControlTowerLoggingResources-*"
|
|
- "StackSet-AWSControlTowerExecutionRole-*"
|
|
- "AWSControlTowerBP-BASELINE-CLOUDTRAIL-MASTER*"
|
|
- "AWSControlTowerBP-BASELINE-CONFIG-MASTER*"
|
|
- "StackSet-AWSControlTower*"
|
|
- "CLOUDTRAIL-ENABLED-ON-SHARED-ACCOUNTS-*"
|
|
- "AFT-Backend*"
|
|
"cloudtrail_*":
|
|
Regions:
|
|
- "*"
|
|
Resources:
|
|
- "aws-controltower-BaselineCloudTrail"
|
|
"cloudwatch_log_group_*":
|
|
Regions:
|
|
- "*"
|
|
Resources:
|
|
- "aws-controltower/CloudTrailLogs"
|
|
- "/aws/lambda/aws-controltower-NotificationForwarder"
|
|
- "StackSet-AWSControlTowerBP-*"
|
|
"iam_inline_policy_no_administrative_privileges":
|
|
Regions:
|
|
- "*"
|
|
Resources:
|
|
- "aws-controltower-ForwardSnsNotificationRole/sns"
|
|
- "aws-controltower-AuditAdministratorRole/AssumeRole-aws-controltower-AuditAdministratorRole"
|
|
- "aws-controltower-AuditReadOnlyRole/AssumeRole-aws-controltower-AuditReadOnlyRole"
|
|
"iam.*policy_*":
|
|
Regions:
|
|
- "*"
|
|
Resources:
|
|
- "AWSControlTowerAccountServiceRolePolicy"
|
|
- "AWSControlTowerServiceRolePolicy"
|
|
- "AWSControlTowerStackSetRolePolicy"
|
|
- "AWSControlTowerAdminPolicy"
|
|
- "AWSLoadBalancerControllerIAMPolicy"
|
|
- "AWSControlTowerCloudTrailRolePolicy"
|
|
"iam_role_*":
|
|
Regions:
|
|
- "*"
|
|
Resources:
|
|
- "aws-controltower-AdministratorExecutionRole"
|
|
- "aws-controltower-AuditAdministratorRole"
|
|
- "aws-controltower-AuditReadOnlyRole"
|
|
- "aws-controltower-CloudWatchLogsRole"
|
|
- "aws-controltower-ConfigRecorderRole"
|
|
- "aws-controltower-ForwardSnsNotificationRole"
|
|
- "aws-controltower-ReadOnlyExecutionRole"
|
|
- "AWSControlTower_VPCFlowLogsRole"
|
|
- "AWSControlTowerExecution"
|
|
- "AWSControlTowerCloudTrailRole"
|
|
- "AWSControlTowerConfigAggregatorRoleForOrganizations"
|
|
- "AWSControlTowerStackSetRole"
|
|
- "AWSControlTowerAdmin"
|
|
- "AWSAFTAdmin"
|
|
- "AWSAFTExecution"
|
|
- "AWSAFTService"
|
|
"s3_bucket_*":
|
|
Regions:
|
|
- "*"
|
|
Resources:
|
|
- "aws-controltower-logs-*"
|
|
- "aws-controltower-s3-access-logs-*"
|
|
"sns_*":
|
|
Regions:
|
|
- "*"
|
|
Resources:
|
|
- "aws-controltower-AggregateSecurityNotifications"
|
|
- "aws-controltower-AllConfigNotifications"
|
|
- "aws-controltower-SecurityNotifications"
|
|
"vpc_*":
|
|
Regions:
|
|
- "*"
|
|
Resources:
|
|
- "*"
|
|
Tags:
|
|
- "Name=aws-controltower-VPC"`;
|
|
|
|
export interface ScanConfigurationValidationError {
|
|
path: string;
|
|
message: string;
|
|
}
|
|
|
|
export interface ScanConfigurationValidationResult {
|
|
isValid: boolean;
|
|
errors: ScanConfigurationValidationError[];
|
|
}
|
|
|
|
// Compile the JSON Schema with ajv at most once per schema reference.
|
|
const _ajv = new Ajv({ allErrors: true, strict: false });
|
|
const _validatorCache = new WeakMap<object, ValidateFunction>();
|
|
|
|
const getValidator = (schema: Record<string, unknown>): ValidateFunction => {
|
|
const cached = _validatorCache.get(schema);
|
|
if (cached) return cached;
|
|
const compiled = _ajv.compile(schema);
|
|
_validatorCache.set(schema, compiled);
|
|
return compiled;
|
|
};
|
|
|
|
const formatAjvPath = (err: ErrorObject): string => {
|
|
// instancePath is a JSON Pointer (/aws/max_unused_access_keys_days or /aws/ec2_high_risk_ports/1).
|
|
// Convert to a dotted form with list indices as [n].
|
|
const raw = err.instancePath.replace(/^\//, "");
|
|
if (!raw) {
|
|
const extra = (err.params as { additionalProperty?: string })
|
|
?.additionalProperty;
|
|
return extra ? extra : "<root>";
|
|
}
|
|
return raw
|
|
.split("/")
|
|
.map((piece) => piece.replace(/~1/g, "/").replace(/~0/g, "~"))
|
|
.reduce<string>((acc, piece) => {
|
|
if (/^\d+$/.test(piece)) return `${acc}[${piece}]`;
|
|
return acc ? `${acc}.${piece}` : piece;
|
|
}, "");
|
|
};
|
|
|
|
/**
|
|
* Validate a YAML string against the aggregated Scan Configuration JSON Schema.
|
|
*
|
|
* If `schema` is null (e.g. backend unreachable), it falls back to only
|
|
* checking that the YAML parses to a mapping — so the user is never blocked
|
|
* from saving when the schema endpoint is down.
|
|
*/
|
|
export const validateScanConfigurationPayload = (
|
|
val: string,
|
|
schema: Record<string, unknown> | null,
|
|
): ScanConfigurationValidationResult => {
|
|
const yamlCheck = validateYaml(val);
|
|
if (!yamlCheck.isValid) {
|
|
return {
|
|
isValid: false,
|
|
errors: [{ path: "<root>", message: `Invalid YAML: ${yamlCheck.error}` }],
|
|
};
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = yaml.load(val);
|
|
} catch (e) {
|
|
return {
|
|
isValid: false,
|
|
errors: [
|
|
{
|
|
path: "<root>",
|
|
message: e instanceof Error ? e.message : "Failed to parse YAML",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
return {
|
|
isValid: false,
|
|
errors: [
|
|
{
|
|
path: "<root>",
|
|
message:
|
|
"YAML must be a mapping with provider sections (aws, azure, ...).",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
if (!schema) {
|
|
return { isValid: true, errors: [] };
|
|
}
|
|
|
|
const validate = getValidator(schema);
|
|
if (validate(parsed)) {
|
|
return { isValid: true, errors: [] };
|
|
}
|
|
const errors = (validate.errors ?? []).map((err) => ({
|
|
path: formatAjvPath(err),
|
|
message: err.message ?? "Invalid value",
|
|
}));
|
|
return { isValid: false, errors };
|
|
};
|
|
|
|
export const defaultScanConfigurationYaml = `# Scan Configuration overrides the per-tenant defaults documented in
|
|
# prowler/config/config.yaml. Add only the keys you want to override.
|
|
# Allowed ranges and enums are described by the server-side JSON Schema
|
|
# served at /api/v1/scan-configurations/schema; invalid values are flagged below.
|
|
|
|
aws:
|
|
max_unused_access_keys_days: 45
|
|
|
|
# azure:
|
|
# php_latest_version: "8.2"
|
|
`;
|