Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
41fe563
Add CI workflow and sigma rules sync script
Aug 21, 2026
33bc6f8
Merge wt/tooling: CI workflow and SigmaHQ rule re-sync script
Aug 21, 2026
107dec0
Close Sigma-spec gaps: N-of quantifier, base64 modifiers, cased, keyw…
Aug 21, 2026
30f1b84
Merge wt/sigma-spec: N-of, base64/base64offset/cased modifiers, keywo…
Aug 21, 2026
948e8da
Add parent-context caching, ps sampling watchdog, and user field to P…
Aug 21, 2026
5095569
Fix swapped trailing-trim in base64offset encodings
Aug 21, 2026
9753838
Merge wt/procmon-robustness: parent-context cache, sampling watchdog,…
Aug 21, 2026
8028be3
Start monitoring at launch, add launch-at-login, surface degraded state
Aug 21, 2026
70866c8
Add PersistenceWatcher: a second sensor for LaunchAgents/Daemons/peri…
Aug 21, 2026
846c895
Add tamper-evidence for rule/allowlist state via IntegrityGuard
Aug 21, 2026
09d8d10
Make IntegrityGuard opt-in and key its sidecar by full path
Aug 21, 2026
2773c86
Add sequence/chain correlation across LOLBin process trees
Aug 21, 2026
946d5b1
Add evidence export paths and actionable notifications
Aug 21, 2026
1353fa7
Document merged detection robustness enhancements
Aug 21, 2026
5738fd4
Fix README layout tree and tighten three phrasings
Aug 21, 2026
c9dc905
Never block launch on the integrity Keychain prompt
Aug 21, 2026
b38fede
Prefer a stable signing identity over ad-hoc in the app build
Aug 21, 2026
c73b071
Add ancestry-based provenance attribution to detection events
Aug 21, 2026
bd92a1e
Merge wt/provenance: ancestry provenance attribution on detection events
Aug 21, 2026
9e55f04
Supersede the imported xattr Gatekeeper-bypass rule with a precise one
Aug 21, 2026
b8d5533
Merge wt/xattr-precision: quarantine-removal rule supersedes imported…
Aug 21, 2026
44ba7b0
Scope allowlist entries to a provenance label
Aug 21, 2026
198a694
Escalate agent-attributed sensitive activity, quiet routine agent alerts
Aug 21, 2026
0acd8dd
Document merged detection robustness enhancements: provenance attribu…
Aug 21, 2026
f316de1
Make provenance chips visually prominent in their own violet
Aug 21, 2026
d241d2d
Annotate exportHistory @MainActor for the CI toolchain
Aug 21, 2026
c3b9c5d
Annotate main-actor-touching View structs explicitly for older toolch…
Aug 21, 2026
ceee34a
Fix mangled AllowlistRow declaration from the annotation sweep
Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
210 changes: 167 additions & 43 deletions README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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
139 changes: 139 additions & 0 deletions Sources/Argus/AgentActivityPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import Foundation

