1use std::{
8 collections::{BTreeMap, BTreeSet},
9 error::Error,
10 fmt,
11};
12
13use serde::Serialize;
14use serde_json::Value;
15
16use super::{render_serializable_json, write_markdown_code_span, RenderBuffer, ReportError};
17
18mod html;
19mod import;
20
21#[cfg(feature = "supplied-session-review")]
22pub(super) fn validate_supplied_session_oauth_authorization(
23 value: &Value,
24) -> Result<ImportedSuppliedSessionAudit, ComparisonError> {
25 import::validate_supplied_session_oauth_authorization(value)
26}
27
28#[cfg(feature = "supplied-session-review")]
30pub(super) fn validate_supplied_session_oauth_refresh(
31 value: &Value,
32) -> Result<ImportedSuppliedSessionAudit, ComparisonError> {
33 import::validate_supplied_session_oauth_refresh(value)
34}
35
36#[cfg(feature = "supplied-session-review")]
38pub(super) fn validate_supplied_session_oauth_code(
39 value: &Value,
40) -> Result<ImportedSuppliedSessionAudit, ComparisonError> {
41 import::validate_supplied_session_oauth_code(value)
42}
43
44pub(super) fn validate_supplied_session_renewal(
46 value: &Value,
47) -> Result<ImportedSuppliedSessionAudit, ComparisonError> {
48 import::validate_supplied_session_renewal(value)
49}
50#[cfg(test)]
51#[path = "comparison_tests.rs"]
52mod tests;
53
54pub const COMPARISON_DOCUMENT_SCHEMA: &str = "termivar-report-comparison/v1";
56pub(super) const SUPPLIED_SESSION_COMPARISON_SCHEMA: &str =
58 "termivar-supplied-session-comparison/v1";
59pub(super) const SECRET_EXPOSURE_COMPARISON_SCHEMA: &str = "termivar-secret-exposure-comparison/v1";
61pub(super) const TLS_OBSERVATION_COMPARISON_SCHEMA: &str = "termivar-tls-observation-comparison/v1";
63pub(super) const TLS_NEGOTIATION_REVIEW_COMPARISON_SCHEMA: &str =
65 "termivar-tls-negotiation-review-comparison/v1";
66pub(super) const WEBSOCKET_REVIEW_COMPARISON_SCHEMA: &str =
68 "termivar-websocket-review-comparison/v1";
69pub(super) const WEBSOCKET_SUPPLIED_SESSION_COMPARISON_SCHEMA: &str =
70 "termivar-websocket-review-comparison/v2";
71pub(super) const JWT_POLICY_REVIEW_COMPARISON_SCHEMA: &str =
73 "termivar-jwt-policy-review-comparison/v1";
74pub(super) const XML_EXTERNAL_ENTITY_REVIEW_COMPARISON_SCHEMA: &str =
76 "termivar-xml-external-entity-review-comparison/v1";
77pub(super) const TEMPLATE_EVALUATION_REVIEW_COMPARISON_SCHEMA: &str =
79 "termivar-template-evaluation-review-comparison/v1";
80pub(super) const CONTROL_REFERENCE_MAPPING_COMPARISON_SCHEMA: &str =
82 "termivar-control-reference-mapping-comparison/v1";
83pub(super) const RECON_SNAPSHOT_IMPORT_COMPARISON_SCHEMA: &str =
85 "termivar-recon-snapshot-import-comparison/v1";
86pub(super) const RECON_CERTSPOTTER_COMPARISON_SCHEMA: &str =
88 "termivar-recon-certspotter-comparison/v1";
89pub(super) const WORDPRESS_COMPARISON_SCHEMA_V1: &str = "termivar-wordpress-review-comparison/v1";
91pub(super) const WORDPRESS_COMPARISON_SCHEMA_V2: &str = "termivar-wordpress-review-comparison/v2";
92pub(super) const WORDPRESS_COMPARISON_SCHEMA_V3: &str = "termivar-wordpress-review-comparison/v3";
93pub(super) const WORDPRESS_COMPARISON_SCHEMA_V4: &str = "termivar-wordpress-review-comparison/v4";
94const WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY: &str =
95 "technology.wordpress-metadata-source-response-observed@1";
96pub const MAX_COMPARISON_INPUT_BYTES: usize = super::MAX_RENDERED_REPORT_BYTES;
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101#[non_exhaustive]
102pub enum ComparisonFormat {
103 Markdown,
105 Json,
107 Html,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113#[non_exhaustive]
114pub enum ComparisonError {
115 InputLimitExceeded,
117 InvalidJson,
119 InvalidDocument,
121 UnsupportedDocument,
123 AmbiguousIdentity,
125 OutputLimitExceeded,
127 Serialization,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
139#[non_exhaustive]
140pub struct ImportedAssessmentSummary {
141 schema: String,
142 profile: String,
143 status: String,
144 subject_count: u64,
145 item_count: u64,
146}
147
148impl ImportedAssessmentSummary {
149 pub fn schema(&self) -> &str {
151 &self.schema
152 }
153
154 pub fn profile(&self) -> &str {
156 &self.profile
157 }
158
159 pub fn status(&self) -> &str {
161 &self.status
162 }
163
164 pub const fn subject_count(&self) -> u64 {
166 self.subject_count
167 }
168
169 pub const fn item_count(&self) -> u64 {
171 self.item_count
172 }
173}
174
175impl fmt::Display for ComparisonError {
176 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
177 formatter.write_str(match self {
178 Self::InputLimitExceeded => "comparison input exceeds the byte limit",
179 Self::InvalidJson => "comparison input is not valid bounded JSON",
180 Self::InvalidDocument => "comparison input has invalid or inconsistent fields",
181 Self::UnsupportedDocument => {
182 "comparison requires supported complete assessment documents"
183 },
184 Self::AmbiguousIdentity => "comparison input contains ambiguous item identities",
185 Self::OutputLimitExceeded => "comparison output exceeds the byte limit",
186 Self::Serialization => "comparison output could not be serialized",
187 })
188 }
189}
190
191impl Error for ComparisonError {}
192
193impl From<ReportError> for ComparisonError {
194 fn from(error: ReportError) -> Self {
195 match error {
196 ReportError::OutputLimitExceeded { .. } => Self::OutputLimitExceeded,
197 ReportError::Serialization => Self::Serialization,
198 }
199 }
200}
201
202pub fn compare_reports(
214 before: &[u8],
215 after: &[u8],
216 format: ComparisonFormat,
217) -> Result<String, ComparisonError> {
218 let document = compare_documents(import::parse(before)?, import::parse(after)?)?;
219 render(&document, format, super::MAX_RENDERED_REPORT_BYTES)
220}
221
222pub fn import_assessment_summary(
229 bytes: &[u8],
230) -> Result<ImportedAssessmentSummary, ComparisonError> {
231 let imported = import::parse(bytes)?;
232 Ok(ImportedAssessmentSummary {
233 schema: imported.metadata.schema,
234 profile: imported.metadata.profile,
235 status: imported.metadata.status,
236 subject_count: imported.metadata.subject_count,
237 item_count: imported.metadata.item_count,
238 })
239}
240
241#[derive(Debug, Serialize)]
242pub(super) struct ComparisonDocument {
243 pub(super) schema: &'static str,
244 pub(super) scope_assurance: &'static str,
245 pub(super) coverage_equivalence: &'static str,
246 pub(super) source_authenticity: &'static str,
247 pub(super) interpretation_limits: [&'static str; 4],
248 pub(super) before: SourceMetadata,
249 pub(super) after: SourceMetadata,
250 #[serde(skip_serializing_if = "Option::is_none")]
251 pub(super) supplied_session_comparison: Option<SuppliedSessionComparison>,
252 #[serde(skip_serializing_if = "Option::is_none")]
253 pub(super) secret_exposure_comparison: Option<SecretExposureComparison>,
254 #[serde(skip_serializing_if = "Option::is_none")]
255 pub(super) tls_observation_comparison: Option<TlsObservationComparison>,
256 #[serde(skip_serializing_if = "Option::is_none")]
257 pub(super) tls_negotiation_review_comparison: Option<TlsNegotiationReviewComparison>,
258 #[serde(skip_serializing_if = "Option::is_none")]
259 pub(super) websocket_review_comparison: Option<WebSocketReviewComparison>,
260 #[serde(skip_serializing_if = "Option::is_none")]
261 pub(super) jwt_policy_review_comparison: Option<JwtPolicyReviewComparison>,
262 #[serde(skip_serializing_if = "Option::is_none")]
263 pub(super) xml_external_entity_review_comparison: Option<XmlExternalEntityReviewComparison>,
264 #[serde(skip_serializing_if = "Option::is_none")]
265 pub(super) template_evaluation_review_comparison: Option<TemplateEvaluationReviewComparison>,
266 #[serde(skip_serializing_if = "Option::is_none")]
267 pub(super) control_reference_mapping_comparison: Option<ControlReferenceMappingComparison>,
268 #[serde(skip_serializing_if = "Option::is_none")]
269 pub(super) recon_snapshot_import_comparison: Option<ReconSnapshotImportComparison>,
270 #[serde(skip_serializing_if = "Option::is_none")]
271 pub(super) recon_certspotter_comparison: Option<ReconCertSpotterComparison>,
272 #[serde(skip_serializing_if = "Option::is_none")]
273 pub(super) wordpress_review_comparison: Option<WordPressReviewComparison>,
274 pub(super) only_in_after: Vec<ComparisonItem>,
275 pub(super) only_in_before: Vec<ComparisonItem>,
276 pub(super) changed: Vec<ComparisonItem>,
277 pub(super) unchanged: Vec<ComparisonItem>,
278}
279
280#[derive(Debug, Serialize)]
281pub(super) struct SuppliedSessionComparison {
282 pub(super) schema: &'static str,
283 pub(super) status: &'static str,
284 #[serde(skip_serializing_if = "Option::is_none")]
285 pub(super) reason: Option<&'static str>,
286 pub(super) scope_assurance: &'static str,
287 pub(super) context: WordPressFacetComparison,
288 pub(super) health_and_coverage: WordPressFacetComparison,
289 pub(super) accounting: WordPressFacetComparison,
290 pub(super) interpretation_limits: [&'static str; 5],
291}
292
293#[derive(Debug, Serialize)]
294pub(super) struct SecretExposureComparison {
295 pub(super) schema: &'static str,
296 pub(super) status: &'static str,
297 #[serde(skip_serializing_if = "Option::is_none")]
298 pub(super) reason: Option<&'static str>,
299 pub(super) methodology: WordPressFacetComparison,
300 pub(super) coverage: WordPressFacetComparison,
301 pub(super) interpretation_limits: [&'static str; 4],
302}
303
304#[derive(Debug, Serialize)]
305pub(super) struct TlsObservationComparison {
306 pub(super) schema: &'static str,
307 pub(super) status: &'static str,
308 #[serde(skip_serializing_if = "Option::is_none")]
309 pub(super) reason: Option<&'static str>,
310 pub(super) methodology: WordPressFacetComparison,
311 pub(super) coverage: WordPressFacetComparison,
312 pub(super) certificate_observations: WordPressFacetComparison,
313 pub(super) interpretation_limits: [&'static str; 5],
314}
315
316#[derive(Debug, Serialize)]
317pub(super) struct TlsNegotiationReviewComparison {
318 pub(super) schema: &'static str,
319 pub(super) status: &'static str,
320 #[serde(skip_serializing_if = "Option::is_none")]
321 pub(super) reason: Option<&'static str>,
322 pub(super) methodology: WordPressFacetComparison,
323 pub(super) coverage: WordPressFacetComparison,
324 pub(super) capability_observations: WordPressFacetComparison,
325 pub(super) interpretation_limits: [&'static str; 6],
326}
327
328#[derive(Debug, Serialize)]
329pub(super) struct WebSocketReviewComparison {
330 pub(super) schema: &'static str,
331 pub(super) status: &'static str,
332 #[serde(skip_serializing_if = "Option::is_none")]
333 pub(super) reason: Option<&'static str>,
334 #[serde(skip_serializing_if = "Option::is_none")]
335 pub(super) supplied_session_context: Option<WordPressFacetComparison>,
336 pub(super) methodology: WordPressFacetComparison,
337 pub(super) coverage: WordPressFacetComparison,
338 pub(super) outcome: WordPressFacetComparison,
339 pub(super) interpretation_limits: [&'static str; 6],
340}
341
342#[derive(Debug, Serialize)]
343pub(super) struct JwtPolicyReviewComparison {
344 pub(super) schema: &'static str,
345 pub(super) status: &'static str,
346 #[serde(skip_serializing_if = "Option::is_none")]
347 pub(super) reason: Option<&'static str>,
348 pub(super) methodology: WordPressFacetComparison,
349 pub(super) coverage: WordPressFacetComparison,
350 pub(super) outcome: WordPressFacetComparison,
351 pub(super) interpretation_limits: [&'static str; 6],
352 #[serde(skip)]
353 pub(super) target_selected: bool,
354}
355
356#[derive(Debug, Serialize)]
357pub(super) struct XmlExternalEntityReviewComparison {
358 pub(super) schema: &'static str,
359 pub(super) status: &'static str,
360 #[serde(skip_serializing_if = "Option::is_none")]
361 pub(super) reason: Option<&'static str>,
362 pub(super) methodology: WordPressFacetComparison,
363 pub(super) coverage: WordPressFacetComparison,
364 pub(super) outcome: WordPressFacetComparison,
365 pub(super) interpretation_limits: [&'static str; 6],
366}
367
368#[derive(Debug, Serialize)]
369pub(super) struct TemplateEvaluationReviewComparison {
370 pub(super) schema: &'static str,
371 pub(super) status: &'static str,
372 #[serde(skip_serializing_if = "Option::is_none")]
373 pub(super) reason: Option<&'static str>,
374 pub(super) methodology: WordPressFacetComparison,
375 pub(super) coverage: WordPressFacetComparison,
376 pub(super) outcome: WordPressFacetComparison,
377 pub(super) interpretation_limits: [&'static str; 6],
378}
379
380#[derive(Debug, Serialize)]
381pub(super) struct ControlReferenceMappingComparison {
382 pub(super) schema: &'static str,
383 pub(super) status: &'static str,
384 #[serde(skip_serializing_if = "Option::is_none")]
385 pub(super) reason: Option<&'static str>,
386 pub(super) methodology: WordPressFacetComparison,
387 pub(super) coverage: WordPressFacetComparison,
388 pub(super) reference_set: WordPressFacetComparison,
389 pub(super) interpretation_limits: [&'static str; 5],
390}
391
392#[derive(Debug, Serialize)]
393pub(super) struct ReconSnapshotImportComparison {
394 pub(super) schema: &'static str,
395 pub(super) status: &'static str,
396 #[serde(skip_serializing_if = "Option::is_none")]
397 pub(super) reason: Option<&'static str>,
398 pub(super) methodology: WordPressFacetComparison,
399 pub(super) provenance_and_sources: WordPressFacetComparison,
400 pub(super) coverage_and_accounting: WordPressFacetComparison,
401 pub(super) hypotheses: WordPressFacetComparison,
402 pub(super) interpretation_limits: [&'static str; 6],
403}
404
405#[derive(Debug, Serialize)]
406pub(super) struct ReconCertSpotterComparison {
407 pub(super) schema: &'static str,
408 pub(super) status: &'static str,
409 #[serde(skip_serializing_if = "Option::is_none")]
410 pub(super) reason: Option<&'static str>,
411 pub(super) provider: WordPressFacetComparison,
412 pub(super) methodology: WordPressFacetComparison,
413 pub(super) coverage: WordPressFacetComparison,
414 pub(super) hypotheses: WordPressFacetComparison,
415 pub(super) interpretation_limits: [&'static str; 6],
416}
417
418#[derive(Debug, Serialize)]
419pub(super) struct WordPressReviewComparison {
420 pub(super) schema: &'static str,
421 pub(super) status: &'static str,
422 #[serde(skip_serializing_if = "Option::is_none")]
423 pub(super) reason: Option<&'static str>,
424 pub(super) scope_assurance: &'static str,
425 pub(super) coverage: WordPressFacetComparison,
426 pub(super) methodology: WordPressFacetComparison,
427 pub(super) provenance: WordPressFacetComparison,
428 #[serde(skip_serializing_if = "Option::is_none")]
429 pub(super) discovery_source_content: Option<WordPressFacetComparison>,
430 #[serde(skip_serializing_if = "Option::is_none")]
431 pub(super) asset_fingerprints: Option<WordPressAssetFingerprintComparison>,
432 pub(super) components: WordPressEntityChanges<WordPressComponentKey>,
433 pub(super) advisories: WordPressEntityChanges<WordPressAdvisoryKey>,
434 pub(super) interpretation_limits: [&'static str; 6],
435}
436
437#[derive(Debug, Serialize)]
438pub(super) struct WordPressAssetFingerprintComparison {
439 pub(super) status: &'static str,
440 #[serde(skip_serializing_if = "Option::is_none")]
441 pub(super) reason: Option<&'static str>,
442 pub(super) methodology: WordPressFacetComparison,
443 pub(super) catalogue: WordPressFacetComparison,
444 pub(super) coverage: WordPressFacetComparison,
445 pub(super) resources: WordPressEntityChanges<WordPressAssetFingerprintResourceKey>,
446 pub(super) components: WordPressEntityChanges<WordPressAssetFingerprintComponentKey>,
447 pub(super) interpretation_limits: [&'static str; 5],
448}
449
450#[derive(Debug, Serialize)]
451pub(super) struct WordPressFacetComparison {
452 pub(super) status: String,
453 pub(super) changed_fields: Vec<String>,
454 pub(super) before: Option<Value>,
455 pub(super) after: Option<Value>,
456 pub(super) note: &'static str,
457}
458
459#[derive(Debug, Serialize)]
460pub(super) struct WordPressEntityChanges<K> {
461 pub(super) paired_unchanged_count: usize,
462 pub(super) paired_changed: Vec<WordPressEntityChange<K>>,
463 pub(super) only_in_before: Vec<WordPressOneSidedEntity<K>>,
464 pub(super) only_in_after: Vec<WordPressOneSidedEntity<K>>,
465}
466
467impl<K> Default for WordPressEntityChanges<K> {
468 fn default() -> Self {
469 Self {
470 paired_unchanged_count: 0,
471 paired_changed: Vec::new(),
472 only_in_before: Vec::new(),
473 only_in_after: Vec::new(),
474 }
475 }
476}
477
478#[derive(Debug, Serialize)]
479pub(super) struct WordPressEntityChange<K> {
480 pub(super) key: K,
481 pub(super) changed_dimensions: Vec<String>,
482 pub(super) before: BTreeMap<String, Value>,
483 pub(super) after: BTreeMap<String, Value>,
484}
485
486#[derive(Debug, Serialize)]
487pub(super) struct WordPressOneSidedEntity<K> {
488 pub(super) key: K,
489 pub(super) content: BTreeMap<String, Value>,
490 pub(super) interpretation: &'static str,
491}
492
493#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
494pub(super) struct WordPressComponentKey {
495 pub(super) kind: String,
496 pub(super) slug: String,
497}
498
499#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
500pub(super) struct WordPressAdvisoryKey {
501 pub(super) source_kind: String,
502 pub(super) source_namespace: String,
503 pub(super) upstream_id: String,
504 pub(super) component: WordPressComponentKey,
505}
506
507#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
508pub(super) struct WordPressAssetFingerprintResourceKey {
509 pub(super) source_namespace: String,
510 pub(super) component: WordPressComponentKey,
511 pub(super) relative_path: String,
512}
513
514#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
515pub(super) struct WordPressAssetFingerprintComponentKey {
516 pub(super) source_namespace: String,
517 pub(super) component: WordPressComponentKey,
518}
519
520#[derive(Debug, Clone, PartialEq, Eq)]
521pub(super) struct ImportedWordPressAssetFingerprintAudit {
522 pub(super) methodology: Value,
523 pub(super) catalogue: Value,
524 pub(super) coverage: Value,
525 pub(super) resources: BTreeMap<WordPressAssetFingerprintResourceKey, BTreeMap<String, Value>>,
526 pub(super) components: BTreeMap<WordPressAssetFingerprintComponentKey, BTreeMap<String, Value>>,
527}
528
529#[derive(Debug, Clone, PartialEq, Eq)]
530pub(super) struct ImportedSuppliedSessionAudit {
531 pub(super) context: Value,
532 pub(super) health_and_coverage: Value,
533 pub(super) accounting: Value,
534 pub(super) resources: BTreeMap<String, SuppliedSessionResourceBinding>,
538}
539
540#[derive(Debug, Clone, PartialEq, Eq)]
541pub(super) struct ImportedSecretExposureAudit {
542 pub(super) methodology: Value,
543 pub(super) coverage: Value,
544}
545
546#[derive(Debug, Clone, PartialEq, Eq)]
547pub(super) struct ImportedTlsObservationAudit {
548 pub(super) methodology: Value,
549 pub(super) coverage: Value,
550 pub(super) certificate_observations: Value,
551}
552
553#[derive(Debug, Clone, PartialEq, Eq)]
554pub(super) struct ImportedTlsNegotiationReviewAudit {
555 pub(super) methodology: Value,
556 pub(super) coverage: Value,
557 pub(super) capability_observations: Value,
558}
559
560#[derive(Debug, Clone, PartialEq, Eq)]
561pub(super) struct ImportedWebSocketReviewAudit {
562 pub(super) schema: String,
563 pub(super) supplied_session_identity: Option<Value>,
564 pub(super) supplied_session_context: Option<Value>,
565 pub(super) methodology: Value,
566 pub(super) coverage: Value,
567 pub(super) outcome: Value,
568}
569
570#[derive(Debug, Clone, PartialEq, Eq)]
571pub(super) struct ImportedJwtPolicyReviewAudit {
572 pub(super) schema: String,
573 pub(super) methodology: Value,
574 pub(super) coverage: Value,
575 pub(super) outcome: Value,
576}
577
578#[derive(Debug, Clone, PartialEq, Eq)]
579pub(super) struct ImportedXmlExternalEntityReviewAudit {
580 pub(super) methodology: Value,
581 pub(super) coverage: Value,
582 pub(super) outcome: Value,
583}
584
585#[derive(Debug, Clone, PartialEq, Eq)]
586pub(super) struct ImportedTemplateEvaluationReviewAudit {
587 pub(super) methodology: Value,
588 pub(super) coverage: Value,
589 pub(super) outcome: Value,
590}
591
592#[derive(Debug, Clone, PartialEq, Eq)]
593pub(super) struct ImportedControlReferenceMappingAudit {
594 pub(super) methodology: Value,
595 pub(super) coverage: Value,
596 pub(super) reference_set: Value,
597}
598
599#[derive(Debug, Clone, PartialEq, Eq)]
600pub(super) struct ImportedReconSnapshotAudit {
601 pub(super) methodology: Value,
602 pub(super) provenance_and_sources: Value,
603 pub(super) coverage_and_accounting: Value,
604 pub(super) hypotheses: Value,
605}
606
607#[derive(Debug, Clone, PartialEq, Eq)]
608pub(super) struct ImportedReconCertSpotterAudit {
609 pub(super) provider: Value,
610 pub(super) methodology: Value,
611 pub(super) coverage: Value,
612 pub(super) hypotheses: Value,
613}
614
615#[derive(Debug, Clone, PartialEq, Eq)]
616pub(super) struct SuppliedSessionResourceBinding {
617 pub(super) evidence_reference: Option<String>,
618 pub(super) response_bytes: u64,
619 pub(super) epoch: u64,
620}
621
622#[derive(Debug, Clone, PartialEq, Eq)]
623pub(super) struct ImportedWordPressAudit {
624 pub(super) application_reference: Option<String>,
626 pub(super) coverage: Value,
627 pub(super) inventory_coverage_recorded: bool,
628 pub(super) methodology: Value,
629 pub(super) provenance: Value,
630 pub(super) discovery_source_content: Option<Value>,
631 pub(super) supplied_session_context: Option<Value>,
634 pub(super) asset_fingerprints: Option<ImportedWordPressAssetFingerprintAudit>,
635 pub(super) components: BTreeMap<WordPressComponentKey, BTreeMap<String, Value>>,
636 pub(super) advisories: BTreeMap<WordPressAdvisoryKey, BTreeMap<String, Value>>,
637}
638
639#[derive(Debug, Serialize)]
640pub(super) struct SourceMetadata {
641 pub(super) sha256: String,
642 pub(super) schema: String,
643 pub(super) source_schema: String,
644 pub(super) run_schema: String,
645 pub(super) profile_schema: String,
646 pub(super) profile: String,
647 pub(super) status: String,
648 pub(super) subject_count: u64,
649 pub(super) item_count: u64,
650 pub(super) optional_audits: BTreeMap<String, Value>,
651}
652
653#[derive(Debug, Serialize)]
654pub(super) struct ComparisonItem {
655 pub(super) fingerprint: String,
656 pub(super) capability_id: String,
657 pub(super) before: Option<ItemProjection>,
658 pub(super) after: Option<ItemProjection>,
659 pub(super) changed_fields: Vec<&'static str>,
660}
661
662#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
663pub(super) struct ItemProjection {
664 pub(super) title: String,
665 pub(super) category: String,
666 pub(super) disposition: String,
667 pub(super) claim_basis: String,
668 pub(super) severity: Option<String>,
669 pub(super) cwe: Option<String>,
670 pub(super) confidence_ppm: u32,
671 pub(super) redacted_summary: String,
672 pub(super) remediation: RemediationProjection,
673 pub(super) evidence: EvidenceMetadata,
674}
675
676#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
677pub(super) struct RemediationProjection {
678 pub(super) id: String,
679 pub(super) summary: String,
680}
681
682#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
683pub(super) struct EvidenceMetadata {
684 pub(super) evidence_count: u64,
685 pub(super) evidence_reference_count: usize,
686 pub(super) control_reference_count: usize,
687 pub(super) candidate_reference_count: usize,
688 pub(super) case_present: bool,
689 pub(super) outcome_present: bool,
690 pub(super) verification_stage: Option<String>,
691}
692
693struct ImportedDocument {
694 metadata: SourceMetadata,
695 items: BTreeMap<String, ImportedItem>,
696 supplied_session: Option<ImportedSuppliedSessionAudit>,
697 secret_exposure: Option<ImportedSecretExposureAudit>,
698 tls_observation: Option<ImportedTlsObservationAudit>,
699 tls_negotiation_review: Option<ImportedTlsNegotiationReviewAudit>,
700 websocket_review: Option<ImportedWebSocketReviewAudit>,
701 jwt_policy_review: Option<ImportedJwtPolicyReviewAudit>,
702 xml_external_entity_review: Option<ImportedXmlExternalEntityReviewAudit>,
703 template_evaluation_review: Option<ImportedTemplateEvaluationReviewAudit>,
704 control_reference_mapping: Option<ImportedControlReferenceMappingAudit>,
705 recon_snapshot_import: Option<ImportedReconSnapshotAudit>,
706 recon_certspotter: Option<ImportedReconCertSpotterAudit>,
707 wordpress_review: Option<ImportedWordPressAudit>,
708}
709
710struct ImportedItem {
711 capability_id: String,
712 projection: ItemProjection,
713 observation_evidence_references: BTreeSet<String>,
714}
715
716fn compare_documents(
717 before: ImportedDocument,
718 mut after: ImportedDocument,
719) -> Result<ComparisonDocument, ComparisonError> {
720 let block_item_pairing = !supplied_session_context_allows_item_pairing(
721 before.supplied_session.as_ref(),
722 after.supplied_session.as_ref(),
723 );
724 let supplied_session_comparison = compare_supplied_sessions(
725 before.supplied_session.as_ref(),
726 after.supplied_session.as_ref(),
727 );
728 let secret_exposure_comparison = compare_secret_exposure(
729 before.secret_exposure.as_ref(),
730 after.secret_exposure.as_ref(),
731 );
732 let tls_observation_comparison = compare_tls_observation(
733 before.tls_observation.as_ref(),
734 after.tls_observation.as_ref(),
735 );
736 let tls_negotiation_review_comparison = compare_tls_negotiation_review(
737 before.tls_negotiation_review.as_ref(),
738 after.tls_negotiation_review.as_ref(),
739 );
740 let websocket_review_comparison = compare_websocket_review(
741 before.websocket_review.as_ref(),
742 after.websocket_review.as_ref(),
743 );
744 let jwt_policy_review_comparison = compare_jwt_policy_review(
745 before.jwt_policy_review.as_ref(),
746 after.jwt_policy_review.as_ref(),
747 );
748 let xml_external_entity_review_comparison = compare_xml_external_entity_review(
749 before.xml_external_entity_review.as_ref(),
750 after.xml_external_entity_review.as_ref(),
751 );
752 let template_evaluation_review_comparison = compare_template_evaluation_review(
753 before.template_evaluation_review.as_ref(),
754 after.template_evaluation_review.as_ref(),
755 );
756 let control_reference_mapping_comparison = compare_control_reference_mapping(
757 before.control_reference_mapping.as_ref(),
758 after.control_reference_mapping.as_ref(),
759 );
760 let recon_snapshot_import_comparison = compare_recon_snapshot_import(
761 before.recon_snapshot_import.as_ref(),
762 after.recon_snapshot_import.as_ref(),
763 );
764 let recon_certspotter_comparison = compare_recon_certspotter(
765 before.recon_certspotter.as_ref(),
766 after.recon_certspotter.as_ref(),
767 );
768 let wordpress_review_comparison = compare_wordpress_reviews(
769 before.wordpress_review.as_ref(),
770 after.wordpress_review.as_ref(),
771 );
772 let mut document = ComparisonDocument {
773 schema: COMPARISON_DOCUMENT_SCHEMA,
774 scope_assurance: "operator-declared",
775 coverage_equivalence: "not-established",
776 source_authenticity: "not-established-by-parsing",
777 interpretation_limits: [
778 "Imported dispositions and claim bases are untrusted source labels, not endorsed findings.",
779 "Only in before does not mean fixed or resolved.",
780 "Only in after does not establish when an observation first appeared.",
781 "Unchanged means equal compared item projections, not equivalent coverage or security.",
782 ],
783 before: before.metadata,
784 after: after.metadata,
785 supplied_session_comparison,
786 secret_exposure_comparison,
787 tls_observation_comparison,
788 tls_negotiation_review_comparison,
789 websocket_review_comparison,
790 jwt_policy_review_comparison,
791 xml_external_entity_review_comparison,
792 template_evaluation_review_comparison,
793 control_reference_mapping_comparison,
794 recon_snapshot_import_comparison,
795 recon_certspotter_comparison,
796 wordpress_review_comparison,
797 only_in_after: Vec::new(),
798 only_in_before: Vec::new(),
799 changed: Vec::new(),
800 unchanged: Vec::new(),
801 };
802 for (fingerprint, old) in before.items {
803 if !block_item_pairing {
804 if let Some(new) = after.items.remove(&fingerprint) {
805 if old.capability_id != new.capability_id {
806 return Err(ComparisonError::AmbiguousIdentity);
807 }
808 let changed_fields = changed_fields(&old.projection, &new.projection);
809 let item = ComparisonItem {
810 fingerprint,
811 capability_id: old.capability_id,
812 before: Some(old.projection),
813 after: Some(new.projection),
814 changed_fields,
815 };
816 if item.changed_fields.is_empty() {
817 document.unchanged.push(item);
818 } else {
819 document.changed.push(item);
820 }
821 continue;
822 }
823 }
824 document.only_in_before.push(ComparisonItem {
825 fingerprint,
826 capability_id: old.capability_id,
827 before: Some(old.projection),
828 after: None,
829 changed_fields: Vec::new(),
830 });
831 }
832 for (fingerprint, new) in after.items {
833 document.only_in_after.push(ComparisonItem {
834 fingerprint,
835 capability_id: new.capability_id,
836 before: None,
837 after: Some(new.projection),
838 changed_fields: Vec::new(),
839 });
840 }
841 Ok(document)
842}
843
844fn supplied_session_context_allows_item_pairing(
845 before: Option<&ImportedSuppliedSessionAudit>,
846 after: Option<&ImportedSuppliedSessionAudit>,
847) -> bool {
848 match (before, after) {
849 (None, None) => true,
850 (Some(before), Some(after)) => before.context == after.context,
851 (Some(_), None) | (None, Some(_)) => false,
852 }
853}
854
855fn compare_supplied_sessions(
856 before: Option<&ImportedSuppliedSessionAudit>,
857 after: Option<&ImportedSuppliedSessionAudit>,
858) -> Option<SuppliedSessionComparison> {
859 if before.is_none() && after.is_none() {
860 return None;
861 }
862 let contains_oauth_methodology = [before, after].into_iter().flatten().any(|audit| {
863 audit
864 .context
865 .get("schema")
866 .and_then(Value::as_str)
867 .is_some_and(|schema| {
868 matches!(
869 schema,
870 "security.supplied-session-audit/v6" | "security.supplied-session-audit/v7"
871 )
872 })
873 });
874 let contains_oauth_refresh = [before, after].into_iter().flatten().any(|audit| {
875 audit.context.get("schema").and_then(Value::as_str)
876 == Some("security.supplied-session-audit/v6")
877 });
878 let contains_oauth_authorization = [before, after].into_iter().flatten().any(|audit| {
879 audit.context.get("schema").and_then(Value::as_str)
880 == Some("security.supplied-session-audit/v7")
881 });
882 let (status, reason, context_status, facet_status) = match (before, after) {
883 (Some(before), Some(after)) if before.context == after.context => (
884 "compared_within_same_declared_context",
885 None,
886 "same_declared_context",
887 None,
888 ),
889 (Some(before), Some(after)) => {
890 let application_changed = before.context.get("application_reference")
891 != after.context.get("application_reference");
892 let policy_changed =
893 before.context.get("policy_reference") != after.context.get("policy_reference");
894 let principal_alias_changed =
895 before.context.get("principal_alias") != after.context.get("principal_alias");
896 let epoch_changed =
897 before.context.get("session_epoch") != after.context.get("session_epoch");
898 let methodology_changed = [
899 "schema",
900 "oauth_code_policy",
901 "oauth_refresh_policy",
902 "oauth_authorization_policy",
903 ]
904 .into_iter()
905 .any(|field| before.context.get(field) != after.context.get(field));
906 let reason = if application_changed {
907 "application_scope_changed"
908 } else if policy_changed {
909 "session_policy_changed"
910 } else if principal_alias_changed {
911 "operator_declared_principal_changed"
912 } else if contains_oauth_methodology && methodology_changed {
913 "session_methodology_changed"
914 } else if contains_oauth_methodology && epoch_changed {
915 "session_epoch_changed"
916 } else {
917 "declared_session_context_changed"
918 };
919 ("not_compared", Some(reason), reason, Some("not_comparable"))
920 },
921 (Some(_), None) => (
922 "not_compared",
923 Some("after_audit_missing"),
924 "audit_presence_changed",
925 Some("not_comparable"),
926 ),
927 (None, Some(_)) => (
928 "not_compared",
929 Some("before_audit_missing"),
930 "audit_presence_changed",
931 Some("not_comparable"),
932 ),
933 (None, None) => return None,
934 };
935 let health_status = facet_status.unwrap_or_else(|| {
936 paired_status(
937 before.map(|audit| &audit.health_and_coverage),
938 after.map(|audit| &audit.health_and_coverage),
939 )
940 });
941 let accounting_status = facet_status.unwrap_or_else(|| {
942 paired_status(
943 before.map(|audit| &audit.accounting),
944 after.map(|audit| &audit.accounting),
945 )
946 });
947 Some(SuppliedSessionComparison {
948 schema: SUPPLIED_SESSION_COMPARISON_SCHEMA,
949 status,
950 reason,
951 scope_assurance: "operator_declared",
952 context: facet(
953 before.map(|audit| &audit.context),
954 after.map(|audit| &audit.context),
955 context_status,
956 "Equal opaque references represent the same supplied declarations within these files; they do not authenticate a principal, application, policy source, or cross-run identity.",
957 ),
958 health_and_coverage: facet(
959 before.map(|audit| &audit.health_and_coverage),
960 after.map(|audit| &audit.health_and_coverage),
961 health_status,
962 "Checkpoint and resource coverage differences can result from session loss, interruption, or policy context; they are not installation change, vulnerability change, or remediation evidence.",
963 ),
964 accounting: facet(
965 before.map(|audit| &audit.accounting),
966 after.map(|audit| &audit.accounting),
967 accounting_status,
968 "Counts and bytes describe bounded committed activity in each supplied audit; equality does not establish equivalent authenticated coverage.",
969 ),
970 interpretation_limits: [
971 "The principal reference is a generic V1 slot with operator-declared assurance, not authenticated or cross-run principal identity.",
972 "Policy and application digests identify declared values; they do not authenticate the target, source, account, role, or tenant.",
973 "Different contexts are not paired as target changes, and missing or reduced coverage is not remediation.",
974 "Health checkpoints establish only their recorded predicate at bounded moments, not continuous authentication.",
975 if contains_oauth_authorization {
976 "Selected client-generated authorization is qualified only where callback facts were recorded; it does not authenticate a browser or sender, verify OIDC nonce/identity/signatures, or establish remote PKCE enforcement. A reception reservation is not an outgoing token request."
977 } else if contains_oauth_refresh {
978 "Only the recorded explicitly scheduled renewal was assessed; replacement value inequality does not establish server revocation, replay detection, or identity. No exploit, impact validation, or anonymous fallback was performed."
979 } else {
980 "Exploit execution, impact validation, automatic refresh, and anonymous fallback were not performed by this audit."
981 },
982 ],
983 })
984}
985
986fn compare_secret_exposure(
987 before: Option<&ImportedSecretExposureAudit>,
988 after: Option<&ImportedSecretExposureAudit>,
989) -> Option<SecretExposureComparison> {
990 if before.is_none() && after.is_none() {
991 return None;
992 }
993 let (status, reason) = match (before, after) {
994 (Some(_), Some(_)) => ("compared", None),
995 (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
996 (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
997 (None, None) => return None,
998 };
999 Some(SecretExposureComparison {
1000 schema: SECRET_EXPOSURE_COMPARISON_SCHEMA,
1001 status,
1002 reason,
1003 methodology: facet(
1004 before.map(|audit| &audit.methodology),
1005 after.map(|audit| &audit.methodology),
1006 paired_status(
1007 before.map(|audit| &audit.methodology),
1008 after.map(|audit| &audit.methodology),
1009 ),
1010 "Catalogue, policy, representation, context, or inspection-limit changes are methodology changes; they do not establish a target change.",
1011 ),
1012 coverage: facet(
1013 before.map(|audit| &audit.coverage),
1014 after.map(|audit| &audit.coverage),
1015 paired_status(
1016 before.map(|audit| &audit.coverage),
1017 after.map(|audit| &audit.coverage),
1018 ),
1019 "Missing or reduced response, byte, outcome, or observation coverage is not remediation, secret absence, or proof of safety.",
1020 ),
1021 interpretation_limits: [
1022 "The fixed detector catalogue reports bounded secret-shaped response observations; it does not validate ownership, permissions, or provider acceptance.",
1023 "No raw matched value or public secret hash is imported into this display-only comparison.",
1024 "A one-sided observation does not establish when exposure began or ended, and disappearance is not remediation.",
1025 "Source authentication, secret validity, exploit execution, and impact validation are not established by this audit.",
1026 ],
1027 })
1028}
1029
1030fn compare_tls_observation(
1031 before: Option<&ImportedTlsObservationAudit>,
1032 after: Option<&ImportedTlsObservationAudit>,
1033) -> Option<TlsObservationComparison> {
1034 if before.is_none() && after.is_none() {
1035 return None;
1036 }
1037 let (status, reason) = match (before, after) {
1038 (Some(_), Some(_)) => ("compared", None),
1039 (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
1040 (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
1041 (None, None) => return None,
1042 };
1043 Some(TlsObservationComparison {
1044 schema: TLS_OBSERVATION_COMPARISON_SCHEMA,
1045 status,
1046 reason,
1047 methodology: facet(
1048 before.map(|audit| &audit.methodology),
1049 after.map(|audit| &audit.methodology),
1050 paired_status(
1051 before.map(|audit| &audit.methodology),
1052 after.map(|audit| &audit.methodology),
1053 ),
1054 "Policy, target scheme, backend visibility, validation scope, or active-matrix selection changes are methodology changes; they are not vulnerability, hardening, or remediation evidence.",
1055 ),
1056 coverage: facet(
1057 before.map(|audit| &audit.coverage),
1058 after.map(|audit| &audit.coverage),
1059 paired_status(
1060 before.map(|audit| &audit.coverage),
1061 after.map(|audit| &audit.coverage),
1062 ),
1063 "Assessment-request, response, retention, first-observation-time, validity-at-observation, and occurrence-count changes describe observation coverage; fewer observations do not establish a certificate removal or remediation.",
1064 ),
1065 certificate_observations: facet(
1066 before.map(|audit| &audit.certificate_observations),
1067 after.map(|audit| &audit.certificate_observations),
1068 paired_status(
1069 before.map(|audit| &audit.certificate_observations),
1070 after.map(|audit| &audit.certificate_observations),
1071 ),
1072 "Leaf digest, byte length, declared validity bounds, and SAN-count changes are stable certificate-observation differences only; they do not authenticate a source or enumerate TLS support.",
1073 ),
1074 interpretation_limits: [
1075 "The audit describes leaf bytes exposed by successful existing response connections; it does not enumerate all server protocols, cipher suites, certificates, chains, or handshakes.",
1076 "The current backend does not expose negotiated protocol, negotiated cipher suite, ALPN, full chain, connection reuse, session resumption, or resumed-versus-full handshake state.",
1077 "Standard transport validation is scoped to a successful response connection and is not source authentication, a fresh signature check by this report, or worldwide trust.",
1078 "Revocation, AIA, CRL, OCSP, certificate-transparency, and active negotiation retrieval were not performed.",
1079 "A one-sided or changed certificate observation does not establish vulnerability, exploitation, impact, rotation quality, or remediation.",
1080 ],
1081 })
1082}
1083
1084fn compare_tls_negotiation_review(
1085 before: Option<&ImportedTlsNegotiationReviewAudit>,
1086 after: Option<&ImportedTlsNegotiationReviewAudit>,
1087) -> Option<TlsNegotiationReviewComparison> {
1088 if before.is_none() && after.is_none() {
1089 return None;
1090 }
1091 let (status, reason) = match (before, after) {
1092 (Some(_), Some(_)) => ("compared", None),
1093 (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
1094 (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
1095 (None, None) => return None,
1096 };
1097 Some(TlsNegotiationReviewComparison {
1098 schema: TLS_NEGOTIATION_REVIEW_COMPARISON_SCHEMA,
1099 status,
1100 reason,
1101 methodology: facet(
1102 before.map(|audit| &audit.methodology),
1103 after.map(|audit| &audit.methodology),
1104 paired_status(
1105 before.map(|audit| &audit.methodology),
1106 after.map(|audit| &audit.methodology),
1107 ),
1108 "Policy, fixed client offer profiles, backend availability, target binding, validation trust, or connection-method changes are methodology changes; they are not target vulnerability, hardening, or remediation evidence.",
1109 ),
1110 coverage: facet(
1111 before.map(|audit| &audit.coverage),
1112 after.map(|audit| &audit.coverage),
1113 paired_status(
1114 before.map(|audit| &audit.coverage),
1115 after.map(|audit| &audit.coverage),
1116 ),
1117 "Dispatch, terminal outcome, transport-validation result, and accounted-byte changes describe bounded matrix coverage; a failed or missing handshake does not establish that the server rejected an offered profile.",
1118 ),
1119 capability_observations: facet(
1120 before.map(|audit| &audit.capability_observations),
1121 after.map(|audit| &audit.capability_observations),
1122 paired_status(
1123 before.map(|audit| &audit.capability_observations),
1124 after.map(|audit| &audit.capability_observations),
1125 ),
1126 "Negotiated protocol and cipher-suite changes are observations from the exact successful matrix cells only; they do not enumerate every server-supported protocol or cipher suite.",
1127 ),
1128 interpretation_limits: [
1129 "The matrix contains two backend-supported offer profiles and three explicit client-backend-unsupported rows; it is not a complete protocol or cipher-suite audit.",
1130 "A negotiated cell establishes compatibility only for that offered client profile at that connection attempt; it does not authenticate the source or every supported configuration.",
1131 "A failed, cancelled, budget-limited, or unavailable cell is not evidence that the server does not support that protocol or cipher family.",
1132 "No application request, credential, client certificate, early data, retry, redirect, proxy, or alternate-address fallback was used by this audit.",
1133 "Certificate validation is scoped to the configured trust material and exact selected target; revocation, CT, AIA, CRL, and OCSP retrieval were not performed.",
1134 "A one-sided or changed observation does not establish vulnerability, exploitability, impact, remediation, or production readiness.",
1135 ],
1136 })
1137}
1138
1139fn compare_websocket_review(
1140 before: Option<&ImportedWebSocketReviewAudit>,
1141 after: Option<&ImportedWebSocketReviewAudit>,
1142) -> Option<WebSocketReviewComparison> {
1143 if before.is_none() && after.is_none() {
1144 return None;
1145 }
1146 let supplied_session_semantics = before
1147 .into_iter()
1148 .chain(after)
1149 .any(|audit| audit.schema == "security.websocket-review-audit/v2");
1150 let (status, reason) = match (before, after) {
1151 (Some(before), Some(after)) if before.schema != after.schema => {
1152 ("not_comparable", Some("audit_schema_changed"))
1153 },
1154 (Some(before), Some(after))
1155 if before.schema == "security.websocket-review-audit/v2"
1156 && before.supplied_session_identity != after.supplied_session_identity =>
1157 {
1158 ("not_comparable", Some("supplied_session_context_changed"))
1159 },
1160 (Some(_), Some(_)) => ("compared", None),
1161 (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
1162 (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
1163 (None, None) => return None,
1164 };
1165 let facet_status = |before: Option<&Value>, after: Option<&Value>| {
1166 if status == "compared" {
1167 paired_status(before, after)
1168 } else {
1169 "not_comparable"
1170 }
1171 };
1172 Some(WebSocketReviewComparison {
1173 schema: if supplied_session_semantics {
1174 WEBSOCKET_SUPPLIED_SESSION_COMPARISON_SCHEMA
1175 } else {
1176 WEBSOCKET_REVIEW_COMPARISON_SCHEMA
1177 },
1178 status,
1179 reason,
1180 supplied_session_context: supplied_session_semantics.then(|| {
1181 facet(
1182 before.and_then(|audit| audit.supplied_session_context.as_ref()),
1183 after.and_then(|audit| audit.supplied_session_context.as_ref()),
1184 facet_status(
1185 before.and_then(|audit| audit.supplied_session_context.as_ref()),
1186 after.and_then(|audit| audit.supplied_session_context.as_ref()),
1187 ),
1188 "The declared principal/application/session epoch and the startup/terminal health qualification describe observation context only. A changed or missing health reference does not establish logout, authorization loss, vulnerability, or remediation; continuous authentication remains not established.",
1189 )
1190 }),
1191 methodology: facet(
1192 before.map(|audit| &audit.methodology),
1193 after.map(|audit| &audit.methodology),
1194 facet_status(
1195 before.map(|audit| &audit.methodology),
1196 after.map(|audit| &audit.methodology),
1197 ),
1198 "Policy, opaque endpoint reference, origin mode, subprotocol-selection state, observable-limit declarations, or claim-limit changes are methodology differences. Physical continuation-frame counts remain unavailable and no target security change is established.",
1199 ),
1200 coverage: facet(
1201 before.map(|audit| &audit.coverage),
1202 after.map(|audit| &audit.coverage),
1203 facet_status(
1204 before.map(|audit| &audit.coverage),
1205 after.map(|audit| &audit.coverage),
1206 ),
1207 "Request, connection, message, observable control-event, application-byte, raw-socket transport-byte, and parent-accounted byte counts describe only each bounded exchange. Raw transport reads include Upgrade/framing overhead and WSS encrypted TLS records; reduced or missing coverage is not remediation or rejection evidence.",
1208 ),
1209 outcome: facet(
1210 before.map(|audit| &audit.outcome),
1211 after.map(|audit| &audit.outcome),
1212 facet_status(
1213 before.map(|audit| &audit.outcome),
1214 after.map(|audit| &audit.outcome),
1215 ),
1216 if supplied_session_semantics {
1217 "Terminal state and per-message length/status relationships describe protocol completion independently of supplied-session health qualification. A mismatched response exposes no body-derived reference. These values do not establish authenticated identity, authorization, server state, exploitability, vulnerability, or remediation."
1218 } else {
1219 "Terminal state and per-message length/status relationships describe the configured anonymous exchange only. A mismatched response exposes no body-derived reference. These values do not establish authorization, server state, exploitability, vulnerability, or remediation."
1220 },
1221 ),
1222 interpretation_limits: if supplied_session_semantics {
1223 [
1224 "The comparison retains no payload, URL, path, query, subprotocol value, token, raw error, header, cookie, or credential.",
1225 "Only the configured supplied-session RFC 6455 HTTP/1.1 upgrade exchange was observed; HTTP/2 extended CONNECT was not exercised.",
1226 "Opaque policy, endpoint, principal, checkpoint, and message references are not authenticated identities. Expected-response references commit only to operator-supplied expectations; no mismatch-body digest is retained.",
1227 "Protocol completion and supplied-session health qualification are separate. Even healthy startup and terminal checkpoints do not establish continuous authentication or authorization.",
1228 "Transport bytes use raw socket scope, include HTTP Upgrade and WebSocket framing, and count encrypted TLS records for WSS; they are not application-byte measurements.",
1229 "A schema, declared context, or one-sided audit mismatch is not comparable and establishes no vulnerability, impact, rejection, or remediation.",
1230 ]
1231 } else {
1232 [
1233 "The comparison retains no payload, URL, path, query, subprotocol value, token, raw error, header, cookie, or credential.",
1234 "Only the configured anonymous RFC 6455 HTTP/1.1 upgrade exchange was observed; HTTP/2 extended CONNECT was not exercised.",
1235 "Opaque policy, endpoint, and message references are not authenticated target identities. Expected-response references commit only to operator-supplied expectations; no mismatch-body digest is retained.",
1236 "Transport bytes use raw socket scope, include HTTP Upgrade and WebSocket framing, and count encrypted TLS records for WSS; they are not application-byte measurements.",
1237 "A one-sided audit is not comparable and does not establish introduction, disappearance, rejection, or remediation.",
1238 "No authorization, server-state, vulnerability, exploitability, impact, or source-authentication conclusion is established by this audit.",
1239 ]
1240 },
1241 })
1242}
1243
1244fn compare_jwt_policy_review(
1245 before: Option<&ImportedJwtPolicyReviewAudit>,
1246 after: Option<&ImportedJwtPolicyReviewAudit>,
1247) -> Option<JwtPolicyReviewComparison> {
1248 if before.is_none() && after.is_none() {
1249 return None;
1250 }
1251 let (status, reason) = match (before, after) {
1252 (Some(before), Some(after)) if before.schema == after.schema => ("compared", None),
1253 (Some(_), Some(_)) => ("not_comparable", Some("audit_schema_changed")),
1254 (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
1255 (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
1256 (None, None) => return None,
1257 };
1258 let target_selected = before
1259 .into_iter()
1260 .chain(after)
1261 .any(|audit| audit.schema == "security.jwt-policy-review-audit/v2");
1262 let methodology_status = if status == "compared" {
1263 paired_status(
1264 before.map(|audit| &audit.methodology),
1265 after.map(|audit| &audit.methodology),
1266 )
1267 } else {
1268 "not_comparable"
1269 };
1270 let coverage_status = if status == "compared" {
1271 paired_status(
1272 before.map(|audit| &audit.coverage),
1273 after.map(|audit| &audit.coverage),
1274 )
1275 } else {
1276 "not_comparable"
1277 };
1278 let outcome_status = if status == "compared" {
1279 paired_status(
1280 before.map(|audit| &audit.outcome),
1281 after.map(|audit| &audit.outcome),
1282 )
1283 } else {
1284 "not_comparable"
1285 };
1286 Some(JwtPolicyReviewComparison {
1287 schema: JWT_POLICY_REVIEW_COMPARISON_SCHEMA,
1288 status,
1289 reason,
1290 methodology: facet(
1291 before.map(|audit| &audit.methodology),
1292 after.map(|audit| &audit.methodology),
1293 methodology_status,
1294 "Declared policy reference/revision, expected-claim selection, clock-skew allowance, or exact public-key-byte identity changes can change interpretation without any target behavior changing.",
1295 ),
1296 coverage: facet(
1297 before.map(|audit| &audit.coverage),
1298 after.map(|audit| &audit.coverage),
1299 coverage_status,
1300 if target_selected {
1301 "The local evaluation instant plus bounded target dispatch, commit, retained-byte, and exact transport-byte accounting describe coverage. Charged chunk overrun can exceed retained bytes; missing or changed coverage is not a target fix, regression, or authorization result."
1302 } else {
1303 "The local evaluation instant and explicit absence of external activity describe bounded review coverage; they do not establish continuous clock accuracy or target acceptance."
1304 },
1305 ),
1306 outcome: facet(
1307 before.map(|audit| &audit.outcome),
1308 after.map(|audit| &audit.outcome),
1309 outcome_status,
1310 if target_selected {
1311 "Local parsing, policy consistency, signature verification, target marker controls, and authorization effect are distinct. A changed marker relationship remains review-level and does not establish a general bypass or impact."
1312 } else {
1313 "Parsing, local policy consistency, and local signature status are separate outcomes. A changed local outcome is not evidence that a target accepted a token or that authorization changed."
1314 },
1315 ),
1316 interpretation_limits: if target_selected {
1317 [
1318 "The compact token, claim names and values, signature bytes, selected marker name, raw resource URL, and verification-key material are not imported into this display-only comparison.",
1319 "The operator policy revision must change when private policy semantics change; public digests and opaque references authenticate neither key, target, policy, nor source.",
1320 "Local parsing does not authenticate claims; local signature verification applies only to the explicitly supplied local public key.",
1321 "A target marker relationship applies only to the selected resource and matched controls; it is not a confirmed bypass, authorization effect, exploitability, or impact.",
1322 "A v1 local-only audit and a v2 target-selected audit are not comparable; the target selection is a methodology and coverage change.",
1323 "A one-sided or changed audit does not establish vulnerability, remediation, or source authenticity.",
1324 ]
1325 } else {
1326 [
1327 "The compact token, claim names and values, signature bytes, and verification-key material are not imported into this display-only comparison.",
1328 "The operator policy revision must change when private policy semantics change; the public-key SHA-256 identifies bytes but authenticates neither key nor source.",
1329 "Local parsing does not authenticate claims; local signature verification applies only to the explicitly supplied local public key.",
1330 "Policy consistency is not target acceptance, authorization, exploitability, or impact.",
1331 "The evaluator performed no target request, token forwarding, or remote-key retrieval.",
1332 "A one-sided or changed audit does not establish vulnerability, remediation, or source authenticity.",
1333 ]
1334 },
1335 target_selected,
1336 })
1337}
1338
1339fn compare_xml_external_entity_review(
1340 before: Option<&ImportedXmlExternalEntityReviewAudit>,
1341 after: Option<&ImportedXmlExternalEntityReviewAudit>,
1342) -> Option<XmlExternalEntityReviewComparison> {
1343 if before.is_none() && after.is_none() {
1344 return None;
1345 }
1346 let (status, reason) = match (before, after) {
1347 (Some(_), Some(_)) => ("compared", None),
1348 (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
1349 (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
1350 (None, None) => return None,
1351 };
1352 let facet_status = |before: Option<&Value>, after: Option<&Value>| {
1353 if status == "compared" {
1354 paired_status(before, after)
1355 } else {
1356 "not_comparable"
1357 }
1358 };
1359 Some(XmlExternalEntityReviewComparison {
1360 schema: XML_EXTERNAL_ENTITY_REVIEW_COMPARISON_SCHEMA,
1361 status,
1362 reason,
1363 methodology: facet(
1364 before.map(|audit| &audit.methodology),
1365 after.map(|audit| &audit.methodology),
1366 facet_status(
1367 before.map(|audit| &audit.methodology),
1368 after.map(|audit| &audit.methodology),
1369 ),
1370 "The v1 policy identity is a composite binding over private scope and schedule inputs. Policy identity, execution mode, exact method, media type, control model, algorithm, or claim-limit changes are reported as methodology changes; they do not establish a target security change.",
1371 ),
1372 coverage: facet(
1373 before.map(|audit| &audit.coverage),
1374 after.map(|audit| &audit.coverage),
1375 facet_status(
1376 before.map(|audit| &audit.coverage),
1377 after.map(|audit| &audit.coverage),
1378 ),
1379 "Counts, bytes, completeness, callback coverage, distinct-event accounting, cleanup verification, and item projection describe bounded review coverage; reduced or missing coverage is not remediation or parser hardening.",
1380 ),
1381 outcome: facet(
1382 before.map(|audit| &audit.outcome),
1383 after.map(|audit| &audit.outcome),
1384 facet_status(
1385 before.map(|audit| &audit.outcome),
1386 after.map(|audit| &audit.outcome),
1387 ),
1388 "The terminal review outcome is separate from methodology and coverage. A changed outcome does not identify a parser, confirm XXE, establish file access, data exfiltration, internal reachability, impact, or remediation.",
1389 ),
1390 interpretation_limits: [
1391 "The comparison retains no endpoint URL or path, provider origin, callback URL or identifier, XML body, system literal, token, header, cookie, response body, or raw error.",
1392 "The pseudonymous policy digest binds private policy inputs but does not authenticate a source, target, parser, provider, or cross-run deployment identity.",
1393 "A positive outcome means only two distinct correlated external-entity callback events followed the candidate and replay dispatches under a clean no-control-callback model.",
1394 "File read, data exfiltration, internal-network access, parser identity, semantic response effect, and impact validation were not performed or established.",
1395 "A one-sided or incomplete audit is not evidence that behavior appeared, disappeared, was blocked, or was remediated.",
1396 "Equal saved values do not establish equivalent deployment, authorization, provider health, review coverage, or target security.",
1397 ],
1398 })
1399}
1400
1401fn compare_template_evaluation_review(
1402 before: Option<&ImportedTemplateEvaluationReviewAudit>,
1403 after: Option<&ImportedTemplateEvaluationReviewAudit>,
1404) -> Option<TemplateEvaluationReviewComparison> {
1405 if before.is_none() && after.is_none() {
1406 return None;
1407 }
1408 let (status, reason) = match (before, after) {
1409 (Some(_), Some(_)) => ("compared", None),
1410 (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
1411 (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
1412 (None, None) => return None,
1413 };
1414 let facet_status = |before: Option<&Value>, after: Option<&Value>| {
1415 if status == "compared" {
1416 paired_status(before, after)
1417 } else {
1418 "not_comparable"
1419 }
1420 };
1421 Some(TemplateEvaluationReviewComparison {
1422 schema: TEMPLATE_EVALUATION_REVIEW_COMPARISON_SCHEMA,
1423 status,
1424 reason,
1425 methodology: facet(
1426 before.map(|audit| &audit.methodology),
1427 after.map(|audit| &audit.methodology),
1428 facet_status(
1429 before.map(|audit| &audit.methodology),
1430 after.map(|audit| &audit.methodology),
1431 ),
1432 "Policy, harmless expression family, engine-identity limitation, operation declarations, or claim-limit changes are methodology changes; they do not establish a target security change or template-engine identity.",
1433 ),
1434 coverage: facet(
1435 before.map(|audit| &audit.coverage),
1436 after.map(|audit| &audit.coverage),
1437 facet_status(
1438 before.map(|audit| &audit.coverage),
1439 after.map(|audit| &audit.coverage),
1440 ),
1441 "Selected cases and bounded request/response commitment counts describe review coverage. Reduced, missing, or incomplete coverage is not remediation or parser hardening.",
1442 ),
1443 outcome: facet(
1444 before.map(|audit| &audit.outcome),
1445 after.map(|audit| &audit.outcome),
1446 facet_status(
1447 before.map(|audit| &audit.outcome),
1448 after.map(|audit| &audit.outcome),
1449 ),
1450 "The classified benign-expression outcome and projected-item count are separate from methodology and coverage. A changed outcome does not establish a particular engine, operating-system execution, file access, outbound interaction, impact, vulnerability, or remediation.",
1451 ),
1452 interpretation_limits: [
1453 "The comparison retains no expression, query name or value, URL, response body, template source, engine banner, token, credential, file path, command, or raw error.",
1454 "Candidate-specific evaluation means only that two bounded benign candidate/replay cases differed from their controls under the declared family.",
1455 "The listed family is compatible with Jinja-like semantics but does not authenticate or uniquely identify a template engine.",
1456 "Operating-system execution, file access, outbound interaction, and impact validation were not performed.",
1457 "A one-sided or incomplete audit is not evidence that behavior appeared, disappeared, was blocked, or was remediated.",
1458 "Equal saved values do not establish equivalent deployment, authorization, response coverage, source authenticity, or target security.",
1459 ],
1460 })
1461}
1462
1463fn compare_control_reference_mapping(
1464 before: Option<&ImportedControlReferenceMappingAudit>,
1465 after: Option<&ImportedControlReferenceMappingAudit>,
1466) -> Option<ControlReferenceMappingComparison> {
1467 if before.is_none() && after.is_none() {
1468 return None;
1469 }
1470 let (status, reason) = match (before, after) {
1471 (Some(_), Some(_)) => ("compared", None),
1472 (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
1473 (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
1474 (None, None) => return None,
1475 };
1476 Some(ControlReferenceMappingComparison {
1477 schema: CONTROL_REFERENCE_MAPPING_COMPARISON_SCHEMA,
1478 status,
1479 reason,
1480 methodology: facet(
1481 before.map(|audit| &audit.methodology),
1482 after.map(|audit| &audit.methodology),
1483 paired_status(
1484 before.map(|audit| &audit.methodology),
1485 after.map(|audit| &audit.methodology),
1486 ),
1487 "Policy, catalogue revision, reviewed-source revision, rights posture, external-activity declaration, or claim-limit changes are methodology changes; they do not establish a target change, vulnerability, remediation, or control result.",
1488 ),
1489 coverage: facet(
1490 before.map(|audit| &audit.coverage),
1491 after.map(|audit| &audit.coverage),
1492 paired_status(
1493 before.map(|audit| &audit.coverage),
1494 after.map(|audit| &audit.coverage),
1495 ),
1496 "Mapped, unmapped, relationship, reference-link, and omission counts describe bounded mapping coverage; fewer references do not establish remediation, control fulfilment, or safety.",
1497 ),
1498 reference_set: facet(
1499 before.map(|audit| &audit.reference_set),
1500 after.map(|audit| &audit.reference_set),
1501 paired_status(
1502 before.map(|audit| &audit.reference_set),
1503 after.map(|audit| &audit.reference_set),
1504 ),
1505 "Catalogue/source revisions and evidence-to-reference relationships are reference-set differences. They are not target observations, introduced vulnerabilities, verified remediation, compliance, or certification.",
1506 ),
1507 interpretation_limits: [
1508 "The mapping is an offline, versioned reference projection over already composed assessment items; it performs no target, provider, or source-retrieval request.",
1509 "Framework identifiers, short titles where rights permit, and original mapping rationales provide technical review context only; they are not copied standards text or a control assessment.",
1510 "A mapping relationship does not establish organizational applicability, control fulfilment, compliance, certification, legal noncompliance, or source authenticity.",
1511 "An unmapped item means only that no exact rule in this finite catalogue matched; absence of a finding or mapping establishes no control outcome.",
1512 "One-sided or changed mapping audits can result from catalogue, source, methodology, coverage, or source-item changes and never by themselves prove target change or remediation.",
1513 ],
1514 })
1515}
1516
1517fn compare_recon_snapshot_import(
1518 before: Option<&ImportedReconSnapshotAudit>,
1519 after: Option<&ImportedReconSnapshotAudit>,
1520) -> Option<ReconSnapshotImportComparison> {
1521 if before.is_none() && after.is_none() {
1522 return None;
1523 }
1524 let (status, reason) = match (before, after) {
1525 (Some(_), Some(_)) => ("compared", None),
1526 (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
1527 (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
1528 (None, None) => return None,
1529 };
1530 Some(ReconSnapshotImportComparison {
1531 schema: RECON_SNAPSHOT_IMPORT_COMPARISON_SCHEMA,
1532 status,
1533 reason,
1534 methodology: facet(
1535 before.map(|audit| &audit.methodology),
1536 after.map(|audit| &audit.methodology),
1537 paired_status(
1538 before.map(|audit| &audit.methodology),
1539 after.map(|audit| &audit.methodology),
1540 ),
1541 "Parser policy, external-activity declaration, or claim-limit changes are methodology changes; exact-input identity is compared with provenance and sources. None establishes a target, vulnerability, or remediation change.",
1542 ),
1543 provenance_and_sources: facet(
1544 before.map(|audit| &audit.provenance_and_sources),
1545 after.map(|audit| &audit.provenance_and_sources),
1546 paired_status(
1547 before.map(|audit| &audit.provenance_and_sources),
1548 after.map(|audit| &audit.provenance_and_sources),
1549 ),
1550 "Snapshot identity and source declarations are operator-supplied provenance. Differences do not authenticate either source, establish freshness, or authorize scanning a named asset.",
1551 ),
1552 coverage_and_accounting: facet(
1553 before.map(|audit| &audit.coverage_and_accounting),
1554 after.map(|audit| &audit.coverage_and_accounting),
1555 paired_status(
1556 before.map(|audit| &audit.coverage_and_accounting),
1557 after.map(|audit| &audit.coverage_and_accounting),
1558 ),
1559 "Source, record, association, and prepared-index counts describe the bounded imported snapshot only. Equal counts do not prove equal content or complete provider coverage.",
1560 ),
1561 hypotheses: facet(
1562 before.map(|audit| &audit.hypotheses),
1563 after.map(|audit| &audit.hypotheses),
1564 paired_status(
1565 before.map(|audit| &audit.hypotheses),
1566 after.map(|audit| &audit.hypotheses),
1567 ),
1568 "Added, removed, or changed imported hypotheses are source-data differences, not target changes, newly discovered vulnerabilities, verified remediation, ownership, or reachability results.",
1569 ),
1570 interpretation_limits: [
1571 "The imported snapshot is inert local reference material and performed no target request, provider request, archive processing, or decompression.",
1572 "Names, addresses, service-product hints, and reputation labels remain source-qualified hypotheses and never become assessment items or broker authority.",
1573 "Exact-input SHA-256 identifies supplied bytes; it is not a signature, source authentication, rights verification, freshness proof, or completeness proof.",
1574 "A certificate name or historical DNS address does not establish current ownership, authorization, reachability, or tenant association.",
1575 "One-sided audit presence is not comparable and does not establish asset appearance, disappearance, vulnerability, or remediation.",
1576 "Hypothesis differences must be reviewed with their declared sources and limitations; they do not create a scan permit.",
1577 ],
1578 })
1579}
1580
1581fn compare_recon_certspotter(
1582 before: Option<&ImportedReconCertSpotterAudit>,
1583 after: Option<&ImportedReconCertSpotterAudit>,
1584) -> Option<ReconCertSpotterComparison> {
1585 if before.is_none() && after.is_none() {
1586 return None;
1587 }
1588 let (status, reason) = match (before, after) {
1589 (Some(_), Some(_)) => ("compared", None),
1590 (Some(_), None) => ("not_comparable", Some("after_audit_missing")),
1591 (None, Some(_)) => ("not_comparable", Some("before_audit_missing")),
1592 (None, None) => return None,
1593 };
1594 Some(ReconCertSpotterComparison {
1595 schema: RECON_CERTSPOTTER_COMPARISON_SCHEMA,
1596 status,
1597 reason,
1598 provider: facet(
1599 before.map(|audit| &audit.provider),
1600 after.map(|audit| &audit.provider),
1601 paired_status(
1602 before.map(|audit| &audit.provider),
1603 after.map(|audit| &audit.provider),
1604 ),
1605 "Provider identity, effective origin, execution mode, query scope, or opaque policy-reference changes are provider-context differences. They do not authenticate a source, establish ownership, or authorize scanning.",
1606 ),
1607 methodology: facet(
1608 before.map(|audit| &audit.methodology),
1609 after.map(|audit| &audit.methodology),
1610 paired_status(
1611 before.map(|audit| &audit.methodology),
1612 after.map(|audit| &audit.methodology),
1613 ),
1614 "Endpoint, pagination, compiled-bound, no-retry, no-polling, credential, or fixed claim-limit changes are methodology differences, not target changes or security findings.",
1615 ),
1616 coverage: facet(
1617 before.map(|audit| &audit.coverage),
1618 after.map(|audit| &audit.coverage),
1619 paired_status(
1620 before.map(|audit| &audit.coverage),
1621 after.map(|audit| &audit.coverage),
1622 ),
1623 "Terminal state, completeness, cursor, request, byte, issuance, retention, and omission counters describe only each bounded provider collection. Equal counters do not prove equal content, currentness, or complete CT coverage.",
1624 ),
1625 hypotheses: facet(
1626 before.map(|audit| &audit.hypotheses),
1627 after.map(|audit| &audit.hypotheses),
1628 paired_status(
1629 before.map(|audit| &audit.hypotheses),
1630 after.map(|audit| &audit.hypotheses),
1631 ),
1632 "Added, removed, or changed source-qualified names are provider-result differences. They are not findings and do not establish ownership, authentication, currentness, reachability, or scan authority.",
1633 ),
1634 interpretation_limits: [
1635 "The comparison parses saved reports only; it performs no target or provider request and does not authenticate either report or provider response.",
1636 "Cert Spotter names remain source-qualified hypotheses and never become assessment items, findings, broker permits, or target authority.",
1637 "Provider exhaustion means only that an empty page was observed at the bounded cursor; it is not global CT completeness or currentness proof.",
1638 "A one-sided audit or hypothesis does not establish asset appearance, disappearance, ownership, reachability, vulnerability, or remediation.",
1639 "Transport success and TLS validation do not authenticate the provider as the asset owner or validate the returned certificate names.",
1640 "Every compared hypothesis retains the fixed no-authority, no-ownership, no-currentness, and no-source-authentication claim limits.",
1641 ],
1642 })
1643}
1644
1645fn compare_wordpress_reviews(
1646 before: Option<&ImportedWordPressAudit>,
1647 after: Option<&ImportedWordPressAudit>,
1648) -> Option<WordPressReviewComparison> {
1649 if before.is_none() && after.is_none() {
1650 return None;
1651 }
1652 let (status, reason, compare_entities) = wordpress_comparison_scope(before, after);
1653 let coverage = facet(
1654 before.map(|audit| &audit.coverage),
1655 after.map(|audit| &audit.coverage),
1656 match (before, after) {
1657 (Some(left), Some(right)) if left.coverage != right.coverage => "changed",
1658 (Some(left), Some(right))
1659 if left.inventory_coverage_recorded && right.inventory_coverage_recorded =>
1660 {
1661 "same_declared_projection"
1662 },
1663 (Some(_), Some(_)) => "not_established",
1664 _ => "coverage_changed",
1665 },
1666 "Declared inventory and source-selection coverage only; equal values do not establish equivalent security coverage.",
1667 );
1668 let methodology = facet(
1669 before.map(|audit| &audit.methodology),
1670 after.map(|audit| &audit.methodology),
1671 paired_status(before.map(|audit| &audit.methodology), after.map(|audit| &audit.methodology)),
1672 "A methodology change can change interpretation without an installation or advisory changing.",
1673 );
1674 let provenance_status = wordpress_provenance_status(before, after);
1675 let provenance = facet(
1676 before.map(|audit| &audit.provenance),
1677 after.map(|audit| &audit.provenance),
1678 provenance_status,
1679 "Digests identify supplied bytes; they do not authenticate a source or prove collection completeness.",
1680 );
1681 let discovery_source_content = if before
1682 .is_some_and(|audit| audit.discovery_source_content.is_some())
1683 || after.is_some_and(|audit| audit.discovery_source_content.is_some())
1684 {
1685 Some(facet(
1686 before.and_then(|audit| audit.discovery_source_content.as_ref()),
1687 after.and_then(|audit| audit.discovery_source_content.as_ref()),
1688 paired_status(
1689 before.and_then(|audit| audit.discovery_source_content.as_ref()),
1690 after.and_then(|audit| audit.discovery_source_content.as_ref()),
1691 ),
1692 "REST namespace declarations and other non-component metadata are source content; changes do not by themselves establish changed security coverage.",
1693 ))
1694 } else {
1695 None
1696 };
1697 let asset_fingerprints = compare_wordpress_asset_fingerprints(
1698 before.and_then(|audit| audit.asset_fingerprints.as_ref()),
1699 after.and_then(|audit| audit.asset_fingerprints.as_ref()),
1700 status,
1701 reason,
1702 compare_entities,
1703 );
1704 let (components, advisories) =
1705 if let (true, Some(before), Some(after)) = (compare_entities, before, after) {
1706 (
1707 compare_wordpress_entities(&before.components, &after.components),
1708 compare_wordpress_entities(&before.advisories, &after.advisories),
1709 )
1710 } else {
1711 (
1712 WordPressEntityChanges::default(),
1713 WordPressEntityChanges::default(),
1714 )
1715 };
1716 Some(WordPressReviewComparison {
1717 schema: if before.is_some_and(|audit| audit.supplied_session_context.is_some())
1718 || after.is_some_and(|audit| audit.supplied_session_context.is_some())
1719 {
1720 WORDPRESS_COMPARISON_SCHEMA_V4
1721 } else if asset_fingerprints.is_some() {
1722 WORDPRESS_COMPARISON_SCHEMA_V3
1723 } else if discovery_source_content.is_some() {
1724 WORDPRESS_COMPARISON_SCHEMA_V2
1725 } else {
1726 WORDPRESS_COMPARISON_SCHEMA_V1
1727 },
1728 status,
1729 reason,
1730 scope_assurance: "operator-declared",
1731 coverage,
1732 methodology,
1733 provenance,
1734 discovery_source_content,
1735 asset_fingerprints,
1736 components,
1737 advisories,
1738 interpretation_limits: [
1739 "WordPress differences compare validated display-only audit projections; they do not rerun an assessment.",
1740 "One-sided components or advisories do not establish installation, removal, discovery time, or remediation.",
1741 "Simultaneous inventory, source, methodology, and applicability changes are listed without assigning cause.",
1742 "Applicability remains an imported declared-data result, not proof of exploitability or safety.",
1743 "A missing audit or reduced declaration changes coverage; it does not mean all WordPress issues disappeared.",
1744 "Public metadata content is compared separately from collection outcomes; neither dimension authenticates installation state.",
1745 ],
1746 })
1747}
1748
1749fn compare_wordpress_asset_fingerprints(
1750 before: Option<&ImportedWordPressAssetFingerprintAudit>,
1751 after: Option<&ImportedWordPressAssetFingerprintAudit>,
1752 scope_status: &'static str,
1753 scope_reason: Option<&'static str>,
1754 compare_entities: bool,
1755) -> Option<WordPressAssetFingerprintComparison> {
1756 let (status, reason) = match (before, after) {
1757 (Some(_), Some(_)) if compare_entities => ("compared", None),
1758 (Some(_), Some(_)) => (scope_status, scope_reason),
1759 (Some(_), None) => ("not_compared", Some("after_fingerprint_audit_missing")),
1760 (None, Some(_)) => ("not_compared", Some("before_fingerprint_audit_missing")),
1761 (None, None) => return None,
1762 };
1763 let methodology = facet(
1764 before.map(|audit| &audit.methodology),
1765 after.map(|audit| &audit.methodology),
1766 paired_status(
1767 before.map(|audit| &audit.methodology),
1768 after.map(|audit| &audit.methodology),
1769 ),
1770 "Fingerprint methodology changes can change candidate sets without target resource bytes changing.",
1771 );
1772 let catalogue = facet(
1773 before.map(|audit| &audit.catalogue),
1774 after.map(|audit| &audit.catalogue),
1775 wordpress_fingerprint_catalogue_status(
1776 before.map(|audit| &audit.catalogue),
1777 after.map(|audit| &audit.catalogue),
1778 ),
1779 "A changed finite reference set can change candidates without an installed component or target resource changing.",
1780 );
1781 let coverage = facet(
1782 before.map(|audit| &audit.coverage),
1783 after.map(|audit| &audit.coverage),
1784 paired_status(
1785 before.map(|audit| &audit.coverage),
1786 after.map(|audit| &audit.coverage),
1787 ),
1788 "Unavailable or omitted resource coverage is not a component removal, clean result, or remediation.",
1789 );
1790 let (resources, components) = match (before, after, compare_entities) {
1791 (Some(before), Some(after), true) => (
1792 compare_wordpress_entities(&before.resources, &after.resources),
1793 compare_wordpress_entities(&before.components, &after.components),
1794 ),
1795 _ => (
1796 WordPressEntityChanges::default(),
1797 WordPressEntityChanges::default(),
1798 ),
1799 };
1800 Some(WordPressAssetFingerprintComparison {
1801 status,
1802 reason,
1803 methodology,
1804 catalogue,
1805 coverage,
1806 resources,
1807 components,
1808 interpretation_limits: [
1809 "Compared SHA-256 values and byte lengths describe complete admitted response bytes, not whole-package verification.",
1810 "Catalogue candidates are conditional on a finite operator-supplied reference set and are not installed-version evidence.",
1811 "Identical bytes can occur in unlisted releases, copied assets, caches, or custom builds.",
1812 "A one-sided or failed resource observation does not establish component installation, removal, or remediation.",
1813 "Source labels and digests identify supplied data; they do not authenticate its publisher or completeness.",
1814 ],
1815 })
1816}
1817
1818fn wordpress_fingerprint_catalogue_status(
1819 before: Option<&Value>,
1820 after: Option<&Value>,
1821) -> &'static str {
1822 let (Some(before), Some(after)) = (before, after) else {
1823 return "not_comparable";
1824 };
1825 if before == after {
1826 return "unchanged";
1827 }
1828 let (Some(before_fields), Some(after_fields)) = (before.as_object(), after.as_object()) else {
1829 return "changed";
1830 };
1831 if before_fields.get("semantic_sha256") == after_fields.get("semantic_sha256")
1832 && fingerprint_catalogue_without_byte_identity(before)
1833 == fingerprint_catalogue_without_byte_identity(after)
1834 {
1835 "input_bytes_changed_without_reference_semantic_change"
1836 } else {
1837 "changed"
1838 }
1839}
1840
1841fn fingerprint_catalogue_without_byte_identity(value: &Value) -> Value {
1842 let mut value = value.clone();
1843 if let Some(fields) = value.as_object_mut() {
1844 fields.remove("byte_length");
1845 fields.remove("sha256");
1846 }
1847 value
1848}
1849
1850fn wordpress_comparison_scope(
1851 before: Option<&ImportedWordPressAudit>,
1852 after: Option<&ImportedWordPressAudit>,
1853) -> (&'static str, Option<&'static str>, bool) {
1854 let (Some(before), Some(after)) = (before, after) else {
1855 return if before.is_some() {
1856 ("not_compared", Some("after_audit_missing"), false)
1857 } else {
1858 ("not_compared", Some("before_audit_missing"), false)
1859 };
1860 };
1861
1862 let application_scope = match (
1863 before.application_reference.as_deref(),
1864 after.application_reference.as_deref(),
1865 ) {
1866 (Some(before), Some(after)) if before == after => None,
1867 (Some(_), Some(_)) => Some("application_scope_mismatch"),
1868 (None, None) => None,
1869 _ => Some("application_scope_unknown"),
1870 };
1871 if let Some(reason) = application_scope {
1872 return ("not_compared", Some(reason), false);
1873 }
1874 match (
1875 before.supplied_session_context.as_ref(),
1876 after.supplied_session_context.as_ref(),
1877 ) {
1878 (Some(before), Some(after)) if before == after => ("compared", None, true),
1879 (Some(_), Some(_)) => (
1880 "not_compared",
1881 Some("supplied_session_context_mismatch"),
1882 false,
1883 ),
1884 (None, None) => ("compared", None, true),
1885 _ => (
1886 "not_compared",
1887 Some("supplied_session_context_unknown"),
1888 false,
1889 ),
1890 }
1891}
1892
1893fn paired_status(before: Option<&Value>, after: Option<&Value>) -> &'static str {
1894 match (before, after) {
1895 (Some(before), Some(after)) if before == after => "unchanged",
1896 (Some(_), Some(_)) => "changed",
1897 _ => "not_comparable",
1898 }
1899}
1900
1901fn facet(
1902 before: Option<&Value>,
1903 after: Option<&Value>,
1904 status: &str,
1905 note: &'static str,
1906) -> WordPressFacetComparison {
1907 WordPressFacetComparison {
1908 status: status.to_owned(),
1909 changed_fields: changed_value_fields(before, after),
1910 before: before.cloned(),
1911 after: after.cloned(),
1912 note,
1913 }
1914}
1915
1916fn changed_value_fields(before: Option<&Value>, after: Option<&Value>) -> Vec<String> {
1917 let Some(before) = before.and_then(Value::as_object) else {
1918 return if before == after {
1919 Vec::new()
1920 } else {
1921 vec!["audit_presence".to_owned()]
1922 };
1923 };
1924 let Some(after) = after.and_then(Value::as_object) else {
1925 return vec!["audit_presence".to_owned()];
1926 };
1927 before
1928 .keys()
1929 .chain(after.keys())
1930 .collect::<BTreeSet<_>>()
1931 .into_iter()
1932 .filter(|key| before.get(*key) != after.get(*key))
1933 .cloned()
1934 .collect()
1935}
1936
1937fn wordpress_provenance_status(
1938 before: Option<&ImportedWordPressAudit>,
1939 after: Option<&ImportedWordPressAudit>,
1940) -> &'static str {
1941 let (Some(before), Some(after)) = (before, after) else {
1942 return "not_comparable";
1943 };
1944 if before.provenance == after.provenance {
1945 return "unchanged";
1946 }
1947 let before_input = before
1948 .provenance
1949 .get("external_input")
1950 .and_then(Value::as_object);
1951 let after_input = after
1952 .provenance
1953 .get("external_input")
1954 .and_then(Value::as_object);
1955 if let (Some(before_input), Some(after_input)) = (before_input, after_input) {
1956 let same_semantic_digest =
1957 before_input.get("semantic_sha256") == after_input.get("semantic_sha256");
1958 if same_semantic_digest
1959 && before.components == after.components
1960 && before.advisories == after.advisories
1961 && provenance_without_external_byte_identity(&before.provenance)
1962 == provenance_without_external_byte_identity(&after.provenance)
1963 {
1964 return "input_bytes_changed_without_selected_semantic_change";
1965 }
1966 if !same_semantic_digest
1967 && before.components == after.components
1968 && before.advisories == after.advisories
1969 {
1970 return "source_snapshot_semantics_changed_without_selected_record_change";
1971 }
1972 }
1973 if before.components == after.components && before.advisories == after.advisories {
1974 return "input_provenance_changed_semantic_significance_not_established";
1975 }
1976 "changed"
1977}
1978
1979fn provenance_without_external_byte_identity(value: &Value) -> Value {
1980 let mut value = value.clone();
1981 if let Some(input) = value
1982 .get_mut("external_input")
1983 .and_then(Value::as_object_mut)
1984 {
1985 input.remove("byte_length");
1986 input.remove("sha256");
1987 }
1988 value
1989}
1990
1991fn compare_wordpress_entities<K>(
1992 before: &BTreeMap<K, BTreeMap<String, Value>>,
1993 after: &BTreeMap<K, BTreeMap<String, Value>>,
1994) -> WordPressEntityChanges<K>
1995where
1996 K: Clone + Ord,
1997{
1998 let mut result = WordPressEntityChanges::default();
1999 let mut remaining = after.clone();
2000 for (key, before_content) in before {
2001 if let Some(after_content) = remaining.remove(key) {
2002 let changed_dimensions = before_content
2003 .keys()
2004 .chain(after_content.keys())
2005 .collect::<BTreeSet<_>>()
2006 .into_iter()
2007 .filter(|dimension| before_content.get(*dimension) != after_content.get(*dimension))
2008 .cloned()
2009 .collect::<Vec<_>>();
2010 if changed_dimensions.is_empty() {
2011 result.paired_unchanged_count += 1;
2012 } else {
2013 result.paired_changed.push(WordPressEntityChange {
2014 key: key.clone(),
2015 changed_dimensions,
2016 before: before_content.clone(),
2017 after: after_content,
2018 });
2019 }
2020 } else {
2021 result.only_in_before.push(WordPressOneSidedEntity {
2022 key: key.clone(),
2023 content: before_content.clone(),
2024 interpretation:
2025 "present_only_in_the_supplied_before_audit_not_verified_remediation",
2026 });
2027 }
2028 }
2029 for (key, content) in remaining {
2030 result.only_in_after.push(WordPressOneSidedEntity {
2031 key,
2032 content,
2033 interpretation: "present_only_in_the_supplied_after_audit_not_verified_newness",
2034 });
2035 }
2036 result
2037}
2038
2039fn changed_fields(before: &ItemProjection, after: &ItemProjection) -> Vec<&'static str> {
2040 [
2041 ("title", before.title != after.title),
2042 ("category", before.category != after.category),
2043 ("disposition", before.disposition != after.disposition),
2044 ("claim_basis", before.claim_basis != after.claim_basis),
2045 ("severity", before.severity != after.severity),
2046 ("cwe", before.cwe != after.cwe),
2047 (
2048 "confidence_ppm",
2049 before.confidence_ppm != after.confidence_ppm,
2050 ),
2051 (
2052 "redacted_summary",
2053 before.redacted_summary != after.redacted_summary,
2054 ),
2055 ("remediation", before.remediation != after.remediation),
2056 ("evidence", before.evidence != after.evidence),
2057 ]
2058 .into_iter()
2059 .filter_map(|(name, changed)| changed.then_some(name))
2060 .collect()
2061}
2062
2063fn render(
2064 document: &ComparisonDocument,
2065 format: ComparisonFormat,
2066 limit: usize,
2067) -> Result<String, ComparisonError> {
2068 match format {
2069 ComparisonFormat::Json => Ok(render_serializable_json(document, limit)?),
2070 ComparisonFormat::Markdown => render_markdown(document, limit),
2071 ComparisonFormat::Html => html::render(document, limit),
2072 }
2073}
2074
2075fn render_markdown(document: &ComparisonDocument, limit: usize) -> Result<String, ComparisonError> {
2076 let mut output = RenderBuffer::new(limit);
2077 output.push_str("# Offline report comparison\n\n")?;
2078 output.push_str(
2079 "Scope is operator-declared. Coverage equivalence and source authenticity are not established. \
2080Imported claims are not endorsed. Only in before does not mean fixed or resolved. \
2081Only in after does not establish when an observation first appeared. \
2082Unchanged means equality of the compared projection, not proof of security.\n\n",
2083 )?;
2084 for (name, source) in [("Before", &document.before), ("After", &document.after)] {
2085 output.push_fmt(format_args!("## {name} source\n\n"))?;
2086 write_source_markdown(&mut output, source)?;
2087 }
2088 if let Some(session) = &document.supplied_session_comparison {
2089 write_supplied_session_comparison_markdown(&mut output, session)?;
2090 }
2091 if let Some(secret_exposure) = &document.secret_exposure_comparison {
2092 write_secret_exposure_comparison_markdown(&mut output, secret_exposure)?;
2093 }
2094 if let Some(tls_observation) = &document.tls_observation_comparison {
2095 write_tls_observation_comparison_markdown(&mut output, tls_observation)?;
2096 }
2097 if let Some(tls_negotiation) = &document.tls_negotiation_review_comparison {
2098 write_tls_negotiation_review_comparison_markdown(&mut output, tls_negotiation)?;
2099 }
2100 if let Some(websocket_review) = &document.websocket_review_comparison {
2101 write_websocket_review_comparison_markdown(&mut output, websocket_review)?;
2102 }
2103 if let Some(jwt_policy_review) = &document.jwt_policy_review_comparison {
2104 write_jwt_policy_review_comparison_markdown(&mut output, jwt_policy_review)?;
2105 }
2106 if let Some(xml_external_entity_review) = &document.xml_external_entity_review_comparison {
2107 write_xml_external_entity_review_comparison_markdown(
2108 &mut output,
2109 xml_external_entity_review,
2110 )?;
2111 }
2112 if let Some(template_evaluation_review) = &document.template_evaluation_review_comparison {
2113 write_template_evaluation_review_comparison_markdown(
2114 &mut output,
2115 template_evaluation_review,
2116 )?;
2117 }
2118 if let Some(control_reference_mapping) = &document.control_reference_mapping_comparison {
2119 write_control_reference_mapping_comparison_markdown(
2120 &mut output,
2121 control_reference_mapping,
2122 )?;
2123 }
2124 if let Some(recon_snapshot) = &document.recon_snapshot_import_comparison {
2125 write_recon_snapshot_import_comparison_markdown(&mut output, recon_snapshot)?;
2126 }
2127 if let Some(recon_certspotter) = &document.recon_certspotter_comparison {
2128 write_recon_certspotter_comparison_markdown(&mut output, recon_certspotter)?;
2129 }
2130 if let Some(wordpress) = &document.wordpress_review_comparison {
2131 write_wordpress_comparison_markdown(&mut output, wordpress)?;
2132 }
2133 for (name, items) in [
2134 ("only_in_after", &document.only_in_after),
2135 ("only_in_before", &document.only_in_before),
2136 ("changed", &document.changed),
2137 ("unchanged", &document.unchanged),
2138 ] {
2139 output.push_fmt(format_args!("## {name} ({})\n\n", items.len()))?;
2140 if items.is_empty() {
2141 output.push_str("No items in this group.\n\n")?;
2142 }
2143 for item in items {
2144 output.push_str("### Item\n\n- Fingerprint: ")?;
2145 write_markdown_code_span(&mut output, &item.fingerprint)?;
2146 output.push_str("\n- Capability: ")?;
2147 write_markdown_code_span(&mut output, &item.capability_id)?;
2148 if !item.changed_fields.is_empty() {
2149 output.push_str("\n- Changed fields: ")?;
2150 write_markdown_code_span(&mut output, &item.changed_fields.join(", "))?;
2151 }
2152 output.push_str("\n\n")?;
2153 if let (Some(before), Some(_)) = (&item.before, &item.after) {
2154 if item.changed_fields.is_empty() {
2155 output.push_str("#### Shared comparable projection\n\n")?;
2156 write_projection(&mut output, before)?;
2157 continue;
2158 }
2159 }
2160 for (name, projection) in [("Before", &item.before), ("After", &item.after)] {
2161 output.push_fmt(format_args!("#### {name}\n\n"))?;
2162 if let Some(projection) = projection {
2163 write_projection(&mut output, projection)?;
2164 } else {
2165 output.push_str("Not present in this input.\n\n")?;
2166 }
2167 }
2168 }
2169 }
2170 Ok(output.finish())
2171}
2172
2173fn write_supplied_session_comparison_markdown(
2174 output: &mut RenderBuffer,
2175 comparison: &SuppliedSessionComparison,
2176) -> Result<(), ComparisonError> {
2177 output.push_str("## Supplied session differences\n\n- Schema: ")?;
2178 write_markdown_code_span(output, comparison.schema)?;
2179 output.push_str("\n- Status: ")?;
2180 write_markdown_code_span(output, comparison.status)?;
2181 if let Some(reason) = comparison.reason {
2182 output.push_str("\n- Reason: ")?;
2183 write_markdown_code_span(output, reason)?;
2184 }
2185 output.push_str("\n- Scope assurance: ")?;
2186 write_markdown_code_span(output, comparison.scope_assurance)?;
2187 output.push_str(
2188 "\n\nThis display-only section keeps declared session context separate from health, coverage, and bounded accounting. It does not authenticate a principal or establish target change or remediation.\n\n",
2189 )?;
2190 for (label, facet) in [
2191 ("Declared context", &comparison.context),
2192 ("Health and coverage", &comparison.health_and_coverage),
2193 ("Accounting", &comparison.accounting),
2194 ] {
2195 output.push_fmt(format_args!("### {label}\n\n- Status: "))?;
2196 write_markdown_code_span(output, &facet.status)?;
2197 if !facet.changed_fields.is_empty() {
2198 output.push_str("\n- Changed fields: ")?;
2199 write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2200 }
2201 output.push_str("\n- Before: ")?;
2202 write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2203 output.push_str("\n- After: ")?;
2204 write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2205 output.push_str("\n- Interpretation: ")?;
2206 write_markdown_code_span(output, facet.note)?;
2207 output.push_str("\n\n")?;
2208 }
2209 output.push_str("### Interpretation limits\n\n")?;
2210 for limit in comparison.interpretation_limits {
2211 output.push_str("- ")?;
2212 write_markdown_code_span(output, limit)?;
2213 output.push_char('\n')?;
2214 }
2215 output.push_char('\n')?;
2216 Ok(())
2217}
2218
2219fn write_source_markdown(
2220 output: &mut RenderBuffer,
2221 source: &SourceMetadata,
2222) -> Result<(), ComparisonError> {
2223 for (name, value) in [
2224 ("sha256", source.sha256.as_str()),
2225 ("schema", source.schema.as_str()),
2226 ("source_schema", source.source_schema.as_str()),
2227 ("run_schema", source.run_schema.as_str()),
2228 ("profile_schema", source.profile_schema.as_str()),
2229 ("profile", source.profile.as_str()),
2230 ("status", source.status.as_str()),
2231 ] {
2232 output.push_str("- ")?;
2233 write_markdown_code_span(output, name)?;
2234 output.push_str(": ")?;
2235 write_markdown_code_span(output, value)?;
2236 output.push_char('\n')?;
2237 }
2238 for (name, value) in [
2239 ("subject_count", source.subject_count),
2240 ("item_count", source.item_count),
2241 ] {
2242 output.push_str("- ")?;
2243 write_markdown_code_span(output, name)?;
2244 output.push_str(": ")?;
2245 write_markdown_code_span(output, &value.to_string())?;
2246 output.push_char('\n')?;
2247 }
2248 for (name, value) in &source.optional_audits {
2249 output.push_str("- Optional audit ")?;
2250 write_markdown_code_span(output, name)?;
2251 output.push_str(": ")?;
2252 if name == "wordpress_review" {
2253 write_markdown_code_span(output, &display_serializable(value)?)?;
2254 output.push_str(" (validated snapshot; semantic differences are shown below)")?;
2255 } else {
2256 write_markdown_code_span(output, &display_serializable(value)?)?;
2257 }
2258 output.push_char('\n')?;
2259 }
2260 output.push_char('\n')?;
2261 Ok(())
2262}
2263
2264fn write_secret_exposure_comparison_markdown(
2265 output: &mut RenderBuffer,
2266 comparison: &SecretExposureComparison,
2267) -> Result<(), ComparisonError> {
2268 output.push_str("## Passive secret-exposure differences\n\n- Schema: ")?;
2269 write_markdown_code_span(output, comparison.schema)?;
2270 output.push_str("\n- Status: ")?;
2271 write_markdown_code_span(output, comparison.status)?;
2272 if let Some(reason) = comparison.reason {
2273 output.push_str("\n- Reason: ")?;
2274 write_markdown_code_span(output, reason)?;
2275 }
2276 output.push_str(
2277 "\n\nThis section compares validated, value-free audit projections. It does not validate a secret, authenticate a source, contact a provider, or establish remediation.\n\n",
2278 )?;
2279 for (label, facet) in [
2280 ("Methodology", &comparison.methodology),
2281 ("Coverage", &comparison.coverage),
2282 ] {
2283 output.push_fmt(format_args!("### Secret exposure {label}\n\n- Status: "))?;
2284 write_markdown_code_span(output, &facet.status)?;
2285 if !facet.changed_fields.is_empty() {
2286 output.push_str("\n- Changed fields: ")?;
2287 write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2288 }
2289 output.push_str("\n- Before: ")?;
2290 write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2291 output.push_str("\n- After: ")?;
2292 write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2293 output.push_str("\n- Interpretation: ")?;
2294 write_markdown_code_span(output, facet.note)?;
2295 output.push_str("\n\n")?;
2296 }
2297 output.push_str("### Secret exposure interpretation limits\n\n")?;
2298 for limit in comparison.interpretation_limits {
2299 output.push_str("- ")?;
2300 write_markdown_code_span(output, limit)?;
2301 output.push_char('\n')?;
2302 }
2303 output.push_char('\n')?;
2304 Ok(())
2305}
2306
2307fn write_tls_observation_comparison_markdown(
2308 output: &mut RenderBuffer,
2309 comparison: &TlsObservationComparison,
2310) -> Result<(), ComparisonError> {
2311 output.push_str("## Existing-connection TLS observation differences\n\n- Schema: ")?;
2312 write_markdown_code_span(output, comparison.schema)?;
2313 output.push_str("\n- Status: ")?;
2314 write_markdown_code_span(output, comparison.status)?;
2315 if let Some(reason) = comparison.reason {
2316 output.push_str("\n- Reason: ")?;
2317 write_markdown_code_span(output, reason)?;
2318 }
2319 output.push_str(
2320 "\n\nThis section compares validated passive TLS audit projections. It does not enumerate server TLS support, authenticate a source, check revocation, or establish vulnerability or remediation.\n\n",
2321 )?;
2322 for (label, facet) in [
2323 ("Methodology", &comparison.methodology),
2324 ("Coverage", &comparison.coverage),
2325 (
2326 "Certificate observations",
2327 &comparison.certificate_observations,
2328 ),
2329 ] {
2330 output.push_fmt(format_args!("### TLS {label}\n\n- Status: "))?;
2331 write_markdown_code_span(output, &facet.status)?;
2332 if !facet.changed_fields.is_empty() {
2333 output.push_str("\n- Changed fields: ")?;
2334 write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2335 }
2336 output.push_str("\n- Before: ")?;
2337 write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2338 output.push_str("\n- After: ")?;
2339 write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2340 output.push_str("\n- Interpretation: ")?;
2341 write_markdown_code_span(output, facet.note)?;
2342 output.push_str("\n\n")?;
2343 }
2344 output.push_str("### TLS observation interpretation limits\n\n")?;
2345 for limit in comparison.interpretation_limits {
2346 output.push_str("- ")?;
2347 write_markdown_code_span(output, limit)?;
2348 output.push_char('\n')?;
2349 }
2350 output.push_char('\n')?;
2351 Ok(())
2352}
2353
2354fn write_tls_negotiation_review_comparison_markdown(
2355 output: &mut RenderBuffer,
2356 comparison: &TlsNegotiationReviewComparison,
2357) -> Result<(), ComparisonError> {
2358 output.push_str("## Active TLS negotiation review differences\n\n- Schema: ")?;
2359 write_markdown_code_span(output, comparison.schema)?;
2360 output.push_str("\n- Status: ")?;
2361 write_markdown_code_span(output, comparison.status)?;
2362 if let Some(reason) = comparison.reason {
2363 output.push_str("\n- Reason: ")?;
2364 write_markdown_code_span(output, reason)?;
2365 }
2366 output.push_str(
2367 "\n\nThis section compares a validated finite two-connection TLS offer matrix plus three explicit client-backend-unsupported rows. It does not enumerate all server capabilities, authenticate the source, or establish vulnerability or remediation.\n\n",
2368 )?;
2369 for (label, facet) in [
2370 ("Methodology", &comparison.methodology),
2371 ("Coverage", &comparison.coverage),
2372 (
2373 "Capability observations",
2374 &comparison.capability_observations,
2375 ),
2376 ] {
2377 output.push_fmt(format_args!("### TLS negotiation {label}\n\n- Status: "))?;
2378 write_markdown_code_span(output, &facet.status)?;
2379 if !facet.changed_fields.is_empty() {
2380 output.push_str("\n- Changed fields: ")?;
2381 write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2382 }
2383 output.push_str("\n- Before: ")?;
2384 write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2385 output.push_str("\n- After: ")?;
2386 write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2387 output.push_str("\n- Interpretation: ")?;
2388 write_markdown_code_span(output, facet.note)?;
2389 output.push_str("\n\n")?;
2390 }
2391 output.push_str("### TLS negotiation interpretation limits\n\n")?;
2392 for limit in comparison.interpretation_limits {
2393 output.push_str("- ")?;
2394 write_markdown_code_span(output, limit)?;
2395 output.push_char('\n')?;
2396 }
2397 output.push_char('\n')?;
2398 Ok(())
2399}
2400
2401fn write_websocket_review_comparison_markdown(
2402 output: &mut RenderBuffer,
2403 comparison: &WebSocketReviewComparison,
2404) -> Result<(), ComparisonError> {
2405 output.push_str("## WebSocket review differences\n\n- Schema: ")?;
2406 write_markdown_code_span(output, comparison.schema)?;
2407 output.push_str("\n- Status: ")?;
2408 write_markdown_code_span(output, comparison.status)?;
2409 if let Some(reason) = comparison.reason {
2410 output.push_str("\n- Reason: ")?;
2411 write_markdown_code_span(output, reason)?;
2412 }
2413 if comparison.supplied_session_context.is_some() {
2414 output.push_str(
2415 "\n\nThis section compares strict, value-free projections of one bounded supplied-session WebSocket exchange. It retains no payload, URL, path, query, subprotocol value, mismatched-response digest, token, credential, or raw error. Protocol completion is distinct from startup/terminal session-health qualification, and continuous authentication remains not established. Raw transport-byte fields include HTTP Upgrade/framing overhead and WSS encrypted TLS records; they are not application bytes. The comparison does not establish authenticated identity, authorization, server state, vulnerability, impact, or remediation.\n\n",
2416 )?;
2417 } else {
2418 output.push_str(
2419 "\n\nThis section compares strict, value-free projections of one bounded anonymous WebSocket exchange. It retains no payload, URL, path, query, subprotocol value, mismatched-response digest, token, or raw error. Raw transport-byte fields include HTTP Upgrade/framing overhead and WSS encrypted TLS records; they are not application bytes. The comparison does not establish authorization, server state, vulnerability, impact, or remediation.\n\n",
2420 )?;
2421 }
2422 if let Some(context) = &comparison.supplied_session_context {
2423 output.push_str("### WebSocket supplied-session context\n\n- Status: ")?;
2424 write_markdown_code_span(output, &context.status)?;
2425 if !context.changed_fields.is_empty() {
2426 output.push_str("\n- Changed fields: ")?;
2427 write_markdown_code_span(output, &context.changed_fields.join(", "))?;
2428 }
2429 output.push_str("\n- Before: ")?;
2430 write_markdown_code_span(output, &display_json(context.before.as_ref())?)?;
2431 output.push_str("\n- After: ")?;
2432 write_markdown_code_span(output, &display_json(context.after.as_ref())?)?;
2433 output.push_str("\n- Interpretation: ")?;
2434 write_markdown_code_span(output, context.note)?;
2435 output.push_str("\n\n")?;
2436 }
2437 for (label, facet) in [
2438 ("Methodology", &comparison.methodology),
2439 ("Coverage", &comparison.coverage),
2440 ("Outcome", &comparison.outcome),
2441 ] {
2442 output.push_fmt(format_args!("### WebSocket {label}\n\n- Status: "))?;
2443 write_markdown_code_span(output, &facet.status)?;
2444 if !facet.changed_fields.is_empty() {
2445 output.push_str("\n- Changed fields: ")?;
2446 write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2447 }
2448 output.push_str("\n- Before: ")?;
2449 write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2450 output.push_str("\n- After: ")?;
2451 write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2452 output.push_str("\n- Interpretation: ")?;
2453 write_markdown_code_span(output, facet.note)?;
2454 output.push_str("\n\n")?;
2455 }
2456 output.push_str("### WebSocket review interpretation limits\n\n")?;
2457 for limit in comparison.interpretation_limits {
2458 output.push_str("- ")?;
2459 write_markdown_code_span(output, limit)?;
2460 output.push_char('\n')?;
2461 }
2462 output.push_char('\n')?;
2463 Ok(())
2464}
2465
2466fn write_jwt_policy_review_comparison_markdown(
2467 output: &mut RenderBuffer,
2468 comparison: &JwtPolicyReviewComparison,
2469) -> Result<(), ComparisonError> {
2470 if comparison.target_selected {
2471 output.push_str("## JWT policy review differences\n\n- Schema: ")?;
2472 } else {
2473 output.push_str("## Local JWT policy review differences\n\n- Schema: ")?;
2474 }
2475 write_markdown_code_span(output, comparison.schema)?;
2476 output.push_str("\n- Status: ")?;
2477 write_markdown_code_span(output, comparison.status)?;
2478 if let Some(reason) = comparison.reason {
2479 output.push_str("\n- Reason: ")?;
2480 write_markdown_code_span(output, reason)?;
2481 }
2482 if comparison.target_selected {
2483 output.push_str(
2484 "\n\nThis section compares validated, value-free local policy projections and bounded target-control projections. It retains no token, claim, signature, key, selected marker name, or raw resource URL and does not establish a general authentication bypass, authorization effect, vulnerability, impact, or remediation.\n\n",
2485 )?;
2486 } else {
2487 output.push_str(
2488 "\n\nThis section compares validated, value-free local JWT audit projections. It does not expose the token, claims, signature, or key; contact a target; or establish target acceptance, authorization, vulnerability, or remediation.\n\n",
2489 )?;
2490 }
2491 for (label, facet) in [
2492 ("Methodology", &comparison.methodology),
2493 ("Coverage", &comparison.coverage),
2494 ("Outcome", &comparison.outcome),
2495 ] {
2496 output.push_fmt(format_args!("### JWT {label}\n\n- Status: "))?;
2497 write_markdown_code_span(output, &facet.status)?;
2498 if !facet.changed_fields.is_empty() {
2499 output.push_str("\n- Changed fields: ")?;
2500 write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2501 }
2502 output.push_str("\n- Before: ")?;
2503 write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2504 output.push_str("\n- After: ")?;
2505 write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2506 output.push_str("\n- Interpretation: ")?;
2507 write_markdown_code_span(output, facet.note)?;
2508 output.push_str("\n\n")?;
2509 }
2510 output.push_str("### JWT policy review interpretation limits\n\n")?;
2511 for limit in comparison.interpretation_limits {
2512 output.push_str("- ")?;
2513 write_markdown_code_span(output, limit)?;
2514 output.push_char('\n')?;
2515 }
2516 output.push_char('\n')?;
2517 Ok(())
2518}
2519
2520fn write_xml_external_entity_review_comparison_markdown(
2521 output: &mut RenderBuffer,
2522 comparison: &XmlExternalEntityReviewComparison,
2523) -> Result<(), ComparisonError> {
2524 output.push_str("## Controlled XML external-entity review differences\n\n- Schema: ")?;
2525 write_markdown_code_span(output, comparison.schema)?;
2526 output.push_str("\n- Status: ")?;
2527 write_markdown_code_span(output, comparison.status)?;
2528 if let Some(reason) = comparison.reason {
2529 output.push_str("\n- Reason: ")?;
2530 write_markdown_code_span(output, reason)?;
2531 }
2532 output.push_str(
2533 "\n\nThis section compares validated, value-free projections of one bounded callback-control review. It retains no endpoint, provider, callback, XML, token, path, response-body, or raw-error values. It does not confirm XXE, identify a parser, establish file access, data exfiltration, internal reachability, impact, or remediation.\n\n",
2534 )?;
2535 for (label, facet) in [
2536 ("Methodology", &comparison.methodology),
2537 ("Coverage", &comparison.coverage),
2538 ("Outcome", &comparison.outcome),
2539 ] {
2540 output.push_fmt(format_args!("### XML review {label}\n\n- Status: "))?;
2541 write_markdown_code_span(output, &facet.status)?;
2542 if !facet.changed_fields.is_empty() {
2543 output.push_str("\n- Changed fields: ")?;
2544 write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2545 }
2546 output.push_str("\n- Before: ")?;
2547 write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2548 output.push_str("\n- After: ")?;
2549 write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2550 output.push_str("\n- Interpretation: ")?;
2551 write_markdown_code_span(output, facet.note)?;
2552 output.push_str("\n\n")?;
2553 }
2554 output.push_str("### XML external-entity review interpretation limits\n\n")?;
2555 for limit in comparison.interpretation_limits {
2556 output.push_str("- ")?;
2557 write_markdown_code_span(output, limit)?;
2558 output.push_char('\n')?;
2559 }
2560 output.push_char('\n')?;
2561 Ok(())
2562}
2563
2564fn write_template_evaluation_review_comparison_markdown(
2565 output: &mut RenderBuffer,
2566 comparison: &TemplateEvaluationReviewComparison,
2567) -> Result<(), ComparisonError> {
2568 output.push_str("## Benign template-evaluation review differences\n\n- Schema: ")?;
2569 write_markdown_code_span(output, comparison.schema)?;
2570 output.push_str("\n- Status: ")?;
2571 write_markdown_code_span(output, comparison.status)?;
2572 if let Some(reason) = comparison.reason {
2573 output.push_str("\n- Reason: ")?;
2574 write_markdown_code_span(output, reason)?;
2575 }
2576 output.push_str(
2577 "\n\nThis section compares validated, value-free projections of two bounded benign expression cases. It retains no expression, query value, response body, template source, engine banner, file path, command, or raw error. It does not identify a template engine or establish operating-system execution, file access, outbound interaction, impact, vulnerability, or remediation.\n\n",
2578 )?;
2579 for (label, facet) in [
2580 ("Methodology", &comparison.methodology),
2581 ("Coverage", &comparison.coverage),
2582 ("Outcome", &comparison.outcome),
2583 ] {
2584 output.push_fmt(format_args!(
2585 "### Template-evaluation {label}\n\n- Status: "
2586 ))?;
2587 write_markdown_code_span(output, &facet.status)?;
2588 if !facet.changed_fields.is_empty() {
2589 output.push_str("\n- Changed fields: ")?;
2590 write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2591 }
2592 output.push_str("\n- Before: ")?;
2593 write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2594 output.push_str("\n- After: ")?;
2595 write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2596 output.push_str("\n- Interpretation: ")?;
2597 write_markdown_code_span(output, facet.note)?;
2598 output.push_str("\n\n")?;
2599 }
2600 output.push_str("### Template-evaluation interpretation limits\n\n")?;
2601 for limit in comparison.interpretation_limits {
2602 output.push_str("- ")?;
2603 write_markdown_code_span(output, limit)?;
2604 output.push_char('\n')?;
2605 }
2606 output.push_char('\n')?;
2607 Ok(())
2608}
2609
2610fn write_control_reference_mapping_comparison_markdown(
2611 output: &mut RenderBuffer,
2612 comparison: &ControlReferenceMappingComparison,
2613) -> Result<(), ComparisonError> {
2614 output.push_str("## Control-reference mapping differences\n\n- Schema: ")?;
2615 write_markdown_code_span(output, comparison.schema)?;
2616 output.push_str("\n- Status: ")?;
2617 write_markdown_code_span(output, comparison.status)?;
2618 if let Some(reason) = comparison.reason {
2619 output.push_str("\n- Reason: ")?;
2620 write_markdown_code_span(output, reason)?;
2621 }
2622 output.push_str(
2623 "\n\nThis section compares a validated offline reference projection over already composed items. Catalogue, source, coverage, and relationship changes are methodology/reference-set differences, not target changes, remediation, compliance, certification, or legal conclusions.\n\n",
2624 )?;
2625 for (label, facet) in [
2626 ("Methodology", &comparison.methodology),
2627 ("Coverage", &comparison.coverage),
2628 ("Reference set", &comparison.reference_set),
2629 ] {
2630 output.push_fmt(format_args!("### Control-reference {label}\n\n- Status: "))?;
2631 write_markdown_code_span(output, &facet.status)?;
2632 if !facet.changed_fields.is_empty() {
2633 output.push_str("\n- Changed fields: ")?;
2634 write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2635 }
2636 output.push_str("\n- Before: ")?;
2637 write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2638 output.push_str("\n- After: ")?;
2639 write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2640 output.push_str("\n- Interpretation: ")?;
2641 write_markdown_code_span(output, facet.note)?;
2642 output.push_str("\n\n")?;
2643 }
2644 output.push_str("### Control-reference mapping interpretation limits\n\n")?;
2645 for limit in comparison.interpretation_limits {
2646 output.push_str("- ")?;
2647 write_markdown_code_span(output, limit)?;
2648 output.push_char('\n')?;
2649 }
2650 output.push_char('\n')?;
2651 Ok(())
2652}
2653
2654fn write_recon_snapshot_import_comparison_markdown(
2655 output: &mut RenderBuffer,
2656 comparison: &ReconSnapshotImportComparison,
2657) -> Result<(), ComparisonError> {
2658 output.push_str("## Reconnaissance snapshot import differences\n\n- Schema: ")?;
2659 write_markdown_code_span(output, comparison.schema)?;
2660 output.push_str("\n- Status: ")?;
2661 write_markdown_code_span(output, comparison.status)?;
2662 if let Some(reason) = comparison.reason {
2663 output.push_str("\n- Reason: ")?;
2664 write_markdown_code_span(output, reason)?;
2665 }
2666 output.push_str("\n\nThis section compares validated inert local snapshot projections. Source, coverage, and hypothesis changes are imported-context differences, not target changes, findings, authorization, vulnerability, or remediation.\n\n")?;
2667 for (label, facet) in [
2668 ("Methodology", &comparison.methodology),
2669 ("Provenance and sources", &comparison.provenance_and_sources),
2670 (
2671 "Coverage and accounting",
2672 &comparison.coverage_and_accounting,
2673 ),
2674 ("Hypotheses", &comparison.hypotheses),
2675 ] {
2676 output.push_fmt(format_args!("### Recon snapshot {label}\n\n- Status: "))?;
2677 write_markdown_code_span(output, &facet.status)?;
2678 if !facet.changed_fields.is_empty() {
2679 output.push_str("\n- Changed fields: ")?;
2680 write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2681 }
2682 output.push_str("\n- Before: ")?;
2683 write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2684 output.push_str("\n- After: ")?;
2685 write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2686 output.push_str("\n- Interpretation: ")?;
2687 write_markdown_code_span(output, facet.note)?;
2688 output.push_str("\n\n")?;
2689 }
2690 output.push_str("### Reconnaissance snapshot interpretation limits\n\n")?;
2691 for limit in comparison.interpretation_limits {
2692 output.push_str("- ")?;
2693 write_markdown_code_span(output, limit)?;
2694 output.push_char('\n')?;
2695 }
2696 output.push_char('\n')?;
2697 Ok(())
2698}
2699
2700fn write_recon_certspotter_comparison_markdown(
2701 output: &mut RenderBuffer,
2702 comparison: &ReconCertSpotterComparison,
2703) -> Result<(), ComparisonError> {
2704 output.push_str("## Cert Spotter provider differences\n\n- Schema: ")?;
2705 write_markdown_code_span(output, comparison.schema)?;
2706 output.push_str("\n- Status: ")?;
2707 write_markdown_code_span(output, comparison.status)?;
2708 if let Some(reason) = comparison.reason {
2709 output.push_str("\n- Reason: ")?;
2710 write_markdown_code_span(output, reason)?;
2711 }
2712 output.push_str("\n\nThis section compares validated bounded provider projections. Provider, methodology, coverage, and source-qualified hypothesis changes are saved-report context differences, not findings, scan authority, source authentication, ownership, currentness, vulnerability, or remediation.\n\n")?;
2713 for (label, facet) in [
2714 ("Provider", &comparison.provider),
2715 ("Methodology", &comparison.methodology),
2716 ("Coverage", &comparison.coverage),
2717 ("Source-qualified hypotheses", &comparison.hypotheses),
2718 ] {
2719 output.push_fmt(format_args!("### Cert Spotter {label}\n\n- Status: "))?;
2720 write_markdown_code_span(output, &facet.status)?;
2721 if !facet.changed_fields.is_empty() {
2722 output.push_str("\n- Changed fields: ")?;
2723 write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2724 }
2725 output.push_str("\n- Before: ")?;
2726 write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2727 output.push_str("\n- After: ")?;
2728 write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2729 output.push_str("\n- Interpretation: ")?;
2730 write_markdown_code_span(output, facet.note)?;
2731 output.push_str("\n\n")?;
2732 }
2733 output.push_str("### Cert Spotter interpretation limits\n\n")?;
2734 for limit in comparison.interpretation_limits {
2735 output.push_str("- ")?;
2736 write_markdown_code_span(output, limit)?;
2737 output.push_char('\n')?;
2738 }
2739 output.push_char('\n')?;
2740 Ok(())
2741}
2742
2743fn write_wordpress_comparison_markdown(
2744 output: &mut RenderBuffer,
2745 comparison: &WordPressReviewComparison,
2746) -> Result<(), ComparisonError> {
2747 output.push_str("## WordPress review differences\n\n")?;
2748 output.push_str("- Schema: ")?;
2749 write_markdown_code_span(output, comparison.schema)?;
2750 output.push_str("\n- Status: ")?;
2751 write_markdown_code_span(output, comparison.status)?;
2752 if let Some(reason) = comparison.reason {
2753 output.push_str("\n- Reason: ")?;
2754 write_markdown_code_span(output, reason)?;
2755 }
2756 output.push_str("\n- Scope assurance: ")?;
2757 write_markdown_code_span(output, comparison.scope_assurance)?;
2758 output.push_str(
2759 "\n\nThis section compares validated display-only WordPress audit projections. It does not rerun the scan or establish remediation, newness, or causation.\n\n",
2760 )?;
2761 for (label, facet) in [
2762 ("Declared coverage", &comparison.coverage),
2763 ("Methodology", &comparison.methodology),
2764 ("Provenance", &comparison.provenance),
2765 ] {
2766 output.push_fmt(format_args!("### {label}\n\n- Status: "))?;
2767 write_markdown_code_span(output, &facet.status)?;
2768 if !facet.changed_fields.is_empty() {
2769 output.push_str("\n- Changed fields: ")?;
2770 write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2771 }
2772 output.push_str("\n- Before: ")?;
2773 write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2774 output.push_str("\n- After: ")?;
2775 write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2776 output.push_str("\n- Interpretation: ")?;
2777 write_markdown_code_span(output, facet.note)?;
2778 output.push_str("\n\n")?;
2779 }
2780 if let Some(facet) = &comparison.discovery_source_content {
2781 output.push_str("### Discovery source content\n\n- Status: ")?;
2782 write_markdown_code_span(output, &facet.status)?;
2783 if !facet.changed_fields.is_empty() {
2784 output.push_str("\n- Changed fields: ")?;
2785 write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2786 }
2787 output.push_str("\n- Before: ")?;
2788 write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2789 output.push_str("\n- After: ")?;
2790 write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2791 output.push_str("\n- Interpretation: ")?;
2792 write_markdown_code_span(output, facet.note)?;
2793 output.push_str("\n\n")?;
2794 }
2795 if let Some(fingerprints) = &comparison.asset_fingerprints {
2796 output.push_str("### Asset fingerprint candidates\n\n- Status: ")?;
2797 write_markdown_code_span(output, fingerprints.status)?;
2798 if let Some(reason) = fingerprints.reason {
2799 output.push_str("\n- Reason: ")?;
2800 write_markdown_code_span(output, reason)?;
2801 }
2802 output.push_str("\n\n")?;
2803 for (label, facet) in [
2804 ("Fingerprint methodology", &fingerprints.methodology),
2805 ("Finite reference catalogue", &fingerprints.catalogue),
2806 ("Resource coverage", &fingerprints.coverage),
2807 ] {
2808 output.push_fmt(format_args!("#### {label}\n\n- Status: "))?;
2809 write_markdown_code_span(output, &facet.status)?;
2810 if !facet.changed_fields.is_empty() {
2811 output.push_str("\n- Changed fields: ")?;
2812 write_markdown_code_span(output, &facet.changed_fields.join(", "))?;
2813 }
2814 output.push_str("\n- Before: ")?;
2815 write_markdown_code_span(output, &display_json(facet.before.as_ref())?)?;
2816 output.push_str("\n- After: ")?;
2817 write_markdown_code_span(output, &display_json(facet.after.as_ref())?)?;
2818 output.push_str("\n- Interpretation: ")?;
2819 write_markdown_code_span(output, facet.note)?;
2820 output.push_str("\n\n")?;
2821 }
2822 write_wordpress_entity_changes_markdown(
2823 output,
2824 "Fingerprint resources",
2825 &fingerprints.resources,
2826 )?;
2827 write_wordpress_entity_changes_markdown(
2828 output,
2829 "Fingerprint candidate sets",
2830 &fingerprints.components,
2831 )?;
2832 output.push_str("#### Fingerprint interpretation limits\n\n")?;
2833 for limit in fingerprints.interpretation_limits {
2834 output.push_str("- ")?;
2835 write_markdown_code_span(output, limit)?;
2836 output.push_char('\n')?;
2837 }
2838 output.push_char('\n')?;
2839 }
2840 write_wordpress_entity_changes_markdown(output, "Components", &comparison.components)?;
2841 write_wordpress_entity_changes_markdown(output, "Advisories", &comparison.advisories)?;
2842 output.push_str("### Interpretation limits\n\n")?;
2843 for limit in comparison.interpretation_limits {
2844 output.push_str("- ")?;
2845 write_markdown_code_span(output, limit)?;
2846 output.push_char('\n')?;
2847 }
2848 output.push_char('\n')?;
2849 Ok(())
2850}
2851
2852fn write_wordpress_entity_changes_markdown<K: Serialize>(
2853 output: &mut RenderBuffer,
2854 label: &str,
2855 changes: &WordPressEntityChanges<K>,
2856) -> Result<(), ComparisonError> {
2857 output.push_fmt(format_args!("### {label}\n\n"))?;
2858 output.push_fmt(format_args!(
2859 "- Paired unchanged: {}\n- Paired changed: {}\n- Only in supplied before audit: {}\n- Only in supplied after audit: {}\n\n",
2860 changes.paired_unchanged_count,
2861 changes.paired_changed.len(),
2862 changes.only_in_before.len(),
2863 changes.only_in_after.len(),
2864 ))?;
2865 for change in &changes.paired_changed {
2866 output.push_str("#### Paired identity with changed content\n\n- Key: ")?;
2867 write_markdown_code_span(output, &display_serializable(&change.key)?)?;
2868 output.push_str("\n- Changed dimensions: ")?;
2869 write_markdown_code_span(output, &change.changed_dimensions.join(", "))?;
2870 output.push_str("\n- Before: ")?;
2871 write_markdown_code_span(output, &display_serializable(&change.before)?)?;
2872 output.push_str("\n- After: ")?;
2873 write_markdown_code_span(output, &display_serializable(&change.after)?)?;
2874 output.push_str("\n\n")?;
2875 }
2876 for (side, entities) in [
2877 ("before", &changes.only_in_before),
2878 ("after", &changes.only_in_after),
2879 ] {
2880 for entity in entities {
2881 output.push_fmt(format_args!(
2882 "#### Present only in the supplied {side} audit\n\n- Key: "
2883 ))?;
2884 write_markdown_code_span(output, &display_serializable(&entity.key)?)?;
2885 output.push_str("\n- Content: ")?;
2886 write_markdown_code_span(output, &display_serializable(&entity.content)?)?;
2887 output.push_str("\n- Interpretation: ")?;
2888 write_markdown_code_span(output, entity.interpretation)?;
2889 output.push_str("\n\n")?;
2890 }
2891 }
2892 Ok(())
2893}
2894
2895fn display_json(value: Option<&Value>) -> Result<String, ComparisonError> {
2896 value.map_or_else(
2897 || Ok("not recorded".to_owned()),
2898 |value| serde_json::to_string(value).map_err(|_| ComparisonError::Serialization),
2899 )
2900}
2901
2902fn display_serializable(value: &impl Serialize) -> Result<String, ComparisonError> {
2903 serde_json::to_string(value).map_err(|_| ComparisonError::Serialization)
2904}
2905
2906fn write_projection(
2907 output: &mut RenderBuffer,
2908 projection: &impl Serialize,
2909) -> Result<(), ComparisonError> {
2910 let value = serde_json::to_value(projection).map_err(|_| ComparisonError::Serialization)?;
2911 let fields = value.as_object().ok_or(ComparisonError::Serialization)?;
2912 for (field, value) in fields {
2913 output.push_str("- ")?;
2914 write_markdown_code_span(output, field)?;
2915 output.push_str(": ")?;
2916 let text = match value {
2917 Value::String(text) => text.clone(),
2918 _ => value.to_string(),
2919 };
2920 write_markdown_code_span(output, &text)?;
2921 output.push_char('\n')?;
2922 }
2923 output.push_char('\n')?;
2924 Ok(())
2925}