Skip to content

Commit e2a0e44

Browse files
feat: Add Pydantic YAML validation for card files - Add pydantic as production dependency - Create validation models - Integrate validation in convert.py - Add 21 comprehensive tests - All 27 tests passing
1 parent 4c7d540 commit e2a0e44

6 files changed

Lines changed: 474 additions & 2 deletions

File tree

.clusterfuzzlite/build.sh

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,19 @@
33
# build project
44
python3 -m pip install -r requirements.txt
55
python3 -m pip install -r install_cornucopia_deps.txt
6+
python3 -m pip install pydantic==2.12.5
67

78
# Build fuzzers into $OUT. These could be detected in other ways.
89
for fuzzer in $(find "$SRC/cornucopia/tests/scripts" -name '*_fuzzer.py'); do
910
fuzzer_basename=$(basename -s .py "$fuzzer")
1011
fuzzer_package=${fuzzer_basename}.pkg
1112

12-
python3 -m PyInstaller --distpath "$OUT" --onefile --exclude IPython --paths "$SRC"/cornucopia:"$SRC"/cornucopia/scripts:"$SRC"/cornucopia/tests/test-files --hidden-import scripts --collect-submodules scripts --name "$fuzzer_package" "$fuzzer"
13+
python3 -m PyInstaller --distpath "$OUT" --onefile --exclude IPython --paths "$SRC"/cornucopia:"$SRC"/cornucopia/scripts:"$SRC"/cornucopia/tests/test-files --hidden-import scripts --collect-submodules scripts --hidden-import=pydantic --collect-submodules pydantic --name "$fuzzer_package" "$fuzzer"
1314