/// Turns provenance attribution ("this ancestry looks like it was launched
/// under an AI coding agent") into a triage decision, without ever letting
/// that attribution suppress or downgrade a detection.
///
/// Design position: a supervised AI-agent session (e.g. Claude Code) is a
/// **distinct trust domain**, not benign background noise. Its command
/// stream can be steered by content the agent reads — a prompt injection —
/// so activity attributed to an agent deserves *more* scrutiny for the
/// technique categories a hijacked agent would plausibly reach for
/// (persistence, credential theft, defense evasion), even while routine
/// agent-attributed activity is allowed to be quieter in the UI. This file
/// only classifies; `ProcessMonitor` decides what to do with the result
/// (escalate a sensitive match, quiet a routine notification).
enum AgentActivityPolicy {
/// The `ProvenanceTag.category` `ProvenanceClassifier` assigns its "AI
/// coding agent" supervisor row (currently just `claude`). Named here
/// once, rather than repeating the string literal, so this file's
/// agent-attribution check can't silently drift from the category
/// `ProvenanceClassifier` actually produces. `ProvenanceClassifier.swift`
/// itself is not otherwise touched by this feature — if its supervisor
/// table ever renames or splits that category, this constant is the one
/// place to update.
static let agentProvenanceCategory = "AI agent"

/// MITRE ATT&CK technique-ID prefixes (the "TNNNN" part, sub-techniques
/// included via prefix match) this package treats as sensitive when
/// they show up in an agent-attributed process tree:
///
/// - Persistence — T1543 (Create/Modify System Process), T1547
/// (Boot/Logon Autostart Execution), T1053 (Scheduled Task/Job)
/// - Credential access — T1555 (Credentials from Password Stores),
/// T1552 (Unsecured Credentials), T1539 (Steal Web Session Cookie)
/// - Defense evasion / impairment — T1562 (Impair Defenses), T1553
/// (Subvert Trust Controls)
///
/// These are exactly the categories a hijacked (prompt-injected) agent
/// session would plausibly reach for to survive a restart, exfiltrate
/// secrets, or blind the tooling that would otherwise catch it. Every
/// other technique an agent trips is still logged and risk-scored like
/// any other match — it's just not escalated.
static let sensitiveTechniquePrefixes: Set<String> = [
"T1543", "T1547", "T1053",
"T1555", "T1552", "T1539",
"T1562", "T1553",
]

/// The outcome of weighing one event's provenance tags and matched
/// rules against the sensitive-technique table.
enum Assessment: Equatable {
/// No tag identifies an AI-agent supervisor in this event's
/// ancestry — agent policy doesn't apply.
case notAgentAttributed
/// Agent-attributed, but none of its matched rules touch a
/// sensitive technique — eligible for quieter notifications.
case routine
/// Agent-attributed *and* at least one matched rule falls in the
/// sensitive-technique table — must be escalated, never quieted.
/// `matchedTechniques` lists the sensitive IDs found, in first-seen
/// order, deduplicated.
case sensitive(matchedTechniques: [String])
}

/// True when any tag identifies the AI-agent supervisor category.
static func isAgentAttributed(_ tags: [ProvenanceTag]) -> Bool {
tags.contains { $0.category == agentProvenanceCategory }
}

/// Classifies an event's agent attribution and, if attributed, whether
/// any of its matched rules land in the sensitive-technique table.
///
/// `matchedRules` is scanned rather than a single technique string
/// because an event can carry several matched rules at once, and each
/// rule's `technique` field can itself be several comma-separated IDs
/// (`MatchedRule.technique`, built from `SigmaRule.techniqueLabel`, e.g.
/// "T1548, T1059.002") — both need splitting apart before the prefix
/// check runs.
static func assessment(tags: [ProvenanceTag], matchedRules: [MatchedRule]) -> Assessment {
guard isAgentAttributed(tags) else { return .notAgentAttributed }

var matchedTechniques: [String] = []
var seen: Set<String> = []
for rule in matchedRules {
for id in techniqueIDs(in: rule.technique) where isSensitive(id) {
if seen.insert(id).inserted {
matchedTechniques.append(id)
}
}
}
return matchedTechniques.isEmpty ? .routine : .sensitive(matchedTechniques: matchedTechniques)
}

/// One level above the highest severity in `severities`, capped at
/// `.critical` — "one level worse than this process tree would
/// otherwise have scored," never wrapping past the top of the scale.
static func escalatedSeverity(above severities: [Severity]) -> Severity {
let current = severities.max() ?? .info
let nextRaw = min(current.rawValue + 1, Severity.critical.rawValue)
return Severity(rawValue: nextRaw) ?? .critical
}

/// Builds the synthetic rule `ProcessMonitor` appends to an event's
/// matched rules when `assessment` comes back `.sensitive` — this is
/// what actually raises `topSeverity` (and therefore risk and
/// notification eligibility) rather than the sensitive assessment doing
/// so directly.
static func escalationRule(matchedTechniques: [String], otherSeverities: [Severity]) -> MatchedRule {
MatchedRule(
name: "AI agent session touched a sensitive technique",
severity: escalatedSeverity(above: otherSeverities),
technique: matchedTechniques.joined(separator: ", "),
explanation: "A supervised AI-agent process tree performed activity matching a "
+ "persistence, credential-access, or defense-evasion technique. A hijacked "
+ "(prompt-injected) agent session is exactly how this class of compromise "
+ "presents, so this is escalated above the underlying rule's own severity "
+ "regardless of how routine the rest of the session looks."
)
}

/// Splits a `MatchedRule.technique` string (e.g. "T1548, T1059.002")
/// into its individual IDs, trimming the separator whitespace.
private static func techniqueIDs(in technique: String) -> [String] {
technique.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
}

/// Whether `techniqueID` (e.g. "T1053" or "T1053.005") falls under one
/// of `sensitiveTechniquePrefixes`. Prefix match alone would let
/// "T15430" (not a real technique) falsely match "T1543" — real
/// technique IDs only ever continue past the four-digit prefix with a
/// "." sub-technique separator, so that's what's required here too.
private static func isSensitive(_ techniqueID: String) -> Bool {
sensitiveTechniquePrefixes.contains { prefix in
guard techniqueID.hasPrefix(prefix) else { return false }
let rest = techniqueID.dropFirst(prefix.count)
return rest.isEmpty || rest.hasPrefix(".")
}
}
}
101 changes: 85 additions & 16 deletions Sources/Argus/AllowlistStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,41 @@ struct AllowlistEntry: Identifiable, Codable, Equatable {
let ruleName: String
let executable: String
let createdAt: Date
/// When set, this entry only suppresses alerts for a process whose
/// `ProcessEvent.provenance` includes this label (case-insensitively) —
/// e.g. "claude", so allowlisting (rule, zsh) doesn't blind the rule for
/// every `zsh` on the system, only the ones running under that
/// supervisor. `nil` (the default) means unconditional, matching the
/// suppression semantics from before this field existed.
///
/// Matching is by provenance *label*, not a raw ancestry substring —
/// deliberately reusing `ProvenanceClassifier` as the single source of
/// truth for what an ancestry means, rather than giving the allowlist
/// its own, possibly divergent, notion of "under claude".
///
/// Declared `Optional` so the compiler-synthesized `Decodable` decodes it
/// with `decodeIfPresent`: `allowlist.json` entries written before this
/// field existed are missing the key entirely and still decode, as
/// unconditional entries.
let requiredProvenance: String?

init(id: UUID = UUID(), ruleName: String, executable: String, createdAt: Date = Date()) {
init(id: UUID = UUID(), ruleName: String, executable: String, createdAt: Date = Date(), requiredProvenance: String? = nil) {
self.id = id
self.ruleName = ruleName
self.executable = executable
self.createdAt = createdAt
self.requiredProvenance = requiredProvenance
}

/// Whether this entry suppresses an alert for the given rule/executable
/// observed with the given provenance labels — the one definition of
/// "does this entry apply", shared by `AllowlistStore.isAllowed`
/// (live suppression) and `AllowlistStore.allow` (redundancy checks on
/// write) so the two can never drift apart.
func matches(ruleName: String, executable: String, provenance: [String]) -> Bool {
guard self.ruleName == ruleName, self.executable == executable else { return false }
guard let requiredProvenance else { return true }
return provenance.contains { $0.caseInsensitiveCompare(requiredProvenance) == .orderedSame }
}
}

Expand All @@ -22,10 +51,21 @@ 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. `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 fileURL: URL
let fileURL: URL
/// 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) {
init(fileURL: URL? = nil, integrityGuard: IntegrityGuard? = nil) {
if let fileURL {
self.fileURL = fileURL
} else {
Expand All @@ -36,16 +76,43 @@ final class AllowlistStore: ObservableObject {
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: dir.path)
self.fileURL = dir.appendingPathComponent("allowlist.json")
}
self.integrityGuard = integrityGuard
load()
}

func isAllowed(ruleName: String, executable: String) -> Bool {
entries.contains { $0.ruleName == ruleName && $0.executable == executable }
/// 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, provenance: [String]) -> Bool {
entries.contains { $0.matches(ruleName: ruleName, executable: executable, provenance: provenance) }
}

/// True when an existing entry already covers a request to allowlist
/// (ruleName, executable) at `requiredProvenance`'s scope — either an
/// exact-scope duplicate, or a broader unconditional entry that already
/// suppresses everything a narrower scoped request would. Distinct from
/// `isAllowed`: that answers "would this suppress a live event right
/// now"; this answers "would adding this entry be redundant".
private func alreadyCovers(ruleName: String, executable: String, requiredProvenance: String?) -> Bool {
entries.contains { entry in
guard entry.ruleName == ruleName, entry.executable == executable else { return false }
return entry.requiredProvenance == nil || entry.requiredProvenance == requiredProvenance
}
}

func allow(ruleName: String, executable: String) {
guard !isAllowed(ruleName: ruleName, executable: executable) else { return }
entries.append(AllowlistEntry(ruleName: ruleName, executable: executable))
func allow(ruleName: String, executable: String, requiredProvenance: String? = nil) {
guard !alreadyCovers(ruleName: ruleName, executable: executable, requiredProvenance: requiredProvenance) else { return }
entries.append(AllowlistEntry(ruleName: ruleName, executable: executable, requiredProvenance: requiredProvenance))
save()
}

Expand All @@ -59,18 +126,19 @@ final class AllowlistStore: ObservableObject {
/// as disabling a rule outright, so it goes through the same Touch
/// ID/password gate as `RuleStore.requestToggle`, with every attempt
/// logged regardless of outcome.
func requestAllow(ruleName: String, executable: String, completion: @escaping (Bool) -> Void = { _ in }) {
guard !isAllowed(ruleName: ruleName, executable: executable) else {
func requestAllow(ruleName: String, executable: String, requiredProvenance: String? = nil, completion: @escaping (Bool) -> Void = { _ in }) {
guard !alreadyCovers(ruleName: ruleName, executable: executable, requiredProvenance: requiredProvenance) else {
completion(true)
return
}
RuleAuthenticator.authenticate(reason: "Authenticate to allowlist \u{201c}\(ruleName)\u{201d} alerts from \(executable).") { [weak self] granted in
let scopeSuffix = requiredProvenance.map { " (only under \($0))" } ?? ""
RuleAuthenticator.authenticate(reason: "Authenticate to allowlist \u{201c}\(ruleName)\u{201d} alerts from \(executable)\(scopeSuffix).") { [weak self] granted in
guard let self else { return }
if granted {
self.allow(ruleName: ruleName, executable: executable)
DiagnosticsLog.write("allowlist added: \(ruleName) / \(executable)")
self.allow(ruleName: ruleName, executable: executable, requiredProvenance: requiredProvenance)
DiagnosticsLog.write("allowlist added: \(ruleName) / \(executable)\(scopeSuffix)")
} else {
DiagnosticsLog.write("allowlist add denied (authentication failed): \(ruleName) / \(executable)")
DiagnosticsLog.write("allowlist add denied (authentication failed): \(ruleName) / \(executable)\(scopeSuffix)")
}
completion(granted)
}
Expand Down Expand Up @@ -102,13 +170,14 @@ 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)
}
}

/// Pure filtering logic, kept free of the actor-isolated store so it's
/// trivially unit-testable without spinning up MainActor/async plumbing.
enum AllowlistFilter {
static func apply(_ matches: [MatchedRule], executable: String, isAllowed: (String, String) -> Bool) -> [MatchedRule] {
matches.filter { !isAllowed($0.name, executable) }
static func apply(_ matches: [MatchedRule], executable: String, provenance: [String], isAllowed: (String, String, [String]) -> Bool) -> [MatchedRule] {
matches.filter { !isAllowed($0.name, executable, provenance) }
}
}
Loading
Loading