Skip to content

Commit abb953d

Browse files
Haofeiclaude
andcommitted
Fix REIR capability coverage: absent grant field is unknown, not a wildcard (audit 4.1)
A granted capability that omitted provider/service/action/resource previously COVERED a requirement that named a specific one (`(None, Some) => true`), so a broad `category`-only grant satisfied a specific `s3:PutObject on arn:...` requirement — a capability-gate bypass. - reconciliation: a grant field that is absent no longer covers a specific required field; breadth must be explicit (`*`). action/resource/provider/ service all tightened. Add adversarial tests (category-only must not cover specific; missing fields are unknown; explicit `*` does cover; category-level on both still covered; category-only requirement reports missing vs a specific grant). - rsscript adapter: presence-level facts (runtime.native, native source scan) no longer stamp the package id as a `resource` scope — they are scoped by subject, not a resource — so they reconcile at category level as intended. - s3 demo fixture: the runtime network grant now carries its real provider (`rsscript`) instead of relying on the absent-as-wildcard behavior. Whole workspace green (1154 tests); s3-iam demo still covers legitimately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b5e9bd3 commit abb953d

3 files changed

Lines changed: 127 additions & 7 deletions

File tree

reir/src/adapters/rsscript.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3666,7 +3666,7 @@ fn capability_fact(
36663666
provider: Some("rsscript".to_owned()),
36673667
service: None,
36683668
action: None,
3669-
resource: Some(subject.id.clone()),
3669+
resource: None, // presence-level: scoped by subject, not a resource
36703670
constraints,
36713671
}),
36723672
value: FactValue::True,
@@ -4486,7 +4486,7 @@ fn native_scan_capability_fact(
44864486
provider: Some("rsscript".to_owned()),
44874487
service: Some("native_rust_source_scan".to_owned()),
44884488
action: None,
4489-
resource: Some(subject.id.clone()),
4489+
resource: None, // presence-level: scoped by subject, not a resource
44904490
constraints: HashMap::new(),
44914491
}),
44924492
value: FactValue::True,

