Catalog design review¶
Why each type is shaped as it is. Catalog v3 reviewed every catalog v2 type against the same questions:
what real thing it models, whether one thing can get two spellings or two things one key, whether its
required properties are what tools actually emit, and whether a merge can clobber a better value with
a worse one. The answers live in each type's description, which kb_types publishes and the
catalog reference renders. This page records the verdicts and the defects the review
found, each with the regression test that holds the fix. Later versions answer the same questions for
the types they add, and mark those rows with the version: catalog v5 added advisory and package.
Defects¶
| ID | Types | Finding | Resolution | Regression test |
|---|---|---|---|---|
| B1 | txt_record, spf_record | A malformed SPF value had no home. | A value is diverted exactly when its dedicated type accepts it. | tests/test_catalog.py::test_every_version_tag_spelling_has_exactly_one_home |
| B2 | docs | The scope enumerations omitted mta_sts_policy. |
The prose is pinned to scope_relations(). |
tests/test_catalog_reference.py::test_the_parent_scope_prose_enumerates_exactly_the_scoped_types |
| D-01 | endpoint | The query was removed before validation, so a malformed query passed. | The query is removed only from a URL that validates with it. | tests/test_catalog.py::test_golden_canonicalization_cases |
| D-02 | ip_address, endpoint | IPv4-mapped IPv6 was accepted, differently per interpreter. | ip refuses every IPv4-mapped address. |
tests/test_catalog.py::test_golden_format_cases |
| D-03 | port | Port 0 was accepted. | port_number_assigned.1 refuses it; uint16 keeps 0 for SRV and MX. |
tests/test_catalog.py::test_golden_check_cases |
| D-04 | asn | AS0 was accepted. | asn_assigned.1 refuses it (RFC 7607). |
tests/test_catalog.py::test_golden_check_cases |
| D-05 | spf_record | SPF values had no length bound. | Bounded at 4096 characters. | tests/test_catalog.py::test_golden_format_cases |
| D-06 | secret, exposes_secret | The plaintext-key check was top-level and case-sensitive, and edges were unchecked. | Casefolded, recursive, and applied to the edges; the error names a pointer. | tests/test_catalog.py::test_a_plaintext_key_is_named_by_its_pointer_and_never_by_its_value |
| D-07 | endpoint | URL hosts refused underscore labels that subdomain accepts. |
A URL host accepts every subdomain spelling. | tests/test_catalog.py::test_valid_node_fields_and_boundaries |
| D-08 | catalog | Checks, canonicalizations and registries were outside the fingerprint. | Versioned ids and registry digests are fingerprinted. | tests/test_catalog.py::test_renaming_or_versioning_a_rule_id_changes_the_contract |
| D-09 | docs | The tool page claimed kb_types published rules it did not. |
kb_types publishes check ids and descriptions, and the page says so. |
tests/unit/test_graph.py::test_types_publish_what_each_type_models_and_the_rules_it_runs |
| D-10 | reference | The txt_record rule text was stale. |
Rule prose lives beside the rule and is generated. | tests/test_catalog_reference.py::test_the_page_documents_every_published_rule_with_its_types |
| D-11 | reference | The drift test compared 4 sections, and endpoint prose was hand-copied. | The page is compared byte for byte after formatting. | tests/test_catalog_reference.py::test_the_committed_page_is_what_the_generator_produces |
| D-12 | catalog | Cross-field rules ran on nodes only. | Relations declare checks as well. | tests/test_catalog.py::test_relation_checks_run_inside_record_validation |
| D-13 | finding | Title identity collided across matchers and forked on renames. | Identity is (rule, matcher) under the parent. | tests/test_identity.py::test_a_finding_is_its_rule_and_matcher_under_its_parent_whatever_its_title |
| D-14 | formats | cpe23_or_empty was published but unused. |
Replaced by cpe23, consumed by technology and runs_technology. |
tests/test_catalog.py::test_cpe23_rule_rejects_empty_legacy_uri_and_malformed_bindings |
| D-15 | several | Documented attribute conventions were not validated. | Declared as validated optional properties. | tests/test_catalog.py::test_declared_optional_properties_are_validated_when_present |
| D-16 | technology | The slug has no naming authority. | Kept; a product-level CPE anchors it, and the slug convention is documented. | tests/test_catalog.py::test_golden_check_cases |
| D-17 | ip_address, ip_cidr | version is required although derivable. |
Kept; it is filterable and compared by the containment checks. | tests/test_catalog.py::test_golden_check_cases |
| D-18 | storage | observed_at is last-writer-wins. |
Resolved in v4: records keep monotonic first_seen and last_seen. |
tests/storage/test_graph.py::test_an_older_observation_only_adds_the_properties_a_record_lacks |
| D-19 | finding | nuclei's unknown severity had no home. |
Added to the enum, documented as non-ordinal. | tests/test_catalog.py::test_enum_rule_rejection_keeps_the_published_list_spelling |
| D-20 | has_contact | A CAA iodef had no home. |
Role iodef with endpoint targets restricted to POST. |
tests/storage/test_graph.py::test_a_contact_role_is_checked_on_the_merged_edge |
| D-21 | service | The secure flag the TLS check requires was undeclared, found by the coverage harness. |
Declared as an optional boolean. | tests/test_catalog.py::test_invalid_node_types_bounds_and_noncanonical_spellings |
| D-22 | endpoint | The URL grammar refused - in paths and queries, found by the coverage fixtures. |
- is accepted wherever RFC 3986 allows it. |
tests/test_catalog.py::test_valid_node_fields_and_boundaries |
| D-23 | domain | wildcard_answer was declared on registrable domains, where no source can feed it. |
It stays on subdomain only. |
tests/test_catalog.py::test_ac10_attribute_properties_are_declared |
Node types¶
| Node | Verdict | Why |
|---|---|---|
advisory |
new in v5 | Replaces cve. One published vulnerability record keyed on its id: CVE, GHSA, or an id from an OSV database that publishes one record per vulnerability. Vendor and distribution bulletins, which bundle many CVEs, are refused. CVSS and the publication time are declared on every id; EPSS and the KEV date only on a CVE, the only id FIRST and CISA score or list. It is never a finding source. |
asn |
changed | Keyed on the number. AS0 is refused (D-04). The holder label, country and RIR are declared attributes, because RDAP publishes them for the number resource itself. |
certificate |
changed | Keyed on the DER SHA-256, the one value every scanner can compute. The validity window, subject CN, issuer DN, serial and self-signed flag are declared. Trust-store-dependent flags stay out, because they describe the observer. |
cloud_account |
new in v3 | An AWS account, GCP project or Azure subscription, keyed on (provider, account id). Each provider has one spelling, enforced by a check, because the leading zeros of an AWS id are significant. |
cloud_resource |
new in v3 | Keyed on the provider-assigned default hostname, which names one resource at a time. The 14 hostname patterns are structural and proved disjoint at import. Alias hosts are refused with a pointer to the canonical form. |
cwe |
changed | Shared vocabulary keyed on the MITRE id, and the join point for scanner-specific titles. name is declared, fed by the CWE catalog. |
dkim_record |
kept | Scoped to its domain and keyed on the selector, so the node is a current-state view of the key. A rotated key survives in evidence. |
dmarc_record |
kept | Shared by every name that publishes the same policy string. The TXT diversion guarantees each tagged spelling one home. |
domain |
changed | Registrable names only, as the bundled PSL classifies them. The wildcard flag is declared; the wildcard-answer flag is not, because a public suffix has no answer to compare (D-23). Registration facts moved to whois_registration. |
email_address |
kept | A lowercase mailbox, so one mailbox is one node although the local part is case-sensitive on the wire. Person types stay out of scope. |
endpoint |
changed | One method on one path. The query is validated, then removed (D-01), and - is a path character (D-22). The latest response's HTTP metadata is declared. A secret in a URL path stays in url, an accepted residual. |
finding |
changed | Keyed on (rule, matcher) under a per-source parent (D-13). title is a latest-wins attribute, so a template rename patches it in place. The severity enum gains a non-ordinal unknown (D-19). |
host_key |
kept | Keyed on the algorithm and the SHA-256 of the key blob. It is the strongest host-correlation pivot the catalog has. |
http_fingerprint |
kept | A clustering pivot, never an identifier. The declared kind fixes the value spelling. |
identity_tenant |
kept | Only providers with one canonical tenant spelling are in the enum. The spelling is enforced per provider. |
ip_address |
changed | Canonical compressed spelling; IPv4-mapped IPv6 is refused (D-02). Range-list classifications (CDN, WAF, cloud) are declared attributes of the address. version is kept although it is derivable (D-17). |
ip_cidr |
changed | A canonical network. The RDAP netname, country and RIR are declared. |
mta_sts_policy |
changed | Scoped because the TXT value is only a version pointer that tenants publish verbatim. The policy file's mode, max_age and sorted mx patterns are declared. |
organization |
changed | Keyed on (registry, handle), never on a free-text name. name is declared as a latest-wins attribute. |
package |
new in v5 | A package or container image the target publishes, keyed on its versionless purl in one canonical spelling per purl type, so every release is one node. An oci image keeps its registry repository in repository_url, so same-named images in two registries are two nodes. It carries inventory state, but allowed_ownership refuses dependency: a component the target runs is a technology. |
parameter |
kept | A parameter name and location, scoped to its endpoint. Values stay in evidence. |
phone |
kept | An E.164 contact. Person types stay out of scope. |
port |
changed | An open port scoped to its address. Port 0 is refused on the node while the shared uint16 format keeps it (D-03). |
registrar |
kept | Keyed on the IANA id, which survives renames. Id 0 is refused. It is now reached from a registration, not from a domain. |
repository |
changed | Keyed on host, owner and name, lowercase. The default branch, visibility, archived and fork facts are declared. |
secret |
changed | Digest only. Plaintext-bearing keys are refused in any case and at any depth (D-06). kind is optional so that a later write lacking it cannot clobber it, and the harness requires scanners to send it. |
service |
changed | A registry service name scoped to its port. The version-probe product and version, and the secure flag the TLS check requires, are declared (D-21). |
spf_record |
changed | Shared by every name that publishes the string. It is bounded at 4096 characters like every TXT rule (D-05). |
storage_bucket |
kept | Only providers with a global namespace are in the enum. It maps to a cloud_account provider for in_account. |
subdomain |
changed | A name below a registrable domain. Underscore labels are accepted left of the registrable domain. Wildcard DNS flags are declared. |
technology |
changed | A shared product slug. Only a product-level CPE is accepted on the node. The per-host version and versioned CPE live on runs_technology (D-14, D-16). |
tls_cipher_suite |
kept | Shared vocabulary keyed on the protocol version and the IANA name. It is never a finding source. |
tls_fingerprint |
kept | A stack clustering pivot. The declared kind fixes the length. |
txt_record |
kept | Generic TXT. A value is diverted exactly when its dedicated type accepts it (B1, D-10). |
whois_registration |
new in v3 | One registry-level registration, keyed on (zone, registry object id) under its domain, so a re-registration is a new node. Placeholder ids are refused. A missing handle writes no node. |
Relation types¶
| Relation | Verdict | Why |
|---|---|---|
affected_by |
kept | From a service, endpoint, finding or published package to an advisory. |
aliases |
new in v5 | Two advisory ids for one vulnerability. One edge per pair, pointing toward the CVE, then the GHSA; two CVE ids are refused. |
announced_by |
kept | A network announced by an AS; distinct from operated_by. |
authenticates |
new in v3 | From a secret to what it logs in to, keyed on (username, breach token). One pair in two breaches is two edges. The plaintext check applies. |
backed_by_bucket |
kept | A name or URL that serves from a bucket. |
caa_issue |
kept | CAA issue, with parameter names folded because the list is identity-bearing. |
caa_issuewild |
kept | CAA issuewild, as above. |
cname_to |
kept | A CNAME record; self edges are allowed. |
contains_cidr |
kept | Proper-subnet containment, checked on the merged edge. |
contains_ip |
kept | Address containment, checked on the merged edge. |
covers_name |
kept | Exact and wildcard coverage are two identities. |
dname_to |
kept | A DNAME record. |
exposes_secret |
changed | Keyed on location. The plaintext check now applies to the edge too (D-06, D-12). A cloud resource or package may expose a secret. |
federates_with |
changed | The realm's namespace_type is declared. |
has_contact |
changed | Registration roles attach to the registration. A CAA iodef report URL is an endpoint target, restricted to POST (D-20). A package has only maintainer and security emails. |
has_dkim_selector |
kept | The scope relation of dkim_record. |
has_dmarc |
kept | Attaches the policy to the name itself, not to a _dmarc node. |
has_finding |
changed | A registration, cloud account, cloud resource and package may carry findings. Shared vocabulary never does. |
has_http_fingerprint |
kept | From an endpoint to a response fingerprint. |
has_mail_exchange |
kept | Keyed on the preference. |
has_mta_sts_policy |
kept | The scope relation of mta_sts_policy. |
has_nameserver |
kept | An NS delegation. |
has_open_port |
kept | The scope relation of port. |
has_parameter |
kept | The scope relation of parameter. |
has_registration |
new in v3 | The scope relation of whois_registration. It pins the registration's zone to its domain. |
has_service |
kept | The scope relation of service. |
has_soa_primary |
kept | The SOA primary server. |
has_spf |
kept | Attaches an SPF record. |
has_srv_target |
kept | Keyed on the SRV fields. |
has_subdomain |
kept | Naming structure, checked on the merged edge. |
has_svcb_binding |
kept | HTTPS and SVCB records, written only after the SvcParams are parsed. |
has_tls_fingerprint |
kept | From a service to its stack fingerprint. |
has_txt_record |
kept | Attaches a generic TXT value. |
has_weakness |
kept | Finding or advisory to CWE. |
hosted_on |
new in v3 | A name or URL that serves from a cloud resource, typically through a CNAME to its default hostname. |
in_account |
new in v3 | A resource, bucket or cloud-registry image that belongs to an account, with the provider matched on the edge. |
issued_by |
kept | A self edge records a self-signed certificate. |
links_to |
new in v3 | Crawl links keyed on the referencing element, so an anchor and a script include are two edges. |
loads_package |
new in v5 | From an endpoint to the target's own package it loads. A third-party component stays a technology. |
operated_by |
kept | Number-resource holding, keyed on the RIR handle. |
owns_repository |
kept | An attribution claim that needs evidence. |
presents_certificate |
changed | name_mismatch is declared, because it is a fact of one presentation. |
presents_host_key |
kept | From an SSH service to its host key. |
protected_by |
kept | A CDN or WAF in the request path. Range-list classification is an address attribute instead. |
published_from |
new in v5 | From a package to the repository it is published from. Not a reliance relation, so it never blocks a rejection. |
redirects_to |
kept | Keyed on the redirect status. |
registered_through |
changed | From a registration, not a domain, so each registration keeps its own registrar. |
resolves_to |
kept | A or AAAA resolution. |
reverse_resolves_to |
kept | PTR resolution. |
runs_technology |
changed | Carries the per-host version and versioned CPE. |
serves_endpoint |
kept | From a service to an endpoint. |
supports_tls_cipher |
kept | From a service to an accepted suite. |
What is not modeled¶
Every field a covered source emits has a home or a stated reason, as the ASM and OSINT coverage page shows per field. These are the recurring reasons:
| Not modeled | Why |
|---|---|
| Person and company identities | Out of scope by decision; registrant names, leak-corpus names and addresses are non-storable. |
| A registration whose registry publishes no id | No whois_registration is written; the RDAP or WHOIS response stays evidence on the domain. |
| Cloud Run services and Cloud Functions | Neither has a one-resource default hostname; recorded as endpoints and a project cloud_account. |
| Trust-store verdicts on certificates | untrusted, revoked and expired describe the observer, not the certificate. |
| Volatile response metrics | Word, line and timing counts change on every request. |
| Advisory applicability data | NVD references, configurations and descriptions describe the advisory, not an asset. |
| Per-source first and last seen on records | Records keep one monotonic first_seen/last_seen; per-source times live on evidence. |
| Keyed secret digests | A per-workspace keyed digest needs a storage change; unsalted SHA-256 is kept for joins. |
| Cross-corpus breach equivalence | No naming authority maps one corpus's breach ids to another's. |