252 lines
6.0 KiB
Python
252 lines
6.0 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
|
|
class MethodDefinitionError(ValueError):
|
|
pass
|
|
|
|
|
|
_RESOURCE_RE = re.compile(r"^[a-z][a-z0-9_-]*:[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
_STEP_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
_MASK_CHARSET_SIZES = {
|
|
"?l": 26,
|
|
"?u": 26,
|
|
"?d": 10,
|
|
"?s": 33,
|
|
"?a": 95,
|
|
"?b": 256,
|
|
}
|
|
_MASK_MIN_LENGTH = 8
|
|
|
|
|
|
def validate_method_definition(
|
|
definition: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
if not isinstance(definition, dict):
|
|
raise MethodDefinitionError(
|
|
"Method definition must be a JSON object"
|
|
)
|
|
|
|
if definition.get("schema_version") != 1:
|
|
raise MethodDefinitionError(
|
|
"Unsupported method definition schema version"
|
|
)
|
|
|
|
hash_mode = definition.get("hash_mode")
|
|
|
|
if not isinstance(hash_mode, int) or isinstance(hash_mode, bool):
|
|
raise MethodDefinitionError(
|
|
"Method definition hash_mode must be a non-negative integer"
|
|
)
|
|
|
|
if hash_mode < 0:
|
|
raise MethodDefinitionError(
|
|
"Method definition hash_mode must be a non-negative integer"
|
|
)
|
|
|
|
steps = definition.get("steps")
|
|
|
|
if not isinstance(steps, list) or not steps:
|
|
raise MethodDefinitionError(
|
|
"Method definition must contain a non-empty steps list"
|
|
)
|
|
|
|
seen_step_ids: set[str] = set()
|
|
|
|
for step in steps:
|
|
_validate_step(step, seen_step_ids)
|
|
|
|
return definition
|
|
|
|
|
|
def _validate_step(
|
|
step: Any,
|
|
seen_step_ids: set[str],
|
|
) -> None:
|
|
if not isinstance(step, dict):
|
|
raise MethodDefinitionError(
|
|
"Each method step must be a JSON object"
|
|
)
|
|
|
|
step_id = step.get("step_id")
|
|
|
|
if not isinstance(step_id, str) or not _STEP_ID_RE.fullmatch(step_id):
|
|
raise MethodDefinitionError(
|
|
"Each method step must have a valid step_id"
|
|
)
|
|
|
|
if step_id in seen_step_ids:
|
|
raise MethodDefinitionError(
|
|
f"Duplicate step_id: {step_id}"
|
|
)
|
|
|
|
seen_step_ids.add(step_id)
|
|
|
|
attack_mode = step.get("attack_mode")
|
|
|
|
if not isinstance(attack_mode, int) or isinstance(attack_mode, bool):
|
|
raise MethodDefinitionError(
|
|
f"Invalid attack_mode for step: {step_id}"
|
|
)
|
|
|
|
if attack_mode < 0:
|
|
raise MethodDefinitionError(
|
|
f"Invalid attack_mode for step: {step_id}"
|
|
)
|
|
|
|
if attack_mode == 0:
|
|
_require_dictionary(step, step_id)
|
|
|
|
elif attack_mode == 1:
|
|
_require_dictionaries(step, step_id)
|
|
|
|
elif attack_mode == 3:
|
|
_require_mask(
|
|
step,
|
|
step_id,
|
|
minimum_length=_MASK_MIN_LENGTH,
|
|
)
|
|
|
|
elif attack_mode == 6:
|
|
_require_dictionary(step, step_id)
|
|
_require_mask(
|
|
step,
|
|
step_id,
|
|
minimum_length=_MASK_MIN_LENGTH,
|
|
)
|
|
|
|
elif attack_mode == 7:
|
|
_require_mask(
|
|
step,
|
|
step_id,
|
|
minimum_length=_MASK_MIN_LENGTH,
|
|
)
|
|
_require_dictionary(step, step_id)
|
|
|
|
if "rules" in step:
|
|
_validate_resource_object(
|
|
step["rules"],
|
|
"rules",
|
|
step_id,
|
|
)
|
|
|
|
|
|
def _require_dictionary(
|
|
step: dict[str, Any],
|
|
step_id: str,
|
|
) -> None:
|
|
if "dictionary" not in step:
|
|
raise MethodDefinitionError(
|
|
f"Step {step_id} requires dictionary"
|
|
)
|
|
|
|
_validate_resource_object(
|
|
step["dictionary"],
|
|
"dictionary",
|
|
step_id,
|
|
)
|
|
|
|
|
|
def _require_dictionaries(
|
|
step: dict[str, Any],
|
|
step_id: str,
|
|
) -> None:
|
|
dictionaries = step.get("dictionaries")
|
|
|
|
if not isinstance(dictionaries, list) or len(dictionaries) != 2:
|
|
raise MethodDefinitionError(
|
|
f"Step {step_id} requires exactly two dictionaries"
|
|
)
|
|
|
|
for dictionary in dictionaries:
|
|
_validate_resource_object(
|
|
dictionary,
|
|
"dictionary",
|
|
step_id,
|
|
)
|
|
|
|
|
|
def _require_mask(
|
|
step: dict[str, Any],
|
|
step_id: str,
|
|
minimum_length: int | None = None,
|
|
) -> None:
|
|
mask = step.get("mask")
|
|
|
|
if not isinstance(mask, str) or not mask:
|
|
raise MethodDefinitionError(
|
|
f"Step {step_id} requires a non-empty mask"
|
|
)
|
|
|
|
length = 0
|
|
index = 0
|
|
|
|
while index < len(mask):
|
|
if mask[index] != "?":
|
|
length += 1
|
|
index += 1
|
|
continue
|
|
|
|
if index + 1 >= len(mask):
|
|
raise MethodDefinitionError(
|
|
f"Step {step_id} mask contains a lone '?' "
|
|
f"at position {index + 1}"
|
|
)
|
|
|
|
token = mask[index:index + 2]
|
|
|
|
if token == "??":
|
|
length += 1
|
|
index += 2
|
|
continue
|
|
|
|
if token not in _MASK_CHARSET_SIZES:
|
|
raise MethodDefinitionError(
|
|
f"Step {step_id} contains unsupported mask token "
|
|
f"{token!r} at position {index + 1}"
|
|
)
|
|
|
|
length += 1
|
|
index += 2
|
|
|
|
if (
|
|
minimum_length is not None
|
|
and length < minimum_length
|
|
):
|
|
raise MethodDefinitionError(
|
|
f"Step {step_id} mask must produce at least "
|
|
f"{minimum_length} characters; got {length}"
|
|
)
|
|
|
|
def _validate_resource_object(
|
|
value: Any,
|
|
resource_type: str,
|
|
step_id: str,
|
|
) -> None:
|
|
if not isinstance(value, dict):
|
|
raise MethodDefinitionError(
|
|
f"Step {step_id} {resource_type} must be an object"
|
|
)
|
|
|
|
resource = value.get("resource")
|
|
|
|
if not isinstance(resource, str) or not _RESOURCE_RE.fullmatch(resource):
|
|
raise MethodDefinitionError(
|
|
f"Step {step_id} {resource_type}.resource must be "
|
|
"a valid logical resource ID"
|
|
)
|
|
|
|
expected_prefix = (
|
|
"wordlist:"
|
|
if resource_type == "dictionary"
|
|
else f"{resource_type}:"
|
|
)
|
|
|
|
if not resource.startswith(expected_prefix):
|
|
raise MethodDefinitionError(
|
|
f"Step {step_id} {resource_type}.resource must start "
|
|
f"with {expected_prefix!r}"
|
|
)
|