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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ tone_instructions: >-
reviews:
profile: assertive
# Keep reviews advisory: CI gates (cargo clippy, cargo test, tsc) decide mergeability.
request_changes_workflow: false
request_changes_workflow: true
high_level_summary: true
changed_files_summary: true
Comment on lines 14 to 18
sequence_diagrams: true
Expand All @@ -22,7 +22,7 @@ reviews:
related_issues: true
related_prs: true
suggested_labels: true
auto_apply_labels: false
auto_apply_labels: true
suggested_reviewers: false
poem: false
in_progress_fortune: false
Expand All @@ -33,7 +33,7 @@ reviews:
auto_review:
enabled: true
auto_incremental_review: true
drafts: false
drafts: true
base_branches:
- main
# Dependency bumps and release automation are machine-generated; skip them.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
use serde::{Deserialize, Deserializer, Serialize, Serializer};

use crate::intune::evidence::{
intune_raw_preserving_string_enum, IntuneArtifactCoverage, IntuneErrorCode, IntuneEvidenceRef,
IntuneFinding, IntuneFindingConfidence, IntuneNamedValue, IntuneObservationContext,
IntuneParseState,
intune_raw_preserving_string_enum, IntuneAccessState, IntuneArtifactCoverage, IntuneErrorCode,
IntuneEvidenceRef, IntuneFinding, IntuneFindingConfidence, IntuneNamedValue,
IntuneObservationContext, IntuneParseState,
};

/// Schema version of the Autopilot snapshot contract.
Expand Down Expand Up @@ -241,6 +241,20 @@ impl AutopilotObservation {
self.context.evidence_ref.clone()
}

/// Whether this observation may support a conclusion at all.
///
/// A record is assessable only when its own declared context is fully
/// available and cleanly parsed. Non-assessable evidence (capped, denied,
/// malformed) can BLOCK a success conclusion but can never PROVE one, so no
/// terminal outcome and no high-confidence terminal finding may be sourced
/// from it (ADR-001). This is the single definition consulted by both the
/// reducer's semantic gates and the finding-side evidence helpers, so the
/// boundary cannot drift between them.
pub fn is_assessable(&self) -> bool {
self.context.access_state == IntuneAccessState::Available
&& self.context.parse_state == IntuneParseState::Parsed
}

/// Look up one named-data value, case-insensitively.
pub fn named(&self, name: &str) -> Option<&str> {
self.named_data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,28 @@ mod tests {
assert_eq!(code.decimal, Some(-2_145_647_638));
}

/// A 64-bit sign-extended HRESULT canonicalizes to its 32-bit form on
/// purpose. `0xFFFFFFFF80070002` is `0x80070002` printed through a 64-bit
/// register; the upper 32 bits are sign extension, not information. The
/// raw token always survives verbatim, and a genuinely 64-bit value that
/// is not a sign extension keeps its decimal and gains no fabricated
/// 32-bit hex.
#[test]
fn a_sign_extended_hresult_canonicalizes_to_its_32_bit_form() {
let code = error_code_from_token("0xFFFFFFFF80070002");
assert_eq!(code.raw, "0xFFFFFFFF80070002");
assert_eq!(code.decimal, Some(-2_147_024_894));
assert_eq!(code.hex.as_deref(), Some("0x80070002"));

let wide = error_code_from_token("0x1234567880070002");
assert_eq!(wide.raw, "0x1234567880070002");
assert_eq!(wide.decimal, Some(0x1234_5678_8007_0002));
assert_eq!(
wide.hex, None,
"a non-sign-extended 64-bit value must not gain an invented 32-bit hex form"
);
}

#[test]
fn a_message_without_an_hresult_yields_no_error_code() {
assert!(extract_error_code(Some("AutopilotRetrieveSettings succeeded.")).is_none());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,23 @@ fn user_path_re() -> &'static Regex {

/// A hardware hash or similar long opaque blob embedded in free text.
///
/// Bounded at 32 characters so a GUID (32 hex digits plus dashes, matched in
/// Bounded at 40 characters so a GUID (32 hex digits plus dashes, matched in
/// runs of at most 12) and an eight-digit HRESULT are both left readable; those
/// are diagnostic grammar, not identity.
///
/// No `\b` anchors. The Base64 alphabet includes `=`, `+`, and `/`, none of
/// which is a word character, so a trailing `\b` could not close a match on a
/// padded or punctuation-terminated hash and left its tail exposed. The match
/// is greedy and leftmost, so it consumes the whole contiguous Base64 run
/// wherever a >=40 run exists -- exactly the whole hash -- while the >=40 bound
/// still excludes the 36-char GUID (dashes break it into <=12-char runs) and
/// the short HRESULT. The `[blob:…]` token this produces contains `[`, `:`, and
/// `]`, which are outside the alphabet, so a token can never re-match: the
/// projection stays idempotent.
fn opaque_blob_re() -> &'static Regex {
static CELL: OnceLock<Regex> = OnceLock::new();
CELL.get_or_init(|| {
Regex::new(r"\b[A-Za-z0-9+/=]{40,}\b").expect("opaque blob regex must compile")
Regex::new(r"[A-Za-z0-9+/=]{40,}").expect("opaque blob regex must compile")
})
}

Expand Down Expand Up @@ -201,10 +211,11 @@ pub fn redacted_export_projection(snapshot: &AutopilotSnapshot) -> AutopilotSnap
.collect();
}

