Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ jobs:
- name: Validate all records against the schema
run: python scripts/validate_records.py

- name: Validate all crosswalks against the crosswalk schema
run: python scripts/validate_crosswalks.py

- name: Check every record has positive and negative conformance fixtures
run: python scripts/check_fixtures.py

Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"jsonschema>=4.23",
# Registers the "uri" and "uri-reference" format checkers. Without it,
# jsonschema parses "format": "uri" and then does nothing with it, so the
# constraint reads as enforced and is not. Chosen over rfc3987, which is
# GPL, and over the [format-nongpl] extra, which pulls in thirteen
# distributions to reach the same two checkers.
"rfc3986-validator>=0.1.1",
]

# Not a Python library -- nothing here imports "bawbel_ave" as a package.
Expand Down
197 changes: 197 additions & 0 deletions schema/crosswalk-1.0.0.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://aveproject.org/schema/crosswalk-1.0.0.schema.json",
"title": "AVE Crosswalk",
"description": "AVE (the behavioral vulnerability enumeration standard for agentic AI components) — a mapping between AVE and one other taxonomy, scanner, or standard. Schema v1.0.0. Derived from the crosswalk files already in crosswalks/: the required set is what every one of them carries, and the shared vocabulary below is typed where every file that uses a name uses it the same way. A crosswalk is a bespoke document about one pair of taxonomies, so per-pair keys are permitted throughout and are not enumerated here.",
"type": "object",
"additionalProperties": true,
"required": [
"$schema",
"source",
"target",
"generated",
"note",
"mappings",
"coverage"
],
"properties": {
"$schema": {
"type": "string",
"pattern": "^https://aveproject\\.org/schema/crosswalk-[0-9]+\\.[0-9]+\\.[0-9]+\\.schema\\.json$",
"description": "The versioned crosswalk schema this file was authored against.",
"examples": [
"https://aveproject.org/schema/crosswalk-1.0.0.schema.json"
]
},
"source": {
"$ref": "#/$defs/endpoint",
"description": "The side the mapping reads from. For a crosswalk named X-to-AVE this is X; for AVE-to-X it is AVE."
},
"target": {
"$ref": "#/$defs/endpoint",
"description": "The side the mapping reads into."
},
"generated": {
"type": "string",
"format": "date",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$",
"description": "ISO 8601 date this crosswalk's contents were last derived, e.g. 2026-07-25. The pattern is stated alongside the format keyword deliberately: format is an annotation a validator may ignore, and a date that does not parse is the failure this field exists to prevent."
},
"note": {
"type": "string",
"description": "What this crosswalk maps, what it deliberately does not, and the reading of the other taxonomy it assumes. This is the field a reader consults before trusting a row."
},
"revision_note": {
"type": "string",
"description": "What changed since the previous revision of this file, including any category the other side renamed or retitled. Optional."
},
"mappings": {
"type": "array",
"minItems": 1,
"description": "One entry per correspondence. The entry shape is per-crosswalk — a scanner crosswalk keys on its rule ids, a taxonomy crosswalk keys on category ids — so only the names shared across existing files are typed below.",
"items": {
"$ref": "#/$defs/mapping"
}
},
"gaps": {
"type": "array",
"description": "Correspondences that do not exist, each with the reason. A crosswalk that lists only what matched overstates its own coverage. Optional.",
"items": {
"$ref": "#/$defs/gap"
}
},
"coverage": {
"type": "object",
"minProperties": 1,
"description": "How much of each side the mappings account for. The counter names are per-crosswalk because the countable units differ (categories, rules, patterns, records), so this is an open object of counts, with room for one prose value explaining what the counts leave out.",
"additionalProperties": {
"type": [
"integer",
"string"
],
"minimum": 0
}
}
},
"$defs": {
"ave_id": {
"type": "string",
"pattern": "^AVE-[0-9]{4}-[0-9]{5}$",
"description": "An AVE record identifier, in the format AVE-YYYY-NNNNN defined by the record schema."
},
"endpoint": {
"type": "object",
"additionalProperties": true,
"required": [
"url"
],
"description": "One side of the mapping. Only url is universal across existing crosswalks; the rest of an endpoint's description depends on what the side is — a standard has a version and a record count, a scanner has a vendor, a licence, and a rule total.",
"properties": {
"url": {
"type": "string",
"format": "uri",
"pattern": "^https?://",
"description": "Canonical URL for this side: its project site, specification, or repository. As with generated, the pattern sits beside the format keyword because format is an annotation unless the validator is run with format checking on, and jsonschema's uri checker additionally needs the rfc3986-validator package installed. The pattern holds either way."
},
"standard": {
"type": "string",
"description": "Name of the standard, when this side is one. Optional."
},
"tool": {
"type": "string",
"description": "Name of the tool, when this side is one. Optional."
},
"vendor": {
"type": "string",
"description": "Who publishes the tool. Optional."
},
"license": {
"type": "string",
"description": "SPDX identifier for the tool's licence, e.g. Apache-2.0. Optional."
},
"version": {
"type": "string",
"description": "Version of the standard or tool this crosswalk was read against. Optional."
},
"record_count": {
"type": "integer",
"minimum": 0,
"description": "Number of AVE records this side carried when the crosswalk was derived. Meaningful only alongside commit: a count on its own cannot be re-derived, because the corpus it counts keeps moving. Optional."
},
"commit": {
"type": "string",
"pattern": "^[0-9a-f]{40}$",
"description": "Full 40-character commit sha of the repository this side was read at. Optional, and the field a stale count is diagnosed with: a crosswalk stating a record count and a read date, with no commit, cannot be checked by a reader without re-counting the upstream tree by hand. Full rather than abbreviated, because an abbreviation that is unique today is not guaranteed to stay unique in a growing repository."
},
"checked_against_live_site": {
"type": "string",
"format": "date",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$",
"description": "ISO 8601 date the other side's published site content was last verified, where that side has no repository to pin. Distinct from generated: this crosswalk's rows may have been re-derived more recently than the descriptions they were matched against. Optional."
}
}
},
"mapping": {
"type": "object",
"minProperties": 1,
"additionalProperties": true,
"description": "One correspondence. Per-crosswalk keys naming the other side's unit (a rule id, a category, a module) are expected and unconstrained; the names below are shared across existing crosswalks and are typed so that a malformed AVE identifier fails here rather than downstream.",
"properties": {
"ave_id": {
"$ref": "#/$defs/ave_id",
"description": "The AVE record this entry maps to, when exactly one does."
},
"ave_ids": {
"type": "array",
"description": "Every AVE record this entry maps to, when more than one does.",
"items": {
"$ref": "#/$defs/ave_id"
}
},
"primary_ave_id": {
"oneOf": [
{
"$ref": "#/$defs/ave_id"
},
{
"type": "null"
}
],
"description": "The closest single AVE record, where ave_ids lists several. Explicitly null when no one record is closest, which is a different statement from omitting the field."
},
"description": {
"type": "string",
"description": "What the other side's unit covers, in its own terms."
},
"notes": {
"type": "string",
"description": "How the correspondence was judged, and what it does not carry across."
},
"gap": {
"type": "string",
"description": "What this entry maps only partially, and what is left uncovered."
}
}
},
"gap": {
"type": "object",
"minProperties": 1,
"additionalProperties": true,
"description": "One correspondence that does not exist. The key naming the other side's unmapped unit is per-crosswalk; ave_id and the reason are shared.",
"properties": {
"ave_id": {
"$ref": "#/$defs/ave_id",
"description": "The AVE record that has no counterpart on the other side."
},
"reason": {
"type": "string",
"description": "Why no correspondence exists. A gap without a reason is indistinguishable from an omission."
},
"note": {
"type": "string",
"description": "Further detail on the gap."
}
}
}
}
}
121 changes: 121 additions & 0 deletions scripts/validate_crosswalks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# What: validates every file in crosswalks/ against schema/crosswalk-1.0.0.schema.json,
# the same way scripts/validate_records.py validates records/ against the record
# schema, plus two checks the schema cannot express on its own: that the $schema
# a crosswalk declares is a schema this repository actually ships, and that every
# AVE identifier a crosswalk cites resolves to a record in records/
# Why: every crosswalk in this repository declares
# https://aveproject.org/schema/crosswalk-1.0.0.schema.json and, until that file
# existed, nothing validated any of them. A dangling $schema URL is worse than
# none: it reads as validated. The identifier check is the other half -- a
# crosswalk row pointing at an AVE id that was never published, or that was
# renumbered before the immutability rule, is a broken mapping that no schema
# pattern can catch, because the id is well-formed and simply does not exist.
# How: jsonschema.Draft202012Validator with format checking enabled, so the date and
# uri formats in the schema are enforced rather than annotated, over every
# crosswalks/*.json; then a set membership test of every cited id against the
# ave_id values in records/. jsonschema implements the date checker itself but
# registers uri only when rfc3986-validator is installed, so that package is a
# dev dependency here; without it "format": "uri" parses and then constrains
# nothing, and the ^https?:// pattern beside it is the only thing refusing a
# bad url.
import json
import sys
from pathlib import Path