reir/src/reconciliation.rs

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -214,25 +214,44 @@ fn capability_covers(granted: &Capability, required: &Capability) -> bool {
214214

215215
fn optional_field_covers(granted: Option<&str>, required: Option<&str>) -> bool {
216216
match (granted, required) {
217+
// The requirement does not constrain this dimension.
217218
(_, None) => true,
218-
(None, Some(_)) => true,
219+
// An explicit wildcard grant covers any specific requirement.
220+
(Some("*"), Some(_)) => true,
219221
(Some(granted), Some(required)) => granted == required,
222+
// A grant that does not name this field is UNKNOWN, not a wildcard: it
223+
// cannot prove it covers a requirement that names a specific value.
224+
(None, Some(_)) => false,
220225
}
221226
}
222227

223228
fn action_covers(granted: Option<&str>, required: Option<&str>) -> bool {
224229
match (granted, required) {
230+
// An explicit wildcard grant covers any required action.
231+
(Some("*"), _) => true,
225232
(Some(granted), Some(required)) => granted == required,
226-
(None, _) => true,
233+
// Both unconstrained.
234+
(None, None) => true,
235+
// A grant with no action is unknown, not a wildcard — it does not cover a
236+
// requirement that names a specific action.
237+
(None, Some(_)) => false,
238+
// A grant scoped to a specific action does not cover an unconstrained
239+
// (broad) requirement.
227240
(Some(_), None) => false,
228241
}
229242
}
230243

231244
fn resource_covers(granted: Option<&str>, required: Option<&str>) -> bool {
232245
match (granted, required) {
233-
(None, _) => true,
246+
// Both unconstrained.
247+
(None, None) => true,
248+
// A grant with no resource is unknown, not a wildcard — it does not cover
249+
// a requirement that names a specific resource.
250+
(None, Some(_)) => false,
251+
// A grant scoped to a specific resource covers an unconstrained requirement.
234252
(Some(_), None) => true,
235253
(Some(granted), Some(required)) if granted == required => true,
254+
// Explicit prefix wildcard, e.g. `arn:aws:s3:::bucket/*`.
236255
(Some(granted), Some(required)) if granted.ends_with('*') => {
237256
required.starts_with(&granted[..granted.len() - 1])
238257
}
@@ -243,7 +262,10 @@ fn resource_covers(granted: Option<&str>, required: Option<&str>) -> bool {
243262
#[cfg(test)]
244263
mod tests {
245264
use super::*;
246-
use crate::CapabilityCategory;
265+
use crate::{
266+
AcquisitionMode, CapabilityCategory, Confidence, ConfidenceLevel, Fact, FactKind, FactRole,
267+
FactValue, Precision, Subject, SubjectKind,
268+
};
247269
use std::collections::HashMap;
248270

249271
fn capability(service: &str) -> Capability {
@@ -265,4 +287,102 @@ mod tests {
265287
&capability("s3")
266288
));
267289
}
290+
291+
fn broad(category: CapabilityCategory) -> Capability {
292+
Capability {
293+
category,
294+
provider: None,
295+
service: None,
296+
action: None,
297+
resource: None,
298+
constraints: HashMap::new(),
299+
}
300+
}
301+
302+
fn wildcard(category: CapabilityCategory, provider: &str) -> Capability {
303+
Capability {
304+
category,
305+
provider: Some(provider.to_owned()),
306+
service: Some("*".to_owned()),
307+
action: Some("*".to_owned()),
308+
resource: Some("*".to_owned()),
309+
constraints: HashMap::new(),
310+
}
311+
}
312+
313+
#[test]
314+
fn category_only_grant_does_not_cover_specific_requirement() {
315+
// The classic bypass: a broad `object_storage.write` grant must NOT
316+
// satisfy a requirement that names a specific provider/service/action/resource.
317+
let grant = broad(CapabilityCategory::ObjectStorageWrite);
318+
let required = capability("s3");
319+
assert!(!capability_covers(&grant, &required));
320+
}
321+
322+
#[test]
323+
fn missing_grant_fields_are_unknown_not_wildcard() {
324+
// Each specific field on the requirement must be matched by the grant.
325+
let required = capability("s3");
326+
let mut missing_provider = capability("s3");
327+
missing_provider.provider = None;
328+
assert!(!capability_covers(&missing_provider, &required));
329+
let mut missing_action = capability("s3");
330+
missing_action.action = None;
331+
assert!(!capability_covers(&missing_action, &required));
332+
let mut missing_resource = capability("s3");
333+
missing_resource.resource = None;
334+
assert!(!capability_covers(&missing_resource, &required));
335+
}
336+
337+
#[test]
338+
fn explicit_wildcard_grant_covers_specific_requirement() {
339+
// Breadth must be explicit (`*`), and then it does cover.
340+
let grant = wildcard(CapabilityCategory::ObjectStorageWrite, "aws");
341+
let required = capability("s3");
342+
assert!(capability_covers(&grant, &required));
343+
}
344+
345+
#[test]
346+
fn category_level_grant_covers_category_level_requirement() {
347+
// Genuinely broad-on-both (e.g. runtime.native / network.client) still works.
348+
let grant = broad(CapabilityCategory::RuntimeNative);
349+
let required = broad(CapabilityCategory::RuntimeNative);
350+
assert!(capability_covers(&grant, &required));
351+
}
352+
353+
#[test]
354+
fn category_only_requirement_reports_missing_against_specific_grant_only() {
355+
// A category-only requirement is broad; a specific grant does not cover it.
356+
let required = vec![Fact {
357+
schema: "reir.fact.v0.1".to_string(),
358+
id: "req.broad".to_string(),
359+
kind: FactKind::Capability,
360+
role: Some(FactRole::Required),
361+
subject: Subject {
362+
kind: SubjectKind::Package,
363+
id: "pkg".to_string(),
364+
name: None,
365+
package: None,
366+
},
367+
capability: Some(broad(CapabilityCategory::ObjectStorageWrite)),
368+
value: FactValue::True,
369+
confidence: Confidence {
370+
level: ConfidenceLevel::Authoritative,
371+
source: None,
372+
},
373+
acquisition_mode: AcquisitionMode::PackageMetadata,
374+
precision: Precision::Category,
375+
evidence: Vec::new(),
376+
unknown_reason: None,
377+
}];
378+
let granted = vec![Fact {
379+
capability: Some(capability("s3")),
380+
..required[0].clone()
381+
}];
382+
let results = reconcile_capabilities(&required, &granted);
383+
assert!(results.iter().any(|r| matches!(
384+
r.kind,
385+
ReconciliationKind::MissingCapability
386+
)));
387+
}
268388
}

tests/s3_iam_reir_demo_e2e.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,7 @@ fn runtime_grants() -> Vec<Fact> {
584584
runtime_grant(
585585
"fact.mock_runtime.network_client",
586586
CapabilityCategory::NetworkClient,
587-
None,
587+
Some("rsscript"),
588588
Some("native_rust_source_scan"),
589589
),
590590
]

0 commit comments

Comments
 (0)