1415
echo "#!/bin/sh
1516
# LLVMFuzzerTestOneInput for fuzzer detection.
1617
echo "fuzzing now, this is what is here"
17-
this_dir=\$(dirname \"\$0\")
18+
this_dir=\$(dirname "\$0")
1819
ASAN_OPTIONS=\$ASAN_OPTIONS:symbolize=1:external_symbolizer_path=\$this_dir/llvm-symbolizer:detect_leaks=0 \
1920
\$this_dir/$fuzzer_package \$@" > "$OUT"/"$fuzzer_basename"
2021
chmod +x "$OUT/$fuzzer_basename"

Pipfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ pathvalidate = "==3.3.1"
3535
security = "==1.3.1"
3636
colorama = "*"
3737
mypy = "*"
38+
pydantic = "==2.12.5"
3839

3940
[requires]
4041
python_version = "3.12"

scripts/card_models.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Pydantic models for validating YAML card and mapping structures.
2+
3+
This module provides validation models for OWASP Cornucopia YAML files,
4+
ensuring structural integrity while maintaining backward compatibility.
5+
"""
6+
7+
from pydantic import BaseModel, Field, ValidationError, ConfigDict
8+
from typing import Dict, List, Optional, Any
9+
10+
11+
class Card(BaseModel):
12+
"""Minimal validation for card YAML structure.
13+
14+
Validates core required fields while allowing additional fields
15+
like capec, stride, owasp_asvs, etc. through extra="allow".
16+
"""
17+
model_config = ConfigDict(extra="allow")
18+
19+
id: str = Field(..., min_length=1, description="Required card ID (e.g. VE2, AT3)")
20+
value: str = Field(..., min_length=1, description="Required card value (e.g. 2, K, A)")
21+
desc: str = Field(..., min_length=10, description="Required card description")
22+
url: Optional[str] = None
23+
misc: Optional[str] = None
24+
25+
26+
class MappingCard(BaseModel):
27+
"""Minimal validation for mapping card structure.
28+
29+
Mapping files don't have descriptions, only id, value, and mapping data.
30+
"""
31+
model_config = ConfigDict(extra="allow")
32+
33+
id: str = Field(..., min_length=1, description="Required card ID (e.g. VE2, AT3)")
34+
value: str = Field(..., min_length=1, description="Required card value (e.g. 2, K, A)")
35+
url: Optional[str] = None
36+
37+
38+
class Suit(BaseModel):
39+
"""Validation for suit structure.
40+
41+
A suit contains an ID, name, and a collection of cards.
42+
"""
43+
model_config = ConfigDict(extra="allow")
44+
45+
id: str = Field(..., min_length=1)
46+
name: str = Field(..., min_length=1)
47+
cards: List[Card] = Field(..., min_length=1)
48+
49+
50+
class MappingSuit(BaseModel):
51+
"""Validation for mapping suit structure.
52+
53+
A mapping suit contains an ID, name, and a collection of mapping cards.
54+
"""
55+
model_config = ConfigDict(extra="allow")
56+
57+
id: str = Field(..., min_length=1)
58+
name: str = Field(..., min_length=1)
59+
cards: List[MappingCard] = Field(..., min_length=1)
60+
61+
62+
class Meta(BaseModel):
63+
"""Validation for metadata section.
64+
65+
Contains edition, component, language, and version information.
66+
Additional fields like layouts, templates, and languages are allowed.
67+
"""
68+
model_config = ConfigDict(extra="allow")
69+
70+
edition: str
71+
component: str
72+
language: str
73+
version: str
74+
75+
76+
class CardYAML(BaseModel):
77+
"""Top-level card YAML structure.
78+
79+
Represents the complete structure of a card YAML file,
80+
including metadata, suits, and optional paragraphs.
81+
"""
82+
model_config = ConfigDict(extra="allow")
83+
84+
meta: Meta
85+
suits: List[Suit] = Field(..., min_length=1)
86+
87+
88+
class MappingYAML(BaseModel):
89+
"""Top-level mapping YAML structure.
90+
91+
Represents the complete structure of a mapping YAML file,
92+
which includes additional mapping information like CAPEC, ASVS, etc.
93+
"""
94+
model_config = ConfigDict(extra="allow")
95+
96+
meta: Meta
97+
suits: List[MappingSuit] = Field(..., min_length=1)
98+
99+
100+
# Export ValidationError for convenience
101+
__all__ = ['Card', 'MappingCard', 'Suit', 'MappingSuit', 'Meta', 'CardYAML', 'MappingYAML', 'ValidationError']

scripts/convert.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@
1818
from pathvalidate.argparse import validate_filepath_arg
1919
from pathvalidate import sanitize_filepath
2020

21+
# Add parent directory to path for card_models import
22+
script_dir = os.path.dirname(os.path.abspath(__file__))
23+
parent_dir = os.path.dirname(script_dir)
24+
if parent_dir not in sys.path:
25+
sys.path.insert(0, parent_dir)
26+
27+
from scripts.card_models import CardYAML, MappingYAML, ValidationError as PydanticValidationError
28+
2129

2230
class ConvertVars:
2331
BASE_PATH = os.path.split(os.path.dirname(os.path.realpath(__file__)))[0]
@@ -636,6 +644,13 @@ def get_mapping_data_for_edition(
636644
with open(mappingfile, "r", encoding="utf-8") as f:
637645
try:
638646
data = yaml.safe_load(f)
647+
# Validate structure with Pydantic
648+
try:
649+
validated = MappingYAML(**data)
650+
data = validated.model_dump(exclude_none=True) # Convert back to dict, exclude None values
651+
except PydanticValidationError as ve:
652+
logging.warning(f"Mapping file validation warning for {mappingfile}: {ve}")
653+
# Continue with unvalidated data for backward compatibility
639654
except yaml.YAMLError as e:
640655
logging.info(f"Error loading yaml file: {mappingfile}. Error = {e}")
641656
data = {}
@@ -737,6 +752,14 @@ def get_language_data(
737752
with open(language_file, "r", encoding="utf-8") as f:
738753
try:
739754
data = yaml.safe_load(f)
755+
# Validate structure with Pydantic
756+
try:
757+
validated = CardYAML(**data)
758+
data = validated.model_dump(exclude_none=True) # Convert back to dict, exclude None values
759+
except PydanticValidationError as ve:
760+
logging.error(f"Card file validation failed for {language_file}: {ve}")
761+
# For card files, validation failure is more critical
762+
data = {}
740763
except yaml.YAMLError as e:
741764
logging.error(f"Error loading yaml file: {language_file}. Error = {e}")
742765
data = {}

0 commit comments

Comments
 (0)