diff --git a/.github/workflows/reputation-error-precedence-red.yml b/.github/workflows/reputation-error-precedence-red.yml new file mode 100644 index 0000000..877d48c --- /dev/null +++ b/.github/workflows/reputation-error-precedence-red.yml @@ -0,0 +1,31 @@ +name: Reputation error precedence causal verification + +on: + pull_request: + paths: + - '.github/workflows/reputation-error-precedence-red.yml' + - 'crates/wardnet-reputation-core/src/model.rs' + - 'crates/wardnet-reputation-core/tests/error_precedence.rs' + +permissions: + contents: read + +jobs: + verify: + runs-on: macos-15-arm64 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + - name: Assert exact PR source + env: + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: stable + - name: Check formatting + run: cargo fmt --check + - name: Test reputation error precedence + run: cargo test --locked -p wardnet-reputation-core --test error_precedence diff --git a/Cargo.lock b/Cargo.lock index c696190..1e2824a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1377,6 +1377,14 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wardnet-reputation-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index b2ec231..4692dde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ description = "Rust-first WAF/IDS/AI SOC gateway with DNSBL and commercial readi license = "MIT" [workspace] -members = [".", "crates/waf-ids-core"] +members = [".", "crates/waf-ids-core", "crates/wardnet-reputation-core"] resolver = "3" [dependencies] diff --git a/crates/wardnet-reputation-core/Cargo.toml b/crates/wardnet-reputation-core/Cargo.toml new file mode 100644 index 0000000..7270b88 --- /dev/null +++ b/crates/wardnet-reputation-core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "wardnet-reputation-core" +version = "0.1.0" +edition = "2024" +description = "Pure Wardnet outbound site-reputation domain contracts and policy core" +license = "MIT" + +[dependencies] +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +serde_json = "1" diff --git a/crates/wardnet-reputation-core/TRACEABILITY.md b/crates/wardnet-reputation-core/TRACEABILITY.md new file mode 100644 index 0000000..a6e8d8d --- /dev/null +++ b/crates/wardnet-reputation-core/TRACEABILITY.md @@ -0,0 +1,49 @@ +# Reputation contract research and standards traceability + +This note records the evidence boundary for `wardnet-reputation-core`. The crate defines transport-neutral Wardnet domain contracts; it is not an anomaly detector, feed client, executable egress authority, or transport policy engine. + +## Decision rationale + +NIST Cybersecurity Framework (CSF) 2.0 treats Detect as outcomes for finding and analyzing possible cybersecurity attacks and compromises while leaving implementation mechanisms to the adopting organization. Wardnet therefore keeps the observed security assessment, evidence-authority health, and policy action as separate fields instead of treating a detector output as self-executing authorization. + +Chandola, Banerjee, and Kumar (2009) show that anomaly-detection techniques depend on domain-specific assumptions about what distinguishes normal from anomalous behavior. That supports a conservative contract boundary here: absence of eligible adverse evidence is `unknown`, not evidence of benignness; producer confidence is retained as producer metadata rather than converted into a Wardnet probability; and an adverse classification remains traceable to reviewed evidence. The paper does **not** define Wardnet's `KnownMalicious`, `Suspicious`, or `Unknown` vocabulary. Those are Wardnet bounded-context terms for evidence state and must not be presented as categories from the paper. + +The v1 contract therefore chooses: + +- `KnownMalicious` only for eligible reviewed evidence that asserts a hard threat within explicit scope; +- `Suspicious` for adverse evidence that does not establish the hard-threat condition; +- `Unknown` when no eligible adverse match establishes safety; +- a separate `EvidenceHealthV1` so source outage or expiry can fail closed without rewriting the underlying assessment; +- a separate `PolicyActionV1`, because a Wardnet reputation allow only permits continuation to independent gates and is never executable EgressWeave transport authorization. + +Rejected alternatives are: treating `unknown` as benign, aggregating producer confidence into an invented probability, using HTTP success as an authorization signal, or allowing business authorization to override adverse evidence. These choices would erase provenance or conflate observation, policy, and enforcement authority. + +Load-balancing literature is intentionally not used to justify this contract shape. This crate performs no scheduling, dispatch, network I/O, or executable load balancing, so latency/implementation-overhead results from load-balancing systems are not causal evidence for the v1 data model. Performance and concurrency research becomes applicable when the evaluator/cache and measured deployment path are implemented; the implementation plan requires those later slices to profile and benchmark the real path rather than pre-justify this transport-neutral schema with unrelated systems results. + +## Wire-schema compatibility and fail-closed decoding + +The v1 wire structs accept only fields declared by the exact `wardnet.reputation.v1` schema. Serde's default forward-compatible behavior of ignoring unknown struct fields is deliberately rejected at this security boundary. An unrecognized scope-bearing field can otherwise combine with an omitted optional field and change semantics. The hostile regression demonstrates the concrete case: `tenant_ids` would be ignored while absent `tenant_id` deserializes as `None`, and `None` is the contract state that may represent globally applicable evidence. That is a scope-widening failure, not harmless extension data. + +The same rule applies to reviewed source eligibility. A source policy must now state whether it is reviewed for `all_authenticated_tenants` or an `explicit_tenant_set`; an omitted tenant-scope declaration is invalid, an explicit set cannot be empty, and an all-tenant declaration cannot smuggle a contradictory tenant list. This closes the gap between authenticated tenant binding and source eligibility without treating a missing restriction as an implicit organization-wide grant. It also keeps the tenant decision inside Wardnet's reputation-policy contract rather than moving identity authentication into this crate. + +MITRE CWE-20 explicitly calls out missing and extra inputs as properties that input validation should consider and recommends accepting only values that strictly conform to the intended specification. NIST SP 800-218 SSDF 1.1 PW.5.1 likewise includes validating all inputs as a secure-coding implementation example. Accordingly, every v1 wire struct uses strict unknown-field rejection. A producer or consumer that needs a new security-relevant field must negotiate a new compatible schema version instead of relying on a v1 reader to discard it. + +The rejected alternative was permissive unknown-field decoding for forward compatibility. It was rejected because this contract contains optional scope, marking, and evidence metadata whose absence has domain meaning; silently discarding a misspelled or future field can therefore convert an explicit restriction into absence. Exact versioning is the safer and more reviewable compatibility mechanism. + +As of 2026-09-06, NIST SP 800-218 Rev. 1 / SSDF 1.2 is still an Initial Public Draft rather than a final replacement. It is tracked for forward awareness, while the final SSDF 1.1 remains the normative NIST citation used for this implemented decision. + +## Source handling + +The ACM article is cited by DOI and bibliographic metadata only. Its publisher access is not assumed to grant redistribution rights, so no article PDF is copied into this repository. NIST CSF 2.0 and SSDF 1.1 are linked to official NIST publications. Future local copies must be added only when redistribution terms are verified. + +## References + +Chandola, V., Banerjee, A., & Kumar, V. (2009). Anomaly detection: A survey. *ACM Computing Surveys, 41*(3), Article 15. https://doi.org/10.1145/1541880.1541882 + +MITRE. (n.d.). *CWE-20: Improper input validation* (Version 4.20). Retrieved September 6, 2026, from https://cwe.mitre.org/data/definitions/20.html + +Pascoe, C., Quinn, S., & Scarfone, K. (2024). *The NIST Cybersecurity Framework (CSF) 2.0* (NIST CSWP 29). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.CSWP.29 + +Souppaya, M., & Scarfone, K. (2022). *Secure Software Development Framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST SP 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 + +Booth, H., Ogata, M., Kent, K., Souppaya, M., & Dodson, D. (2025). *Secure Software Development Framework (SSDF) version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST SP 800-218 Rev. 1, Initial Public Draft). National Institute of Standards and Technology. https://csrc.nist.gov/pubs/sp/800/218/r1/ipd diff --git a/crates/wardnet-reputation-core/src/lib.rs b/crates/wardnet-reputation-core/src/lib.rs new file mode 100644 index 0000000..aa8c671 --- /dev/null +++ b/crates/wardnet-reputation-core/src/lib.rs @@ -0,0 +1,12 @@ +//! Pure Wardnet outbound site-reputation contracts. +//! +//! This crate deliberately performs no HTTP, DNS, transport authorization, database I/O, +//! environment access, or LLM work. Executable outbound target interpretation remains an +//! EgressWeave responsibility; this crate only accepts already-canonical offline descriptors. +//! +//! The research, standards, rejected alternatives, and evidence-handling rationale for this +//! contract boundary are recorded in the adjacent [TRACEABILITY.md](../TRACEABILITY.md). + +pub mod model; + +pub use model::*; diff --git a/crates/wardnet-reputation-core/src/model.rs b/crates/wardnet-reputation-core/src/model.rs new file mode 100644 index 0000000..718c528 --- /dev/null +++ b/crates/wardnet-reputation-core/src/model.rs @@ -0,0 +1,557 @@ +//! Versioned, transport-neutral outbound site-reputation domain contracts. +//! +//! Contract terminology, research/standards grounding, rejected alternatives, and the explicit +//! separation from executable transport authorization are recorded in +//! [TRACEABILITY.md](../TRACEABILITY.md). + +use serde::{Deserialize, Serialize}; + +/// Wire schema identifier for the first Wardnet reputation contract family. +pub const REPUTATION_SCHEMA_V1: &str = "wardnet.reputation.v1"; + +const MAX_TEXT_BYTES_V1: usize = 1_024; +const MAX_LIST_ITEMS_V1: usize = 64; + +/// Rejects contract schema identities outside the explicitly supported v1 family. +fn validate_schema(schema_version: &str) -> Result<(), ContractValidationErrorV1> { + if schema_version == REPUTATION_SCHEMA_V1 { + Ok(()) + } else { + Err(ContractValidationErrorV1::UnsupportedSchema) + } +} + +/// Applies the shared nonblank and byte-bound contract to one required text field. +fn validate_text(value: &str, field: &'static str) -> Result<(), ContractValidationErrorV1> { + if value.trim().is_empty() { + return Err(ContractValidationErrorV1::BlankField(field)); + } + if value.len() > MAX_TEXT_BYTES_V1 { + return Err(ContractValidationErrorV1::BoundExceeded(field)); + } + Ok(()) +} + +/// Applies required-text validation only when an optional producer field is present. +fn validate_optional_text( + value: Option<&str>, + field: &'static str, +) -> Result<(), ContractValidationErrorV1> { + if let Some(value) = value { + validate_text(value, field)?; + } + Ok(()) +} + +/// Bounds a repeated text field before validating every element with the shared text contract. +fn validate_text_list( + values: &[String], + field: &'static str, +) -> Result<(), ContractValidationErrorV1> { + if values.len() > MAX_LIST_ITEMS_V1 { + return Err(ContractValidationErrorV1::BoundExceeded(field)); + } + for value in values { + validate_text(value, field)?; + } + Ok(()) +} + +/// Direction of the evaluated destination operation. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DirectionV1 { + /// Outbound traffic from a protected workload toward an external destination. + Outbound, + /// Inbound traffic is represented so validation can reject it explicitly. + Inbound, +} + +/// Canonical subject kind supplied to Wardnet by an owning canonicalization boundary. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum DestinationSubjectKindV1 { + /// Exact canonical host name. + ExactHost, + /// Exact visible canonical URL observable. + ObservableUrl, + /// Exact canonical actual address and port observable. + ActualAddress, +} + +/// Explicit matching scope for a canonical destination subject. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DestinationScopeV1 { + /// Match exactly the supplied canonical subject. + Exact, + /// Match the exact host and dot-boundary subdomains only. + HostAndSubdomains, +} + +/// Canonical destination descriptor that Wardnet matches without reparsing network syntax. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DestinationSubjectV1 { + /// Subject kind defining how the opaque canonical value may be matched. + pub kind: DestinationSubjectKindV1, + /// Canonical value produced by the owning canonicalization boundary. + pub value: String, + /// Explicit matching scope; non-host kinds must remain exact. + pub scope: DestinationScopeV1, +} + +impl DestinationSubjectV1 { + /// Rejects blank subjects and prevents subdomain scope from being attached to non-host kinds. + fn validate(&self) -> Result<(), ContractValidationErrorV1> { + validate_text(&self.value, "subject.value")?; + if self.scope == DestinationScopeV1::HostAndSubdomains + && self.kind != DestinationSubjectKindV1::ExactHost + { + return Err(ContractValidationErrorV1::AmbiguousSubjectScope); + } + Ok(()) + } +} + +/// Authenticated evaluation context after identity claims have been verified by the service edge. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DestinationContextV1 { + /// Contract schema identifier. + pub schema_version: String, + /// Evaluated direction; this contract accepts outbound only. + pub direction: DirectionV1, + /// Authenticated tenant identifier. + pub tenant_id: String, + /// Authenticated workload identifier. + pub workload_id: String, + /// Registered purpose identifier. + pub purpose: String, + /// Unique operation correlation identifier. + pub operation_id: String, + /// Reputation protection profile identifier. + pub profile_id: String, + /// Canonical destination subject. + pub subject: DestinationSubjectV1, + /// Canonicalization profile name owned outside this crate. + pub canonicalization_profile: String, + /// Canonicalization profile version owned outside this crate. + pub canonicalization_version: String, +} + +impl DestinationContextV1 { + /// Validate the bounded contract shape without authenticating caller-controlled identity text. + pub fn validate(&self) -> Result<(), ContractValidationErrorV1> { + validate_schema(&self.schema_version)?; + if self.direction != DirectionV1::Outbound { + return Err(ContractValidationErrorV1::WrongDirection); + } + validate_text(&self.tenant_id, "tenant_id")?; + validate_text(&self.workload_id, "workload_id")?; + validate_text(&self.purpose, "purpose")?; + validate_text(&self.operation_id, "operation_id")?; + validate_text(&self.profile_id, "profile_id")?; + self.subject.validate()?; + validate_text(&self.canonicalization_profile, "canonicalization_profile")?; + validate_text(&self.canonicalization_version, "canonicalization_version")?; + Ok(()) + } +} + +/// Security classification preserved from an eligible producer record. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EvidenceClassificationV1 { + /// Producer evidence asserts a known malicious destination within its explicit scope. + KnownMalicious, + /// Producer evidence is adverse but not eligible to assert known maliciousness. + Suspicious, +} + +/// Versioned source evidence retained with lifecycle and provenance semantics. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct EvidenceRecordV1 { + /// Contract schema identifier. + pub schema_version: String, + /// Reviewed source policy identifier. + pub source_id: String, + /// Producer-native record identifier. + pub producer_record_id: String, + /// Producer-native monotonic or immutable record version identifier. + pub producer_record_version: String, + /// Canonical subject and explicit scope asserted by the producer mapping. + pub subject: DestinationSubjectV1, + /// Security classification preserved by the adapter. + pub classification: EvidenceClassificationV1, + /// Producer severity text, preserved rather than converted into a probability. + pub producer_severity: Option, + /// Optional producer confidence on the producer's own 0-100 scale. + pub producer_confidence: Option, + /// Time the producer says the observation was made. + pub observed_at_unix: u64, + /// Time Wardnet received the authenticated producer record. + pub received_at_unix: u64, + /// Start of the producer validity interval. + pub valid_from_unix: u64, + /// End of the producer validity interval. + pub valid_until_unix: u64, + /// Producer lifecycle revocation flag. + pub revoked: bool, + /// Producer lifecycle deletion flag. + pub deleted: bool, + /// Whether reviewed source policy permits this record to contribute to enforcement. + pub enforcement_eligible: bool, + /// Optional tenant restriction; absence means source policy may treat the evidence as global. + pub tenant_id: Option, + /// Optional producer data-marking identifier. + pub marking: Option, + /// Licensing or terms reference required for evidence use and redistribution decisions. + pub license_ref: String, + /// Bounded provenance references that identify authenticated source material or derivation. + pub provenance_refs: Vec, +} + +impl EvidenceRecordV1 { + /// Validate contract shape and time ordering at an injected evaluation time. + pub fn validate_at(&self, now_unix: u64) -> Result<(), ContractValidationErrorV1> { + validate_schema(&self.schema_version)?; + validate_text(&self.source_id, "source_id")?; + validate_text(&self.producer_record_id, "producer_record_id")?; + validate_text(&self.producer_record_version, "producer_record_version")?; + self.subject.validate()?; + validate_optional_text(self.producer_severity.as_deref(), "producer_severity")?; + validate_optional_text(self.tenant_id.as_deref(), "tenant_id")?; + validate_optional_text(self.marking.as_deref(), "marking")?; + validate_text(&self.license_ref, "license_ref")?; + validate_text_list(&self.provenance_refs, "provenance_refs")?; + if self.enforcement_eligible && self.provenance_refs.is_empty() { + return Err(ContractValidationErrorV1::MissingEnforcementProvenance); + } + if self.observed_at_unix > self.received_at_unix + || self.received_at_unix > now_unix + || self.valid_from_unix > self.valid_until_unix + { + return Err(ContractValidationErrorV1::InvalidTimeOrder); + } + if self + .producer_confidence + .is_some_and(|confidence| confidence > 100) + { + return Err(ContractValidationErrorV1::InvalidConfidence); + } + Ok(()) + } +} + +/// Reviewed tenant-eligibility semantics for one reputation evidence source. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SourceTenantScopeV1 { + /// The reviewed source may contribute for any authenticated tenant. + AllAuthenticatedTenants, + /// The reviewed source may contribute only for an explicit bounded tenant set. + ExplicitTenantSet, +} + +/// Policy attached to one reviewed evidence source. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct SourcePolicyV1 { + /// Contract schema identifier. + pub schema_version: String, + /// Stable source identifier referenced by evidence and policy snapshots. + pub source_id: String, + /// Whether this source can establish known-malicious enforcement evidence. + pub enforcement_capable: bool, + /// Canonical subject kinds the source is permitted to assert. + pub permitted_subject_kinds: Vec, + /// Maximum evidence age allowed by Wardnet policy, in seconds. + pub max_evidence_age_seconds: u64, + /// Explicit reviewed tenant-scope mode; omission is invalid on the v1 wire. + pub tenant_scope: SourceTenantScopeV1, + /// Bounded tenant identifiers when `tenant_scope` is `explicit_tenant_set`. + pub allowed_tenant_ids: Vec, + /// Purposes for which this source may contribute; empty is invalid. + pub allowed_purposes: Vec, +} + +impl SourcePolicyV1 { + /// Validate source-policy shape without fetching or authenticating a source. + pub fn validate(&self) -> Result<(), ContractValidationErrorV1> { + validate_schema(&self.schema_version)?; + validate_text(&self.source_id, "source_id")?; + if self.permitted_subject_kinds.is_empty() || self.allowed_purposes.is_empty() { + return Err(ContractValidationErrorV1::EmptySourceEligibility); + } + if self.permitted_subject_kinds.len() > MAX_LIST_ITEMS_V1 { + return Err(ContractValidationErrorV1::BoundExceeded( + "permitted_subject_kinds", + )); + } + validate_text_list(&self.allowed_tenant_ids, "allowed_tenant_ids")?; + match self.tenant_scope { + SourceTenantScopeV1::AllAuthenticatedTenants if !self.allowed_tenant_ids.is_empty() => { + return Err(ContractValidationErrorV1::InvalidTenantEligibility); + } + SourceTenantScopeV1::ExplicitTenantSet if self.allowed_tenant_ids.is_empty() => { + return Err(ContractValidationErrorV1::InvalidTenantEligibility); + } + _ => {} + } + validate_text_list(&self.allowed_purposes, "allowed_purposes")?; + Ok(()) + } +} + +/// Runtime policy mode; monitor never manufactures a protect authorization. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EvaluationModeV1 { + /// Enforce fail-closed reputation policy. + Protect, + /// Produce a shadow result without a protect grant. + Monitor, +} + +/// Immutable reputation policy revision consumed by the pure core. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct PolicySnapshotV1 { + /// Contract schema identifier. + pub schema_version: String, + /// Stable policy identifier. + pub policy_id: String, + /// Monotonic immutable policy revision. + pub revision: u64, + /// Evaluation mode. + pub mode: EvaluationModeV1, + /// Nonempty reviewed sources required for protect evaluation. + pub required_sources: Vec, + /// Policy validity start. + pub valid_from_unix: u64, + /// Policy validity end. + pub valid_until_unix: u64, +} + +impl PolicySnapshotV1 { + /// Validate the policy snapshot at an injected evaluation time. + pub fn validate_at(&self, now_unix: u64) -> Result<(), ContractValidationErrorV1> { + validate_schema(&self.schema_version)?; + validate_text(&self.policy_id, "policy_id")?; + if self.mode == EvaluationModeV1::Protect && self.required_sources.is_empty() { + return Err(ContractValidationErrorV1::EmptyRequiredSources); + } + validate_text_list(&self.required_sources, "required_sources")?; + for (index, source) in self.required_sources.iter().enumerate() { + if self.required_sources[..index].contains(source) { + return Err(ContractValidationErrorV1::DuplicateRequiredSource); + } + } + if self.valid_from_unix > self.valid_until_unix + || now_unix < self.valid_from_unix + || now_unix > self.valid_until_unix + { + return Err(ContractValidationErrorV1::InvalidTimeOrder); + } + Ok(()) + } +} + +/// Deterministic assessment dimension kept separate from policy action. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ReputationAssessmentV1 { + /// At least one active eligible reviewed source asserts known maliciousness. + KnownMalicious, + /// Adverse evidence exists but does not establish known maliciousness. + Suspicious, + /// No active eligible adverse match establishes safety. + Unknown, +} + +/// Health of the evidence authorities required by the evaluated policy. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EvidenceHealthV1 { + /// All required authorities and applicable evidence are current. + Fresh, + /// Optional authority degradation exists while all required authorities remain healthy. + Degraded, + /// Required evidence exists but is outside its validity or age bound. + Expired, + /// A required authority or its verifiable evidence is unavailable. + Unavailable, +} + +/// Reputation policy action; transport authorization remains a separate authority. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PolicyActionV1 { + /// Reputation policy allows continuation to other independent gates. + Allow, + /// Reputation policy denies continuation. + Deny, +} + +/// Stable machine-readable reason for a reputation policy action. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DecisionReasonV1 { + /// An eligible hard-threat record matched the exact evaluated scope. + KnownMalicious, + /// Suspicious evidence caused the initial protect profile to deny. + Suspicious, + /// No active eligible adverse match exists, and no exact-scope authorization applies. + UnknownDestination, + /// An exact-scope business authorization permits an unknown destination to continue to other gates. + BusinessAuthorization, + /// Required evidence or authority is unavailable or unverifiable. + RequiredAuthorityUnavailable, + /// Contract identity or version could not be validated. + InvalidContract, +} + +/// Checks that the reason describes the assessment, with authority-health failure taking precedence. +fn reason_matches_assessment( + assessment: ReputationAssessmentV1, + evidence_health: EvidenceHealthV1, + reason: DecisionReasonV1, +) -> bool { + if matches!( + evidence_health, + EvidenceHealthV1::Expired | EvidenceHealthV1::Unavailable + ) { + return reason == DecisionReasonV1::RequiredAuthorityUnavailable; + } + + match assessment { + ReputationAssessmentV1::KnownMalicious => reason == DecisionReasonV1::KnownMalicious, + ReputationAssessmentV1::Suspicious => reason == DecisionReasonV1::Suspicious, + ReputationAssessmentV1::Unknown => matches!( + reason, + DecisionReasonV1::UnknownDestination + | DecisionReasonV1::BusinessAuthorization + | DecisionReasonV1::InvalidContract + ), + } +} + +/// Checks whether a machine-readable reason is coherent with the reputation-only policy action. +fn reason_matches_action(action: PolicyActionV1, reason: DecisionReasonV1) -> bool { + match action { + PolicyActionV1::Allow => reason == DecisionReasonV1::BusinessAuthorization, + PolicyActionV1::Deny => reason != DecisionReasonV1::BusinessAuthorization, + } +} + +/// Explainable pure-core decision envelope; it is not proof that traffic was actually blocked. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DecisionEnvelopeV1 { + /// Contract schema identifier. + pub schema_version: String, + /// Unique evaluation identifier. + pub evaluation_id: String, + /// Authenticated request and canonical destination identity evaluated by this decision. + pub context: DestinationContextV1, + /// Policy identifier used for the decision. + pub policy_id: String, + /// Exact policy revision used for the decision. + pub policy_revision: u64, + /// Immutable evidence snapshot generation evaluated by this decision. + pub evidence_generation: String, + /// Deterministic security assessment. + pub assessment: ReputationAssessmentV1, + /// Required evidence health. + pub evidence_health: EvidenceHealthV1, + /// Reputation-only policy action. + pub action: PolicyActionV1, + /// Stable explanation reason. + pub reason: DecisionReasonV1, + /// Evaluation time. + pub evaluated_at_unix: u64, + /// Earliest expiry across policy and evidence used by the decision. + pub expires_at_unix: u64, + /// Bounded producer evidence references used for explanation. + pub evidence_refs: Vec, +} + +impl DecisionEnvelopeV1 { + /// Validate a serialized decision envelope without treating it as an authenticated grant. + pub fn validate(&self) -> Result<(), ContractValidationErrorV1> { + validate_schema(&self.schema_version)?; + validate_text(&self.evaluation_id, "evaluation_id")?; + self.context.validate()?; + validate_text(&self.policy_id, "policy_id")?; + validate_text(&self.evidence_generation, "evidence_generation")?; + validate_text_list(&self.evidence_refs, "evidence_refs")?; + if !reason_matches_assessment(self.assessment, self.evidence_health, self.reason) { + return Err(ContractValidationErrorV1::InconsistentAssessmentReason); + } + let adverse_assessment = matches!( + self.assessment, + ReputationAssessmentV1::KnownMalicious | ReputationAssessmentV1::Suspicious + ); + if adverse_assessment && self.evidence_refs.is_empty() { + return Err(ContractValidationErrorV1::MissingDecisionEvidence); + } + if adverse_assessment && self.action == PolicyActionV1::Allow { + return Err(ContractValidationErrorV1::UnsafeAdverseAllow); + } + let unhealthy_required_authority = matches!( + self.evidence_health, + EvidenceHealthV1::Expired | EvidenceHealthV1::Unavailable + ); + if unhealthy_required_authority && self.action == PolicyActionV1::Allow { + return Err(ContractValidationErrorV1::UnsafeUnhealthyEvidenceAllow); + } + if !reason_matches_action(self.action, self.reason) { + return Err(ContractValidationErrorV1::InconsistentActionReason); + } + if self.evaluated_at_unix > self.expires_at_unix { + return Err(ContractValidationErrorV1::InvalidTimeOrder); + } + Ok(()) + } +} + +/// Typed fail-closed validation errors for versioned reputation contracts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContractValidationErrorV1 { + /// Schema identifier is unsupported by this crate version. + UnsupportedSchema, + /// Direction is not outbound. + WrongDirection, + /// A required bounded text field is blank. + BlankField(&'static str), + /// A bounded text or list field exceeds the v1 contract limit. + BoundExceeded(&'static str), + /// A destination kind/scope combination is ambiguous or unsupported. + AmbiguousSubjectScope, + /// A time interval or observed/received ordering is invalid. + InvalidTimeOrder, + /// A required-source policy contains no required sources. + EmptyRequiredSources, + /// A required-source identifier appears more than once. + DuplicateRequiredSource, + /// A producer confidence value exceeds the preserved 0-100 range. + InvalidConfidence, + /// A source policy does not permit any subject kind or purpose. + EmptySourceEligibility, + /// A source policy tenant-scope mode contradicts its explicit tenant set. + InvalidTenantEligibility, + /// Evidence eligible for enforcement has no provenance reference. + MissingEnforcementProvenance, + /// An adverse decision assessment has no evidence reference for SOC traceability. + MissingDecisionEvidence, + /// Decision assessment and machine-readable reason contradict each other. + InconsistentAssessmentReason, + /// Decision action and machine-readable reason contradict each other. + InconsistentActionReason, + /// An adverse assessment attempts to serialize as an allow action. + UnsafeAdverseAllow, + /// Expired or unavailable required evidence attempts to serialize as an allow action. + UnsafeUnhealthyEvidenceAllow, +} diff --git a/crates/wardnet-reputation-core/tests/contract.rs b/crates/wardnet-reputation-core/tests/contract.rs new file mode 100644 index 0000000..b8c83d9 --- /dev/null +++ b/crates/wardnet-reputation-core/tests/contract.rs @@ -0,0 +1,313 @@ +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::json; +use wardnet_reputation_core::{ + ContractValidationErrorV1, DestinationContextV1, DestinationScopeV1, DestinationSubjectKindV1, + DestinationSubjectV1, DirectionV1, EvaluationModeV1, EvidenceClassificationV1, + EvidenceRecordV1, PolicySnapshotV1, REPUTATION_SCHEMA_V1, SourcePolicyV1, SourceTenantScopeV1, +}; + +const NOW: u64 = 1_788_652_800; + +fn subject() -> DestinationSubjectV1 { + DestinationSubjectV1 { + kind: DestinationSubjectKindV1::ExactHost, + value: "updates.example.invalid".to_string(), + scope: DestinationScopeV1::Exact, + } +} + +fn context() -> DestinationContextV1 { + DestinationContextV1 { + schema_version: REPUTATION_SCHEMA_V1.to_string(), + direction: DirectionV1::Outbound, + tenant_id: "tenant-example".to_string(), + workload_id: "workload-example".to_string(), + purpose: "package_metadata".to_string(), + operation_id: "op-0001".to_string(), + profile_id: "protect-default".to_string(), + subject: subject(), + canonicalization_profile: "egressweave-offline-fixture".to_string(), + canonicalization_version: "1".to_string(), + } +} + +fn source_policy() -> SourcePolicyV1 { + SourcePolicyV1 { + schema_version: REPUTATION_SCHEMA_V1.to_string(), + source_id: "reviewed-source".to_string(), + enforcement_capable: true, + permitted_subject_kinds: vec![DestinationSubjectKindV1::ExactHost], + max_evidence_age_seconds: 3_600, + tenant_scope: SourceTenantScopeV1::ExplicitTenantSet, + allowed_tenant_ids: vec!["tenant-example".to_string()], + allowed_purposes: vec!["package_metadata".to_string()], + } +} + +fn evidence() -> EvidenceRecordV1 { + EvidenceRecordV1 { + schema_version: REPUTATION_SCHEMA_V1.to_string(), + source_id: "reviewed-source".to_string(), + producer_record_id: "record-1".to_string(), + producer_record_version: "1".to_string(), + subject: subject(), + classification: EvidenceClassificationV1::KnownMalicious, + producer_severity: Some("high".to_string()), + producer_confidence: Some(80), + observed_at_unix: NOW - 120, + received_at_unix: NOW - 60, + valid_from_unix: NOW - 120, + valid_until_unix: NOW + 600, + revoked: false, + deleted: false, + enforcement_eligible: true, + tenant_id: Some("tenant-example".to_string()), + marking: Some("TLP:CLEAR".to_string()), + license_ref: "synthetic-fixture".to_string(), + provenance_refs: vec!["urn:wardnet:test:record-1".to_string()], + } +} + +fn assert_unknown_field_rejected(candidate: T, field: &str) +where + T: Serialize + DeserializeOwned, +{ + let mut value = serde_json::to_value(candidate).expect("contract serializes"); + value + .as_object_mut() + .expect("wire contract serializes as an object") + .insert(field.to_string(), json!(true)); + assert!( + serde_json::from_value::(value).is_err(), + "v1 wire contract must reject unknown field {field}" + ); +} + +#[test] +fn rejects_wrong_direction_and_unknown_schema() { + let mut candidate = context(); + candidate.direction = DirectionV1::Inbound; + assert_eq!( + candidate.validate(), + Err(ContractValidationErrorV1::WrongDirection) + ); + + let mut candidate = context(); + candidate.schema_version = "wardnet.reputation.v2".to_string(); + assert_eq!( + candidate.validate(), + Err(ContractValidationErrorV1::UnsupportedSchema) + ); +} + +#[test] +fn rejects_blank_authenticated_context_fields() { + for field in ["workload_id", "purpose"] { + let mut candidate = context(); + match field { + "workload_id" => candidate.workload_id = " \t".to_string(), + "purpose" => candidate.purpose.clear(), + _ => unreachable!(), + } + assert_eq!( + candidate.validate(), + Err(ContractValidationErrorV1::BlankField(field)) + ); + } +} + +#[test] +fn rejects_ambiguous_subject_scope() { + let mut candidate = context(); + candidate.subject.kind = DestinationSubjectKindV1::ObservableUrl; + candidate.subject.scope = DestinationScopeV1::HostAndSubdomains; + assert_eq!( + candidate.validate(), + Err(ContractValidationErrorV1::AmbiguousSubjectScope) + ); +} + +#[test] +fn rejects_missing_required_source_policy() { + let policy = PolicySnapshotV1 { + schema_version: REPUTATION_SCHEMA_V1.to_string(), + policy_id: "protect-default".to_string(), + revision: 1, + mode: EvaluationModeV1::Protect, + required_sources: Vec::new(), + valid_from_unix: NOW - 60, + valid_until_unix: NOW + 600, + }; + assert_eq!( + policy.validate_at(NOW), + Err(ContractValidationErrorV1::EmptyRequiredSources) + ); +} + +#[test] +fn rejects_invalid_evidence_time_order_and_confidence() { + let mut candidate = evidence(); + candidate.valid_from_unix = NOW + 10; + candidate.valid_until_unix = NOW; + assert_eq!( + candidate.validate_at(NOW), + Err(ContractValidationErrorV1::InvalidTimeOrder) + ); + + let mut candidate = evidence(); + candidate.producer_confidence = Some(101); + assert_eq!( + candidate.validate_at(NOW), + Err(ContractValidationErrorV1::InvalidConfidence) + ); +} + +#[test] +fn rejects_enforcement_evidence_without_provenance() { + let mut candidate = evidence(); + candidate.provenance_refs.clear(); + + assert_eq!( + candidate.validate_at(NOW), + Err(ContractValidationErrorV1::MissingEnforcementProvenance), + "enforcement-eligible evidence without provenance must fail closed" + ); +} + +#[test] +fn rejects_unknown_evidence_fields_that_could_widen_scope() { + let mut value = serde_json::to_value(evidence()).expect("evidence serializes"); + let object = value + .as_object_mut() + .expect("evidence contract serializes as an object"); + object.remove("tenant_id"); + object.insert("tenant_ids".to_string(), json!(["tenant-example"])); + + let decoded = serde_json::from_value::(value); + assert!( + decoded.is_err(), + "an unrecognized tenant restriction must fail closed instead of degrading to global evidence" + ); +} + +#[test] +fn rejects_unknown_fields_across_nondecision_v1_wire_structs() { + assert_unknown_field_rejected(subject(), "unexpected_subject_field"); + assert_unknown_field_rejected(context(), "caller_authenticated"); + assert_unknown_field_rejected(evidence(), "unexpected_evidence_scope"); + assert_unknown_field_rejected(source_policy(), "fallback_allow"); + assert_unknown_field_rejected( + PolicySnapshotV1 { + schema_version: REPUTATION_SCHEMA_V1.to_string(), + policy_id: "protect-default".to_string(), + revision: 1, + mode: EvaluationModeV1::Protect, + required_sources: vec!["reviewed-source".to_string()], + valid_from_unix: NOW - 60, + valid_until_unix: NOW + 600, + }, + "unknown_policy_extension", + ); +} + +#[test] +fn rejects_empty_source_eligibility() { + let mut candidate = source_policy(); + candidate.permitted_subject_kinds.clear(); + assert_eq!( + candidate.validate(), + Err(ContractValidationErrorV1::EmptySourceEligibility) + ); + + let mut candidate = source_policy(); + candidate.allowed_purposes.clear(); + assert_eq!( + candidate.validate(), + Err(ContractValidationErrorV1::EmptySourceEligibility) + ); +} + +#[test] +fn rejects_source_policy_without_explicit_tenant_eligibility() { + let mut value = serde_json::to_value(source_policy()).expect("source policy serializes"); + let object = value + .as_object_mut() + .expect("source policy serializes as an object"); + object.remove("tenant_scope"); + object.remove("allowed_tenant_ids"); + + assert!( + serde_json::from_value::(value).is_err(), + "a reviewed source must declare tenant eligibility explicitly instead of silently widening to every tenant" + ); +} + +#[test] +fn rejects_ambiguous_source_tenant_eligibility() { + let mut candidate = source_policy(); + candidate.tenant_scope = SourceTenantScopeV1::ExplicitTenantSet; + candidate.allowed_tenant_ids.clear(); + assert_eq!( + candidate.validate(), + Err(ContractValidationErrorV1::InvalidTenantEligibility) + ); + + let mut candidate = source_policy(); + candidate.tenant_scope = SourceTenantScopeV1::AllAuthenticatedTenants; + assert_eq!( + candidate.validate(), + Err(ContractValidationErrorV1::InvalidTenantEligibility) + ); +} + +#[test] +fn accepts_reviewed_all_authenticated_tenant_scope_without_tenant_list() { + let mut candidate = source_policy(); + candidate.tenant_scope = SourceTenantScopeV1::AllAuthenticatedTenants; + candidate.allowed_tenant_ids.clear(); + candidate + .validate() + .expect("explicit reviewed all-tenant eligibility remains a valid source policy"); +} + +#[derive(Debug, Deserialize)] +struct ContractFixture { + case_id: String, + now_unix: u64, + context: DestinationContextV1, + source_policy: SourcePolicyV1, + evidence: Vec, + expected_assessment: String, + expected_action: String, + expected_reason: String, + expected_visibility: String, +} + +#[test] +fn exact_host_fixture_round_trips_stably() { + let fixture: ContractFixture = serde_json::from_str(include_str!( + "../../../tests/fixtures/reputation/v1/exact_host_roundtrip.json" + )) + .expect("synthetic contract fixture is valid JSON"); + + assert_eq!(fixture.case_id, "REP-CONTRACT-ROUNDTRIP-01"); + assert_eq!(fixture.now_unix, NOW); + assert_eq!(fixture.expected_assessment, "unknown"); + assert_eq!(fixture.expected_action, "deny"); + assert_eq!(fixture.expected_reason, "unknown_destination"); + assert_eq!(fixture.expected_visibility, "exact_host"); + assert!(fixture.evidence.is_empty()); + fixture + .context + .validate() + .expect("fixture context is valid"); + fixture + .source_policy + .validate() + .expect("fixture source policy is valid"); + + let encoded = serde_json::to_string(&fixture.context).expect("context serializes"); + let decoded: DestinationContextV1 = + serde_json::from_str(&encoded).expect("context deserializes"); + assert_eq!(decoded, fixture.context); +} diff --git a/crates/wardnet-reputation-core/tests/decision_binding.rs b/crates/wardnet-reputation-core/tests/decision_binding.rs new file mode 100644 index 0000000..aeb3a36 --- /dev/null +++ b/crates/wardnet-reputation-core/tests/decision_binding.rs @@ -0,0 +1,254 @@ +use serde_json::{Value, json}; +use wardnet_reputation_core::{ + ContractValidationErrorV1, DecisionEnvelopeV1, REPUTATION_SCHEMA_V1, +}; + +const NOW: u64 = 1_788_652_800; + +fn decision_json(workload_id: &str, evidence_generation: &str) -> Value { + json!({ + "schema_version": REPUTATION_SCHEMA_V1, + "evaluation_id": "eval-0001", + "policy_id": "protect-default", + "policy_revision": 1, + "assessment": "unknown", + "evidence_health": "fresh", + "action": "deny", + "reason": "unknown_destination", + "evaluated_at_unix": NOW, + "expires_at_unix": NOW + 60, + "evidence_refs": [], + "context": { + "schema_version": REPUTATION_SCHEMA_V1, + "direction": "outbound", + "tenant_id": "tenant-example", + "workload_id": workload_id, + "purpose": "package_metadata", + "operation_id": "op-0001", + "profile_id": "protect-default", + "subject": { + "kind": "exact_host", + "value": "updates.example.invalid", + "scope": "exact" + }, + "canonicalization_profile": "egressweave-offline-fixture", + "canonicalization_version": "1" + }, + "evidence_generation": evidence_generation + }) +} + +#[test] +fn decision_envelope_rejects_unknown_wire_fields() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["transport_authorized"] = json!(true); + + assert!( + serde_json::from_value::(value).is_err(), + "v1 decision readers must reject unknown transport or authorization claims instead of ignoring them" + ); +} + +#[test] +fn decision_envelope_rejects_invalid_authenticated_context_binding() { + let decision: DecisionEnvelopeV1 = serde_json::from_value(decision_json("", "snapshot-42")) + .expect("v1 decision envelope should deserialize"); + + assert_eq!( + decision.validate(), + Err(ContractValidationErrorV1::BlankField("workload_id")) + ); +} + +#[test] +fn decision_envelope_rejects_missing_evidence_generation_binding() { + let decision: DecisionEnvelopeV1 = + serde_json::from_value(decision_json("workload-example", "")) + .expect("v1 decision envelope should deserialize"); + + assert_eq!( + decision.validate(), + Err(ContractValidationErrorV1::BlankField("evidence_generation")) + ); +} + +#[test] +fn decision_envelope_rejects_untraceable_adverse_assessment() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["assessment"] = json!("known_malicious"); + value["reason"] = json!("known_malicious"); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + assert!( + decision.validate().is_err(), + "known-malicious decisions without evidence references must fail closed" + ); +} + +#[test] +fn decision_envelope_rejects_known_malicious_allow() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["assessment"] = json!("known_malicious"); + value["action"] = json!("allow"); + value["reason"] = json!("known_malicious"); + value["evidence_refs"] = json!(["urn:wardnet:evidence:record-1"]); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + assert!( + decision.validate().is_err(), + "known-malicious assessments must never serialize as an allow action" + ); +} + +#[test] +fn decision_envelope_rejects_unavailable_required_authority_allow() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["evidence_health"] = json!("unavailable"); + value["action"] = json!("allow"); + value["reason"] = json!("required_authority_unavailable"); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + assert!( + decision.validate().is_err(), + "required-authority outage must never serialize as a reputation allow" + ); +} + +#[test] +fn decision_envelope_rejects_known_malicious_with_non_adverse_reason() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["assessment"] = json!("known_malicious"); + value["reason"] = json!("unknown_destination"); + value["evidence_refs"] = json!(["urn:wardnet:evidence:record-1"]); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + assert!( + decision.validate().is_err(), + "known-malicious assessments must not serialize with a contradictory SOC reason" + ); +} + +#[test] +fn decision_envelope_rejects_adverse_reason_for_unknown_assessment() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["reason"] = json!("suspicious"); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + assert!( + decision.validate().is_err(), + "adverse SOC reasons must not be detached from the matching adverse assessment" + ); +} + +#[test] +fn decision_envelope_accepts_consistent_suspicious_denial() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["assessment"] = json!("suspicious"); + value["reason"] = json!("suspicious"); + value["evidence_refs"] = json!(["urn:wardnet:evidence:record-2"]); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + decision + .validate() + .expect("consistent suspicious deny must remain a valid reputation decision"); +} + +#[test] +fn decision_envelope_accepts_authority_failure_reason_over_adverse_assessment() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["assessment"] = json!("suspicious"); + value["evidence_health"] = json!("unavailable"); + value["reason"] = json!("required_authority_unavailable"); + value["evidence_refs"] = json!(["urn:wardnet:evidence:record-2"]); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + decision.validate().expect( + "higher-precedence required-authority failure must remain explainable without erasing the adverse assessment", + ); +} + +#[test] +fn decision_envelope_rejects_unknown_destination_reason_on_allow() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["action"] = json!("allow"); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + assert!( + decision.validate().is_err(), + "unknown_destination describes a protect denial and must not validate as an allow reason" + ); +} + +#[test] +fn decision_envelope_rejects_invalid_contract_reason_on_allow() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["action"] = json!("allow"); + value["reason"] = json!("invalid_contract"); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + assert!( + decision.validate().is_err(), + "invalid_contract is fail-closed and must never validate as an allow reason" + ); +} + +#[test] +fn decision_envelope_accepts_business_authorization_allow_with_fresh_evidence() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["action"] = json!("allow"); + value["reason"] = json!("business_authorization"); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + decision.validate().expect( + "unknown plus an exact-scope business authorization may continue when required evidence is fresh", + ); +} + +#[test] +fn decision_envelope_accepts_business_authorization_allow_with_optional_degradation() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["evidence_health"] = json!("degraded"); + value["action"] = json!("allow"); + value["reason"] = json!("business_authorization"); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + decision.validate().expect( + "optional-source degradation may coexist with an authorized unknown allow while required sources remain healthy", + ); +} + +#[test] +fn decision_envelope_rejects_business_authorization_reason_on_deny() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["reason"] = json!("business_authorization"); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + assert_eq!( + decision.validate(), + Err(ContractValidationErrorV1::InconsistentActionReason) + ); +} diff --git a/crates/wardnet-reputation-core/tests/error_precedence.rs b/crates/wardnet-reputation-core/tests/error_precedence.rs new file mode 100644 index 0000000..6e98f3c --- /dev/null +++ b/crates/wardnet-reputation-core/tests/error_precedence.rs @@ -0,0 +1,73 @@ +use wardnet_reputation_core::{ + ContractValidationErrorV1, DecisionEnvelopeV1, DecisionReasonV1, DestinationContextV1, + DestinationScopeV1, DestinationSubjectKindV1, DestinationSubjectV1, DirectionV1, + EvidenceHealthV1, PolicyActionV1, REPUTATION_SCHEMA_V1, ReputationAssessmentV1, +}; + +const NOW: u64 = 1_788_652_800; + +fn context() -> DestinationContextV1 { + DestinationContextV1 { + schema_version: REPUTATION_SCHEMA_V1.to_owned(), + direction: DirectionV1::Outbound, + tenant_id: "tenant-example".to_owned(), + workload_id: "workload-example".to_owned(), + purpose: "package_metadata".to_owned(), + operation_id: "op-error-precedence".to_owned(), + profile_id: "protect-default".to_owned(), + subject: DestinationSubjectV1 { + kind: DestinationSubjectKindV1::ExactHost, + value: "updates.example.invalid".to_owned(), + scope: DestinationScopeV1::Exact, + }, + canonicalization_profile: "egressweave-offline-fixture".to_owned(), + canonicalization_version: "1".to_owned(), + } +} + +fn base_decision() -> DecisionEnvelopeV1 { + DecisionEnvelopeV1 { + schema_version: REPUTATION_SCHEMA_V1.to_owned(), + evaluation_id: "eval-error-precedence".to_owned(), + context: context(), + policy_id: "protect-default".to_owned(), + policy_revision: 1, + evidence_generation: "snapshot-42".to_owned(), + assessment: ReputationAssessmentV1::Unknown, + evidence_health: EvidenceHealthV1::Fresh, + action: PolicyActionV1::Deny, + reason: DecisionReasonV1::UnknownDestination, + evaluated_at_unix: NOW, + expires_at_unix: NOW + 60, + evidence_refs: Vec::new(), + } +} + +#[test] +fn adverse_allow_returns_the_specific_fail_closed_error() { + let mut decision = base_decision(); + decision.assessment = ReputationAssessmentV1::KnownMalicious; + decision.action = PolicyActionV1::Allow; + decision.reason = DecisionReasonV1::KnownMalicious; + decision.evidence_refs = vec!["urn:wardnet:evidence:record-1".to_owned()]; + + assert_eq!( + decision.validate(), + Err(ContractValidationErrorV1::UnsafeAdverseAllow), + "an otherwise coherent adverse decision must classify the unsafe allow itself, not hide it behind a generic action/reason mismatch", + ); +} + +#[test] +fn unhealthy_required_authority_allow_returns_the_specific_fail_closed_error() { + let mut decision = base_decision(); + decision.evidence_health = EvidenceHealthV1::Unavailable; + decision.action = PolicyActionV1::Allow; + decision.reason = DecisionReasonV1::RequiredAuthorityUnavailable; + + assert_eq!( + decision.validate(), + Err(ContractValidationErrorV1::UnsafeUnhealthyEvidenceAllow), + "an otherwise coherent required-authority outage must classify the unsafe allow itself, not hide it behind a generic action/reason mismatch", + ); +} diff --git a/tests/fixtures/reputation/v1/exact_host_roundtrip.json b/tests/fixtures/reputation/v1/exact_host_roundtrip.json new file mode 100644 index 0000000..92d0485 --- /dev/null +++ b/tests/fixtures/reputation/v1/exact_host_roundtrip.json @@ -0,0 +1,35 @@ +{ + "case_id": "REP-CONTRACT-ROUNDTRIP-01", + "now_unix": 1788652800, + "context": { + "schema_version": "wardnet.reputation.v1", + "direction": "outbound", + "tenant_id": "tenant-example", + "workload_id": "workload-example", + "purpose": "package_metadata", + "operation_id": "op-0001", + "profile_id": "protect-default", + "subject": { + "kind": "exact_host", + "value": "updates.example.invalid", + "scope": "exact" + }, + "canonicalization_profile": "egressweave-offline-fixture", + "canonicalization_version": "1" + }, + "source_policy": { + "schema_version": "wardnet.reputation.v1", + "source_id": "reviewed-source", + "enforcement_capable": true, + "permitted_subject_kinds": ["exact_host"], + "max_evidence_age_seconds": 3600, + "tenant_scope": "explicit_tenant_set", + "allowed_tenant_ids": ["tenant-example"], + "allowed_purposes": ["package_metadata"] + }, + "evidence": [], + "expected_assessment": "unknown", + "expected_action": "deny", + "expected_reason": "unknown_destination", + "expected_visibility": "exact_host" +}