1use std::{
8 cmp::Ordering,
9 collections::{BTreeMap, BTreeSet},
10 fmt,
11 sync::Arc,
12};
13
14use serde::{
15 de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor},
16 Deserialize,
17};
18use serde_json::{Map, Number, Value};
19use thiserror::Error;
20use url::Url;
21
22mod asset_fingerprint;
23mod inventory;
24mod layout;
25mod wordfence_v3;
26
27pub(crate) use asset_fingerprint::valid_wordpress_asset_fingerprint_path;
28pub(crate) use wordfence_v3::MAX_WORDFENCE_V3_SOFTWARE_PER_RECORD;
29
30pub use asset_fingerprint::{
31 match_wordpress_asset_fingerprint_component, parse_wordpress_asset_fingerprint_catalog,
32 WordPressAssetFingerprintAggregateState, WordPressAssetFingerprintCatalog,
33 WordPressAssetFingerprintCatalogImport, WordPressAssetFingerprintCatalogMetadata,
34 WordPressAssetFingerprintComponent, WordPressAssetFingerprintComponentMatch,
35 WordPressAssetFingerprintError, WordPressAssetFingerprintFile, WordPressAssetFingerprintNotice,
36 WordPressAssetFingerprintProvenance, WordPressAssetFingerprintRelation,
37 WordPressAssetFingerprintRelease, WordPressAssetFingerprintReleaseMatch,
38 WordPressAssetFingerprintReleaseSource, WordPressAssetFingerprintReleaseState,
39 WordPressAssetFingerprintRepresentationProfile, WordPressAssetFingerprintResourceMatch,
40 WordPressAssetFingerprintUnknownReason, WordPressObservedAssetFingerprint,
41 MAX_WORDPRESS_ASSET_FINGERPRINT_ASSET_BYTES, MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_BYTES,
42 MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_COMPONENTS,
43 MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_FILES,
44 MAX_WORDPRESS_ASSET_FINGERPRINT_FILES_PER_RELEASE, MAX_WORDPRESS_ASSET_FINGERPRINT_PATH_BYTES,
45 MAX_WORDPRESS_ASSET_FINGERPRINT_RELEASES_PER_COMPONENT,
46 MAX_WORDPRESS_ASSET_FINGERPRINT_RESOURCES_PER_COMPONENT,
47 MAX_WORDPRESS_ASSET_FINGERPRINT_RETAINED_BYTES, WORDPRESS_ASSET_FINGERPRINT_CATALOG_SCHEMA,
48 WORDPRESS_ASSET_FINGERPRINT_REPRESENTATION_PROFILE,
49};
50
51pub use inventory::{
52 parse_wordpress_saved_inventory, WordPressInventoryCategoryStatus,
53 WordPressInventoryEntryStatus, WordPressInventoryLimitation,
54 WordPressInventoryLimitationReason, WordPressLocalInputClass, WordPressLocalInputProvenance,
55 WordPressSavedInventory, WordPressSavedInventoryCoverage, WordPressSavedInventorySummary,
56 MAX_WORDPRESS_SAVED_INVENTORY_BYTES,
57};
58pub use layout::{
59 parse_wordpress_discovery_layout, WordPressDiscoveryLayout,
60 MAX_WORDPRESS_DISCOVERY_LAYOUT_BYTES, WORDPRESS_DISCOVERY_LAYOUT_SCHEMA,
61};
62pub use wordfence_v3::{
63 parse_wordfence_v3_production, WordfenceV3AffectedRange, WordfenceV3AssociationKey,
64 WordfenceV3AssociationView, WordfenceV3Catalog, WordfenceV3Cvss, WordfenceV3CvssRating,
65 WordfenceV3Cwe, WordfenceV3IdentityCounts, WordfenceV3IdentityMapping, WordfenceV3Notice,
66 WordfenceV3ProductionError, WordfenceV3ProductionImport, WordfenceV3RangeEndpoint,
67 WordfenceV3RangeValue, WordfenceV3Record, WordfenceV3SoftwareAssociation,
68 WordfenceV3SourceComponentIdentity, WordfenceV3UnresolvedSoftwareAssociation,
69 MAX_WORDFENCE_V3_NOTICE_PARTIES, MAX_WORDFENCE_V3_PATCHED_VERSIONS,
70 MAX_WORDFENCE_V3_PRODUCTION_BYTES, MAX_WORDFENCE_V3_RANGES_PER_ASSOCIATION,
71 MAX_WORDFENCE_V3_RECORDS, MAX_WORDFENCE_V3_RECORD_BYTES, MAX_WORDFENCE_V3_REFERENCES,
72 MAX_WORDFENCE_V3_RESEARCHERS, MAX_WORDFENCE_V3_RESEARCHER_BYTES,
73 MAX_WORDFENCE_V3_RETAINED_BYTES, MAX_WORDFENCE_V3_SOFTWARE_ASSOCIATIONS,
74 WORDFENCE_V3_IDENTITY_MAPPING_POLICY, WORDFENCE_V3_MAPPING_REVISION,
75 WORDFENCE_V3_MAPPING_REVISION_V2, WORDFENCE_V3_PRODUCTION_FORMAT,
76 WORDFENCE_V3_RESOURCE_POLICY_V1, WORDFENCE_V3_RESOURCE_POLICY_V2,
77 WORDFENCE_V3_RESOURCE_POLICY_V3, WORDFENCE_V3_SOURCE_NAMESPACE,
78};
79
80use crate::wordpress_version::{
81 checked_accumulate_external_interpretation_work, ProfiledVersionError, ProfiledVersionKey,
82};
83pub use crate::wordpress_version::{
84 NumericDottedVersion, NumericDottedVersionError, PhpReleaseSubsetVersion,
85 PhpReleaseSubsetVersionError, WordPressComparisonProfile,
86};
87
88pub const WORDPRESS_CONTEXT_SCHEMA: &str = "security.wordpress-context/v1";
89pub const WORDPRESS_ADVISORY_CATALOG_SCHEMA: &str = "security.wordpress-advisory-catalog/v1";
90pub const WORDPRESS_ADVISORY_CATALOG_SCHEMA_V2: &str = "security.wordpress-advisory-catalog/v2";
91pub const MAX_WORDPRESS_CONTEXT_BYTES: usize = 1024 * 1024;
92pub const MAX_WORDPRESS_ADVISORY_CATALOG_BYTES: usize = 4 * 1024 * 1024;
93pub const MAX_WORDPRESS_CONTEXT_COMPONENTS: usize = 256;
94pub const MAX_WORDPRESS_ADVISORY_RECORDS: usize = 4_096;
95pub const MAX_WORDPRESS_RANGES_PER_ADVISORY: usize = 16;
96pub const MAX_WORDPRESS_FIXED_VERSIONS_PER_ADVISORY: usize = 16;
97pub const MAX_WORDPRESS_PREREQUISITES_PER_ADVISORY: usize = 8;
98pub const MAX_WORDPRESS_PATCH_ASSERTIONS_PER_COMPONENT: usize = 32;
99pub const MAX_WORDPRESS_SIGNALS: usize = 256;
100pub const MAX_WORDPRESS_RESULT_COMPONENTS: usize =
104 MAX_WORDPRESS_SIGNALS + MAX_WORDPRESS_CONTEXT_COMPONENTS;
105pub const MAX_WORDPRESS_RESULT_VERSION_EVIDENCE: usize =
108 MAX_WORDPRESS_SIGNALS + MAX_WORDPRESS_CONTEXT_COMPONENTS;
109pub const MAX_WORDPRESS_VERSION_COMPONENTS: usize =
110 crate::wordpress_version::MAX_WORDPRESS_VERSION_COMPONENTS;
111pub const MAX_WORDPRESS_VERSION_BYTES: usize =
112 crate::wordpress_version::MAX_WORDPRESS_VERSION_BYTES;
113pub const MAX_WORDPRESS_SLUG_BYTES: usize = 64;
114pub const MAX_WORDPRESS_IDENTIFIER_BYTES: usize = 128;
115pub const MAX_WORDPRESS_REFERENCE_BYTES: usize = 2_048;
116pub const MAX_WORDPRESS_SUMMARY_BYTES: usize = 1_024;
117pub const MAX_WORDPRESS_REMEDIATION_BYTES: usize = 2_048;
118pub const MAX_WORDPRESS_EVALUATION_WORK: usize = MAX_WORDPRESS_ADVISORY_RECORDS
119 * (MAX_WORDPRESS_RANGES_PER_ADVISORY + MAX_WORDPRESS_PREREQUISITES_PER_ADVISORY);
120pub(crate) const MAX_WORDPRESS_VERSION_RESOLUTION_WORK: usize =
121 MAX_WORDPRESS_RESULT_VERSION_EVIDENCE * 2;
122const MAX_WORDPRESS_EXTERNAL_RESULT_BYTES: usize = 16 * 1_024 * 1_024;
125pub const MAX_WORDPRESS_EXTERNAL_IDENTITY_LIMITATION_PROJECTIONS: usize = 64;
131const MAX_WORDPRESS_EXTERNAL_IDENTITY_LIMITATION_PROJECTION_BYTES: usize = 1_024 * 1_024;
132
133const MAX_CONTEXT_JSON_NODES: usize = 16_384;
134const MAX_CATALOG_JSON_NODES: usize = 262_144;
135const MAX_JSON_DEPTH: usize = 8;
136const MAX_JSON_OBJECT_FIELDS: usize = 16;
137const MAX_JSON_KEY_BYTES: usize = 64;
138const MAX_JSON_STRING_BYTES: usize = MAX_WORDPRESS_REFERENCE_BYTES;
139const DUPLICATE_KEY_MARKER: &str = "termivar-wordpress-duplicate-key";
140const JSON_LIMIT_MARKER: &str = "termivar-wordpress-json-limit";
141
142#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
143pub enum WordPressComponentKind {
144 Core,
145 Plugin,
146 Theme,
147}
148
149#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
150pub struct WordPressComponentIdentity {
151 kind: WordPressComponentKind,
152 slug: String,
153}
154
155impl WordPressComponentIdentity {
156 #[must_use]
157 pub fn core() -> Self {
158 Self {
159 kind: WordPressComponentKind::Core,
160 slug: "wordpress".to_owned(),
161 }
162 }
163
164 pub fn new(
165 kind: WordPressComponentKind,
166 slug: impl Into<String>,
167 ) -> Result<Self, WordPressReviewError> {
168 let slug = slug.into();
169 if !valid_slug(&slug)
170 || (kind == WordPressComponentKind::Core && slug != "wordpress")
171 || (kind != WordPressComponentKind::Core && slug == "wordpress")
172 {
173 return Err(WordPressReviewError::InvalidComponentIdentity);
174 }
175 Ok(Self { kind, slug })
176 }
177
178 #[must_use]
179 pub const fn kind(&self) -> WordPressComponentKind {
180 self.kind
181 }
182
183 #[must_use]
184 pub fn slug(&self) -> &str {
185 &self.slug
186 }
187}
188
189#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
190pub enum WordPressHostingOs {
191 Linux,
192 Windows,
193 Macos,
194 Bsd,
195 Other,
196}
197
198#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
199pub enum WordPressMultisiteState {
200 Enabled,
201 Disabled,
202}
203
204#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
205pub enum WordPressActivationState {
206 Active,
207 Inactive,
208 NetworkActive,
209 Unknown,
210}
211
212#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
213pub enum WordPressPatchState {
214 Applied,
215 NotApplied,
216 Unknown,
217}
218
219#[derive(Clone, Debug, Eq, PartialEq)]
220pub struct WordPressPatchAssertion {
221 id: String,
222 state: WordPressPatchState,
223}
224
225impl WordPressPatchAssertion {
226 #[must_use]
227 pub fn id(&self) -> &str {
228 &self.id
229 }
230
231 #[must_use]
232 pub const fn state(&self) -> WordPressPatchState {
233 self.state
234 }
235}
236
237#[derive(Clone, Debug, Eq, PartialEq)]
238pub struct WordPressContextComponent {
239 identity: WordPressComponentIdentity,
240 version: Option<String>,
241 activation: Option<WordPressActivationState>,
242 patches: Vec<WordPressPatchAssertion>,
243 inventory_status: Option<WordPressInventoryEntryStatus>,
244}
245
246impl WordPressContextComponent {
247 #[must_use]
248 pub const fn identity(&self) -> &WordPressComponentIdentity {
249 &self.identity
250 }
251
252 #[must_use]
253 pub fn version(&self) -> Option<&str> {
254 self.version.as_deref()
255 }
256
257 #[must_use]
258 pub const fn activation(&self) -> Option<WordPressActivationState> {
259 self.activation
260 }
261
262 #[must_use]
263 pub fn patches(&self) -> &[WordPressPatchAssertion] {
264 &self.patches
265 }
266
267 #[must_use]
268 pub const fn inventory_status(&self) -> Option<WordPressInventoryEntryStatus> {
269 self.inventory_status
270 }
271}
272
273#[derive(Clone, Debug, Eq, PartialEq)]
274pub struct WordPressContext {
275 root_url: Url,
276 hosting_os: Option<WordPressHostingOs>,
277 multisite: Option<WordPressMultisiteState>,
278 components: Vec<WordPressContextComponent>,
279}
280
281impl WordPressContext {
282 #[must_use]
283 pub const fn root_url(&self) -> &Url {
284 &self.root_url
285 }
286
287 #[must_use]
288 pub const fn hosting_os(&self) -> Option<WordPressHostingOs> {
289 self.hosting_os
290 }
291
292 #[must_use]
293 pub const fn multisite(&self) -> Option<WordPressMultisiteState> {
294 self.multisite
295 }
296
297 #[must_use]
298 pub fn components(&self) -> &[WordPressContextComponent] {
299 &self.components
300 }
301}
302
303#[derive(Clone, Debug, Eq, PartialEq)]
304pub struct WordPressCatalogMetadata {
305 id: String,
306 revision: String,
307 retrieved_on: String,
308}
309
310impl WordPressCatalogMetadata {
311 #[must_use]
312 pub fn id(&self) -> &str {
313 &self.id
314 }
315
316 #[must_use]
317 pub fn revision(&self) -> &str {
318 &self.revision
319 }
320
321 #[must_use]
322 pub fn retrieved_on(&self) -> &str {
323 &self.retrieved_on
324 }
325}
326
327#[derive(Clone, Debug, Eq, PartialEq)]
328pub struct WordPressAdvisorySource {
329 reference: String,
330 revision: String,
331 retrieved_on: String,
332 usage_basis: String,
333}
334
335impl WordPressAdvisorySource {
336 #[must_use]
337 pub fn reference(&self) -> &str {
338 &self.reference
339 }
340
341 #[must_use]
342 pub fn revision(&self) -> &str {
343 &self.revision
344 }
345
346 #[must_use]
347 pub fn retrieved_on(&self) -> &str {
348 &self.retrieved_on
349 }
350
351 #[must_use]
352 pub fn usage_basis(&self) -> &str {
353 &self.usage_basis
354 }
355}
356
357#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
358pub struct WordPressVersionEndpoint {
359 declared: String,
360 version: NumericDottedVersion,
361 inclusive: bool,
362}
363
364impl WordPressVersionEndpoint {
365 #[must_use]
366 pub fn declared(&self) -> &str {
367 &self.declared
368 }
369
370 #[must_use]
371 pub const fn version(&self) -> &NumericDottedVersion {
372 &self.version
373 }
374
375 #[must_use]
376 pub const fn inclusive(&self) -> bool {
377 self.inclusive
378 }
379}
380
381#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
382pub struct WordPressAffectedRange {
383 lower: Option<WordPressVersionEndpoint>,
384 upper: Option<WordPressVersionEndpoint>,
385}
386
387#[derive(Clone, Debug, Eq, PartialEq)]
388pub struct WordPressProfiledVersionEndpoint {
389 declared: String,
390 version: ProfiledVersionKey,
391 inclusive: bool,
392}
393
394impl WordPressProfiledVersionEndpoint {
395 #[must_use]
396 pub fn declared(&self) -> &str {
397 &self.declared
398 }
399
400 #[must_use]
401 pub const fn inclusive(&self) -> bool {
402 self.inclusive
403 }
404}
405
406#[derive(Clone, Debug, Eq, PartialEq)]
407pub struct WordPressProfiledAffectedRange {
408 lower: Option<WordPressProfiledVersionEndpoint>,
409 upper: Option<WordPressProfiledVersionEndpoint>,
410}
411
412impl WordPressProfiledAffectedRange {
413 #[must_use]
414 pub const fn lower(&self) -> Option<&WordPressProfiledVersionEndpoint> {
415 self.lower.as_ref()
416 }
417
418 #[must_use]
419 pub const fn upper(&self) -> Option<&WordPressProfiledVersionEndpoint> {
420 self.upper.as_ref()
421 }
422
423 fn contains(&self, version: &ProfiledVersionKey) -> Result<bool, ProfiledVersionError> {
424 let above_lower = self.lower.as_ref().is_none_or(|endpoint| {
425 endpoint.version.compare(version).is_ok_and(|ordering| {
426 ordering == Ordering::Less || (endpoint.inclusive && ordering == Ordering::Equal)
427 })
428 });
429 let below_upper = self.upper.as_ref().is_none_or(|endpoint| {
430 endpoint.version.compare(version).is_ok_and(|ordering| {
431 ordering == Ordering::Greater || (endpoint.inclusive && ordering == Ordering::Equal)
432 })
433 });
434 if self
435 .lower
436 .as_ref()
437 .is_some_and(|endpoint| endpoint.version.compare(version).is_err())
438 || self
439 .upper
440 .as_ref()
441 .is_some_and(|endpoint| endpoint.version.compare(version).is_err())
442 {
443 return Err(ProfiledVersionError);
444 }
445 Ok(above_lower && below_upper)
446 }
447}
448
449impl WordPressAffectedRange {
450 #[must_use]
451 pub const fn lower(&self) -> Option<&WordPressVersionEndpoint> {
452 self.lower.as_ref()
453 }
454
455 #[must_use]
456 pub const fn upper(&self) -> Option<&WordPressVersionEndpoint> {
457 self.upper.as_ref()
458 }
459
460 #[must_use]
461 pub fn contains(&self, version: &NumericDottedVersion) -> bool {
462 let above_lower = self.lower.as_ref().is_none_or(|endpoint| {
463 version > &endpoint.version || (endpoint.inclusive && version == &endpoint.version)
464 });
465 let below_upper = self.upper.as_ref().is_none_or(|endpoint| {
466 version < &endpoint.version || (endpoint.inclusive && version == &endpoint.version)
467 });
468 above_lower && below_upper
469 }
470}
471
472#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
473pub enum WordPressPrerequisite {
474 HostingOs {
475 equals: WordPressHostingOs,
476 },
477 Multisite {
478 equals: WordPressMultisiteState,
479 },
480 Activation {
481 equals: WordPressActivationState,
482 },
483 Patch {
484 id: String,
485 equals: WordPressPatchState,
486 },
487 Unsupported {
488 name: String,
489 },
490}
491
492#[derive(Clone, Debug, Eq, PartialEq)]
493pub struct WordPressAdvisoryRecord {
494 id: String,
495 component: WordPressComponentIdentity,
496 source: WordPressAdvisorySource,
497 cve: Option<String>,
498 summary: String,
499 affected_ranges: Vec<WordPressAffectedRange>,
500 profiled_affected_ranges: Vec<WordPressProfiledAffectedRange>,
501 comparison_profile: WordPressComparisonProfile,
502 fixed_versions: Vec<String>,
503 prerequisites: Vec<WordPressPrerequisite>,
504 remediation: Option<String>,
505}
506
507impl WordPressAdvisoryRecord {
508 #[must_use]
509 pub fn id(&self) -> &str {
510 &self.id
511 }
512
513 #[must_use]
514 pub const fn component(&self) -> &WordPressComponentIdentity {
515 &self.component
516 }
517
518 #[must_use]
519 pub const fn source(&self) -> &WordPressAdvisorySource {
520 &self.source
521 }
522
523 #[must_use]
524 pub fn cve(&self) -> Option<&str> {
525 self.cve.as_deref()
526 }
527
528 #[must_use]
529 pub fn summary(&self) -> &str {
530 &self.summary
531 }
532
533 #[must_use]
534 pub fn affected_ranges(&self) -> &[WordPressAffectedRange] {
535 &self.affected_ranges
536 }
537
538 #[must_use]
539 pub fn profiled_affected_ranges(&self) -> &[WordPressProfiledAffectedRange] {
540 &self.profiled_affected_ranges
541 }
542
543 #[must_use]
544 pub const fn comparison_profile(&self) -> WordPressComparisonProfile {
545 self.comparison_profile
546 }
547
548 #[must_use]
549 pub fn affected_range_count(&self) -> usize {
550 self.affected_ranges.len() + self.profiled_affected_ranges.len()
551 }
552
553 #[must_use]
554 pub fn compare_versions(&self, left: &str, right: &str) -> Option<Ordering> {
555 let left = ProfiledVersionKey::parse(self.comparison_profile, left).ok()?;
556 let right = ProfiledVersionKey::parse(self.comparison_profile, right).ok()?;
557 left.compare(&right).ok()
558 }
559
560 #[must_use]
561 pub fn fixed_versions(&self) -> &[String] {
562 &self.fixed_versions
563 }
564
565 #[must_use]
566 pub fn prerequisites(&self) -> &[WordPressPrerequisite] {
567 &self.prerequisites
568 }
569
570 #[must_use]
571 pub fn remediation(&self) -> Option<&str> {
572 self.remediation.as_deref()
573 }
574}
575
576#[derive(Clone, Debug, Eq, PartialEq)]
577pub struct WordPressAdvisoryCatalog {
578 schema: WordPressAdvisoryCatalogSchema,
579 metadata: WordPressCatalogMetadata,
580 records: Vec<WordPressAdvisoryRecord>,
581}
582
583impl WordPressAdvisoryCatalog {
584 #[must_use]
585 pub const fn schema(&self) -> WordPressAdvisoryCatalogSchema {
586 self.schema
587 }
588
589 #[must_use]
590 pub const fn metadata(&self) -> &WordPressCatalogMetadata {
591 &self.metadata
592 }
593
594 #[must_use]
595 pub fn records(&self) -> &[WordPressAdvisoryRecord] {
596 &self.records
597 }
598}
599
600#[derive(Clone, Copy, Debug, Eq, PartialEq)]
601pub enum WordPressAdvisoryCatalogSchema {
602 V1,
603 V2,
604}
605
606impl WordPressAdvisoryCatalogSchema {
607 #[must_use]
608 pub const fn id(self) -> &'static str {
609 match self {
610 Self::V1 => WORDPRESS_ADVISORY_CATALOG_SCHEMA,
611 Self::V2 => WORDPRESS_ADVISORY_CATALOG_SCHEMA_V2,
612 }
613 }
614}
615
616#[derive(Clone, Debug, Default, Eq, PartialEq)]
617pub struct WordPressReviewInputs {
618 context: Option<WordPressContext>,
619 catalog: Option<WordPressAdvisoryCatalog>,
620 wordfence_v3_catalog: Option<WordfenceV3Catalog>,
621 external_version_profile: Option<WordPressComparisonProfile>,
622 inventory_summary: Option<WordPressSavedInventorySummary>,
623 local_input_provenance: Vec<WordPressLocalInputProvenance>,
624 discovery_layout: Option<WordPressDiscoveryLayout>,
625 asset_fingerprint_catalog: Option<Arc<WordPressAssetFingerprintCatalogImport>>,
626}
627
628impl WordPressReviewInputs {
629 #[must_use]
630 pub const fn new(
631 context: Option<WordPressContext>,
632 catalog: Option<WordPressAdvisoryCatalog>,
633 ) -> Self {
634 Self {
635 context,
636 catalog,
637 wordfence_v3_catalog: None,
638 external_version_profile: None,
639 inventory_summary: None,
640 local_input_provenance: Vec::new(),
641 discovery_layout: None,
642 asset_fingerprint_catalog: None,
643 }
644 }
645
646 pub fn from_saved_inventory(
653 inventory: WordPressSavedInventory,
654 catalog: Option<WordPressAdvisoryCatalog>,
655 provenance: Vec<WordPressLocalInputProvenance>,
656 ) -> Result<Self, WordPressReviewError> {
657 let (context, inventory_summary, expected_inputs) = inventory.into_parts();
658 let local_input_provenance =
659 inventory::validate_inventory_provenance(&expected_inputs, provenance)?;
660 Ok(Self {
661 context: Some(context),
662 catalog,
663 wordfence_v3_catalog: None,
664 external_version_profile: None,
665 inventory_summary: Some(inventory_summary),
666 local_input_provenance,
667 discovery_layout: None,
668 asset_fingerprint_catalog: None,
669 })
670 }
671
672 pub fn with_wordfence_v3_catalog(
679 mut self,
680 catalog: WordfenceV3Catalog,
681 ) -> Result<Self, WordPressReviewError> {
682 if self.catalog.is_some() || self.wordfence_v3_catalog.is_some() {
683 return Err(WordPressReviewError::ConflictingAdvisoryInputs);
684 }
685 self.wordfence_v3_catalog = Some(catalog);
686 Ok(self)
687 }
688
689 pub fn with_external_version_profile(
696 mut self,
697 profile: WordPressComparisonProfile,
698 ) -> Result<Self, WordPressReviewError> {
699 if self.wordfence_v3_catalog.is_none()
700 || self.catalog.is_some()
701 || self.external_version_profile.is_some()
702 {
703 return Err(WordPressReviewError::ExternalVersionProfileRequiresWordfenceV3);
704 }
705 self.external_version_profile = Some(profile);
706 Ok(self)
707 }
708
709 pub fn with_discovery_layout(
715 mut self,
716 layout: WordPressDiscoveryLayout,
717 ) -> Result<Self, WordPressReviewError> {
718 if self.discovery_layout.is_some() {
719 return Err(WordPressReviewError::InvalidDiscoveryLayout);
720 }
721 self.discovery_layout = Some(layout);
722 Ok(self)
723 }
724
725 pub fn with_asset_fingerprint_catalog(
731 mut self,
732 catalog: WordPressAssetFingerprintCatalogImport,
733 ) -> Result<Self, WordPressReviewError> {
734 if self.asset_fingerprint_catalog.is_some() {
735 return Err(WordPressReviewError::InvalidAssetFingerprintCatalog);
736 }
737 self.asset_fingerprint_catalog = Some(Arc::new(catalog));
738 Ok(self)
739 }
740
741 #[must_use]
742 pub const fn context(&self) -> Option<&WordPressContext> {
743 self.context.as_ref()
744 }
745
746 #[must_use]
747 pub const fn catalog(&self) -> Option<&WordPressAdvisoryCatalog> {
748 self.catalog.as_ref()
749 }
750
751 #[must_use]
752 pub const fn wordfence_v3_catalog(&self) -> Option<&WordfenceV3Catalog> {
753 self.wordfence_v3_catalog.as_ref()
754 }
755
756 #[must_use]
757 pub const fn external_version_profile(&self) -> Option<WordPressComparisonProfile> {
758 self.external_version_profile
759 }
760
761 #[must_use]
762 pub const fn inventory_summary(&self) -> Option<&WordPressSavedInventorySummary> {
763 self.inventory_summary.as_ref()
764 }
765
766 #[must_use]
767 pub fn local_input_provenance(&self) -> &[WordPressLocalInputProvenance] {
768 &self.local_input_provenance
769 }
770
771 #[must_use]
772 pub const fn discovery_layout(&self) -> Option<&WordPressDiscoveryLayout> {
773 self.discovery_layout.as_ref()
774 }
775
776 #[must_use]
777 pub fn asset_fingerprint_catalog(&self) -> Option<&WordPressAssetFingerprintCatalogImport> {
778 self.asset_fingerprint_catalog.as_deref()
779 }
780
781 pub(crate) fn shared_asset_fingerprint_catalog(
782 &self,
783 ) -> Option<Arc<WordPressAssetFingerprintCatalogImport>> {
784 self.asset_fingerprint_catalog.clone()
785 }
786}
787
788#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
789pub enum WordPressEvidenceSource {
790 GeneratorMetadata,
791 SameOriginAssetPath,
792 ThemeStylesheetDeclaration,
793 OperatorContext,
794}
795
796#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
797pub enum WordPressEvidenceConfidence {
798 PublicDeclaration,
799 StructuralHint,
800 OperatorAssertion,
801}
802
803#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
804pub struct WordPressComponentSignal {
805 identity: WordPressComponentIdentity,
806 version: Option<String>,
807 source: WordPressEvidenceSource,
808}
809
810impl WordPressComponentSignal {
811 pub fn generator_metadata(version: Option<&str>) -> Result<Self, WordPressReviewError> {
812 let version = version.map(validate_source_version).transpose()?;
813 Ok(Self {
814 identity: WordPressComponentIdentity::core(),
815 version,
816 source: WordPressEvidenceSource::GeneratorMetadata,
817 })
818 }
819
820 pub fn same_origin_asset(
821 kind: WordPressComponentKind,
822 slug: &str,
823 ) -> Result<Self, WordPressReviewError> {
824 Ok(Self {
825 identity: WordPressComponentIdentity::new(kind, slug)?,
826 version: None,
827 source: WordPressEvidenceSource::SameOriginAssetPath,
828 })
829 }
830
831 pub fn theme_stylesheet_declaration(
835 slug: &str,
836 version: &str,
837 ) -> Result<Self, WordPressReviewError> {
838 Ok(Self {
839 identity: WordPressComponentIdentity::new(WordPressComponentKind::Theme, slug)?,
840 version: Some(validate_source_version(version)?),
841 source: WordPressEvidenceSource::ThemeStylesheetDeclaration,
842 })
843 }
844
845 pub fn core_asset() -> Self {
847 Self {
848 identity: WordPressComponentIdentity::core(),
849 version: None,
850 source: WordPressEvidenceSource::SameOriginAssetPath,
851 }
852 }
853
854 #[must_use]
855 pub const fn identity(&self) -> &WordPressComponentIdentity {
856 &self.identity
857 }
858
859 #[must_use]
860 pub fn version(&self) -> Option<&str> {
861 self.version.as_deref()
862 }
863
864 #[must_use]
865 pub const fn source(&self) -> WordPressEvidenceSource {
866 self.source
867 }
868
869 #[must_use]
870 pub const fn confidence(&self) -> WordPressEvidenceConfidence {
871 confidence_for_source(self.source)
872 }
873}
874
875#[derive(Clone, Copy, Debug, Eq, PartialEq)]
876pub enum WordPressComponentEvidenceClass {
877 ObservedHint,
878 OperatorSupplied,
879 Conflicting,
880 Unknown,
881}
882
883#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
884pub struct WordPressVersionEvidence {
885 value: String,
886 source: WordPressEvidenceSource,
887}
888
889impl WordPressVersionEvidence {
890 #[must_use]
891 pub fn value(&self) -> &str {
892 &self.value
893 }
894
895 #[must_use]
896 pub const fn source(&self) -> WordPressEvidenceSource {
897 self.source
898 }
899
900 #[must_use]
901 pub const fn confidence(&self) -> WordPressEvidenceConfidence {
902 confidence_for_source(self.source)
903 }
904}
905
906#[derive(Clone, Debug, Eq, PartialEq)]
907pub struct WordPressComponentAssessment {
908 identity: WordPressComponentIdentity,
909 evidence_class: WordPressComponentEvidenceClass,
910 identity_sources: Vec<WordPressEvidenceSource>,
911 confidence_classes: Vec<WordPressEvidenceConfidence>,
912 versions: Vec<WordPressVersionEvidence>,
913 activation: Option<WordPressActivationState>,
914 inventory_status: Option<WordPressInventoryEntryStatus>,
915}
916
917impl WordPressComponentAssessment {
918 #[must_use]
919 pub const fn identity(&self) -> &WordPressComponentIdentity {
920 &self.identity
921 }
922
923 #[must_use]
924 pub const fn evidence_class(&self) -> WordPressComponentEvidenceClass {
925 self.evidence_class
926 }
927
928 #[must_use]
929 pub fn identity_sources(&self) -> &[WordPressEvidenceSource] {
930 &self.identity_sources
931 }
932
933 #[must_use]
934 pub fn confidence_classes(&self) -> &[WordPressEvidenceConfidence] {
935 &self.confidence_classes
936 }
937
938 #[must_use]
939 pub fn versions(&self) -> &[WordPressVersionEvidence] {
940 &self.versions
941 }
942
943 #[must_use]
944 pub const fn activation(&self) -> Option<WordPressActivationState> {
945 self.activation
946 }
947
948 #[must_use]
949 pub const fn inventory_status(&self) -> Option<WordPressInventoryEntryStatus> {
950 self.inventory_status
951 }
952}
953
954#[derive(Clone, Copy, Debug, Eq, PartialEq)]
955pub enum WordPressCatalogStatus {
956 CatalogNotSupplied,
957 Evaluated,
958}
959
960#[derive(Clone, Copy, Debug, Eq, PartialEq)]
961pub enum WordPressVersionRelation {
962 WithinDeclaredRange,
963 OutsideDeclaredRanges,
964 Unknown,
965 Unsupported,
966}
967
968#[derive(Clone, Copy, Debug, Eq, PartialEq)]
969pub enum WordPressVersionResolution {
970 Missing,
971 SupportedEquivalent,
972 Conflicting,
973 Unsupported,
974}
975
976#[derive(Clone, Copy, Debug, Eq, PartialEq)]
977pub enum WordPressVersionResolutionReason {
978 NoVersionEvidence,
979 SingleSupportedVersion,
980 EquivalentSupportedVersions,
981 ConflictingVersionEvidence,
982 UnsupportedVersionEvidence,
983}
984
985#[derive(Clone, Copy, Debug, Eq, PartialEq)]
986pub enum WordPressPrerequisiteOutcome {
987 MatchedOnSuppliedFacts,
988 ContradictedOnSuppliedFacts,
989 Unknown,
990 Unsupported,
991}
992
993#[derive(Clone, Debug, Eq, PartialEq)]
994pub struct WordPressPrerequisiteEvaluation {
995 prerequisite: WordPressPrerequisite,
996 outcome: WordPressPrerequisiteOutcome,
997}
998
999impl WordPressPrerequisiteEvaluation {
1000 #[must_use]
1001 pub const fn prerequisite(&self) -> &WordPressPrerequisite {
1002 &self.prerequisite
1003 }
1004
1005 #[must_use]
1006 pub const fn outcome(&self) -> WordPressPrerequisiteOutcome {
1007 self.outcome
1008 }
1009}
1010
1011#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1012pub enum WordPressApplicability {
1013 CandidateMatchOnDeclaredFacts,
1014 ContradictedByDeclaredFacts,
1015 IndeterminateMissingEvidence,
1016 IndeterminateUnsupported,
1017}
1018
1019#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1020pub enum WordPressExecutionStatus {
1021 NotPerformed,
1022}
1023
1024#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1031pub enum WordPressExternalComparisonPolicy {
1032 WordfenceV3SourceSemanticsUnresolvedV1,
1033 TermivarWordfenceV3ExplicitInterpretationV1,
1034}
1035
1036impl WordPressExternalComparisonPolicy {
1037 #[must_use]
1038 pub const fn id(self) -> &'static str {
1039 match self {
1040 Self::WordfenceV3SourceSemanticsUnresolvedV1 => {
1041 "wordfence-v3/source-semantics-unresolved/v1"
1042 },
1043 Self::TermivarWordfenceV3ExplicitInterpretationV1 => {
1044 "termivar.wordfence-v3-explicit-interpretation/v1"
1045 },
1046 }
1047 }
1048}
1049
1050#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1051pub enum WordPressExternalVersionRelation {
1052 SourceComparisonSemanticsUnresolved,
1053 WithinSupportedRangeUnderSelectedPolicy,
1054 OutsideDeclaredRangesUnderSelectedPolicy,
1055 Indeterminate,
1056}
1057
1058#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1059pub enum WordPressExternalVersionRelationReason {
1060 SourceComparisonSemanticsUnresolved,
1061 ContainingRange,
1062 ContainingRangeWithPartialCoverage,
1063 AllRangesOutside,
1064 MissingVersionEvidence,
1065 ConflictingVersionEvidence,
1066 UnsupportedVersionEvidence,
1067 MissingAffectedRanges,
1068 UnsupportedAffectedRange,
1069 InvalidAffectedRange,
1070 SourcePatchedVersionWithinAffectedRange,
1071 IdentityMappingCandidate,
1072 IdentityMappingAmbiguous,
1073}
1074
1075#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1076pub enum WordPressExternalApplicability {
1077 IndeterminateUnsupported,
1078 VersionMatchUnderSelectedPolicy,
1079 NoVersionMatchUnderSelectedPolicy,
1080 Indeterminate,
1081}
1082
1083#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1084pub enum WordPressExternalRangeRelation {
1085 Contains,
1086 DoesNotContain,
1087 NotEvaluated,
1088 Unsupported,
1089 InvalidUnderProfile,
1090}
1091
1092#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1093pub enum WordPressExternalRangeReason {
1094 SelectedVersionWithinBounds,
1095 SelectedVersionOutsideBounds,
1096 MissingVersionEvidence,
1097 ConflictingVersionEvidence,
1098 UnsupportedVersionEvidence,
1099 UnsupportedLowerBound,
1100 UnsupportedUpperBound,
1101 UnsupportedBothBounds,
1102 ReversedBounds,
1103 EmptyExclusiveInterval,
1104 IdentityMappingCandidate,
1105 IdentityMappingAmbiguous,
1106}
1107
1108#[derive(Clone, Debug, Eq, PartialEq)]
1109pub struct WordPressExternalRangeEvaluation {
1110 relation: WordPressExternalRangeRelation,
1111 reason: WordPressExternalRangeReason,
1112}
1113
1114impl WordPressExternalRangeEvaluation {
1115 #[must_use]
1116 pub const fn relation(&self) -> WordPressExternalRangeRelation {
1117 self.relation
1118 }
1119
1120 #[must_use]
1121 pub const fn reason(&self) -> WordPressExternalRangeReason {
1122 self.reason
1123 }
1124}
1125
1126#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1129pub enum WordPressExternalVersionEvidenceStatus {
1130 Missing,
1131 SingleDeclaration,
1132 RepeatedExactDeclaration,
1133 MultipleDistinctDeclarations,
1134}
1135
1136#[derive(Clone, Debug, Eq, PartialEq)]
1137pub struct WordPressExternalVersionEvidenceResolution {
1138 status: WordPressExternalVersionEvidenceStatus,
1139 evidence_row_count: usize,
1140 distinct_spelling_count: usize,
1141 semantic_status: Option<WordPressVersionResolution>,
1142 semantic_reason: Option<WordPressVersionResolutionReason>,
1143}
1144
1145impl WordPressExternalVersionEvidenceResolution {
1146 #[must_use]
1147 pub const fn status(&self) -> WordPressExternalVersionEvidenceStatus {
1148 self.status
1149 }
1150
1151 #[must_use]
1152 pub const fn evidence_row_count(&self) -> usize {
1153 self.evidence_row_count
1154 }
1155
1156 #[must_use]
1157 pub const fn distinct_spelling_count(&self) -> usize {
1158 self.distinct_spelling_count
1159 }
1160
1161 #[must_use]
1162 pub const fn semantic_status(&self) -> Option<WordPressVersionResolution> {
1163 self.semantic_status
1164 }
1165
1166 #[must_use]
1167 pub const fn semantic_reason(&self) -> Option<WordPressVersionResolutionReason> {
1168 self.semantic_reason
1169 }
1170}
1171
1172#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
1179pub struct WordPressExternalAssociationKey {
1180 source_namespace: String,
1181 upstream_id: String,
1182 component: WordPressComponentIdentity,
1183 source_component: WordfenceV3SourceComponentIdentity,
1184 source_association_sha256: [u8; 32],
1185}
1186
1187impl WordPressExternalAssociationKey {
1188 #[must_use]
1189 pub fn source_namespace(&self) -> &str {
1190 &self.source_namespace
1191 }
1192
1193 #[must_use]
1194 pub fn upstream_id(&self) -> &str {
1195 &self.upstream_id
1196 }
1197
1198 #[must_use]
1199 pub const fn component(&self) -> &WordPressComponentIdentity {
1200 &self.component
1201 }
1202
1203 #[must_use]
1204 pub const fn source_component(&self) -> &WordfenceV3SourceComponentIdentity {
1205 &self.source_component
1206 }
1207
1208 #[must_use]
1209 pub const fn source_association_sha256(&self) -> &[u8; 32] {
1210 &self.source_association_sha256
1211 }
1212}
1213
1214#[derive(Clone, Debug, Eq, PartialEq)]
1215pub struct WordPressExternalReviewCounts {
1216 parsed_records: usize,
1217 software_associations: usize,
1218 exact_identity_associations: usize,
1219 candidate_identity_associations: usize,
1220 ambiguous_identity_associations: usize,
1221 unresolved_identity_associations: usize,
1222 projected_identity_limitations: usize,
1223 unprojected_identity_limitations: usize,
1224 selected_associations: usize,
1225 evaluable_associations: usize,
1226 unsupported_associations: usize,
1227 excluded_associations: usize,
1228 mapped_unselected_associations: usize,
1229 within_associations: usize,
1230 outside_associations: usize,
1231 indeterminate_associations: usize,
1232 selected_ranges: usize,
1233 evaluated_ranges: usize,
1234 containing_ranges: usize,
1235 noncontaining_ranges: usize,
1236 unsupported_ranges: usize,
1237 invalid_ranges: usize,
1238 not_evaluated_ranges: usize,
1239 partial_range_coverage_associations: usize,
1240}
1241
1242impl WordPressExternalReviewCounts {
1243 #[must_use]
1244 pub const fn parsed_records(&self) -> usize {
1245 self.parsed_records
1246 }
1247
1248 #[must_use]
1249 pub const fn software_associations(&self) -> usize {
1250 self.software_associations
1251 }
1252
1253 #[must_use]
1254 pub const fn exact_identity_associations(&self) -> usize {
1255 self.exact_identity_associations
1256 }
1257
1258 #[must_use]
1259 pub const fn candidate_identity_associations(&self) -> usize {
1260 self.candidate_identity_associations
1261 }
1262
1263 #[must_use]
1264 pub const fn ambiguous_identity_associations(&self) -> usize {
1265 self.ambiguous_identity_associations
1266 }
1267
1268 #[must_use]
1269 pub const fn unresolved_identity_associations(&self) -> usize {
1270 self.unresolved_identity_associations
1271 }
1272
1273 #[must_use]
1274 pub const fn projected_identity_limitations(&self) -> usize {
1275 self.projected_identity_limitations
1276 }
1277
1278 #[must_use]
1279 pub const fn unprojected_identity_limitations(&self) -> usize {
1280 self.unprojected_identity_limitations
1281 }
1282
1283 #[must_use]
1284 pub const fn selected_associations(&self) -> usize {
1285 self.selected_associations
1286 }
1287
1288 #[must_use]
1289 pub const fn evaluable_associations(&self) -> usize {
1290 self.evaluable_associations
1291 }
1292
1293 #[must_use]
1294 pub const fn unsupported_associations(&self) -> usize {
1295 self.unsupported_associations
1296 }
1297
1298 #[must_use]
1299 pub const fn excluded_associations(&self) -> usize {
1300 self.excluded_associations
1301 }
1302
1303 #[must_use]
1304 pub const fn mapped_unselected_associations(&self) -> usize {
1305 self.mapped_unselected_associations
1306 }
1307
1308 #[must_use]
1309 pub const fn within_associations(&self) -> usize {
1310 self.within_associations
1311 }
1312
1313 #[must_use]
1314 pub const fn outside_associations(&self) -> usize {
1315 self.outside_associations
1316 }
1317
1318 #[must_use]
1319 pub const fn indeterminate_associations(&self) -> usize {
1320 self.indeterminate_associations
1321 }
1322
1323 #[must_use]
1324 pub const fn selected_ranges(&self) -> usize {
1325 self.selected_ranges
1326 }
1327
1328 #[must_use]
1329 pub const fn evaluated_ranges(&self) -> usize {
1330 self.evaluated_ranges
1331 }
1332
1333 #[must_use]
1334 pub const fn containing_ranges(&self) -> usize {
1335 self.containing_ranges
1336 }
1337
1338 #[must_use]
1339 pub const fn noncontaining_ranges(&self) -> usize {
1340 self.noncontaining_ranges
1341 }
1342
1343 #[must_use]
1344 pub const fn unsupported_ranges(&self) -> usize {
1345 self.unsupported_ranges
1346 }
1347
1348 #[must_use]
1349 pub const fn invalid_ranges(&self) -> usize {
1350 self.invalid_ranges
1351 }
1352
1353 #[must_use]
1354 pub const fn not_evaluated_ranges(&self) -> usize {
1355 self.not_evaluated_ranges
1356 }
1357
1358 #[must_use]
1359 pub const fn partial_range_coverage_associations(&self) -> usize {
1360 self.partial_range_coverage_associations
1361 }
1362}
1363
1364#[derive(Clone, Debug, Eq, PartialEq)]
1368pub struct WordPressExternalAdvisoryEvaluation {
1369 key: WordPressExternalAssociationKey,
1370 identity_mapping: WordfenceV3IdentityMapping,
1371 identity_collision_raw_count: Option<usize>,
1372 title: String,
1373 informational: bool,
1374 description: String,
1375 references: Vec<String>,
1376 cwe: Option<WordfenceV3Cwe>,
1377 cvss: Option<WordfenceV3Cvss>,
1378 cve: Option<String>,
1379 cve_link: Option<String>,
1380 researchers: Vec<String>,
1381 published: Option<String>,
1382 updated: Option<String>,
1383 notice_ids: Vec<String>,
1384 display_name: String,
1385 affected_ranges: Vec<WordfenceV3AffectedRange>,
1386 range_evaluations: Option<Vec<WordPressExternalRangeEvaluation>>,
1387 source_patched: bool,
1388 source_patched_versions: Vec<String>,
1389 source_remediation: String,
1390 component_evidence: WordPressComponentEvidenceClass,
1391 version_evidence_resolution: WordPressExternalVersionEvidenceResolution,
1392 comparison_policy: WordPressExternalComparisonPolicy,
1393 version_relation: WordPressExternalVersionRelation,
1394 version_relation_reason: WordPressExternalVersionRelationReason,
1395 applicability: WordPressExternalApplicability,
1396}
1397
1398#[derive(Clone, Debug, Eq, PartialEq)]
1403pub struct WordPressExternalIdentityLimitation {
1404 upstream_id: String,
1405 source_component: WordfenceV3SourceComponentIdentity,
1406 source_association_sha256: [u8; 32],
1407 title: String,
1408 display_name: String,
1409 references: Vec<String>,
1410 affected_ranges: Vec<WordfenceV3AffectedRange>,
1411 source_patched: bool,
1412 source_patched_versions: Vec<String>,
1413 source_remediation: String,
1414 notice_ids: Vec<String>,
1415}
1416
1417impl WordPressExternalIdentityLimitation {
1418 #[must_use]
1419 pub fn upstream_id(&self) -> &str {
1420 &self.upstream_id
1421 }
1422
1423 #[must_use]
1424 pub const fn source_component(&self) -> &WordfenceV3SourceComponentIdentity {
1425 &self.source_component
1426 }
1427
1428 #[must_use]
1429 pub const fn source_association_sha256(&self) -> &[u8; 32] {
1430 &self.source_association_sha256
1431 }
1432
1433 #[must_use]
1434 pub fn title(&self) -> &str {
1435 &self.title
1436 }
1437
1438 #[must_use]
1439 pub fn display_name(&self) -> &str {
1440 &self.display_name
1441 }
1442
1443 #[must_use]
1444 pub fn references(&self) -> &[String] {
1445 &self.references
1446 }
1447
1448 #[must_use]
1449 pub fn affected_ranges(&self) -> &[WordfenceV3AffectedRange] {
1450 &self.affected_ranges
1451 }
1452
1453 #[must_use]
1454 pub const fn source_patched(&self) -> bool {
1455 self.source_patched
1456 }
1457
1458 #[must_use]
1459 pub fn source_patched_versions(&self) -> &[String] {
1460 &self.source_patched_versions
1461 }
1462
1463 #[must_use]
1464 pub fn source_remediation(&self) -> &str {
1465 &self.source_remediation
1466 }
1467
1468 #[must_use]
1469 pub fn notice_ids(&self) -> &[String] {
1470 &self.notice_ids
1471 }
1472}
1473
1474impl WordPressExternalAdvisoryEvaluation {
1475 #[must_use]
1476 pub const fn key(&self) -> &WordPressExternalAssociationKey {
1477 &self.key
1478 }
1479 #[must_use]
1480 pub const fn identity_mapping(&self) -> WordfenceV3IdentityMapping {
1481 self.identity_mapping
1482 }
1483 #[must_use]
1484 pub const fn identity_collision_raw_count(&self) -> Option<usize> {
1485 self.identity_collision_raw_count
1486 }
1487 #[must_use]
1488 pub fn title(&self) -> &str {
1489 &self.title
1490 }
1491 #[must_use]
1492 pub const fn informational(&self) -> bool {
1493 self.informational
1494 }
1495 #[must_use]
1496 pub fn description(&self) -> &str {
1497 &self.description
1498 }
1499 #[must_use]
1500 pub fn references(&self) -> &[String] {
1501 &self.references
1502 }
1503 #[must_use]
1504 pub const fn cwe(&self) -> Option<&WordfenceV3Cwe> {
1505 self.cwe.as_ref()
1506 }
1507 #[must_use]
1508 pub const fn cvss(&self) -> Option<&WordfenceV3Cvss> {
1509 self.cvss.as_ref()
1510 }
1511 #[must_use]
1512 pub fn cve(&self) -> Option<&str> {
1513 self.cve.as_deref()
1514 }
1515 #[must_use]
1516 pub fn cve_link(&self) -> Option<&str> {
1517 self.cve_link.as_deref()
1518 }
1519 #[must_use]
1520 pub fn researchers(&self) -> &[String] {
1521 &self.researchers
1522 }
1523 #[must_use]
1524 pub fn published(&self) -> Option<&str> {
1525 self.published.as_deref()
1526 }
1527 #[must_use]
1528 pub fn updated(&self) -> Option<&str> {
1529 self.updated.as_deref()
1530 }
1531 #[must_use]
1532 pub fn notice_ids(&self) -> &[String] {
1533 &self.notice_ids
1534 }
1535 #[must_use]
1536 pub fn display_name(&self) -> &str {
1537 &self.display_name
1538 }
1539 #[must_use]
1540 pub fn affected_ranges(&self) -> &[WordfenceV3AffectedRange] {
1541 &self.affected_ranges
1542 }
1543 #[must_use]
1544 pub fn range_evaluations(&self) -> Option<&[WordPressExternalRangeEvaluation]> {
1545 self.range_evaluations.as_deref()
1546 }
1547 #[must_use]
1548 pub const fn source_patched(&self) -> bool {
1549 self.source_patched
1550 }
1551 #[must_use]
1552 pub fn source_patched_versions(&self) -> &[String] {
1553 &self.source_patched_versions
1554 }
1555 #[must_use]
1556 pub fn source_remediation(&self) -> &str {
1557 &self.source_remediation
1558 }
1559 #[must_use]
1560 pub const fn component_evidence(&self) -> WordPressComponentEvidenceClass {
1561 self.component_evidence
1562 }
1563 #[must_use]
1564 pub const fn version_evidence_resolution(&self) -> &WordPressExternalVersionEvidenceResolution {
1565 &self.version_evidence_resolution
1566 }
1567 #[must_use]
1568 pub const fn comparison_policy(&self) -> WordPressExternalComparisonPolicy {
1569 self.comparison_policy
1570 }
1571 #[must_use]
1572 pub const fn version_relation(&self) -> WordPressExternalVersionRelation {
1573 self.version_relation
1574 }
1575 #[must_use]
1576 pub const fn version_relation_reason(&self) -> WordPressExternalVersionRelationReason {
1577 self.version_relation_reason
1578 }
1579 #[must_use]
1580 pub const fn applicability(&self) -> WordPressExternalApplicability {
1581 self.applicability
1582 }
1583 #[must_use]
1584 pub const fn exploit_execution(&self) -> WordPressExecutionStatus {
1585 WordPressExecutionStatus::NotPerformed
1586 }
1587 #[must_use]
1588 pub const fn impact_validation(&self) -> WordPressExecutionStatus {
1589 WordPressExecutionStatus::NotPerformed
1590 }
1591}
1592
1593#[derive(Clone, Debug, Eq, PartialEq)]
1594pub struct WordPressExternalReview {
1595 byte_length: u64,
1596 sha256: [u8; 32],
1597 semantic_sha256: [u8; 32],
1598 retained_bytes: usize,
1599 mapping_revision: &'static str,
1600 resource_policy: &'static str,
1601 identity_counts: WordfenceV3IdentityCounts,
1602 comparison_policy: WordPressExternalComparisonPolicy,
1603 comparison_profile: Option<WordPressComparisonProfile>,
1604 counts: WordPressExternalReviewCounts,
1605 notices: Vec<WordfenceV3Notice>,
1606 evaluations: Vec<WordPressExternalAdvisoryEvaluation>,
1607 identity_limitations: Vec<WordPressExternalIdentityLimitation>,
1608}
1609
1610impl WordPressExternalReview {
1611 #[must_use]
1612 pub const fn source_namespace(&self) -> &'static str {
1613 WORDFENCE_V3_SOURCE_NAMESPACE
1614 }
1615 #[must_use]
1616 pub const fn source_format(&self) -> &'static str {
1617 WORDFENCE_V3_PRODUCTION_FORMAT
1618 }
1619 #[must_use]
1620 pub const fn mapping_revision(&self) -> &'static str {
1621 self.mapping_revision
1622 }
1623 #[must_use]
1624 pub const fn identity_mapping_policy(&self) -> &'static str {
1625 WORDFENCE_V3_IDENTITY_MAPPING_POLICY
1626 }
1627 #[must_use]
1628 pub const fn identity_source_assurance(&self) -> &'static str {
1629 "not_established"
1630 }
1631 #[must_use]
1632 pub const fn resource_policy(&self) -> &'static str {
1633 self.resource_policy
1634 }
1635 #[must_use]
1636 pub const fn identity_counts(&self) -> &WordfenceV3IdentityCounts {
1637 &self.identity_counts
1638 }
1639 #[must_use]
1640 pub fn requires_audit_v6(&self) -> bool {
1641 self.mapping_revision == WORDFENCE_V3_MAPPING_REVISION_V2
1642 || self.resource_policy != WORDFENCE_V3_RESOURCE_POLICY_V1
1643 }
1644 #[must_use]
1645 pub const fn byte_length(&self) -> u64 {
1646 self.byte_length
1647 }
1648 #[must_use]
1649 pub const fn sha256(&self) -> &[u8; 32] {
1650 &self.sha256
1651 }
1652 #[must_use]
1653 pub const fn semantic_sha256(&self) -> &[u8; 32] {
1654 &self.semantic_sha256
1655 }
1656 #[must_use]
1657 pub const fn retained_bytes(&self) -> usize {
1658 self.retained_bytes
1659 }
1660 #[must_use]
1661 pub const fn comparison_policy(&self) -> WordPressExternalComparisonPolicy {
1662 self.comparison_policy
1663 }
1664 #[must_use]
1665 pub const fn comparison_profile(&self) -> Option<WordPressComparisonProfile> {
1666 self.comparison_profile
1667 }
1668 #[must_use]
1669 pub const fn counts(&self) -> &WordPressExternalReviewCounts {
1670 &self.counts
1671 }
1672 #[must_use]
1673 pub fn notices(&self) -> &[WordfenceV3Notice] {
1674 &self.notices
1675 }
1676 #[must_use]
1677 pub fn evaluations(&self) -> &[WordPressExternalAdvisoryEvaluation] {
1678 &self.evaluations
1679 }
1680
1681 #[must_use]
1682 pub fn identity_limitations(&self) -> &[WordPressExternalIdentityLimitation] {
1683 &self.identity_limitations
1684 }
1685}
1686
1687#[derive(Clone, Debug, Eq, PartialEq)]
1688pub struct WordPressAdvisoryEvaluation {
1689 record: WordPressAdvisoryRecord,
1690 component_evidence: WordPressComponentEvidenceClass,
1691 version_relation: WordPressVersionRelation,
1692 version_resolution: Option<WordPressVersionResolution>,
1693 version_resolution_reason: Option<WordPressVersionResolutionReason>,
1694 prerequisites: Vec<WordPressPrerequisiteEvaluation>,
1695 applicability: WordPressApplicability,
1696}
1697
1698impl WordPressAdvisoryEvaluation {
1699 #[must_use]
1700 pub const fn record(&self) -> &WordPressAdvisoryRecord {
1701 &self.record
1702 }
1703
1704 #[must_use]
1705 pub const fn component_evidence(&self) -> WordPressComponentEvidenceClass {
1706 self.component_evidence
1707 }
1708
1709 #[must_use]
1710 pub const fn version_relation(&self) -> WordPressVersionRelation {
1711 self.version_relation
1712 }
1713
1714 #[must_use]
1715 pub const fn version_resolution(&self) -> Option<WordPressVersionResolution> {
1716 self.version_resolution
1717 }
1718
1719 #[must_use]
1720 pub const fn version_resolution_reason(&self) -> Option<WordPressVersionResolutionReason> {
1721 self.version_resolution_reason
1722 }
1723
1724 #[must_use]
1725 pub fn prerequisites(&self) -> &[WordPressPrerequisiteEvaluation] {
1726 &self.prerequisites
1727 }
1728
1729 #[must_use]
1730 pub const fn applicability(&self) -> WordPressApplicability {
1731 self.applicability
1732 }
1733
1734 #[must_use]
1735 pub const fn exploit_execution(&self) -> WordPressExecutionStatus {
1736 WordPressExecutionStatus::NotPerformed
1737 }
1738
1739 #[must_use]
1740 pub const fn impact_validation(&self) -> WordPressExecutionStatus {
1741 WordPressExecutionStatus::NotPerformed
1742 }
1743}
1744
1745#[derive(Clone, Debug, Eq, PartialEq)]
1746pub struct WordPressReviewResult {
1747 catalog_status: WordPressCatalogStatus,
1748 catalog_schema: Option<WordPressAdvisoryCatalogSchema>,
1749 catalog_metadata: Option<WordPressCatalogMetadata>,
1750 components: Vec<WordPressComponentAssessment>,
1751 advisories: Vec<WordPressAdvisoryEvaluation>,
1752 external_review: Option<WordPressExternalReview>,
1753 inventory_summary: Option<WordPressSavedInventorySummary>,
1754 local_input_provenance: Vec<WordPressLocalInputProvenance>,
1755}
1756
1757impl WordPressReviewResult {
1758 #[must_use]
1759 pub const fn catalog_status(&self) -> WordPressCatalogStatus {
1760 self.catalog_status
1761 }
1762
1763 #[must_use]
1764 pub const fn catalog_schema(&self) -> Option<WordPressAdvisoryCatalogSchema> {
1765 self.catalog_schema
1766 }
1767
1768 #[must_use]
1769 pub const fn catalog_metadata(&self) -> Option<&WordPressCatalogMetadata> {
1770 self.catalog_metadata.as_ref()
1771 }
1772
1773 #[must_use]
1774 pub fn components(&self) -> &[WordPressComponentAssessment] {
1775 &self.components
1776 }
1777
1778 #[must_use]
1779 pub fn advisories(&self) -> &[WordPressAdvisoryEvaluation] {
1780 &self.advisories
1781 }
1782
1783 #[must_use]
1784 pub const fn external_review(&self) -> Option<&WordPressExternalReview> {
1785 self.external_review.as_ref()
1786 }
1787
1788 #[must_use]
1789 pub const fn inventory_summary(&self) -> Option<&WordPressSavedInventorySummary> {
1790 self.inventory_summary.as_ref()
1791 }
1792
1793 #[must_use]
1794 pub fn local_input_provenance(&self) -> &[WordPressLocalInputProvenance] {
1795 &self.local_input_provenance
1796 }
1797}
1798
1799#[derive(Clone, Debug, Eq, Error, PartialEq)]
1800pub enum WordPressReviewError {
1801 #[error("WordPress context exceeds its byte limit")]
1802 ContextTooLarge,
1803 #[error("WordPress advisory catalog exceeds its byte limit")]
1804 CatalogTooLarge,
1805 #[error("WordPress discovery layout exceeds its byte limit")]
1806 DiscoveryLayoutTooLarge,
1807 #[error("saved WordPress inventory exceeds its aggregate byte limit")]
1808 InventoryTooLarge,
1809 #[error("WordPress input is empty")]
1810 EmptyInput,
1811 #[error("WordPress input contains a duplicate object key")]
1812 DuplicateKey,
1813 #[error("WordPress input exceeds a structural limit")]
1814 JsonLimitExceeded,
1815 #[error("WordPress input is not valid bounded JSON")]
1816 MalformedJson,
1817 #[error("WordPress input uses an unsupported schema")]
1818 UnsupportedSchema,
1819 #[error("WordPress context is invalid")]
1820 InvalidContext,
1821 #[error("saved WordPress inventory is invalid")]
1822 InvalidInventory,
1823 #[error("WordPress discovery layout is invalid")]
1824 InvalidDiscoveryLayout,
1825 #[error("saved WordPress inventory exceeds its component limit")]
1826 InventoryComponentLimitExceeded,
1827 #[error("saved WordPress inventory provenance does not match its supplied inputs")]
1828 InvalidInventoryProvenance,
1829 #[error("WordPress advisory catalog is invalid")]
1830 InvalidCatalog,
1831 #[error("WordPress asset fingerprint catalogue selection is invalid")]
1832 InvalidAssetFingerprintCatalog,
1833 #[error("native and external WordPress advisory inputs cannot be combined")]
1834 ConflictingAdvisoryInputs,
1835 #[error("an external version profile requires a local Wordfence V3 advisory export")]
1836 ExternalVersionProfileRequiresWordfenceV3,
1837 #[error("external WordPress advisory data cannot be represented within review limits")]
1838 InvalidExternalCatalog,
1839 #[error("WordPress component identity is invalid")]
1840 InvalidComponentIdentity,
1841 #[error("WordPress input repeats a component identity")]
1842 DuplicateComponent,
1843 #[error("WordPress advisory catalog repeats a record identity")]
1844 DuplicateAdvisory,
1845 #[error("WordPress advisory contains an invalid affected range")]
1846 InvalidVersionRange,
1847 #[error("WordPress response signal limit exceeded")]
1848 SignalLimitExceeded,
1849 #[error("WordPress discovery candidate identity accounting limit exceeded")]
1850 DiscoveryCandidateIdentityLimitExceeded,
1851 #[error("WordPress review result limit exceeded")]
1852 ResultLimitExceeded,
1853 #[error("WordPress advisory evaluation work limit exceeded")]
1854 EvaluationLimitExceeded,
1855}
1856
1857pub fn parse_wordpress_context(bytes: &[u8]) -> Result<WordPressContext, WordPressReviewError> {
1858 let value = parse_bounded_json(
1859 bytes,
1860 MAX_WORDPRESS_CONTEXT_BYTES,
1861 MAX_CONTEXT_JSON_NODES,
1862 MAX_WORDPRESS_CONTEXT_COMPONENTS,
1863 WordPressReviewError::ContextTooLarge,
1864 )?;
1865 require_schema(&value, WORDPRESS_CONTEXT_SCHEMA)?;
1866 let wire: ContextWire =
1867 serde_json::from_value(value).map_err(|_| WordPressReviewError::InvalidContext)?;
1868 let root_url = validate_root_url(&wire.root)?;
1869 if wire.components.len() > MAX_WORDPRESS_CONTEXT_COMPONENTS {
1870 return Err(WordPressReviewError::InvalidContext);
1871 }
1872 let mut identities = BTreeSet::new();
1873 let mut components = Vec::with_capacity(wire.components.len());
1874 for component in wire.components {
1875 let identity = WordPressComponentIdentity::new(component.kind.into(), component.slug)?;
1876 if !identities.insert(identity.clone()) {
1877 return Err(WordPressReviewError::DuplicateComponent);
1878 }
1879 let version = component
1880 .version
1881 .as_deref()
1882 .map(validate_source_version)
1883 .transpose()?;
1884 let patches = validate_patches(component.patches)?;
1885 components.push(WordPressContextComponent {
1886 identity,
1887 version,
1888 activation: component.activation.map(Into::into),
1889 patches,
1890 inventory_status: None,
1891 });
1892 }
1893 components.sort_by(|left, right| left.identity.cmp(&right.identity));
1894 Ok(WordPressContext {
1895 root_url,
1896 hosting_os: wire.hosting_os.map(Into::into),
1897 multisite: wire.multisite.map(Into::into),
1898 components,
1899 })
1900}
1901
1902pub fn parse_wordpress_advisory_catalog(
1903 bytes: &[u8],
1904) -> Result<WordPressAdvisoryCatalog, WordPressReviewError> {
1905 let value = parse_bounded_json(
1906 bytes,
1907 MAX_WORDPRESS_ADVISORY_CATALOG_BYTES,
1908 MAX_CATALOG_JSON_NODES,
1909 MAX_WORDPRESS_ADVISORY_RECORDS,
1910 WordPressReviewError::CatalogTooLarge,
1911 )?;
1912 let schema = match value.get("schema").and_then(Value::as_str) {
1913 Some(WORDPRESS_ADVISORY_CATALOG_SCHEMA) => WordPressAdvisoryCatalogSchema::V1,
1914 Some(WORDPRESS_ADVISORY_CATALOG_SCHEMA_V2) => WordPressAdvisoryCatalogSchema::V2,
1915 _ => return Err(WordPressReviewError::UnsupportedSchema),
1916 };
1917 let (catalog, records) = match schema {
1918 WordPressAdvisoryCatalogSchema::V1 => {
1919 let wire: CatalogV1Wire =
1920 serde_json::from_value(value).map_err(|_| WordPressReviewError::InvalidCatalog)?;
1921 (
1922 wire.catalog,
1923 wire.records
1924 .into_iter()
1925 .map(AdvisoryRecordInput::from)
1926 .collect::<Vec<_>>(),
1927 )
1928 },
1929 WordPressAdvisoryCatalogSchema::V2 => {
1930 let wire: CatalogV2Wire =
1931 serde_json::from_value(value).map_err(|_| WordPressReviewError::InvalidCatalog)?;
1932 (
1933 wire.catalog,
1934 wire.records
1935 .into_iter()
1936 .map(AdvisoryRecordInput::try_from)
1937 .collect::<Result<Vec<_>, _>>()?,
1938 )
1939 },
1940 };
1941 validate_identifier(&catalog.id)?;
1942 validate_identifier(&catalog.revision)?;
1943 validate_date(&catalog.retrieved_on)?;
1944 if records.len() > MAX_WORDPRESS_ADVISORY_RECORDS {
1945 return Err(WordPressReviewError::InvalidCatalog);
1946 }
1947 let metadata = WordPressCatalogMetadata {
1948 id: catalog.id,
1949 revision: catalog.revision,
1950 retrieved_on: catalog.retrieved_on,
1951 };
1952 let mut record_ids = BTreeSet::new();
1953 let mut validated_records = Vec::with_capacity(records.len());
1954 let mut work = 0_usize;
1955 for record in records {
1956 validate_identifier(&record.id)?;
1957 if !record_ids.insert(record.id.clone()) {
1958 return Err(WordPressReviewError::DuplicateAdvisory);
1959 }
1960 let component =
1961 WordPressComponentIdentity::new(record.component.kind.into(), record.component.slug)?;
1962 let source = validate_source(record.source)?;
1963 if let Some(cve) = record.cve.as_deref() {
1964 if !valid_cve(cve) {
1965 return Err(WordPressReviewError::InvalidCatalog);
1966 }
1967 }
1968 validate_text(&record.summary, MAX_WORDPRESS_SUMMARY_BYTES)?;
1969 if record.affected_ranges.len() > MAX_WORDPRESS_RANGES_PER_ADVISORY
1970 || record.fixed_versions.len() > MAX_WORDPRESS_FIXED_VERSIONS_PER_ADVISORY
1971 || record.prerequisites.len() > MAX_WORDPRESS_PREREQUISITES_PER_ADVISORY
1972 {
1973 return Err(WordPressReviewError::InvalidCatalog);
1974 }
1975 work = work
1976 .checked_add(record.affected_ranges.len())
1977 .and_then(|value| value.checked_add(record.prerequisites.len()))
1978 .ok_or(WordPressReviewError::EvaluationLimitExceeded)?;
1979 if work > MAX_WORDPRESS_EVALUATION_WORK {
1980 return Err(WordPressReviewError::EvaluationLimitExceeded);
1981 }
1982 let (affected_ranges, profiled_affected_ranges) = match schema {
1983 WordPressAdvisoryCatalogSchema::V1 => {
1984 let mut ranges = record
1985 .affected_ranges
1986 .into_iter()
1987 .map(validate_range)
1988 .collect::<Result<Vec<_>, _>>()?;
1989 ranges.sort();
1990 ranges.dedup();
1991 (ranges, Vec::new())
1992 },
1993 WordPressAdvisoryCatalogSchema::V2 => {
1994 let mut ranges = record
1995 .affected_ranges
1996 .into_iter()
1997 .map(|range| validate_profiled_range(range, record.comparison_profile))
1998 .collect::<Result<Vec<_>, _>>()?;
1999 ranges.sort_by(compare_profiled_ranges);
2000 ranges.dedup_by(|left, right| profiled_ranges_equivalent(left, right));
2001 (Vec::new(), ranges)
2002 },
2003 };
2004 let mut fixed_versions: Vec<(ProfiledVersionKey, String)> =
2005 Vec::with_capacity(record.fixed_versions.len());
2006 for fixed in record.fixed_versions {
2007 let parsed = ProfiledVersionKey::parse(record.comparison_profile, &fixed)
2008 .map_err(|_| WordPressReviewError::InvalidVersionRange)?;
2009 if fixed_versions
2010 .iter()
2011 .any(|(existing, _)| existing.compare(&parsed).ok() == Some(Ordering::Equal))
2012 {
2013 return Err(WordPressReviewError::InvalidCatalog);
2014 }
2015 fixed_versions.push((parsed, fixed));
2016 }
2017 fixed_versions.sort_by(|left, right| {
2018 left.0
2019 .compare(&right.0)
2020 .expect("one record has exactly one validated comparison profile")
2021 .then_with(|| left.1.cmp(&right.1))
2022 });
2023 let fixed_versions = fixed_versions
2024 .into_iter()
2025 .map(|(_, declared)| declared)
2026 .collect();
2027 let mut prerequisites = record
2028 .prerequisites
2029 .into_iter()
2030 .map(validate_prerequisite)
2031 .collect::<Result<Vec<_>, _>>()?;
2032 prerequisites.sort();
2033 if prerequisites.windows(2).any(|pair| pair[0] == pair[1]) {
2034 return Err(WordPressReviewError::InvalidCatalog);
2035 }
2036 let remediation = record
2037 .remediation
2038 .map(|value| {
2039 validate_text(&value, MAX_WORDPRESS_REMEDIATION_BYTES)?;
2040 Ok(value)
2041 })
2042 .transpose()?;
2043 validated_records.push(WordPressAdvisoryRecord {
2044 id: record.id,
2045 component,
2046 source,
2047 cve: record.cve,
2048 summary: record.summary,
2049 affected_ranges,
2050 profiled_affected_ranges,
2051 comparison_profile: record.comparison_profile,
2052 fixed_versions,
2053 prerequisites,
2054 remediation,
2055 });
2056 }
2057 validated_records.sort_by(|left, right| left.id.cmp(&right.id));
2058 Ok(WordPressAdvisoryCatalog {
2059 schema,
2060 metadata,
2061 records: validated_records,
2062 })
2063}
2064
2065pub fn evaluate_wordpress_review(
2066 signals: &[WordPressComponentSignal],
2067 inputs: &WordPressReviewInputs,
2068) -> Result<WordPressReviewResult, WordPressReviewError> {
2069 if signals.len() > MAX_WORDPRESS_SIGNALS {
2070 return Err(WordPressReviewError::SignalLimitExceeded);
2071 }
2072 let mut aggregates = BTreeMap::<WordPressComponentIdentity, ComponentAggregate>::new();
2073 for signal in signals {
2074 let aggregate = aggregates.entry(signal.identity.clone()).or_default();
2075 aggregate.sources.insert(signal.source);
2076 aggregate.confidence_classes.insert(signal.confidence());
2077 if let Some(version) = &signal.version {
2078 aggregate.versions.insert(WordPressVersionEvidence {
2079 value: version.clone(),
2080 source: signal.source,
2081 });
2082 }
2083 }
2084 if let Some(context) = inputs.context() {
2085 for component in context.components() {
2086 let aggregate = aggregates.entry(component.identity.clone()).or_default();
2087 aggregate
2088 .sources
2089 .insert(WordPressEvidenceSource::OperatorContext);
2090 aggregate
2091 .confidence_classes
2092 .insert(WordPressEvidenceConfidence::OperatorAssertion);
2093 aggregate.activation = component.activation;
2094 aggregate.inventory_status = component.inventory_status;
2095 if let Some(version) = &component.version {
2096 aggregate.versions.insert(WordPressVersionEvidence {
2097 value: version.clone(),
2098 source: WordPressEvidenceSource::OperatorContext,
2099 });
2100 }
2101 }
2102 }
2103 let result_version_count = aggregates.values().try_fold(0_usize, |count, aggregate| {
2104 count.checked_add(aggregate.versions.len())
2105 });
2106 if aggregates.len() > MAX_WORDPRESS_RESULT_COMPONENTS
2107 || result_version_count.is_none_or(|count| count > MAX_WORDPRESS_RESULT_VERSION_EVIDENCE)
2108 {
2109 return Err(WordPressReviewError::ResultLimitExceeded);
2110 }
2111 let mut components = Vec::with_capacity(aggregates.len());
2112 let profiled_catalog = inputs
2113 .catalog()
2114 .is_some_and(|catalog| catalog.schema() == WordPressAdvisoryCatalogSchema::V2)
2115 || inputs.wordfence_v3_catalog().is_some();
2116 for (identity, aggregate) in &aggregates {
2117 components.push(component_assessment(
2118 identity.clone(),
2119 aggregate,
2120 profiled_catalog,
2121 ));
2122 }
2123 if let Some(catalog) = inputs.wordfence_v3_catalog() {
2124 let external_review =
2125 evaluate_wordfence_v3_review(catalog, &components, inputs.external_version_profile())?;
2126 return Ok(WordPressReviewResult {
2127 catalog_status: WordPressCatalogStatus::Evaluated,
2128 catalog_schema: None,
2129 catalog_metadata: None,
2130 components,
2131 advisories: Vec::new(),
2132 external_review: Some(external_review),
2133 inventory_summary: inputs.inventory_summary.clone(),
2134 local_input_provenance: inputs.local_input_provenance.clone(),
2135 });
2136 }
2137 let Some(catalog) = inputs.catalog() else {
2138 return Ok(WordPressReviewResult {
2139 catalog_status: WordPressCatalogStatus::CatalogNotSupplied,
2140 catalog_schema: None,
2141 catalog_metadata: None,
2142 components,
2143 advisories: Vec::new(),
2144 external_review: None,
2145 inventory_summary: inputs.inventory_summary.clone(),
2146 local_input_provenance: inputs.local_input_provenance.clone(),
2147 });
2148 };
2149 let work = catalog.records().iter().try_fold(0_usize, |work, record| {
2150 work.checked_add(record.affected_range_count())?
2151 .checked_add(record.prerequisites.len())
2152 });
2153 if work.is_none_or(|work| work > MAX_WORDPRESS_EVALUATION_WORK) {
2154 return Err(WordPressReviewError::EvaluationLimitExceeded);
2155 }
2156 let assessments = components
2157 .iter()
2158 .map(|assessment| (assessment.identity.clone(), assessment))
2159 .collect::<BTreeMap<_, _>>();
2160 let mut profiled_resolutions = BTreeMap::new();
2161 if catalog.schema() == WordPressAdvisoryCatalogSchema::V2 {
2162 let profiles = catalog
2163 .records()
2164 .iter()
2165 .map(WordPressAdvisoryRecord::comparison_profile)
2166 .collect::<BTreeSet<_>>();
2167 let mut resolution_work = 0_usize;
2168 for (identity, assessment) in &assessments {
2169 for profile in &profiles {
2170 resolution_work = resolution_work
2171 .checked_add(assessment.versions().len())
2172 .ok_or(WordPressReviewError::EvaluationLimitExceeded)?;
2173 if resolution_work > MAX_WORDPRESS_VERSION_RESOLUTION_WORK {
2174 return Err(WordPressReviewError::EvaluationLimitExceeded);
2175 }
2176 profiled_resolutions.insert(
2177 ((*identity).clone(), *profile),
2178 resolve_profiled_versions(assessment, *profile),
2179 );
2180 }
2181 }
2182 }
2183 let mut advisories = Vec::with_capacity(catalog.records().len());
2184 for record in catalog.records() {
2185 let assessment = assessments.get(record.component());
2186 let (component_evidence, version_relation, version_resolution, resolution_reason) =
2187 match catalog.schema() {
2188 WordPressAdvisoryCatalogSchema::V1 => {
2189 let component_evidence = assessment
2190 .map_or(WordPressComponentEvidenceClass::Unknown, |assessment| {
2191 assessment.evidence_class
2192 });
2193 (
2194 component_evidence,
2195 evaluate_version_relation(assessment.copied(), record),
2196 None,
2197 None,
2198 )
2199 },
2200 WordPressAdvisoryCatalogSchema::V2 => {
2201 let resolution = assessment
2202 .and_then(|assessment| {
2203 profiled_resolutions
2204 .get(&(assessment.identity().clone(), record.comparison_profile()))
2205 })
2206 .cloned()
2207 .unwrap_or_else(VersionResolutionState::missing);
2208 (
2209 assessment.map_or(WordPressComponentEvidenceClass::Unknown, |assessment| {
2210 profile_independent_component_evidence(assessment)
2211 }),
2212 evaluate_profiled_version_relation(&resolution, record),
2213 Some(resolution.status),
2214 Some(resolution.reason),
2215 )
2216 },
2217 };
2218 let prerequisites = record
2219 .prerequisites
2220 .iter()
2221 .cloned()
2222 .map(|prerequisite| {
2223 let outcome =
2224 evaluate_prerequisite(&prerequisite, inputs.context(), record.component());
2225 WordPressPrerequisiteEvaluation {
2226 prerequisite,
2227 outcome,
2228 }
2229 })
2230 .collect::<Vec<_>>();
2231 let applicability =
2232 summarize_applicability(component_evidence, version_relation, &prerequisites);
2233 advisories.push(WordPressAdvisoryEvaluation {
2234 record: record.clone(),
2235 component_evidence,
2236 version_relation,
2237 version_resolution,
2238 version_resolution_reason: resolution_reason,
2239 prerequisites,
2240 applicability,
2241 });
2242 }
2243 Ok(WordPressReviewResult {
2244 catalog_status: WordPressCatalogStatus::Evaluated,
2245 catalog_schema: Some(catalog.schema()),
2246 catalog_metadata: Some(catalog.metadata().clone()),
2247 components,
2248 advisories,
2249 external_review: None,
2250 inventory_summary: inputs.inventory_summary.clone(),
2251 local_input_provenance: inputs.local_input_provenance.clone(),
2252 })
2253}
2254
2255fn evaluate_wordfence_v3_review(
2256 catalog: &WordfenceV3Catalog,
2257 components: &[WordPressComponentAssessment],
2258 selected_profile: Option<WordPressComparisonProfile>,
2259) -> Result<WordPressExternalReview, WordPressReviewError> {
2260 let assessments = components
2261 .iter()
2262 .map(|assessment| (assessment.identity().clone(), assessment))
2263 .collect::<BTreeMap<_, _>>();
2264 let mut selected = Vec::new();
2265 let mut keys = BTreeSet::new();
2266 let mut selected_notice_ids = BTreeSet::new();
2267 let mut evaluation_work = 0_usize;
2268 let mut resolution_work = 0_usize;
2269 let mut projected_result_bytes = 0_usize;
2270 let mut identity_limitations = Vec::new();
2271 for (component, assessment) in &assessments {
2272 let mut profiled_resolution = None;
2273 for view in catalog.associations_for(component) {
2274 if selected.len() == MAX_WORDPRESS_ADVISORY_RECORDS {
2275 return Err(WordPressReviewError::ResultLimitExceeded);
2276 }
2277 let record = view.record();
2278 let association = view.association();
2279 projected_result_bytes = projected_result_bytes
2280 .checked_add(wordfence_v3_projection_bytes(record, association)?)
2281 .ok_or(WordPressReviewError::ResultLimitExceeded)?;
2282 if projected_result_bytes > MAX_WORDPRESS_EXTERNAL_RESULT_BYTES {
2283 return Err(WordPressReviewError::ResultLimitExceeded);
2284 }
2285 let exact_identity =
2286 association.identity_mapping() == WordfenceV3IdentityMapping::Exact;
2287 if selected_profile.is_some() && exact_identity {
2288 evaluation_work = checked_accumulate_external_interpretation_work(
2289 evaluation_work,
2290 association.affected_ranges().len(),
2291 association.patched_versions().len(),
2292 MAX_WORDPRESS_EVALUATION_WORK,
2293 )
2294 .ok_or(WordPressReviewError::EvaluationLimitExceeded)?;
2295 } else {
2296 evaluation_work = evaluation_work
2297 .checked_add(association.affected_ranges().len())
2298 .ok_or(WordPressReviewError::EvaluationLimitExceeded)?;
2299 if evaluation_work > MAX_WORDPRESS_EVALUATION_WORK {
2300 return Err(WordPressReviewError::EvaluationLimitExceeded);
2301 }
2302 }
2303 let key = WordPressExternalAssociationKey {
2304 source_namespace: WORDFENCE_V3_SOURCE_NAMESPACE.to_owned(),
2305 upstream_id: record.upstream_id().to_owned(),
2306 component: association.component().clone(),
2307 source_component: association.source_component().clone(),
2308 source_association_sha256: *association.key().source_association_sha256(),
2309 };
2310 if !keys.insert(key.clone()) {
2311 return Err(WordPressReviewError::InvalidExternalCatalog);
2312 }
2313 selected_notice_ids.extend(record.notice_ids().iter().cloned());
2314 if selected_notice_ids.len() > MAX_WORDPRESS_ADVISORY_RECORDS {
2315 return Err(WordPressReviewError::ResultLimitExceeded);
2316 }
2317 let profile_evaluation =
2318 if let Some(profile) = selected_profile.filter(|_| exact_identity) {
2319 if profiled_resolution.is_none() {
2320 resolution_work = resolution_work
2321 .checked_add(assessment.versions().len())
2322 .ok_or(WordPressReviewError::EvaluationLimitExceeded)?;
2323 if resolution_work > MAX_WORDPRESS_VERSION_RESOLUTION_WORK {
2324 return Err(WordPressReviewError::EvaluationLimitExceeded);
2325 }
2326 profiled_resolution = Some(resolve_profiled_versions(assessment, profile));
2327 }
2328 Some(evaluate_wordfence_profiled_association(
2329 profiled_resolution
2330 .as_ref()
2331 .ok_or(WordPressReviewError::InvalidExternalCatalog)?,
2332 association,
2333 profile,
2334 )?)
2335 } else {
2336 None
2337 };
2338 let version_evidence_resolution = profile_evaluation.as_ref().map_or_else(
2339 || external_version_evidence_resolution(assessment, None),
2340 |evaluation| {
2341 external_version_evidence_resolution(
2342 assessment,
2343 Some(&evaluation.version_resolution),
2344 )
2345 },
2346 )?;
2347 let identity_limited_reason = match association.identity_mapping() {
2348 WordfenceV3IdentityMapping::Exact => None,
2349 WordfenceV3IdentityMapping::AsciiCaseFoldCandidate => {
2350 Some(WordPressExternalVersionRelationReason::IdentityMappingCandidate)
2351 },
2352 WordfenceV3IdentityMapping::AsciiCaseFoldAmbiguous => {
2353 Some(WordPressExternalVersionRelationReason::IdentityMappingAmbiguous)
2354 },
2355 };
2356 let identity_limited_range_reason = match association.identity_mapping() {
2357 WordfenceV3IdentityMapping::Exact => None,
2358 WordfenceV3IdentityMapping::AsciiCaseFoldCandidate => {
2359 Some(WordPressExternalRangeReason::IdentityMappingCandidate)
2360 },
2361 WordfenceV3IdentityMapping::AsciiCaseFoldAmbiguous => {
2362 Some(WordPressExternalRangeReason::IdentityMappingAmbiguous)
2363 },
2364 };
2365 let range_evaluations = if selected_profile.is_none() {
2366 None
2367 } else if let Some(evaluation) = &profile_evaluation {
2368 Some(evaluation.ranges.clone())
2369 } else {
2370 let reason = identity_limited_range_reason
2371 .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2372 Some(
2373 association
2374 .affected_ranges()
2375 .iter()
2376 .map(|_| WordPressExternalRangeEvaluation {
2377 relation: WordPressExternalRangeRelation::NotEvaluated,
2378 reason,
2379 })
2380 .collect(),
2381 )
2382 };
2383 selected.push(WordPressExternalAdvisoryEvaluation {
2384 key,
2385 identity_mapping: association.identity_mapping(),
2386 identity_collision_raw_count: association.identity_collision_raw_count(),
2387 title: record.title().to_owned(),
2388 informational: record.informational(),
2389 description: record.description().to_owned(),
2390 references: record.references().to_vec(),
2391 cwe: record.cwe().cloned(),
2392 cvss: record.cvss().cloned(),
2393 cve: record.cve().map(str::to_owned),
2394 cve_link: record.cve_link().map(str::to_owned),
2395 researchers: record.researchers().to_vec(),
2396 published: record.published().map(str::to_owned),
2397 updated: record.updated().map(str::to_owned),
2398 notice_ids: record.notice_ids().to_vec(),
2399 display_name: association.display_name().to_owned(),
2400 affected_ranges: association.affected_ranges().to_vec(),
2401 range_evaluations,
2402 source_patched: association.patched(),
2403 source_patched_versions: association.patched_versions().to_vec(),
2404 source_remediation: association.remediation().to_owned(),
2405 component_evidence: profile_independent_component_evidence(assessment),
2406 version_evidence_resolution,
2407 comparison_policy: if selected_profile.is_some() {
2408 WordPressExternalComparisonPolicy::TermivarWordfenceV3ExplicitInterpretationV1
2409 } else {
2410 WordPressExternalComparisonPolicy::WordfenceV3SourceSemanticsUnresolvedV1
2411 },
2412 version_relation: if selected_profile.is_none() {
2413 WordPressExternalVersionRelation::SourceComparisonSemanticsUnresolved
2414 } else {
2415 profile_evaluation.as_ref().map_or(
2416 WordPressExternalVersionRelation::Indeterminate,
2417 |evaluation| evaluation.relation,
2418 )
2419 },
2420 version_relation_reason: if selected_profile.is_none() {
2421 WordPressExternalVersionRelationReason::SourceComparisonSemanticsUnresolved
2422 } else {
2423 profile_evaluation.as_ref().map_or_else(
2424 || {
2425 identity_limited_reason
2426 .ok_or(WordPressReviewError::InvalidExternalCatalog)
2427 },
2428 |evaluation| Ok(evaluation.reason),
2429 )?
2430 },
2431 applicability: if selected_profile.is_none() {
2432 WordPressExternalApplicability::IndeterminateUnsupported
2433 } else {
2434 profile_evaluation.as_ref().map_or(
2435 WordPressExternalApplicability::Indeterminate,
2436 |evaluation| evaluation.applicability,
2437 )
2438 },
2439 });
2440 }
2441 }
2442 selected.sort_by(|left, right| left.key.cmp(&right.key));
2443
2444 let notices_by_id = catalog
2445 .notices()
2446 .iter()
2447 .map(|notice| (notice.id(), notice))
2448 .collect::<BTreeMap<_, _>>();
2449 for id in &selected_notice_ids {
2450 let notice = notices_by_id
2451 .get(id.as_str())
2452 .copied()
2453 .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2454 projected_result_bytes = projected_result_bytes
2455 .checked_add(wordfence_v3_notice_projection_bytes(notice)?)
2456 .ok_or(WordPressReviewError::ResultLimitExceeded)?;
2457 if projected_result_bytes > MAX_WORDPRESS_EXTERNAL_RESULT_BYTES {
2458 return Err(WordPressReviewError::ResultLimitExceeded);
2459 }
2460 }
2461
2462 let mut identity_projection_bytes = 0_usize;
2463 'identity_projection: for record in catalog.records() {
2464 for association in record.unresolved_software() {
2465 if identity_limitations.len() == MAX_WORDPRESS_EXTERNAL_IDENTITY_LIMITATION_PROJECTIONS
2466 {
2467 break 'identity_projection;
2468 }
2469 let row_bytes = wordfence_v3_unresolved_projection_bytes(record, association)?;
2470 let mut new_notice_bytes = 0_usize;
2471 let mut new_notice_count = 0_usize;
2472 for id in record.notice_ids() {
2473 if !selected_notice_ids.contains(id) {
2474 let notice = notices_by_id
2475 .get(id.as_str())
2476 .copied()
2477 .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2478 new_notice_bytes = new_notice_bytes
2479 .checked_add(wordfence_v3_notice_projection_bytes(notice)?)
2480 .ok_or(WordPressReviewError::ResultLimitExceeded)?;
2481 new_notice_count = new_notice_count
2482 .checked_add(1)
2483 .ok_or(WordPressReviewError::ResultLimitExceeded)?;
2484 }
2485 }
2486 let next_identity_bytes = identity_projection_bytes
2487 .checked_add(row_bytes)
2488 .and_then(|bytes| bytes.checked_add(new_notice_bytes));
2489 let next_result_bytes = projected_result_bytes
2490 .checked_add(row_bytes)
2491 .and_then(|bytes| bytes.checked_add(new_notice_bytes));
2492 let next_notice_count = selected_notice_ids.len().checked_add(new_notice_count);
2493 if next_identity_bytes.is_none_or(|bytes| {
2494 bytes > MAX_WORDPRESS_EXTERNAL_IDENTITY_LIMITATION_PROJECTION_BYTES
2495 }) || next_result_bytes
2496 .is_none_or(|bytes| bytes > MAX_WORDPRESS_EXTERNAL_RESULT_BYTES)
2497 || next_notice_count.is_none_or(|count| count > MAX_WORDPRESS_ADVISORY_RECORDS)
2498 {
2499 break 'identity_projection;
2500 }
2501 identity_projection_bytes =
2502 next_identity_bytes.ok_or(WordPressReviewError::ResultLimitExceeded)?;
2503 projected_result_bytes =
2504 next_result_bytes.ok_or(WordPressReviewError::ResultLimitExceeded)?;
2505 selected_notice_ids.extend(record.notice_ids().iter().cloned());
2506 identity_limitations.push(WordPressExternalIdentityLimitation {
2507 upstream_id: record.upstream_id().to_owned(),
2508 source_component: association.source_component().clone(),
2509 source_association_sha256: *association.source_association_sha256(),
2510 title: record.title().to_owned(),
2511 display_name: association.display_name().to_owned(),
2512 references: record.references().to_vec(),
2513 affected_ranges: association.affected_ranges().to_vec(),
2514 source_patched: association.patched(),
2515 source_patched_versions: association.patched_versions().to_vec(),
2516 source_remediation: association.remediation().to_owned(),
2517 notice_ids: record.notice_ids().to_vec(),
2518 });
2519 }
2520 }
2521 let mut notices = Vec::with_capacity(selected_notice_ids.len());
2522 for id in &selected_notice_ids {
2523 let notice = notices_by_id
2524 .get(id.as_str())
2525 .copied()
2526 .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2527 notices.push(notice.clone());
2528 }
2529 let selected_associations = selected.len();
2530 let software_associations = catalog.software_association_count();
2531 let projected_identity_limitations = identity_limitations.len();
2532 let unprojected_identity_limitations = catalog
2533 .identity_counts()
2534 .unresolved()
2535 .checked_sub(projected_identity_limitations)
2536 .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2537 let excluded_associations = software_associations
2538 .checked_sub(selected_associations)
2539 .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2540 let mapped_unselected_associations = excluded_associations
2541 .checked_sub(catalog.identity_counts().unresolved())
2542 .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2543 let within_associations = selected
2544 .iter()
2545 .filter(|evaluation| {
2546 evaluation.version_relation
2547 == WordPressExternalVersionRelation::WithinSupportedRangeUnderSelectedPolicy
2548 })
2549 .count();
2550 let outside_associations = selected
2551 .iter()
2552 .filter(|evaluation| {
2553 evaluation.version_relation
2554 == WordPressExternalVersionRelation::OutsideDeclaredRangesUnderSelectedPolicy
2555 })
2556 .count();
2557 let indeterminate_associations = if selected_profile.is_some() {
2558 selected_associations
2559 .checked_sub(within_associations)
2560 .and_then(|count| count.checked_sub(outside_associations))
2561 .ok_or(WordPressReviewError::InvalidExternalCatalog)?
2562 } else {
2563 0
2564 };
2565 let evaluable_associations = within_associations
2566 .checked_add(outside_associations)
2567 .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2568 let unsupported_associations = if selected_profile.is_some() {
2569 indeterminate_associations
2570 } else {
2571 selected_associations
2572 };
2573 let mut selected_ranges = 0_usize;
2574 let mut containing_ranges = 0_usize;
2575 let mut noncontaining_ranges = 0_usize;
2576 let mut unsupported_ranges = 0_usize;
2577 let mut invalid_ranges = 0_usize;
2578 let mut not_evaluated_ranges = 0_usize;
2579 let mut partial_range_coverage_associations = 0_usize;
2580 if selected_profile.is_some() {
2581 for evaluation in &selected {
2582 let ranges = evaluation
2583 .range_evaluations
2584 .as_deref()
2585 .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2586 selected_ranges = selected_ranges
2587 .checked_add(ranges.len())
2588 .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2589 for range in ranges {
2590 match range.relation {
2591 WordPressExternalRangeRelation::Contains => containing_ranges += 1,
2592 WordPressExternalRangeRelation::DoesNotContain => noncontaining_ranges += 1,
2593 WordPressExternalRangeRelation::NotEvaluated => not_evaluated_ranges += 1,
2594 WordPressExternalRangeRelation::Unsupported => unsupported_ranges += 1,
2595 WordPressExternalRangeRelation::InvalidUnderProfile => invalid_ranges += 1,
2596 }
2597 }
2598 if evaluation.version_relation_reason
2599 == WordPressExternalVersionRelationReason::ContainingRangeWithPartialCoverage
2600 {
2601 partial_range_coverage_associations += 1;
2602 }
2603 }
2604 }
2605 let evaluated_ranges = containing_ranges
2606 .checked_add(noncontaining_ranges)
2607 .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2608 Ok(WordPressExternalReview {
2609 byte_length: catalog.byte_length(),
2610 sha256: *catalog.sha256(),
2611 semantic_sha256: *catalog.semantic_sha256(),
2612 retained_bytes: catalog.retained_bytes(),
2613 mapping_revision: catalog.mapping_revision(),
2614 resource_policy: catalog.resource_policy(),
2615 identity_counts: catalog.identity_counts().clone(),
2616 comparison_policy: if selected_profile.is_some() {
2617 WordPressExternalComparisonPolicy::TermivarWordfenceV3ExplicitInterpretationV1
2618 } else {
2619 WordPressExternalComparisonPolicy::WordfenceV3SourceSemanticsUnresolvedV1
2620 },
2621 comparison_profile: selected_profile,
2622 counts: WordPressExternalReviewCounts {
2623 parsed_records: catalog.record_count(),
2624 software_associations,
2625 exact_identity_associations: catalog.identity_counts().exact(),
2626 candidate_identity_associations: catalog.identity_counts().candidate(),
2627 ambiguous_identity_associations: catalog.identity_counts().ambiguous(),
2628 unresolved_identity_associations: catalog.identity_counts().unresolved(),
2629 projected_identity_limitations,
2630 unprojected_identity_limitations,
2631 selected_associations,
2632 evaluable_associations,
2633 unsupported_associations,
2634 excluded_associations,
2635 mapped_unselected_associations,
2636 within_associations,
2637 outside_associations,
2638 indeterminate_associations,
2639 selected_ranges,
2640 evaluated_ranges,
2641 containing_ranges,
2642 noncontaining_ranges,
2643 unsupported_ranges,
2644 invalid_ranges,
2645 not_evaluated_ranges,
2646 partial_range_coverage_associations,
2647 },
2648 notices,
2649 evaluations: selected,
2650 identity_limitations,
2651 })
2652}
2653
2654struct WordPressExternalProfiledEvaluation {
2655 version_resolution: VersionResolutionState,
2656 ranges: Vec<WordPressExternalRangeEvaluation>,
2657 relation: WordPressExternalVersionRelation,
2658 reason: WordPressExternalVersionRelationReason,
2659 applicability: WordPressExternalApplicability,
2660}
2661
2662enum ParsedExternalRange {
2663 Supported {
2664 lower: Option<(ProfiledVersionKey, bool)>,
2665 upper: Option<(ProfiledVersionKey, bool)>,
2666 },
2667 Unsupported(WordPressExternalRangeReason),
2668 Invalid(WordPressExternalRangeReason),
2669}
2670
2671fn evaluate_wordfence_profiled_association(
2672 version_resolution: &VersionResolutionState,
2673 association: &WordfenceV3SoftwareAssociation,
2674 profile: WordPressComparisonProfile,
2675) -> Result<WordPressExternalProfiledEvaluation, WordPressReviewError> {
2676 let parsed_ranges = association
2677 .affected_ranges()
2678 .iter()
2679 .map(|range| parse_external_range(range, profile))
2680 .collect::<Result<Vec<_>, _>>()?;
2681 let ranges = parsed_ranges
2682 .iter()
2683 .map(|range| evaluate_external_range(range, version_resolution))
2684 .collect::<Result<Vec<_>, _>>()?;
2685 let source_patched_version_conflict = external_patched_version_conflicts(
2686 association.patched_versions(),
2687 &parsed_ranges,
2688 profile,
2689 )?;
2690
2691 let has_invalid = ranges
2692 .iter()
2693 .any(|range| range.relation == WordPressExternalRangeRelation::InvalidUnderProfile);
2694 let has_unsupported = ranges
2695 .iter()
2696 .any(|range| range.relation == WordPressExternalRangeRelation::Unsupported);
2697 let has_containing = ranges
2698 .iter()
2699 .any(|range| range.relation == WordPressExternalRangeRelation::Contains);
2700 let all_outside = !ranges.is_empty()
2701 && ranges
2702 .iter()
2703 .all(|range| range.relation == WordPressExternalRangeRelation::DoesNotContain);
2704
2705 let (relation, reason, applicability) = if has_invalid {
2706 (
2707 WordPressExternalVersionRelation::Indeterminate,
2708 WordPressExternalVersionRelationReason::InvalidAffectedRange,
2709 WordPressExternalApplicability::Indeterminate,
2710 )
2711 } else if source_patched_version_conflict {
2712 (
2713 WordPressExternalVersionRelation::Indeterminate,
2714 WordPressExternalVersionRelationReason::SourcePatchedVersionWithinAffectedRange,
2715 WordPressExternalApplicability::Indeterminate,
2716 )
2717 } else {
2718 match version_resolution.status {
2719 WordPressVersionResolution::Missing => (
2720 WordPressExternalVersionRelation::Indeterminate,
2721 WordPressExternalVersionRelationReason::MissingVersionEvidence,
2722 WordPressExternalApplicability::Indeterminate,
2723 ),
2724 WordPressVersionResolution::Conflicting => (
2725 WordPressExternalVersionRelation::Indeterminate,
2726 WordPressExternalVersionRelationReason::ConflictingVersionEvidence,
2727 WordPressExternalApplicability::Indeterminate,
2728 ),
2729 WordPressVersionResolution::Unsupported => (
2730 WordPressExternalVersionRelation::Indeterminate,
2731 WordPressExternalVersionRelationReason::UnsupportedVersionEvidence,
2732 WordPressExternalApplicability::Indeterminate,
2733 ),
2734 WordPressVersionResolution::SupportedEquivalent if ranges.is_empty() => (
2735 WordPressExternalVersionRelation::Indeterminate,
2736 WordPressExternalVersionRelationReason::MissingAffectedRanges,
2737 WordPressExternalApplicability::Indeterminate,
2738 ),
2739 WordPressVersionResolution::SupportedEquivalent if has_containing => (
2740 WordPressExternalVersionRelation::WithinSupportedRangeUnderSelectedPolicy,
2741 if has_unsupported {
2742 WordPressExternalVersionRelationReason::ContainingRangeWithPartialCoverage
2743 } else {
2744 WordPressExternalVersionRelationReason::ContainingRange
2745 },
2746 WordPressExternalApplicability::VersionMatchUnderSelectedPolicy,
2747 ),
2748 WordPressVersionResolution::SupportedEquivalent if has_unsupported => (
2749 WordPressExternalVersionRelation::Indeterminate,
2750 WordPressExternalVersionRelationReason::UnsupportedAffectedRange,
2751 WordPressExternalApplicability::Indeterminate,
2752 ),
2753 WordPressVersionResolution::SupportedEquivalent if all_outside => (
2754 WordPressExternalVersionRelation::OutsideDeclaredRangesUnderSelectedPolicy,
2755 WordPressExternalVersionRelationReason::AllRangesOutside,
2756 WordPressExternalApplicability::NoVersionMatchUnderSelectedPolicy,
2757 ),
2758 WordPressVersionResolution::SupportedEquivalent => {
2759 return Err(WordPressReviewError::InvalidExternalCatalog);
2760 },
2761 }
2762 };
2763
2764 Ok(WordPressExternalProfiledEvaluation {
2765 version_resolution: version_resolution.clone(),
2766 ranges,
2767 relation,
2768 reason,
2769 applicability,
2770 })
2771}
2772
2773fn external_patched_version_conflicts(
2774 patched_versions: &[String],
2775 ranges: &[ParsedExternalRange],
2776 profile: WordPressComparisonProfile,
2777) -> Result<bool, WordPressReviewError> {
2778 for declared in patched_versions {
2779 let Ok(version) = ProfiledVersionKey::parse(profile, declared) else {
2780 continue;
2781 };
2782 for range in ranges {
2783 let ParsedExternalRange::Supported { lower, upper } = range else {
2784 continue;
2785 };
2786 let above_lower = lower.as_ref().is_none_or(|(bound, inclusive)| {
2787 bound.compare(&version).ok().is_some_and(|ordering| {
2788 ordering == Ordering::Less || (ordering == Ordering::Equal && *inclusive)
2789 })
2790 });
2791 let below_upper = upper.as_ref().is_none_or(|(bound, inclusive)| {
2792 version.compare(bound).ok().is_some_and(|ordering| {
2793 ordering == Ordering::Less || (ordering == Ordering::Equal && *inclusive)
2794 })
2795 });
2796 if above_lower && below_upper {
2797 return Ok(true);
2798 }
2799 }
2800 }
2801 Ok(false)
2802}
2803
2804fn parse_external_range(
2805 range: &WordfenceV3AffectedRange,
2806 profile: WordPressComparisonProfile,
2807) -> Result<ParsedExternalRange, WordPressReviewError> {
2808 let lower = match range.from().value() {
2809 WordfenceV3RangeValue::Any => Ok(None),
2810 WordfenceV3RangeValue::Declared(value) => ProfiledVersionKey::parse(profile, value)
2811 .map(|version| Some((version, range.from().inclusive()))),
2812 };
2813 let upper = match range.to().value() {
2814 WordfenceV3RangeValue::Any => Ok(None),
2815 WordfenceV3RangeValue::Declared(value) => ProfiledVersionKey::parse(profile, value)
2816 .map(|version| Some((version, range.to().inclusive()))),
2817 };
2818 let (lower, upper) = match (lower, upper) {
2819 (Err(_), Err(_)) => {
2820 return Ok(ParsedExternalRange::Unsupported(
2821 WordPressExternalRangeReason::UnsupportedBothBounds,
2822 ));
2823 },
2824 (Err(_), Ok(_)) => {
2825 return Ok(ParsedExternalRange::Unsupported(
2826 WordPressExternalRangeReason::UnsupportedLowerBound,
2827 ));
2828 },
2829 (Ok(_), Err(_)) => {
2830 return Ok(ParsedExternalRange::Unsupported(
2831 WordPressExternalRangeReason::UnsupportedUpperBound,
2832 ));
2833 },
2834 (Ok(lower), Ok(upper)) => (lower, upper),
2835 };
2836 if let (Some((lower_version, lower_inclusive)), Some((upper_version, upper_inclusive))) =
2837 (&lower, &upper)
2838 {
2839 match lower_version
2840 .compare(upper_version)
2841 .map_err(|_| WordPressReviewError::InvalidExternalCatalog)?
2842 {
2843 Ordering::Greater => {
2844 return Ok(ParsedExternalRange::Invalid(
2845 WordPressExternalRangeReason::ReversedBounds,
2846 ));
2847 },
2848 Ordering::Equal if !(*lower_inclusive && *upper_inclusive) => {
2849 return Ok(ParsedExternalRange::Invalid(
2850 WordPressExternalRangeReason::EmptyExclusiveInterval,
2851 ));
2852 },
2853 Ordering::Less | Ordering::Equal => {},
2854 }
2855 }
2856 Ok(ParsedExternalRange::Supported { lower, upper })
2857}
2858
2859fn evaluate_external_range(
2860 range: &ParsedExternalRange,
2861 resolution: &VersionResolutionState,
2862) -> Result<WordPressExternalRangeEvaluation, WordPressReviewError> {
2863 match range {
2864 ParsedExternalRange::Unsupported(reason) => Ok(WordPressExternalRangeEvaluation {
2865 relation: WordPressExternalRangeRelation::Unsupported,
2866 reason: *reason,
2867 }),
2868 ParsedExternalRange::Invalid(reason) => Ok(WordPressExternalRangeEvaluation {
2869 relation: WordPressExternalRangeRelation::InvalidUnderProfile,
2870 reason: *reason,
2871 }),
2872 ParsedExternalRange::Supported { lower, upper } => {
2873 let Some(version) = resolution.version.as_ref() else {
2874 let reason = match resolution.status {
2875 WordPressVersionResolution::Missing => {
2876 WordPressExternalRangeReason::MissingVersionEvidence
2877 },
2878 WordPressVersionResolution::Conflicting => {
2879 WordPressExternalRangeReason::ConflictingVersionEvidence
2880 },
2881 WordPressVersionResolution::Unsupported => {
2882 WordPressExternalRangeReason::UnsupportedVersionEvidence
2883 },
2884 WordPressVersionResolution::SupportedEquivalent => {
2885 return Err(WordPressReviewError::InvalidExternalCatalog);
2886 },
2887 };
2888 return Ok(WordPressExternalRangeEvaluation {
2889 relation: WordPressExternalRangeRelation::NotEvaluated,
2890 reason,
2891 });
2892 };
2893 let above_lower = match lower {
2894 None => true,
2895 Some((bound, inclusive)) => match version
2896 .compare(bound)
2897 .map_err(|_| WordPressReviewError::InvalidExternalCatalog)?
2898 {
2899 Ordering::Greater => true,
2900 Ordering::Equal => *inclusive,
2901 Ordering::Less => false,
2902 },
2903 };
2904 let below_upper = match upper {
2905 None => true,
2906 Some((bound, inclusive)) => match version
2907 .compare(bound)
2908 .map_err(|_| WordPressReviewError::InvalidExternalCatalog)?
2909 {
2910 Ordering::Less => true,
2911 Ordering::Equal => *inclusive,
2912 Ordering::Greater => false,
2913 },
2914 };
2915 let contains = above_lower && below_upper;
2916 Ok(WordPressExternalRangeEvaluation {
2917 relation: if contains {
2918 WordPressExternalRangeRelation::Contains
2919 } else {
2920 WordPressExternalRangeRelation::DoesNotContain
2921 },
2922 reason: if contains {
2923 WordPressExternalRangeReason::SelectedVersionWithinBounds
2924 } else {
2925 WordPressExternalRangeReason::SelectedVersionOutsideBounds
2926 },
2927 })
2928 },
2929 }
2930}
2931
2932fn external_version_evidence_resolution(
2933 assessment: &WordPressComponentAssessment,
2934 semantic: Option<&VersionResolutionState>,
2935) -> Result<WordPressExternalVersionEvidenceResolution, WordPressReviewError> {
2936 let evidence_row_count = assessment.versions.len();
2937 let distinct_spelling_count = assessment
2938 .versions
2939 .iter()
2940 .map(|version| version.value.as_str())
2941 .collect::<BTreeSet<_>>()
2942 .len();
2943 let status = match (evidence_row_count, distinct_spelling_count) {
2944 (0, 0) => WordPressExternalVersionEvidenceStatus::Missing,
2945 (1, 1) => WordPressExternalVersionEvidenceStatus::SingleDeclaration,
2946 (count, 1) if count > 1 => WordPressExternalVersionEvidenceStatus::RepeatedExactDeclaration,
2947 (count, distinct) if count > 1 && distinct > 1 && distinct <= count => {
2948 WordPressExternalVersionEvidenceStatus::MultipleDistinctDeclarations
2949 },
2950 _ => return Err(WordPressReviewError::InvalidExternalCatalog),
2951 };
2952 Ok(WordPressExternalVersionEvidenceResolution {
2953 status,
2954 evidence_row_count,
2955 distinct_spelling_count,
2956 semantic_status: semantic.map(|resolution| resolution.status),
2957 semantic_reason: semantic.map(|resolution| resolution.reason),
2958 })
2959}
2960
2961fn wordfence_v3_projection_bytes(
2962 record: &WordfenceV3Record,
2963 association: &WordfenceV3SoftwareAssociation,
2964) -> Result<usize, WordPressReviewError> {
2965 let mut total = 512_usize;
2966 for value in [
2967 record.upstream_id(),
2968 record.title(),
2969 record.description(),
2970 record.cve().unwrap_or_default(),
2971 record.cve_link().unwrap_or_default(),
2972 record.published().unwrap_or_default(),
2973 record.updated().unwrap_or_default(),
2974 association.source_component().slug(),
2975 association.display_name(),
2976 association.remediation(),
2977 ] {
2978 total = total
2979 .checked_add(value.len())
2980 .ok_or(WordPressReviewError::ResultLimitExceeded)?;
2981 }
2982 for value in record
2983 .references()
2984 .iter()
2985 .chain(record.researchers())
2986 .chain(record.notice_ids())
2987 .chain(association.patched_versions())
2988 {
2989 total = total
2990 .checked_add(value.len() + 16)
2991 .ok_or(WordPressReviewError::ResultLimitExceeded)?;
2992 }
2993 if let Some(cwe) = record.cwe() {
2994 total = total
2995 .checked_add(cwe.name().len() + cwe.description().len() + 32)
2996 .ok_or(WordPressReviewError::ResultLimitExceeded)?;
2997 }
2998 if let Some(cvss) = record.cvss() {
2999 total = total
3000 .checked_add(cvss.vector().len() + cvss.score().len() + 32)
3001 .ok_or(WordPressReviewError::ResultLimitExceeded)?;
3002 }
3003 for range in association.affected_ranges() {
3004 total = total
3005 .checked_add(
3006 range.label().len()
3007 + range.from().value().declared().len()
3008 + range.to().value().declared().len()
3009 + 64,
3010 )
3011 .ok_or(WordPressReviewError::ResultLimitExceeded)?;
3012 }
3013 Ok(total)
3014}
3015
3016fn wordfence_v3_unresolved_projection_bytes(
3017 record: &WordfenceV3Record,
3018 association: &WordfenceV3UnresolvedSoftwareAssociation,
3019) -> Result<usize, WordPressReviewError> {
3020 let mut total = 256_usize;
3021 for value in [
3022 record.upstream_id(),
3023 record.title(),
3024 association.source_component().slug(),
3025 association.display_name(),
3026 association.remediation(),
3027 ] {
3028 total = total
3029 .checked_add(value.len())
3030 .ok_or(WordPressReviewError::ResultLimitExceeded)?;
3031 }
3032 for value in record
3033 .references()
3034 .iter()
3035 .chain(record.notice_ids())
3036 .chain(association.patched_versions())
3037 {
3038 total = total
3039 .checked_add(value.len() + 16)
3040 .ok_or(WordPressReviewError::ResultLimitExceeded)?;
3041 }
3042 for range in association.affected_ranges() {
3043 total = total
3044 .checked_add(
3045 range.label().len()
3046 + range.from().value().declared().len()
3047 + range.to().value().declared().len()
3048 + 64,
3049 )
3050 .ok_or(WordPressReviewError::ResultLimitExceeded)?;
3051 }
3052 Ok(total)
3053}
3054
3055fn wordfence_v3_notice_projection_bytes(
3056 notice: &WordfenceV3Notice,
3057) -> Result<usize, WordPressReviewError> {
3058 [
3059 notice.id(),
3060 notice.message(),
3061 notice.party(),
3062 notice.notice(),
3063 notice.license(),
3064 notice.license_url(),
3065 ]
3066 .iter()
3067 .try_fold(128_usize, |total, value| {
3068 total
3069 .checked_add(value.len())
3070 .ok_or(WordPressReviewError::ResultLimitExceeded)
3071 })
3072}
3073
3074#[derive(Default)]
3075struct ComponentAggregate {
3076 sources: BTreeSet<WordPressEvidenceSource>,
3077 confidence_classes: BTreeSet<WordPressEvidenceConfidence>,
3078 versions: BTreeSet<WordPressVersionEvidence>,
3079 activation: Option<WordPressActivationState>,
3080 inventory_status: Option<WordPressInventoryEntryStatus>,
3081}
3082
3083fn component_assessment(
3084 identity: WordPressComponentIdentity,
3085 aggregate: &ComponentAggregate,
3086 profiled_catalog: bool,
3087) -> WordPressComponentAssessment {
3088 let conflicting = !profiled_catalog && versions_conflict(&aggregate.versions);
3089 let observed = aggregate.sources.iter().any(|source| {
3090 matches!(
3091 source,
3092 WordPressEvidenceSource::GeneratorMetadata
3093 | WordPressEvidenceSource::SameOriginAssetPath
3094 | WordPressEvidenceSource::ThemeStylesheetDeclaration
3095 )
3096 });
3097 let operator = aggregate
3098 .sources
3099 .contains(&WordPressEvidenceSource::OperatorContext);
3100 let evidence_class = if conflicting {
3101 WordPressComponentEvidenceClass::Conflicting
3102 } else if observed {
3103 WordPressComponentEvidenceClass::ObservedHint
3104 } else if operator {
3105 WordPressComponentEvidenceClass::OperatorSupplied
3106 } else {
3107 WordPressComponentEvidenceClass::Unknown
3108 };
3109 WordPressComponentAssessment {
3110 identity,
3111 evidence_class,
3112 identity_sources: aggregate.sources.iter().copied().collect(),
3113 confidence_classes: aggregate.confidence_classes.iter().copied().collect(),
3114 versions: aggregate.versions.iter().cloned().collect(),
3115 activation: aggregate.activation,
3116 inventory_status: aggregate.inventory_status,
3117 }
3118}
3119
3120const fn confidence_for_source(source: WordPressEvidenceSource) -> WordPressEvidenceConfidence {
3121 match source {
3122 WordPressEvidenceSource::GeneratorMetadata => {
3123 WordPressEvidenceConfidence::PublicDeclaration
3124 },
3125 WordPressEvidenceSource::SameOriginAssetPath => WordPressEvidenceConfidence::StructuralHint,
3126 WordPressEvidenceSource::ThemeStylesheetDeclaration => {
3127 WordPressEvidenceConfidence::PublicDeclaration
3128 },
3129 WordPressEvidenceSource::OperatorContext => WordPressEvidenceConfidence::OperatorAssertion,
3130 }
3131}
3132
3133fn versions_conflict(versions: &BTreeSet<WordPressVersionEvidence>) -> bool {
3134 let mut normalized = BTreeSet::new();
3135 let mut unsupported = BTreeSet::new();
3136 for version in versions {
3137 match NumericDottedVersion::parse(&version.value) {
3138 Ok(version) => {
3139 normalized.insert(version);
3140 },
3141 Err(_) => {
3142 unsupported.insert(version.value.as_str());
3143 },
3144 }
3145 }
3146 normalized.len() > 1
3147 || unsupported.len() > 1
3148 || (!normalized.is_empty() && !unsupported.is_empty())
3149}
3150
3151#[derive(Clone)]
3152struct VersionResolutionState {
3153 status: WordPressVersionResolution,
3154 reason: WordPressVersionResolutionReason,
3155 version: Option<ProfiledVersionKey>,
3156}
3157
3158impl VersionResolutionState {
3159 fn missing() -> Self {
3160 Self {
3161 status: WordPressVersionResolution::Missing,
3162 reason: WordPressVersionResolutionReason::NoVersionEvidence,
3163 version: None,
3164 }
3165 }
3166}
3167
3168fn resolve_profiled_versions(
3169 assessment: &WordPressComponentAssessment,
3170 profile: WordPressComparisonProfile,
3171) -> VersionResolutionState {
3172 if assessment.versions.is_empty() {
3173 return VersionResolutionState::missing();
3174 }
3175 let mut supported = None::<ProfiledVersionKey>;
3176 let mut supported_conflict = false;
3177 let mut unsupported = false;
3178 for evidence in &assessment.versions {
3179 match ProfiledVersionKey::parse(profile, &evidence.value) {
3180 Ok(version) => {
3181 if let Some(selected) = supported.as_ref() {
3182 if selected.compare(&version).ok() != Some(Ordering::Equal) {
3183 supported_conflict = true;
3184 }
3185 } else {
3186 supported = Some(version);
3187 }
3188 },
3189 Err(_) => {
3190 unsupported = true;
3191 },
3192 }
3193 }
3194 if supported_conflict {
3195 return VersionResolutionState {
3196 status: WordPressVersionResolution::Conflicting,
3197 reason: WordPressVersionResolutionReason::ConflictingVersionEvidence,
3198 version: None,
3199 };
3200 }
3201 if unsupported || supported.is_none() {
3202 return VersionResolutionState {
3203 status: WordPressVersionResolution::Unsupported,
3204 reason: WordPressVersionResolutionReason::UnsupportedVersionEvidence,
3205 version: None,
3206 };
3207 }
3208 VersionResolutionState {
3209 status: WordPressVersionResolution::SupportedEquivalent,
3210 reason: if assessment.versions.len() == 1 {
3211 WordPressVersionResolutionReason::SingleSupportedVersion
3212 } else {
3213 WordPressVersionResolutionReason::EquivalentSupportedVersions
3214 },
3215 version: supported,
3216 }
3217}
3218
3219fn profile_independent_component_evidence(
3220 assessment: &WordPressComponentAssessment,
3221) -> WordPressComponentEvidenceClass {
3222 if assessment.identity_sources.iter().any(|source| {
3223 matches!(
3224 source,
3225 WordPressEvidenceSource::GeneratorMetadata
3226 | WordPressEvidenceSource::SameOriginAssetPath
3227 | WordPressEvidenceSource::ThemeStylesheetDeclaration
3228 )
3229 }) {
3230 WordPressComponentEvidenceClass::ObservedHint
3231 } else if assessment
3232 .identity_sources
3233 .contains(&WordPressEvidenceSource::OperatorContext)
3234 {
3235 WordPressComponentEvidenceClass::OperatorSupplied
3236 } else {
3237 WordPressComponentEvidenceClass::Unknown
3238 }
3239}
3240
3241fn evaluate_profiled_version_relation(
3242 resolution: &VersionResolutionState,
3243 record: &WordPressAdvisoryRecord,
3244) -> WordPressVersionRelation {
3245 match resolution.status {
3246 WordPressVersionResolution::Missing | WordPressVersionResolution::Conflicting => {
3247 WordPressVersionRelation::Unknown
3248 },
3249 WordPressVersionResolution::Unsupported => WordPressVersionRelation::Unsupported,
3250 WordPressVersionResolution::SupportedEquivalent => {
3251 let Some(version) = resolution.version.as_ref() else {
3252 return WordPressVersionRelation::Unsupported;
3253 };
3254 if record.profiled_affected_ranges.is_empty() {
3255 return WordPressVersionRelation::Unknown;
3256 }
3257 match record
3258 .profiled_affected_ranges
3259 .iter()
3260 .try_fold(false, |matched, range| {
3261 range.contains(version).map(|contains| matched || contains)
3262 }) {
3263 Ok(true) => WordPressVersionRelation::WithinDeclaredRange,
3264 Ok(false) => WordPressVersionRelation::OutsideDeclaredRanges,
3265 Err(_) => WordPressVersionRelation::Unsupported,
3266 }
3267 },
3268 }
3269}
3270
3271fn evaluate_version_relation(
3272 assessment: Option<&WordPressComponentAssessment>,
3273 record: &WordPressAdvisoryRecord,
3274) -> WordPressVersionRelation {
3275 let Some(assessment) = assessment else {
3276 return WordPressVersionRelation::Unknown;
3277 };
3278 if assessment.evidence_class == WordPressComponentEvidenceClass::Conflicting {
3279 return WordPressVersionRelation::Unknown;
3280 }
3281 let Some(version) = assessment.versions.first() else {
3282 return WordPressVersionRelation::Unknown;
3283 };
3284 let Ok(version) = NumericDottedVersion::parse(&version.value) else {
3285 return WordPressVersionRelation::Unsupported;
3286 };
3287 if record.affected_ranges.is_empty() {
3288 return WordPressVersionRelation::Unknown;
3289 }
3290 if record
3291 .affected_ranges
3292 .iter()
3293 .any(|range| range.contains(&version))
3294 {
3295 WordPressVersionRelation::WithinDeclaredRange
3296 } else {
3297 WordPressVersionRelation::OutsideDeclaredRanges
3298 }
3299}
3300
3301fn evaluate_prerequisite(
3302 prerequisite: &WordPressPrerequisite,
3303 context: Option<&WordPressContext>,
3304 component: &WordPressComponentIdentity,
3305) -> WordPressPrerequisiteOutcome {
3306 let Some(context) = context else {
3307 return match prerequisite {
3308 WordPressPrerequisite::Unsupported { .. } => WordPressPrerequisiteOutcome::Unsupported,
3309 _ => WordPressPrerequisiteOutcome::Unknown,
3310 };
3311 };
3312 match prerequisite {
3313 WordPressPrerequisite::HostingOs { equals } => {
3314 compare_supplied(context.hosting_os, *equals)
3315 },
3316 WordPressPrerequisite::Multisite { equals } => compare_supplied(context.multisite, *equals),
3317 WordPressPrerequisite::Activation { equals } => {
3318 let supplied = context
3319 .components
3320 .iter()
3321 .find(|candidate| candidate.identity == *component)
3322 .and_then(|candidate| candidate.activation)
3323 .filter(|state| *state != WordPressActivationState::Unknown);
3324 compare_supplied(supplied, *equals)
3325 },
3326 WordPressPrerequisite::Patch { id, equals } => {
3327 let supplied = context
3328 .components
3329 .iter()
3330 .find(|candidate| candidate.identity == *component)
3331 .and_then(|candidate| candidate.patches.iter().find(|patch| patch.id == *id))
3332 .map(|patch| patch.state)
3333 .filter(|state| *state != WordPressPatchState::Unknown);
3334 compare_supplied(supplied, *equals)
3335 },
3336 WordPressPrerequisite::Unsupported { .. } => WordPressPrerequisiteOutcome::Unsupported,
3337 }
3338}
3339
3340fn compare_supplied<T: Eq>(supplied: Option<T>, expected: T) -> WordPressPrerequisiteOutcome {
3341 match supplied {
3342 Some(value) if value == expected => WordPressPrerequisiteOutcome::MatchedOnSuppliedFacts,
3343 Some(_) => WordPressPrerequisiteOutcome::ContradictedOnSuppliedFacts,
3344 None => WordPressPrerequisiteOutcome::Unknown,
3345 }
3346}
3347
3348fn summarize_applicability(
3349 component: WordPressComponentEvidenceClass,
3350 version: WordPressVersionRelation,
3351 prerequisites: &[WordPressPrerequisiteEvaluation],
3352) -> WordPressApplicability {
3353 if version == WordPressVersionRelation::OutsideDeclaredRanges
3354 || prerequisites.iter().any(|result| {
3355 result.outcome == WordPressPrerequisiteOutcome::ContradictedOnSuppliedFacts
3356 })
3357 {
3358 return WordPressApplicability::ContradictedByDeclaredFacts;
3359 }
3360 if version == WordPressVersionRelation::Unsupported
3361 || prerequisites
3362 .iter()
3363 .any(|result| result.outcome == WordPressPrerequisiteOutcome::Unsupported)
3364 {
3365 return WordPressApplicability::IndeterminateUnsupported;
3366 }
3367 if component == WordPressComponentEvidenceClass::Unknown
3368 || component == WordPressComponentEvidenceClass::Conflicting
3369 || version == WordPressVersionRelation::Unknown
3370 || prerequisites
3371 .iter()
3372 .any(|result| result.outcome == WordPressPrerequisiteOutcome::Unknown)
3373 {
3374 return WordPressApplicability::IndeterminateMissingEvidence;
3375 }
3376 WordPressApplicability::CandidateMatchOnDeclaredFacts
3377}
3378
3379fn validate_patches(
3380 patches: Vec<PatchWire>,
3381) -> Result<Vec<WordPressPatchAssertion>, WordPressReviewError> {
3382 if patches.len() > MAX_WORDPRESS_PATCH_ASSERTIONS_PER_COMPONENT {
3383 return Err(WordPressReviewError::InvalidContext);
3384 }
3385 let mut seen = BTreeSet::new();
3386 let mut validated = Vec::with_capacity(patches.len());
3387 for patch in patches {
3388 if !valid_identifier(&patch.id) {
3389 return Err(WordPressReviewError::InvalidContext);
3390 }
3391 if !seen.insert(patch.id.clone()) {
3392 return Err(WordPressReviewError::InvalidContext);
3393 }
3394 validated.push(WordPressPatchAssertion {
3395 id: patch.id,
3396 state: patch.state.into(),
3397 });
3398 }
3399 validated.sort_by(|left, right| left.id.cmp(&right.id));
3400 Ok(validated)
3401}
3402
3403fn validate_source(source: SourceWire) -> Result<WordPressAdvisorySource, WordPressReviewError> {
3404 validate_identifier(&source.revision)?;
3405 validate_date(&source.retrieved_on)?;
3406 validate_text(&source.usage_basis, 256)?;
3407 if source.reference.len() > MAX_WORDPRESS_REFERENCE_BYTES {
3408 return Err(WordPressReviewError::InvalidCatalog);
3409 }
3410 let url = Url::parse(&source.reference).map_err(|_| WordPressReviewError::InvalidCatalog)?;
3411 if url.scheme() != "https"
3412 || !url.has_host()
3413 || !url.username().is_empty()
3414 || url.password().is_some()
3415 {
3416 return Err(WordPressReviewError::InvalidCatalog);
3417 }
3418 Ok(WordPressAdvisorySource {
3419 reference: source.reference,
3420 revision: source.revision,
3421 retrieved_on: source.retrieved_on,
3422 usage_basis: source.usage_basis,
3423 })
3424}
3425
3426fn validate_range(range: RangeWire) -> Result<WordPressAffectedRange, WordPressReviewError> {
3427 if range.lower.is_none() && range.upper.is_none() {
3428 return Err(WordPressReviewError::InvalidVersionRange);
3429 }
3430 let lower = range.lower.map(validate_endpoint).transpose()?;
3431 let upper = range.upper.map(validate_endpoint).transpose()?;
3432 if let (Some(lower), Some(upper)) = (&lower, &upper) {
3433 match lower.version.cmp(&upper.version) {
3434 Ordering::Greater => return Err(WordPressReviewError::InvalidVersionRange),
3435 Ordering::Equal if !(lower.inclusive && upper.inclusive) => {
3436 return Err(WordPressReviewError::InvalidVersionRange);
3437 },
3438 Ordering::Less | Ordering::Equal => {},
3439 }
3440 }
3441 Ok(WordPressAffectedRange { lower, upper })
3442}
3443
3444fn validate_endpoint(
3445 endpoint: EndpointWire,
3446) -> Result<WordPressVersionEndpoint, WordPressReviewError> {
3447 let version = NumericDottedVersion::parse(&endpoint.version)
3448 .map_err(|_| WordPressReviewError::InvalidVersionRange)?;
3449 Ok(WordPressVersionEndpoint {
3450 declared: endpoint.version,
3451 version,
3452 inclusive: endpoint.inclusive,
3453 })
3454}
3455
3456fn validate_prerequisite(
3457 prerequisite: PrerequisiteWire,
3458) -> Result<WordPressPrerequisite, WordPressReviewError> {
3459 Ok(match prerequisite {
3460 PrerequisiteWire::HostingOs { equals } => WordPressPrerequisite::HostingOs {
3461 equals: equals.into(),
3462 },
3463 PrerequisiteWire::Multisite { equals } => WordPressPrerequisite::Multisite {
3464 equals: equals.into(),
3465 },
3466 PrerequisiteWire::Activation { equals } => {
3467 let equals = equals.into();
3468 if equals == WordPressActivationState::Unknown {
3469 return Err(WordPressReviewError::InvalidCatalog);
3470 }
3471 WordPressPrerequisite::Activation { equals }
3472 },
3473 PrerequisiteWire::Patch { id, equals } => {
3474 validate_identifier(&id)?;
3475 let equals = equals.into();
3476 if equals == WordPressPatchState::Unknown {
3477 return Err(WordPressReviewError::InvalidCatalog);
3478 }
3479 WordPressPrerequisite::Patch { id, equals }
3480 },
3481 PrerequisiteWire::Unsupported { name } => {
3482 validate_identifier(&name)?;
3483 WordPressPrerequisite::Unsupported { name }
3484 },
3485 })
3486}
3487
3488fn validate_root_url(value: &str) -> Result<Url, WordPressReviewError> {
3489 if value.len() > MAX_WORDPRESS_REFERENCE_BYTES {
3490 return Err(WordPressReviewError::InvalidContext);
3491 }
3492 let url = Url::parse(value).map_err(|_| WordPressReviewError::InvalidContext)?;
3493 if !matches!(url.scheme(), "http" | "https")
3494 || !url.has_host()
3495 || !url.username().is_empty()
3496 || url.password().is_some()
3497 || url.query().is_some()
3498 || url.fragment().is_some()
3499 || url.path() != "/"
3500 {
3501 return Err(WordPressReviewError::InvalidContext);
3502 }
3503 Ok(url)
3504}
3505
3506fn validate_source_version(value: &str) -> Result<String, WordPressReviewError> {
3507 if value.is_empty()
3508 || value.len() > MAX_WORDPRESS_VERSION_BYTES
3509 || value.chars().any(char::is_control)
3510 {
3511 return Err(WordPressReviewError::InvalidContext);
3512 }
3513 Ok(value.to_owned())
3514}
3515
3516fn validate_identifier(value: &str) -> Result<(), WordPressReviewError> {
3517 if !valid_identifier(value) {
3518 return Err(WordPressReviewError::InvalidCatalog);
3519 }
3520 Ok(())
3521}
3522
3523fn valid_identifier(value: &str) -> bool {
3524 !value.is_empty()
3525 && value.len() <= MAX_WORDPRESS_IDENTIFIER_BYTES
3526 && value.bytes().all(|byte| {
3527 byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'/' | b'@' | b'-')
3528 })
3529}
3530
3531fn validate_text(value: &str, maximum: usize) -> Result<(), WordPressReviewError> {
3532 if value.is_empty() || value.len() > maximum || value.chars().any(char::is_control) {
3533 return Err(WordPressReviewError::InvalidCatalog);
3534 }
3535 Ok(())
3536}
3537
3538pub(crate) fn valid_slug(value: &str) -> bool {
3539 if value.is_empty() || value.len() > MAX_WORDPRESS_SLUG_BYTES {
3540 return false;
3541 }
3542 let mut previous_hyphen = true;
3543 for byte in value.bytes() {
3544 match byte {
3545 b'a'..=b'z' | b'0'..=b'9' => previous_hyphen = false,
3546 b'-' if !previous_hyphen => previous_hyphen = true,
3547 _ => return false,
3548 }
3549 }
3550 !previous_hyphen
3551}
3552
3553fn valid_cve(value: &str) -> bool {
3554 let Some(rest) = value.strip_prefix("CVE-") else {
3555 return false;
3556 };
3557 let mut parts = rest.split('-');
3558 let (Some(year), Some(sequence), None) = (parts.next(), parts.next(), parts.next()) else {
3559 return false;
3560 };
3561 year.len() == 4
3562 && year.bytes().all(|byte| byte.is_ascii_digit())
3563 && (4..=10).contains(&sequence.len())
3564 && sequence.bytes().all(|byte| byte.is_ascii_digit())
3565}
3566
3567fn validate_date(value: &str) -> Result<(), WordPressReviewError> {
3568 let bytes = value.as_bytes();
3569 if bytes.len() != 10
3570 || bytes[4] != b'-'
3571 || bytes[7] != b'-'
3572 || !bytes
3573 .iter()
3574 .enumerate()
3575 .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit())
3576 {
3577 return Err(WordPressReviewError::InvalidCatalog);
3578 }
3579 let year = decimal(&bytes[0..4]).ok_or(WordPressReviewError::InvalidCatalog)?;
3580 let month = decimal(&bytes[5..7]).ok_or(WordPressReviewError::InvalidCatalog)?;
3581 let day = decimal(&bytes[8..10]).ok_or(WordPressReviewError::InvalidCatalog)?;
3582 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
3583 let maximum = match month {
3584 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
3585 4 | 6 | 9 | 11 => 30,
3586 2 if leap => 29,
3587 2 => 28,
3588 _ => return Err(WordPressReviewError::InvalidCatalog),
3589 };
3590 if day == 0 || day > maximum {
3591 return Err(WordPressReviewError::InvalidCatalog);
3592 }
3593 Ok(())
3594}
3595
3596fn decimal(bytes: &[u8]) -> Option<u32> {
3597 bytes.iter().try_fold(0_u32, |value, digit| {
3598 value.checked_mul(10)?.checked_add(u32::from(*digit - b'0'))
3599 })
3600}
3601
3602fn require_schema(value: &Value, expected: &str) -> Result<(), WordPressReviewError> {
3603 if value
3604 .as_object()
3605 .and_then(|object| object.get("schema"))
3606 .and_then(Value::as_str)
3607 != Some(expected)
3608 {
3609 return Err(WordPressReviewError::UnsupportedSchema);
3610 }
3611 Ok(())
3612}
3613
3614fn parse_bounded_json(
3615 bytes: &[u8],
3616 maximum_bytes: usize,
3617 maximum_nodes: usize,
3618 maximum_array_length: usize,
3619 too_large: WordPressReviewError,
3620) -> Result<Value, WordPressReviewError> {
3621 if bytes.is_empty() {
3622 return Err(WordPressReviewError::EmptyInput);
3623 }
3624 if bytes.len() > maximum_bytes {
3625 return Err(too_large);
3626 }
3627 let limits = JsonLimits {
3628 maximum_nodes,
3629 maximum_array_length,
3630 };
3631 let mut budget = JsonBudget::default();
3632 let mut decoder = serde_json::Deserializer::from_slice(bytes);
3633 let parsed = BoundedValueSeed {
3634 depth: 0,
3635 limits,
3636 budget: &mut budget,
3637 }
3638 .deserialize(&mut decoder)
3639 .map_err(classify_json_error)?;
3640 decoder.end().map_err(classify_json_error)?;
3641 Ok(parsed)
3642}
3643
3644fn classify_json_error(error: serde_json::Error) -> WordPressReviewError {
3645 let message = error.to_string();
3646 if message.contains(DUPLICATE_KEY_MARKER) {
3647 WordPressReviewError::DuplicateKey
3648 } else if message.contains(JSON_LIMIT_MARKER) {
3649 WordPressReviewError::JsonLimitExceeded
3650 } else {
3651 WordPressReviewError::MalformedJson
3652 }
3653}
3654
3655#[derive(Clone, Copy)]
3656struct JsonLimits {
3657 maximum_nodes: usize,
3658 maximum_array_length: usize,
3659}
3660
3661#[derive(Default)]
3662struct JsonBudget {
3663 nodes: usize,
3664}
3665
3666struct BoundedValueSeed<'a> {
3667 depth: usize,
3668 limits: JsonLimits,
3669 budget: &'a mut JsonBudget,
3670}
3671
3672impl<'de> DeserializeSeed<'de> for BoundedValueSeed<'_> {
3673 type Value = Value;
3674
3675 fn deserialize<D: de::Deserializer<'de>>(self, decoder: D) -> Result<Value, D::Error> {
3676 if self.depth > MAX_JSON_DEPTH || self.budget.nodes == self.limits.maximum_nodes {
3677 return Err(de::Error::custom(JSON_LIMIT_MARKER));
3678 }
3679 self.budget.nodes += 1;
3680 decoder.deserialize_any(BoundedValueVisitor {
3681 depth: self.depth,
3682 limits: self.limits,
3683 budget: self.budget,
3684 })
3685 }
3686}
3687
3688struct BoundedValueVisitor<'a> {
3689 depth: usize,
3690 limits: JsonLimits,
3691 budget: &'a mut JsonBudget,
3692}
3693
3694impl<'de> Visitor<'de> for BoundedValueVisitor<'_> {
3695 type Value = Value;
3696
3697 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
3698 formatter.write_str("bounded WordPress JSON")
3699 }
3700
3701 fn visit_bool<E: de::Error>(self, value: bool) -> Result<Value, E> {
3702 Ok(Value::Bool(value))
3703 }
3704
3705 fn visit_unit<E: de::Error>(self) -> Result<Value, E> {
3706 Ok(Value::Null)
3707 }
3708
3709 fn visit_u64<E: de::Error>(self, value: u64) -> Result<Value, E> {
3710 Ok(Value::Number(Number::from(value)))
3711 }
3712
3713 fn visit_i64<E: de::Error>(self, value: i64) -> Result<Value, E> {
3714 Ok(Value::Number(Number::from(value)))
3715 }
3716
3717 fn visit_f64<E: de::Error>(self, value: f64) -> Result<Value, E> {
3718 Number::from_f64(value)
3719 .map(Value::Number)
3720 .ok_or_else(|| de::Error::custom("non-finite number"))
3721 }
3722
3723 fn visit_borrowed_str<E: de::Error>(self, value: &'de str) -> Result<Value, E> {
3724 bounded_string(value)
3725 }
3726
3727 fn visit_str<E: de::Error>(self, value: &str) -> Result<Value, E> {
3728 bounded_string(value)
3729 }
3730
3731 fn visit_string<E: de::Error>(self, value: String) -> Result<Value, E> {
3732 if value.len() > MAX_JSON_STRING_BYTES {
3733 Err(de::Error::custom(JSON_LIMIT_MARKER))
3734 } else {
3735 Ok(Value::String(value))
3736 }
3737 }
3738
3739 fn visit_seq<A: SeqAccess<'de>>(self, mut values: A) -> Result<Value, A::Error> {
3740 let mut array = Vec::new();
3741 let depth = self.depth;
3742 let limits = self.limits;
3743 let budget = self.budget;
3744 loop {
3745 if array.len() == limits.maximum_array_length {
3746 if values.next_element::<de::IgnoredAny>()?.is_some() {
3747 return Err(de::Error::custom(JSON_LIMIT_MARKER));
3748 }
3749 break;
3750 }
3751 let Some(value) = values.next_element_seed(BoundedValueSeed {
3752 depth: depth + 1,
3753 limits,
3754 budget: &mut *budget,
3755 })?
3756 else {
3757 break;
3758 };
3759 array.push(value);
3760 }
3761 Ok(Value::Array(array))
3762 }
3763
3764 fn visit_map<A: MapAccess<'de>>(self, mut fields: A) -> Result<Value, A::Error> {
3765 let mut object = Map::new();
3766 let depth = self.depth;
3767 let limits = self.limits;
3768 let budget = self.budget;
3769 loop {
3770 if object.len() == MAX_JSON_OBJECT_FIELDS {
3771 if fields.next_key_seed(BoundedKeySeed)?.is_some() {
3772 return Err(de::Error::custom(JSON_LIMIT_MARKER));
3773 }
3774 break;
3775 }
3776 let Some(key) = fields.next_key_seed(BoundedKeySeed)? else {
3777 break;
3778 };
3779 if object.contains_key(&key) {
3780 return Err(de::Error::custom(DUPLICATE_KEY_MARKER));
3781 }
3782 let value = fields.next_value_seed(BoundedValueSeed {
3783 depth: depth + 1,
3784 limits,
3785 budget: &mut *budget,
3786 })?;
3787 object.insert(key, value);
3788 }
3789 Ok(Value::Object(object))
3790 }
3791}
3792
3793fn bounded_string<E: de::Error>(value: &str) -> Result<Value, E> {
3794 if value.len() > MAX_JSON_STRING_BYTES {
3795 Err(de::Error::custom(JSON_LIMIT_MARKER))
3796 } else {
3797 Ok(Value::String(value.to_owned()))
3798 }
3799}
3800
3801struct BoundedKeySeed;
3802
3803impl<'de> DeserializeSeed<'de> for BoundedKeySeed {
3804 type Value = String;
3805
3806 fn deserialize<D: de::Deserializer<'de>>(self, decoder: D) -> Result<String, D::Error> {
3807 decoder.deserialize_str(BoundedKeyVisitor)
3808 }
3809}
3810
3811struct BoundedKeyVisitor;
3812
3813impl Visitor<'_> for BoundedKeyVisitor {
3814 type Value = String;
3815
3816 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
3817 formatter.write_str("a bounded JSON object key")
3818 }
3819
3820 fn visit_borrowed_str<E: de::Error>(self, value: &str) -> Result<String, E> {
3821 self.visit_str(value)
3822 }
3823
3824 fn visit_str<E: de::Error>(self, value: &str) -> Result<String, E> {
3825 if value.len() > MAX_JSON_KEY_BYTES {
3826 Err(de::Error::custom(JSON_LIMIT_MARKER))
3827 } else {
3828 Ok(value.to_owned())
3829 }
3830 }
3831
3832 fn visit_string<E: de::Error>(self, value: String) -> Result<String, E> {
3833 if value.len() > MAX_JSON_KEY_BYTES {
3834 Err(de::Error::custom(JSON_LIMIT_MARKER))
3835 } else {
3836 Ok(value)
3837 }
3838 }
3839}
3840
3841#[derive(Deserialize)]
3842#[serde(deny_unknown_fields)]
3843struct ContextWire {
3844 #[allow(dead_code)]
3845 schema: String,
3846 root: String,
3847 #[serde(default)]
3848 hosting_os: Option<HostingOsWire>,
3849 #[serde(default)]
3850 multisite: Option<MultisiteWire>,
3851 components: Vec<ContextComponentWire>,
3852}
3853
3854#[derive(Deserialize)]
3855#[serde(deny_unknown_fields)]
3856struct ContextComponentWire {
3857 kind: ComponentKindWire,
3858 slug: String,
3859 #[serde(default)]
3860 version: Option<String>,
3861 #[serde(default)]
3862 activation: Option<ActivationWire>,
3863 #[serde(default)]
3864 patches: Vec<PatchWire>,
3865}
3866
3867#[derive(Deserialize)]
3868#[serde(deny_unknown_fields)]
3869struct PatchWire {
3870 id: String,
3871 state: PatchStateWire,
3872}
3873
3874#[derive(Deserialize)]
3875#[serde(deny_unknown_fields)]
3876struct CatalogV1Wire {
3877 #[allow(dead_code)]
3878 schema: String,
3879 catalog: CatalogMetadataWire,
3880 records: Vec<AdvisoryRecordV1Wire>,
3881}
3882
3883fn validate_profiled_range(
3884 range: RangeWire,
3885 profile: WordPressComparisonProfile,
3886) -> Result<WordPressProfiledAffectedRange, WordPressReviewError> {
3887 if range.lower.is_none() && range.upper.is_none() {
3888 return Err(WordPressReviewError::InvalidVersionRange);
3889 }
3890 let lower = range
3891 .lower
3892 .map(|endpoint| validate_profiled_endpoint(endpoint, profile))
3893 .transpose()?;
3894 let upper = range
3895 .upper
3896 .map(|endpoint| validate_profiled_endpoint(endpoint, profile))
3897 .transpose()?;
3898 if let (Some(lower), Some(upper)) = (&lower, &upper) {
3899 match lower
3900 .version
3901 .compare(&upper.version)
3902 .map_err(|_| WordPressReviewError::InvalidVersionRange)?
3903 {
3904 Ordering::Greater => return Err(WordPressReviewError::InvalidVersionRange),
3905 Ordering::Equal if !(lower.inclusive && upper.inclusive) => {
3906 return Err(WordPressReviewError::InvalidVersionRange);
3907 },
3908 Ordering::Less | Ordering::Equal => {},
3909 }
3910 }
3911 Ok(WordPressProfiledAffectedRange { lower, upper })
3912}
3913
3914fn validate_profiled_endpoint(
3915 endpoint: EndpointWire,
3916 profile: WordPressComparisonProfile,
3917) -> Result<WordPressProfiledVersionEndpoint, WordPressReviewError> {
3918 let version = ProfiledVersionKey::parse(profile, &endpoint.version)
3919 .map_err(|_| WordPressReviewError::InvalidVersionRange)?;
3920 Ok(WordPressProfiledVersionEndpoint {
3921 declared: endpoint.version,
3922 version,
3923 inclusive: endpoint.inclusive,
3924 })
3925}
3926
3927fn compare_profiled_ranges(
3928 left: &WordPressProfiledAffectedRange,
3929 right: &WordPressProfiledAffectedRange,
3930) -> Ordering {
3931 compare_profiled_endpoint_semantics(left.lower.as_ref(), right.lower.as_ref())
3932 .then_with(|| {
3933 compare_profiled_endpoint_semantics(left.upper.as_ref(), right.upper.as_ref())
3934 })
3935 .then_with(|| {
3936 compare_profiled_endpoint_spellings(left.lower.as_ref(), right.lower.as_ref())
3937 })
3938 .then_with(|| {
3939 compare_profiled_endpoint_spellings(left.upper.as_ref(), right.upper.as_ref())
3940 })
3941}
3942
3943fn compare_profiled_endpoint_semantics(
3944 left: Option<&WordPressProfiledVersionEndpoint>,
3945 right: Option<&WordPressProfiledVersionEndpoint>,
3946) -> Ordering {
3947 match (left, right) {
3948 (None, None) => Ordering::Equal,
3949 (None, Some(_)) => Ordering::Less,
3950 (Some(_), None) => Ordering::Greater,
3951 (Some(left), Some(right)) => left
3952 .version
3953 .compare(&right.version)
3954 .expect("one record has exactly one validated comparison profile")
3955 .then_with(|| left.inclusive.cmp(&right.inclusive)),
3956 }
3957}
3958
3959fn compare_profiled_endpoint_spellings(
3960 left: Option<&WordPressProfiledVersionEndpoint>,
3961 right: Option<&WordPressProfiledVersionEndpoint>,
3962) -> Ordering {
3963 match (left, right) {
3964 (None, None) => Ordering::Equal,
3965 (None, Some(_)) => Ordering::Less,
3966 (Some(_), None) => Ordering::Greater,
3967 (Some(left), Some(right)) => left.declared.cmp(&right.declared),
3968 }
3969}
3970
3971fn profiled_ranges_equivalent(
3972 left: &WordPressProfiledAffectedRange,
3973 right: &WordPressProfiledAffectedRange,
3974) -> bool {
3975 profiled_endpoints_equivalent(left.lower.as_ref(), right.lower.as_ref())
3976 && profiled_endpoints_equivalent(left.upper.as_ref(), right.upper.as_ref())
3977}
3978
3979fn profiled_endpoints_equivalent(
3980 left: Option<&WordPressProfiledVersionEndpoint>,
3981 right: Option<&WordPressProfiledVersionEndpoint>,
3982) -> bool {
3983 match (left, right) {
3984 (None, None) => true,
3985 (Some(left), Some(right)) => {
3986 left.inclusive == right.inclusive
3987 && left.version.compare(&right.version).ok() == Some(Ordering::Equal)
3988 },
3989 (None, Some(_)) | (Some(_), None) => false,
3990 }
3991}
3992
3993#[derive(Deserialize)]
3994#[serde(deny_unknown_fields)]
3995struct CatalogV2Wire {
3996 #[allow(dead_code)]
3997 schema: String,
3998 catalog: CatalogMetadataWire,
3999 records: Vec<AdvisoryRecordV2Wire>,
4000}
4001
4002#[derive(Deserialize)]
4003#[serde(deny_unknown_fields)]
4004struct CatalogMetadataWire {
4005 id: String,
4006 revision: String,
4007 retrieved_on: String,
4008}
4009
4010#[derive(Deserialize)]
4011#[serde(deny_unknown_fields)]
4012struct AdvisoryRecordV1Wire {
4013 id: String,
4014 component: ComponentIdentityWire,
4015 source: SourceWire,
4016 #[serde(default)]
4017 cve: Option<String>,
4018 summary: String,
4019 #[serde(default)]
4020 affected_ranges: Vec<RangeWire>,
4021 #[serde(default)]
4022 fixed_versions: Vec<String>,
4023 #[serde(default)]
4024 prerequisites: Vec<PrerequisiteWire>,
4025 #[serde(default)]
4026 remediation: Option<String>,
4027}
4028
4029#[derive(Deserialize)]
4030#[serde(deny_unknown_fields)]
4031struct AdvisoryRecordV2Wire {
4032 id: String,
4033 component: ComponentIdentityWire,
4034 source: SourceWire,
4035 comparison_profile: String,
4036 #[serde(default)]
4037 cve: Option<String>,
4038 summary: String,
4039 #[serde(default)]
4040 affected_ranges: Vec<RangeWire>,
4041 #[serde(default)]
4042 fixed_versions: Vec<String>,
4043 #[serde(default)]
4044 prerequisites: Vec<PrerequisiteWire>,
4045 #[serde(default)]
4046 remediation: Option<String>,
4047}
4048
4049struct AdvisoryRecordInput {
4050 id: String,
4051 component: ComponentIdentityWire,
4052 source: SourceWire,
4053 comparison_profile: WordPressComparisonProfile,
4054 cve: Option<String>,
4055 summary: String,
4056 affected_ranges: Vec<RangeWire>,
4057 fixed_versions: Vec<String>,
4058 prerequisites: Vec<PrerequisiteWire>,
4059 remediation: Option<String>,
4060}
4061
4062impl From<AdvisoryRecordV1Wire> for AdvisoryRecordInput {
4063 fn from(record: AdvisoryRecordV1Wire) -> Self {
4064 Self {
4065 id: record.id,
4066 component: record.component,
4067 source: record.source,
4068 comparison_profile: WordPressComparisonProfile::NumericDottedV1,
4069 cve: record.cve,
4070 summary: record.summary,
4071 affected_ranges: record.affected_ranges,
4072 fixed_versions: record.fixed_versions,
4073 prerequisites: record.prerequisites,
4074 remediation: record.remediation,
4075 }
4076 }
4077}
4078
4079impl TryFrom<AdvisoryRecordV2Wire> for AdvisoryRecordInput {
4080 type Error = WordPressReviewError;
4081
4082 fn try_from(record: AdvisoryRecordV2Wire) -> Result<Self, Self::Error> {
4083 Ok(Self {
4084 id: record.id,
4085 component: record.component,
4086 source: record.source,
4087 comparison_profile: WordPressComparisonProfile::parse(&record.comparison_profile)
4088 .map_err(|_| WordPressReviewError::InvalidCatalog)?,
4089 cve: record.cve,
4090 summary: record.summary,
4091 affected_ranges: record.affected_ranges,
4092 fixed_versions: record.fixed_versions,
4093 prerequisites: record.prerequisites,
4094 remediation: record.remediation,
4095 })
4096 }
4097}
4098
4099#[derive(Deserialize)]
4100#[serde(deny_unknown_fields)]
4101struct ComponentIdentityWire {
4102 kind: ComponentKindWire,
4103 slug: String,
4104}
4105
4106#[derive(Deserialize)]
4107#[serde(deny_unknown_fields)]
4108struct SourceWire {
4109 reference: String,
4110 revision: String,
4111 retrieved_on: String,
4112 usage_basis: String,
4113}
4114
4115#[derive(Deserialize)]
4116#[serde(deny_unknown_fields)]
4117struct RangeWire {
4118 #[serde(default)]
4119 lower: Option<EndpointWire>,
4120 #[serde(default)]
4121 upper: Option<EndpointWire>,
4122}
4123
4124#[derive(Deserialize)]
4125#[serde(deny_unknown_fields)]
4126struct EndpointWire {
4127 version: String,
4128 inclusive: bool,
4129}
4130
4131#[derive(Deserialize)]
4132#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
4133enum PrerequisiteWire {
4134 HostingOs { equals: HostingOsWire },
4135 Multisite { equals: MultisiteWire },
4136 Activation { equals: ActivationWire },
4137 Patch { id: String, equals: PatchStateWire },
4138 Unsupported { name: String },
4139}
4140
4141#[derive(Clone, Copy, Deserialize)]
4142#[serde(rename_all = "snake_case")]
4143enum ComponentKindWire {
4144 Core,
4145 Plugin,
4146 Theme,
4147}
4148
4149impl From<ComponentKindWire> for WordPressComponentKind {
4150 fn from(value: ComponentKindWire) -> Self {
4151 match value {
4152 ComponentKindWire::Core => Self::Core,
4153 ComponentKindWire::Plugin => Self::Plugin,
4154 ComponentKindWire::Theme => Self::Theme,
4155 }
4156 }
4157}
4158
4159#[derive(Clone, Copy, Deserialize)]
4160#[serde(rename_all = "snake_case")]
4161enum HostingOsWire {
4162 Linux,
4163 Windows,
4164 Macos,
4165 Bsd,
4166 Other,
4167}
4168
4169impl From<HostingOsWire> for WordPressHostingOs {
4170 fn from(value: HostingOsWire) -> Self {
4171 match value {
4172 HostingOsWire::Linux => Self::Linux,
4173 HostingOsWire::Windows => Self::Windows,
4174 HostingOsWire::Macos => Self::Macos,
4175 HostingOsWire::Bsd => Self::Bsd,
4176 HostingOsWire::Other => Self::Other,
4177 }
4178 }
4179}
4180
4181#[derive(Clone, Copy, Deserialize)]
4182#[serde(rename_all = "snake_case")]
4183enum MultisiteWire {
4184 Enabled,
4185 Disabled,
4186}
4187
4188impl From<MultisiteWire> for WordPressMultisiteState {
4189 fn from(value: MultisiteWire) -> Self {
4190 match value {
4191 MultisiteWire::Enabled => Self::Enabled,
4192 MultisiteWire::Disabled => Self::Disabled,
4193 }
4194 }
4195}
4196
4197#[derive(Clone, Copy, Deserialize)]
4198#[serde(rename_all = "snake_case")]
4199enum ActivationWire {
4200 Active,
4201 Inactive,
4202 NetworkActive,
4203 Unknown,
4204}
4205
4206impl From<ActivationWire> for WordPressActivationState {
4207 fn from(value: ActivationWire) -> Self {
4208 match value {
4209 ActivationWire::Active => Self::Active,
4210 ActivationWire::Inactive => Self::Inactive,
4211 ActivationWire::NetworkActive => Self::NetworkActive,
4212 ActivationWire::Unknown => Self::Unknown,
4213 }
4214 }
4215}
4216
4217#[derive(Clone, Copy, Deserialize)]
4218#[serde(rename_all = "snake_case")]
4219enum PatchStateWire {
4220 Applied,
4221 NotApplied,
4222 Unknown,
4223}
4224
4225impl From<PatchStateWire> for WordPressPatchState {
4226 fn from(value: PatchStateWire) -> Self {
4227 match value {
4228 PatchStateWire::Applied => Self::Applied,
4229 PatchStateWire::NotApplied => Self::NotApplied,
4230 PatchStateWire::Unknown => Self::Unknown,
4231 }
4232 }
4233}
4234
4235#[cfg(test)]
4236mod tests {
4237 use super::*;
4238
4239 fn wordfence_associations(count: usize) -> String {
4240 use std::fmt::Write as _;
4241
4242 let mut document = String::from("{");
4243 for index in 0..count {
4244 if index != 0 {
4245 document.push(',');
4246 }
4247 let id = format!("00000000-0000-4000-8000-{index:012x}");
4248 write!(
4249 document,
4250 r#""{id}":{{"id":"{id}","title":"Synthetic","software":[{{"type":"plugin","name":"Synthetic","slug":"selected-plugin","affected_versions":{{"all":{{"from_version":"*","from_inclusive":false,"to_version":"*","to_inclusive":false}}}},"patched":false,"patched_versions":[],"remediation":"Review source guidance"}}],"informational":false,"description":"Synthetic bounded evaluator fixture","references":["https://example.invalid/{id}"],"cwe":null,"cvss":null,"cve":null,"cve_link":null,"researchers":[],"published":null,"updated":null,"copyrights":null}}"#
4251 )
4252 .unwrap();
4253 }
4254 document.push('}');
4255 document
4256 }
4257
4258 fn wordfence_dense_profiled_associations(count: usize) -> String {
4259 let ranges = (0..MAX_WORDFENCE_V3_RANGES_PER_ASSOCIATION)
4260 .map(|index| {
4261 (
4262 format!("range-{index:02}"),
4263 serde_json::json!({
4264 "from_version":format!("1.{index}"),
4265 "from_inclusive":true,
4266 "to_version":format!("1.{index}.1"),
4267 "to_inclusive":true
4268 }),
4269 )
4270 })
4271 .collect::<serde_json::Map<_, _>>();
4272 let patched_versions = (0..MAX_WORDFENCE_V3_PATCHED_VERSIONS)
4273 .map(|index| format!("9.{index}"))
4274 .collect::<Vec<_>>();
4275 let mut records = serde_json::Map::new();
4276 for index in 0..count {
4277 let id = format!("00000000-0000-4000-8000-{index:012x}");
4278 records.insert(
4279 id.clone(),
4280 serde_json::json!({
4281 "id":id,
4282 "title":"[SYNTHETIC] Dense work fixture",
4283 "software":[{
4284 "type":"plugin",
4285 "name":"[SYNTHETIC] Dense component",
4286 "slug":"selected-plugin",
4287 "affected_versions":ranges.clone(),
4288 "patched":true,
4289 "patched_versions":patched_versions.clone(),
4290 "remediation":"Review source guidance"
4291 }],
4292 "informational":false,
4293 "description":"Synthetic Cartesian-work fixture",
4294 "references":[format!("https://example.invalid/{index}")],
4295 "cwe":null,
4296 "cvss":null,
4297 "cve":null,
4298 "cve_link":null,
4299 "researchers":[],
4300 "published":null,
4301 "updated":null,
4302 "copyrights":null
4303 }),
4304 );
4305 }
4306 serde_json::Value::Object(records).to_string()
4307 }
4308
4309 const CONTEXT: &str = r#"{
4310 "schema":"security.wordpress-context/v1",
4311 "root":"https://example.test/",
4312 "hosting_os":"linux",
4313 "multisite":"enabled",
4314 "components":[
4315 {"kind":"core","slug":"wordpress","version":"6.9.4","activation":"active"},
4316 {"kind":"plugin","slug":"sample-plugin","version":"2.4.0","activation":"network_active",
4317 "patches":[{"id":"vendor-backport-1","state":"applied"}]},
4318 {"kind":"theme","slug":"sample-theme","activation":"inactive"}
4319 ]
4320 }"#;
4321
4322 const CATALOG: &str = r#"{
4323 "schema":"security.wordpress-advisory-catalog/v1",
4324 "catalog":{"id":"synthetic-wordpress-catalog","revision":"r1","retrieved_on":"2026-09-06"},
4325 "records":[
4326 {
4327 "id":"SYNTHETIC-CORE-1",
4328 "component":{"kind":"core","slug":"wordpress"},
4329 "source":{"reference":"https://example.test/advisories/core-1","revision":"r1","retrieved_on":"2026-09-06","usage_basis":"synthetic test record"},
4330 "cve":"CVE-2099-1000",
4331 "summary":"Synthetic bounded core record",
4332 "affected_ranges":[
4333 {"lower":{"version":"6.9.0","inclusive":true},"upper":{"version":"6.9.5","inclusive":false}},
4334 {"lower":{"version":"7.0.0","inclusive":true},"upper":{"version":"7.0.2","inclusive":false}}
4335 ],
4336 "fixed_versions":["6.9.5","7.0.2"],
4337 "prerequisites":[{"kind":"hosting_os","equals":"linux"},{"kind":"multisite","equals":"enabled"}],
4338 "remediation":"Follow the cited vendor guidance"
4339 },
4340 {
4341 "id":"SYNTHETIC-PLUGIN-1",
4342 "component":{"kind":"plugin","slug":"sample-plugin"},
4343 "source":{"reference":"https://example.test/advisories/plugin-1","revision":"r2","retrieved_on":"2026-09-06","usage_basis":"synthetic test record"},
4344 "summary":"Synthetic plugin record",
4345 "affected_ranges":[{"upper":{"version":"2.5.0","inclusive":false}}],
4346 "fixed_versions":["2.5.0"],
4347 "prerequisites":[{"kind":"activation","equals":"network_active"},{"kind":"patch","id":"vendor-backport-1","equals":"not_applied"}]
4348 }
4349 ]
4350 }"#;
4351
4352 const PROFILED_CONTEXT: &str = r#"{
4353 "schema":"security.wordpress-context/v1",
4354 "root":"https://example.test/",
4355 "hosting_os":"linux",
4356 "components":[
4357 {"kind":"core","slug":"wordpress","version":"2.4.0-beta1","activation":"active"}
4358 ]
4359 }"#;
4360
4361 const PROFILED_CATALOG: &str = r#"{
4362 "schema":"security.wordpress-advisory-catalog/v2",
4363 "catalog":{"id":"synthetic-wordpress-profiled-catalog","revision":"r2","retrieved_on":"2026-09-07"},
4364 "records":[
4365 {
4366 "id":"SYNTHETIC-NUMERIC-BETA",
4367 "component":{"kind":"core","slug":"wordpress"},
4368 "source":{"reference":"https://example.test/advisories/numeric-beta","revision":"r2","retrieved_on":"2026-09-07","usage_basis":"fictional numeric-profile test record"},
4369 "comparison_profile":"numeric-dotted/v1",
4370 "summary":"Synthetic numeric profile record",
4371 "affected_ranges":[{"lower":{"version":"2.0.0","inclusive":true},"upper":{"version":"3.0.0","inclusive":false}}],
4372 "fixed_versions":["3.0.0"]
4373 },
4374 {
4375 "id":"SYNTHETIC-PHP-BETA",
4376 "component":{"kind":"core","slug":"wordpress"},
4377 "source":{"reference":"https://example.test/advisories/php-beta","revision":"r2","retrieved_on":"2026-09-07","usage_basis":"fictional PHP-subset test record"},
4378 "comparison_profile":"php-release-subset/v1",
4379 "summary":"Synthetic PHP release-subset profile record",
4380 "affected_ranges":[{"lower":{"version":"2.4.0-beta0","inclusive":true},"upper":{"version":"2.4.0","inclusive":false}}],
4381 "fixed_versions":["2.4.0"],
4382 "prerequisites":[{"kind":"hosting_os","equals":"linux"}]
4383 }
4384 ]
4385 }"#;
4386
4387 fn inputs() -> WordPressReviewInputs {
4388 WordPressReviewInputs::new(
4389 Some(parse_wordpress_context(CONTEXT.as_bytes()).unwrap()),
4390 Some(parse_wordpress_advisory_catalog(CATALOG.as_bytes()).unwrap()),
4391 )
4392 }
4393
4394 #[test]
4395 fn committed_context_and_catalog_examples_match_the_strict_v1_contract() {
4396 let context = parse_wordpress_context(include_bytes!(
4397 "../../../docs/examples/wordpress-review/context.synthetic.json"
4398 ))
4399 .unwrap();
4400 let curated = parse_wordpress_advisory_catalog(include_bytes!(
4401 "../../../docs/examples/wordpress-review/advisories.curated.json"
4402 ))
4403 .unwrap();
4404 let synthetic = parse_wordpress_advisory_catalog(include_bytes!(
4405 "../../../docs/examples/wordpress-review/advisories.synthetic.json"
4406 ))
4407 .unwrap();
4408
4409 assert_eq!(context.components().len(), 4);
4410 assert_eq!(curated.records().len(), 1);
4411 assert_eq!(curated.records()[0].id(), "GHSA-fpp7-x2x2-2mjf");
4412 assert_eq!(synthetic.records().len(), 3);
4413 assert!(synthetic
4414 .records()
4415 .iter()
4416 .all(|record| record.id().starts_with("SYNTHETIC-")));
4417 }
4418
4419 #[test]
4420 fn context_parser_retains_typed_assertions_and_exact_root() {
4421 let context = parse_wordpress_context(CONTEXT.as_bytes()).unwrap();
4422 assert_eq!(context.root_url().as_str(), "https://example.test/");
4423 assert_eq!(context.hosting_os(), Some(WordPressHostingOs::Linux));
4424 assert_eq!(context.multisite(), Some(WordPressMultisiteState::Enabled));
4425 assert_eq!(context.components().len(), 3);
4426 assert_eq!(context.components()[1].identity().slug(), "sample-plugin");
4427 assert_eq!(
4428 context.components()[1].patches()[0].id(),
4429 "vendor-backport-1"
4430 );
4431 }
4432
4433 #[test]
4434 fn context_rejects_duplicate_keys_identities_and_non_root_authority() {
4435 let duplicate_key = CONTEXT.replacen(
4436 "\"root\":\"https://example.test/\"",
4437 "\"root\":\"https://example.test/\",\"root\":\"https://other.test/\"",
4438 1,
4439 );
4440 assert_eq!(
4441 parse_wordpress_context(duplicate_key.as_bytes()),
4442 Err(WordPressReviewError::DuplicateKey)
4443 );
4444 let duplicate_component = CONTEXT.replace(
4445 "{\"kind\":\"theme\",\"slug\":\"sample-theme\",\"activation\":\"inactive\"}",
4446 "{\"kind\":\"core\",\"slug\":\"wordpress\",\"version\":\"6.9.4\"}",
4447 );
4448 assert_eq!(
4449 parse_wordpress_context(duplicate_component.as_bytes()),
4450 Err(WordPressReviewError::DuplicateComponent)
4451 );
4452 for root in [
4453 "https://user@example.test/",
4454 "https://example.test/subpath",
4455 "https://example.test/?query=value",
4456 "file:///tmp/site",
4457 ] {
4458 let document = CONTEXT.replace("https://example.test/", root);
4459 assert!(matches!(
4460 parse_wordpress_context(document.as_bytes()),
4461 Err(WordPressReviewError::InvalidContext)
4462 ));
4463 }
4464 }
4465
4466 #[test]
4467 fn context_limits_are_enforced_before_model_construction() {
4468 assert_eq!(
4469 parse_wordpress_context(&vec![b' '; MAX_WORDPRESS_CONTEXT_BYTES + 1]),
4470 Err(WordPressReviewError::ContextTooLarge)
4471 );
4472 let components = (0..=MAX_WORDPRESS_CONTEXT_COMPONENTS)
4473 .map(|index| format!(r#"{{"kind":"plugin","slug":"plugin-{index}"}}"#))
4474 .collect::<Vec<_>>()
4475 .join(",");
4476 let document = format!(
4477 r#"{{"schema":"{WORDPRESS_CONTEXT_SCHEMA}","root":"https://example.test/","components":[{components}]}}"#
4478 );
4479 assert!(matches!(
4480 parse_wordpress_context(document.as_bytes()),
4481 Err(WordPressReviewError::JsonLimitExceeded | WordPressReviewError::InvalidContext)
4482 ));
4483 }
4484
4485 #[test]
4486 fn catalog_parser_retains_disjoint_ranges_and_source_basis() {
4487 let catalog = parse_wordpress_advisory_catalog(CATALOG.as_bytes()).unwrap();
4488 assert_eq!(catalog.metadata().id(), "synthetic-wordpress-catalog");
4489 assert_eq!(catalog.records().len(), 2);
4490 assert_eq!(catalog.records()[0].affected_ranges().len(), 2);
4491 assert_eq!(
4492 catalog.records()[0].source().reference(),
4493 "https://example.test/advisories/core-1"
4494 );
4495 assert_eq!(catalog.records()[0].fixed_versions(), ["6.9.5", "7.0.2"]);
4496 }
4497
4498 #[test]
4499 fn catalog_v2_requires_one_explicit_supported_profile_per_record() {
4500 let catalog = parse_wordpress_advisory_catalog(PROFILED_CATALOG.as_bytes()).unwrap();
4501 assert_eq!(catalog.schema(), WordPressAdvisoryCatalogSchema::V2);
4502 assert_eq!(catalog.records().len(), 2);
4503 assert_eq!(
4504 catalog.records()[0].comparison_profile(),
4505 WordPressComparisonProfile::NumericDottedV1
4506 );
4507 assert_eq!(
4508 catalog.records()[1].comparison_profile(),
4509 WordPressComparisonProfile::PhpReleaseSubsetV1
4510 );
4511 assert!(catalog
4512 .records()
4513 .iter()
4514 .all(|record| record.affected_ranges().is_empty()));
4515 assert!(catalog
4516 .records()
4517 .iter()
4518 .all(|record| record.profiled_affected_ranges().len() == 1));
4519
4520 let v1_with_profile = CATALOG.replacen(
4521 "\"summary\":\"Synthetic bounded core record\"",
4522 "\"comparison_profile\":\"numeric-dotted/v1\",\"summary\":\"Synthetic bounded core record\"",
4523 1,
4524 );
4525 assert_eq!(
4526 parse_wordpress_advisory_catalog(v1_with_profile.as_bytes()),
4527 Err(WordPressReviewError::InvalidCatalog)
4528 );
4529
4530 let v2_without_profile =
4531 PROFILED_CATALOG.replacen("\"comparison_profile\":\"numeric-dotted/v1\",", "", 1);
4532 assert_eq!(
4533 parse_wordpress_advisory_catalog(v2_without_profile.as_bytes()),
4534 Err(WordPressReviewError::InvalidCatalog)
4535 );
4536
4537 let v2_unknown_profile = PROFILED_CATALOG.replacen("numeric-dotted/v1", "semver/v1", 1);
4538 assert_eq!(
4539 parse_wordpress_advisory_catalog(v2_unknown_profile.as_bytes()),
4540 Err(WordPressReviewError::InvalidCatalog)
4541 );
4542 }
4543
4544 #[test]
4545 fn profiled_evaluation_interprets_beta_only_under_the_selected_profile() {
4546 let inputs = WordPressReviewInputs::new(
4547 Some(parse_wordpress_context(PROFILED_CONTEXT.as_bytes()).unwrap()),
4548 Some(parse_wordpress_advisory_catalog(PROFILED_CATALOG.as_bytes()).unwrap()),
4549 );
4550 let signals = [WordPressComponentSignal::generator_metadata(Some("2.4.0-beta1")).unwrap()];
4551 let result = evaluate_wordpress_review(&signals, &inputs).unwrap();
4552 assert!(result.components().iter().all(|component| {
4553 component.evidence_class() != WordPressComponentEvidenceClass::Conflicting
4554 }));
4555
4556 assert_eq!(
4557 result.catalog_schema(),
4558 Some(WordPressAdvisoryCatalogSchema::V2)
4559 );
4560 let numeric = result
4561 .advisories()
4562 .iter()
4563 .find(|evaluation| evaluation.record().id() == "SYNTHETIC-NUMERIC-BETA")
4564 .unwrap();
4565 assert_eq!(
4566 numeric.version_resolution(),
4567 Some(WordPressVersionResolution::Unsupported)
4568 );
4569 assert_eq!(
4570 numeric.version_resolution_reason(),
4571 Some(WordPressVersionResolutionReason::UnsupportedVersionEvidence)
4572 );
4573 assert_eq!(
4574 numeric.version_relation(),
4575 WordPressVersionRelation::Unsupported
4576 );
4577 assert_eq!(
4578 numeric.applicability(),
4579 WordPressApplicability::IndeterminateUnsupported
4580 );
4581
4582 let php = result
4583 .advisories()
4584 .iter()
4585 .find(|evaluation| evaluation.record().id() == "SYNTHETIC-PHP-BETA")
4586 .unwrap();
4587 assert_eq!(
4588 php.version_resolution(),
4589 Some(WordPressVersionResolution::SupportedEquivalent)
4590 );
4591 assert_eq!(
4592 php.version_resolution_reason(),
4593 Some(WordPressVersionResolutionReason::EquivalentSupportedVersions)
4594 );
4595 assert_eq!(
4596 php.version_relation(),
4597 WordPressVersionRelation::WithinDeclaredRange
4598 );
4599 assert_eq!(
4600 php.prerequisites()[0].outcome(),
4601 WordPressPrerequisiteOutcome::MatchedOnSuppliedFacts
4602 );
4603 assert_eq!(
4604 php.applicability(),
4605 WordPressApplicability::CandidateMatchOnDeclaredFacts
4606 );
4607 assert_eq!(
4608 php.exploit_execution(),
4609 WordPressExecutionStatus::NotPerformed
4610 );
4611 assert_eq!(
4612 php.impact_validation(),
4613 WordPressExecutionStatus::NotPerformed
4614 );
4615 }
4616
4617 #[test]
4618 fn profiled_aliases_keep_component_provenance_separate_from_record_resolution() {
4619 let context = PROFILED_CONTEXT.replacen("2.4.0-beta1", "2.4.0+beta1", 1);
4620 let inputs = WordPressReviewInputs::new(
4621 Some(parse_wordpress_context(context.as_bytes()).unwrap()),
4622 Some(parse_wordpress_advisory_catalog(PROFILED_CATALOG.as_bytes()).unwrap()),
4623 );
4624 let signals = [WordPressComponentSignal::generator_metadata(Some("2.4.0-beta1")).unwrap()];
4625 let result = evaluate_wordpress_review(&signals, &inputs).unwrap();
4626 let component = result
4627 .components()
4628 .iter()
4629 .find(|component| component.identity().kind() == WordPressComponentKind::Core)
4630 .unwrap();
4631 assert_eq!(
4632 component.evidence_class(),
4633 WordPressComponentEvidenceClass::ObservedHint
4634 );
4635
4636 let numeric = result
4637 .advisories()
4638 .iter()
4639 .find(|evaluation| evaluation.record().id() == "SYNTHETIC-NUMERIC-BETA")
4640 .unwrap();
4641 assert_eq!(
4642 numeric.version_resolution(),
4643 Some(WordPressVersionResolution::Unsupported)
4644 );
4645 let php = result
4646 .advisories()
4647 .iter()
4648 .find(|evaluation| evaluation.record().id() == "SYNTHETIC-PHP-BETA")
4649 .unwrap();
4650 assert_eq!(
4651 php.version_resolution(),
4652 Some(WordPressVersionResolution::SupportedEquivalent)
4653 );
4654 assert_eq!(
4655 php.version_resolution_reason(),
4656 Some(WordPressVersionResolutionReason::EquivalentSupportedVersions)
4657 );
4658 }
4659
4660 #[test]
4661 fn unsupported_profiled_evidence_is_not_invented_as_a_semantic_conflict() {
4662 let context = PROFILED_CONTEXT.replacen("2.4.0-beta1", "2.4.0-vendor-a", 1);
4663 let inputs = WordPressReviewInputs::new(
4664 Some(parse_wordpress_context(context.as_bytes()).unwrap()),
4665 Some(parse_wordpress_advisory_catalog(PROFILED_CATALOG.as_bytes()).unwrap()),
4666 );
4667 let signals =
4668 [WordPressComponentSignal::generator_metadata(Some("2.4.0-vendor-b")).unwrap()];
4669 let result = evaluate_wordpress_review(&signals, &inputs).unwrap();
4670 for advisory in result.advisories() {
4671 assert_eq!(
4672 advisory.version_resolution(),
4673 Some(WordPressVersionResolution::Unsupported)
4674 );
4675 assert_eq!(
4676 advisory.version_resolution_reason(),
4677 Some(WordPressVersionResolutionReason::UnsupportedVersionEvidence)
4678 );
4679 assert_eq!(
4680 advisory.applicability(),
4681 WordPressApplicability::IndeterminateUnsupported
4682 );
4683 }
4684
4685 let mixed_signals =
4686 [WordPressComponentSignal::generator_metadata(Some("2.4.0-beta1")).unwrap()];
4687 let mixed = evaluate_wordpress_review(&mixed_signals, &inputs).unwrap();
4688 for advisory in mixed.advisories() {
4689 assert_eq!(
4690 advisory.version_resolution(),
4691 Some(WordPressVersionResolution::Unsupported)
4692 );
4693 assert_eq!(
4694 advisory.version_resolution_reason(),
4695 Some(WordPressVersionResolutionReason::UnsupportedVersionEvidence)
4696 );
4697 }
4698 }
4699
4700 #[test]
4701 fn version_evidence_resolution_is_profile_specific() {
4702 let catalog = r#"{
4703 "schema":"security.wordpress-advisory-catalog/v2",
4704 "catalog":{"id":"synthetic-profile-resolution","revision":"r1","retrieved_on":"2026-09-07"},
4705 "records":[
4706 {
4707 "id":"SYNTHETIC-NUMERIC-TRAILING-ZERO",
4708 "component":{"kind":"core","slug":"wordpress"},
4709 "source":{"reference":"https://example.test/advisories/numeric-zero","revision":"r1","retrieved_on":"2026-09-07","usage_basis":"fictional profile-resolution test"},
4710 "comparison_profile":"numeric-dotted/v1",
4711 "summary":"Synthetic numeric trailing-zero record",
4712 "affected_ranges":[{"lower":{"version":"1.0","inclusive":true},"upper":{"version":"1.0.0","inclusive":true}}]
4713 },
4714 {
4715 "id":"SYNTHETIC-PHP-TRAILING-ZERO",
4716 "component":{"kind":"core","slug":"wordpress"},
4717 "source":{"reference":"https://example.test/advisories/php-zero","revision":"r1","retrieved_on":"2026-09-07","usage_basis":"fictional profile-resolution test"},
4718 "comparison_profile":"php-release-subset/v1",
4719 "summary":"Synthetic PHP trailing-zero record",
4720 "affected_ranges":[{"lower":{"version":"1.0","inclusive":true},"upper":{"version":"1.0.0","inclusive":true}}]
4721 }
4722 ]
4723 }"#;
4724 let context = r#"{
4725 "schema":"security.wordpress-context/v1",
4726 "root":"https://example.test/",
4727 "components":[{"kind":"core","slug":"wordpress","version":"1.0.0"}]
4728 }"#;
4729 let inputs = WordPressReviewInputs::new(
4730 Some(parse_wordpress_context(context.as_bytes()).unwrap()),
4731 Some(parse_wordpress_advisory_catalog(catalog.as_bytes()).unwrap()),
4732 );
4733 let signals = [WordPressComponentSignal::generator_metadata(Some("1.0")).unwrap()];
4734 let result = evaluate_wordpress_review(&signals, &inputs).unwrap();
4735 let numeric = result
4736 .advisories()
4737 .iter()
4738 .find(|evaluation| evaluation.record().id().contains("NUMERIC"))
4739 .unwrap();
4740 assert_eq!(
4741 numeric.version_resolution(),
4742 Some(WordPressVersionResolution::SupportedEquivalent)
4743 );
4744 assert_eq!(
4745 numeric.version_resolution_reason(),
4746 Some(WordPressVersionResolutionReason::EquivalentSupportedVersions)
4747 );
4748 assert_eq!(
4749 numeric.version_relation(),
4750 WordPressVersionRelation::WithinDeclaredRange
4751 );
4752
4753 let php = result
4754 .advisories()
4755 .iter()
4756 .find(|evaluation| evaluation.record().id().contains("PHP"))
4757 .unwrap();
4758 assert_eq!(
4759 php.version_resolution(),
4760 Some(WordPressVersionResolution::Conflicting)
4761 );
4762 assert_eq!(
4763 php.version_resolution_reason(),
4764 Some(WordPressVersionResolutionReason::ConflictingVersionEvidence)
4765 );
4766 assert_eq!(php.version_relation(), WordPressVersionRelation::Unknown);
4767 assert_eq!(
4768 php.applicability(),
4769 WordPressApplicability::IndeterminateMissingEvidence
4770 );
4771 }
4772
4773 #[test]
4774 fn profiled_ranges_and_fixed_versions_use_semantic_profile_equality() {
4775 let exclusive_equal_aliases = PROFILED_CATALOG
4776 .replacen("2.4.0-beta0", "1.0-alpha1", 1)
4777 .replacen(
4778 "2.4.0\",\"inclusive\":false",
4779 "1.0a1\",\"inclusive\":false",
4780 1,
4781 );
4782 assert_eq!(
4783 parse_wordpress_advisory_catalog(exclusive_equal_aliases.as_bytes()),
4784 Err(WordPressReviewError::InvalidVersionRange)
4785 );
4786
4787 let duplicate_fixed_aliases = PROFILED_CATALOG.replacen(
4788 "\"fixed_versions\":[\"2.4.0\"]",
4789 "\"fixed_versions\":[\"2.4.0-alpha1\",\"2.4.0a1\"]",
4790 1,
4791 );
4792 assert_eq!(
4793 parse_wordpress_advisory_catalog(duplicate_fixed_aliases.as_bytes()),
4794 Err(WordPressReviewError::InvalidCatalog)
4795 );
4796
4797 let php_record = parse_wordpress_advisory_catalog(PROFILED_CATALOG.as_bytes())
4798 .unwrap()
4799 .records()
4800 .iter()
4801 .find(|record| record.id() == "SYNTHETIC-PHP-BETA")
4802 .unwrap()
4803 .clone();
4804 assert_eq!(
4805 php_record.compare_versions("1.0+beta1", "1.0-beta1"),
4806 Some(Ordering::Equal)
4807 );
4808 assert_eq!(
4809 php_record.compare_versions("1.0", "1.0.0"),
4810 Some(Ordering::Less)
4811 );
4812 assert_eq!(php_record.compare_versions("1.0-vendor1", "1.0"), None);
4813
4814 let mut interleaved_aliases: Value = serde_json::from_str(PROFILED_CATALOG).unwrap();
4815 interleaved_aliases["records"][1]["affected_ranges"] = serde_json::json!([
4816 {
4817 "lower":{"version":"1.0-alpha1","inclusive":true},
4818 "upper":{"version":"3.0","inclusive":false}
4819 },
4820 {
4821 "lower":{"version":"1.0-alpha1","inclusive":true},
4822 "upper":{"version":"4.0","inclusive":false}
4823 },
4824 {
4825 "lower":{"version":"1.0a1","inclusive":true},
4826 "upper":{"version":"3.0","inclusive":false}
4827 }
4828 ]);
4829 let bytes = serde_json::to_vec(&interleaved_aliases).unwrap();
4830 let catalogue = parse_wordpress_advisory_catalog(&bytes).unwrap();
4831 let ranges = catalogue
4832 .records()
4833 .iter()
4834 .find(|record| record.id() == "SYNTHETIC-PHP-BETA")
4835 .unwrap()
4836 .profiled_affected_ranges();
4837 assert_eq!(ranges.len(), 2);
4838 assert_eq!(ranges[0].lower().unwrap().declared(), "1.0-alpha1");
4839 assert_eq!(ranges[0].upper().unwrap().declared(), "3.0");
4840 }
4841
4842 #[test]
4843 fn catalog_rejects_duplicates_unknown_fields_and_malformed_ranges() {
4844 let duplicate = CATALOG.replacen(
4845 "\"summary\":\"Synthetic bounded core record\"",
4846 "\"summary\":\"Synthetic bounded core record\",\"summary\":\"other\"",
4847 1,
4848 );
4849 assert_eq!(
4850 parse_wordpress_advisory_catalog(duplicate.as_bytes()),
4851 Err(WordPressReviewError::DuplicateKey)
4852 );
4853 let unknown = CATALOG.replacen(
4854 "\"summary\":\"Synthetic bounded core record\"",
4855 "\"summary\":\"Synthetic bounded core record\",\"predicate\":\"execute-me\"",
4856 1,
4857 );
4858 assert_eq!(
4859 parse_wordpress_advisory_catalog(unknown.as_bytes()),
4860 Err(WordPressReviewError::InvalidCatalog)
4861 );
4862 let backwards = CATALOG.replacen("6.9.0", "6.9.9", 1);
4863 assert_eq!(
4864 parse_wordpress_advisory_catalog(backwards.as_bytes()),
4865 Err(WordPressReviewError::InvalidVersionRange)
4866 );
4867 let inferred_range = CATALOG.replacen(
4868 "{\"lower\":{\"version\":\"6.9.0\",\"inclusive\":true},\"upper\":{\"version\":\"6.9.5\",\"inclusive\":false}}",
4869 "{}",
4870 1,
4871 );
4872 assert_eq!(
4873 parse_wordpress_advisory_catalog(inferred_range.as_bytes()),
4874 Err(WordPressReviewError::InvalidVersionRange)
4875 );
4876 }
4877
4878 #[test]
4879 fn numeric_dotted_profile_has_explicit_trailing_zero_and_boundary_semantics() {
4880 let one_two = NumericDottedVersion::parse("1.2").unwrap();
4881 let one_two_zero = NumericDottedVersion::parse("1.2.0").unwrap();
4882 assert_eq!(one_two, one_two_zero);
4883 assert!(NumericDottedVersion::parse("1.2.1").unwrap() > one_two);
4884 for unsupported in ["1.2-beta", "v1.2", "1..2", "01.2", "4294967296"] {
4885 assert_eq!(
4886 NumericDottedVersion::parse(unsupported),
4887 Err(NumericDottedVersionError)
4888 );
4889 }
4890
4891 let catalog = parse_wordpress_advisory_catalog(CATALOG.as_bytes()).unwrap();
4892 let range = &catalog.records()[0].affected_ranges()[0];
4893 assert!(range.contains(&NumericDottedVersion::parse("6.9.0").unwrap()));
4894 assert!(range.contains(&NumericDottedVersion::parse("6.9.4").unwrap()));
4895 assert!(!range.contains(&NumericDottedVersion::parse("6.9.5").unwrap()));
4896 assert!(!range.contains(&NumericDottedVersion::parse("7.0.1").unwrap()));
4897 }
4898
4899 #[test]
4900 fn explicit_endpoint_inclusivity_and_missing_ranges_remain_distinct() {
4901 let inclusive_upper = CATALOG.replacen(
4902 "\"version\":\"6.9.5\",\"inclusive\":false",
4903 "\"version\":\"6.9.5\",\"inclusive\":true",
4904 1,
4905 );
4906 let catalog = parse_wordpress_advisory_catalog(inclusive_upper.as_bytes()).unwrap();
4907 assert!(catalog.records()[0].affected_ranges()[0]
4908 .contains(&NumericDottedVersion::parse("6.9.5").unwrap()));
4909
4910 let no_ranges = CATALOG.replacen(
4911 r#""affected_ranges":[
4912 {"lower":{"version":"6.9.0","inclusive":true},"upper":{"version":"6.9.5","inclusive":false}},
4913 {"lower":{"version":"7.0.0","inclusive":true},"upper":{"version":"7.0.2","inclusive":false}}
4914 ]"#,
4915 r#""affected_ranges":[]"#,
4916 1,
4917 );
4918 let inputs = WordPressReviewInputs::new(
4919 Some(parse_wordpress_context(CONTEXT.as_bytes()).unwrap()),
4920 Some(parse_wordpress_advisory_catalog(no_ranges.as_bytes()).unwrap()),
4921 );
4922 let result = evaluate_wordpress_review(&[], &inputs).unwrap();
4923 assert_eq!(
4924 result.advisories()[0].version_relation(),
4925 WordPressVersionRelation::Unknown,
4926 "a documented fixed version must not invent an affected interval"
4927 );
4928 }
4929
4930 #[test]
4931 fn evaluator_separates_observation_operator_source_and_candidate_applicability() {
4932 let signals = [WordPressComponentSignal::generator_metadata(Some("6.9.4")).unwrap()];
4933 let result = evaluate_wordpress_review(&signals, &inputs()).unwrap();
4934 assert_eq!(result.catalog_status(), WordPressCatalogStatus::Evaluated);
4935 assert_eq!(result.catalog_metadata().unwrap().revision(), "r1");
4936 let core = result
4937 .components()
4938 .iter()
4939 .find(|component| component.identity().kind() == WordPressComponentKind::Core)
4940 .unwrap();
4941 assert_eq!(
4942 core.evidence_class(),
4943 WordPressComponentEvidenceClass::ObservedHint
4944 );
4945 assert_eq!(
4946 core.identity_sources(),
4947 [
4948 WordPressEvidenceSource::GeneratorMetadata,
4949 WordPressEvidenceSource::OperatorContext,
4950 ]
4951 );
4952 assert_eq!(
4953 core.confidence_classes(),
4954 [
4955 WordPressEvidenceConfidence::PublicDeclaration,
4956 WordPressEvidenceConfidence::OperatorAssertion,
4957 ]
4958 );
4959 assert_eq!(
4960 core.versions()[0].confidence(),
4961 WordPressEvidenceConfidence::PublicDeclaration
4962 );
4963 let core_advisory = &result.advisories()[0];
4964 assert_eq!(
4965 core_advisory.version_relation(),
4966 WordPressVersionRelation::WithinDeclaredRange
4967 );
4968 assert_eq!(
4969 core_advisory.applicability(),
4970 WordPressApplicability::CandidateMatchOnDeclaredFacts
4971 );
4972 assert_eq!(
4973 core_advisory.exploit_execution(),
4974 WordPressExecutionStatus::NotPerformed
4975 );
4976 assert_eq!(
4977 core_advisory.impact_validation(),
4978 WordPressExecutionStatus::NotPerformed
4979 );
4980 }
4981
4982 #[test]
4983 fn repeated_hints_do_not_become_independent_evidence() {
4984 let signal = WordPressComponentSignal::same_origin_asset(
4985 WordPressComponentKind::Plugin,
4986 "sample-plugin",
4987 )
4988 .unwrap();
4989 let result = evaluate_wordpress_review(
4990 &[signal.clone(), signal.clone(), signal],
4991 &WordPressReviewInputs::default(),
4992 )
4993 .unwrap();
4994 assert_eq!(result.components().len(), 1);
4995 assert_eq!(
4996 result.components()[0].identity_sources(),
4997 [WordPressEvidenceSource::SameOriginAssetPath]
4998 );
4999 assert!(result.components()[0].versions().is_empty());
5000
5001 let core = WordPressComponentSignal::core_asset();
5002 assert_eq!(core.identity(), &WordPressComponentIdentity::core());
5003 assert_eq!(core.source(), WordPressEvidenceSource::SameOriginAssetPath);
5004 assert_eq!(
5005 core.confidence(),
5006 WordPressEvidenceConfidence::StructuralHint
5007 );
5008 }
5009
5010 #[test]
5011 fn contradictory_versions_are_not_resolved_by_last_write() {
5012 let signals = [WordPressComponentSignal::generator_metadata(Some("6.9.3")).unwrap()];
5013 let result = evaluate_wordpress_review(&signals, &inputs()).unwrap();
5014 assert_eq!(
5015 result.components()[0].evidence_class(),
5016 WordPressComponentEvidenceClass::Conflicting
5017 );
5018 assert_eq!(
5019 result.advisories()[0].version_relation(),
5020 WordPressVersionRelation::Unknown
5021 );
5022 assert_eq!(
5023 result.advisories()[0].applicability(),
5024 WordPressApplicability::IndeterminateMissingEvidence
5025 );
5026 }
5027
5028 #[test]
5029 fn unsupported_observed_version_stays_indeterminate() {
5030 let context = CONTEXT.replace("6.9.4", "6.9.4-RC1");
5031 let inputs = WordPressReviewInputs::new(
5032 Some(parse_wordpress_context(context.as_bytes()).unwrap()),
5033 Some(parse_wordpress_advisory_catalog(CATALOG.as_bytes()).unwrap()),
5034 );
5035 let result = evaluate_wordpress_review(&[], &inputs).unwrap();
5036 assert_eq!(
5037 result.advisories()[0].version_relation(),
5038 WordPressVersionRelation::Unsupported
5039 );
5040 assert_eq!(
5041 result.advisories()[0].applicability(),
5042 WordPressApplicability::IndeterminateUnsupported
5043 );
5044 }
5045
5046 #[test]
5047 fn prerequisite_outcomes_do_not_turn_conflicts_into_security_claims() {
5048 let result = evaluate_wordpress_review(&[], &inputs()).unwrap();
5049 let plugin = result
5050 .advisories()
5051 .iter()
5052 .find(|evaluation| evaluation.record().id() == "SYNTHETIC-PLUGIN-1")
5053 .unwrap();
5054 assert_eq!(
5055 plugin.prerequisites()[0].outcome(),
5056 WordPressPrerequisiteOutcome::MatchedOnSuppliedFacts
5057 );
5058 assert_eq!(
5059 plugin.prerequisites()[1].outcome(),
5060 WordPressPrerequisiteOutcome::ContradictedOnSuppliedFacts
5061 );
5062 assert_eq!(
5063 plugin.applicability(),
5064 WordPressApplicability::ContradictedByDeclaredFacts
5065 );
5066 }
5067
5068 #[test]
5069 fn a_contradicted_requirement_dominates_an_unresolved_unsupported_requirement() {
5070 let catalogue = CATALOG.replacen(
5071 r#"{"kind":"multisite","equals":"enabled"}"#,
5072 r#"{"kind":"multisite","equals":"disabled"},{"kind":"unsupported","name":"vendor_specific_runtime_state"}"#,
5073 1,
5074 );
5075 let inputs = WordPressReviewInputs::new(
5076 Some(parse_wordpress_context(CONTEXT.as_bytes()).unwrap()),
5077 Some(parse_wordpress_advisory_catalog(catalogue.as_bytes()).unwrap()),
5078 );
5079 let result = evaluate_wordpress_review(&[], &inputs).unwrap();
5080 let core = result
5081 .advisories()
5082 .iter()
5083 .find(|evaluation| evaluation.record().id() == "SYNTHETIC-CORE-1")
5084 .unwrap();
5085
5086 assert!(core.prerequisites().iter().any(|evaluation| {
5087 evaluation.outcome() == WordPressPrerequisiteOutcome::ContradictedOnSuppliedFacts
5088 }));
5089 assert!(core.prerequisites().iter().any(|evaluation| {
5090 evaluation.outcome() == WordPressPrerequisiteOutcome::Unsupported
5091 }));
5092 assert_eq!(
5093 core.applicability(),
5094 WordPressApplicability::ContradictedByDeclaredFacts
5095 );
5096 }
5097
5098 #[test]
5099 fn absent_or_unknown_patch_assertions_do_not_mean_patch_absence() {
5100 let without_patch = CONTEXT.replace(
5101 r#""patches":[{"id":"vendor-backport-1","state":"applied"}]"#,
5102 r#""patches":[]"#,
5103 );
5104 let inputs = WordPressReviewInputs::new(
5105 Some(parse_wordpress_context(without_patch.as_bytes()).unwrap()),
5106 Some(parse_wordpress_advisory_catalog(CATALOG.as_bytes()).unwrap()),
5107 );
5108 let result = evaluate_wordpress_review(&[], &inputs).unwrap();
5109 let plugin = result
5110 .advisories()
5111 .iter()
5112 .find(|evaluation| evaluation.record().id() == "SYNTHETIC-PLUGIN-1")
5113 .unwrap();
5114 assert_eq!(
5115 plugin.prerequisites()[1].outcome(),
5116 WordPressPrerequisiteOutcome::Unknown
5117 );
5118 assert_eq!(
5119 plugin.applicability(),
5120 WordPressApplicability::IndeterminateMissingEvidence
5121 );
5122 }
5123
5124 #[test]
5125 fn unsupported_and_missing_prerequisites_are_distinct() {
5126 let unsupported = CATALOG.replacen(
5127 "{\"kind\":\"hosting_os\",\"equals\":\"linux\"}",
5128 "{\"kind\":\"unsupported\",\"name\":\"php-extension-state\"}",
5129 1,
5130 );
5131 let inputs = WordPressReviewInputs::new(
5132 None,
5133 Some(parse_wordpress_advisory_catalog(unsupported.as_bytes()).unwrap()),
5134 );
5135 let result = evaluate_wordpress_review(&[], &inputs).unwrap();
5136 let unsupported = result.advisories()[0]
5137 .prerequisites()
5138 .iter()
5139 .find(|evaluation| {
5140 matches!(
5141 evaluation.prerequisite(),
5142 WordPressPrerequisite::Unsupported { .. }
5143 )
5144 })
5145 .unwrap();
5146 assert_eq!(
5147 unsupported.outcome(),
5148 WordPressPrerequisiteOutcome::Unsupported
5149 );
5150 let multisite = result.advisories()[0]
5151 .prerequisites()
5152 .iter()
5153 .find(|evaluation| {
5154 matches!(
5155 evaluation.prerequisite(),
5156 WordPressPrerequisite::Multisite { .. }
5157 )
5158 })
5159 .unwrap();
5160 assert_eq!(multisite.outcome(), WordPressPrerequisiteOutcome::Unknown);
5161 assert_eq!(
5162 result.advisories()[0].applicability(),
5163 WordPressApplicability::IndeterminateUnsupported
5164 );
5165 }
5166
5167 #[test]
5168 fn absent_catalogue_is_visible_and_never_an_all_clear() {
5169 let result = evaluate_wordpress_review(
5170 &[WordPressComponentSignal::generator_metadata(None).unwrap()],
5171 &WordPressReviewInputs::default(),
5172 )
5173 .unwrap();
5174 assert_eq!(
5175 result.catalog_status(),
5176 WordPressCatalogStatus::CatalogNotSupplied
5177 );
5178 assert_eq!(result.catalog_metadata(), None);
5179 assert!(result.advisories().is_empty());
5180 assert_eq!(
5181 result.components()[0].evidence_class(),
5182 WordPressComponentEvidenceClass::ObservedHint
5183 );
5184 }
5185
5186 #[test]
5187 fn no_matching_component_keeps_record_indeterminate() {
5188 let inputs = WordPressReviewInputs::new(
5189 None,
5190 Some(parse_wordpress_advisory_catalog(CATALOG.as_bytes()).unwrap()),
5191 );
5192 let result = evaluate_wordpress_review(&[], &inputs).unwrap();
5193 for advisory in result.advisories() {
5194 assert_eq!(
5195 advisory.component_evidence(),
5196 WordPressComponentEvidenceClass::Unknown
5197 );
5198 assert_eq!(
5199 advisory.version_relation(),
5200 WordPressVersionRelation::Unknown
5201 );
5202 assert!(matches!(
5203 advisory.applicability(),
5204 WordPressApplicability::IndeterminateMissingEvidence
5205 | WordPressApplicability::IndeterminateUnsupported
5206 ));
5207 }
5208 }
5209
5210 #[test]
5211 fn parser_is_order_independent_but_catalog_output_is_deterministic() {
5212 let reordered = CATALOG.replace(
5213 "\"schema\":\"security.wordpress-advisory-catalog/v1\",\n \"catalog\"",
5214 "\"catalog\"",
5215 );
5216 let reordered = reordered.replacen(
5217 "\"records\":[",
5218 "\"schema\":\"security.wordpress-advisory-catalog/v1\",\"records\":[",
5219 1,
5220 );
5221 let parsed = parse_wordpress_advisory_catalog(reordered.as_bytes()).unwrap();
5222 assert_eq!(
5223 parsed,
5224 parse_wordpress_advisory_catalog(CATALOG.as_bytes()).unwrap()
5225 );
5226 assert_eq!(parsed.records()[0].id(), "SYNTHETIC-CORE-1");
5227 assert_eq!(parsed.records()[1].id(), "SYNTHETIC-PLUGIN-1");
5228 }
5229
5230 #[test]
5231 fn schema_and_catalog_size_limits_fail_closed() {
5232 let wrong = CONTEXT.replace(WORDPRESS_CONTEXT_SCHEMA, "security.wordpress-context/v2");
5233 assert_eq!(
5234 parse_wordpress_context(wrong.as_bytes()),
5235 Err(WordPressReviewError::UnsupportedSchema)
5236 );
5237 assert_eq!(
5238 parse_wordpress_advisory_catalog(&vec![b' '; MAX_WORDPRESS_ADVISORY_CATALOG_BYTES + 1]),
5239 Err(WordPressReviewError::CatalogTooLarge)
5240 );
5241 }
5242
5243 #[test]
5244 fn source_urls_and_inert_data_cannot_express_executable_policy() {
5245 for replacement in [
5246 "javascript:alert(1)",
5247 "http://example.test/advisory",
5248 "https://user:secret@example.test/advisory",
5249 ] {
5250 let document = CATALOG.replace("https://example.test/advisories/core-1", replacement);
5251 assert_eq!(
5252 parse_wordpress_advisory_catalog(document.as_bytes()),
5253 Err(WordPressReviewError::InvalidCatalog)
5254 );
5255 }
5256 let script = CATALOG.replacen(
5257 "\"summary\":\"Synthetic bounded core record\"",
5258 "\"summary\":\"Synthetic bounded core record\",\"script\":\"system('id')\"",
5259 1,
5260 );
5261 assert_eq!(
5262 parse_wordpress_advisory_catalog(script.as_bytes()),
5263 Err(WordPressReviewError::InvalidCatalog)
5264 );
5265 }
5266
5267 #[test]
5268 fn signal_limit_and_invalid_asset_identities_fail_closed() {
5269 let signal = WordPressComponentSignal::generator_metadata(None).unwrap();
5270 assert_eq!(
5271 evaluate_wordpress_review(
5272 &vec![signal; MAX_WORDPRESS_SIGNALS + 1],
5273 &WordPressReviewInputs::default()
5274 ),
5275 Err(WordPressReviewError::SignalLimitExceeded)
5276 );
5277 assert!(WordPressComponentSignal::same_origin_asset(
5278 WordPressComponentKind::Plugin,
5279 "../escape"
5280 )
5281 .is_err());
5282 assert!(WordPressComponentSignal::same_origin_asset(
5283 WordPressComponentKind::Plugin,
5284 "wordpress"
5285 )
5286 .is_err());
5287 }
5288
5289 #[test]
5290 fn disjoint_signal_and_context_component_bounds_form_one_valid_result() {
5291 let signals = (0..MAX_WORDPRESS_SIGNALS)
5292 .map(|index| {
5293 WordPressComponentSignal::same_origin_asset(
5294 WordPressComponentKind::Plugin,
5295 &format!("observed-{index:03}"),
5296 )
5297 .unwrap()
5298 })
5299 .collect::<Vec<_>>();
5300 let components = (0..MAX_WORDPRESS_CONTEXT_COMPONENTS)
5301 .map(|index| WordPressContextComponent {
5302 identity: WordPressComponentIdentity::new(
5303 WordPressComponentKind::Plugin,
5304 format!("declared-{index:03}"),
5305 )
5306 .unwrap(),
5307 version: None,
5308 activation: None,
5309 patches: Vec::new(),
5310 inventory_status: None,
5311 })
5312 .collect();
5313 let context = WordPressContext {
5314 root_url: Url::parse("https://example.test/").unwrap(),
5315 hosting_os: None,
5316 multisite: None,
5317 components,
5318 };
5319
5320 let result =
5321 evaluate_wordpress_review(&signals, &WordPressReviewInputs::new(Some(context), None))
5322 .unwrap();
5323
5324 assert_eq!(
5325 MAX_WORDPRESS_RESULT_COMPONENTS,
5326 MAX_WORDPRESS_SIGNALS + MAX_WORDPRESS_CONTEXT_COMPONENTS
5327 );
5328 assert_eq!(result.components().len(), MAX_WORDPRESS_RESULT_COMPONENTS);
5329 }
5330
5331 #[test]
5332 fn combined_signal_and_context_versions_use_the_explicit_result_bound() {
5333 let signals = (0..MAX_WORDPRESS_SIGNALS)
5334 .map(|index| {
5335 WordPressComponentSignal::generator_metadata(Some(&format!("1.0.{index}"))).unwrap()
5336 })
5337 .collect::<Vec<_>>();
5338 let components = (0..MAX_WORDPRESS_CONTEXT_COMPONENTS)
5339 .map(|index| WordPressContextComponent {
5340 identity: WordPressComponentIdentity::new(
5341 WordPressComponentKind::Plugin,
5342 format!("declared-{index:03}"),
5343 )
5344 .unwrap(),
5345 version: Some(format!("2.0.{index}")),
5346 activation: None,
5347 patches: Vec::new(),
5348 inventory_status: None,
5349 })
5350 .collect();
5351 let context = WordPressContext {
5352 root_url: Url::parse("https://example.test/").unwrap(),
5353 hosting_os: None,
5354 multisite: None,
5355 components,
5356 };
5357
5358 let result =
5359 evaluate_wordpress_review(&signals, &WordPressReviewInputs::new(Some(context), None))
5360 .unwrap();
5361 let version_rows = result
5362 .components()
5363 .iter()
5364 .map(|component| component.versions().len())
5365 .sum::<usize>();
5366
5367 assert_eq!(
5368 MAX_WORDPRESS_RESULT_VERSION_EVIDENCE,
5369 MAX_WORDPRESS_SIGNALS + MAX_WORDPRESS_CONTEXT_COMPONENTS
5370 );
5371 assert_eq!(version_rows, MAX_WORDPRESS_RESULT_VERSION_EVIDENCE);
5372 }
5373
5374 #[test]
5375 fn external_catalog_selects_only_exact_component_associations() {
5376 let import = parse_wordfence_v3_production(
5377 &include_bytes!(
5378 "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
5379 )[..],
5380 )
5381 .unwrap();
5382 let context = parse_wordpress_context(
5383 br#"{
5384 "schema":"security.wordpress-context/v1",
5385 "root":"https://example.test/",
5386 "components":[
5387 {"kind":"plugin","slug":"termivar-fixture-component","version":"1.0"}
5388 ]
5389 }"#,
5390 )
5391 .unwrap();
5392 let inputs = WordPressReviewInputs::new(Some(context), None)
5393 .with_wordfence_v3_catalog(import)
5394 .unwrap();
5395
5396 let result = evaluate_wordpress_review(&[], &inputs).unwrap();
5397 let external = result.external_review().unwrap();
5398
5399 assert_eq!(result.catalog_status(), WordPressCatalogStatus::Evaluated);
5400 assert_eq!(result.catalog_schema(), None);
5401 assert_eq!(result.catalog_metadata(), None);
5402 assert!(result.advisories().is_empty());
5403 assert_eq!(external.counts().parsed_records(), 2);
5404 assert_eq!(external.counts().software_associations(), 3);
5405 assert_eq!(external.counts().exact_identity_associations(), 3);
5406 assert_eq!(external.counts().candidate_identity_associations(), 0);
5407 assert_eq!(external.counts().ambiguous_identity_associations(), 0);
5408 assert_eq!(external.counts().unresolved_identity_associations(), 0);
5409 assert_eq!(external.counts().selected_associations(), 1);
5410 assert_eq!(external.counts().evaluable_associations(), 0);
5411 assert_eq!(external.counts().unsupported_associations(), 1);
5412 assert_eq!(external.counts().excluded_associations(), 2);
5413 assert_eq!(external.evaluations().len(), 1);
5414 assert_eq!(external.notices().len(), 1);
5415 assert_eq!(external.mapping_revision(), WORDFENCE_V3_MAPPING_REVISION);
5416 assert_eq!(external.resource_policy(), WORDFENCE_V3_RESOURCE_POLICY_V1);
5417 assert!(!external.requires_audit_v6());
5418 let evaluation = &external.evaluations()[0];
5419 assert_eq!(
5420 evaluation.key().source_namespace(),
5421 WORDFENCE_V3_SOURCE_NAMESPACE
5422 );
5423 assert_eq!(
5424 evaluation.key().component().kind(),
5425 WordPressComponentKind::Plugin
5426 );
5427 assert_eq!(
5428 evaluation.key().component().slug(),
5429 "termivar-fixture-component"
5430 );
5431 assert_eq!(
5432 evaluation.key().source_component().slug(),
5433 "termivar-fixture-component"
5434 );
5435 assert_eq!(
5436 evaluation.identity_mapping(),
5437 WordfenceV3IdentityMapping::Exact
5438 );
5439 assert_eq!(
5440 evaluation.version_relation(),
5441 WordPressExternalVersionRelation::SourceComparisonSemanticsUnresolved
5442 );
5443 assert_eq!(
5444 evaluation.applicability(),
5445 WordPressExternalApplicability::IndeterminateUnsupported
5446 );
5447 assert_eq!(
5448 evaluation.version_evidence_resolution().status(),
5449 WordPressExternalVersionEvidenceStatus::SingleDeclaration
5450 );
5451 assert_eq!(
5452 evaluation
5453 .version_evidence_resolution()
5454 .evidence_row_count(),
5455 1
5456 );
5457 assert_eq!(
5458 evaluation
5459 .version_evidence_resolution()
5460 .distinct_spelling_count(),
5461 1
5462 );
5463 assert_eq!(
5464 evaluation.exploit_execution(),
5465 WordPressExecutionStatus::NotPerformed
5466 );
5467 assert_eq!(
5468 evaluation.impact_validation(),
5469 WordPressExecutionStatus::NotPerformed
5470 );
5471 }
5472
5473 #[test]
5474 fn external_version_evidence_resolution_is_evaluator_owned_and_comparator_neutral() {
5475 for (operator_version, expected_status, expected_distinct) in [
5476 (
5477 "1.0",
5478 WordPressExternalVersionEvidenceStatus::RepeatedExactDeclaration,
5479 1,
5480 ),
5481 (
5482 "1.0.0",
5483 WordPressExternalVersionEvidenceStatus::MultipleDistinctDeclarations,
5484 2,
5485 ),
5486 ] {
5487 let import = parse_wordfence_v3_production(
5488 &include_bytes!(
5489 "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
5490 )[..],
5491 )
5492 .unwrap();
5493 let context = parse_wordpress_context(
5494 format!(
5495 r#"{{
5496 "schema":"security.wordpress-context/v1",
5497 "root":"https://example.test/",
5498 "components":[{{"kind":"core","slug":"wordpress","version":"{operator_version}"}}]
5499 }}"#
5500 )
5501 .as_bytes(),
5502 )
5503 .unwrap();
5504 let signals = [WordPressComponentSignal::generator_metadata(Some("1.0")).unwrap()];
5505 let result = evaluate_wordpress_review(
5506 &signals,
5507 &WordPressReviewInputs::new(Some(context), None)
5508 .with_wordfence_v3_catalog(import)
5509 .unwrap(),
5510 )
5511 .unwrap();
5512 let core = result
5513 .external_review()
5514 .unwrap()
5515 .evaluations()
5516 .iter()
5517 .find(|evaluation| {
5518 evaluation.key().component().kind() == WordPressComponentKind::Core
5519 })
5520 .unwrap();
5521 let resolution = core.version_evidence_resolution();
5522 assert_eq!(resolution.status(), expected_status);
5523 assert_eq!(resolution.evidence_row_count(), 2);
5524 assert_eq!(resolution.distinct_spelling_count(), expected_distinct);
5525 assert_eq!(
5526 core.version_relation(),
5527 WordPressExternalVersionRelation::SourceComparisonSemanticsUnresolved
5528 );
5529 }
5530 }
5531
5532 #[test]
5533 fn external_catalog_no_match_retains_counts_without_unrelated_rows_or_rights() {
5534 let import = parse_wordfence_v3_production(
5535 &include_bytes!(
5536 "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
5537 )[..],
5538 )
5539 .unwrap();
5540 let result = evaluate_wordpress_review(
5541 &[],
5542 &WordPressReviewInputs::new(None, None)
5543 .with_wordfence_v3_catalog(import)
5544 .unwrap(),
5545 )
5546 .unwrap();
5547 let external = result.external_review().unwrap();
5548 assert!(result.components().is_empty());
5549 assert_eq!(external.counts().selected_associations(), 0);
5550 assert_eq!(external.counts().excluded_associations(), 3);
5551 assert!(external.evaluations().is_empty());
5552 assert!(external.notices().is_empty());
5553 }
5554
5555 #[test]
5556 fn every_selected_external_row_stays_indeterminate_without_a_comparison_policy() {
5557 let import = parse_wordfence_v3_production(
5558 &include_bytes!(
5559 "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
5560 )[..],
5561 )
5562 .unwrap();
5563 let context = parse_wordpress_context(
5564 br#"{
5565 "schema":"security.wordpress-context/v1",
5566 "root":"https://example.test/",
5567 "components":[
5568 {"kind":"core","slug":"wordpress","version":"1.0"},
5569 {"kind":"plugin","slug":"termivar-fixture-component","version":"1.0.0"},
5570 {"kind":"theme","slug":"termivar-fixture-component","version":"2.0"}
5571 ]
5572 }"#,
5573 )
5574 .unwrap();
5575 let result = evaluate_wordpress_review(
5576 &[],
5577 &WordPressReviewInputs::new(Some(context), None)
5578 .with_wordfence_v3_catalog(import)
5579 .unwrap(),
5580 )
5581 .unwrap();
5582 let external = result.external_review().unwrap();
5583 assert_eq!(external.counts().selected_associations(), 3);
5584 assert_eq!(external.counts().evaluable_associations(), 0);
5585 assert_eq!(external.counts().unsupported_associations(), 3);
5586 assert!(external.evaluations().iter().all(|evaluation| {
5587 evaluation.version_relation()
5588 == WordPressExternalVersionRelation::SourceComparisonSemanticsUnresolved
5589 && evaluation.applicability()
5590 == WordPressExternalApplicability::IndeterminateUnsupported
5591 && evaluation.exploit_execution() == WordPressExecutionStatus::NotPerformed
5592 && evaluation.impact_validation() == WordPressExecutionStatus::NotPerformed
5593 }));
5594 }
5595
5596 fn external_review_for(
5597 context: &str,
5598 document: &str,
5599 profile: WordPressComparisonProfile,
5600 ) -> WordPressReviewResult {
5601 let context = parse_wordpress_context(context.as_bytes()).unwrap();
5602 let import = parse_wordfence_v3_production(document.as_bytes()).unwrap();
5603 let inputs = WordPressReviewInputs::new(Some(context), None)
5604 .with_wordfence_v3_catalog(import)
5605 .unwrap()
5606 .with_external_version_profile(profile)
5607 .unwrap();
5608 evaluate_wordpress_review(&[], &inputs).unwrap()
5609 }
5610
5611 fn single_external_association(
5612 component_type: &str,
5613 slug: &str,
5614 affected_versions: &str,
5615 ) -> String {
5616 format!(
5617 r#"{{
5618 "00000000-0000-4000-8000-000000000099": {{
5619 "id":"00000000-0000-4000-8000-000000000099",
5620 "title":"[SYNTHETIC] External boundary fixture",
5621 "software":[{{
5622 "type":"{component_type}",
5623 "name":"[SYNTHETIC] Boundary component",
5624 "slug":"{slug}",
5625 "affected_versions":{affected_versions},
5626 "patched":false,
5627 "patched_versions":[],
5628 "remediation":"Review source guidance"
5629 }}],
5630 "informational":false,
5631 "description":"Synthetic boundary fixture",
5632 "references":["https://example.invalid/termivar/external-boundary"],
5633 "cwe":null,
5634 "cvss":null,
5635 "cve":null,
5636 "cve_link":null,
5637 "researchers":[],
5638 "published":null,
5639 "updated":null,
5640 "copyrights":null
5641 }}
5642 }}"#
5643 )
5644 }
5645
5646 #[test]
5647 fn unresolved_source_identity_detail_is_bounded_without_losing_total_accounting() {
5648 let total = MAX_WORDPRESS_EXTERNAL_IDENTITY_LIMITATION_PROJECTIONS + 1;
5649 let mut records = serde_json::Map::new();
5650 for index in 0..total {
5651 let id = format!("00000000-0000-4000-8000-{index:012x}");
5652 records.insert(
5653 id.clone(),
5654 serde_json::json!({
5655 "id": id,
5656 "title": "[SYNTHETIC] Unresolved identity projection fixture",
5657 "software": [{
5658 "type": "plugin",
5659 "name": "[SYNTHETIC] Unresolved component",
5660 "slug": format!("vendor/plugin-{index}.php"),
5661 "affected_versions": {
5662 "[1.0, 2.0]": {
5663 "from_version": "1.0",
5664 "from_inclusive": true,
5665 "to_version": "2.0",
5666 "to_inclusive": true
5667 }
5668 },
5669 "patched": false,
5670 "patched_versions": [],
5671 "remediation": "Synthetic fixture guidance"
5672 }],
5673 "informational": false,
5674 "description": "Synthetic bounded projection fixture",
5675 "references": ["https://example.invalid/termivar/unresolved"],
5676 "cwe": null,
5677 "cvss": null,
5678 "cve": null,
5679 "cve_link": null,
5680 "researchers": [],
5681 "published": null,
5682 "updated": null,
5683 "copyrights": null
5684 }),
5685 );
5686 }
5687 let document = serde_json::to_vec(&serde_json::Value::Object(records)).unwrap();
5688 let import = parse_wordfence_v3_production(document.as_slice()).unwrap();
5689 let inputs = WordPressReviewInputs::new(None, None)
5690 .with_wordfence_v3_catalog(import)
5691 .unwrap();
5692 let result = evaluate_wordpress_review(&[], &inputs).unwrap();
5693 let external = result.external_review().unwrap();
5694
5695 assert_eq!(external.counts().software_associations(), total);
5696 assert_eq!(external.counts().unresolved_identity_associations(), total);
5697 assert_eq!(
5698 external.counts().projected_identity_limitations(),
5699 MAX_WORDPRESS_EXTERNAL_IDENTITY_LIMITATION_PROJECTIONS
5700 );
5701 assert_eq!(external.counts().unprojected_identity_limitations(), 1);
5702 assert_eq!(
5703 external.identity_limitations().len(),
5704 MAX_WORDPRESS_EXTERNAL_IDENTITY_LIMITATION_PROJECTIONS
5705 );
5706 assert_eq!(external.counts().selected_associations(), 0);
5707 assert_eq!(external.counts().mapped_unselected_associations(), 0);
5708 assert_eq!(external.counts().excluded_associations(), total);
5709 }
5710
5711 #[test]
5712 fn large_unresolved_source_rows_stop_at_detail_byte_budget_instead_of_failing_review() {
5713 const TOTAL: usize = 8;
5714 let mut records = serde_json::Map::new();
5715 for index in 0..TOTAL {
5716 let id = format!("00000000-0000-4000-8000-{index:012x}");
5717 let references = (0..64)
5718 .map(|reference| {
5719 format!(
5720 "https://example.invalid/{}/{index}/{reference}",
5721 "x".repeat(1_900)
5722 )
5723 })
5724 .collect::<Vec<_>>();
5725 let researchers = (0..64)
5726 .map(|researcher| format!("{}-{researcher}", "r".repeat(480)))
5727 .collect::<Vec<_>>();
5728 let mut affected_versions = serde_json::Map::new();
5729 for range in 0..128 {
5730 affected_versions.insert(
5731 format!("[{range}.0.0,{range}.0.1]"),
5732 serde_json::json!({
5733 "from_version": format!("{range}.0.0"),
5734 "from_inclusive": true,
5735 "to_version": format!("{range}.0.1"),
5736 "to_inclusive": true
5737 }),
5738 );
5739 }
5740 let mut copyrights = serde_json::Map::new();
5741 copyrights.insert("message".to_owned(), serde_json::json!("m".repeat(2_048)));
5742 for party in 0..15 {
5743 copyrights.insert(
5744 format!("fixture-{index}-{party}"),
5745 serde_json::json!({
5746 "notice": "n".repeat(2_048),
5747 "license": "l".repeat(2_048),
5748 "license_url": format!("https://example.invalid/license/{index}/{party}")
5749 }),
5750 );
5751 }
5752 records.insert(
5753 id.clone(),
5754 serde_json::json!({
5755 "id": id,
5756 "title": "t".repeat(1_024),
5757 "software": [{
5758 "type": "plugin",
5759 "name": "n".repeat(1_024),
5760 "slug": format!("vendor/plugin-{index}.php"),
5761 "affected_versions": serde_json::Value::Object(affected_versions),
5762 "patched": true,
5763 "patched_versions": (0..128)
5764 .map(|version| format!("{version}.0.2"))
5765 .collect::<Vec<_>>(),
5766 "remediation": "m".repeat(2_048)
5767 }],
5768 "informational": false,
5769 "description": "d".repeat(4_096),
5770 "references": references,
5771 "cwe": null,
5772 "cvss": null,
5773 "cve": null,
5774 "cve_link": null,
5775 "researchers": researchers,
5776 "published": null,
5777 "updated": null,
5778 "copyrights": serde_json::Value::Object(copyrights)
5779 }),
5780 );
5781 }
5782 let document = serde_json::to_vec(&serde_json::Value::Object(records)).unwrap();
5783 let import = parse_wordfence_v3_production(document.as_slice()).unwrap();
5784 let inputs = WordPressReviewInputs::new(None, None)
5785 .with_wordfence_v3_catalog(import)
5786 .unwrap();
5787 let result = evaluate_wordpress_review(&[], &inputs).unwrap();
5788 let external = result.external_review().unwrap();
5789
5790 assert_eq!(external.counts().unresolved_identity_associations(), TOTAL);
5791 assert!(external.counts().projected_identity_limitations() > 0);
5792 assert!(external.counts().projected_identity_limitations() < TOTAL);
5793 assert_eq!(
5794 external
5795 .counts()
5796 .projected_identity_limitations()
5797 .checked_add(external.counts().unprojected_identity_limitations()),
5798 Some(TOTAL)
5799 );
5800 assert_eq!(
5801 external.identity_limitations().len(),
5802 external.counts().projected_identity_limitations()
5803 );
5804 }
5805
5806 #[test]
5807 fn explicit_external_profile_produces_nonconstant_range_relations() {
5808 let document = include_str!(
5809 "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
5810 );
5811 let plugin_context = r#"{
5812 "schema":"security.wordpress-context/v1",
5813 "root":"https://example.test/",
5814 "components":[
5815 {"kind":"plugin","slug":"termivar-fixture-component","version":"1.0"}
5816 ]
5817 }"#;
5818 let numeric = external_review_for(
5819 plugin_context,
5820 document,
5821 WordPressComparisonProfile::NumericDottedV1,
5822 );
5823 let numeric = numeric.external_review().unwrap();
5824 assert_eq!(
5825 numeric.comparison_policy(),
5826 WordPressExternalComparisonPolicy::TermivarWordfenceV3ExplicitInterpretationV1
5827 );
5828 assert_eq!(
5829 numeric.comparison_profile(),
5830 Some(WordPressComparisonProfile::NumericDottedV1)
5831 );
5832 assert_eq!(numeric.counts().within_associations(), 1);
5833 assert_eq!(numeric.counts().outside_associations(), 0);
5834 assert_eq!(numeric.counts().indeterminate_associations(), 0);
5835 assert_eq!(numeric.counts().evaluable_associations(), 1);
5836 assert_eq!(numeric.counts().unsupported_associations(), 0);
5837 assert_eq!(
5838 numeric.evaluations()[0].version_relation(),
5839 WordPressExternalVersionRelation::WithinSupportedRangeUnderSelectedPolicy
5840 );
5841 assert_eq!(
5842 numeric.evaluations()[0].applicability(),
5843 WordPressExternalApplicability::VersionMatchUnderSelectedPolicy
5844 );
5845 assert_eq!(
5846 numeric.evaluations()[0]
5847 .range_evaluations()
5848 .unwrap()
5849 .iter()
5850 .map(WordPressExternalRangeEvaluation::relation)
5851 .collect::<Vec<_>>(),
5852 [
5853 WordPressExternalRangeRelation::Contains,
5854 WordPressExternalRangeRelation::DoesNotContain,
5855 ]
5856 );
5857
5858 let php = external_review_for(
5859 plugin_context,
5860 document,
5861 WordPressComparisonProfile::PhpReleaseSubsetV1,
5862 );
5863 let php = php.external_review().unwrap();
5864 assert_eq!(php.counts().within_associations(), 0);
5865 assert_eq!(php.counts().outside_associations(), 1);
5866 assert_eq!(php.counts().indeterminate_associations(), 0);
5867 assert_eq!(
5868 php.evaluations()[0].version_relation(),
5869 WordPressExternalVersionRelation::OutsideDeclaredRangesUnderSelectedPolicy
5870 );
5871 assert_eq!(
5872 php.evaluations()[0].applicability(),
5873 WordPressExternalApplicability::NoVersionMatchUnderSelectedPolicy
5874 );
5875 }
5876
5877 #[test]
5878 fn source_identity_candidate_is_selected_as_a_limitation_never_as_a_range_conclusion() {
5879 let document = include_str!(
5880 "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
5881 )
5882 .replacen(
5883 r#""slug": "termivar-fixture-component""#,
5884 r#""slug": "Termivar-Fixture-Component""#,
5885 1,
5886 );
5887 let context = r#"{
5888 "schema":"security.wordpress-context/v1",
5889 "root":"https://example.test/",
5890 "components":[
5891 {"kind":"plugin","slug":"termivar-fixture-component","version":"9.9"}
5892 ]
5893 }"#;
5894 let result = external_review_for(
5895 context,
5896 &document,
5897 WordPressComparisonProfile::NumericDottedV1,
5898 );
5899 let external = result.external_review().unwrap();
5900 assert!(external.requires_audit_v6());
5901 assert_eq!(external.counts().exact_identity_associations(), 2);
5902 assert_eq!(external.counts().candidate_identity_associations(), 1);
5903 assert_eq!(external.counts().ambiguous_identity_associations(), 0);
5904 assert_eq!(external.counts().unresolved_identity_associations(), 0);
5905 assert_eq!(external.counts().selected_associations(), 1);
5906 assert_eq!(external.counts().within_associations(), 0);
5907 assert_eq!(external.counts().outside_associations(), 0);
5908 assert_eq!(external.counts().indeterminate_associations(), 1);
5909 assert_eq!(external.counts().not_evaluated_ranges(), 2);
5910 let evaluation = &external.evaluations()[0];
5911 assert_eq!(
5912 evaluation.identity_mapping(),
5913 WordfenceV3IdentityMapping::AsciiCaseFoldCandidate
5914 );
5915 assert_eq!(
5916 evaluation.key().source_component().slug(),
5917 "Termivar-Fixture-Component"
5918 );
5919 assert_eq!(
5920 evaluation.version_relation(),
5921 WordPressExternalVersionRelation::Indeterminate
5922 );
5923 assert_eq!(
5924 evaluation.version_relation_reason(),
5925 WordPressExternalVersionRelationReason::IdentityMappingCandidate
5926 );
5927 assert_eq!(
5928 evaluation.applicability(),
5929 WordPressExternalApplicability::Indeterminate
5930 );
5931 assert!(evaluation.range_evaluations().unwrap().iter().all(|range| {
5932 range.relation() == WordPressExternalRangeRelation::NotEvaluated
5933 && range.reason() == WordPressExternalRangeReason::IdentityMappingCandidate
5934 }));
5935 }
5936
5937 #[test]
5938 fn explicit_external_profile_keeps_unsupported_and_invalid_ranges_uncertain() {
5939 let base = include_str!(
5940 "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
5941 );
5942 let context = r#"{
5943 "schema":"security.wordpress-context/v1",
5944 "root":"https://example.test/",
5945 "components":[
5946 {"kind":"plugin","slug":"termivar-fixture-component","version":"1.1"}
5947 ]
5948 }"#;
5949 let unsupported = base.replacen(
5950 r#""[2.0.0, 2.0.1)": {
5951 "from_version": "2.0.0",
5952 "from_inclusive": true,
5953 "to_version": "2.0.1",
5954 "to_inclusive": false
5955 }"#,
5956 r#""[vendor, vendor-next)": {
5957 "from_version": "vendor",
5958 "from_inclusive": true,
5959 "to_version": "vendor-next",
5960 "to_inclusive": false
5961 }"#,
5962 1,
5963 );
5964 let qualified = external_review_for(
5965 context,
5966 &unsupported,
5967 WordPressComparisonProfile::NumericDottedV1,
5968 );
5969 let qualified = qualified.external_review().unwrap();
5970 assert_eq!(qualified.counts().within_associations(), 1);
5971 assert_eq!(qualified.counts().partial_range_coverage_associations(), 1);
5972 assert_eq!(qualified.counts().unsupported_ranges(), 1);
5973 assert_eq!(
5974 qualified.evaluations()[0].version_relation_reason(),
5975 WordPressExternalVersionRelationReason::ContainingRangeWithPartialCoverage
5976 );
5977
5978 let outside_context = context.replace("1.1", "1.9");
5979 let indeterminate = external_review_for(
5980 &outside_context,
5981 &unsupported,
5982 WordPressComparisonProfile::NumericDottedV1,
5983 );
5984 let indeterminate = indeterminate.external_review().unwrap();
5985 assert_eq!(indeterminate.counts().indeterminate_associations(), 1);
5986 assert_eq!(
5987 indeterminate.evaluations()[0].version_relation_reason(),
5988 WordPressExternalVersionRelationReason::UnsupportedAffectedRange
5989 );
5990
5991 let reversed = base.replacen(
5992 r#""from_version": "1.0.0",
5993 "from_inclusive": true,
5994 "to_version": "1.2.3""#,
5995 r#""from_version": "1.2.3",
5996 "from_inclusive": true,
5997 "to_version": "1.0.0""#,
5998 1,
5999 );
6000 let invalid = external_review_for(
6001 context,
6002 &reversed,
6003 WordPressComparisonProfile::NumericDottedV1,
6004 );
6005 let invalid = invalid.external_review().unwrap();
6006 assert_eq!(invalid.counts().invalid_ranges(), 1);
6007 assert_eq!(invalid.counts().indeterminate_associations(), 1);
6008 assert_eq!(
6009 invalid.evaluations()[0].version_relation_reason(),
6010 WordPressExternalVersionRelationReason::InvalidAffectedRange
6011 );
6012 }
6013
6014 #[test]
6015 fn explicit_external_profile_keeps_boundary_uncertainty_typed_and_reconciled() {
6016 let context_with_version = r#"{
6017 "schema":"security.wordpress-context/v1",
6018 "root":"https://example.test/",
6019 "components":[{"kind":"plugin","slug":"boundary-plugin","version":"1.5"}]
6020 }"#;
6021 let context_without_version = r#"{
6022 "schema":"security.wordpress-context/v1",
6023 "root":"https://example.test/",
6024 "components":[{"kind":"plugin","slug":"boundary-plugin"}]
6025 }"#;
6026 let cases = [
6027 (
6028 "missing version",
6029 context_without_version,
6030 r#"{"range":{"from_version":"1.0","from_inclusive":true,"to_version":"2.0","to_inclusive":true}}"#,
6031 WordPressExternalVersionRelationReason::MissingVersionEvidence,
6032 Some((
6033 WordPressExternalRangeRelation::NotEvaluated,
6034 WordPressExternalRangeReason::MissingVersionEvidence,
6035 )),
6036 (1, 0, 0),
6037 ),
6038 (
6039 "unsupported lower",
6040 context_with_version,
6041 r#"{"range":{"from_version":"vendor","from_inclusive":true,"to_version":"2.0","to_inclusive":true}}"#,
6042 WordPressExternalVersionRelationReason::UnsupportedAffectedRange,
6043 Some((
6044 WordPressExternalRangeRelation::Unsupported,
6045 WordPressExternalRangeReason::UnsupportedLowerBound,
6046 )),
6047 (0, 1, 0),
6048 ),
6049 (
6050 "unsupported upper",
6051 context_with_version,
6052 r#"{"range":{"from_version":"1.0","from_inclusive":true,"to_version":"vendor","to_inclusive":true}}"#,
6053 WordPressExternalVersionRelationReason::UnsupportedAffectedRange,
6054 Some((
6055 WordPressExternalRangeRelation::Unsupported,
6056 WordPressExternalRangeReason::UnsupportedUpperBound,
6057 )),
6058 (0, 1, 0),
6059 ),
6060 (
6061 "equal exclusive interval",
6062 context_with_version,
6063 r#"{"range":{"from_version":"1.5","from_inclusive":false,"to_version":"1.5","to_inclusive":true}}"#,
6064 WordPressExternalVersionRelationReason::InvalidAffectedRange,
6065 Some((
6066 WordPressExternalRangeRelation::InvalidUnderProfile,
6067 WordPressExternalRangeReason::EmptyExclusiveInterval,
6068 )),
6069 (0, 0, 1),
6070 ),
6071 (
6072 "missing ranges",
6073 context_with_version,
6074 "{}",
6075 WordPressExternalVersionRelationReason::MissingAffectedRanges,
6076 None,
6077 (0, 0, 0),
6078 ),
6079 ];
6080
6081 for (label, context, ranges, expected_reason, expected_range, expected_counts) in cases {
6082 let document = single_external_association("plugin", "boundary-plugin", ranges);
6083 let result = external_review_for(
6084 context,
6085 &document,
6086 WordPressComparisonProfile::NumericDottedV1,
6087 );
6088 let external = result.external_review().unwrap();
6089 let evaluation = &external.evaluations()[0];
6090 assert_eq!(external.counts().selected_associations(), 1, "{label}");
6091 assert_eq!(external.counts().within_associations(), 0, "{label}");
6092 assert_eq!(external.counts().outside_associations(), 0, "{label}");
6093 assert_eq!(external.counts().indeterminate_associations(), 1, "{label}");
6094 assert_eq!(
6095 evaluation.version_relation(),
6096 WordPressExternalVersionRelation::Indeterminate,
6097 "{label}"
6098 );
6099 assert_eq!(
6100 evaluation.version_relation_reason(),
6101 expected_reason,
6102 "{label}"
6103 );
6104 assert_eq!(
6105 evaluation.applicability(),
6106 WordPressExternalApplicability::Indeterminate,
6107 "{label}"
6108 );
6109 assert_eq!(
6110 (
6111 external.counts().not_evaluated_ranges(),
6112 external.counts().unsupported_ranges(),
6113 external.counts().invalid_ranges(),
6114 ),
6115 expected_counts,
6116 "{label}"
6117 );
6118 match expected_range {
6119 Some((relation, reason)) => {
6120 let ranges = evaluation.range_evaluations().unwrap();
6121 assert_eq!(ranges.len(), 1, "{label}");
6122 assert_eq!(ranges[0].relation(), relation, "{label}");
6123 assert_eq!(ranges[0].reason(), reason, "{label}");
6124 },
6125 None => assert!(
6126 evaluation.range_evaluations().unwrap().is_empty(),
6127 "{label}"
6128 ),
6129 }
6130 }
6131
6132 let conflict_document = single_external_association(
6133 "core",
6134 "wordpress",
6135 r#"{"range":{"from_version":"1.0","from_inclusive":true,"to_version":"2.0","to_inclusive":true}}"#,
6136 );
6137 let context = parse_wordpress_context(
6138 br#"{
6139 "schema":"security.wordpress-context/v1",
6140 "root":"https://example.test/",
6141 "components":[{"kind":"core","slug":"wordpress","version":"1.5"}]
6142 }"#,
6143 )
6144 .unwrap();
6145 let import = parse_wordfence_v3_production(conflict_document.as_bytes()).unwrap();
6146 let inputs = WordPressReviewInputs::new(Some(context), None)
6147 .with_wordfence_v3_catalog(import)
6148 .unwrap()
6149 .with_external_version_profile(WordPressComparisonProfile::NumericDottedV1)
6150 .unwrap();
6151 let result = evaluate_wordpress_review(
6152 &[WordPressComponentSignal::generator_metadata(Some("1.6")).unwrap()],
6153 &inputs,
6154 )
6155 .unwrap();
6156 let external = result.external_review().unwrap();
6157 let evaluation = &external.evaluations()[0];
6158 assert_eq!(external.counts().selected_associations(), 1);
6159 assert_eq!(external.counts().indeterminate_associations(), 1);
6160 assert_eq!(external.counts().not_evaluated_ranges(), 1);
6161 assert_eq!(
6162 evaluation.version_relation_reason(),
6163 WordPressExternalVersionRelationReason::ConflictingVersionEvidence
6164 );
6165 assert_eq!(
6166 evaluation.range_evaluations().unwrap()[0].reason(),
6167 WordPressExternalRangeReason::ConflictingVersionEvidence
6168 );
6169 }
6170
6171 #[test]
6172 fn explicit_external_profile_keeps_patched_and_affected_source_conflicts_indeterminate() {
6173 let document = include_str!(
6174 "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
6175 )
6176 .replacen(r#""1.2.4""#, r#""1.1""#, 1);
6177 let context = r#"{
6178 "schema":"security.wordpress-context/v1",
6179 "root":"https://example.test/",
6180 "components":[
6181 {"kind":"plugin","slug":"termivar-fixture-component","version":"1.1"}
6182 ]
6183 }"#;
6184 let result = external_review_for(
6185 context,
6186 &document,
6187 WordPressComparisonProfile::NumericDottedV1,
6188 );
6189 let external = result.external_review().unwrap();
6190 assert_eq!(external.counts().within_associations(), 0);
6191 assert_eq!(external.counts().outside_associations(), 0);
6192 assert_eq!(external.counts().indeterminate_associations(), 1);
6193 assert_eq!(
6194 external.evaluations()[0].version_relation_reason(),
6195 WordPressExternalVersionRelationReason::SourcePatchedVersionWithinAffectedRange
6196 );
6197 assert_eq!(
6198 external.evaluations()[0].applicability(),
6199 WordPressExternalApplicability::Indeterminate
6200 );
6201 }
6202
6203 #[test]
6204 fn explicit_external_profile_resolves_trailing_zeros_per_selected_semantics() {
6205 let document = include_str!(
6206 "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
6207 )
6208 .replacen("[1.0, 1.0.0]", "[1.0.0, 1.0.0]", 1)
6209 .replacen(r#""from_version": "1.0""#, r#""from_version": "1.0.0""#, 1);
6210 let context = r#"{
6211 "schema":"security.wordpress-context/v1",
6212 "root":"https://example.test/",
6213 "components":[{"kind":"core","slug":"wordpress","version":"1.0"}]
6214 }"#;
6215 for (profile, expected) in [
6216 (
6217 WordPressComparisonProfile::NumericDottedV1,
6218 WordPressExternalVersionRelation::WithinSupportedRangeUnderSelectedPolicy,
6219 ),
6220 (
6221 WordPressComparisonProfile::PhpReleaseSubsetV1,
6222 WordPressExternalVersionRelation::OutsideDeclaredRangesUnderSelectedPolicy,
6223 ),
6224 ] {
6225 let result = external_review_for(context, &document, profile);
6226 assert_eq!(
6227 result.external_review().unwrap().evaluations()[0].version_relation(),
6228 expected
6229 );
6230 }
6231 }
6232
6233 #[test]
6234 fn external_profile_requires_exactly_one_attached_wordfence_export() {
6235 assert_eq!(
6236 WordPressReviewInputs::default()
6237 .with_external_version_profile(WordPressComparisonProfile::NumericDottedV1),
6238 Err(WordPressReviewError::ExternalVersionProfileRequiresWordfenceV3)
6239 );
6240 let import = parse_wordfence_v3_production(
6241 &include_bytes!(
6242 "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
6243 )[..],
6244 )
6245 .unwrap();
6246 let selected = WordPressReviewInputs::default()
6247 .with_wordfence_v3_catalog(import)
6248 .unwrap()
6249 .with_external_version_profile(WordPressComparisonProfile::NumericDottedV1)
6250 .unwrap();
6251 assert_eq!(
6252 selected.with_external_version_profile(WordPressComparisonProfile::PhpReleaseSubsetV1),
6253 Err(WordPressReviewError::ExternalVersionProfileRequiresWordfenceV3)
6254 );
6255 }
6256
6257 #[test]
6258 fn native_and_external_advisory_inputs_are_mutually_exclusive() {
6259 let import = parse_wordfence_v3_production(
6260 &include_bytes!(
6261 "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
6262 )[..],
6263 )
6264 .unwrap();
6265 let native = parse_wordpress_advisory_catalog(CATALOG.as_bytes()).unwrap();
6266 assert_eq!(
6267 WordPressReviewInputs::new(None, Some(native)).with_wordfence_v3_catalog(import),
6268 Err(WordPressReviewError::ConflictingAdvisoryInputs)
6269 );
6270 }
6271
6272 #[test]
6273 fn external_selected_associations_fail_instead_of_truncating() {
6274 let document = wordfence_associations(MAX_WORDPRESS_ADVISORY_RECORDS + 1);
6275 let import = parse_wordfence_v3_production(document.as_bytes()).unwrap();
6276 let context = parse_wordpress_context(
6277 br#"{
6278 "schema":"security.wordpress-context/v1",
6279 "root":"https://example.test/",
6280 "components":[{"kind":"plugin","slug":"selected-plugin","version":"1.0"}]
6281 }"#,
6282 )
6283 .unwrap();
6284 assert_eq!(
6285 evaluate_wordpress_review(
6286 &[],
6287 &WordPressReviewInputs::new(Some(context), None)
6288 .with_wordfence_v3_catalog(import)
6289 .unwrap()
6290 ),
6291 Err(WordPressReviewError::ResultLimitExceeded)
6292 );
6293 }
6294
6295 #[test]
6296 fn explicit_external_profile_resolves_one_component_once_for_many_associations() {
6297 let document = wordfence_associations(MAX_WORDPRESS_ADVISORY_RECORDS);
6298 let import = parse_wordfence_v3_production(document.as_bytes()).unwrap();
6299 let context = parse_wordpress_context(
6300 br#"{
6301 "schema":"security.wordpress-context/v1",
6302 "root":"https://example.test/",
6303 "components":[{"kind":"plugin","slug":"selected-plugin","version":"1.0"}]
6304 }"#,
6305 )
6306 .unwrap();
6307 let inputs = WordPressReviewInputs::new(Some(context), None)
6308 .with_wordfence_v3_catalog(import)
6309 .unwrap()
6310 .with_external_version_profile(WordPressComparisonProfile::NumericDottedV1)
6311 .unwrap();
6312 let result = evaluate_wordpress_review(&[], &inputs).unwrap();
6313 let external = result.external_review().unwrap();
6314 assert_eq!(
6315 external.counts().selected_associations(),
6316 MAX_WORDPRESS_ADVISORY_RECORDS
6317 );
6318 assert_eq!(
6319 external.counts().within_associations(),
6320 MAX_WORDPRESS_ADVISORY_RECORDS
6321 );
6322 assert_eq!(external.counts().indeterminate_associations(), 0);
6323 }
6324
6325 #[test]
6326 fn explicit_external_profile_charges_patched_version_range_cartesian_work() {
6327 let work_per_association = crate::wordpress_version::checked_external_interpretation_work(
6328 MAX_WORDFENCE_V3_RANGES_PER_ASSOCIATION,
6329 MAX_WORDFENCE_V3_PATCHED_VERSIONS,
6330 )
6331 .unwrap();
6332 let association_count = MAX_WORDPRESS_EVALUATION_WORK / work_per_association + 1;
6333 let document = wordfence_dense_profiled_associations(association_count);
6334 let import = parse_wordfence_v3_production(document.as_bytes()).unwrap();
6335 let context = parse_wordpress_context(
6336 br#"{
6337 "schema":"security.wordpress-context/v1",
6338 "root":"https://example.test/",
6339 "components":[{"kind":"plugin","slug":"selected-plugin","version":"1.0"}]
6340 }"#,
6341 )
6342 .unwrap();
6343 let inputs = WordPressReviewInputs::new(Some(context), None)
6344 .with_wordfence_v3_catalog(import)
6345 .unwrap()
6346 .with_external_version_profile(WordPressComparisonProfile::NumericDottedV1)
6347 .unwrap();
6348 assert_eq!(
6349 evaluate_wordpress_review(&[], &inputs),
6350 Err(WordPressReviewError::EvaluationLimitExceeded)
6351 );
6352 }
6353}