// `mask_value` everywhere a whole value is masked: it performs the
// trim/lowercase normalization the module contract promises, so the same
// identifier masks identically whatever field or casing it arrived in.
for key in &mut projected.esp_linkage.matched_keys {
if !is_token(&key.value) {
key.value = stable_token(VALUE_KIND, &key.value);
}
key.value = mask_value(&key.value);
}

for entry in &mut projected.coverage {
Expand All @@ -230,9 +241,7 @@ fn redact_named_values(values: &mut [IntuneNamedValue]) {
.iter()
.any(|key| key.eq_ignore_ascii_case(&value.name))
{
if !is_token(&value.value) {
value.value = stable_token(VALUE_KIND, &value.value);
}
value.value = mask_value(&value.value);
} else {
value.value = redact_text(&value.value);
}
Expand All @@ -249,19 +258,9 @@ fn redact_conflict_value(value: &str) -> String {
.iter()
.any(|key| key.eq_ignore_ascii_case(name)) =>
{
if is_token(raw) {
value.to_owned()
} else {
format!("{name}={}", stable_token(VALUE_KIND, raw))
}
}
_ => {
if is_token(value) {
value.to_owned()
} else {
stable_token(VALUE_KIND, value)
}
format!("{name}={}", mask_value(raw))
}
_ => mask_value(value),
}
}

Expand Down Expand Up @@ -315,6 +314,40 @@ mod tests {
assert!(!masked.contains(&hash), "got {masked}");
}

/// A Base64 hardware hash may end in `=`, `==`, `+`, or `/` -- none of which
/// is a word character, so a trailing `\b` cannot close the match at them.
/// Every one of these must be masked in full, or raw identity material rides
/// out in the padding after a partial match (ADR-004: restricted values are
/// absent from export).
#[test]
fn a_base64_blob_ending_in_punctuation_is_masked_in_full() {
// Each blob is a >=40 char Base64 run terminated by a non-word char.
for blob in [
format!("{}=", "A".repeat(43)), // single pad
format!("{}==", "A".repeat(42)), // double pad
format!("{}+", "A".repeat(43)), // plus terminal
format!("{}/", "A".repeat(43)), // slash terminal
] {
let masked = redact_text(&format!("hardware hash {blob} was reported"));
assert!(
!masked.contains(&blob),
"the whole blob must be masked, got {masked}"
);
// No fragment of the original run -- including the padding/punctuation
// tail -- may survive between the surrounding words.
assert!(
!masked.contains("AA"),
"no run of the blob may survive, got {masked}"
);
for tail in ['=', '+', '/'] {
assert!(
!masked.contains(&format!("{tail} was")),
"a punctuation tail must not dangle after masking, got {masked}"
);
}
}
}

#[test]
fn an_already_masked_whole_value_is_left_alone() {
let token = stable_token(VALUE_KIND, "abc");
Expand All @@ -326,6 +359,40 @@ mod tests {
);
}

/// Every whole-value masking site must normalize case and surrounding
/// space before hashing, or the same identifier arriving in two casings
/// would mask to two different tokens and destroy the very correlation the
/// projection exists to preserve.
#[test]
fn whole_value_masking_normalizes_case_and_space_at_every_site() {
let canonical = mask_value("abcdabcd-1234-5678-9012-abcdabcdabcd");

// Named-data values with a sensitive key.
let mut values = vec![IntuneNamedValue {
name: "entraDeviceId".to_owned(),
value: " ABCDABCD-1234-5678-9012-ABCDABCDABCD ".to_owned(),
}];
redact_named_values(&mut values);
assert_eq!(values[0].value, canonical);

// Conflict values, both shapes.
assert_eq!(
redact_conflict_value("entraDeviceId= ABCDABCD-1234-5678-9012-ABCDABCDABCD "),
format!("entraDeviceId={canonical}")
);
assert_eq!(
redact_conflict_value(" ABCDABCD-1234-5678-9012-ABCDABCDABCD "),
canonical
);

// The identity-field path already goes through mask_value; the token
// must line up with all of the above.
assert_eq!(
redact_opt(&Some("Abcdabcd-1234-5678-9012-abcdabcdABCD".to_owned())),
Some(canonical)
);
}

#[test]
fn a_non_sensitive_conflict_key_still_masks_its_value() {
// Falling through to the bare-value branch is deliberate: a conflict
Expand Down
Loading