From 41fe563d130b0e160e6a7fdda4679b1a00d51964 Mon Sep 17 00:00:00 2001 From: Mark Watts Date: Fri, 21 Aug 2026 11:38:37 +0100 Subject: [PATCH 01/23] Add CI workflow and sigma rules sync script - .github/workflows/ci.yml: GitHub Actions workflow for testing and building on macOS 14 - scripts/sync_sigma_rules.sh: Developer tool to sync bundled Sigma rules from upstream Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 16 +++++++++ scripts/sync_sigma_rules.sh | 66 +++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100755 scripts/sync_sigma_rules.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fff1545 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,16 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - name: Run tests + run: swift test + - name: Build app + run: bash scripts/build_app.sh + continue-on-error: true diff --git a/scripts/sync_sigma_rules.sh b/scripts/sync_sigma_rules.sh new file mode 100755 index 0000000..096db38 --- /dev/null +++ b/scripts/sync_sigma_rules.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# Syncs bundled SigmaHQ rules from upstream. This script is manually invoked by +# developers and is never called by the app or build process. +set -euo pipefail + +# Check that we're in the repo root +if [[ ! -d "Resources/Rules/imported" ]]; then + echo "Error: Resources/Rules/imported not found. Run this script from the repo root." >&2 + exit 1 +fi + +# Create and trap cleanup of temp directory +TMPDIR=$(mktemp -d) +trap "rm -rf '$TMPDIR'" EXIT + +echo "==> Cloning SigmaHQ/sigma (shallow, depth 1)..." +git clone --depth 1 https://github.com/SigmaHQ/sigma "$TMPDIR/sigma" + +updated=0 +unchanged=0 +missing_upstream=0 + +echo "==> Syncing imported (macos/process_creation)..." +for file in Resources/Rules/imported/*.yml; do + filename=$(basename "$file") + upstream_file="$TMPDIR/sigma/rules/macos/process_creation/$filename" + + if [[ -f "$upstream_file" ]]; then + if ! cmp -s "$file" "$upstream_file"; then + cp "$upstream_file" "$file" + ((updated++)) + else + ((unchanged++)) + fi + else + echo "Warning: $filename no longer exists upstream (not removed locally)" >&2 + ((missing_upstream++)) + fi +done + +echo "==> Syncing imported-portable (linux/process_creation)..." +for file in Resources/Rules/imported-portable/*.yml; do + filename=$(basename "$file") + upstream_file="$TMPDIR/sigma/rules/linux/process_creation/$filename" + + if [[ -f "$upstream_file" ]]; then + if ! cmp -s "$file" "$upstream_file"; then + cp "$upstream_file" "$file" + ((updated++)) + else + ((unchanged++)) + fi + else + echo "Warning: $filename no longer exists upstream (not removed locally)" >&2 + ((missing_upstream++)) + fi +done + +echo "" +echo "==> Summary" +git diff --stat Resources/Rules +echo "" +echo "Updated: $updated, Unchanged: $unchanged, Missing upstream: $missing_upstream" +echo "" +echo "Note: BundledRulesTests asserts rule count and IDs." +echo "Run 'swift test' to verify bundled rules." From 107dec0a0812527599de9b8045e5dbe8f218d1fc Mon Sep 17 00:00:00 2001 From: Mark Watts Date: Fri, 21 Aug 2026 11:42:52 +0100 Subject: [PATCH 02/23] Close Sigma-spec gaps: N-of quantifier, base64 modifiers, cased, keyword scope, logsource filtering Generalizes "1 of x*" to any "N of x*", adds base64/base64offset and cased field modifiers with values precomputed at load time, makes keyword selections search every record field instead of just CommandLine, and skips rules at load whose logsource isn't macOS/Linux process_creation (surfaced in the rule browser and covered by new RuleStoreTests/SigmaEngineTests). Co-Authored-By: Claude Fable 5 --- Sources/Argus/DashboardView.swift | 6 + Sources/Argus/Sigma/RuleStore.swift | 59 +++++++-- Sources/Argus/Sigma/SigmaCondition.swift | 22 ++-- Sources/Argus/Sigma/SigmaMatcher.swift | 35 ++++- Sources/Argus/Sigma/SigmaRule.swift | 56 +++++++- Tests/ArgusTests/BundledRulesTests.swift | 6 +- Tests/ArgusTests/RuleStoreTests.swift | 56 ++++++++ Tests/ArgusTests/SigmaEngineTests.swift | 161 +++++++++++++++++++++++ 8 files changed, 373 insertions(+), 28 deletions(-) diff --git a/Sources/Argus/DashboardView.swift b/Sources/Argus/DashboardView.swift index e146a15..15f9fd7 100644 --- a/Sources/Argus/DashboardView.swift +++ b/Sources/Argus/DashboardView.swift @@ -571,6 +571,12 @@ struct RuleManagementPanel: View { .font(.system(size: 10.5)) .foregroundStyle(Theme.accent) } + + if ruleStore.skippedIncompatibleCount > 0 { + Text("\(ruleStore.skippedIncompatibleCount) rules skipped (not macOS process_creation)") + .font(.system(size: 9)) + .foregroundStyle(Theme.dim) + } } .padding(14) .frame(width: 420) diff --git a/Sources/Argus/Sigma/RuleStore.swift b/Sources/Argus/Sigma/RuleStore.swift index 62460a6..5cfdd86 100644 --- a/Sources/Argus/Sigma/RuleStore.swift +++ b/Sources/Argus/Sigma/RuleStore.swift @@ -11,6 +11,10 @@ import Foundation final class RuleStore: ObservableObject { @Published private(set) var rules: [SigmaRule] = [] @Published private(set) var disabledRuleIDs: Set = [] + /// Rules dropped at load time because their `logsource` isn't something + /// this app can actually evaluate (see `isCompatibleLogsource`) — e.g. a + /// Windows-only rule dropped into the user rules folder by mistake. + @Published private(set) var skippedIncompatibleCount: Int = 0 private let bundledRulesDirectory: URL? let userRulesDirectory: URL @@ -34,13 +38,23 @@ final class RuleStore: ObservableObject { func reload() { var loaded: [SigmaRule] = [] + var skipped = 0 if let bundledRulesDirectory { - loaded += Self.loadRules(from: bundledRulesDirectory.appendingPathComponent("imported"), origin: .sigmaHQMacOS) - loaded += Self.loadRules(from: bundledRulesDirectory.appendingPathComponent("imported-portable"), origin: .sigmaHQPortable) - loaded += Self.loadRules(from: bundledRulesDirectory.appendingPathComponent("custom"), origin: .custom) + for (directory, origin) in [ + (bundledRulesDirectory.appendingPathComponent("imported"), RuleOrigin.sigmaHQMacOS), + (bundledRulesDirectory.appendingPathComponent("imported-portable"), RuleOrigin.sigmaHQPortable), + (bundledRulesDirectory.appendingPathComponent("custom"), RuleOrigin.custom), + ] { + let (loadedRules, skippedCount) = Self.loadRules(from: directory, origin: origin) + loaded += loadedRules + skipped += skippedCount + } } - loaded += Self.loadRules(from: userRulesDirectory, origin: .user) + let (userRules, userSkipped) = Self.loadRules(from: userRulesDirectory, origin: .user) + loaded += userRules + skipped += userSkipped rules = loaded.sorted { $0.title < $1.title } + skippedIncompatibleCount = skipped } func isEnabled(_ rule: SigmaRule) -> Bool { @@ -80,19 +94,46 @@ final class RuleStore: ObservableObject { NSWorkspace.shared.activateFileViewerSelecting([userRulesDirectory]) } - nonisolated static func loadRules(from directory: URL, origin: RuleOrigin) -> [SigmaRule] { - guard let files = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else { return [] } + /// Loads every rule in `directory`, returning the rules this app can + /// actually evaluate alongside a count of how many were dropped for + /// having an incompatible `logsource` — a nonisolated static, so the + /// count is handed back rather than written straight to a `@MainActor` + /// property; `reload()` aggregates it across directories. + nonisolated static func loadRules(from directory: URL, origin: RuleOrigin) -> (rules: [SigmaRule], skipped: Int) { + guard let files = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else { return ([], 0) } var result: [SigmaRule] = [] + var skipped = 0 for file in files.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { guard ["yml", "yaml"].contains(file.pathExtension.lowercased()) else { continue } guard let content = try? String(contentsOf: file, encoding: .utf8) else { continue } for (text, value) in YAMLParser.parseDocumentsWithSource(content) { - if let rule = SigmaRule.parse(value, sourceFile: file.lastPathComponent, origin: origin, rawYAML: text) { - result.append(rule) + guard let rule = SigmaRule.parse(value, sourceFile: file.lastPathComponent, origin: origin, rawYAML: text) else { continue } + guard isCompatibleLogsource(product: rule.logsourceProduct, category: rule.logsourceCategory) else { + skipped += 1 + continue } + result.append(rule) } } - return result + return (result, skipped) + } + + /// This app only ever samples macOS process-creation events, so a rule + /// is evaluable only if its `logsource.category` is unset or + /// `process_creation`, and its `logsource.product` is unset, `macos`, + /// or `linux` — `linux` because the bundled imported-portable/ rules are + /// genuinely portable shell/interpreter techniques that apply unchanged + /// on macOS (see RuleOrigin.sigmaHQPortable). + nonisolated private static func isCompatibleLogsource(product: String?, category: String?) -> Bool { + let categoryOK = category == nil || category?.lowercased() == "process_creation" + let productOK: Bool + if let product { + let normalized = product.lowercased() + productOK = normalized == "macos" || normalized == "linux" + } else { + productOK = true + } + return categoryOK && productOK } private func loadDisabledState() { diff --git a/Sources/Argus/Sigma/SigmaCondition.swift b/Sources/Argus/Sigma/SigmaCondition.swift index 2014512..6f5c771 100644 --- a/Sources/Argus/Sigma/SigmaCondition.swift +++ b/Sources/Argus/Sigma/SigmaCondition.swift @@ -1,14 +1,14 @@ import Foundation /// Parser/evaluator for Sigma's `condition:` mini-language — boolean -/// combinations of named selections, `1 of x*` / `all of x*` wildcard -/// quantifiers, `them` (all selections), parens, and `not`. Verified -/// against every distinct condition string appearing in the 75 rules this -/// app ships (see SigmaConditionTests) — a real, if not 100%-of-the-spec, -/// implementation. +/// combinations of named selections, `N of x*` (including `1 of x*`) / +/// `all of x*` wildcard quantifiers, `them` (all selections), parens, and +/// `not`. Verified against every distinct condition string appearing in +/// the 75 rules this app ships (see SigmaConditionTests) — a real, if not +/// 100%-of-the-spec, implementation. indirect enum SigmaConditionNode { case identifier(String) - case oneOf(pattern: String) + case nOf(n: Int, pattern: String) case allOf(pattern: String) case and(SigmaConditionNode, SigmaConditionNode) case or(SigmaConditionNode, SigmaConditionNode) @@ -28,8 +28,10 @@ enum SigmaConditionParser { switch node { case .identifier(let name): return results[name] ?? false - case .oneOf(let pattern): - return matchingNames(pattern, allNames).contains { results[$0] ?? false } + case .nOf(let n, let pattern): + let names = matchingNames(pattern, allNames) + let trueCount = names.filter { results[$0] ?? false }.count + return trueCount >= n case .allOf(let pattern): let names = matchingNames(pattern, allNames) return !names.isEmpty && names.allSatisfy { results[$0] ?? false } @@ -111,10 +113,10 @@ enum SigmaConditionParser { pos += 1 return node } - if tok == "1", pos + 2 < tokens.count, tokens[pos + 1].lowercased() == "of" { + if let n = Int(tok), n > 0, pos + 2 < tokens.count, tokens[pos + 1].lowercased() == "of" { let pattern = tokens[pos + 2] pos += 3 - return .oneOf(pattern: pattern) + return .nOf(n: n, pattern: pattern) } if tok.lowercased() == "all", pos + 2 < tokens.count, tokens[pos + 1].lowercased() == "of" { let pattern = tokens[pos + 2] diff --git a/Sources/Argus/Sigma/SigmaMatcher.swift b/Sources/Argus/Sigma/SigmaMatcher.swift index 30c4964..3e643a7 100644 --- a/Sources/Argus/Sigma/SigmaMatcher.swift +++ b/Sources/Argus/Sigma/SigmaMatcher.swift @@ -8,6 +8,9 @@ import Foundation /// field = OR unless `|all` is present (then AND), and a selection given as /// a YAML list of field-groups = OR across the list (AND within each group) /// — the shape SigmaHQ uses for "either this combination, or that one". +/// `base64` / `base64offset` encode the rule value before comparing, and +/// `cased` (like base64) compares case-sensitively instead of the default +/// lowercased comparison. enum SigmaMatcher { static func matches(_ rule: SigmaRule, record: [String: String]) -> Bool { guard let node = rule.parsedCondition else { return false } @@ -26,8 +29,14 @@ enum SigmaMatcher { private static func evaluateItem(_ item: SigmaSelectionItem, record: [String: String]) -> Bool { switch item { case .keywords(let words): - let haystack = (record["CommandLine"] ?? "").lowercased() - return words.contains { haystack.contains($0.lowercased()) } + // Keyword selections have no field name, so per spec they're + // matched against every field present on the record, not just + // CommandLine. + let haystacks = record.values.map { $0.lowercased() } + return words.contains { word in + let needle = word.lowercased() + return haystacks.contains { $0.contains(needle) } + } case .fields(let matches): // A non-empty AND of field matches. Empty means every field in the // group was dropped as malformed at parse time; treat that as a @@ -49,8 +58,26 @@ enum SigmaMatcher { let range = NSRange(haystack.startIndex.. [String] { + let bytes = Array(value.utf8) + let startOffsets = [0, 2, 3] + return (0..<3).map { i -> String in + let padded = Data(repeating: 0, count: i) + Data(bytes) + let encoded = padded.base64EncodedString() + let start = min(startOffsets[i], encoded.count) + let remainder = (bytes.count + i) % 3 + let endTrim: Int + switch remainder { + case 2: endTrim = 3 + case 1: endTrim = 2 + default: endTrim = 0 + } + let startIndex = encoded.index(encoded.startIndex, offsetBy: start) + let endOffset = max(start, encoded.count - endTrim) + let endIndex = encoded.index(encoded.startIndex, offsetBy: endOffset) + return startIndex < endIndex ? String(encoded[startIndex.. SigmaRule? { let docs = YAMLParser.parseDocuments(yaml) guard let first = docs.first else { return nil } @@ -179,6 +253,93 @@ final class SigmaEngineTests: XCTestCase { XCTAssertFalse(SigmaMatcher.matches(rule, record: benign)) } + // MARK: - `N of` quantifier + + func testNOfQuantifierRequiresAtLeastNMatchingSelections() { + guard let rule = parseFirst(nOfSelections) else { return XCTFail("failed to parse") } + + let onlyOne = ["CommandLine": "run marker-a only"] + XCTAssertFalse(SigmaMatcher.matches(rule, record: onlyOne), "1 of 3 selections true should not satisfy '2 of'") + + let twoOfThree = ["CommandLine": "run marker-a and marker-b"] + XCTAssertTrue(SigmaMatcher.matches(rule, record: twoOfThree)) + + let allThree = ["CommandLine": "run marker-a marker-b marker-c"] + XCTAssertTrue(SigmaMatcher.matches(rule, record: allThree)) + } + + func testOneOfQuantifierStillBehavesLikeBefore() { + // "1 of x*" is the N=1 case of the same code path — verify it wasn't + // regressed by generalizing to N. + guard let node = SigmaConditionParser.parse("1 of selection_*") else { return XCTFail("failed to parse") } + XCTAssertTrue(SigmaConditionParser.evaluate(node, results: ["selection_a": false, "selection_b": true], allNames: ["selection_a", "selection_b"])) + XCTAssertFalse(SigmaConditionParser.evaluate(node, results: ["selection_a": false, "selection_b": false], allNames: ["selection_a", "selection_b"])) + } + + // MARK: - base64 / base64offset modifiers + + func testBase64ModifierMatchesEncodedSubstringCaseSensitively() { + guard let rule = parseFirst(base64Marker) else { return XCTFail("failed to parse") } + let encoded = Data("malicious-payload".utf8).base64EncodedString() + + let matching = ["CommandLine": "echo \(encoded) | base64 -d | sh"] + XCTAssertTrue(SigmaMatcher.matches(rule, record: matching)) + + let wrongCase = ["CommandLine": "echo \(encoded.uppercased()) | base64 -d | sh"] + XCTAssertFalse(SigmaMatcher.matches(rule, record: wrongCase), "base64 matching is case-sensitive — must not match on case difference alone") + + let unrelated = ["CommandLine": "echo aGVsbG8gd29ybGQ= | base64 -d"] + XCTAssertFalse(SigmaMatcher.matches(rule, record: unrelated)) + } + + func testBase64OffsetModifierMatchesRegardlessOfByteAlignment() { + guard let rule = parseFirst(base64OffsetMarker) else { return XCTFail("failed to parse") } + let value = "malicious-payload" + + // Embed the value at every possible byte alignment (mod 3) within a + // larger encoded stream and confirm one of the three precomputed + // offset encodings is found regardless of where it lands. + for prefixLength in 0...5 { + let prefix = String(repeating: "X", count: prefixLength) + let fullyEncoded = Data((prefix + value).utf8).base64EncodedString() + let record = ["CommandLine": fullyEncoded] + XCTAssertTrue(SigmaMatcher.matches(rule, record: record), "prefix length \(prefixLength) (offset \(prefixLength % 3)) should still match") + } + + let noPayload = ["CommandLine": Data("totally-unrelated-string".utf8).base64EncodedString()] + XCTAssertFalse(SigmaMatcher.matches(rule, record: noPayload)) + } + + // MARK: - `cased` modifier + + func testCasedModifierRequiresExactCase() { + guard let rule = parseFirst(casedMarker) else { return XCTFail("failed to parse") } + + let exactCase = ["CommandLine": "run --flag MaliciousCase now"] + XCTAssertTrue(SigmaMatcher.matches(rule, record: exactCase)) + + let differentCase = ["CommandLine": "run --flag maliciouscase now"] + XCTAssertFalse(SigmaMatcher.matches(rule, record: differentCase), "cased modifier must not fall back to lowercased comparison") + } + + // MARK: - keyword selections match all fields + + func testKeywordSelectionMatchesAnyFieldNotJustCommandLine() { + guard let rule = parseFirst(keywordMarker) else { return XCTFail("failed to parse") } + + let inCommandLine = ["CommandLine": "run suspicious-marker now", "Image": "/usr/bin/run"] + XCTAssertTrue(SigmaMatcher.matches(rule, record: inCommandLine)) + + let inImageOnly = ["CommandLine": "run now", "Image": "/usr/bin/suspicious-marker"] + XCTAssertTrue(SigmaMatcher.matches(rule, record: inImageOnly), "keyword selections must search every field, not just CommandLine") + + let inParentImageOnly = ["CommandLine": "run now", "Image": "/usr/bin/run", "ParentImage": "/bin/suspicious-marker"] + XCTAssertTrue(SigmaMatcher.matches(rule, record: inParentImageOnly)) + + let nowhere = ["CommandLine": "run now", "Image": "/usr/bin/run"] + XCTAssertFalse(SigmaMatcher.matches(rule, record: nowhere)) + } + func testMultiDocumentFileParsesBothRules() { let combined = netcatReverseShell + "\n---\n" + jxaInMemory let docs = YAMLParser.parseDocuments(combined) From 948e8da23ac9e7f7092e6ad2bb09fe563c44bc53 Mon Sep 17 00:00:00 2001 From: Mark Watts Date: Fri, 21 Aug 2026 11:45:24 +0100 Subject: [PATCH 03/23] Add parent-context caching, ps sampling watchdog, and user field to ProcessMonitor Resolve ParentImage/ParentCommandLine/ParentUser from a pid-keyed cache retained across a few ticks so an already-exited parent still supplies context for Sigma matching. Bound each ps invocation with a hard timeout and surface repeated sampling failures via a new isDegraded published property instead of silently treating them as "no processes". Add the process owner (User/ParentUser) to the Sigma match record. Extracts parsePSOutput, ParentContextCache, and SamplingHealthTracker as pure, independently testable units and adds ProcessMonitorTests covering them. Co-Authored-By: Claude Fable 5 --- Sources/Argus/Models.swift | 4 + Sources/Argus/ProcessMonitor.swift | 194 ++++++++++++++++++--- Tests/ArgusTests/ProcessMonitorTests.swift | 170 ++++++++++++++++++ 3 files changed, 345 insertions(+), 23 deletions(-) create mode 100644 Tests/ArgusTests/ProcessMonitorTests.swift diff --git a/Sources/Argus/Models.swift b/Sources/Argus/Models.swift index 92e26db..26632c0 100644 --- a/Sources/Argus/Models.swift +++ b/Sources/Argus/Models.swift @@ -56,6 +56,10 @@ struct RawProcess: Identifiable, Equatable { /// this normalization, every imported rule's `Image|endswith: '/curl'` /// would silently never match ordinary typed shell usage. let image: String + /// The owning account's short username, as reported by `ps -o user`. + /// macOS short usernames never contain spaces, which is what makes the + /// fixed-width `pid ppid user command...` column parse tractable. + let user: String } /// A process that tripped one or more rules — what the dashboard actually shows. diff --git a/Sources/Argus/ProcessMonitor.swift b/Sources/Argus/ProcessMonitor.swift index 887f36e..e525ec7 100644 --- a/Sources/Argus/ProcessMonitor.swift +++ b/Sources/Argus/ProcessMonitor.swift @@ -1,6 +1,91 @@ import Foundation import Combine +/// Caches pid → image/command/user across polling ticks so a newly-spawned +/// child's ParentImage/ParentCommandLine/ParentUser can still be resolved +/// after the parent has already exited by the next poll — a common race: +/// many LOLBin chains spawn a short-lived parent (e.g. a one-shot `sh -c`) +/// that's gone from the process table before the ~1.2s sample interval +/// elapses. Without this cache those Sigma rules that key off parent +/// context would silently never match such chains. +/// +/// The current sample's entries always overwrite older ones. Entries for +/// pids no longer present in the sample are kept for `retentionTicks` more +/// ticks (default 3, ~30s at the default poll interval) and then dropped — +/// pruning matters because macOS recycles pids; without it, a new, +/// unrelated process could inherit a long-dead process's stale identity as +/// its "parent". +struct ParentContextCache { + struct Entry { + var image: String + var command: String + var user: String + var lastSeenTick: Int + } + + private(set) var entries: [Int32: Entry] = [:] + let retentionTicks: Int + + init(retentionTicks: Int = 3) { + self.retentionTicks = retentionTicks + } + + /// Merges the current sample into the cache and prunes anything not + /// refreshed within the retention window. + mutating func update(with sample: [RawProcess], tick: Int) { + for p in sample { + entries[p.id] = Entry(image: p.image, command: p.command, user: p.user, lastSeenTick: tick) + } + entries = entries.filter { tick - $0.value.lastSeenTick <= retentionTicks } + } + + func image(for pid: Int32) -> String? { entries[pid]?.image } + func command(for pid: Int32) -> String? { entries[pid]?.command } + func user(for pid: Int32) -> String? { entries[pid]?.user } +} + +/// Why a `ps` sample didn't yield a usable process list. +enum SampleFailure: Error { + case launchFailed + case timeout + case decodeFailed +} + +/// Tracks consecutive sampling failures and reports edge-triggered +/// transitions into/out of the "degraded" state. Isolated from the actor and +/// from `Process` so the threshold logic can be driven directly in tests +/// without spawning `ps`. +struct SamplingHealthTracker { + enum Transition { case none, becameDegraded, recovered } + + private(set) var consecutiveFailures = 0 + private(set) var isDegraded = false + let threshold: Int + + init(threshold: Int = 3) { + self.threshold = threshold + } + + /// Only the tick where the failure streak *reaches* the threshold + /// reports `.becameDegraded` — later failures in the same streak report + /// `.none` so callers log the transition once, not on every failure. + mutating func recordFailure() -> Transition { + consecutiveFailures += 1 + if consecutiveFailures == threshold { + isDegraded = true + return .becameDegraded + } + return .none + } + + mutating func recordSuccess() -> Transition { + let wasDegraded = isDegraded + consecutiveFailures = 0 + isDegraded = false + return wasDegraded ? .recovered : .none + } +} + /// Polls the local process table, diffs it against the previous sample to find /// newly-spawned processes, and runs each one through the active Sigma rules. /// @@ -20,6 +105,10 @@ final class ProcessMonitor: ObservableObject { @Published private(set) var suppressedCount: Int = 0 @Published private(set) var historicalEventCount: Int = 0 @Published private(set) var activityLog: [(Date, Int)] = [] // (time, matched-event count) per tick, for the sparkline + /// True once sampling has failed `samplingHealth.threshold` times in a row. + /// A hung or missing `/bin/ps` must not be silently mistaken for "no + /// processes running" — this is the signal the UI can surface instead. + @Published private(set) var isDegraded: Bool = false var riskLevel: Severity { switch riskScore { @@ -41,6 +130,10 @@ final class ProcessMonitor: ObservableObject { private let defaultIntervalSeconds: Double = 1.2 private let defaultHalfLifeSeconds: Double = 55.0 + private var parentCache = ParentContextCache() + private var tickIndex = 0 + private var samplingHealth = SamplingHealthTracker() + func configure(allowlist: AllowlistStore) { self.allowlist = allowlist } @@ -83,8 +176,31 @@ final class ProcessMonitor: ObservableObject { } private func tick() async { - let raw = await Self.sampleProcesses() + let result = await Self.sampleProcesses() sampleCount += 1 + + switch result { + case .failure(let failure): + let transition = samplingHealth.recordFailure() + isDegraded = samplingHealth.isDegraded + if transition == .becameDegraded { + DiagnosticsLog.write("monitor degraded — \(samplingHealth.consecutiveFailures) consecutive sampling failures (\(failure))") + } + // A failed sample is not "every process exited" — knownPIDs, the + // baseline, and this tick's diff are all left untouched so the + // next successful sample resumes from the real prior state. + return + case .success(let raw): + let transition = samplingHealth.recordSuccess() + isDegraded = samplingHealth.isDegraded + if transition == .recovered { + DiagnosticsLog.write("monitor recovered — sampling succeeded") + } + processSample(raw) + } + } + + private func processSample(_ raw: [RawProcess]) { let halfLife = settings?.riskDecayHalfLifeSeconds ?? defaultHalfLifeSeconds let decayFactor = pow(0.5, currentPollInterval / halfLife) riskScore = max(0, riskScore * decayFactor) @@ -93,6 +209,9 @@ final class ProcessMonitor: ObservableObject { let currentPIDs = Set(raw.map(\.id)) defer { knownPIDs = currentPIDs } + parentCache.update(with: raw, tick: tickIndex) + tickIndex += 1 + guard baselined else { baselined = true activityLog.append((Date(), 0)) @@ -105,24 +224,15 @@ final class ProcessMonitor: ObservableObject { !knownPIDs.contains($0.id) && $0.id != ownPID && $0.ppid != ownPID } - // Full-sample lookup so a new process's ParentImage/ParentCommandLine - // can be resolved — several real Sigma rules key off parent context - // (e.g. "curl spawned by bash" is far more specific than "curl" alone). - var imageByPID: [Int32: String] = [:] - var commandByPID: [Int32: String] = [:] - for p in raw { - imageByPID[p.id] = p.image - commandByPID[p.id] = p.command - } - let activeRules = ruleStore?.activeRules ?? [] var matchedThisTick = 0 for proc in newProcs { totalSeen += 1 - var record: [String: String] = ["CommandLine": proc.command, "Image": proc.image] - if let parentImage = imageByPID[proc.ppid] { record["ParentImage"] = parentImage } - if let parentCommand = commandByPID[proc.ppid] { record["ParentCommandLine"] = parentCommand } + var record: [String: String] = ["CommandLine": proc.command, "Image": proc.image, "User": proc.user] + if let parentImage = parentCache.image(for: proc.ppid) { record["ParentImage"] = parentImage } + if let parentCommand = parentCache.command(for: proc.ppid) { record["ParentCommandLine"] = parentCommand } + if let parentUser = parentCache.user(for: proc.ppid) { record["ParentUser"] = parentUser } let rawMatches: [MatchedRule] = activeRules.compactMap { rule in guard SigmaMatcher.matches(rule, record: record) else { return nil } @@ -181,7 +291,7 @@ final class ProcessMonitor: ObservableObject { orbitNodes.removeAll { $0.bornAt < cutoff } } - nonisolated private static func sampleProcesses() async -> [RawProcess] { + nonisolated private static func sampleProcesses() async -> Result<[RawProcess], SampleFailure> { await withCheckedContinuation { cont in DispatchQueue.global(qos: .utility).async { cont.resume(returning: runPS()) @@ -189,33 +299,71 @@ final class ProcessMonitor: ObservableObject { } } - nonisolated private static func runPS() -> [RawProcess] { + /// Hard ceiling on how long a single `ps` invocation may run. Without + /// this, a wedged `/bin/ps` (seen in practice on macOS under heavy I/O + /// contention) blocks `readDataToEndOfFile()` forever, silently freezing + /// the entire poll loop with no error and no indication anything is wrong. + nonisolated private static let samplingTimeoutSeconds: Double = 10 + + nonisolated private static func runPS() -> Result<[RawProcess], SampleFailure> { let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/ps") - process.arguments = ["-axww", "-o", "pid,ppid,command"] + process.arguments = ["-axww", "-o", "pid,ppid,user,command"] let outPipe = Pipe() process.standardOutput = outPipe process.standardError = Pipe() do { try process.run() } catch { - return [] + return .failure(.launchFailed) + } + + // Watchdog: kill `ps` if it hasn't finished by the deadline, so the + // blocking read below is bounded no matter what `ps` does. + let watchdog = DispatchWorkItem { + if process.isRunning { process.terminate() } } + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + samplingTimeoutSeconds, execute: watchdog) + let data = outPipe.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() - guard let output = String(data: data, encoding: .utf8) else { return [] } + watchdog.cancel() + + if process.terminationReason == .uncaughtSignal { + return .failure(.timeout) + } + guard let output = String(data: data, encoding: .utf8) else { return .failure(.decodeFailed) } + return .success(parsePSOutput(output)) + } + /// Parses `ps -axww -o pid,ppid,user,command` output into `RawProcess` + /// records. Pure and side-effect free so it can be exercised directly in + /// tests without spawning `ps`. + /// + /// Tolerant of malformed rows: the header line and any line that doesn't + /// split into the expected four columns (or whose pid/ppid aren't + /// integers) is skipped rather than aborting the whole sample — one odd + /// row from `ps` shouldn't blind the monitor to every other process + /// running at the same time. + nonisolated static func parsePSOutput(_ output: String) -> [RawProcess] { var results: [RawProcess] = [] let lines = output.split(separator: "\n", omittingEmptySubsequences: true) for line in lines.dropFirst() { let trimmed = line.trimmingCharacters(in: .whitespaces) - let parts = trimmed.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: true) - guard parts.count == 3, let pid = Int32(parts[0]), let ppid = Int32(parts[1]) else { continue } - let command = String(parts[2]) + let parts = trimmed.split(separator: " ", maxSplits: 3, omittingEmptySubsequences: true) + guard parts.count == 4, let pid = Int32(parts[0]), let ppid = Int32(parts[1]) else { continue } + let user = String(parts[2]) + // `USER` is left-justified and padded to a fixed column width by + // `ps`, so the run of spaces separating it from `COMMAND` is + // often more than one character. `split(maxSplits:)` only + // collapses a whitespace run when it's not the final split, so + // that padding can survive as leading whitespace on this last + // component — trim it rather than let it corrupt Image/CommandLine. + let command = String(parts[3]).trimmingCharacters(in: .whitespaces) let firstToken = command.split(separator: " ").first.map(String.init) ?? command let short = (firstToken as NSString).lastPathComponent let image = firstToken.contains("/") ? firstToken : "/" + firstToken - results.append(RawProcess(id: pid, ppid: ppid, command: command, executable: short, image: image)) + results.append(RawProcess(id: pid, ppid: ppid, command: command, executable: short, image: image, user: user)) } return results } diff --git a/Tests/ArgusTests/ProcessMonitorTests.swift b/Tests/ArgusTests/ProcessMonitorTests.swift new file mode 100644 index 0000000..cf3582f --- /dev/null +++ b/Tests/ArgusTests/ProcessMonitorTests.swift @@ -0,0 +1,170 @@ +import XCTest +@testable import Argus + +final class ProcessMonitorParsingTests: XCTestCase { + func testParsesNormalLines() { + let output = """ + PID PPID USER COMMAND + 1 0 root /sbin/launchd + 412 1 mark /usr/bin/curl -s https://example.com + """ + let procs = ProcessMonitor.parsePSOutput(output) + XCTAssertEqual(procs.count, 2) + XCTAssertEqual(procs[0].id, 1) + XCTAssertEqual(procs[0].ppid, 0) + XCTAssertEqual(procs[0].user, "root") + XCTAssertEqual(procs[0].command, "/sbin/launchd") + XCTAssertEqual(procs[1].id, 412) + XCTAssertEqual(procs[1].ppid, 1) + XCTAssertEqual(procs[1].user, "mark") + XCTAssertEqual(procs[1].command, "/usr/bin/curl -s https://example.com") + } + + func testHeaderLineIsSkipped() { + let output = """ + PID PPID USER COMMAND + 1 0 root /sbin/launchd + """ + let procs = ProcessMonitor.parsePSOutput(output) + XCTAssertEqual(procs.count, 1) + XCTAssertFalse(procs.contains { $0.command.contains("COMMAND") }) + } + + func testMalformedLinesAreSkipped() { + let output = """ + PID PPID USER COMMAND + 1 0 root /sbin/launchd + not-a-pid 1 mark bash + 5 root + 7 2 mark /bin/echo hi + """ + let procs = ProcessMonitor.parsePSOutput(output) + // Only the two well-formed rows (pid 1, pid 7) should survive; the + // non-numeric pid row and the too-short row are dropped. + XCTAssertEqual(procs.map(\.id), [1, 7]) + } + + func testAbsolutePathImageIsPreservedAsIs() { + let output = """ + PID PPID USER COMMAND + 200 1 mark /usr/bin/curl -s https://example.com + """ + let procs = ProcessMonitor.parsePSOutput(output) + XCTAssertEqual(procs.first?.image, "/usr/bin/curl") + XCTAssertEqual(procs.first?.executable, "curl") + } + + func testBareCommandArgv0IsNormalizedToLeadingSlashImage() { + let output = """ + PID PPID USER COMMAND + 201 1 mark curl -s https://example.com + """ + let procs = ProcessMonitor.parsePSOutput(output) + XCTAssertEqual(procs.first?.image, "/curl") + XCTAssertEqual(procs.first?.executable, "curl") + } + + func testUserColumnIsParsed() { + let output = """ + PID PPID USER COMMAND + 300 1 _spotlight /usr/bin/mdworker + """ + let procs = ProcessMonitor.parsePSOutput(output) + XCTAssertEqual(procs.first?.user, "_spotlight") + } + + func testEmptyOutputYieldsNoProcesses() { + XCTAssertEqual(ProcessMonitor.parsePSOutput(""), []) + XCTAssertEqual(ProcessMonitor.parsePSOutput(" PID PPID USER COMMAND"), []) + } +} + +final class ParentContextCacheTests: XCTestCase { + private func raw(_ id: Int32, ppid: Int32, image: String, user: String = "mark") -> RawProcess { + RawProcess(id: id, ppid: ppid, command: image, executable: (image as NSString).lastPathComponent, image: image, user: user) + } + + func testCurrentSampleIsResolvable() { + var cache = ParentContextCache(retentionTicks: 3) + cache.update(with: [raw(10, ppid: 1, image: "/bin/bash", user: "mark")], tick: 0) + XCTAssertEqual(cache.image(for: 10), "/bin/bash") + XCTAssertEqual(cache.user(for: 10), "mark") + } + + func testRetainsExitedParentWithinGraceWindow() { + var cache = ParentContextCache(retentionTicks: 3) + // Parent pid 10 present at tick 0, then gone from every later sample. + cache.update(with: [raw(10, ppid: 1, image: "/bin/bash")], tick: 0) + + cache.update(with: [raw(11, ppid: 10, image: "/usr/bin/curl")], tick: 1) + XCTAssertEqual(cache.image(for: 10), "/bin/bash", "still within the grace window") + + cache.update(with: [raw(12, ppid: 10, image: "/usr/bin/curl")], tick: 2) + XCTAssertEqual(cache.image(for: 10), "/bin/bash") + + cache.update(with: [raw(13, ppid: 10, image: "/usr/bin/curl")], tick: 3) + XCTAssertEqual(cache.image(for: 10), "/bin/bash", "last tick of the retention window") + } + + func testPrunesAfterGraceWindowExpires() { + var cache = ParentContextCache(retentionTicks: 3) + cache.update(with: [raw(10, ppid: 1, image: "/bin/bash")], tick: 0) + + cache.update(with: [], tick: 4) + XCTAssertNil(cache.image(for: 10), "pid should be pruned once past the retention window") + XCTAssertNil(cache.command(for: 10)) + XCTAssertNil(cache.user(for: 10)) + } + + func testCurrentSampleAlwaysWinsOverCachedEntry() { + var cache = ParentContextCache(retentionTicks: 3) + cache.update(with: [raw(10, ppid: 1, image: "/bin/bash")], tick: 0) + cache.update(with: [raw(10, ppid: 1, image: "/bin/zsh")], tick: 1) + XCTAssertEqual(cache.image(for: 10), "/bin/zsh") + } +} + +final class SamplingHealthTrackerTests: XCTestCase { + func testStaysHealthyBelowThreshold() { + var tracker = SamplingHealthTracker(threshold: 3) + XCTAssertEqual(tracker.recordFailure(), .none) + XCTAssertFalse(tracker.isDegraded) + XCTAssertEqual(tracker.recordFailure(), .none) + XCTAssertFalse(tracker.isDegraded) + } + + func testBecomesDegradedExactlyAtThreshold() { + var tracker = SamplingHealthTracker(threshold: 3) + _ = tracker.recordFailure() + _ = tracker.recordFailure() + XCTAssertEqual(tracker.recordFailure(), .becameDegraded) + XCTAssertTrue(tracker.isDegraded) + } + + func testDoesNotReReportDegradedOnFurtherFailures() { + var tracker = SamplingHealthTracker(threshold: 3) + _ = tracker.recordFailure() + _ = tracker.recordFailure() + _ = tracker.recordFailure() + XCTAssertEqual(tracker.recordFailure(), .none, "already degraded — no repeat transition") + XCTAssertTrue(tracker.isDegraded) + } + + func testRecoversAfterSuccess() { + var tracker = SamplingHealthTracker(threshold: 3) + _ = tracker.recordFailure() + _ = tracker.recordFailure() + _ = tracker.recordFailure() + XCTAssertTrue(tracker.isDegraded) + XCTAssertEqual(tracker.recordSuccess(), .recovered) + XCTAssertFalse(tracker.isDegraded) + XCTAssertEqual(tracker.consecutiveFailures, 0) + } + + func testSuccessWithoutPriorDegradationReportsNone() { + var tracker = SamplingHealthTracker(threshold: 3) + _ = tracker.recordFailure() + XCTAssertEqual(tracker.recordSuccess(), .none) + XCTAssertFalse(tracker.isDegraded) + } +} From 50955693451670016652823ea71e818df2967f0a Mon Sep 17 00:00:00 2001 From: Mark Watts Date: Fri, 21 Aug 2026 11:45:35 +0100 Subject: [PATCH 04/23] Fix swapped trailing-trim in base64offset encodings The reference algorithm drops 3 chars when the final group holds 1 dangling byte and 2 when it holds 2; the cases were inverted, producing encodings one char too long or too short at two of the three offsets. Verified against reference vectors, now asserted directly in tests. Co-Authored-By: Claude Fable 5 --- Sources/Argus/Sigma/SigmaRule.swift | 9 +++++++-- Tests/ArgusTests/SigmaEngineTests.swift | 17 +++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Sources/Argus/Sigma/SigmaRule.swift b/Sources/Argus/Sigma/SigmaRule.swift index 15a2e54..81e8448 100644 --- a/Sources/Argus/Sigma/SigmaRule.swift +++ b/Sources/Argus/Sigma/SigmaRule.swift @@ -226,10 +226,15 @@ struct SigmaRule: Identifiable { let encoded = padded.base64EncodedString() let start = min(startOffsets[i], encoded.count) let remainder = (bytes.count + i) % 3 + // A final group of 1 dangling byte encodes as "XY==" where Y + // carries only 2 real bits — drop 3 chars; 2 dangling bytes + // encode as "XYZ=" where Z carries only 4 real bits — drop 2. + // (Matches the reference implementation's end_offsets of + // (None, -3, -2) indexed by (len + i) % 3.) let endTrim: Int switch remainder { - case 2: endTrim = 3 - case 1: endTrim = 2 + case 1: endTrim = 3 + case 2: endTrim = 2 default: endTrim = 0 } let startIndex = encoded.index(encoded.startIndex, offsetBy: start) diff --git a/Tests/ArgusTests/SigmaEngineTests.swift b/Tests/ArgusTests/SigmaEngineTests.swift index 4f92b6f..72aa2c6 100644 --- a/Tests/ArgusTests/SigmaEngineTests.swift +++ b/Tests/ArgusTests/SigmaEngineTests.swift @@ -298,10 +298,13 @@ final class SigmaEngineTests: XCTestCase { // Embed the value at every possible byte alignment (mod 3) within a // larger encoded stream and confirm one of the three precomputed - // offset encodings is found regardless of where it lands. + // offset encodings is found regardless of where it lands. The suffix + // matters: it forces the bytes after the value to differ from plain + // padding, which is exactly the case the trailing-character trim + // exists for — without it, an over-long encoding still matches. for prefixLength in 0...5 { let prefix = String(repeating: "X", count: prefixLength) - let fullyEncoded = Data((prefix + value).utf8).base64EncodedString() + let fullyEncoded = Data((prefix + value + "; rm -rf /tmp/x").utf8).base64EncodedString() let record = ["CommandLine": fullyEncoded] XCTAssertTrue(SigmaMatcher.matches(rule, record: record), "prefix length \(prefixLength) (offset \(prefixLength % 3)) should still match") } @@ -310,6 +313,16 @@ final class SigmaEngineTests: XCTestCase { XCTAssertFalse(SigmaMatcher.matches(rule, record: noPayload)) } + func testBase64OffsetEncodingsMatchReferenceVectors() { + // Computed with the reference implementation + // (base64.b64encode(b' '*i + val)[start[i]:end[(len(val)+i)%3]]). + XCTAssertEqual(SigmaRule.base64OffsetEncodings(of: "/bin/bash"), + ["L2Jpbi9iYXNo", "9iaW4vYmFza", "vYmluL2Jhc2"]) + XCTAssertEqual(SigmaRule.base64OffsetEncodings(of: "bash -i"), + ["YmFzaCAta", "Jhc2ggLW", "iYXNoIC1p"]) + XCTAssertEqual(SigmaRule.base64OffsetEncodings(of: "A"), ["Q", "", "B"]) + } + // MARK: - `cased` modifier func testCasedModifierRequiresExactCase() { From 8028be3bb3e32568e3b4749ace18e1d0c9767a18 Mon Sep 17 00:00:00 2001 From: Mark Watts Date: Fri, 21 Aug 2026 11:55:56 +0100 Subject: [PATCH 05/23] Start monitoring at launch, add launch-at-login, surface degraded state Monitoring and notification-permission wiring now happens in ArgusApp.init() instead of the dashboard window's onAppear, so Argus watches processes even if the window is never opened. Adds an SMAppService-backed "Launch at login" toggle to Settings (source of truth stays in SMAppService, not AppSettings). Surfaces ProcessMonitor.isDegraded in the menu bar icon, the menu bar flyout, and the dashboard header. Replaces the flyout dismissal's fragile window-title check with NSWindow.identifier, verified empirically to reliably match the Window scene's id across order-out/reopen cycles. Co-Authored-By: Claude Fable 5 --- Sources/Argus/App.swift | 68 ++++++++++++++++++++++-------- Sources/Argus/DashboardView.swift | 70 +++++++++++++++++++++++++++++++ Sources/Argus/MenuBarPanel.swift | 11 +++++ 3 files changed, 131 insertions(+), 18 deletions(-) diff --git a/Sources/Argus/App.swift b/Sources/Argus/App.swift index 4e7543e..1d50eac 100644 --- a/Sources/Argus/App.swift +++ b/Sources/Argus/App.swift @@ -28,24 +28,54 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @main struct ArgusApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - @StateObject private var monitor = ProcessMonitor() - @StateObject private var allowlist = AllowlistStore() - @StateObject private var settings = AppSettings() - @StateObject private var ruleStore = RuleStore() - private let eventStore = EventStore() + @StateObject private var monitor: ProcessMonitor + @StateObject private var allowlist: AllowlistStore + @StateObject private var settings: AppSettings + @StateObject private var ruleStore: RuleStore + private let eventStore: EventStore + + /// Identifies the main dashboard's `NSWindow`. Verified empirically + /// (via a standalone probe app mirroring this app's Window + MenuBarExtra + /// shape) that SwiftUI reliably sets `NSWindow.identifier` to the scene's + /// `id` string for a `Window(id:)` scene — it's present as soon as the + /// window is created, survives `orderOut(nil)`, and is still correct + /// after the window is reopened via `openWindow(id:)`. That makes it a + /// stable, title-independent way to pick the dashboard out of + /// `NSApp.windows` (the menu bar's own popover window, by contrast, has + /// a `nil` identifier and `canBecomeMain == false`). + private static let mainWindowID = "main" + + /// Monitoring must run regardless of whether the main window ever + /// appears — Argus is a menu-bar app first, and someone may never open + /// the dashboard at all. Wiring this in the window's `.onAppear` (the + /// previous approach) meant no monitoring happened until the window was + /// shown. Doing it here, in the app's own init, means it starts at + /// launch every time. + init() { + let allowlistStore = AllowlistStore() + let appSettings = AppSettings() + let rules = RuleStore() + let events = EventStore() + + let m = ProcessMonitor() + m.configure(allowlist: allowlistStore) + m.configure(eventStore: events) + m.configure(settings: appSettings) + m.configure(ruleStore: rules) + m.start() + NotificationManager.requestAuthorizationIfNeeded() + + _monitor = StateObject(wrappedValue: m) + _allowlist = StateObject(wrappedValue: allowlistStore) + _settings = StateObject(wrappedValue: appSettings) + _ruleStore = StateObject(wrappedValue: rules) + eventStore = events + } var body: some Scene { - Window("Argus", id: "main") { + Window("Argus", id: Self.mainWindowID) { DashboardView(monitor: monitor, allowlist: allowlist, eventStore: eventStore, settings: settings, ruleStore: ruleStore) .frame(minWidth: 980, minHeight: 680) - .onAppear { - monitor.configure(allowlist: allowlist) - monitor.configure(eventStore: eventStore) - monitor.configure(settings: settings) - monitor.configure(ruleStore: ruleStore) - monitor.start() - NotificationManager.requestAuthorizationIfNeeded() - } .preferredColorScheme(.dark) } .windowResizability(.contentMinSize) @@ -55,8 +85,8 @@ struct ArgusApp: App { MenuBarExtra { MenuBarPanel(monitor: monitor, dismissFlyout: dismissMenuBarFlyout) } label: { - Image(systemName: "eye.fill") - .foregroundStyle(Theme.color(for: monitor.riskLevel)) + Image(systemName: monitor.isDegraded ? "exclamationmark.triangle.fill" : "eye.fill") + .foregroundStyle(monitor.isDegraded ? Theme.color(for: .elevated) : Theme.color(for: monitor.riskLevel)) } .menuBarExtraStyle(.window) } @@ -66,9 +96,11 @@ struct ArgusApp: App { /// (both `keyWindow?.close()` and toggling `isInserted` failed to /// dismiss it in practice). `orderOut(nil)` skips that machinery /// entirely — it just hides the window — so hide every visible window - /// that isn't the main dashboard. + /// that isn't the main dashboard. Identified by `NSWindow.identifier` + /// (see `mainWindowID` above) rather than by title, which is fragile if + /// the window is ever retitled or localized. private func dismissMenuBarFlyout() { - for window in NSApp.windows where window.isVisible && window.title != "Argus" { + for window in NSApp.windows where window.isVisible && window.identifier?.rawValue != Self.mainWindowID { window.orderOut(nil) } } diff --git a/Sources/Argus/DashboardView.swift b/Sources/Argus/DashboardView.swift index 15f9fd7..eb7fac4 100644 --- a/Sources/Argus/DashboardView.swift +++ b/Sources/Argus/DashboardView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ServiceManagement struct DashboardView: View { @ObservedObject var monitor: ProcessMonitor @@ -58,6 +59,9 @@ struct DashboardView: View { } Spacer() HStack(spacing: 10) { + if monitor.isDegraded { + degradedBadge + } statField(label: "PROCESSES SEEN", value: "\(monitor.totalSeen)") statField(label: "SAMPLES", value: "\(monitor.sampleCount)") @@ -131,6 +135,30 @@ struct DashboardView: View { .padding(.vertical, 4) } + /// Shown in the header only while `ProcessMonitor.isDegraded` is true — + /// i.e. `ps` sampling has failed repeatedly and the process table view + /// is stale. A hung/missing sampler must not look identical to "nothing + /// suspicious happening", so this sits right next to the stats it would + /// otherwise silently undermine. + private var degradedBadge: some View { + HStack(spacing: 4) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10)) + Text("MONITOR DEGRADED") + .font(.system(size: 9, weight: .bold)) + .tracking(1) + } + .foregroundStyle(Theme.color(for: .elevated)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Theme.color(for: .elevated).opacity(0.12)) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Theme.color(for: .elevated).opacity(0.4), lineWidth: 1)) + ) + .help("Process sampling has failed repeatedly — the data shown here may be stale.") + } + /// Same shape as `statField`, but visibly a control: bordered chip, a /// chevron, and an accent-tinted value — so it doesn't blend into the /// read-only stats sitting right next to it. @@ -882,6 +910,7 @@ struct HistoryPanel: View { /// rather than fixed in code. struct SettingsPanel: View { @ObservedObject var settings: AppSettings + @State private var launchAtLoginEnabled = SMAppService.mainApp.status == .enabled var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -943,10 +972,51 @@ struct SettingsPanel: View { } } } + + Divider().background(Theme.border) + + VStack(alignment: .leading, spacing: 6) { + Toggle(isOn: launchAtLoginBinding) { + Text("Launch at login") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Theme.text) + } + .toggleStyle(.switch) + .tint(Theme.accent) + Text("A monitor only protects you while it's running — enable this so Argus starts watching as soon as you log in.") + .font(.system(size: 9.5)) + .foregroundStyle(Theme.dim) + } } .padding(14) .frame(width: 300) .background(Theme.bg) .foregroundStyle(Theme.text) } + + /// `SMAppService` is the source of truth for launch-at-login state — + /// it's deliberately not mirrored into `AppSettings`/UserDefaults, so + /// there's nothing that can drift out of sync with it. Registration is + /// best-effort: on failure (including the expected case of an + /// un-bundled `swift test`/debug run, where `SMAppService` has no real + /// app bundle to register) we log via `DiagnosticsLog` and snap the + /// toggle back to the prior state rather than crash or claim success. + private var launchAtLoginBinding: Binding { + Binding( + get: { launchAtLoginEnabled }, + set: { newValue in + launchAtLoginEnabled = newValue + do { + if newValue { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + } catch { + DiagnosticsLog.write("launch-at-login \(newValue ? "register" : "unregister") failed: \(error.localizedDescription)") + launchAtLoginEnabled = !newValue + } + } + ) + } } diff --git a/Sources/Argus/MenuBarPanel.swift b/Sources/Argus/MenuBarPanel.swift index 78e7e22..3f7b58e 100644 --- a/Sources/Argus/MenuBarPanel.swift +++ b/Sources/Argus/MenuBarPanel.swift @@ -21,6 +21,17 @@ struct MenuBarPanel: View { .foregroundStyle(Theme.color(for: monitor.riskLevel)) } + if monitor.isDegraded { + HStack(spacing: 5) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 9)) + Text("Monitor degraded — process sampling is failing") + .font(.system(size: 9.5, weight: .medium)) + .lineLimit(1) + } + .foregroundStyle(Theme.color(for: .elevated)) + } + GaugeView(score: monitor.riskScore, level: monitor.riskLevel) .scaleEffect(0.7) .frame(height: 90) From 70866c87bc9f3adc9d06128b105f94e633f61cf5 Mon Sep 17 00:00:00 2001 From: Mark Watts Date: Fri, 21 Aug 2026 12:00:26 +0100 Subject: [PATCH 06/23] Add PersistenceWatcher: a second sensor for LaunchAgents/Daemons/periodic ProcessMonitor polls `ps` every ~1.2s, which can miss a process that spawns, writes a persistence artifact, and exits within a single tick. This adds an independent, event-driven watcher on the standard macOS persistence locations (~/Library/LaunchAgents, /Library/LaunchAgents, /Library/LaunchDaemons, /etc/periodic) so the artifact left behind is caught even when the writing process itself was never sampled. Detected changes feed into ProcessMonitor via a new ingestExternal(_:) entry point that mirrors processSample's matched-event handling, minus orbit-node/allowlist handling which don't apply to synthetic, process-less events. Co-Authored-By: Claude Fable 5 --- Sources/Argus/App.swift | 14 + Sources/Argus/PersistenceWatcher.swift | 278 ++++++++++++++++++ Sources/Argus/ProcessMonitor.swift | 23 ++ .../ArgusTests/PersistenceWatcherTests.swift | 197 +++++++++++++ 4 files changed, 512 insertions(+) create mode 100644 Sources/Argus/PersistenceWatcher.swift create mode 100644 Tests/ArgusTests/PersistenceWatcherTests.swift diff --git a/Sources/Argus/App.swift b/Sources/Argus/App.swift index 1d50eac..d541173 100644 --- a/Sources/Argus/App.swift +++ b/Sources/Argus/App.swift @@ -33,6 +33,10 @@ struct ArgusApp: App { @StateObject private var settings: AppSettings @StateObject private var ruleStore: RuleStore private let eventStore: EventStore + /// Held for the app's lifetime purely to keep its `DispatchSource`s + /// alive — `PersistenceWatcher` isn't observed by any view, so nothing + /// else in the view hierarchy retains it. + private let persistenceWatcher: PersistenceWatcher /// Identifies the main dashboard's `NSWindow`. Verified empirically /// (via a standalone probe app mirroring this app's Window + MenuBarExtra @@ -65,11 +69,21 @@ struct ArgusApp: App { m.start() NotificationManager.requestAuthorizationIfNeeded() + // Second, independent sensor: catches persistence artifacts left on + // disk even when the process that wrote them was too short-lived for + // ProcessMonitor's ~1.2s poll to ever sample it. + let watcher = PersistenceWatcher() + watcher.onEvent = { [weak m] event in + m?.ingestExternal(event) + } + watcher.start() + _monitor = StateObject(wrappedValue: m) _allowlist = StateObject(wrappedValue: allowlistStore) _settings = StateObject(wrappedValue: appSettings) _ruleStore = StateObject(wrappedValue: rules) eventStore = events + persistenceWatcher = watcher } var body: some Scene { diff --git a/Sources/Argus/PersistenceWatcher.swift b/Sources/Argus/PersistenceWatcher.swift new file mode 100644 index 0000000..4c4d9e6 --- /dev/null +++ b/Sources/Argus/PersistenceWatcher.swift @@ -0,0 +1,278 @@ +import Foundation +import Darwin + +/// A directory listing reduced to what the diff core needs: filename → +/// last modification date. Kept as a plain dictionary (rather than a richer +/// type) so the diff core has zero filesystem dependency and can be driven +/// directly from literal values in tests. +typealias DirectorySnapshot = [String: Date] + +/// What happened to one entry between two snapshots of the same directory. +enum ArtifactChangeKind: Equatable { + case added + case modified + case removed +} + +/// One filename's change between two snapshots. +struct ArtifactChange: Equatable { + let filename: String + let kind: ArtifactChangeKind +} + +/// Pure diff of two directory snapshots. Deliberately free of any file-system +/// access (no `FileManager`, no `Date()`) so it can be exercised directly and +/// deterministically in tests without a real directory on disk. +enum SnapshotDiff { + /// A name present in both snapshots with an unchanged modification date is + /// not reported — only additions, removals, and entries whose mtime moved + /// count as a change. Results are sorted by filename so callers (and + /// tests) get a deterministic order regardless of dictionary iteration. + static func diff(previous: DirectorySnapshot, current: DirectorySnapshot) -> [ArtifactChange] { + var changes: [ArtifactChange] = [] + + for (name, currentDate) in current { + if let previousDate = previous[name] { + if previousDate != currentDate { + changes.append(ArtifactChange(filename: name, kind: .modified)) + } + } else { + changes.append(ArtifactChange(filename: name, kind: .added)) + } + } + for name in previous.keys where current[name] == nil { + changes.append(ArtifactChange(filename: name, kind: .removed)) + } + + return changes.sorted { $0.filename < $1.filename } + } +} + +/// The persistence-artifact locations Argus watches, each tagged with the +/// MITRE ATT&CK technique its contents represent so synthetic events can +/// cite the right technique without re-deriving it from a path string. +enum PersistenceLocationKind: Equatable { + case launchAgents + case launchDaemons + case periodicCron + + var displayName: String { + switch self { + case .launchAgents: return "LaunchAgents" + case .launchDaemons: return "LaunchDaemons" + case .periodicCron: return "periodic" + } + } + + var technique: String { + switch self { + case .launchDaemons: return "T1543.001" + case .launchAgents: return "T1547.011" + case .periodicCron: return "T1053.003" + } + } +} + +/// Builds the synthetic `ProcessEvent` for one detected artifact change. Pure +/// (no filesystem, no dispatch) so the severity/technique/explanation mapping +/// can be tested directly against literal inputs. +/// +/// These events have no real pid — they represent a change to a persistence +/// artifact on disk, not an observed process — so `pid`/`ppid` are 0 and +/// `executable`/`command` describe the file instead. Allowlist filtering +/// (`AllowlistFilter`, keyed on `executable`) intentionally does NOT apply to +/// these events: an allowlisted *process* is not the same thing as an +/// allowlisted *persistence location*, and silently suppressing a LaunchAgent +/// change because some unrelated executable was once allowlisted would defeat +/// the point of watching the artifact independently of the process that wrote it. +enum PersistenceEventBuilder { + static func makeEvent(filename: String, changeKind: ArtifactChangeKind, locationKind: PersistenceLocationKind, directoryPath: String) -> ProcessEvent { + let verb: String + let severity: Severity + switch changeKind { + case .added: + verb = "added" + severity = .elevated + case .modified: + verb = "modified" + severity = .elevated + case .removed: + verb = "removed" + severity = .watch + } + + let fullPath = (directoryPath as NSString).appendingPathComponent(filename) + let explanation = "A \(locationKind.displayName) entry was \(verb) at \(fullPath). " + + explanationSuffix(for: locationKind) + + let rule = MatchedRule( + name: "Persistence artifact \(verb): \(locationKind.displayName)", + severity: severity, + technique: locationKind.technique, + explanation: explanation + ) + + return ProcessEvent(pid: 0, ppid: 0, executable: filename, command: fullPath, rules: [rule], timestamp: Date()) + } + + private static func explanationSuffix(for kind: PersistenceLocationKind) -> String { + switch kind { + case .launchAgents: + return "LaunchAgents are the classic macOS persistence endgame: a plist here runs automatically every time this user logs in, until it's removed." + case .launchDaemons: + return "LaunchDaemons are the classic macOS persistence endgame: a plist here runs automatically at boot with root privileges, until it's removed." + case .periodicCron: + return "Scripts under /etc/periodic run automatically on a fixed schedule, making this a common cron-style persistence point." + } + } +} + +/// Watches one directory for changes, coalesces bursts of filesystem events +/// into a single rescan, and reports the diff as synthetic `ProcessEvent`s. +/// +/// Deliberately event-driven (`DispatchSource` on an `O_EVTONLY` file +/// descriptor) rather than polled: `ProcessMonitor` already accepts up to +/// ~1.2s of blind spot on the process table in exchange for zero +/// entitlements, and layering a second poll loop here would just move the +/// same trade-off onto the filesystem instead of fixing it. `DispatchSource` +/// gets near-immediate notification without polling at all. +final class DirectoryWatch { + private let path: String + private let kind: PersistenceLocationKind + private let onChange: (ProcessEvent) -> Void + private let queue: DispatchQueue + private let source: DispatchSourceFileSystemObject + private var lastSnapshot: DirectorySnapshot + private var pendingRescan: DispatchWorkItem? + private let debounceInterval: TimeInterval + + /// Fails (returns nil) if `path` doesn't exist or can't be opened — + /// e.g. `/Library/LaunchDaemons` readable but some sandboxed or + /// permission-restricted directory isn't. Callers are expected to log + /// and skip on failure rather than treat it as fatal, since several of + /// the watched locations are optional (not every Mac has all of them, + /// and some require privileges this app may not have). + init?(path: String, kind: PersistenceLocationKind, debounceInterval: TimeInterval = 1.0, onChange: @escaping (ProcessEvent) -> Void) { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), isDirectory.boolValue else { + return nil + } + let fd = open(path, O_EVTONLY) + guard fd >= 0 else { return nil } + + self.path = path + self.kind = kind + self.onChange = onChange + self.debounceInterval = debounceInterval + self.queue = DispatchQueue(label: "com.argus.persistencewatcher.\(kind.displayName)") + self.source = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fd, eventMask: .write, queue: queue) + + // Silent baseline: capture the starting snapshot before the source is + // ever resumed, so files that already existed when Argus launched + // never appear as "added" the first time an event fires. + self.lastSnapshot = Self.snapshot(of: path) + + source.setEventHandler { [weak self] in + self?.scheduleRescan() + } + source.setCancelHandler { + close(fd) + } + source.resume() + } + + func cancel() { + source.cancel() + } + + /// Bursty writers (an editor save, `plutil -convert`, a package installer + /// touching several files) can fire several `.write` events for what is + /// really one logical change. Debouncing to a single rescan ~1s after the + /// last event avoids reporting the same change multiple times or + /// diffing against a half-written file. + private func scheduleRescan() { + pendingRescan?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.rescan() + } + pendingRescan = work + queue.asyncAfter(deadline: .now() + debounceInterval, execute: work) + } + + private func rescan() { + let current = Self.snapshot(of: path) + let changes = SnapshotDiff.diff(previous: lastSnapshot, current: current) + lastSnapshot = current + for change in changes { + let event = PersistenceEventBuilder.makeEvent(filename: change.filename, changeKind: change.kind, locationKind: kind, directoryPath: path) + onChange(event) + } + } + + private static func snapshot(of path: String) -> DirectorySnapshot { + let fm = FileManager.default + guard let names = try? fm.contentsOfDirectory(atPath: path) else { return [:] } + var result: DirectorySnapshot = [:] + for name in names { + let fullPath = (path as NSString).appendingPathComponent(name) + if let attributes = try? fm.attributesOfItem(atPath: fullPath), + let modified = attributes[.modificationDate] as? Date { + result[name] = modified + } + } + return result + } +} + +/// Second, independent sensor alongside `ProcessMonitor`: instead of relying +/// on catching the *process* that writes a persistence artifact during a +/// ~1.2s `ps` poll (which a short-lived writer can slip through entirely), +/// this watches the artifact *locations* directly. A LaunchAgent/LaunchDaemon +/// plist or a periodic script left behind is caught even when the process +/// that created it was never sampled. +final class PersistenceWatcher { + /// Invoked on the main actor for every detected artifact change. Set + /// before calling `start()`. + var onEvent: (@MainActor (ProcessEvent) -> Void)? + + private var watches: [DirectoryWatch] = [] + + /// The standard macOS persistence-artifact directories. `~/Library/LaunchAgents` + /// is resolved from the real home directory at call time rather than + /// hardcoded, so this works under any user account. + static func defaultLocations() -> [(path: String, kind: PersistenceLocationKind)] { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return [ + ("\(home)/Library/LaunchAgents", .launchAgents), + ("/Library/LaunchAgents", .launchAgents), + ("/Library/LaunchDaemons", .launchDaemons), + ("/etc/periodic", .periodicCron), + ] + } + + /// Begins watching every location in `defaultLocations()` that exists and + /// is readable. A missing or unopenable directory (no `/etc/periodic` on + /// some systems, insufficient privileges on another) is skipped with a + /// single diagnostic line rather than retried in a loop — there is + /// nothing to recover from short of the directory appearing later, which + /// this process won't be relaunched to notice anyway. + func start() { + for location in Self.defaultLocations() { + guard let watch = DirectoryWatch(path: location.path, kind: location.kind, onChange: { [weak self] event in + guard let self else { return } + Task { @MainActor in + self.onEvent?(event) + } + }) else { + DiagnosticsLog.write("persistence watcher — skipping unreadable location \(location.path)") + continue + } + watches.append(watch) + } + } + + func stop() { + for watch in watches { watch.cancel() } + watches.removeAll() + } +} diff --git a/Sources/Argus/ProcessMonitor.swift b/Sources/Argus/ProcessMonitor.swift index e525ec7..d32355f 100644 --- a/Sources/Argus/ProcessMonitor.swift +++ b/Sources/Argus/ProcessMonitor.swift @@ -281,6 +281,29 @@ final class ProcessMonitor: ObservableObject { } } + /// Entry point for events discovered by a sensor other than the `ps` + /// poll loop (currently `PersistenceWatcher`). Mirrors the matched-event + /// branch of `processSample` — insert-at-front with the 300 cap, persist, + /// bump counters, threshold-gated notification, risk contribution, + /// diagnostics line — but skips orbit-node handling: pid 0 has no orbit + /// meaning (the Orbit view visualizes the live process graph, and a + /// synthetic artifact event isn't part of it), and skips allowlist + /// filtering, since these events represent a persistence-artifact change + /// rather than a rule matching an observed process (see + /// `PersistenceEventBuilder`'s doc comment). + func ingestExternal(_ event: ProcessEvent) { + events.insert(event, at: 0) + if events.count > 300 { events.removeLast(events.count - 300) } + eventStore?.append(event) + historicalEventCount += 1 + if let settings, settings.notificationThreshold.shouldNotify(for: event.topSeverity) { + NotificationManager.notify(event: event) + } + riskScore = min(100, riskScore + event.topSeverity.weight) + let techniques = event.rules.map(\.technique).joined(separator: "; ") + DiagnosticsLog.write("[\(event.topSeverity.label)] external pid=\(event.pid) \(event.executable) — \(techniques) — risk=\(Int(riskScore))") + } + private func trimActivityLog() { let cutoff = Date().addingTimeInterval(-300) activityLog.removeAll { $0.0 < cutoff } diff --git a/Tests/ArgusTests/PersistenceWatcherTests.swift b/Tests/ArgusTests/PersistenceWatcherTests.swift new file mode 100644 index 0000000..28db573 --- /dev/null +++ b/Tests/ArgusTests/PersistenceWatcherTests.swift @@ -0,0 +1,197 @@ +import XCTest +@testable import Argus + +final class SnapshotDiffTests: XCTestCase { + func testAddedFileIsReported() { + let previous: DirectorySnapshot = [:] + let current: DirectorySnapshot = ["com.evil.agent.plist": Date()] + let changes = SnapshotDiff.diff(previous: previous, current: current) + XCTAssertEqual(changes, [ArtifactChange(filename: "com.evil.agent.plist", kind: .added)]) + } + + func testModifiedFileIsReportedWhenModDateChanges() { + let older = Date(timeIntervalSince1970: 1000) + let newer = Date(timeIntervalSince1970: 2000) + let previous: DirectorySnapshot = ["com.example.agent.plist": older] + let current: DirectorySnapshot = ["com.example.agent.plist": newer] + let changes = SnapshotDiff.diff(previous: previous, current: current) + XCTAssertEqual(changes, [ArtifactChange(filename: "com.example.agent.plist", kind: .modified)]) + } + + func testRemovedFileIsReported() { + let previous: DirectorySnapshot = ["com.example.agent.plist": Date()] + let current: DirectorySnapshot = [:] + let changes = SnapshotDiff.diff(previous: previous, current: current) + XCTAssertEqual(changes, [ArtifactChange(filename: "com.example.agent.plist", kind: .removed)]) + } + + func testUnchangedFileYieldsNoChange() { + let same = Date(timeIntervalSince1970: 5000) + let previous: DirectorySnapshot = ["com.example.agent.plist": same] + let current: DirectorySnapshot = ["com.example.agent.plist": same] + XCTAssertEqual(SnapshotDiff.diff(previous: previous, current: current), []) + } + + func testFirstBaselineProducesNoChangesAgainstItself() { + // Simulates DirectoryWatch's silent-baseline behavior: diffing a + // snapshot against itself (as happens when the first rescan runs + // before anything on disk has actually changed) must report nothing, + // so pre-existing files never alert at startup. + let baseline: DirectorySnapshot = [ + "com.apple.something.plist": Date(timeIntervalSince1970: 100), + "com.example.other.plist": Date(timeIntervalSince1970: 200), + ] + XCTAssertEqual(SnapshotDiff.diff(previous: baseline, current: baseline), []) + } + + func testMixedAddModifyRemoveInSingleDiff() { + let previous: DirectorySnapshot = [ + "kept.plist": Date(timeIntervalSince1970: 100), + "changed.plist": Date(timeIntervalSince1970: 100), + "gone.plist": Date(timeIntervalSince1970: 100), + ] + let current: DirectorySnapshot = [ + "kept.plist": Date(timeIntervalSince1970: 100), + "changed.plist": Date(timeIntervalSince1970: 200), + "new.plist": Date(timeIntervalSince1970: 300), + ] + let changes = SnapshotDiff.diff(previous: previous, current: current) + XCTAssertEqual(changes, [ + ArtifactChange(filename: "changed.plist", kind: .modified), + ArtifactChange(filename: "gone.plist", kind: .removed), + ArtifactChange(filename: "new.plist", kind: .added), + ], "results are sorted by filename for determinism") + } +} + +final class PersistenceEventBuilderTests: XCTestCase { + func testLaunchAgentsAddedIsElevatedWithLaunchAgentTechnique() { + let event = PersistenceEventBuilder.makeEvent( + filename: "com.evil.agent.plist", changeKind: .added, + locationKind: .launchAgents, directoryPath: "/Users/mark/Library/LaunchAgents" + ) + XCTAssertEqual(event.pid, 0) + XCTAssertEqual(event.ppid, 0) + XCTAssertEqual(event.executable, "com.evil.agent.plist") + XCTAssertEqual(event.command, "/Users/mark/Library/LaunchAgents/com.evil.agent.plist") + XCTAssertEqual(event.rules.count, 1) + let rule = event.rules[0] + XCTAssertEqual(rule.severity, .elevated) + XCTAssertEqual(rule.technique, "T1547.011") + XCTAssertEqual(rule.name, "Persistence artifact added: LaunchAgents") + XCTAssertTrue(rule.explanation.contains("LaunchAgents")) + } + + func testLaunchAgentsModifiedIsElevated() { + let event = PersistenceEventBuilder.makeEvent( + filename: "com.evil.agent.plist", changeKind: .modified, + locationKind: .launchAgents, directoryPath: "/Library/LaunchAgents" + ) + XCTAssertEqual(event.rules[0].severity, .elevated) + XCTAssertEqual(event.rules[0].name, "Persistence artifact modified: LaunchAgents") + } + + func testLaunchAgentsRemovedIsWatch() { + let event = PersistenceEventBuilder.makeEvent( + filename: "com.evil.agent.plist", changeKind: .removed, + locationKind: .launchAgents, directoryPath: "/Library/LaunchAgents" + ) + XCTAssertEqual(event.rules[0].severity, .watch) + XCTAssertEqual(event.rules[0].name, "Persistence artifact removed: LaunchAgents") + } + + func testLaunchDaemonsUsesDaemonTechnique() { + let event = PersistenceEventBuilder.makeEvent( + filename: "com.evil.daemon.plist", changeKind: .added, + locationKind: .launchDaemons, directoryPath: "/Library/LaunchDaemons" + ) + XCTAssertEqual(event.rules[0].technique, "T1543.001") + XCTAssertEqual(event.rules[0].severity, .elevated) + XCTAssertEqual(event.rules[0].name, "Persistence artifact added: LaunchDaemons") + XCTAssertTrue(event.rules[0].explanation.contains("root")) + } + + func testPeriodicCronUsesCronTechnique() { + let event = PersistenceEventBuilder.makeEvent( + filename: "daily-evil", changeKind: .added, + locationKind: .periodicCron, directoryPath: "/etc/periodic/daily" + ) + XCTAssertEqual(event.rules[0].technique, "T1053.003") + XCTAssertEqual(event.rules[0].severity, .elevated) + } + + func testFullPathIsJoinedFromDirectoryAndFilename() { + let event = PersistenceEventBuilder.makeEvent( + filename: "foo.plist", changeKind: .added, + locationKind: .launchAgents, directoryPath: "/Library/LaunchAgents" + ) + XCTAssertEqual(event.command, "/Library/LaunchAgents/foo.plist") + } +} + +final class PersistenceLocationKindTests: XCTestCase { + func testDisplayNames() { + XCTAssertEqual(PersistenceLocationKind.launchAgents.displayName, "LaunchAgents") + XCTAssertEqual(PersistenceLocationKind.launchDaemons.displayName, "LaunchDaemons") + XCTAssertEqual(PersistenceLocationKind.periodicCron.displayName, "periodic") + } +} + +/// Drives a real `DirectoryWatch` against a temp directory to exercise the +/// end-to-end event → debounce → rescan → diff → callback path. Kept +/// deterministic with a short debounce and a generous, bounded wait via +/// `XCTestExpectation` rather than a fixed sleep. +final class DirectoryWatchIntegrationTests: XCTestCase { + private var tempDir: URL! + + override func setUpWithError() throws { + tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("ArgusPersistenceWatcherTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: tempDir) + } + + func testExistingFileDoesNotAlertAtStartup() throws { + let existing = tempDir.appendingPathComponent("preexisting.plist") + try Data("preexisting".utf8).write(to: existing) + + let notified = XCTestExpectation(description: "onChange should not fire for pre-existing file") + notified.isInverted = true + + let watch = DirectoryWatch(path: tempDir.path, kind: .launchAgents, debounceInterval: 0.2) { _ in + notified.fulfill() + } + XCTAssertNotNil(watch) + + wait(for: [notified], timeout: 0.8) + watch?.cancel() + } + + func testNewFileTriggersAddedEvent() throws { + let received = XCTestExpectation(description: "onChange fires for a new file") + var observedEvent: ProcessEvent? + + let watch = DirectoryWatch(path: tempDir.path, kind: .launchAgents, debounceInterval: 0.2) { event in + observedEvent = event + received.fulfill() + } + XCTAssertNotNil(watch) + + let newFile = tempDir.appendingPathComponent("com.new.agent.plist") + try Data("new".utf8).write(to: newFile) + + wait(for: [received], timeout: 5.0) + watch?.cancel() + + XCTAssertEqual(observedEvent?.executable, "com.new.agent.plist") + XCTAssertEqual(observedEvent?.rules.first?.severity, .elevated) + } + + func testUnreadableDirectoryReturnsNil() { + let missing = tempDir.appendingPathComponent("does-not-exist") + let watch = DirectoryWatch(path: missing.path, kind: .launchAgents) { _ in } + XCTAssertNil(watch) + } +} From 846c895b4720453f25740c3373553307be940ee3 Mon Sep 17 00:00:00 2001 From: Mark Watts Date: Fri, 21 Aug 2026 12:06:10 +0100 Subject: [PATCH 07/23] Add tamper-evidence for rule/allowlist state via IntegrityGuard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Touch ID gates rule toggling and allowlist edits in the UI, but rules-state.json and allowlist.json are still plain, same-user-writable JSON that any local process can rewrite directly to blind a detection silently. IntegrityGuard records an HMAC-SHA256 (keyed by a Keychain-held key) of each file after every authenticated write and re-verifies it at launch, reporting a mismatch as a critical synthetic event in the feed. This is evidence, not prevention — true prevention needs privilege separation, which is out of scope here. Co-Authored-By: Claude Fable 5 --- Sources/Argus/AllowlistStore.swift | 13 +- Sources/Argus/App.swift | 15 ++ Sources/Argus/IntegrityGuard.swift | 231 +++++++++++++++++++++ Sources/Argus/Sigma/RuleStore.swift | 13 +- Tests/ArgusTests/AllowlistTests.swift | 24 +++ Tests/ArgusTests/IntegrityGuardTests.swift | 110 ++++++++++ Tests/ArgusTests/RuleStoreTests.swift | 52 +++++ 7 files changed, 454 insertions(+), 4 deletions(-) create mode 100644 Sources/Argus/IntegrityGuard.swift create mode 100644 Tests/ArgusTests/IntegrityGuardTests.swift diff --git a/Sources/Argus/AllowlistStore.swift b/Sources/Argus/AllowlistStore.swift index 1035847..5d66297 100644 --- a/Sources/Argus/AllowlistStore.swift +++ b/Sources/Argus/AllowlistStore.swift @@ -22,10 +22,16 @@ struct AllowlistEntry: Identifiable, Codable, Equatable { @MainActor final class AllowlistStore: ObservableObject { @Published private(set) var entries: [AllowlistEntry] = [] + /// Result of verifying `allowlist.json` against the last MAC recorded by + /// an authenticated write, computed once at init. See `IntegrityGuard` — + /// the app checks this after construction to decide whether to report a + /// tamper event; the store itself doesn't emit events. + private(set) var integrityVerdict: IntegrityVerdict - private let fileURL: URL + let fileURL: URL + private let integrityGuard: IntegrityGuard - init(fileURL: URL? = nil) { + init(fileURL: URL? = nil, integrityGuard: IntegrityGuard = .shared) { if let fileURL { self.fileURL = fileURL } else { @@ -36,6 +42,8 @@ final class AllowlistStore: ObservableObject { try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: dir.path) self.fileURL = dir.appendingPathComponent("allowlist.json") } + self.integrityGuard = integrityGuard + integrityVerdict = integrityGuard.verify(self.fileURL) load() } @@ -102,6 +110,7 @@ final class AllowlistStore: ObservableObject { private func save() { guard let data = try? JSONEncoder().encode(entries) else { return } try? data.write(to: fileURL, options: .atomic) + integrityGuard.recordAuthenticatedWrite(of: fileURL) } } diff --git a/Sources/Argus/App.swift b/Sources/Argus/App.swift index d541173..cd69245 100644 --- a/Sources/Argus/App.swift +++ b/Sources/Argus/App.swift @@ -78,6 +78,21 @@ struct ArgusApp: App { } watcher.start() + // IntegrityGuard verified rules-state.json/allowlist.json against + // their last authenticated-write MAC during each store's own init + // (above). A `.tampered` verdict means the file changed outside + // Argus's Touch ID/password-gated write path — exactly what a local + // attacker would do to blind a rule or re-enable a suppressed alert + // silently — so surface it as a critical event in the feed. + // `.baselineEstablished`/`.unverifiable` are informational only and + // already logged by IntegrityGuard itself. + for (verdict, fileURL) in [(rules.integrityVerdict, rules.stateFileURL), (allowlistStore.integrityVerdict, allowlistStore.fileURL)] { + if verdict == .tampered { + DiagnosticsLog.write("integrity-guard: tamper detected outside Argus for \(fileURL.lastPathComponent)") + m.ingestExternal(IntegrityGuard.tamperEvent(for: fileURL)) + } + } + _monitor = StateObject(wrappedValue: m) _allowlist = StateObject(wrappedValue: allowlistStore) _settings = StateObject(wrappedValue: appSettings) diff --git a/Sources/Argus/IntegrityGuard.swift b/Sources/Argus/IntegrityGuard.swift new file mode 100644 index 0000000..f6f0535 --- /dev/null +++ b/Sources/Argus/IntegrityGuard.swift @@ -0,0 +1,231 @@ +import Foundation +import CryptoKit +import Security + +/// Detects (but cannot prevent) out-of-band edits to the on-disk files that +/// back security-relevant state — `rules-state.json` (which rules are +/// disabled) and `allowlist.json` (which rule/executable pairs are +/// suppressed). Both are gated behind Touch ID/password in the UI +/// (`RuleStore.requestToggle`, `AllowlistStore.requestAllow`/`requestRemove`), +/// but that only guards the UI path: the files themselves are plain, +/// same-user-writable JSON, and any process running as this user — in +/// particular the exact kind of LOLBin-style local attacker Argus watches +/// for — can rewrite them directly to blind a detection silently. +/// +/// True prevention would require privilege separation (a separate, +/// higher-privileged process owning these files), which is out of scope +/// here. This is evidence, not prevention: every legitimate write goes +/// through `recordAuthenticatedWrite(of:)`, which records an HMAC of the +/// file's contents in a keychain-protected sidecar. At launch, `verify(_:)` +/// recomputes that HMAC and compares it — a mismatch means the file changed +/// through some path other than an authenticated Argus write, and the app +/// reports that as a critical event so the tamper itself becomes visible in +/// the feed, even though it can't be blocked in the first place. +enum IntegrityVerdict: Equatable { + /// The file's current contents match the last recorded MAC. + case verified + /// No MAC was on record for this file yet, so its current contents were + /// adopted as the baseline. Expected the first time a file is verified + /// (e.g. a fresh install, or a file that predates this feature). + case baselineEstablished + /// The file's current contents don't match the last recorded MAC — it + /// was modified by something other than an authenticated Argus write. + case tampered + /// Verification couldn't be performed at all: no signing key is + /// available (Keychain access failed or is unavailable, as happens + /// under `swift test`/CI), or the file couldn't be read. + case unverifiable +} + +/// Supplies the symmetric key `IntegrityGuard` uses to MAC file contents. +/// Abstracted so tests can supply a fixed, in-memory key instead of touching +/// the real Keychain. +protocol IntegrityKeyProvider { + /// Returns the signing key, or `nil` if one isn't available. A `nil` key + /// disables verification gracefully rather than crashing — see + /// `KeychainIntegrityKeyProvider`. + func key() -> Data? +} + +/// Production key provider: a random 32-byte key stored as a generic +/// password in the user's login keychain (service "Argus", account +/// "integrity-key"), created lazily the first time it's needed. Keeping the +/// key in the Keychain rather than alongside the guarded files is the whole +/// point — an attacker who can rewrite `rules-state.json` in place has no +/// reason to also have Keychain access, so the MAC stays trustworthy even +/// against an attacker who knows exactly how this scheme works. +/// +/// Any Keychain error (locked, unavailable, sandboxed test environment with +/// no keychain access, denied) is treated the same as "no key": returns +/// `nil` rather than throwing or crashing. This is what makes it safe to run +/// under `swift test`/CI, where Keychain access typically isn't available at +/// all — verification just reports `.unverifiable` instead. +struct KeychainIntegrityKeyProvider: IntegrityKeyProvider { + private static let service = "Argus" + private static let account = "integrity-key" + private static let keyLength = 32 + + func key() -> Data? { + if let existing = readKey() { + return existing + } + var bytes = [UInt8](repeating: 0, count: Self.keyLength) + guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { + DiagnosticsLog.write("integrity-guard: failed to generate a key (SecRandomCopyBytes)") + return nil + } + let generated = Data(bytes) + guard write(generated) else { + DiagnosticsLog.write("integrity-guard: failed to store a new key in the Keychain") + return nil + } + return generated + } + + private func readKey() -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: Self.account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, let data = item as? Data else { return nil } + return data + } + + private func write(_ data: Data) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: Self.account, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + return SecItemAdd(query as CFDictionary, nil) == errSecSuccess + } +} + +/// See the type-level rationale above (evidence, not prevention). Owns one +/// sidecar file mapping guarded filenames to a hex HMAC-SHA256 of their last +/// authenticated contents, so a single guard instance can cover multiple +/// files (`rules-state.json`, `allowlist.json`, ...). +final class IntegrityGuard { + private let keyProvider: IntegrityKeyProvider + private let sidecarURL: URL + + /// Shared production instance backed by the real Keychain. Individual + /// stores default their `integrityGuard` init parameter to this so + /// callers don't have to wire one up explicitly; tests inject their own + /// instance (fixed key provider, temp-dir sidecar) instead of using this. + static let shared = IntegrityGuard() + + init(keyProvider: IntegrityKeyProvider = KeychainIntegrityKeyProvider(), sidecarURL: URL? = nil) { + self.keyProvider = keyProvider + if let sidecarURL { + self.sidecarURL = sidecarURL + } else { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/Argus", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + // Keep the shared Argus support directory owner-only (see EventStore). + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: dir.path) + self.sidecarURL = dir.appendingPathComponent("integrity.json") + } + } + + /// Call this after every legitimate, already-authenticated write to a + /// guarded file — it recomputes the file's MAC from what's on disk right + /// now and persists it, so the next `verify(_:)` treats this content as + /// trusted. + func recordAuthenticatedWrite(of fileURL: URL) { + guard let key = keyProvider.key() else { + DiagnosticsLog.write("integrity-guard: no key available, cannot record \(fileURL.lastPathComponent)") + return + } + guard let mac = mac(for: fileURL, key: key) else { + DiagnosticsLog.write("integrity-guard: could not read \(fileURL.lastPathComponent) to record its MAC") + return + } + var sidecar = loadSidecar() + sidecar[fileURL.lastPathComponent] = mac + saveSidecar(sidecar) + } + + /// Recomputes `fileURL`'s MAC and compares it against what was last + /// recorded via `recordAuthenticatedWrite(of:)`. + /// + /// After reporting `.tampered`, this re-baselines the file (records its + /// new MAC as though it had just been authenticated) rather than leaving + /// the mismatch on record. Without that, the exact same tamper would + /// re-fire as a fresh `.tampered` verdict on every subsequent launch — + /// once the app has surfaced the tamper as a critical event, there's + /// nothing more for a repeat report to add, and re-baselining is what + /// lets a *new* out-of-band edit be distinguished from the same old one. + func verify(_ fileURL: URL) -> IntegrityVerdict { + guard let key = keyProvider.key() else { + DiagnosticsLog.write("integrity-guard: no key available, cannot verify \(fileURL.lastPathComponent)") + return .unverifiable + } + guard let currentMAC = mac(for: fileURL, key: key) else { + DiagnosticsLog.write("integrity-guard: could not read \(fileURL.lastPathComponent) to verify it") + return .unverifiable + } + + var sidecar = loadSidecar() + guard let recordedMAC = sidecar[fileURL.lastPathComponent] else { + sidecar[fileURL.lastPathComponent] = currentMAC + saveSidecar(sidecar) + DiagnosticsLog.write("integrity-guard: establishing baseline for \(fileURL.lastPathComponent)") + return .baselineEstablished + } + + guard recordedMAC == currentMAC else { + sidecar[fileURL.lastPathComponent] = currentMAC + saveSidecar(sidecar) + return .tampered + } + return .verified + } + + private func mac(for fileURL: URL, key: Data) -> String? { + guard let data = try? Data(contentsOf: fileURL) else { return nil } + let code = HMAC.authenticationCode(for: data, using: SymmetricKey(data: key)) + return Data(code).map { String(format: "%02x", $0) }.joined() + } + + private func loadSidecar() -> [String: String] { + guard let data = try? Data(contentsOf: sidecarURL), + let decoded = try? JSONDecoder().decode([String: String].self, from: data) else { return [:] } + return decoded + } + + private func saveSidecar(_ sidecar: [String: String]) { + guard let data = try? JSONEncoder().encode(sidecar) else { return } + try? data.write(to: sidecarURL, options: .atomic) + // An atomic write replaces the file with a fresh inode carrying + // default (umask) permissions, so re-assert owner-only here (see + // EventStore.trimIfNeeded). + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: sidecarURL.path) + } + + /// Builds the critical synthetic `ProcessEvent` reported when `verify(_:)` + /// returns `.tampered`. Pure (no filesystem, no Keychain) so it's + /// trivially unit-testable; mirrors `PersistenceEventBuilder.makeEvent`'s + /// pid/ppid-0 convention for events that describe a file, not an + /// observed process. + static func tamperEvent(for fileURL: URL) -> ProcessEvent { + let filename = fileURL.lastPathComponent + let rule = MatchedRule( + name: "Detection state modified outside Argus", + severity: .critical, + technique: "T1562.001", + explanation: "\(filename) was modified without going through Argus's authenticated (Touch ID/password) write path. " + + "This is how a local attacker would silently disable a detection rule or allowlist a technique to hide their " + + "own activity — the file's contents no longer match what Argus last wrote." + ) + return ProcessEvent(pid: 0, ppid: 0, executable: filename, command: fileURL.path, rules: [rule], timestamp: Date()) + } +} diff --git a/Sources/Argus/Sigma/RuleStore.swift b/Sources/Argus/Sigma/RuleStore.swift index 5cfdd86..8097384 100644 --- a/Sources/Argus/Sigma/RuleStore.swift +++ b/Sources/Argus/Sigma/RuleStore.swift @@ -15,23 +15,31 @@ final class RuleStore: ObservableObject { /// this app can actually evaluate (see `isCompatibleLogsource`) — e.g. a /// Windows-only rule dropped into the user rules folder by mistake. @Published private(set) var skippedIncompatibleCount: Int = 0 + /// Result of verifying `rules-state.json` against the last MAC recorded + /// by an authenticated write, computed once at init. See `IntegrityGuard` + /// — the app checks this after construction to decide whether to report + /// a tamper event; the store itself doesn't emit events. + private(set) var integrityVerdict: IntegrityVerdict private let bundledRulesDirectory: URL? let userRulesDirectory: URL - private let stateFileURL: URL + let stateFileURL: URL + private let integrityGuard: IntegrityGuard var activeRules: [SigmaRule] { rules.filter { !disabledRuleIDs.contains($0.id) } } - init(bundledRulesDirectory: URL? = nil, userRulesDirectory: URL? = nil, stateFileURL: URL? = nil) { + init(bundledRulesDirectory: URL? = nil, userRulesDirectory: URL? = nil, stateFileURL: URL? = nil, integrityGuard: IntegrityGuard = .shared) { let appSupport = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/Argus", isDirectory: true) self.bundledRulesDirectory = bundledRulesDirectory ?? Bundle.main.resourceURL?.appendingPathComponent("Rules", isDirectory: true) self.userRulesDirectory = userRulesDirectory ?? appSupport.appendingPathComponent("rules", isDirectory: true) self.stateFileURL = stateFileURL ?? appSupport.appendingPathComponent("rules-state.json") + self.integrityGuard = integrityGuard try? FileManager.default.createDirectory(at: self.userRulesDirectory, withIntermediateDirectories: true) // Keep the shared Argus support directory owner-only (see EventStore). try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: appSupport.path) + integrityVerdict = integrityGuard.verify(self.stateFileURL) loadDisabledState() reload() } @@ -145,5 +153,6 @@ final class RuleStore: ObservableObject { private func saveDisabledState() { guard let data = try? JSONEncoder().encode(disabledRuleIDs) else { return } try? data.write(to: stateFileURL, options: .atomic) + integrityGuard.recordAuthenticatedWrite(of: stateFileURL) } } diff --git a/Tests/ArgusTests/AllowlistTests.swift b/Tests/ArgusTests/AllowlistTests.swift index 016bbfa..5a66a2d 100644 --- a/Tests/ArgusTests/AllowlistTests.swift +++ b/Tests/ArgusTests/AllowlistTests.swift @@ -68,4 +68,28 @@ final class AllowlistStoreTests: XCTestCase { XCTAssertEqual(reloaded.entries.count, 1) XCTAssertTrue(reloaded.isAllowed(ruleName: "A", executable: "osascript")) } + + func testStoreWiredWithIntegrityGuardRecordsMACOnSave() { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent("allowlist.json") + + let fixedKeyGuard = IntegrityGuard( + keyProvider: FixedKeyProviderForAllowlistTests(data: Data(repeating: 0x22, count: 32)), + sidecarURL: dir.appendingPathComponent("integrity.json") + ) + + let store = AllowlistStore(fileURL: url, integrityGuard: fixedKeyGuard) + // No allowlist.json exists yet — nothing has been authenticated, nothing to verify. + XCTAssertEqual(store.integrityVerdict, .unverifiable) + + store.allow(ruleName: "A", executable: "osascript") + XCTAssertEqual(fixedKeyGuard.verify(url), .verified, "save() should have recorded a MAC via the injected guard") + } +} + +/// Fixed-key provider so this test doesn't touch the real Keychain. +private struct FixedKeyProviderForAllowlistTests: IntegrityKeyProvider { + let data: Data? + func key() -> Data? { data } } diff --git a/Tests/ArgusTests/IntegrityGuardTests.swift b/Tests/ArgusTests/IntegrityGuardTests.swift new file mode 100644 index 0000000..e99dabb --- /dev/null +++ b/Tests/ArgusTests/IntegrityGuardTests.swift @@ -0,0 +1,110 @@ +import XCTest +@testable import Argus + +/// Fixed, in-memory key so these tests never touch the real Keychain (the +/// production `KeychainIntegrityKeyProvider` is exercised only via +/// `IntegrityGuard.shared`, which these tests deliberately avoid). +private struct FixedKeyProvider: IntegrityKeyProvider { + let data: Data? + func key() -> Data? { data } +} + +final class IntegrityGuardTests: XCTestCase { + private func makeTempDir() -> URL { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func makeGuard(dir: URL, key: Data? = Data(repeating: 0x42, count: 32)) -> IntegrityGuard { + IntegrityGuard(keyProvider: FixedKeyProvider(data: key), sidecarURL: dir.appendingPathComponent("integrity.json")) + } + + func testVerifiedRoundTrip() throws { + let dir = makeTempDir() + let file = dir.appendingPathComponent("rules-state.json") + try "[\"a\"]".write(to: file, atomically: true, encoding: .utf8) + + let guardInstance = makeGuard(dir: dir) + guardInstance.recordAuthenticatedWrite(of: file) + + XCTAssertEqual(guardInstance.verify(file), .verified) + } + + func testBaselineEstablishedOnFirstSight() throws { + let dir = makeTempDir() + let file = dir.appendingPathComponent("allowlist.json") + try "[]".write(to: file, atomically: true, encoding: .utf8) + + let guardInstance = makeGuard(dir: dir) + XCTAssertEqual(guardInstance.verify(file), .baselineEstablished) + // The baseline just adopted should verify cleanly next time, with no + // further authenticated write in between. + XCTAssertEqual(guardInstance.verify(file), .verified) + } + + func testTamperDetection() throws { + let dir = makeTempDir() + let file = dir.appendingPathComponent("rules-state.json") + try "[\"a\"]".write(to: file, atomically: true, encoding: .utf8) + + let guardInstance = makeGuard(dir: dir) + guardInstance.recordAuthenticatedWrite(of: file) + + // Simulate a local attacker rewriting the file directly, bypassing + // RuleStore's authenticated save path entirely. + try "[\"a\", \"b\"]".write(to: file, atomically: true, encoding: .utf8) + + XCTAssertEqual(guardInstance.verify(file), .tampered) + } + + func testRebaselinesAfterTamperSoItDoesNotRefire() throws { + let dir = makeTempDir() + let file = dir.appendingPathComponent("rules-state.json") + try "[\"a\"]".write(to: file, atomically: true, encoding: .utf8) + + let guardInstance = makeGuard(dir: dir) + guardInstance.recordAuthenticatedWrite(of: file) + try "[\"a\", \"b\"]".write(to: file, atomically: true, encoding: .utf8) + + XCTAssertEqual(guardInstance.verify(file), .tampered) + // Same file, unchanged since the tamper was reported: should now + // verify cleanly rather than reporting .tampered again forever. + XCTAssertEqual(guardInstance.verify(file), .verified) + } + + func testNilKeyIsUnverifiable() throws { + let dir = makeTempDir() + let file = dir.appendingPathComponent("rules-state.json") + try "[\"a\"]".write(to: file, atomically: true, encoding: .utf8) + + let guardInstance = makeGuard(dir: dir, key: nil) + // Should not crash, and should decline to write a MAC with no key. + guardInstance.recordAuthenticatedWrite(of: file) + XCTAssertEqual(guardInstance.verify(file), .unverifiable) + } + + func testUnreadableFileIsUnverifiable() { + let dir = makeTempDir() + let missing = dir.appendingPathComponent("does-not-exist.json") + + let guardInstance = makeGuard(dir: dir) + XCTAssertEqual(guardInstance.verify(missing), .unverifiable) + } + + func testTamperEventConstruction() throws { + let fileURL = URL(fileURLWithPath: "/Users/test/Library/Application Support/Argus/rules-state.json") + let event = IntegrityGuard.tamperEvent(for: fileURL) + + XCTAssertEqual(event.pid, 0) + XCTAssertEqual(event.ppid, 0) + XCTAssertEqual(event.executable, "rules-state.json") + XCTAssertEqual(event.command, fileURL.path) + XCTAssertEqual(event.rules.count, 1) + let rule = try XCTUnwrap(event.rules.first) + XCTAssertEqual(rule.name, "Detection state modified outside Argus") + XCTAssertEqual(rule.technique, "T1562.001") + XCTAssertEqual(rule.severity, .critical) + XCTAssertTrue(rule.explanation.contains("rules-state.json")) + } +} diff --git a/Tests/ArgusTests/RuleStoreTests.swift b/Tests/ArgusTests/RuleStoreTests.swift index 2391835..c3d1814 100644 --- a/Tests/ArgusTests/RuleStoreTests.swift +++ b/Tests/ArgusTests/RuleStoreTests.swift @@ -149,4 +149,56 @@ final class RuleStoreTests: XCTestCase { XCTAssertEqual(store.rules.count, 1, "a non-process_creation category should be skipped") XCTAssertEqual(store.skippedIncompatibleCount, 1) } + + func testStoreWiredWithIntegrityGuardRecordsMACOnSave() { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let bundled = root.appendingPathComponent("bundled") + let user = root.appendingPathComponent("user") + let state = root.appendingPathComponent("rules-state.json") + try? FileManager.default.createDirectory(at: bundled.appendingPathComponent("custom"), withIntermediateDirectories: true) + try? FileManager.default.createDirectory(at: bundled.appendingPathComponent("imported"), withIntermediateDirectories: true) + try? FileManager.default.createDirectory(at: bundled.appendingPathComponent("imported-portable"), withIntermediateDirectories: true) + + let fixedKeyGuard = IntegrityGuard( + keyProvider: FixedKeyProviderForTests(data: Data(repeating: 0x11, count: 32)), + sidecarURL: root.appendingPathComponent("integrity.json") + ) + + let sampleRule = """ + title: Test Rule + id: 55555555-5555-5555-5555-555555555555 + status: stable + description: A rule for testing. + author: Test + date: 2026-08-21 + tags: + - attack.execution + - attack.t1059 + logsource: + category: process_creation + product: macos + detection: + selection: + CommandLine|contains: 'dangerous-thing' + condition: selection + level: high + """ + try? sampleRule.write(to: bundled.appendingPathComponent("custom/test.yml"), atomically: true, encoding: .utf8) + + let store = RuleStore(bundledRulesDirectory: bundled, userRulesDirectory: user, stateFileURL: state, integrityGuard: fixedKeyGuard) + // No rules-state.json exists yet — nothing has been authenticated, + // nothing to verify. + XCTAssertEqual(store.integrityVerdict, .unverifiable) + XCTAssertEqual(store.rules.count, 1) + + store.toggle(store.rules[0]) + XCTAssertEqual(fixedKeyGuard.verify(state), .verified, "saveDisabledState should have recorded a MAC via the injected guard") + } +} + +/// Local fixed-key provider so this test doesn't depend on +/// `IntegrityGuardTests`'s private helper across files. +private struct FixedKeyProviderForTests: IntegrityKeyProvider { + let data: Data? + func key() -> Data? { data } } From 09d8d10768612a1a0846be05b58b5c9eb7c9e1fb Mon Sep 17 00:00:00 2001 From: Mark Watts Date: Fri, 21 Aug 2026 12:08:28 +0100 Subject: [PATCH 08/23] Make IntegrityGuard opt-in and key its sidecar by full path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stores previously defaulted to IntegrityGuard.shared, so any test or tool constructing a store against a temp file silently created a real Keychain key and wrote MACs into the user's live integrity.json — and because the sidecar was keyed by filename, a temp rules-state.json clobbered the real file's recorded MAC, priming a false tamper alarm on the next app launch. The guard is now nil unless injected (the app passes .shared explicitly) and the sidecar keys on full paths so same-named files can never collide. Removed the sidecar entries the test runs had already polluted. Co-Authored-By: Claude Fable 5 --- Sources/Argus/AllowlistStore.swift | 13 ++++++++----- Sources/Argus/App.swift | 4 ++-- Sources/Argus/IntegrityGuard.swift | 17 ++++++++++------- Sources/Argus/Sigma/RuleStore.swift | 13 ++++++++----- 4 files changed, 28 insertions(+), 19 deletions(-) diff --git a/Sources/Argus/AllowlistStore.swift b/Sources/Argus/AllowlistStore.swift index 5d66297..4eb83ec 100644 --- a/Sources/Argus/AllowlistStore.swift +++ b/Sources/Argus/AllowlistStore.swift @@ -26,12 +26,15 @@ final class AllowlistStore: ObservableObject { /// an authenticated write, computed once at init. See `IntegrityGuard` — /// the app checks this after construction to decide whether to report a /// tamper event; the store itself doesn't emit events. - private(set) var integrityVerdict: IntegrityVerdict + private(set) var integrityVerdict: IntegrityVerdict? let fileURL: URL - private let integrityGuard: IntegrityGuard + /// Optional so that constructing a store (in tests, previews, tools) + /// never touches the real Keychain or the shared sidecar as a side + /// effect — the app opts in explicitly with `.shared`. + private let integrityGuard: IntegrityGuard? - init(fileURL: URL? = nil, integrityGuard: IntegrityGuard = .shared) { + init(fileURL: URL? = nil, integrityGuard: IntegrityGuard? = nil) { if let fileURL { self.fileURL = fileURL } else { @@ -43,7 +46,7 @@ final class AllowlistStore: ObservableObject { self.fileURL = dir.appendingPathComponent("allowlist.json") } self.integrityGuard = integrityGuard - integrityVerdict = integrityGuard.verify(self.fileURL) + integrityVerdict = integrityGuard?.verify(self.fileURL) load() } @@ -110,7 +113,7 @@ final class AllowlistStore: ObservableObject { private func save() { guard let data = try? JSONEncoder().encode(entries) else { return } try? data.write(to: fileURL, options: .atomic) - integrityGuard.recordAuthenticatedWrite(of: fileURL) + integrityGuard?.recordAuthenticatedWrite(of: fileURL) } } diff --git a/Sources/Argus/App.swift b/Sources/Argus/App.swift index cd69245..b8fe5b2 100644 --- a/Sources/Argus/App.swift +++ b/Sources/Argus/App.swift @@ -56,9 +56,9 @@ struct ArgusApp: App { /// shown. Doing it here, in the app's own init, means it starts at /// launch every time. init() { - let allowlistStore = AllowlistStore() + let allowlistStore = AllowlistStore(integrityGuard: .shared) let appSettings = AppSettings() - let rules = RuleStore() + let rules = RuleStore(integrityGuard: .shared) let events = EventStore() let m = ProcessMonitor() diff --git a/Sources/Argus/IntegrityGuard.swift b/Sources/Argus/IntegrityGuard.swift index f6f0535..b454165 100644 --- a/Sources/Argus/IntegrityGuard.swift +++ b/Sources/Argus/IntegrityGuard.swift @@ -109,9 +109,12 @@ struct KeychainIntegrityKeyProvider: IntegrityKeyProvider { } /// See the type-level rationale above (evidence, not prevention). Owns one -/// sidecar file mapping guarded filenames to a hex HMAC-SHA256 of their last -/// authenticated contents, so a single guard instance can cover multiple -/// files (`rules-state.json`, `allowlist.json`, ...). +/// sidecar file mapping guarded files' full paths to a hex HMAC-SHA256 of +/// their last authenticated contents, so a single guard instance can cover +/// multiple files (`rules-state.json`, `allowlist.json`, ...). Keyed by full +/// path, not filename — a store pointed at a same-named file elsewhere (a +/// test's temp copy, a second profile) must never collide with the real +/// file's recorded MAC. final class IntegrityGuard { private let keyProvider: IntegrityKeyProvider private let sidecarURL: URL @@ -150,7 +153,7 @@ final class IntegrityGuard { return } var sidecar = loadSidecar() - sidecar[fileURL.lastPathComponent] = mac + sidecar[fileURL.path] = mac saveSidecar(sidecar) } @@ -175,15 +178,15 @@ final class IntegrityGuard { } var sidecar = loadSidecar() - guard let recordedMAC = sidecar[fileURL.lastPathComponent] else { - sidecar[fileURL.lastPathComponent] = currentMAC + guard let recordedMAC = sidecar[fileURL.path] else { + sidecar[fileURL.path] = currentMAC saveSidecar(sidecar) DiagnosticsLog.write("integrity-guard: establishing baseline for \(fileURL.lastPathComponent)") return .baselineEstablished } guard recordedMAC == currentMAC else { - sidecar[fileURL.lastPathComponent] = currentMAC + sidecar[fileURL.path] = currentMAC saveSidecar(sidecar) return .tampered } diff --git a/Sources/Argus/Sigma/RuleStore.swift b/Sources/Argus/Sigma/RuleStore.swift index 8097384..4bc6f72 100644 --- a/Sources/Argus/Sigma/RuleStore.swift +++ b/Sources/Argus/Sigma/RuleStore.swift @@ -19,16 +19,19 @@ final class RuleStore: ObservableObject { /// by an authenticated write, computed once at init. See `IntegrityGuard` /// — the app checks this after construction to decide whether to report /// a tamper event; the store itself doesn't emit events. - private(set) var integrityVerdict: IntegrityVerdict + private(set) var integrityVerdict: IntegrityVerdict? private let bundledRulesDirectory: URL? let userRulesDirectory: URL let stateFileURL: URL - private let integrityGuard: IntegrityGuard + /// Optional so that constructing a store (in tests, previews, tools) + /// never touches the real Keychain or the shared sidecar as a side + /// effect — the app opts in explicitly with `.shared`. + private let integrityGuard: IntegrityGuard? var activeRules: [SigmaRule] { rules.filter { !disabledRuleIDs.contains($0.id) } } - init(bundledRulesDirectory: URL? = nil, userRulesDirectory: URL? = nil, stateFileURL: URL? = nil, integrityGuard: IntegrityGuard = .shared) { + init(bundledRulesDirectory: URL? = nil, userRulesDirectory: URL? = nil, stateFileURL: URL? = nil, integrityGuard: IntegrityGuard? = nil) { let appSupport = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/Argus", isDirectory: true) self.bundledRulesDirectory = bundledRulesDirectory ?? Bundle.main.resourceURL?.appendingPathComponent("Rules", isDirectory: true) @@ -39,7 +42,7 @@ final class RuleStore: ObservableObject { // Keep the shared Argus support directory owner-only (see EventStore). try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: appSupport.path) - integrityVerdict = integrityGuard.verify(self.stateFileURL) + integrityVerdict = integrityGuard?.verify(self.stateFileURL) loadDisabledState() reload() } @@ -153,6 +156,6 @@ final class RuleStore: ObservableObject { private func saveDisabledState() { guard let data = try? JSONEncoder().encode(disabledRuleIDs) else { return } try? data.write(to: stateFileURL, options: .atomic) - integrityGuard.recordAuthenticatedWrite(of: stateFileURL) + integrityGuard?.recordAuthenticatedWrite(of: stateFileURL) } } From 2773c86f9cf7c420d474040c430e60a61791ae0a Mon Sep 17 00:00:00 2001 From: Mark Watts Date: Fri, 21 Aug 2026 12:13:26 +0100 Subject: [PATCH 09/23] Add sequence/chain correlation across LOLBin process trees Extends ParentContextCache with ppid tracking and an ancestry walk, and adds ChainCorrelator: a pure, testable class that links matched processes sharing a process-tree lineage within a rolling window when they trip distinct techniques, escalating severity one level and emitting a synthetic "chain" event via ingestExternal. Realizes the README's thesis that LOLBin signal lives in technique sequence, not just individual process scores. Co-Authored-By: Claude Fable 5 --- Sources/Argus/ChainCorrelator.swift | 111 +++++++++++++++++++ Sources/Argus/ProcessMonitor.swift | 67 ++++++++++- Tests/ArgusTests/ChainCorrelatorTests.swift | 116 ++++++++++++++++++++ Tests/ArgusTests/ProcessMonitorTests.swift | 59 ++++++++++ 4 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 Sources/Argus/ChainCorrelator.swift create mode 100644 Tests/ArgusTests/ChainCorrelatorTests.swift diff --git a/Sources/Argus/ChainCorrelator.swift b/Sources/Argus/ChainCorrelator.swift new file mode 100644 index 0000000..6a94879 --- /dev/null +++ b/Sources/Argus/ChainCorrelator.swift @@ -0,0 +1,111 @@ +import Foundation + +/// One matched process folded into the correlator's rolling window. +/// +/// `lineage` is `{pid} ∪ ancestry(of: pid)` with every pid `<= 1` stripped +/// out. Excluding launchd/kernel pids matters: on macOS every process +/// eventually descends from launchd (pid 1), so if `1` were left in, every +/// pair of events observed anywhere on the machine would share it and the +/// correlator would chain unrelated processes together constantly. +struct ChainMember { + let eventID: UUID + let pid: Int32 + let executable: String + let ruleNames: Set + let techniques: Set + let severity: Severity + let timestamp: Date + let lineage: Set +} + +/// A chain the correlator has fired: the prior member(s) it joined together +/// with the newly-registered one, in chronological order. +struct ChainDetection { + let members: [ChainMember] + let techniques: Set + let escalatedSeverity: Severity +} + +/// Correlates matched processes into ancestry-linked "chains" — the same +/// signal the README's core thesis calls out: a single LOLBin invocation is +/// often unremarkable, but two or more *different* techniques firing inside +/// the same process tree within a short window is a much stronger signal +/// than either alone. +/// +/// Deliberately independent of `ProcessMonitor`/`@MainActor` and of `ps` — +/// callers supply the pid, its precomputed ancestry, and the rule/technique +/// data already extracted from a match, so this class can be driven and +/// tested with plain values. +final class ChainCorrelator { + private var members: [ChainMember] = [] + let window: TimeInterval + + init(window: TimeInterval = 600) { + self.window = window + } + + /// Registers one matched process and reports a `ChainDetection` if it + /// joins an existing, still-live member of a different technique in the + /// same process tree. + /// + /// - Parameter ancestry: the pid's ancestor chain (nearest first), + /// typically `ParentContextCache.ancestry(of:)` — *not* including the + /// pid itself. + /// + /// Two members are related iff one's pid is present in the other's + /// lineage, or their lineages otherwise intersect (a shared ancestor + /// further up the tree). A detection only fires when a related member's + /// `ruleNames` are not a subset of the incoming event's `ruleNames` — + /// this is what stops the same rule re-matching a retried command (or a + /// process that legitimately matches several rules at once) from + /// "chaining" with itself: that's already visible as a single + /// multi-rule event, not a sequence of distinct techniques. + /// + /// The new member is recorded whether or not a detection fires — even + /// after a chain has already been reported, a later, third technique in + /// the same tree is new information and re-fires with all prior related + /// members included. Growth is bounded by the rolling `window`, which is + /// pruned (relative to the incoming event's timestamp) before anything + /// else runs. + func register( + eventID: UUID, + pid: Int32, + executable: String, + ruleNames: Set, + techniques: Set, + severity: Severity, + timestamp: Date, + ancestry: [Int32] + ) -> ChainDetection? { + let cutoff = timestamp.addingTimeInterval(-window) + members.removeAll { $0.timestamp < cutoff } + + let lineage = Set(([pid] + ancestry).filter { $0 > 1 }) + + // A chain always needs two distinct processes: a single process + // matching multiple rules is one multi-rule event, not a sequence. + let related = members.filter { existing in + guard existing.pid != pid else { return false } + let sameTree = lineage.contains(existing.pid) + || existing.lineage.contains(pid) + || !lineage.isDisjoint(with: existing.lineage) + guard sameTree else { return false } + return !existing.ruleNames.isSubset(of: ruleNames) + } + + let newMember = ChainMember(eventID: eventID, pid: pid, executable: executable, + ruleNames: ruleNames, techniques: techniques, + severity: severity, timestamp: timestamp, lineage: lineage) + members.append(newMember) + + guard !related.isEmpty else { return nil } + + let allMembers = (related + [newMember]).sorted { $0.timestamp < $1.timestamp } + let allTechniques = allMembers.reduce(into: Set()) { $0.formUnion($1.techniques) } + let maxSeverity = allMembers.map(\.severity).max() ?? severity + let escalatedRaw = min(maxSeverity.rawValue + 1, Severity.critical.rawValue) + let escalated = Severity(rawValue: escalatedRaw) ?? .critical + + return ChainDetection(members: allMembers, techniques: allTechniques, escalatedSeverity: escalated) + } +} diff --git a/Sources/Argus/ProcessMonitor.swift b/Sources/Argus/ProcessMonitor.swift index d32355f..9102299 100644 --- a/Sources/Argus/ProcessMonitor.swift +++ b/Sources/Argus/ProcessMonitor.swift @@ -20,6 +20,7 @@ struct ParentContextCache { var image: String var command: String var user: String + var ppid: Int32 var lastSeenTick: Int } @@ -34,7 +35,7 @@ struct ParentContextCache { /// refreshed within the retention window. mutating func update(with sample: [RawProcess], tick: Int) { for p in sample { - entries[p.id] = Entry(image: p.image, command: p.command, user: p.user, lastSeenTick: tick) + entries[p.id] = Entry(image: p.image, command: p.command, user: p.user, ppid: p.ppid, lastSeenTick: tick) } entries = entries.filter { tick - $0.value.lastSeenTick <= retentionTicks } } @@ -42,6 +43,27 @@ struct ParentContextCache { func image(for pid: Int32) -> String? { entries[pid]?.image } func command(for pid: Int32) -> String? { entries[pid]?.command } func user(for pid: Int32) -> String? { entries[pid]?.user } + func ppid(for pid: Int32) -> Int32? { entries[pid]?.ppid } + + /// Walks ppid links from `pid` up through the cache to build its ancestor + /// chain, nearest ancestor first, excluding `pid` itself. Stops at + /// `pid <= 1` (launchd/kernel — the root of every process tree, so it + /// carries no chain-correlation signal), at a pid the cache has no entry + /// for (parent already aged out or was never sampled), at a cycle (should + /// never happen on a real process table, but a corrupted/adversarial + /// sample must not spin forever), or at `maxDepth` hops. + func ancestry(of pid: Int32, maxDepth: Int = 20) -> [Int32] { + var chain: [Int32] = [] + var seen: Set = [pid] + var current = pid + while chain.count < maxDepth { + guard let parent = ppid(for: current), parent > 1, !seen.contains(parent) else { break } + chain.append(parent) + seen.insert(parent) + current = parent + } + return chain + } } /// Why a `ps` sample didn't yield a usable process list. @@ -133,6 +155,7 @@ final class ProcessMonitor: ObservableObject { private var parentCache = ParentContextCache() private var tickIndex = 0 private var samplingHealth = SamplingHealthTracker() + private let chainCorrelator = ChainCorrelator() func configure(allowlist: AllowlistStore) { self.allowlist = allowlist @@ -269,6 +292,15 @@ final class ProcessMonitor: ObservableObject { bornAt: Date(), angle: angle)) let techniques = matches.map(\.technique).joined(separator: "; ") DiagnosticsLog.write("[\(event.topSeverity.label)] pid=\(proc.id) \(proc.executable) — \(techniques) — risk=\(Int(riskScore))") + + let ancestry = parentCache.ancestry(of: proc.id) + if let detection = chainCorrelator.register( + eventID: event.id, pid: proc.id, executable: proc.executable, + ruleNames: Set(matches.map(\.name)), techniques: Set(matches.map(\.technique)), + severity: event.topSeverity, timestamp: event.timestamp, ancestry: ancestry + ) { + ingestExternal(Self.chainEvent(from: detection, pid: proc.id, ppid: proc.ppid)) + } } } if orbitNodes.count > 400 { orbitNodes.removeFirst(orbitNodes.count - 400) } @@ -304,6 +336,39 @@ final class ProcessMonitor: ObservableObject { DiagnosticsLog.write("[\(event.topSeverity.label)] external pid=\(event.pid) \(event.executable) — \(techniques) — risk=\(Int(riskScore))") } + /// Short, human-scannable timestamps for the chain explanation text below + /// — the full date is already on the enclosing `ProcessEvent`. + nonisolated private static let chainTimestampFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm:ss" + return f + }() + + /// Builds the synthetic `ProcessEvent` representing a `ChainDetection` so + /// it can flow through `ingestExternal` like any other externally-sourced + /// event (feed insert, persist, threshold-gated notification, risk). + /// Attributed to the triggering process's own pid/ppid rather than pid 0 + /// — unlike a `PersistenceWatcher` artifact this isn't sourced from a + /// pid-less filesystem change, it's a statement about a real process + /// tree, so it keeps that tree's identity. + nonisolated private static func chainEvent(from detection: ChainDetection, pid: Int32, ppid: Int32) -> ProcessEvent { + let command = detection.members.map(\.executable).joined(separator: " → ") + let technique = detection.techniques.sorted().joined(separator: ", ") + let explanation = detection.members.map { member -> String in + let names = member.ruleNames.sorted().joined(separator: ", ") + let time = chainTimestampFormatter.string(from: member.timestamp) + return "\(member.executable) (pid \(member.pid), \(time)): \(names)" + }.joined(separator: "; ") + + let rule = MatchedRule( + name: "Suspicious sequence: \(detection.techniques.count) techniques in one process tree", + severity: detection.escalatedSeverity, + technique: technique, + explanation: explanation + ) + return ProcessEvent(pid: pid, ppid: ppid, executable: "chain", command: command, rules: [rule], timestamp: Date()) + } + private func trimActivityLog() { let cutoff = Date().addingTimeInterval(-300) activityLog.removeAll { $0.0 < cutoff } diff --git a/Tests/ArgusTests/ChainCorrelatorTests.swift b/Tests/ArgusTests/ChainCorrelatorTests.swift new file mode 100644 index 0000000..ffe7425 --- /dev/null +++ b/Tests/ArgusTests/ChainCorrelatorTests.swift @@ -0,0 +1,116 @@ +import XCTest +@testable import Argus + +final class ChainCorrelatorTests: XCTestCase { + private let t0 = Date(timeIntervalSince1970: 1_000_000) + + private func member(_ correlator: ChainCorrelator, pid: Int32, executable: String, + rule: String, technique: String, severity: Severity, + at offset: TimeInterval, ancestry: [Int32]) -> ChainDetection? { + correlator.register(eventID: UUID(), pid: pid, executable: executable, + ruleNames: [rule], techniques: [technique], severity: severity, + timestamp: t0.addingTimeInterval(offset), ancestry: ancestry) + } + + func testParentChildAcrossDistinctTechniquesFires() { + let c = ChainCorrelator() + let first = member(c, pid: 100, executable: "cmd.exe", rule: "RuleA", technique: "T1059", + severity: .watch, at: 0, ancestry: []) + XCTAssertNil(first) + + let second = member(c, pid: 200, executable: "certutil.exe", rule: "RuleB", technique: "T1218", + severity: .elevated, at: 10, ancestry: [100]) + guard let detection = second else { return XCTFail("expected a chain detection") } + XCTAssertEqual(Set(detection.members.map(\.pid)), [100, 200]) + XCTAssertEqual(detection.techniques, ["T1059", "T1218"]) + XCTAssertEqual(detection.members.map(\.pid), [100, 200], "members are ordered oldest-first") + } + + func testSameRuleOnTwoProcessesDoesNotFire() { + let c = ChainCorrelator() + _ = member(c, pid: 100, executable: "sh", rule: "RuleA", technique: "T1059", + severity: .watch, at: 0, ancestry: []) + let second = member(c, pid: 200, executable: "sh", rule: "RuleA", technique: "T1059", + severity: .watch, at: 10, ancestry: [100]) + XCTAssertNil(second, "same rule refiring on a retried command must not self-chain") + } + + func testSamePidMultiRuleDoesNotFire() { + let c = ChainCorrelator() + _ = member(c, pid: 100, executable: "curl", rule: "RuleA", technique: "T1059", + severity: .watch, at: 0, ancestry: []) + let second = member(c, pid: 100, executable: "curl", rule: "RuleB", technique: "T1218", + severity: .elevated, at: 1, ancestry: []) + XCTAssertNil(second, "a single process matching multiple rules is one multi-rule event, not a chain") + } + + func testUnrelatedTreesDoNotFire() { + let c = ChainCorrelator() + _ = member(c, pid: 100, executable: "curl", rule: "RuleA", technique: "T1059", + severity: .watch, at: 0, ancestry: [50]) + let second = member(c, pid: 200, executable: "xattr", rule: "RuleB", technique: "T1218", + severity: .elevated, at: 10, ancestry: [60]) + XCTAssertNil(second, "disjoint process trees must not chain") + } + + func testLineageViaCommonNonLaunchdAncestorFires() { + let c = ChainCorrelator() + _ = member(c, pid: 100, executable: "curl", rule: "RuleA", technique: "T1059", + severity: .watch, at: 0, ancestry: [50]) + let second = member(c, pid: 200, executable: "xattr", rule: "RuleB", technique: "T1218", + severity: .elevated, at: 10, ancestry: [50]) + XCTAssertNotNil(second, "a shared non-launchd ancestor further up the tree still counts as related") + } + + func testPidLessThanOrEqualOneCommonAncestorDoesNotFire() { + let c = ChainCorrelator() + _ = member(c, pid: 100, executable: "curl", rule: "RuleA", technique: "T1059", + severity: .watch, at: 0, ancestry: [1]) + let second = member(c, pid: 200, executable: "xattr", rule: "RuleB", technique: "T1218", + severity: .elevated, at: 10, ancestry: [1]) + XCTAssertNil(second, "launchd (pid 1) is shared by every process and must not create a chain by itself") + } + + func testWindowExpiryPreventsChaining() { + let c = ChainCorrelator(window: 600) + _ = member(c, pid: 100, executable: "curl", rule: "RuleA", technique: "T1059", + severity: .watch, at: 0, ancestry: []) + let second = member(c, pid: 200, executable: "xattr", rule: "RuleB", technique: "T1218", + severity: .elevated, at: 700, ancestry: [100]) + XCTAssertNil(second, "the first member has aged out of the rolling window") + } + + func testThirdTechniqueJoiningRefiresWithAllThreeMembers() { + let c = ChainCorrelator() + _ = member(c, pid: 100, executable: "sh", rule: "RuleA", technique: "T1059", + severity: .watch, at: 0, ancestry: []) + let second = member(c, pid: 200, executable: "curl", rule: "RuleB", technique: "T1105", + severity: .watch, at: 10, ancestry: [100]) + XCTAssertNotNil(second) + + let third = member(c, pid: 300, executable: "launchctl", rule: "RuleC", technique: "T1543", + severity: .elevated, at: 20, ancestry: [200, 100]) + guard let detection = third else { return XCTFail("expected a chain detection extending the prior one") } + XCTAssertEqual(Set(detection.members.map(\.pid)), [100, 200, 300]) + XCTAssertEqual(detection.techniques, ["T1059", "T1105", "T1543"]) + XCTAssertEqual(detection.members.map(\.pid), [100, 200, 300], "oldest-first ordering") + } + + func testSeverityEscalatesOneLevelAboveMax() { + let c = ChainCorrelator() + _ = member(c, pid: 100, executable: "sh", rule: "RuleA", technique: "T1059", + severity: .info, at: 0, ancestry: []) + let second = member(c, pid: 200, executable: "curl", rule: "RuleB", technique: "T1105", + severity: .watch, at: 10, ancestry: [100]) + XCTAssertEqual(second?.escalatedSeverity, .elevated) + } + + func testSeverityEscalationCapsAtCritical() { + let c = ChainCorrelator() + _ = member(c, pid: 100, executable: "sh", rule: "RuleA", technique: "T1059", + severity: .critical, at: 0, ancestry: []) + let second = member(c, pid: 200, executable: "curl", rule: "RuleB", technique: "T1105", + severity: .elevated, at: 10, ancestry: [100]) + XCTAssertEqual(second?.escalatedSeverity, .critical, "escalation must not overflow past .critical") + } +} diff --git a/Tests/ArgusTests/ProcessMonitorTests.swift b/Tests/ArgusTests/ProcessMonitorTests.swift index cf3582f..9b1783b 100644 --- a/Tests/ArgusTests/ProcessMonitorTests.swift +++ b/Tests/ArgusTests/ProcessMonitorTests.swift @@ -122,6 +122,65 @@ final class ParentContextCacheTests: XCTestCase { cache.update(with: [raw(10, ppid: 1, image: "/bin/zsh")], tick: 1) XCTAssertEqual(cache.image(for: 10), "/bin/zsh") } + + func testPpidIsResolvable() { + var cache = ParentContextCache(retentionTicks: 3) + cache.update(with: [raw(10, ppid: 1, image: "/bin/bash")], tick: 0) + XCTAssertEqual(cache.ppid(for: 10), 1) + XCTAssertNil(cache.ppid(for: 999)) + } + + func testAncestryWalksThroughKnownAncestors() { + var cache = ParentContextCache(retentionTicks: 3) + cache.update(with: [ + raw(10, ppid: 1, image: "/sbin/launchd"), + raw(20, ppid: 10, image: "/bin/bash"), + raw(30, ppid: 20, image: "/usr/bin/curl"), + ], tick: 0) + XCTAssertEqual(cache.ancestry(of: 30), [20, 10]) + } + + func testAncestryExcludesPidOneAndBelow() { + var cache = ParentContextCache(retentionTicks: 3) + cache.update(with: [raw(10, ppid: 1, image: "/bin/bash")], tick: 0) + XCTAssertEqual(cache.ancestry(of: 10), [], "pid 1 (launchd) carries no chain-correlation signal and must be excluded") + } + + func testAncestryStopsAtUnknownParent() { + var cache = ParentContextCache(retentionTicks: 3) + // pid 40's own ppid (99) is known, but 99 has no entry of its own — + // the walk must include the known link and stop there rather than + // fabricate anything further up. + cache.update(with: [raw(40, ppid: 99, image: "/bin/bash")], tick: 0) + XCTAssertEqual(cache.ancestry(of: 40), [99]) + } + + func testAncestryOfUnknownPidIsEmpty() { + var cache = ParentContextCache(retentionTicks: 3) + cache.update(with: [raw(10, ppid: 1, image: "/bin/bash")], tick: 0) + XCTAssertEqual(cache.ancestry(of: 999), []) + } + + func testAncestryIsCycleSafe() { + var cache = ParentContextCache(retentionTicks: 3) + // Corrupted/adversarial data: 50 and 60 point at each other. Must + // terminate rather than loop forever. + cache.update(with: [ + raw(50, ppid: 60, image: "/bin/a"), + raw(60, ppid: 50, image: "/bin/b"), + ], tick: 0) + XCTAssertEqual(cache.ancestry(of: 50), [60]) + } + + func testAncestryRespectsMaxDepth() { + var cache = ParentContextCache(retentionTicks: 3) + var sample: [RawProcess] = [] + for pid in Int32(2)...30 { + sample.append(raw(pid, ppid: pid - 1, image: "/bin/p\(pid)")) + } + cache.update(with: sample, tick: 0) + XCTAssertEqual(cache.ancestry(of: 30, maxDepth: 5), [29, 28, 27, 26, 25]) + } } final class SamplingHealthTrackerTests: XCTestCase { From 946d5b1a04d7d2d00cc06a52b917c65933ca1396 Mon Sep 17 00:00:00 2001 From: Mark Watts Date: Fri, 21 Aug 2026 12:20:33 +0100 Subject: [PATCH 10/23] Add evidence export paths and actionable notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing a detected event previously meant hand-copying from events.jsonl. Adds "Copy as JSON" to the event feed's context menu, JSON/CSV export of the full history from the History panel, and notification actions ("Show in Argus" / "Allowlist…") so a caught event can be acted on without opening the dashboard first. Co-Authored-By: Claude Fable 5 --- Sources/Argus/App.swift | 12 ++ Sources/Argus/DashboardView.swift | 75 ++++++++++++- Sources/Argus/EventExport.swift | 72 ++++++++++++ Sources/Argus/NotificationManager.swift | 35 +++++- Sources/Argus/NotificationResponder.swift | 91 ++++++++++++++++ Tests/ArgusTests/EventExportTests.swift | 127 ++++++++++++++++++++++ 6 files changed, 407 insertions(+), 5 deletions(-) create mode 100644 Sources/Argus/EventExport.swift create mode 100644 Sources/Argus/NotificationResponder.swift create mode 100644 Tests/ArgusTests/EventExportTests.swift diff --git a/Sources/Argus/App.swift b/Sources/Argus/App.swift index b8fe5b2..99b9779 100644 --- a/Sources/Argus/App.swift +++ b/Sources/Argus/App.swift @@ -1,5 +1,6 @@ import SwiftUI import AppKit +import UserNotifications /// Argus runs `LSUIElement` (menu-bar-only by default, no Dock icon) — /// closing the main window should hide it, not quit the app, since the menu @@ -37,6 +38,10 @@ struct ArgusApp: App { /// alive — `PersistenceWatcher` isn't observed by any view, so nothing /// else in the view hierarchy retains it. private let persistenceWatcher: PersistenceWatcher + /// Held for the app's lifetime purely to keep it alive — see its own + /// doc comment. `UNUserNotificationCenter.delegate` is a weak reference, + /// so nothing else retains this object. + private let notificationResponder: NotificationResponder /// Identifies the main dashboard's `NSWindow`. Verified empirically /// (via a standalone probe app mirroring this app's Window + MenuBarExtra @@ -67,6 +72,12 @@ struct ArgusApp: App { m.configure(settings: appSettings) m.configure(ruleStore: rules) m.start() + + // Registering the delegate before requesting authorization ensures + // it's in place before any notification (including one delivered + // very shortly after launch) could arrive. + let responder = NotificationResponder(allowlist: allowlistStore, mainWindowID: Self.mainWindowID) + UNUserNotificationCenter.current().delegate = responder NotificationManager.requestAuthorizationIfNeeded() // Second, independent sensor: catches persistence artifacts left on @@ -99,6 +110,7 @@ struct ArgusApp: App { _ruleStore = StateObject(wrappedValue: rules) eventStore = events persistenceWatcher = watcher + notificationResponder = responder } var body: some Scene { diff --git a/Sources/Argus/DashboardView.swift b/Sources/Argus/DashboardView.swift index eb7fac4..4f0bc38 100644 --- a/Sources/Argus/DashboardView.swift +++ b/Sources/Argus/DashboardView.swift @@ -1,5 +1,6 @@ import SwiftUI import ServiceManagement +import UniformTypeIdentifiers struct DashboardView: View { @ObservedObject var monitor: ProcessMonitor @@ -497,6 +498,12 @@ struct EventRow: View { allowlist.requestAllow(ruleName: rule.name, executable: event.executable) } } + Divider() + Button("Copy as JSON") { + guard let json = EventExport.json(for: event) else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(json, forType: .string) + } } } } @@ -799,10 +806,14 @@ struct HistoryPanel: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - Text("ACTIVITY HISTORY") - .font(.system(size: 10, weight: .bold)) - .tracking(1.5) - .foregroundStyle(Theme.muted) + HStack { + Text("ACTIVITY HISTORY") + .font(.system(size: 10, weight: .bold)) + .tracking(1.5) + .foregroundStyle(Theme.muted) + Spacer() + exportMenu + } if events.isEmpty { Text("No history yet — matched events accumulate here as Argus runs, and survive a restart.") @@ -839,6 +850,62 @@ struct HistoryPanel: View { } } + /// Dropdown rather than a plain button since there are two formats to + /// choose between — kept visually lightweight (borderless, small type) + /// to match the plain-text "Rules folder"/"Reload" controls elsewhere in + /// these popovers rather than looking like a primary action. + private var exportMenu: some View { + Menu { + Button("Export as JSON…") { exportHistory(as: .json) } + Button("Export as CSV…") { exportHistory(as: .csv) } + } label: { + Label("Export…", systemImage: "square.and.arrow.up") + .font(.system(size: 9.5, weight: .semibold)) + .foregroundStyle(Theme.accent) + } + .menuStyle(.borderlessButton) + .fixedSize() + .disabled(events.isEmpty) + } + + private enum ExportFormat: String { case json = "JSON", csv = "CSV" } + + /// Snapshots `eventStore.loadAll()` up front (not inside the save + /// panel's completion handler) so the exported data reflects what the + /// user saw when they clicked "Export…", and so encoding happens on the + /// main actor where `EventStore` and this view already live — the save + /// panel's completion handler itself only ever touches plain `Data`/ + /// `URL` values. Write failures are logged, never surfaced as a crash — + /// exporting evidence is a nice-to-have, not something that should take + /// the app down if e.g. the destination volume went away mid-write. + private func exportHistory(as format: ExportFormat) { + let all = eventStore.loadAll() + let panel = NSSavePanel() + let data: Data? + switch format { + case .json: + panel.allowedContentTypes = [.json] + panel.nameFieldStringValue = "argus-events.json" + data = EventExport.json(events: all) + case .csv: + panel.allowedContentTypes = [.commaSeparatedText] + panel.nameFieldStringValue = "argus-events.csv" + data = EventExport.csv(events: all).data(using: .utf8) + } + guard let data else { + DiagnosticsLog.write("history export failed: could not encode events as \(format.rawValue)") + return + } + panel.begin { response in + guard response == .OK, let url = panel.url else { return } + do { + try data.write(to: url, options: .atomic) + } catch { + DiagnosticsLog.write("history export failed: \(error.localizedDescription)") + } + } + } + private var heatmap: some View { let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) diff --git a/Sources/Argus/EventExport.swift b/Sources/Argus/EventExport.swift new file mode 100644 index 0000000..9226a31 --- /dev/null +++ b/Sources/Argus/EventExport.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Pure serialization for getting evidence out of Argus — copy-as-JSON from +/// the event feed's context menu, and JSON/CSV export from the History +/// panel. Kept free of AppKit/NSPasteboard/NSSavePanel so it's trivially +/// unit-testable; callers own writing the result to the pasteboard or disk. +enum EventExport { + /// Shared encoder for both single-event and multi-event JSON: + /// pretty-printed and sorted-keys so output is stable and diffable + /// (useful when pasting into a bug report or diffing two exports), dates + /// as ISO8601 so timestamps are unambiguous across locales/timezones + /// when read by someone else. + private static let encoder: JSONEncoder = { + let e = JSONEncoder() + e.outputFormatting = [.prettyPrinted, .sortedKeys] + e.dateEncodingStrategy = .iso8601 + return e + }() + + /// Pretty-printed JSON for a single event — what "Copy as JSON" in the + /// event feed's right-click menu puts on the pasteboard. + static func json(for event: ProcessEvent) -> String? { + guard let data = try? encoder.encode(event) else { return nil } + return String(data: data, encoding: .utf8) + } + + /// Pretty-printed JSON array of every event, for the History panel's + /// "Export…" action. Same encoder config as the single-event helper + /// above, so a single exported event and one copied from the feed look + /// identical modulo array wrapping. + static func json(events: [ProcessEvent]) -> Data? { + try? encoder.encode(events) + } + + private static let isoFormatter = ISO8601DateFormatter() + + /// CSV export of the full event history. Command lines are the reason a + /// naive writer isn't good enough here — real shell one-liners are full + /// of commas, double quotes, and (via multi-line scripts) embedded + /// newlines, all of which must be quoted per RFC 4180 or the file + /// silently misaligns columns the moment someone opens it in a + /// spreadsheet. + static func csv(events: [ProcessEvent]) -> String { + var lines = ["timestamp,pid,ppid,executable,severity,techniques,rules,command"] + for event in events { + let fields = [ + isoFormatter.string(from: event.timestamp), + String(event.pid), + String(event.ppid), + event.executable, + event.topSeverity.label, + event.rules.map(\.technique).joined(separator: ";"), + event.rules.map(\.name).joined(separator: ";"), + event.command + ] + lines.append(fields.map(csvField).joined(separator: ",")) + } + return lines.joined(separator: "\n") + "\n" + } + + /// Quotes a field per RFC 4180 whenever it contains a comma, double + /// quote, or newline (CR or LF) — the exact characters that would + /// otherwise break column alignment or terminate the row early. Internal + /// double quotes are doubled, which is how RFC 4180 escapes a quote + /// inside a quoted field. + private static func csvField(_ value: String) -> String { + guard value.contains(",") || value.contains("\"") || value.contains("\n") || value.contains("\r") else { + return value + } + return "\"" + value.replacingOccurrences(of: "\"", with: "\"\"") + "\"" + } +} diff --git a/Sources/Argus/NotificationManager.swift b/Sources/Argus/NotificationManager.swift index 43895b6..fb7b9b2 100644 --- a/Sources/Argus/NotificationManager.swift +++ b/Sources/Argus/NotificationManager.swift @@ -7,8 +7,33 @@ import UserNotifications /// of *whether* to notify lives in NotificationThreshold.shouldNotify, /// which is pure and tested there instead. enum NotificationManager { + /// Category attached to every delivered event notification. Must match + /// what `NotificationResponder` (the `UNUserNotificationCenterDelegate` + /// wired up in `ArgusApp.init`) switches on when handling actions. + static let categoryIdentifier = "argus.event" + static let showActionIdentifier = "argus.event.show" + static let allowlistActionIdentifier = "argus.event.allowlist" + + /// userInfo keys carrying just enough context for `NotificationResponder` + /// to act on a notification without re-reading the event store. + static let ruleNameKey = "ruleName" + static let executableKey = "executable" + static func requestAuthorizationIfNeeded() { - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } + let center = UNUserNotificationCenter.current() + center.setNotificationCategories([actionableCategory]) + center.requestAuthorization(options: [.alert, .sound]) { _, _ in } + } + + /// "Show in Argus" brings the app forward regardless of authentication — + /// it's read-only. "Allowlist…" changes what Argus suppresses, so it + /// carries `.authenticationRequired`: macOS itself will demand the + /// screen be unlocked before invoking it, on top of the Touch + /// ID/password prompt `AllowlistStore.requestAllow` raises once it runs. + private static var actionableCategory: UNNotificationCategory { + let show = UNNotificationAction(identifier: showActionIdentifier, title: "Show in Argus", options: [.foreground]) + let allow = UNNotificationAction(identifier: allowlistActionIdentifier, title: "Allowlist…", options: [.authenticationRequired]) + return UNNotificationCategory(identifier: categoryIdentifier, actions: [show, allow], intentIdentifiers: [], options: []) } static func notify(event: ProcessEvent) { @@ -17,6 +42,14 @@ enum NotificationManager { content.subtitle = event.executable content.body = event.rules.map(\.name).joined(separator: ", ") content.sound = .default + content.categoryIdentifier = categoryIdentifier + // An event can trip several rules; only the top-severity one is + // actionable from the notification's "Allowlist…" action (see + // NotificationResponder's doc comment on that limitation), so that's + // the only one worth carrying in userInfo. + if let topRule = event.rules.first(where: { $0.severity == event.topSeverity }) { + content.userInfo = [ruleNameKey: topRule.name, executableKey: event.executable] + } let request = UNNotificationRequest(identifier: event.id.uuidString, content: content, trigger: nil) UNUserNotificationCenter.current().add(request) diff --git a/Sources/Argus/NotificationResponder.swift b/Sources/Argus/NotificationResponder.swift new file mode 100644 index 0000000..07a0570 --- /dev/null +++ b/Sources/Argus/NotificationResponder.swift @@ -0,0 +1,91 @@ +import Foundation +import AppKit +import UserNotifications + +/// Handles taps and actions on delivered Argus notifications — the +/// notification-side counterpart to the event feed's right-click menu. +/// Instantiated once in `ArgusApp.init` and held there for the app's +/// lifetime (see `ArgusApp.notificationResponder`), since +/// `UNUserNotificationCenter.delegate` is a weak reference and nothing else +/// keeps this object alive otherwise. +final class NotificationResponder: NSObject, UNUserNotificationCenterDelegate { + /// Weak so this responder never becomes the thing keeping `AllowlistStore` + /// (and its disk I/O, Touch ID plumbing) alive past the app's own + /// `_allowlist` StateObject — mirrors the `[weak m]` pattern `App.swift` + /// already uses when wiring `PersistenceWatcher`. + private weak var allowlist: AllowlistStore? + private let mainWindowID: String + + init(allowlist: AllowlistStore, mainWindowID: String) { + self.allowlist = allowlist + self.mainWindowID = mainWindowID + } + + /// UNUserNotificationCenter delegate callbacks can arrive off the main + /// thread, and both `AllowlistStore` and AppKit require the main thread — + /// hence `nonisolated` here with an explicit hop to `@MainActor` below + /// rather than isolating this whole class (which would fight the + /// framework's expectation that these delegate methods are plain, + /// synchronously-callable ObjC hooks). + nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + // Before this delegate existed, Argus had no + // UNUserNotificationCenter delegate at all, and with no delegate the + // system still shows the alert while the app is frontmost. Adding a + // delegate opts back into the platform's foreground-suppression + // behavior unless it explicitly re-requests presentation — this + // override exists purely to preserve that prior behavior, not to add + // anything new. + completionHandler([.banner, .sound]) + } + + nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + let userInfo = response.notification.request.content.userInfo + let ruleName = userInfo[NotificationManager.ruleNameKey] as? String + let executable = userInfo[NotificationManager.executableKey] as? String + let actionIdentifier = response.actionIdentifier + + Task { @MainActor [weak self] in + switch actionIdentifier { + case UNNotificationDefaultActionIdentifier, NotificationManager.showActionIdentifier: + self?.showMainWindow() + case NotificationManager.allowlistActionIdentifier: + // The event that triggered this notification may have + // matched several rules; NotificationManager.notify only + // attaches the single top-severity one to userInfo (see its + // doc comment), so only that rule is allowlisted here — not + // every rule the event tripped. Going through + // `requestAllow` means this takes the exact same Touch + // ID/password gate and DiagnosticsLog trail as right-clicking + // the event in-app; the notification action is not a bypass + // of that control, just another entry point into it. + if let ruleName, let executable { + self?.allowlist?.requestAllow(ruleName: ruleName, executable: executable) + } + default: + break + } + completionHandler() + } + } + + /// Reopens/activates the dashboard the same way `MenuBarPanel`'s "Open + /// Argus" button does — by locating the `NSWindow` whose `identifier` + /// matches `App.swift`'s `mainWindowID` (see its doc comment) — but + /// without SwiftUI's `openWindow` environment action, which only exists + /// inside a View's environment and isn't reachable from a plain + /// `UNUserNotificationCenterDelegate` object living outside the view + /// hierarchy. That means this can only bring an already-created window + /// forward; unlike `openWindow(id:)` it can't spin one up from nothing. + /// In practice the dashboard's `Window` scene is created at launch, so + /// this only matters if that window were later fully deallocated rather + /// than just ordered out. + @MainActor + private func showMainWindow() { + NSApp.activate(ignoringOtherApps: true) + guard let window = NSApp.windows.first(where: { $0.identifier?.rawValue == mainWindowID }) else { + DiagnosticsLog.write("notification action: main window not found (identifier \(mainWindowID))") + return + } + window.makeKeyAndOrderFront(nil) + } +} diff --git a/Tests/ArgusTests/EventExportTests.swift b/Tests/ArgusTests/EventExportTests.swift new file mode 100644 index 0000000..40c8a57 --- /dev/null +++ b/Tests/ArgusTests/EventExportTests.swift @@ -0,0 +1,127 @@ +import XCTest +@testable import Argus + +final class EventExportTests: XCTestCase { + private func sampleEvent( + pid: Int32 = 1, + ppid: Int32 = 99, + executable: String = "osascript", + command: String = "osascript -e 'do shell script \"id\" with administrator privileges'", + rules: [MatchedRule]? = nil, + timestamp: Date = Date(timeIntervalSince1970: 1_700_000_000) + ) -> ProcessEvent { + ProcessEvent( + pid: pid, + ppid: ppid, + executable: executable, + command: command, + rules: rules ?? [MatchedRule(name: "AppleScript privilege escalation", severity: .critical, + technique: "T1548 – Elevated Execution", explanation: "e")], + timestamp: timestamp + ) + } + + // MARK: - JSON + + func testJSONRoundTripsSingleEvent() throws { + let event = sampleEvent() + guard let json = EventExport.json(for: event) else { + return XCTFail("expected JSON string") + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(ProcessEvent.self, from: Data(json.utf8)) + + XCTAssertEqual(decoded.id, event.id) + XCTAssertEqual(decoded.pid, event.pid) + XCTAssertEqual(decoded.ppid, event.ppid) + XCTAssertEqual(decoded.executable, event.executable) + XCTAssertEqual(decoded.command, event.command) + XCTAssertEqual(decoded.rules.map(\.name), event.rules.map(\.name)) + XCTAssertEqual(decoded.rules.map(\.severity), event.rules.map(\.severity)) + XCTAssertEqual(decoded.timestamp.timeIntervalSince1970, event.timestamp.timeIntervalSince1970, accuracy: 0.001) + } + + func testJSONArrayRoundTripsMultipleEvents() throws { + let events = [sampleEvent(pid: 1), sampleEvent(pid: 2), sampleEvent(pid: 3)] + guard let data = EventExport.json(events: events) else { + return XCTFail("expected JSON data") + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode([ProcessEvent].self, from: data) + + XCTAssertEqual(decoded.count, 3) + XCTAssertEqual(decoded.map(\.pid), [1, 2, 3]) + XCTAssertEqual(decoded.map(\.id), events.map(\.id)) + } + + func testJSONForEventsIsEmptyArrayForEmptyInput() throws { + guard let data = EventExport.json(events: []) else { + return XCTFail("expected JSON data") + } + let decoder = JSONDecoder() + let decoded = try decoder.decode([ProcessEvent].self, from: data) + XCTAssertTrue(decoded.isEmpty) + } + + // MARK: - CSV + + func testCSVHeaderAndRowCount() { + let events = [sampleEvent(pid: 1), sampleEvent(pid: 2), sampleEvent(pid: 3)] + let csv = EventExport.csv(events: events) + + let lines = csv.split(separator: "\n", omittingEmptySubsequences: false) + XCTAssertEqual(lines.first, "timestamp,pid,ppid,executable,severity,techniques,rules,command") + // header + 3 data rows, trailing newline leaves one empty trailing element + XCTAssertEqual(lines.count, 5) + XCTAssertEqual(lines.last, "") + } + + func testCSVEmptyEventListProducesHeaderOnly() { + let csv = EventExport.csv(events: []) + XCTAssertEqual(csv, "timestamp,pid,ppid,executable,severity,techniques,rules,command\n") + } + + func testCSVMultiRuleEventJoinsRulesAndTechniquesWithSemicolons() { + let rules = [ + MatchedRule(name: "Rule A", severity: .watch, technique: "T1059", explanation: "e1"), + MatchedRule(name: "Rule B", severity: .critical, technique: "T1548", explanation: "e2") + ] + let event = sampleEvent(command: "echo hi", rules: rules) + let csv = EventExport.csv(events: [event]) + let isoTimestamp = ISO8601DateFormatter().string(from: event.timestamp) + + let expected = "timestamp,pid,ppid,executable,severity,techniques,rules,command\n" + + "\(isoTimestamp),1,99,osascript,CRITICAL,T1059;T1548,Rule A;Rule B,echo hi\n" + XCTAssertEqual(csv, expected) + } + + /// The load-bearing quoting test: a command containing a comma, embedded + /// double quotes, and a literal newline must survive RFC 4180 quoting + /// exactly — comma/newline alone would misalign columns, unescaped + /// quotes would prematurely close the field. Also exercises quoting on a + /// non-command field (a rule name containing a comma). + func testCSVQuotesFieldsContainingCommasQuotesAndNewlines() { + let command = "echo \"hello, world\"\nsecond line" + let event = sampleEvent( + pid: 42, + ppid: 7, + executable: "bash", + command: command, + rules: [MatchedRule(name: "Rule, A", severity: .critical, technique: "T1059", explanation: "e")] + ) + + let csv = EventExport.csv(events: [event]) + let isoTimestamp = ISO8601DateFormatter().string(from: event.timestamp) + + let expectedCommandField = "\"echo \"\"hello, world\"\"\nsecond line\"" + let expectedRuleField = "\"Rule, A\"" + let expected = "timestamp,pid,ppid,executable,severity,techniques,rules,command\n" + + "\(isoTimestamp),42,7,bash,CRITICAL,T1059,\(expectedRuleField),\(expectedCommandField)\n" + + XCTAssertEqual(csv, expected) + } +} From 1353fa71a926e34e8839a4ba4bff60a94bd2bdde Mon Sep 17 00:00:00 2001 From: Mark Watts Date: Fri, 21 Aug 2026 12:24:29 +0100 Subject: [PATCH 11/23] Document merged detection robustness enhancements Updated README to cover: Sigma engine spec improvements (N of quantifier, base64/base64offset/cased modifiers, keyword field matching, logsource filtering); richer match records with User/ParentUser fields and cross-tick parent-context cache; sampling watchdog with 10s timeout and degraded-state visibility; sequence/chain correlation in 10-min windows; persistence-artifact watcher on LaunchAgents/Daemons/periodic; tamper evidence via integrity.json MACs; lifecycle change (monitoring starts in app init); export/notification actions; and tooling (ci.yml, sync_sigma_rules.sh). Test count updated from 45 to 119, with new suites documented. Project layout tree expanded with new source and test files. Co-Authored-By: Claude Fable 5 --- README.md | 146 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 112 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 0cc145f..97ab22c 100644 --- a/README.md +++ b/README.md @@ -53,21 +53,39 @@ without requiring enterprise EDR tooling or kernel entitlements. a restart instead of resetting to empty. - **System notifications** — a real macOS notification for matched events at or above a threshold you choose (off by default beyond critical-only). + Notifications carry "Show in Argus" and "Allowlist…" action buttons — the + allowlist action goes through the exact same Touch ID gate as the in-app path. Authorization is requested once on first launch. +- **Event export** — right-click an event to "Copy as JSON"; from the History + panel, export the full event history as JSON or CSV (RFC 4180-quoted so + command lines with embedded commas and quotes don't misalign spreadsheets). - **Settings** (gear icon in the header) — poll interval and risk-decay - half-life are tunable now rather than fixed constants, plus the - notification threshold. + half-life are tunable now rather than fixed constants, the notification + threshold, and a "Launch at login" toggle (macOS 13+, uses SMAppService). The app is dark-only by design, matching a monitoring-console identity — it does not follow the system light/dark appearance toggle. ## How detection works -`ProcessMonitor` samples `ps -axww -o pid,ppid,command` roughly every 1.2s +`ProcessMonitor` samples `ps -axww -o pid,ppid,user,command` roughly every 1.2s and diffs it against the previous sample to find newly-spawned processes. Each new process is checked against every active rule; a match becomes an event with severity, a MITRE technique label, and an explanation. +The sample includes the user column now, so rules can match `User` and +`ParentUser` fields. Parent context (image, command line, and user) is +resolved from a cross-tick cache retained for ~3 polling intervals, so a +short-lived parent that exits before the next poll still resolves for +parent-keyed rules — a common race in LOLBin chains where a spawned parent +like `sh -c` is gone from the process table by the next sample. + +The `ps` invocation has a 10-second hard timeout. After 3 consecutive +sampling failures, the app enters a visible "degraded" state: the menu bar +icon switches to a warning triangle, a warning row appears in the flyout, and +a MONITOR DEGRADED badge shows in the header. This prevents a failed sample +from being mistaken for "no processes running". Recovery is automatic. + This is **polling-based, not kernel-event-based** — deliberately. True exec()-level capture on macOS requires the `endpoint-security` entitlement, which Apple grants by application review, not something obtainable @@ -78,20 +96,63 @@ exits within the ~1.2s window can be missed as an individual event (though a parent shell invoking it inline is still visible, since the parent's full command line is what `ps` reports). +### Sequence and chain correlation + +When distinct techniques fire in the same process tree within a rolling +10-minute window, Argus emits a synthetic "Suspicious sequence" event. Its +severity is escalated one level above the members' maximum (capped at +critical), and the event lists the member processes and rules. This is the +signal the README's own thesis calls out: a single LOLBin invocation is often +unremarkable, but two or more different techniques firing inside the same +process tree is a much stronger indicator. Same-rule refires, same-pid +multi-rule matches, and process trees related only through launchd don't +qualify as chains. + +### Persistence-artifact watcher + +An independent, event-driven sensor watches standard macOS persistence +locations: `~/Library/LaunchAgents`, `/Library/LaunchAgents`, +`/Library/LaunchDaemons`, and `/etc/periodic`. Added or modified files are +reported with elevated severity; removals are also watched. Baseline state +at startup is silent, but the watcher catches persistence artifacts even +when the writing process was too short-lived for the polling monitor to ever +sample it. Allowlist filtering deliberately doesn't apply to these events — +a persistence artifact change is a different thing than an allowlisted +process. + +### Tamper evidence + +The app records HMAC-SHA256 MACs of `rules-state.json` and `allowlist.json` +on every authenticated write into a sidecar `integrity.json` file. The signing +key is stored in the login Keychain (service "Argus", account +"integrity-key") rather than on disk, so an attacker who can rewrite the +guarded files in place has no reason to also have Keychain access. At +launch, any mismatch between a file's current contents and its recorded MAC +is surfaced as a critical "Detection state modified outside Argus" event (T1562.001), +so the tamper itself becomes visible in the feed. This is evidence, not prevention — a same-user attacker can still rewrite the files, but no longer +silently. + ## Rule format and management Rules are [Sigma](https://github.com/SigmaHQ/sigma) — the open, vendor- neutral YAML format the wider detection-engineering community actually publishes in, rather than a bespoke format invented for this app. A rule is -a `logsource` (we only match `category: process_creation`, `product: macos`), -a named `detection` block of field/modifier/value selections -(`CommandLine|contains`, `Image|endswith`, `ParentImage|contains`, `|re` -regex, `|all` for AND-of-list, etc.), and a `condition` string combining -those selections (`selection`, `1 of selection_*`, `all of selection_* and -not 1 of filter_*`, and so on). `Sources/Argus/Sigma/` is a real, if partial, -implementation of that spec: a hand-rolled YAML parser, a condition-language -parser/evaluator, and a field matcher — not a re-skin of the old pattern -list. +a `logsource` (we only match `category: process_creation`, `product: macos` +or Linux), a named `detection` block of field/modifier/value selections, and +a `condition` string combining those selections. `Sources/Argus/Sigma/` is a +real, if partial, implementation of that spec: a hand-rolled YAML parser, +a condition-language parser/evaluator, and a field matcher — not a re-skin +of the old pattern list. + +The condition language now supports the general `N of selection_*` quantifier +(not just `1 of` and `all of`). The matcher supports `base64`, `base64offset` +(all three byte-alignment encodings, verified against SigmaHQ reference +vectors), and `cased` modifiers for case-sensitive comparison. Keyword +selections (those with no field name) match against all record fields per +spec, not just CommandLine. Rules whose `logsource` is incompatible +(something other than `process_creation`/`macos` or portable Linux techniques) +are silently skipped at load time; a count is shown in the rule browser +alongside the rule count in the header. **85 rules ship with the app**, sourced from three places: @@ -169,7 +230,9 @@ Matched events persist to `~/Library/Application Support/Argus/events.jsonl` periodically rather than on every write, so a live tail will occasionally see the file shrink back down). Disabled-rule state persists to `~/Library/Application Support/Argus/rules-state.json`, and your own rule -files live in `~/Library/Application Support/Argus/rules/`. +files live in `~/Library/Application Support/Argus/rules/`. An `integrity.json` +sidecar records HMAC-SHA256 MACs of the security-relevant JSON files +(allowlist.json and rules-state.json) to detect out-of-band edits. ## Testing @@ -177,20 +240,19 @@ files live in `~/Library/Application Support/Argus/rules/`. swift test ``` -45 tests. The Sigma engine is validated two ways: `SigmaEngineTests` checks +119 tests. The Sigma engine is validated two ways: `SigmaEngineTests` checks the YAML parser, condition evaluator, and matcher against real rule text -fetched from SigmaHQ (list-of-selections, `|contains|all`, nested -conditions — not paraphrased), and `BundledRulesTests` loads all 85 shipped -rule files from disk, asserts a few structural invariants (unique IDs, -every condition parses, rule count is the exact expected number so silently -losing a whole directory fails loudly), and — for the 10 Argus-authored -rules, which embed `x-example-match`/`x-example-safe` fixtures — asserts -each one matches what it claims to and doesn't match its benign lookalikes. -That's the rule-count-scales replacement for the old "one hardcoded Swift -sample per rule" pattern, which stopped being practical once the catalog -grew past 20. Also covered: `RuleStoreTests` (enable/disable persistence, -user-rule loading), the allowlist, event history persistence/trimming, the -event feed's filter logic, history aggregation, and settings persistence. +fetched from SigmaHQ (including the `N of selection_*` quantifier, `base64`/ +`base64offset`/`cased` modifiers, and keyword matching), and `BundledRulesTests` +loads all 85 shipped rule files, asserts structural invariants, and — for the +10 Argus-authored rules — verifies they match what they claim to. Also +covered: `RuleStoreTests` (enable/disable persistence, user-rule loading), +`ProcessMonitorTests` (parent-context caching, sampling health tracking), +`AllowlistTests`, `EventStoreTests`, `EventFilterTests`, `HistoryStatsTests`, +`AppSettingsTests`, `EventExportTests` (JSON and CSV serialization), +`ChainCorrelatorTests` (sequence detection), `PersistenceWatcherTests` +(artifact diffing and event generation), and `IntegrityGuardTests` (MAC +recording and verification). ## Project layout @@ -203,18 +265,25 @@ Sources/Argus/ YAMLValue.swift minimal YAML document tree YAMLParser.swift hand-rolled block-YAML parser SigmaRule.swift rule model + YAML→model mapping - SigmaCondition.swift condition-language parser/evaluator - SigmaMatcher.swift field/selection matching against a process record - RuleStore.swift loads bundled + user rules, enable/disable persistence + SigmaCondition.swift condition-language parser/evaluator (N of, all of) + SigmaMatcher.swift field/selection matching (base64, base64offset, cased) + RuleStore.swift loads bundled + user rules, enable/disable, skip count ProcessMonitor.swift ps polling, diffing, risk-score decay, Sigma matching + ParentContextCache.swift cross-tick parent lineage resolution + SamplingHealthTracker.swift monitors consecutive failures, degraded state + ChainCorrelator.swift correlates techniques in same process tree (10-min window) + PersistenceWatcher.swift event-driven monitoring of LaunchAgents/Daemons/periodic + IntegrityGuard.swift HMAC verification of rules-state.json and allowlist.json AllowlistStore.swift persisted (rule, executable) suppression EventStore.swift persisted event history (events.jsonl) EventFilter.swift search/severity/session filter logic - HistoryStats.swift day-bucketing + technique-frequency aggregation - AppSettings.swift tunable poll interval, decay, notification threshold - NotificationManager.swift thin UNUserNotificationCenter wrapper + EventExport.swift JSON and RFC 4180-quoted CSV serialization + HistoryStats.swift day-bucketing + technique-frequency aggregation + AppSettings.swift tunable poll interval, decay, notification threshold + NotificationManager.swift UNUserNotificationCenter wrapper + NotificationResponder.swift notification actions (Show in Argus, Allowlist) DiagnosticsLog.swift on-disk activity log - Theme.swift color/type tokens + Theme.swift color/type tokens OrbitView.swift Canvas-based radial visualization GaugeView.swift arced risk meter SparklineView.swift activity histogram @@ -227,8 +296,13 @@ Tests/ArgusTests/ AllowlistTests.swift filter logic + persistence round-trip EventStoreTests.swift history persistence + trimming EventFilterTests.swift search/severity/session filter logic + EventExportTests.swift JSON/CSV serialization round-trip HistoryStatsTests.swift day-bucketing + technique-frequency aggregation AppSettingsTests.swift settings persistence/clamping + notification-threshold logic + ProcessMonitorTests.swift parent-context caching, health tracking + ChainCorrelatorTests.swift sequence detection in process trees + PersistenceWatcherTests.swift artifact diffing and event generation + IntegrityGuardTests.swift MAC recording and verification Resources/ Info.plist app bundle metadata icon_gen.swift generates the app icon programmatically @@ -238,7 +312,11 @@ Resources/ imported/ 67 rules verbatim from SigmaHQ macOS process_creation imported-portable/ 8 rules verbatim from SigmaHQ Linux process_creation (portable shell techniques) custom/ 10 rules authored for Argus, filling gaps in the imported sets -scripts/build_app.sh release build → signed .app bundle (bundles Resources/Rules) +scripts/ + build_app.sh release build → signed .app bundle (bundles Resources/Rules) + sync_sigma_rules.sh manual dev tool to refresh bundled SigmaHQ rules from upstream +.github/workflows/ + ci.yml runs swift test + app build on every push/PR ``` ## Non-goals From 5738fd4b0cc1e444c1191f9214affb0ed736a67c Mon Sep 17 00:00:00 2001 From: Mark Watts Date: Fri, 21 Aug 2026 12:25:43 +0100 Subject: [PATCH 12/23] Fix README layout tree and tighten three phrasings ParentContextCache/SamplingHealthTracker are types inside ProcessMonitor.swift, not files; the tree listed them as files. Also reworded the self-referential thesis mention, the Keychain rationale, and a 'silently skipped'/'count is shown' contradiction. Co-Authored-By: Claude Fable 5 --- README.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 97ab22c..0046449 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ When distinct techniques fire in the same process tree within a rolling 10-minute window, Argus emits a synthetic "Suspicious sequence" event. Its severity is escalated one level above the members' maximum (capped at critical), and the event lists the member processes and rules. This is the -signal the README's own thesis calls out: a single LOLBin invocation is often +signal the "Why this, specifically" section calls out: a single LOLBin invocation is often unremarkable, but two or more different techniques firing inside the same process tree is a much stronger indicator. Same-rule refires, same-pid multi-rule matches, and process trees related only through launchd don't @@ -125,8 +125,8 @@ process. The app records HMAC-SHA256 MACs of `rules-state.json` and `allowlist.json` on every authenticated write into a sidecar `integrity.json` file. The signing key is stored in the login Keychain (service "Argus", account -"integrity-key") rather than on disk, so an attacker who can rewrite the -guarded files in place has no reason to also have Keychain access. At +"integrity-key") rather than on disk, so rewriting the +guarded files in place is not by itself enough to also fix up their MACs. At launch, any mismatch between a file's current contents and its recorded MAC is surfaced as a critical "Detection state modified outside Argus" event (T1562.001), so the tamper itself becomes visible in the feed. This is evidence, not prevention — a same-user attacker can still rewrite the files, but no longer @@ -151,7 +151,7 @@ vectors), and `cased` modifiers for case-sensitive comparison. Keyword selections (those with no field name) match against all record fields per spec, not just CommandLine. Rules whose `logsource` is incompatible (something other than `process_creation`/`macos` or portable Linux techniques) -are silently skipped at load time; a count is shown in the rule browser +are skipped at load time; a count is shown in the rule browser alongside the rule count in the header. **85 rules ship with the app**, sourced from three places: @@ -268,9 +268,8 @@ Sources/Argus/ SigmaCondition.swift condition-language parser/evaluator (N of, all of) SigmaMatcher.swift field/selection matching (base64, base64offset, cased) RuleStore.swift loads bundled + user rules, enable/disable, skip count - ProcessMonitor.swift ps polling, diffing, risk-score decay, Sigma matching - ParentContextCache.swift cross-tick parent lineage resolution - SamplingHealthTracker.swift monitors consecutive failures, degraded state + ProcessMonitor.swift ps polling, diffing, risk-score decay, Sigma matching, + cross-tick parent cache, sampling watchdog ChainCorrelator.swift correlates techniques in same process tree (10-min window) PersistenceWatcher.swift event-driven monitoring of LaunchAgents/Daemons/periodic IntegrityGuard.swift HMAC verification of rules-state.json and allowlist.json From c9dc905755fe00c84bceb4cbc667b985f815c507 Mon Sep 17 00:00:00 2001 From: Mark Watts Date: Fri, 21 Aug 2026 13:06:10 +0100 Subject: [PATCH 13/23] Never block launch on the integrity Keychain prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetching the HMAC key can present a Keychain consent prompt (the item's ACL doesn't cover a rebuilt ad-hoc-signed binary), and SecItem calls block until answered — verifying in the stores' inits froze the whole app behind that dialog, and every verify/record call re-prompted after a denial. IntegrityGuard now funnels all work through its own serial queue with a cached one-shot key attempt (at most one prompt per launch, never on the main thread); stores verify on request via an async verifyIntegrity() the app calls after wiring; DiagnosticsLog appends are serialized now that they arrive from multiple queues. Co-Authored-By: Claude Fable 5 --- Sources/Argus/AllowlistStore.swift | 22 +++++++++-- Sources/Argus/App.swift | 31 ++++++++++------ Sources/Argus/DiagnosticsLog.swift | 27 ++++++++++---- Sources/Argus/IntegrityGuard.swift | 53 ++++++++++++++++++++++++--- Sources/Argus/Sigma/RuleStore.swift | 22 +++++++++-- Tests/ArgusTests/AllowlistTests.swift | 11 +++++- Tests/ArgusTests/RuleStoreTests.swift | 8 ++-- 7 files changed, 138 insertions(+), 36 deletions(-) diff --git a/Sources/Argus/AllowlistStore.swift b/Sources/Argus/AllowlistStore.swift index 4eb83ec..efcd3be 100644 --- a/Sources/Argus/AllowlistStore.swift +++ b/Sources/Argus/AllowlistStore.swift @@ -23,9 +23,11 @@ struct AllowlistEntry: Identifiable, Codable, Equatable { final class AllowlistStore: ObservableObject { @Published private(set) var entries: [AllowlistEntry] = [] /// Result of verifying `allowlist.json` against the last MAC recorded by - /// an authenticated write, computed once at init. See `IntegrityGuard` — - /// the app checks this after construction to decide whether to report a - /// tamper event; the store itself doesn't emit events. + /// an authenticated write. `nil` until `verifyIntegrity` completes — + /// verification is deliberately not done in init, because the Keychain + /// key fetch can present a consent prompt that would otherwise block app + /// launch (see `IntegrityGuard`'s threading note). The store itself + /// doesn't emit events; the app acts on the verdict. private(set) var integrityVerdict: IntegrityVerdict? let fileURL: URL @@ -46,10 +48,22 @@ final class AllowlistStore: ObservableObject { self.fileURL = dir.appendingPathComponent("allowlist.json") } self.integrityGuard = integrityGuard - integrityVerdict = integrityGuard?.verify(self.fileURL) load() } + /// Verifies `allowlist.json` off the main thread and stores the verdict + /// (also handed to `completion`, on the main actor). A no-op when no + /// guard was injected. + func verifyIntegrity(completion: @escaping @MainActor (IntegrityVerdict) -> Void = { _ in }) { + guard let integrityGuard else { return } + integrityGuard.verifyAsync(fileURL) { [weak self] verdict in + Task { @MainActor in + self?.integrityVerdict = verdict + completion(verdict) + } + } + } + func isAllowed(ruleName: String, executable: String) -> Bool { entries.contains { $0.ruleName == ruleName && $0.executable == executable } } diff --git a/Sources/Argus/App.swift b/Sources/Argus/App.swift index 99b9779..4ad259a 100644 --- a/Sources/Argus/App.swift +++ b/Sources/Argus/App.swift @@ -89,19 +89,28 @@ struct ArgusApp: App { } watcher.start() - // IntegrityGuard verified rules-state.json/allowlist.json against - // their last authenticated-write MAC during each store's own init - // (above). A `.tampered` verdict means the file changed outside - // Argus's Touch ID/password-gated write path — exactly what a local - // attacker would do to blind a rule or re-enable a suppressed alert - // silently — so surface it as a critical event in the feed. + // Verify rules-state.json/allowlist.json against their last + // authenticated-write MAC — asynchronously, off this init: the + // Keychain key fetch can present a consent prompt (seen in practice + // after a rebuild changed the ad-hoc signature), and doing it here + // synchronously froze the whole launch behind that dialog. A + // `.tampered` verdict means the file changed outside Argus's Touch + // ID/password-gated write path — exactly what a local attacker would + // do to blind a rule or re-enable a suppressed alert silently — so + // surface it as a critical event in the feed. // `.baselineEstablished`/`.unverifiable` are informational only and // already logged by IntegrityGuard itself. - for (verdict, fileURL) in [(rules.integrityVerdict, rules.stateFileURL), (allowlistStore.integrityVerdict, allowlistStore.fileURL)] { - if verdict == .tampered { - DiagnosticsLog.write("integrity-guard: tamper detected outside Argus for \(fileURL.lastPathComponent)") - m.ingestExternal(IntegrityGuard.tamperEvent(for: fileURL)) - } + let rulesStateURL = rules.stateFileURL + rules.verifyIntegrity { [weak m] verdict in + guard verdict == .tampered else { return } + DiagnosticsLog.write("integrity-guard: tamper detected outside Argus for \(rulesStateURL.lastPathComponent)") + m?.ingestExternal(IntegrityGuard.tamperEvent(for: rulesStateURL)) + } + let allowlistURL = allowlistStore.fileURL + allowlistStore.verifyIntegrity { [weak m] verdict in + guard verdict == .tampered else { return } + DiagnosticsLog.write("integrity-guard: tamper detected outside Argus for \(allowlistURL.lastPathComponent)") + m?.ingestExternal(IntegrityGuard.tamperEvent(for: allowlistURL)) } _monitor = StateObject(wrappedValue: m) diff --git a/Sources/Argus/DiagnosticsLog.swift b/Sources/Argus/DiagnosticsLog.swift index 3d09f8b..2495f40 100644 --- a/Sources/Argus/DiagnosticsLog.swift +++ b/Sources/Argus/DiagnosticsLog.swift @@ -17,21 +17,32 @@ enum DiagnosticsLog { private static let formatter: ISO8601DateFormatter = ISO8601DateFormatter() + /// Serializes appends: `write` is called from the main actor, the + /// integrity guard's queue, and the persistence watcher's queues, and + /// two concurrent seek-then-write appends to one file can interleave + /// mid-record. Fire-and-forget onto one serial queue keeps records whole + /// without making any caller wait on file I/O. + private static let queue = DispatchQueue(label: "argus.diagnostics-log", qos: .utility) + static func write(_ line: String) { // Strip CR/LF so a process whose command line contains newlines can't // forge additional log lines (log injection). The record we append - // ends with the one newline added below. + // ends with the one newline added below. Stamped here, not on the + // queue, so the timestamp reflects when the event happened rather + // than when the queue got to it. let sanitized = line.replacingOccurrences(of: "\r", with: " ") .replacingOccurrences(of: "\n", with: " ") let stamped = "\(formatter.string(from: Date())) \(sanitized)\n" guard let data = stamped.data(using: .utf8) else { return } - if FileManager.default.fileExists(atPath: url.path), let handle = try? FileHandle(forWritingTo: url) { - handle.seekToEndOfFile() - handle.write(data) - try? handle.close() - } else { - try? data.write(to: url) - try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + queue.async { + if FileManager.default.fileExists(atPath: url.path), let handle = try? FileHandle(forWritingTo: url) { + handle.seekToEndOfFile() + handle.write(data) + try? handle.close() + } else { + try? data.write(to: url) + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } } } } diff --git a/Sources/Argus/IntegrityGuard.swift b/Sources/Argus/IntegrityGuard.swift index b454165..e2a3024 100644 --- a/Sources/Argus/IntegrityGuard.swift +++ b/Sources/Argus/IntegrityGuard.swift @@ -115,9 +115,23 @@ struct KeychainIntegrityKeyProvider: IntegrityKeyProvider { /// path, not filename — a store pointed at a same-named file elsewhere (a /// test's temp copy, a second profile) must never collide with the real /// file's recorded MAC. -final class IntegrityGuard { +/// Threading: fetching the Keychain key can present a user consent prompt +/// (the item's ACL doesn't cover this binary — e.g. after a rebuild changes +/// the ad-hoc signature), and SecItem calls block their thread until the +/// user answers. Every operation therefore funnels through a private serial +/// queue: callers never block on a pending prompt (`recordAuthenticatedWrite` +/// and `verifyAsync` are fire-and-forget/async; the synchronous `verify` is +/// for tests), and the key attempt is cached so at most one prompt appears +/// per launch, however many files are guarded. `@unchecked Sendable` is +/// sound because all mutable state is confined to that queue. +final class IntegrityGuard: @unchecked Sendable { private let keyProvider: IntegrityKeyProvider private let sidecarURL: URL + private let queue = DispatchQueue(label: "argus.integrity-guard", qos: .utility) + /// `nil` = not yet attempted; `.some(nil)` = attempted and unavailable + /// (denied, locked, no keychain) — cached so a denial doesn't re-prompt + /// on every subsequent call this launch. Only touched on `queue`. + private var cachedKeyAttempt: Data?? /// Shared production instance backed by the real Keychain. Individual /// stores default their `integrityGuard` init parameter to this so @@ -142,9 +156,38 @@ final class IntegrityGuard { /// Call this after every legitimate, already-authenticated write to a /// guarded file — it recomputes the file's MAC from what's on disk right /// now and persists it, so the next `verify(_:)` treats this content as - /// trusted. + /// trusted. Fire-and-forget: the work (including a possible Keychain + /// prompt) happens on the guard's own queue, never on the caller's + /// thread. Ordering with `verify`/`verifyAsync` is preserved because + /// everything shares the one serial queue. func recordAuthenticatedWrite(of fileURL: URL) { - guard let key = keyProvider.key() else { + queue.async { self.recordOnQueue(fileURL) } + } + + /// Synchronous verification — blocks the calling thread until any + /// already-queued work (pending MAC records) and the verification itself + /// complete. Intended for tests; app code should use `verifyAsync` so a + /// Keychain consent prompt can never freeze the UI. + func verify(_ fileURL: URL) -> IntegrityVerdict { + queue.sync { verifyOnQueue(fileURL) } + } + + /// Asynchronous verification; `completion` runs on the guard's queue — + /// hop to the main actor before touching UI or store state. + func verifyAsync(_ fileURL: URL, completion: @escaping (IntegrityVerdict) -> Void) { + queue.async { completion(self.verifyOnQueue(fileURL)) } + } + + /// The cached one-shot key fetch (see the threading note on the type). + private func keyOnQueue() -> Data? { + if let attempted = cachedKeyAttempt { return attempted } + let key = keyProvider.key() + cachedKeyAttempt = .some(key) + return key + } + + private func recordOnQueue(_ fileURL: URL) { + guard let key = keyOnQueue() else { DiagnosticsLog.write("integrity-guard: no key available, cannot record \(fileURL.lastPathComponent)") return } @@ -167,8 +210,8 @@ final class IntegrityGuard { /// once the app has surfaced the tamper as a critical event, there's /// nothing more for a repeat report to add, and re-baselining is what /// lets a *new* out-of-band edit be distinguished from the same old one. - func verify(_ fileURL: URL) -> IntegrityVerdict { - guard let key = keyProvider.key() else { + private func verifyOnQueue(_ fileURL: URL) -> IntegrityVerdict { + guard let key = keyOnQueue() else { DiagnosticsLog.write("integrity-guard: no key available, cannot verify \(fileURL.lastPathComponent)") return .unverifiable } diff --git a/Sources/Argus/Sigma/RuleStore.swift b/Sources/Argus/Sigma/RuleStore.swift index 4bc6f72..faf7b61 100644 --- a/Sources/Argus/Sigma/RuleStore.swift +++ b/Sources/Argus/Sigma/RuleStore.swift @@ -16,9 +16,11 @@ final class RuleStore: ObservableObject { /// Windows-only rule dropped into the user rules folder by mistake. @Published private(set) var skippedIncompatibleCount: Int = 0 /// Result of verifying `rules-state.json` against the last MAC recorded - /// by an authenticated write, computed once at init. See `IntegrityGuard` - /// — the app checks this after construction to decide whether to report - /// a tamper event; the store itself doesn't emit events. + /// by an authenticated write. `nil` until `verifyIntegrity` completes — + /// verification is deliberately not done in init, because the Keychain + /// key fetch can present a consent prompt that would otherwise block app + /// launch (see `IntegrityGuard`'s threading note). The store itself + /// doesn't emit events; the app acts on the verdict. private(set) var integrityVerdict: IntegrityVerdict? private let bundledRulesDirectory: URL? @@ -42,11 +44,23 @@ final class RuleStore: ObservableObject { // Keep the shared Argus support directory owner-only (see EventStore). try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: appSupport.path) - integrityVerdict = integrityGuard?.verify(self.stateFileURL) loadDisabledState() reload() } + /// Verifies `rules-state.json` off the main thread and stores the + /// verdict (also handed to `completion`, on the main actor). A no-op + /// when no guard was injected. + func verifyIntegrity(completion: @escaping @MainActor (IntegrityVerdict) -> Void = { _ in }) { + guard let integrityGuard else { return } + integrityGuard.verifyAsync(stateFileURL) { [weak self] verdict in + Task { @MainActor in + self?.integrityVerdict = verdict + completion(verdict) + } + } + } + func reload() { var loaded: [SigmaRule] = [] var skipped = 0 diff --git a/Tests/ArgusTests/AllowlistTests.swift b/Tests/ArgusTests/AllowlistTests.swift index 5a66a2d..e06e44f 100644 --- a/Tests/ArgusTests/AllowlistTests.swift +++ b/Tests/ArgusTests/AllowlistTests.swift @@ -80,7 +80,16 @@ final class AllowlistStoreTests: XCTestCase { ) let store = AllowlistStore(fileURL: url, integrityGuard: fixedKeyGuard) - // No allowlist.json exists yet — nothing has been authenticated, nothing to verify. + // Verification is async-on-request now (a Keychain prompt must never + // block init), so init leaves the verdict unset. No allowlist.json + // exists yet — nothing has been authenticated, nothing to verify. + XCTAssertNil(store.integrityVerdict) + let verified = expectation(description: "verifyIntegrity completes") + store.verifyIntegrity { verdict in + XCTAssertEqual(verdict, .unverifiable) + verified.fulfill() + } + wait(for: [verified], timeout: 5) XCTAssertEqual(store.integrityVerdict, .unverifiable) store.allow(ruleName: "A", executable: "osascript") diff --git a/Tests/ArgusTests/RuleStoreTests.swift b/Tests/ArgusTests/RuleStoreTests.swift index c3d1814..d6c2850 100644 --- a/Tests/ArgusTests/RuleStoreTests.swift +++ b/Tests/ArgusTests/RuleStoreTests.swift @@ -186,9 +186,11 @@ final class RuleStoreTests: XCTestCase { try? sampleRule.write(to: bundled.appendingPathComponent("custom/test.yml"), atomically: true, encoding: .utf8) let store = RuleStore(bundledRulesDirectory: bundled, userRulesDirectory: user, stateFileURL: state, integrityGuard: fixedKeyGuard) - // No rules-state.json exists yet — nothing has been authenticated, - // nothing to verify. - XCTAssertEqual(store.integrityVerdict, .unverifiable) + // Verification is async-on-request now (a Keychain prompt must never + // block init), so init leaves the verdict unset. No rules-state.json + // exists yet — nothing has been authenticated, nothing to verify. + XCTAssertNil(store.integrityVerdict) + XCTAssertEqual(fixedKeyGuard.verify(state), .unverifiable) XCTAssertEqual(store.rules.count, 1) store.toggle(store.rules[0]) From b38fede9db06e57c1707ebfc5311597c6011dbe0 Mon Sep 17 00:00:00 2001 From: Mark Watts Date: Fri, 21 Aug 2026 13:12:13 +0100 Subject: [PATCH 14/23] Prefer a stable signing identity over ad-hoc in the app build An ad-hoc signature changes on every rebuild, so the Keychain ACL on the IntegrityGuard key stopped matching after each rebuild and macOS re-prompted for access. The build now signs with $ARGUS_SIGN_IDENTITY or the first valid codesigning identity in the keychain (an Apple Development certificate here), keeping the designated requirement stable across rebuilds; CI has no identities and still lands on the ad-hoc fallback. Verified live: a rebuilt binary read the key with no prompt, and an out-of-band allowlist.json edit made across the restart was reported as the expected critical T1562.001 tamper event. Co-Authored-By: Claude Fable 5 --- README.md | 8 ++++++++ scripts/build_app.sh | 24 ++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0046449..5fcfa4e 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,14 @@ open build/Argus.app Requires Xcode command line tools (`xcode-select -p`) and macOS 13+. No network access, no special permissions, no entitlements to approve. +The build signs with the first codesigning identity in your keychain (or +`$ARGUS_SIGN_IDENTITY`), falling back to an ad-hoc signature when there is +none, as on CI. A stable identity matters beyond cosmetics: the tamper- +evidence key lives in the Keychain, and its ACL matches the app by signing +identity — ad-hoc signatures change every rebuild, so every rebuild would +re-prompt for Keychain access, while a real identity keeps the ACL matching +across rebuilds (and, for Apple Development certificates, across renewals). + A rolling diagnostic log is written to `~/Library/Logs/Argus/argus.log` independent of the UI: diff --git a/scripts/build_app.sh b/scripts/build_app.sh index bae8f4c..0b3a115 100755 --- a/scripts/build_app.sh +++ b/scripts/build_app.sh @@ -36,8 +36,28 @@ sips -z 512 512 "$SRC" --out "$ICONSET/icon_512x512.png" >/dev/null cp "$SRC" "$ICONSET/icon_512x512@2x.png" iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/AppIcon.icns" -echo "==> ad-hoc code signing" -codesign --force --deep -s - "$APP" +# Sign with a stable identity when one is available. An ad-hoc signature +# (-s -) changes on every rebuild, which resets the app's identity as far as +# the Keychain is concerned — macOS then re-prompts for access to the +# IntegrityGuard key after each rebuild. A real signing identity (an Apple +# Development certificate, or any codesigning cert) keeps the designated +# requirement stable across rebuilds, so the Keychain ACL keeps matching and +# the prompt never comes back. Resolution order: +# 1. $ARGUS_SIGN_IDENTITY, if set (name or SHA-1 of a keychain identity) +# 2. the first valid codesigning identity in the keychain +# 3. ad-hoc (-s -) — the CI runner has no identities and lands here +IDENTITY="${ARGUS_SIGN_IDENTITY:-}" +if [ -z "$IDENTITY" ]; then + IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \ + | awk -F'"' '/^ *[0-9]+\)/ { print $2; exit }') +fi +if [ -n "$IDENTITY" ]; then + echo "==> code signing as: $IDENTITY" + codesign --force --deep -s "$IDENTITY" "$APP" +else + echo "==> ad-hoc code signing (no signing identity found)" + codesign --force --deep -s - "$APP" +fi echo "==> done: $APP" echo " open $APP" From c73b071a18d494e7397017dcf7fca72d1cb1c5ce Mon Sep 17 00:00:00 2001 From: Mark Watts Date: Fri, 21 Aug 2026 14:51:08 +0100 Subject: [PATCH 15/23] Add ancestry-based provenance attribution to detection events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces ProvenanceClassifier, a data-driven table that tags a matched process with the supervisors found in its ancestry (Claude Code, Docker, Homebrew, terminals, IDEs) so alerts from supervised automation are distinguishable from standalone activity in the feed. ParentContextCache gains ancestorRecords(of:maxDepth:) — image/command per ancestor pid, nearest first — which ancestry(of:) now derives from, and which processSample reuses both to populate new AncestorImages/AncestorCommandLines Sigma fields and to feed the chain correlator, avoiding a duplicate walk. ProcessEvent.provenance is decoded with decodeIfPresent so existing events.jsonl history without the key still loads. The event feed shows a dim "via claude"-style chip, and search now matches provenance labels too. This is attribution for triage only — ancestry is spoofable and a tag must never be treated as a trust signal. Co-Authored-By: Claude Fable 5 --- Sources/Argus/DashboardView.swift | 26 +++- Sources/Argus/EventFilter.swift | 1 + Sources/Argus/Models.swift | 29 ++++- Sources/Argus/ProcessMonitor.swift | 56 +++++++-- Sources/Argus/ProvenanceClassifier.swift | 104 ++++++++++++++++ Tests/ArgusTests/EventFilterTests.swift | 16 ++- Tests/ArgusTests/ModelsTests.swift | 49 ++++++++ Tests/ArgusTests/ProcessMonitorTests.swift | 56 +++++++++ .../ProvenanceClassifierTests.swift | 114 ++++++++++++++++++ 9 files changed, 435 insertions(+), 16 deletions(-) create mode 100644 Sources/Argus/ProvenanceClassifier.swift create mode 100644 Tests/ArgusTests/ModelsTests.swift create mode 100644 Tests/ArgusTests/ProvenanceClassifierTests.swift diff --git a/Sources/Argus/DashboardView.swift b/Sources/Argus/DashboardView.swift index 4f0bc38..14b1cd6 100644 --- a/Sources/Argus/DashboardView.swift +++ b/Sources/Argus/DashboardView.swift @@ -331,7 +331,7 @@ struct DashboardView: View { Image(systemName: "magnifyingglass") .font(.system(size: 10)) .foregroundStyle(Theme.dim) - TextField("Search executable, command, or technique…", text: $searchText) + TextField("Search executable, command, technique, or supervisor…", text: $searchText) .textFieldStyle(.plain) .font(Theme.mono(11)) if !searchText.isEmpty { @@ -450,6 +450,10 @@ struct EventRow: View { .buttonStyle(.plain) .help("Show every event sharing this process's parent (pid \(event.ppid))") + if !event.provenance.isEmpty { + provenanceBadge + } + Spacer() ForEach(event.rules.prefix(expanded ? event.rules.count : 1)) { rule in Text(rule.technique) @@ -506,6 +510,26 @@ struct EventRow: View { } } } + + /// "via claude"-style chip surfacing `ProcessEvent.provenance` — capped + /// to the first two labels so a deep, multi-supervisor ancestry (e.g. + /// Claude inside a terminal inside tmux) doesn't crowd out the rule + /// chips it sits beside. Deliberately dimmer than the technique/severity + /// chips: this is context for triage, not a signal of its own — see + /// `ProvenanceTag`'s doc comment. + private var provenanceBadge: some View { + HStack(spacing: 3) { + Image(systemName: "arrowshape.turn.up.left") + .font(.system(size: 7)) + Text("via \(event.provenance.prefix(2).joined(separator: ", "))") + .font(.system(size: 9)) + } + .foregroundStyle(Theme.dim) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Theme.surfaceRaised) + .clipShape(Capsule()) + .help("Ancestry attribution, not authorization — process ancestry can be spoofed; this is triage context only.") + } } /// Popover from the header's "RULES LOADED" stat — the rule management diff --git a/Sources/Argus/EventFilter.swift b/Sources/Argus/EventFilter.swift index 6528aae..186f9b4 100644 --- a/Sources/Argus/EventFilter.swift +++ b/Sources/Argus/EventFilter.swift @@ -24,6 +24,7 @@ enum EventFilter { if event.rules.contains(where: { $0.name.lowercased().contains(trimmed) || $0.technique.lowercased().contains(trimmed) }) { return true } + if event.provenance.contains(where: { $0.lowercased().contains(trimmed) }) { return true } return false } } diff --git a/Sources/Argus/Models.swift b/Sources/Argus/Models.swift index 26632c0..67ea51a 100644 --- a/Sources/Argus/Models.swift +++ b/Sources/Argus/Models.swift @@ -72,8 +72,13 @@ struct ProcessEvent: Identifiable, Codable { let command: String let rules: [MatchedRule] let timestamp: Date + /// Supervisor labels from `ProvenanceClassifier` (e.g. "claude", + /// "docker") — attribution for triage, never a trust signal; see + /// `ProvenanceTag`'s doc comment. Empty for synthetic events (chain, + /// persistence, tamper) that don't carry a real process ancestry. + let provenance: [String] - init(id: UUID = UUID(), pid: Int32, ppid: Int32, executable: String, command: String, rules: [MatchedRule], timestamp: Date) { + init(id: UUID = UUID(), pid: Int32, ppid: Int32, executable: String, command: String, rules: [MatchedRule], timestamp: Date, provenance: [String] = []) { self.id = id self.pid = pid self.ppid = ppid @@ -81,6 +86,28 @@ struct ProcessEvent: Identifiable, Codable { self.command = command self.rules = rules self.timestamp = timestamp + self.provenance = provenance + } + + private enum CodingKeys: String, CodingKey { + case id, pid, ppid, executable, command, rules, timestamp, provenance + } + + // Custom decode so historical events.jsonl lines written before + // `provenance` existed still decode: `decodeIfPresent` falls back to `[]` + // instead of failing the whole load on a missing key. `encode(to:)` is + // left to the synthesized Encodable conformance — every newly-written + // event always carries the key. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + pid = try container.decode(Int32.self, forKey: .pid) + ppid = try container.decode(Int32.self, forKey: .ppid) + executable = try container.decode(String.self, forKey: .executable) + command = try container.decode(String.self, forKey: .command) + rules = try container.decode([MatchedRule].self, forKey: .rules) + timestamp = try container.decode(Date.self, forKey: .timestamp) + provenance = try container.decodeIfPresent([String].self, forKey: .provenance) ?? [] } var topSeverity: Severity { rules.map(\.severity).max() ?? .info } diff --git a/Sources/Argus/ProcessMonitor.swift b/Sources/Argus/ProcessMonitor.swift index 9102299..2cfd534 100644 --- a/Sources/Argus/ProcessMonitor.swift +++ b/Sources/Argus/ProcessMonitor.swift @@ -46,24 +46,41 @@ struct ParentContextCache { func ppid(for pid: Int32) -> Int32? { entries[pid]?.ppid } /// Walks ppid links from `pid` up through the cache to build its ancestor - /// chain, nearest ancestor first, excluding `pid` itself. Stops at - /// `pid <= 1` (launchd/kernel — the root of every process tree, so it - /// carries no chain-correlation signal), at a pid the cache has no entry - /// for (parent already aged out or was never sampled), at a cycle (should - /// never happen on a real process table, but a corrupted/adversarial - /// sample must not spin forever), or at `maxDepth` hops. - func ancestry(of pid: Int32, maxDepth: Int = 20) -> [Int32] { - var chain: [Int32] = [] + /// records (pid plus that pid's cached image/command), nearest ancestor + /// first, excluding `pid` itself. Stops at `pid <= 1` (launchd/kernel — + /// the root of every process tree, so it carries no chain-correlation or + /// provenance signal), at a pid the cache has no *ppid link* for (parent + /// already aged out or was never sampled), at a cycle (should never + /// happen on a real process table, but a corrupted/adversarial sample + /// must not spin forever), or at `maxDepth` hops. + /// + /// A pid can appear in the chain (discovered as another pid's ppid) even + /// when the cache never itself sampled that pid — e.g. a parent that + /// exited and aged out past `retentionTicks` before this walk ran. Such a + /// pid still terminates the walk one hop later (its own ppid link is + /// unknown) but is reported here with empty `image`/`command` rather than + /// silently dropped, so callers keying off ancestor pids (chain + /// correlation) and callers keying off ancestor identity (provenance + /// attribution) both see the exact same chain length. + func ancestorRecords(of pid: Int32, maxDepth: Int = 20) -> [(pid: Int32, image: String, command: String)] { + var chain: [(pid: Int32, image: String, command: String)] = [] var seen: Set = [pid] var current = pid while chain.count < maxDepth { guard let parent = ppid(for: current), parent > 1, !seen.contains(parent) else { break } - chain.append(parent) + let entry = entries[parent] + chain.append((pid: parent, image: entry?.image ?? "", command: entry?.command ?? "")) seen.insert(parent) current = parent } return chain } + + /// The pid-only projection of `ancestorRecords(of:maxDepth:)` — see that + /// method for the walk/termination semantics, which this shares exactly. + func ancestry(of pid: Int32, maxDepth: Int = 20) -> [Int32] { + ancestorRecords(of: pid, maxDepth: maxDepth).map(\.pid) + } } /// Why a `ps` sample didn't yield a usable process list. @@ -252,10 +269,21 @@ final class ProcessMonitor: ObservableObject { for proc in newProcs { totalSeen += 1 + // Computed once per process and reused below both for the Sigma + // record's Ancestor* fields and for the chain correlator's + // ancestry — walking the cache twice would be redundant work for + // identical results. + let ancestors = parentCache.ancestorRecords(of: proc.id) + var record: [String: String] = ["CommandLine": proc.command, "Image": proc.image, "User": proc.user] if let parentImage = parentCache.image(for: proc.ppid) { record["ParentImage"] = parentImage } if let parentCommand = parentCache.command(for: proc.ppid) { record["ParentCommandLine"] = parentCommand } if let parentUser = parentCache.user(for: proc.ppid) { record["ParentUser"] = parentUser } + // ";"-joined, nearest ancestor first, so a user rule can express + // e.g. `AncestorCommandLines|contains: claude` to match anywhere + // up the tree rather than only the immediate parent. + record["AncestorImages"] = ancestors.map(\.image).joined(separator: ";") + record["AncestorCommandLines"] = ancestors.map(\.command).joined(separator: ";") let rawMatches: [MatchedRule] = activeRules.compactMap { rule in guard SigmaMatcher.matches(rule, record: record) else { return nil } @@ -277,8 +305,13 @@ final class ProcessMonitor: ObservableObject { bornAt: Date(), angle: angle)) } else { matchedThisTick += 1 + let provenance = ProvenanceClassifier.classify( + ancestorImages: ancestors.map(\.image), + ancestorCommandLines: ancestors.map(\.command) + ).map(\.label) let event = ProcessEvent(pid: proc.id, ppid: proc.ppid, executable: proc.executable, - command: proc.command, rules: matches, timestamp: Date()) + command: proc.command, rules: matches, timestamp: Date(), + provenance: provenance) events.insert(event, at: 0) if events.count > 300 { events.removeLast(events.count - 300) } eventStore?.append(event) @@ -293,11 +326,10 @@ final class ProcessMonitor: ObservableObject { let techniques = matches.map(\.technique).joined(separator: "; ") DiagnosticsLog.write("[\(event.topSeverity.label)] pid=\(proc.id) \(proc.executable) — \(techniques) — risk=\(Int(riskScore))") - let ancestry = parentCache.ancestry(of: proc.id) if let detection = chainCorrelator.register( eventID: event.id, pid: proc.id, executable: proc.executable, ruleNames: Set(matches.map(\.name)), techniques: Set(matches.map(\.technique)), - severity: event.topSeverity, timestamp: event.timestamp, ancestry: ancestry + severity: event.topSeverity, timestamp: event.timestamp, ancestry: ancestors.map(\.pid) ) { ingestExternal(Self.chainEvent(from: detection, pid: proc.id, ppid: proc.ppid)) } diff --git a/Sources/Argus/ProvenanceClassifier.swift b/Sources/Argus/ProvenanceClassifier.swift new file mode 100644 index 0000000..14fa57d --- /dev/null +++ b/Sources/Argus/ProvenanceClassifier.swift @@ -0,0 +1,104 @@ +import Foundation + +/// One supervisor label attached to an event — "this ancestry looks like it +/// was launched under X". +/// +/// This is attribution for triage, **not authorization**. Process ancestry +/// (argv0, image path, parent command lines) is trivially spoofable — any +/// process can name itself "claude", launch from a path containing +/// "/.claude/", or otherwise imitate a supervisor's fingerprint. A +/// `ProvenanceTag` must never be treated as a trust signal, and must never be +/// used to suppress, downgrade, or auto-allow a detection. Its only purpose +/// is to help a human triaging the feed answer "who would plausibly have +/// launched this?" faster than reading raw parent command lines. +struct ProvenanceTag: Equatable { + let category: String + let label: String +} + +/// Classifies a process's ancestry against a fixed table of known +/// supervisors — AI coding agents, container tooling, package managers, +/// terminals, IDEs — so alerts triggered by supervised automation are +/// distinguishable in the feed from standalone activity. +/// +/// Pure and data-driven: every supervisor is a table row (category, display +/// label, match predicate), checked case-insensitively against each +/// ancestor's image and command line. No process/filesystem access, no +/// state — trivially unit-testable and safe to call on every matched event. +enum ProvenanceClassifier { + private struct Supervisor { + let category: String + let label: String + /// Receives one ancestor's image and command line, both already + /// lowercased. Returns whether this ancestor identifies the + /// supervisor. + let matches: (_ image: String, _ command: String) -> Bool + } + + /// `NSString.lastPathComponent` needs Foundation but not AppKit, and + /// matches the basename convention `RawProcess.image`/`ParentImage` + /// already use elsewhere in Argus. + private static func basename(_ image: String) -> String { + (image as NSString).lastPathComponent + } + + private static let supervisors: [Supervisor] = [ + Supervisor(category: "AI agent", label: "claude") { image, command in + command.contains("/.claude/") || basename(image) == "claude" + }, + Supervisor(category: "Container tooling", label: "docker") { image, command in + basename(image) == "docker" || image.contains("com.docker") + }, + Supervisor(category: "Package manager", label: "brew") { image, command in + command.contains("/homebrew/") || basename(image) == "brew" + }, + Supervisor(category: "Terminal", label: "Terminal") { image, _ in + basename(image) == "terminal" + }, + Supervisor(category: "Terminal", label: "iTerm2") { image, _ in + basename(image).contains("iterm") + }, + Supervisor(category: "Terminal", label: "tmux") { image, _ in + basename(image) == "tmux" + }, + Supervisor(category: "IDE", label: "Code") { image, _ in + // VS Code's helper processes on macOS report images like + // "Code Helper (Renderer)"/"Code Helper (Plugin)", not a bare + // "code" — "contains" catches every helper variant plus the + // main "Code" process itself. + basename(image).contains("code helper") || basename(image) == "code" + }, + Supervisor(category: "IDE", label: "Cursor") { image, _ in + basename(image).contains("cursor") + }, + ] + + /// Classifies an ancestry into supervisor tags. + /// + /// - Parameters: + /// - ancestorImages: ancestor images, nearest ancestor first (same + /// ordering as `ParentContextCache.ancestorRecords(of:)`). + /// - ancestorCommandLines: ancestor command lines, same ordering and + /// same length as `ancestorImages`. + /// - Returns: matched tags, nearest-supervisor-first, deduplicated by + /// label — a supervisor that shows up at multiple ancestry depths (e.g. + /// nested `claude` processes) is only reported once, at its nearest + /// occurrence. + static func classify(ancestorImages: [String], ancestorCommandLines: [String]) -> [ProvenanceTag] { + var tags: [ProvenanceTag] = [] + var seenLabels: Set = [] + let depth = min(ancestorImages.count, ancestorCommandLines.count) + + for i in 0.. ProcessEvent { + private func event(pid: Int32, ppid: Int32, executable: String, severity: Severity, technique: String, command: String? = nil, provenance: [String] = []) -> ProcessEvent { ProcessEvent( pid: pid, ppid: ppid, executable: executable, command: command ?? "\(executable) some args", rules: [MatchedRule(name: "rule-\(technique)", severity: severity, technique: technique, explanation: "e")], - timestamp: Date() + timestamp: Date(), + provenance: provenance ) } @@ -48,6 +49,17 @@ final class EventFilterTests: XCTestCase { XCTAssertEqual(Set(filtered.map(\.pid)), [1, 2]) } + func testSearchMatchesProvenanceLabel() { + let events = [ + event(pid: 1, ppid: 10, executable: "python3", severity: .critical, technique: "T1", provenance: ["claude"]), + event(pid: 2, ppid: 10, executable: "curl", severity: .critical, technique: "T2", provenance: ["docker"]), + event(pid: 3, ppid: 10, executable: "nc", severity: .critical, technique: "T3"), + ] + XCTAssertEqual(EventFilter.apply(events, searchText: "claude", severities: Set(Severity.allCases), sessionPPID: nil).map(\.pid), [1]) + XCTAssertEqual(EventFilter.apply(events, searchText: "CLAU", severities: Set(Severity.allCases), sessionPPID: nil).map(\.pid), [1], "search should be case-insensitive") + XCTAssertEqual(EventFilter.apply(events, searchText: "docker", severities: Set(Severity.allCases), sessionPPID: nil).map(\.pid), [2]) + } + func testFiltersCombine() { let events = [ event(pid: 1, ppid: 100, executable: "curl", severity: .watch, technique: "T1"), diff --git a/Tests/ArgusTests/ModelsTests.swift b/Tests/ArgusTests/ModelsTests.swift new file mode 100644 index 0000000..38b1ba2 --- /dev/null +++ b/Tests/ArgusTests/ModelsTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import Argus + +final class ProcessEventCodableTests: XCTestCase { + /// A hand-written JSON line shaped exactly like something written by a + /// build of Argus before `provenance` existed — no `provenance` key at + /// all. Real events.jsonl history on disk looks like this; decoding it + /// must not fail or the user's persisted history silently disappears. + private let legacyJSON = """ + { + "id": "9B1DE1B1-6E23-4B47-9C1B-1F6A0B9E2B10", + "pid": 412, + "ppid": 99, + "executable": "osascript", + "command": "osascript -e 'do shell script \\"id\\" with administrator privileges'", + "rules": [ + { + "id": "3C1D2E3F-4A5B-6C7D-8E9F-0A1B2C3D4E5F", + "name": "AppleScript privilege escalation", + "severity": 3, + "technique": "T1548 \\u2013 Elevated Execution", + "explanation": "e" + } + ], + "timestamp": 700000000.0 + } + """ + + func testDecodesLegacyEventWithoutProvenanceKey() throws { + let data = try XCTUnwrap(legacyJSON.data(using: .utf8)) + let event = try JSONDecoder().decode(ProcessEvent.self, from: data) + XCTAssertEqual(event.pid, 412) + XCTAssertEqual(event.executable, "osascript") + XCTAssertEqual(event.rules.first?.name, "AppleScript privilege escalation") + XCTAssertEqual(event.provenance, [], "missing key must default to empty, not fail decoding") + } + + func testRoundTripsProvenanceThroughEncodeAndDecode() throws { + let event = ProcessEvent(pid: 1, ppid: 2, executable: "claude", command: "claude", rules: [], timestamp: Date(), provenance: ["claude", "tmux"]) + let data = try JSONEncoder().encode(event) + let decoded = try JSONDecoder().decode(ProcessEvent.self, from: data) + XCTAssertEqual(decoded.provenance, ["claude", "tmux"]) + } + + func testProvenanceDefaultsToEmptyWhenOmittedFromInitializer() { + let event = ProcessEvent(pid: 1, ppid: 2, executable: "chain", command: "a -> b", rules: [], timestamp: Date()) + XCTAssertEqual(event.provenance, [], "synthetic events (chain/persistence/tamper) keep an empty provenance") + } +} diff --git a/Tests/ArgusTests/ProcessMonitorTests.swift b/Tests/ArgusTests/ProcessMonitorTests.swift index 9b1783b..f28723d 100644 --- a/Tests/ArgusTests/ProcessMonitorTests.swift +++ b/Tests/ArgusTests/ProcessMonitorTests.swift @@ -181,6 +181,62 @@ final class ParentContextCacheTests: XCTestCase { cache.update(with: sample, tick: 0) XCTAssertEqual(cache.ancestry(of: 30, maxDepth: 5), [29, 28, 27, 26, 25]) } + + // MARK: - ancestorRecords + + func testAncestorRecordsWalksThroughKnownAncestorsWithImageAndCommand() { + var cache = ParentContextCache(retentionTicks: 3) + cache.update(with: [ + raw(10, ppid: 1, image: "/sbin/launchd"), + raw(20, ppid: 10, image: "/bin/bash"), + raw(30, ppid: 20, image: "/usr/bin/curl"), + ], tick: 0) + let records = cache.ancestorRecords(of: 30) + XCTAssertEqual(records.map(\.pid), [20, 10]) + XCTAssertEqual(records.map(\.image), ["/bin/bash", "/sbin/launchd"]) + XCTAssertEqual(records.map(\.command), ["/bin/bash", "/sbin/launchd"]) + } + + func testAncestorRecordsAndAncestryShareIdenticalPidOrdering() { + // ancestry(of:) is now a pid-only projection of ancestorRecords — + // they must always agree on which pids are walked and in what order. + var cache = ParentContextCache(retentionTicks: 3) + cache.update(with: [ + raw(40, ppid: 99, image: "/bin/bash"), // 99 has no entry of its own + ], tick: 0) + XCTAssertEqual(cache.ancestorRecords(of: 40).map(\.pid), cache.ancestry(of: 40)) + XCTAssertEqual(cache.ancestry(of: 40), [99]) + } + + func testAncestorRecordsReportsEmptyImageAndCommandForUncachedPid() { + // pid 99 is known only as pid 40's ppid link — it was never itself + // sampled/cached, so its record carries the pid but empty + // image/command rather than being dropped from the chain. + var cache = ParentContextCache(retentionTicks: 3) + cache.update(with: [raw(40, ppid: 99, image: "/bin/bash")], tick: 0) + let records = cache.ancestorRecords(of: 40) + XCTAssertEqual(records.map(\.pid), [99]) + XCTAssertEqual(records.first?.image, "") + XCTAssertEqual(records.first?.command, "") + } + + func testAncestorRecordsOfUnknownPidIsEmpty() { + var cache = ParentContextCache(retentionTicks: 3) + cache.update(with: [raw(10, ppid: 1, image: "/bin/bash")], tick: 0) + XCTAssertEqual(cache.ancestorRecords(of: 999).count, 0) + } + + func testAncestorRecordsRespectsMaxDepth() { + var cache = ParentContextCache(retentionTicks: 3) + var sample: [RawProcess] = [] + for pid in Int32(2)...30 { + sample.append(raw(pid, ppid: pid - 1, image: "/bin/p\(pid)")) + } + cache.update(with: sample, tick: 0) + let records = cache.ancestorRecords(of: 30, maxDepth: 5) + XCTAssertEqual(records.map(\.pid), [29, 28, 27, 26, 25]) + XCTAssertEqual(records.map(\.image), ["/bin/p29", "/bin/p28", "/bin/p27", "/bin/p26", "/bin/p25"]) + } } final class SamplingHealthTrackerTests: XCTestCase { diff --git a/Tests/ArgusTests/ProvenanceClassifierTests.swift b/Tests/ArgusTests/ProvenanceClassifierTests.swift new file mode 100644 index 0000000..9131b72 --- /dev/null +++ b/Tests/ArgusTests/ProvenanceClassifierTests.swift @@ -0,0 +1,114 @@ +import XCTest +@testable import Argus + +final class ProvenanceClassifierTests: XCTestCase { + func testUnrelatedAncestryYieldsNoTags() { + let tags = ProvenanceClassifier.classify( + ancestorImages: ["/bin/bash", "/sbin/launchd"], + ancestorCommandLines: ["/bin/bash -l", "/sbin/launchd"] + ) + XCTAssertEqual(tags, []) + } + + func testEmptyAncestryYieldsNoTags() { + XCTAssertEqual(ProvenanceClassifier.classify(ancestorImages: [], ancestorCommandLines: []), []) + } + + func testClaudeMatchesByCommandLinePath() { + let tags = ProvenanceClassifier.classify( + ancestorImages: ["/usr/bin/node"], + ancestorCommandLines: ["node /Users/mark/.claude/local/claude.js"] + ) + XCTAssertEqual(tags.map(\.label), ["claude"]) + XCTAssertEqual(tags.first?.category, "AI agent") + } + + func testClaudeMatchesByImageBasename() { + let tags = ProvenanceClassifier.classify( + ancestorImages: ["/usr/local/bin/claude"], + ancestorCommandLines: ["claude --resume"] + ) + XCTAssertEqual(tags.map(\.label), ["claude"]) + } + + func testDockerMatchesCLIImage() { + let tags = ProvenanceClassifier.classify( + ancestorImages: ["/usr/local/bin/docker"], + ancestorCommandLines: ["docker run -it alpine sh"] + ) + XCTAssertEqual(tags.map(\.label), ["docker"]) + XCTAssertEqual(tags.first?.category, "Container tooling") + } + + func testDockerMatchesComDockerImage() { + let tags = ProvenanceClassifier.classify( + ancestorImages: ["/Applications/Docker.app/Contents/MacOS/com.docker.backend"], + ancestorCommandLines: ["com.docker.backend"] + ) + XCTAssertEqual(tags.map(\.label), ["docker"]) + } + + func testBrewMatchesByCommandLinePath() { + let tags = ProvenanceClassifier.classify( + ancestorImages: ["/usr/bin/ruby"], + ancestorCommandLines: ["/opt/Homebrew/bin/brew install curl"] + ) + XCTAssertEqual(tags.map(\.label), ["brew"]) + XCTAssertEqual(tags.first?.category, "Package manager") + } + + func testBrewMatchesByImageBasename() { + let tags = ProvenanceClassifier.classify( + ancestorImages: ["/opt/homebrew/bin/brew"], + ancestorCommandLines: ["brew upgrade"] + ) + XCTAssertEqual(tags.map(\.label), ["brew"]) + } + + func testTerminalSupervisorsMatch() { + XCTAssertEqual( + ProvenanceClassifier.classify(ancestorImages: ["/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal"], ancestorCommandLines: ["Terminal"]).map(\.label), + ["Terminal"] + ) + XCTAssertEqual( + ProvenanceClassifier.classify(ancestorImages: ["/Applications/iTerm.app/Contents/MacOS/iTerm2"], ancestorCommandLines: ["iTerm2"]).map(\.label), + ["iTerm2"] + ) + XCTAssertEqual( + ProvenanceClassifier.classify(ancestorImages: ["/opt/homebrew/bin/tmux"], ancestorCommandLines: ["tmux new-session"]).map(\.label), + ["tmux"] + ) + } + + func testIDESupervisorsMatch() { + XCTAssertEqual( + ProvenanceClassifier.classify( + ancestorImages: ["/Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper (Plugin).app/Contents/MacOS/Code Helper (Plugin)"], + ancestorCommandLines: ["Code Helper (Plugin)"] + ).map(\.label), + ["Code"] + ) + XCTAssertEqual( + ProvenanceClassifier.classify(ancestorImages: ["/Applications/Cursor.app/Contents/MacOS/Cursor"], ancestorCommandLines: ["Cursor"]).map(\.label), + ["Cursor"] + ) + } + + func testDeduplicatesRepeatedSupervisorByLabel() { + let tags = ProvenanceClassifier.classify( + ancestorImages: ["/usr/local/bin/claude", "/usr/local/bin/claude"], + ancestorCommandLines: ["claude --resume", "claude --resume"] + ) + XCTAssertEqual(tags.map(\.label), ["claude"], "repeated ancestry match must not duplicate the tag") + } + + func testNearestSupervisorReportedFirst() { + // Nearest ancestor (index 0) is tmux; further up is claude. Tag + // order must follow ancestry order, nearest first — not table order. + let tags = ProvenanceClassifier.classify( + ancestorImages: ["/opt/homebrew/bin/tmux", "/usr/local/bin/claude"], + ancestorCommandLines: ["tmux new-session", "claude --resume"] + ) + XCTAssertEqual(tags.map(\.label), ["tmux", "claude"]) + } +} From 9e55f041cfde51e5e676c90622ec50b7786a710f Mon Sep 17 00:00:00 2001 From: Mark Watts Date: Fri, 21 Aug 2026 14:52:45 +0100 Subject: [PATCH 16/23] Supersede the imported xattr Gatekeeper-bypass rule with a precise one The bundled SigmaHQ rule (imported/proc_creation_macos_xattr_gatekeeper_bypass.yml, id f5141b6d-9f42-41c6-a7bf-2a780678b29b) requires only a bare '-d' substring alongside 'com.apple.quarantine', which can false-positive on Homebrew's safe "xattr -w com.apple.quarantine ..." (add-quarantine) direction when a '-d' lands inside the quarantine value's UUID by chance. Since imported rules stay verbatim, add an Argus-authored replacement that requires '-d'/'-c' as a whitespace-bounded flag, correctly telling removal apart from addition, and have RuleStore auto-disable the superseded rule by default via a new supersededBundledRuleIDs map. The disabled-state file migrates from a bare Set to {disabled, supersessionsApplied} so a user who deliberately re-enables the superseded rule isn't overridden again on the next launch. Co-Authored-By: Claude Fable 5 --- ...reation_macos_xattr_quarantine_removal.yml | 32 ++++++ Sources/Argus/Sigma/RuleStore.swift | 75 +++++++++++++- Tests/ArgusTests/BundledRulesTests.swift | 4 +- Tests/ArgusTests/RuleStoreTests.swift | 99 +++++++++++++++++++ 4 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 Resources/Rules/custom/proc_creation_macos_xattr_quarantine_removal.yml diff --git a/Resources/Rules/custom/proc_creation_macos_xattr_quarantine_removal.yml b/Resources/Rules/custom/proc_creation_macos_xattr_quarantine_removal.yml new file mode 100644 index 0000000..de30019 --- /dev/null +++ b/Resources/Rules/custom/proc_creation_macos_xattr_quarantine_removal.yml @@ -0,0 +1,32 @@ +title: Gatekeeper Bypass via Quarantine Attribute Removal +id: 8cbd6022-931f-4017-ab5e-a15264ac91e7 +status: stable +description: Detects xattr being used to strip the com.apple.quarantine extended attribute (-d/-r -d) or to clear all extended attributes outright (-c) — the actual Gatekeeper-bypass direction. This supersedes the bundled SigmaHQ rule (imported/proc_creation_macos_xattr_gatekeeper_bypass.yml, id f5141b6d-9f42-41c6-a7bf-2a780678b29b), whose CommandLine|contains|all of '-d' and 'com.apple.quarantine' can false-positive on the safe write direction — Homebrew and other installers legitimately run "xattr -w com.apple.quarantine ..." to *add* the flag, and a "-d" substring can appear incidentally inside that command's quarantine-value UUID. This rule requires "-d"/"-c" as a whitespace-bounded flag rather than a bare substring, so it tells removal apart from addition. +author: Argus +date: 2026-08-21 +tags: + - attack.defense-evasion + - attack.t1553.001 +logsource: + category: process_creation + product: macos +detection: + selection_base: + Image|endswith: '/xattr' + selection_quarantine: + CommandLine|contains: 'com.apple.quarantine' + selection_delete_flag: + CommandLine|re: '(^|\s)-d(\s|$)' + selection_clear_flag: + CommandLine|re: '(^|\s)-c(\s|$)' + condition: selection_base and ((selection_quarantine and selection_delete_flag) or selection_clear_flag) +falsepositives: + - Unknown +level: high +x-example-match: + - xattr -d com.apple.quarantine /Applications/Foo.app + - xattr -r -d com.apple.quarantine /tmp/payload + - xattr -c /tmp/payload +x-example-safe: + - xattr -w com.apple.quarantine "0081;5f8e3d21;Homebrew Cask;" /Applications/Foo.app + - xattr -l /Applications/Foo.app diff --git a/Sources/Argus/Sigma/RuleStore.swift b/Sources/Argus/Sigma/RuleStore.swift index faf7b61..a1c1602 100644 --- a/Sources/Argus/Sigma/RuleStore.swift +++ b/Sources/Argus/Sigma/RuleStore.swift @@ -1,6 +1,20 @@ import AppKit import Foundation +/// On-disk shape of `rules-state.json`. `disabled` mirrors +/// `RuleStore.disabledRuleIDs`; `supersessionsApplied` records which +/// entries of `RuleStore.supersededBundledRuleIDs` have already been +/// auto-applied, so a user who deliberately re-enables a superseded rule +/// via the Touch ID-gated `requestToggle` has that stick across restarts +/// instead of the rule being silently disabled again on the next launch. +/// Older installs wrote this file as a bare `Set` of disabled IDs — +/// `RuleStore.loadDisabledState` falls back to that shape when this one +/// fails to decode, treating `supersessionsApplied` as empty. +private struct RuleDisabledState: Codable { + var disabled: [String] + var supersessionsApplied: [String] +} + /// Loads and manages the Sigma rule catalog: bundled rules shipped with the /// app (imported from SigmaHQ, plus Argus's own gap-filling rules) and /// user rules dropped into `~/Library/Application Support/Argus/rules/` — @@ -9,8 +23,26 @@ import Foundation /// and hit reload. @MainActor final class RuleStore: ObservableObject { + /// Bundled imported rules superseded by a more precise Argus-authored + /// replacement — keyed by the superseded imported rule's `id`, valued + /// by the replacement's `id`. Applied once per entry (see + /// `supersessionsApplied` in `RuleDisabledState`) so re-enabling the + /// superseded rule by hand isn't undone on the next launch. + static let supersededBundledRuleIDs: [String: String] = [ + // SigmaHQ "Gatekeeper Bypass via Xattr": CommandLine|contains|all + // of '-d' and 'com.apple.quarantine' can false-positive on the safe + // xattr -w (add-quarantine) direction Homebrew uses, when a '-d' + // substring lands inside the quarantine value's UUID by chance. + // Superseded by the whitespace-bounded-flag Argus rule, which tells + // removal (-d/-c) apart from addition (-w). + "f5141b6d-9f42-41c6-a7bf-2a780678b29b": "8cbd6022-931f-4017-ab5e-a15264ac91e7", + ] + @Published private(set) var rules: [SigmaRule] = [] @Published private(set) var disabledRuleIDs: Set = [] + /// Which entries of `supersededBundledRuleIDs` have already been + /// auto-disabled at some past launch — see `RuleDisabledState`. + private var supersessionsApplied: Set = [] /// Rules dropped at load time because their `logsource` isn't something /// this app can actually evaluate (see `isCompatibleLogsource`) — e.g. a /// Windows-only rule dropped into the user rules folder by mistake. @@ -46,6 +78,7 @@ final class RuleStore: ObservableObject { loadDisabledState() reload() + applySupersessions() } /// Verifies `rules-state.json` off the main thread and stores the @@ -161,14 +194,48 @@ final class RuleStore: ObservableObject { return categoryOK && productOK } + /// For each not-yet-applied entry in `supersededBundledRuleIDs` whose + /// superseded rule actually loaded (guards against disabling — and + /// writing state for — an id that isn't even part of this rule set, e.g. + /// a minimal test fixture or a build missing the imported directory), + /// disables the superseded rule and records the entry as applied. Only + /// once per entry, ever: a user who deliberately re-enables the + /// superseded rule afterward (Touch ID-gated `requestToggle`) keeps it + /// enabled across restarts, since `supersessionsApplied` already has + /// the entry and this loop skips it. Called after `reload()` so `rules` + /// is populated. + private func applySupersessions() { + let loadedRuleIDs = Set(rules.map(\.id)) + var changed = false + for (supersededID, replacementID) in Self.supersededBundledRuleIDs { + guard loadedRuleIDs.contains(supersededID) else { continue } + guard !supersessionsApplied.contains(supersededID) else { continue } + disabledRuleIDs.insert(supersededID) + supersessionsApplied.insert(supersededID) + changed = true + DiagnosticsLog.write("rule auto-disabled (superseded by \(replacementID)): \(supersededID)") + } + if changed { + saveDisabledState() + } + } + private func loadDisabledState() { - guard let data = try? Data(contentsOf: stateFileURL), - let decoded = try? JSONDecoder().decode(Set.self, from: data) else { return } - disabledRuleIDs = decoded + guard let data = try? Data(contentsOf: stateFileURL) else { return } + if let decoded = try? JSONDecoder().decode(RuleDisabledState.self, from: data) { + disabledRuleIDs = Set(decoded.disabled) + supersessionsApplied = Set(decoded.supersessionsApplied) + } else if let legacy = try? JSONDecoder().decode(Set.self, from: data) { + // Pre-supersession state file: just the disabled IDs, no record + // of which supersessions had already run. + disabledRuleIDs = legacy + supersessionsApplied = [] + } } private func saveDisabledState() { - guard let data = try? JSONEncoder().encode(disabledRuleIDs) else { return } + let state = RuleDisabledState(disabled: Array(disabledRuleIDs), supersessionsApplied: Array(supersessionsApplied)) + guard let data = try? JSONEncoder().encode(state) else { return } try? data.write(to: stateFileURL, options: .atomic) integrityGuard?.recordAuthenticatedWrite(of: stateFileURL) } diff --git a/Tests/ArgusTests/BundledRulesTests.swift b/Tests/ArgusTests/BundledRulesTests.swift index 5526a90..3e2f5f8 100644 --- a/Tests/ArgusTests/BundledRulesTests.swift +++ b/Tests/ArgusTests/BundledRulesTests.swift @@ -34,10 +34,10 @@ final class BundledRulesTests: XCTestCase { } func testExpectedRuleCount() { - // 67 SigmaHQ macOS + 8 SigmaHQ portable-shell + 10 Argus custom = 85. + // 67 SigmaHQ macOS + 8 SigmaHQ portable-shell + 11 Argus custom = 86. // A specific number, not a >0 check, so silently losing a whole // directory of rules (bad bundle path, parse regression) fails loudly. - XCTAssertEqual(Self.allRules.count, 85, "rule count changed — update this if the change was deliberate") + XCTAssertEqual(Self.allRules.count, 86, "rule count changed — update this if the change was deliberate") } func testEveryRuleHasNonEmptyConditionAndDetection() { diff --git a/Tests/ArgusTests/RuleStoreTests.swift b/Tests/ArgusTests/RuleStoreTests.swift index d6c2850..7c46e5f 100644 --- a/Tests/ArgusTests/RuleStoreTests.swift +++ b/Tests/ArgusTests/RuleStoreTests.swift @@ -196,6 +196,98 @@ final class RuleStoreTests: XCTestCase { store.toggle(store.rules[0]) XCTAssertEqual(fixedKeyGuard.verify(state), .verified, "saveDisabledState should have recorded a MAC via the injected guard") } + // MARK: - Supersession + + private static let supersededImportedID = "f5141b6d-9f42-41c6-a7bf-2a780678b29b" + + /// Bundled/user directories, not yet turned into a `RuleStore`, whose + /// "imported" folder carries a stand-in for the real SigmaHQ xattr rule + /// (same `id` as the real one — content otherwise irrelevant) so + /// `RuleStore.applySupersessions()`'s loaded-rule-presence gate lets the + /// supersession fire, mirroring what the real bundle looks like. + private func makeDirsWithImportedGatekeeperRule() -> (bundled: URL, user: URL, state: URL) { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let bundled = root.appendingPathComponent("bundled") + let user = root.appendingPathComponent("user") + let state = root.appendingPathComponent("rules-state.json") + try? FileManager.default.createDirectory(at: bundled.appendingPathComponent("custom"), withIntermediateDirectories: true) + try? FileManager.default.createDirectory(at: bundled.appendingPathComponent("imported"), withIntermediateDirectories: true) + try? FileManager.default.createDirectory(at: bundled.appendingPathComponent("imported-portable"), withIntermediateDirectories: true) + + let importedRule = """ + title: Gatekeeper Bypass via Xattr + id: \(Self.supersededImportedID) + status: test + description: Detects macOS Gatekeeper bypass via xattr utility + author: Test + date: 2026-08-21 + tags: + - attack.defense-impairment + - attack.t1553.001 + logsource: + category: process_creation + product: macos + detection: + selection: + Image|endswith: '/xattr' + CommandLine|contains|all: + - '-d' + - 'com.apple.quarantine' + condition: selection + level: low + """ + try? importedRule.write(to: bundled.appendingPathComponent("imported/xattr.yml"), atomically: true, encoding: .utf8) + return (bundled, user, state) + } + + func testSupersessionDisablesImportedRuleOnFirstLoadAndRecordsApplied() { + let (bundled, user, state) = makeDirsWithImportedGatekeeperRule() + let store = RuleStore(bundledRulesDirectory: bundled, userRulesDirectory: user, stateFileURL: state) + + XCTAssertTrue(store.disabledRuleIDs.contains(Self.supersededImportedID), + "the superseded imported rule should be auto-disabled on first load") + + guard let data = try? Data(contentsOf: state), + let decoded = try? JSONDecoder().decode(RuleDisabledStateForTests.self, from: data) else { + XCTFail("expected a persisted rules-state.json after supersession ran") + return + } + XCTAssertTrue(decoded.disabled.contains(Self.supersededImportedID)) + XCTAssertTrue(decoded.supersessionsApplied.contains(Self.supersededImportedID), + "the supersession should be recorded as applied so it isn't redone if the user re-enables the rule") + } + + func testReenabledSupersededRuleStaysEnabledAcrossReload() { + let (bundled, user, state) = makeDirsWithImportedGatekeeperRule() + // Simulate a prior launch where supersession already ran, and the + // user then deliberately re-enabled the imported rule (removing it + // from `disabled` without clearing the `supersessionsApplied` marker). + let priorState = RuleDisabledStateForTests(disabled: [], supersessionsApplied: [Self.supersededImportedID]) + if let encoded = try? JSONEncoder().encode(priorState) { + try? encoded.write(to: state) + } + + let store = RuleStore(bundledRulesDirectory: bundled, userRulesDirectory: user, stateFileURL: state) + + XCTAssertFalse(store.disabledRuleIDs.contains(Self.supersededImportedID), + "a user-re-enabled superseded rule should not be disabled again on reload") + } + + func testLegacyBareSetStateFileStillDecodesAndSupersessionAppliesOnTop() { + let (bundled, user, state) = makeDirsWithImportedGatekeeperRule() + // Pre-supersession on-disk format: a bare JSON array of disabled IDs. + let legacyDisabledID = "some-other-rule-id" + if let encoded = try? JSONEncoder().encode([legacyDisabledID]) { + try? encoded.write(to: state) + } + + let store = RuleStore(bundledRulesDirectory: bundled, userRulesDirectory: user, stateFileURL: state) + + XCTAssertTrue(store.disabledRuleIDs.contains(legacyDisabledID), + "existing disabled ids from a legacy bare-Set state file should be preserved") + XCTAssertTrue(store.disabledRuleIDs.contains(Self.supersededImportedID), + "supersession should still apply on top of a migrated legacy state file") + } } /// Local fixed-key provider so this test doesn't depend on @@ -204,3 +296,10 @@ private struct FixedKeyProviderForTests: IntegrityKeyProvider { let data: Data? func key() -> Data? { data } } + +/// Mirrors `RuleStore`'s private on-disk state shape so these tests can +/// decode/encode `rules-state.json` without exposing that type. +private struct RuleDisabledStateForTests: Codable { + var disabled: [String] + var supersessionsApplied: [String] +} From 44ba7b047b875fd2b35918c1b69847a35f030b28 Mon Sep 17 00:00:00 2001 From: Mark Watts Date: Fri, 21 Aug 2026 14:58:08 +0100 Subject: [PATCH 17/23] Scope allowlist entries to a provenance label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allowlisting (rule, executable) globally blinds the rule everywhere that executable runs — a real problem for e.g. zsh under a Claude Code session. AllowlistEntry gains an optional requiredProvenance label so an entry can suppress alerts only when the matching event's provenance (from ProvenanceClassifier) contains that label, leaving the unscoped default behavior untouched. Provenance is now classified before allowlist filtering in ProcessMonitor so scoped entries can see it, the dashboard's event context menu offers a scoped "Allow only when under