Skip to content

Commit bd36f03

Browse files
senamakelmedullabot
andcommitted
test(audit): verify loaded native journal and ordinary account isolation
Co-authored-by: Medulla <medulla@tinyhumans.ai>
1 parent 94b0d31 commit bd36f03

3 files changed

Lines changed: 274 additions & 6 deletions

File tree

‎crates/tinysecurity-audit/src/sink_windows_negative_tests.rs‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,67 @@ fn windows_reparse_directory_is_denied_without_outside_mutation()
219219
std::fs::remove_dir(link)?;
220220
Ok(())
221221
}
222+
223+
#[test]
224+
fn windows_second_ordinary_account_cannot_read_or_replace_the_namespace()
225+
-> std::result::Result<(), Box<dyn std::error::Error>> {
226+
let (root, path) = fixture()?;
227+
let sink = JsonlSink::open(&path, 700, 3)?;
228+
sink.append(&batch(0))?;
229+
let journal = root.path().join("audit.journal");
230+
let before = std::fs::read(&journal)?;
231+
// The privileged test installer creates a temporary ordinary local account.
232+
// Its generated password stays inside PowerShell memory, never in arguments,
233+
// environment, stdout, diagnostics or a persisted test file.
234+
let script = r"
235+
$ErrorActionPreference = 'Stop'
236+
$account = 'tsa' + [Guid]::NewGuid().ToString('N').Substring(0, 16)
237+
$password = ConvertTo-SecureString ('Tsa!A1' + [Guid]::NewGuid().ToString('N')) -AsPlainText -Force
238+
$created = $false
239+
try {
240+
New-LocalUser -Name $account -Password $password | Out-Null
241+
$created = $true
242+
Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $account
243+
$credential = [PSCredential]::new(($env:COMPUTERNAME + '\' + $account), $password)
244+
$childScript = @'
245+
$ErrorActionPreference = 'Stop'
246+
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
247+
if ($identity.User.Value -eq $env:TINYSECURITY_TEST_OWNER) { exit 81 }
248+
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
249+
if ($principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { exit 82 }
250+
foreach ($name in @('audit', 'audit.1', 'audit.2', 'audit.3', 'audit.lock', 'audit.journal')) {
251+
try { [void][IO.File]::ReadAllBytes((Join-Path $env:TINYSECURITY_TEST_DIRECTORY $name)); exit 83 }
252+
catch [UnauthorizedAccessException] { }
253+
}
254+
try { [IO.Directory]::Move($env:TINYSECURITY_TEST_DIRECTORY, ($env:TINYSECURITY_TEST_DIRECTORY + '.outsider')); exit 84 }
255+
catch [UnauthorizedAccessException] { }
256+
exit 0
257+
'@
258+
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($childScript))
259+
$process = Start-Process -FilePath (Join-Path $PSHOME 'powershell.exe') -ArgumentList @('-NoProfile', '-NonInteractive', '-EncodedCommand', $encoded) -Credential $credential -PassThru -WindowStyle Hidden
260+
if (-not $process.WaitForExit(30000)) { $process.Kill(); throw 'ordinary account helper timeout' }
261+
$code = $process.ExitCode
262+
$process.Dispose()
263+
if ($code -ne 0) { throw 'ordinary account isolation failed' }
264+
} finally {
265+
if ($created) { Remove-LocalUser -Name $account }
266+
}
267+
";
268+
let output = std::process::Command::new("powershell.exe")
269+
.args(["-NoProfile", "-NonInteractive", "-Command", script])
270+
.env("TINYSECURITY_TEST_DIRECTORY", root.path())
271+
.env("TINYSECURITY_TEST_OWNER", identity()?)
272+
.output()?;
273+
eprintln!(
274+
"audit-stage=ordinary-account exit={:?}",
275+
output.status.code()
276+
);
277+
assert!(
278+
output.status.success(),
279+
"ordinary account must be unable to read or replace the namespace"
280+
);
281+
assert_eq!(std::fs::read(journal)?, before);
282+
assert!(root.path().exists());
283+
assert!(!root.path().with_extension("outsider").exists());
284+
Ok(())
285+
}

‎crates/tinysecurity-module/examples/audit_native_contract.rs‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
//! Loaded native audit contract with separately served, sender-bound callbacks.
2+
#[path = "audit_native_contract/native.rs"]
3+
mod native;
24
use async_trait::async_trait;
35
use serde_json::Value;
46
use std::{
@@ -174,8 +176,10 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
174176
.ok_or("module directory")?
175177
.join("modules.toml");
176178
let digest = tinybus::module::sha256_file(&path)?;
177-
if std::env::args().nth(2).as_deref() == Some("optional") {
178-
return verify_optional_callback_loss(&path).await;
179+
match std::env::args().nth(2).as_deref() {
180+
Some("optional") => return verify_optional_callback_loss(&path).await,
181+
Some("native") => return native::verify(&path).await,
182+
_ => {}
179183
}
180184
let bus = MemoryBus::new();
181185
let broker = Broker::new();
@@ -248,7 +252,8 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
248252
);
249253
host.shutdown(Duration::from_secs(1)).await;
250254
task.abort();
251-
verify_optional_process(&path)?;
255+
verify_process(&path, "optional")?;
256+
verify_process(&path, "native")?;
252257
println!(
253258
"native audit authority, reentrancy, redaction, timeout, replay and permission phases verified"
254259
);
@@ -315,16 +320,17 @@ async fn verify_policy_generation_race(
315320
Ok(())
316321
}
317322

318-
fn verify_optional_process(
323+
fn verify_process(
319324
path: &std::path::Path,
325+
mode: &str,
320326
) -> std::result::Result<(), Box<dyn std::error::Error>> {
321327
assert!(
322328
std::process::Command::new(std::env::current_exe()?)
323329
.arg(path)
324-
.arg("optional")
330+
.arg(mode)
325331
.status()?
326332
.success(),
327-
"separate loaded optional module must preserve degraded permission semantics"
333+
"separate loaded audit phase must verify its native permission semantics"
328334
);
329335
Ok(())
330336
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
//! Loaded permissions tied to the actual OS sink, including precommit rotation denial.
2+
use super::{audit_config, module_peer};
3+
#[cfg(unix)]
4+
use std::os::unix::fs::OpenOptionsExt;
5+
use std::{
6+
fs::OpenOptions,
7+
path::{Path, PathBuf},
8+
time::Duration,
9+
};
10+
use tinybus::{Connection, broker::Broker, module::ModuleHost, transport::memory::MemoryBus};
11+
use tinysecurity_bus::{
12+
AccessTier, AuditCorrelation2, AuditedRequest2, CallerContext, Check, CheckAuditedResponse2,
13+
CheckRequest, ModuleConfig, Verdict, names,
14+
};
15+
16+
fn namespace() -> std::result::Result<(tempfile::TempDir, PathBuf), Box<dyn std::error::Error>> {
17+
#[cfg(unix)]
18+
let directory = tempfile::tempdir()?;
19+
#[cfg(windows)]
20+
let directory = tempfile::tempdir_in(
21+
std::env::var_os("TINYSECURITY_AUDIT_TEST_ROOT")
22+
.ok_or("audit namespace not provisioned")?,
23+
)?;
24+
#[cfg(windows)]
25+
provision(directory.path())?;
26+
#[cfg(unix)]
27+
let path = directory.path().canonicalize()?.join("audit");
28+
#[cfg(windows)]
29+
let path = directory.path().join("audit");
30+
let mut options = OpenOptions::new();
31+
options.write(true).create_new(true);
32+
#[cfg(unix)]
33+
options.mode(0o600);
34+
options
35+
.open(path.with_file_name("audit.journal"))?
36+
.sync_all()?;
37+
#[cfg(unix)]
38+
{
39+
use std::os::unix::fs::PermissionsExt;
40+
let mode = std::fs::metadata(path.with_file_name("audit.journal"))?
41+
.permissions()
42+
.mode()
43+
& 0o777;
44+
eprintln!("audit-stage=native-journal-installer mode={mode}");
45+
assert_eq!(
46+
mode, 0o600,
47+
"the fixture must restrict the authority before module initialization"
48+
);
49+
}
50+
Ok((directory, path))
51+
}
52+
53+
#[cfg(windows)]
54+
fn provision(path: &Path) -> std::result::Result<(), Box<dyn std::error::Error>> {
55+
let script = r"
56+
$ErrorActionPreference = 'Stop'
57+
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
58+
$acl = [Security.AccessControl.DirectorySecurity]::new()
59+
$acl.SetOwner($identity.User)
60+
$acl.SetAccessRuleProtection($true, $false)
61+
$rule = [Security.AccessControl.FileSystemAccessRule]::new($identity.User, [Security.AccessControl.FileSystemRights]::FullControl, [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit', [Security.AccessControl.PropagationFlags]::None, [Security.AccessControl.AccessControlType]::Allow)
62+
[void]$acl.AddAccessRule($rule)
63+
Set-Acl -LiteralPath $env:TINYSECURITY_NATIVE_DIRECTORY -AclObject $acl
64+
";
65+
let output = std::process::Command::new("powershell.exe")
66+
.args(["-NoProfile", "-NonInteractive", "-Command", script])
67+
.env("TINYSECURITY_NATIVE_DIRECTORY", path)
68+
.output()?;
69+
if !output.status.success() {
70+
return Err("native audit namespace installer failed".into());
71+
}
72+
Ok(())
73+
}
74+
75+
fn request(tail: u64) -> AuditedRequest2<CheckRequest> {
76+
AuditedRequest2 {
77+
request: CheckRequest {
78+
caller: CallerContext {
79+
agent: "agent".into(),
80+
call_id: format!("bearer private-native-operation-{tail}"),
81+
tier: AccessTier::Full,
82+
..Default::default()
83+
},
84+
checks: vec![Check::LocalDiagnostic {}],
85+
},
86+
audit: AuditCorrelation2 {
87+
event_id: format!("{:032x}", tail + 1),
88+
expected_tail: tail,
89+
generation: 1,
90+
},
91+
}
92+
}
93+
94+
pub(super) async fn verify(library: &Path) -> std::result::Result<(), Box<dyn std::error::Error>> {
95+
let (_directory, path) = namespace()?;
96+
let bus = MemoryBus::new();
97+
let broker = Broker::new();
98+
let task = broker.spawn(bus.clone());
99+
let host = ModuleHost::new(broker);
100+
let client = Connection::connect(bus.connect().await?).await?;
101+
let mut audit = audit_config(true);
102+
audit.native_path = Some(path.to_str().ok_or("native fixture path encoding")?.into());
103+
audit.max_bytes = 2048;
104+
host.load_file_with_config(
105+
library,
106+
serde_json::to_value(ModuleConfig {
107+
trusted_host: client.unique_name().map(|peer| peer.as_str().to_owned()),
108+
audit: Some(audit),
109+
..Default::default()
110+
})?,
111+
)?;
112+
let module = module_peer(&client).await?;
113+
let proxy = client.proxy(module.as_str(), names::OBJECT_PATH, names::INTERFACE)?;
114+
assert!(proxy.attestation().await?.is_some());
115+
let first: CheckAuditedResponse2 = proxy
116+
.call_confidential(names::methods::CHECK_AUDITED2, (request(0),))
117+
.await?;
118+
assert!(
119+
first.audit.permitted
120+
&& first.audit.committed
121+
&& matches!(first.decision.decisions[0].verdict, Verdict::Allow)
122+
);
123+
assert_eq!(
124+
proxy
125+
.call::<CheckAuditedResponse2>(names::methods::CHECK_AUDITED2, (request(0),))
126+
.await?,
127+
first
128+
);
129+
let journal = path.with_file_name("audit.journal");
130+
assert!(std::fs::metadata(&journal)?.len() > 0);
131+
assert!(
132+
std::fs::read(&journal)?
133+
.windows(b"private-native-operation".len())
134+
.all(|part| part != b"private-native-operation"),
135+
"the actual authority must contain sanitized data only"
136+
);
137+
#[cfg(windows)]
138+
verify_rename_denial(&proxy, &path).await?;
139+
host.shutdown(Duration::from_secs(1)).await;
140+
task.abort();
141+
println!("loaded required permission is backed by the actual restricted native journal");
142+
Ok(())
143+
}
144+
145+
#[cfg(windows)]
146+
async fn verify_rename_denial(
147+
proxy: &tinybus::Proxy,
148+
path: &Path,
149+
) -> std::result::Result<(), Box<dyn std::error::Error>> {
150+
use std::os::windows::fs::OpenOptionsExt;
151+
let pin = OpenOptions::new().read(true).share_mode(3).open(path)?;
152+
let journal = path.with_file_name("audit.journal");
153+
let mut blocked = None;
154+
for tail in 1..16 {
155+
let before = std::fs::read(&journal)?;
156+
let response: CheckAuditedResponse2 = proxy
157+
.call(names::methods::CHECK_AUDITED2, (request(tail),))
158+
.await?;
159+
if !response.audit.permitted {
160+
assert!(!response.audit.committed && response.audit.receipt.is_none());
161+
assert!(
162+
response
163+
.decision
164+
.decisions
165+
.iter()
166+
.all(|decision| !matches!(decision.verdict, Verdict::Allow))
167+
);
168+
assert_eq!(
169+
std::fs::read(&journal)?,
170+
before,
171+
"required denied rename must not journal the candidate"
172+
);
173+
blocked = Some(tail);
174+
break;
175+
}
176+
assert!(response.audit.committed);
177+
}
178+
let tail = blocked.ok_or("required permission never reached blocked rotation")?;
179+
drop(pin);
180+
let permitted: CheckAuditedResponse2 = proxy
181+
.call(names::methods::CHECK_AUDITED2, (request(tail),))
182+
.await?;
183+
assert!(permitted.audit.permitted && permitted.audit.committed);
184+
let receipt = permitted
185+
.audit
186+
.receipt
187+
.as_ref()
188+
.ok_or("native exact receipt absent")?;
189+
assert_eq!(receipt.new_tail, tail + 1);
190+
assert_eq!(receipt.events[0].event_id, request(tail).audit.event_id);
191+
assert_eq!(
192+
proxy
193+
.call::<CheckAuditedResponse2>(names::methods::CHECK_AUDITED2, (request(tail),))
194+
.await?,
195+
permitted
196+
);
197+
Ok(())
198+
}

0 commit comments

Comments
 (0)