Skip to content
Merged
Show file tree
Hide file tree
Changes from 56 commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
ad9eba9
feat(intune): parse Company Portal Windows LocalState logs (#366)
adamgell Jul 31, 2026
204f1a8
docs(intune): make the Company Portal contract claims match the code
adamgell Jul 31, 2026
51ba6f4
chore: sync app lockfile version
adamgell Aug 3, 2026
c748bb9
test(intune): expose Company Portal review gaps
adamgell Aug 3, 2026
dedcd55
fix(intune): close Company Portal review gaps
adamgell Aug 3, 2026
d67d616
test(intune): expose severity redaction leak
adamgell Aug 3, 2026
d946b1c
fix(intune): seal Company Portal redacted evidence
adamgell Aug 3, 2026
062be6f
test(intune): expose empty Company Portal coverage success
adamgell Aug 3, 2026
c7c0994
test(intune): expose Company Portal split tail records
adamgell Aug 3, 2026
60630ac
fix(intune): reject empty Company Portal coverage success
adamgell Aug 3, 2026
b8e0ccd
fix(intune): preserve Company Portal tail continuations
adamgell Aug 3, 2026
2839eb9
test(intune): expose initial Company Portal tail continuation
adamgell Aug 3, 2026
5fd48dc
fix(intune): replace initial Company Portal tail records
adamgell Aug 3, 2026
8c2de02
test(intune): expose multiline tail start line drift
adamgell Aug 3, 2026
76655aa
fix(intune): start tails after physical line count
adamgell Aug 3, 2026
64416dc
test(intune): expose unbounded initial tail reparsing
adamgell Aug 3, 2026
706f7e8
test(intune): pin bounded tail amendments
adamgell Aug 3, 2026
9c58ae9
fix(intune): bound initial tail amendments
adamgell Aug 3, 2026
8853278
fix(intune): apply tail amendments idempotently
adamgell Aug 3, 2026
2d2ace2
test(intune): expose tail accounting gaps
adamgell Aug 3, 2026
b0a931b
fix(intune): keep aggregate tail counts authoritative
adamgell Aug 3, 2026
48a5f74
test(intune): expose tail review gaps
adamgell Aug 3, 2026
1a8c9a3
fix(intune): preserve aggregate tail identity
adamgell Aug 3, 2026
3eb9750
fix(intune): reject discontinuous tail updates
adamgell Aug 3, 2026
cf3df61
test(intune): expose untracked aggregate append
adamgell Aug 3, 2026
0fc9696
fix(intune): ignore untracked aggregate appends
adamgell Aug 3, 2026
7551b60
test(intune): expose aggregate amendment coverage gaps
adamgell Aug 3, 2026
c52084e
fix(intune): preserve aggregate amendment totals
adamgell Aug 3, 2026
c0dd7e0
test(intune): expose short BOM reads
adamgell Aug 3, 2026
0512671
fix(intune): consume bounded BOM probes fully
adamgell Aug 3, 2026
9c7e3fb
perf(intune): index aggregate tail seed entries
adamgell Aug 3, 2026
b666ad2
test(intune): expose stale Company Portal tail seed
adamgell Aug 3, 2026
ca4ac2c
fix(intune): discard incompatible tail seeds
adamgell Aug 3, 2026
872a0ac
test(intune): expose duplicate aggregate tail keys
adamgell Aug 3, 2026
de85431
fix(intune): reject ambiguous aggregate tail seeds
adamgell Aug 3, 2026
27dcbcb
test(intune): name tail amendment behavior precisely
adamgell Aug 3, 2026
43e75fb
test(intune): cover unmatched tail amendments
adamgell Aug 3, 2026
57d7227
refactor(intune): centralize tail amendment merge
adamgell Aug 3, 2026
4b7274e
test(intune): expose rejected amendment notifications
adamgell Aug 3, 2026
738131a
fix(intune): suppress rejected amendment updates
adamgell Aug 3, 2026
35cca20
test(intune): expose aggregate tail coverage drift
adamgell Aug 3, 2026
2450d7c
fix(intune): retain aggregate tail coverage gaps
adamgell Aug 3, 2026
3ee33ee
test(intune): expose tail event coverage gaps
adamgell Aug 3, 2026
165f6d4
fix(intune): validate and account tail events
adamgell Aug 3, 2026
d0765e4
test(intune): expose tail line zero provenance
adamgell Aug 3, 2026
04e1ecb
fix(intune): preserve physical tail line provenance
adamgell Aug 3, 2026
5900f88
test(intune): expose tail payload validation gaps
adamgell Aug 4, 2026
90cfe12
fix(intune): validate tail payload ranges and fields
adamgell Aug 4, 2026
0138373
Merge remote-tracking branch 'origin/main' into staff/intune-366-corr…
adamgell Aug 4, 2026
445bc99
style(parser): format Company Portal redaction change
adamgell Aug 4, 2026
474643a
fix(intune): bound tail range validation memory
adamgell Aug 4, 2026
98b38a3
fix(intune): keep aggregate tails stable across batches
adamgell Aug 4, 2026
176ba82
fix(intune): restart aggregate tails on source reload
adamgell Aug 4, 2026
1553537
merge: restack Company Portal Windows LocalState parser on origin/main
adamgell Aug 5, 2026
0838bec
fix(intune): address residual Company Portal Windows review gaps (#36…
adamgell Aug 5, 2026
8a65ef2
Merge origin/main into codex/intune-366-review-fixes-r119
adamgell Aug 8, 2026
46e7a6a
test(sccm): normalize evidence projections for Windows separators
adamgell Aug 8, 2026
e485cac
ci(temp): dump app_lib import table before the failing Windows step
adamgell Aug 8, 2026
e4b98d8
Revert "ci(temp): dump app_lib import table before the failing Window…
adamgell Aug 8, 2026
aceb2d1
ci: isolate parser-crate Windows steps in their own target dir
adamgell Aug 8, 2026
08b6b00
Revert "ci: isolate parser-crate Windows steps in their own target dir"
adamgell Aug 8, 2026
a4c6ab6
fix(commands): drop mock_app from the aggregate tail seed unit test
adamgell Aug 8, 2026
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
8 changes: 6 additions & 2 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@
# Real-world log fixtures are byte-sensitive (UTF-8 BOM + CRLF); never normalize them.
src-tauri/tests/fixtures/** -text

# SCCM parser fixtures are byte-sensitive evidence; never normalize them.
crates/cmtraceopen-parser/tests/fixtures/** -text
# Parser-crate fixtures encode BOM, CRLF, and UTF-16LE deliberately; the tests
# assert on the decoded bytes, so a checkout must not rewrite line endings and
# `git diff --check` must not read a deliberate CR as trailing whitespace.
# Covers Company Portal, SCCM, and other pure-parser evidence corpora.
crates/cmtraceopen-parser/tests/fixtures/** -text -whitespace
src-tauri/tests/corpus/company_portal/** -text -whitespace
Comment on lines 7 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the corpus path referenced by .gitattributes exists and holds fixtures.
fd -t d -H 'company_portal' src-tauri || echo "no company_portal dir under src-tauri"
fd -t d -H 'corpus' src-tauri || echo "no corpus dir under src-tauri"
fd -t f . src-tauri/tests 2>/dev/null | head -50
# Show which fixture paths the parser tests actually load.
rg -n 'include_(str|bytes)!' crates/cmtraceopen-parser/tests | head -50

Repository: adamgell/cmtraceopen

Length of output: 10130


🏁 Script executed:

#!/bin/bash
printf '%s\n' '--- .gitattributes ---'
cat -n .gitattributes
printf '%s\n' '--- legacy fixture files and byte markers ---'
git ls-files 'src-tauri/tests/fixtures/**' 'src-tauri/tests/corpus/company_portal/**' |
while IFS= read -r f; do
  printf '%s ' "$f"
  python3 - "$f" <<'PY'
import sys
p = sys.argv[1]
b = open(p, "rb").read()
print({"bytes": len(b), "cr": b.count(b"\r"), "lf": b.count(b"\n"), "utf16le_bom": b.startswith(b"\xff\xfe"), "utf8_bom": b.startswith(b"\xef\xbb\xbf")})
PY
done
printf '%s\n' '--- effective attributes ---'
git check-attr -a -- \
  src-tauri/tests/fixtures/ccm/psadt_install.log \
  src-tauri/tests/corpus/company_portal/clean/Log_1.log \
  crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/encoding-utf16le/Log_1.log
printf '%s\n' '--- whitespace check for legacy fixtures ---'
git diff --check --no-index /dev/null src-tauri/tests/fixtures/ccm/psadt_install.log || true
git diff --check --no-index /dev/null src-tauri/tests/corpus/company_portal/clean/Log_1.log || true

Repository: adamgell/cmtraceopen

Length of output: 24113


Add -whitespace to src-tauri/tests/fixtures/**.

These fixtures contain CRLF data, and git diff --check reports the deliberate CR bytes as trailing whitespace.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.gitattributes around lines 7 - 14, Add the -whitespace attribute to the
existing src-tauri/tests/fixtures/** rule in .gitattributes, preserving its
-text behavior so deliberate CRLF fixture data is excluded from git diff --check
whitespace validation.

8 changes: 6 additions & 2 deletions .github/workflows/cmtrace-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -242,8 +242,12 @@ jobs:
key: ${{ runner.os }}-esp-cargo-${{ hashFiles('Cargo.lock') }}
restore-keys: ${{ runner.os }}-esp-cargo-

- name: Test ESP parser contracts
run: cargo test --locked -p cmtraceopen-parser --test esp_diagnostics
# Run all parser-crate targets on Windows in addition to the Linux checks.
- name: Test parser crate contracts
run: cargo test --locked -p cmtraceopen-parser

- name: Clippy parser crate
run: cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings
Comment on lines +249 to +250

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Does the parser crate contain any Windows-gated code that clippy would only lint on Windows?
rg -n 'cfg\(target_os *= *"windows"\)|cfg\(windows\)|target_family *= *"windows"' crates/cmtraceopen-parser || echo "no windows-gated code in cmtraceopen-parser"
rg -n -A20 '^\[(target|dependencies|features)' crates/cmtraceopen-parser/Cargo.toml

Repository: adamgell/cmtraceopen

Length of output: 480


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- workflow context ---'
sed -n '60,100p;225,260p' .github/workflows/cmtrace-ci.yml
printf '%s\n' '--- parser crate source files and cfg attributes ---'
git ls-files crates/cmtraceopen-parser
rg -n 'cfg|target_os|target_family|windows|winapi|windows-sys' crates/cmtraceopen-parser || true
printf '%s\n' '--- workspace and package metadata ---'
sed -n '1,80p' crates/cmtraceopen-parser/Cargo.toml
rg -n -A12 -B3 'cmtraceopen-parser|workspace|target\.' Cargo.toml crates .github/workflows/cmtrace-ci.yml

Repository: adamgell/cmtraceopen

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re

workflow = Path(".github/workflows/cmtrace-ci.yml").read_text()
manifest = Path("crates/cmtraceopen-parser/Cargo.toml").read_text()
sources = [
    p for p in Path("crates/cmtraceopen-parser").rglob("*")
    if p.is_file() and p.suffix in {".rs", ".toml"}
]

windows_cfg = []
for path in sources:
    text = path.read_text(errors="replace")
    if re.search(r'#\s*\[\s*cfg\s*\([^]]*(?:windows|target_os|target_family)[^]]*\)', text):
        windows_cfg.append(str(path))

target_sections = re.findall(
    r'(?ms)^\[target[^\]]*\].*?(?=^\[|\Z)', manifest
)

commands = re.findall(
    r'(?ms)^\s*- name: (Parser crate tests|Parser crate clippy|Test parser crate contracts|Clippy parser crate)\s*$.*?^\s+run: (.+)$',
    workflow,
)

print("windows_cfg_files:", windows_cfg or "none")
print("target_manifest_sections:", len(target_sections))
print("parser_commands:")
for name, command in commands:
    print(f"  {name}: {command}")
print("linux_command_count:", workflow.count(
    "cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings"
))
PY

Repository: adamgell/cmtraceopen

Length of output: 10627


Remove the redundant Windows parser clippy step. The parser crate has no Windows-gated source or target-specific manifest sections. Keep the Windows test step for Windows path handling. Rename the steps to Parser crate tests and Parser crate clippy.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/cmtrace-ci.yml around lines 249 - 250, Remove the
redundant Windows-specific parser clippy step while retaining the Windows parser
test step for path-handling coverage. Rename the relevant parser test step to
“Parser crate tests” and the remaining parser clippy step to “Parser crate
clippy” in the workflow.


- name: Test native ESP diagnostics
run: cargo test --locked -p cmtrace-open --all-features --test esp_diagnostics_sources
Expand Down
5 changes: 5 additions & 0 deletions crates/cmtraceopen-parser/src/esp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,8 @@ pub use redaction::*;
pub use reducer::*;
pub use rules::*;
pub use timeline::*;

// Crate-internal: the free-text redaction pipeline is evidence-agnostic, so
// other evidence modules reuse it rather than growing a second rule table that
// could drift out of step with this one. Not part of the published API.
pub(crate) use redaction::redact_text;
6 changes: 5 additions & 1 deletion crates/cmtraceopen-parser/src/esp/redaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1387,7 +1387,11 @@ fn standalone_digest_match_is_safe_narrative(value: &str, captures: &regex::Capt
&& value[matched.end()..].is_empty()
}

fn redact_text(value: &str) -> String {
/// Redact arbitrary evidence text we did not author.
///
/// `pub(crate)` so sibling evidence modules reuse this rule table instead of
/// writing their own; the rules are about text content, not about ESP.
pub(crate) fn redact_text(value: &str) -> String {
redact_text_for_context(value, TextRedactionContext::Arbitrary)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
//! Detection confirmation for Company Portal Windows LocalState logs.
//!
//! # Why a file name is never enough
//!
//! `Log_<n>.log` is a completely generic name — any UWP package can write one
//! into its own `LocalState` folder. The path may therefore only *nominate* a
//! candidate; the claim has to come from record structure. Confirmation
//! requires field 6 to be a hyphenated GUID **and** field 7 to be a
//! dash-separated version triple, which is what a generic
//! `2024-11-15T16:50:07Z INFO something` line can never satisfy.

use super::grammar::parse_record_fields;
use super::models::{
CompanyPortalGrammarSupport, CompanyPortalLogFileIdentity, CompanyPortalLogFileKind,
};

/// What a single line contributed to the detection decision.
///
/// Presence of this value already means the line was a confirmed record
/// (`classify_line` returns `None` otherwise), so the only variable fact is
/// whether the app version is in the validated set.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompanyPortalLineClassification {
/// The record's app version is one the grammar was derived from.
pub app_version_is_validated: bool,
}

/// Classify one line for the detection sampler.
///
/// Returns `None` for anything that is not a confirmed record, so the caller's
/// counters only ever rise on structurally proven lines.
pub fn classify_line(line: &str) -> Option<CompanyPortalLineClassification> {
let fields = parse_record_fields(line)?;
Some(CompanyPortalLineClassification {
app_version_is_validated: matches!(
fields.app_version.support,
CompanyPortalGrammarSupport::Validated
),
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Check whether a line matches the Company Portal Windows log record grammar.
///
/// The boolean convenience form of [`classify_line`], kept to match the
/// `matches_*_record` house convention used by the other dedicated parsers.
/// `parser::detect` calls [`classify_line`] directly rather than this, because
/// it needs the returned classification to count validated app versions, not
/// just a yes/no.
pub fn matches_company_portal_log_record(line: &str) -> bool {
classify_line(line).is_some()
}

/// Whether a file name follows a LocalState Company Portal log pattern.
///
/// A *hint only*. `Log_1.log` belongs to every UWP package that wants it.
pub fn is_company_portal_log_file_name(file_name: &str) -> bool {
!matches!(
parse_file_identity(file_name).kind,
CompanyPortalLogFileKind::Unrecognized
)
}

/// Derive the file identity from a file name.
///
/// Recognizes `Log_<n>.log` (the main app log) and `Log.<BridgeName>_<n>.log`
/// (the ConfigMgr / IME / launcher bridges, which are written by the same
/// logger and share this grammar). Rotation members keep their index so they
/// stay distinct: no published evidence says whether `Log_1` is the newest or
/// the oldest member, so members are never reordered or deduplicated.
pub fn parse_file_identity(file_name: &str) -> CompanyPortalLogFileIdentity {
let unrecognized = || CompanyPortalLogFileIdentity {
file_name: file_name.to_string(),
kind: CompanyPortalLogFileKind::Unrecognized,
bridge_name: None,
rotation_index: None,
};

let Some(stem) = strip_suffix_ci(file_name, ".log") else {
return unrecognized();
};
let Some(rest) = strip_prefix_ci(stem, "Log") else {
return unrecognized();
};

// `Log_<n>` — the main app log.
if let Some(index) = rest.strip_prefix('_') {
return match parse_rotation_index(index) {
Some(rotation_index) => CompanyPortalLogFileIdentity {
file_name: file_name.to_string(),
kind: CompanyPortalLogFileKind::App,
bridge_name: None,
rotation_index: Some(rotation_index),
},
None => unrecognized(),
};
}

// `Log.<BridgeName>_<n>` — a bridge log.
let Some(bridge) = rest.strip_prefix('.') else {
return unrecognized();
};
let Some((bridge_name, index)) = bridge.rsplit_once('_') else {
return unrecognized();
};
match (bridge_name.is_empty(), parse_rotation_index(index)) {
(false, Some(rotation_index)) => CompanyPortalLogFileIdentity {
file_name: file_name.to_string(),
kind: CompanyPortalLogFileKind::Bridge,
bridge_name: Some(bridge_name.to_string()),
rotation_index: Some(rotation_index),
},
_ => unrecognized(),
}
}

fn parse_rotation_index(raw: &str) -> Option<u32> {
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
raw.parse().ok()
}

fn strip_prefix_ci<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
let candidate = value.get(..prefix.len())?;
candidate
.eq_ignore_ascii_case(prefix)
.then(|| &value[prefix.len()..])
}

fn strip_suffix_ci<'a>(value: &'a str, suffix: &str) -> Option<&'a str> {
let split = value.len().checked_sub(suffix.len())?;
let candidate = value.get(split..)?;
candidate
.eq_ignore_ascii_case(suffix)
.then(|| &value[..split])
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn matcher_accepts_the_published_record() {
assert!(matches_company_portal_log_record(
"2024-11-15T16:50:07.2850341Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 [Configuration Manager Trace Listener] started"
));
}

#[test]
fn matcher_rejects_generic_and_plain_lines() {
assert!(!matches_company_portal_log_record("Just plain text"));
assert!(!matches_company_portal_log_record(
"2024-01-15 14:30:00 some generic timestamped line"
));
assert!(!matches_company_portal_log_record(
"2024-11-15T16:50:07.2850341Z INFO App started"
));
}

#[test]
fn matcher_rejects_a_column_aligned_log_without_guid_and_version_columns() {
// Seven aligned columns, an ISO instant and a severity token — and it is
// still refused, because field 6 is not a GUID and field 7 is not a
// dash-separated triple.
assert!(!matches_company_portal_log_record(
"2026-05-04T08:12:31.4410000Z INFO Startup Foreground 0 Shell 1.2.3 session started"
));
}

#[test]
fn classification_reports_validated_versus_experimental_app_versions() {
let validated = classify_line(
"2024-11-15T16:50:07.2850341Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 ok",
)
.expect("record must classify");
assert!(validated.app_version_is_validated);

let experimental = classify_line(
"2026-02-03T09:15:00.1230000Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 13-4-2 ok",
)
.expect("record must classify");
assert!(!experimental.app_version_is_validated);
}

#[test]
fn app_log_identity_keeps_the_rotation_index() {
let identity = parse_file_identity("Log_3.log");
assert_eq!(identity.kind, CompanyPortalLogFileKind::App);
assert_eq!(identity.rotation_index, Some(3));
assert_eq!(identity.bridge_name, None);
}

#[test]
fn bridge_log_identity_keeps_the_bridge_name() {
let identity = parse_file_identity("Log.ConfigurationManagerBridge_1.log");
assert_eq!(identity.kind, CompanyPortalLogFileKind::Bridge);
assert_eq!(
identity.bridge_name.as_deref(),
Some("ConfigurationManagerBridge")
);
assert_eq!(identity.rotation_index, Some(1));
}

#[test]
fn unrelated_file_names_are_unrecognized() {
for name in [
"IntuneManagementExtension.log",
"Log.log",
"Log_.log",
"Log_abc.log",
"Log._1.log",
"Logger_1.log",
"Log_1.txt",
] {
assert_eq!(
parse_file_identity(name).kind,
CompanyPortalLogFileKind::Unrecognized,
"{name}"
);
assert!(!is_company_portal_log_file_name(name), "{name}");
}
}

#[test]
fn file_name_matching_is_case_insensitive() {
assert!(is_company_portal_log_file_name("log_1.LOG"));
assert!(is_company_portal_log_file_name("LOG.BridgeLauncher_2.log"));
}
}
Loading
Loading