import jsonschema

CROSSWALKS_DIR = Path("crosswalks")
RECORDS_DIR = Path("records")
SCHEMA_PATH = Path("schema/crosswalk-1.0.0.schema.json")


def known_ave_ids() -> set[str]:
ids: set[str] = set()
for path in sorted(RECORDS_DIR.glob("AVE-*.json")):
record = json.loads(path.read_text(encoding="utf-8"))
ave_id = record.get("ave_id")
if isinstance(ave_id, str):
ids.add(ave_id)
return ids


def cited_ave_ids(node: object) -> set[str]:
"""Every value under an ave_id / primary_ave_id / ave_ids key, at any depth.

Only these three keys are read. Prose fields mention identifiers too, and a
substring sweep would flag a record named in a note as if it were a mapping
target.
"""
found: set[str] = set()
if isinstance(node, dict):
for key, value in node.items():
if key in ("ave_id", "primary_ave_id") and isinstance(value, str):
found.add(value)
elif key == "ave_ids" and isinstance(value, list):
found.update(item for item in value if isinstance(item, str))
found |= cited_ave_ids(value)
elif isinstance(node, list):
for item in node:
found |= cited_ave_ids(item)
return found


def check_schema(document: dict, validator: jsonschema.Draft202012Validator) -> list[str]:
return [f"schema: {e.message} (at {'/'.join(str(p) for p in e.path) or '<root>'})"
for e in validator.iter_errors(document)]


def check_declared_schema_is_shipped(document: dict) -> list[str]:
declared = document.get("$schema")
if not isinstance(declared, str):
return []
filename = declared.rsplit("/", 1)[-1]
if not (Path("schema") / filename).is_file():
return [f"$schema declares {declared}, which this repository does not ship "
f"as schema/{filename}"]
return []


def check_ave_ids_resolve(document: dict, published: set[str]) -> list[str]:
# Only the record-format ids are checked. ave-to-ast10.json carries free text
# under gaps_in_ast10[].ave_id ("all 56 records"), which is a prose value in a
# field whose name suggests otherwise; it is left alone here rather than
# reported as a dangling record.
return [f"cites {ave_id}, which is not a record in {RECORDS_DIR}/"
for ave_id in sorted(cited_ave_ids(document) - published)
if ave_id.startswith("AVE-") and len(ave_id) == len("AVE-0000-00000")]


def main() -> int:
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
validator = jsonschema.Draft202012Validator(
schema, format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER
)
published = known_ave_ids()

paths = sorted(CROSSWALKS_DIR.glob("*.json"))
if not paths:
print(f"FAIL: {CROSSWALKS_DIR}/ holds no .json files", file=sys.stderr)
return 1

failed = 0
for path in paths:
document = json.loads(path.read_text(encoding="utf-8"))
problems = (check_schema(document, validator)
+ check_declared_schema_is_shipped(document)
+ check_ave_ids_resolve(document, published))
if problems:
failed += 1
print(f"FAIL {path}")
for problem in problems:
print(f" {problem}")
else:
print(f"ok {path}")

print(f"\n{len(paths) - failed}/{len(paths)} crosswalk(s) valid "
f"against {SCHEMA_PATH}")
return 1 if failed else 0


if __name__ == "__main__":
sys.exit(main())
Loading