Skip to main content

fakecloud_config/
validate.rs

1//! AWS Config data-plane logic: cross-service configuration recording, managed
2//! rule evaluation, and the `SelectResourceConfig` query subset.
3//!
4//! Recording reaches into the live state of other FakeCloud services (S3, EC2,
5//! IAM) and synthesizes genuine `ConfigurationItem`s from what actually exists,
6//! rather than fabricating placeholder resources. Rule evaluation runs real
7//! checks against those recorded items and produces true COMPLIANT /
8//! NON_COMPLIANT results.
9
10use chrono::Utc;
11use serde_json::{json, Value};
12
13use fakecloud_ec2::SharedEc2State;
14use fakecloud_iam::SharedIamState;
15use fakecloud_s3::SharedS3State;
16
17use crate::state::{resource_key, AccountState, ConfigurationItem, EvaluationResult};
18
19/// Handles to the other services' state that AWS Config records from. All
20/// optional so the service degrades gracefully in a minimal deployment (and in
21/// unit tests) — a resource type whose backing service is absent simply
22/// records nothing rather than panicking.
23#[derive(Clone, Default)]
24pub struct CrossServiceStates {
25    pub s3: Option<SharedS3State>,
26    pub iam: Option<SharedIamState>,
27    pub ec2: Option<SharedEc2State>,
28}
29
30/// AWS resource types Config records natively in this implementation.
31pub const SUPPORTED_RESOURCE_TYPES: &[&str] = &[
32    "AWS::S3::Bucket",
33    "AWS::EC2::Instance",
34    "AWS::EC2::SecurityGroup",
35    "AWS::EC2::VPC",
36    "AWS::IAM::User",
37    "AWS::IAM::Role",
38    "AWS::IAM::Policy",
39];
40
41/// A single discovered resource, before it is turned into a full
42/// `ConfigurationItem`.
43pub struct DiscoveredResource {
44    pub resource_type: String,
45    pub resource_id: String,
46    pub resource_name: Option<String>,
47    pub region: String,
48    pub availability_zone: String,
49    pub arn: String,
50    pub tags: std::collections::BTreeMap<String, String>,
51    /// The resource configuration as a JSON value.
52    pub configuration: Value,
53}
54
55/// Whether an S3 bucket with `bucket_name` exists in the wired S3 service.
56/// Returns `None` when S3 is not wired (validation is then skipped so the
57/// service degrades gracefully in minimal deployments and unit tests). S3
58/// bucket names are globally unique, so every account is searched.
59pub fn s3_bucket_exists(states: &CrossServiceStates, bucket_name: &str) -> Option<bool> {
60    let s3 = states.s3.as_ref()?;
61    let guard = s3.read();
62    let exists = guard
63        .iter()
64        .any(|(_, st)| st.buckets.contains_key(bucket_name));
65    Some(exists)
66}
67
68/// Discover every supported resource that currently exists across the wired
69/// services for `account_id`.
70pub fn discover_all(
71    states: &CrossServiceStates,
72    account_id: &str,
73    region: &str,
74) -> Vec<DiscoveredResource> {
75    let mut out = Vec::new();
76    discover_s3(states, account_id, region, &mut out);
77    discover_ec2(states, account_id, region, &mut out);
78    discover_iam(states, account_id, region, &mut out);
79    out
80}
81
82fn discover_s3(
83    states: &CrossServiceStates,
84    account_id: &str,
85    region: &str,
86    out: &mut Vec<DiscoveredResource>,
87) {
88    let Some(s3) = &states.s3 else { return };
89    let guard = s3.read();
90    let Some(st) = guard.get(account_id) else {
91        return;
92    };
93    for (name, bucket) in &st.buckets {
94        let tags = bucket.tags.clone();
95        // A grant is public when it targets the AllUsers / AuthenticatedUsers
96        // groups, or when a public canned ACL is set.
97        let public_read = bucket.acl.as_deref().is_some_and(|a| {
98            matches!(
99                a,
100                "public-read" | "public-read-write" | "authenticated-read"
101            )
102        }) || bucket.acl_grants.iter().any(|g| {
103            is_public_grantee_uri(g.grantee_uri.as_deref())
104                && matches!(g.permission.as_str(), "READ" | "FULL_CONTROL")
105        });
106        let public_write = bucket.acl.as_deref() == Some("public-read-write")
107            || bucket.acl_grants.iter().any(|g| {
108                is_public_grantee_uri(g.grantee_uri.as_deref())
109                    && matches!(g.permission.as_str(), "WRITE" | "FULL_CONTROL")
110            });
111        let config = json!({
112            "name": name,
113            "creationDate": bucket.creation_date.to_rfc3339(),
114            "bucketVersioningConfiguration": {
115                "status": bucket.versioning.clone().unwrap_or_else(|| "Off".to_string()),
116                "isMfaDeleteEnabled": bucket.mfa_delete.as_deref() == Some("Enabled"),
117            },
118            "serverSideEncryptionConfiguration": bucket.encryption_config,
119            "publicAccessBlockConfiguration": bucket.public_access_block,
120            "bucketPolicy": bucket.policy,
121            "grantsPublicRead": public_read,
122            "grantsPublicWrite": public_write,
123            "region": bucket.region,
124        });
125        out.push(DiscoveredResource {
126            resource_type: "AWS::S3::Bucket".into(),
127            resource_id: name.clone(),
128            resource_name: Some(name.clone()),
129            region: region.to_string(),
130            availability_zone: "Regional".into(),
131            arn: format!("arn:aws:s3:::{name}"),
132            tags,
133            configuration: config,
134        });
135    }
136}
137
138fn discover_ec2(
139    states: &CrossServiceStates,
140    account_id: &str,
141    region: &str,
142    out: &mut Vec<DiscoveredResource>,
143) {
144    let Some(ec2) = &states.ec2 else { return };
145    let guard = ec2.read();
146    let Some(st) = guard.get(account_id) else {
147        return;
148    };
149    let tags_for = |id: &str| -> std::collections::BTreeMap<String, String> {
150        st.tags
151            .get(id)
152            .map(|v| v.iter().map(|t| (t.key.clone(), t.value.clone())).collect())
153            .unwrap_or_default()
154    };
155    for (id, inst) in &st.instances {
156        if inst.state_name == "terminated" {
157            continue;
158        }
159        let config = json!({
160            "instanceId": id,
161            "imageId": inst.image_id,
162            "instanceType": inst.instance_type,
163            "state": { "name": inst.state_name, "code": inst.state_code },
164            "privateIpAddress": inst.private_ip,
165            "publicIpAddress": inst.public_ip,
166            "subnetId": inst.subnet_id,
167            "vpcId": inst.vpc_id,
168            "securityGroups": inst.security_group_ids,
169            "keyName": inst.key_name,
170        });
171        let name = tags_for(id).get("Name").cloned();
172        out.push(DiscoveredResource {
173            resource_type: "AWS::EC2::Instance".into(),
174            resource_id: id.clone(),
175            resource_name: name,
176            region: region.to_string(),
177            availability_zone: inst.az.clone(),
178            arn: format!("arn:aws:ec2:{region}:{account_id}:instance/{id}"),
179            tags: tags_for(id),
180            configuration: config,
181        });
182    }
183    for (id, sg) in &st.security_groups {
184        let ingress: Vec<Value> = sg
185            .rules
186            .iter()
187            .filter(|r| !r.is_egress)
188            .map(security_group_rule_json)
189            .collect();
190        let egress: Vec<Value> = sg
191            .rules
192            .iter()
193            .filter(|r| r.is_egress)
194            .map(security_group_rule_json)
195            .collect();
196        let config = json!({
197            "groupId": sg.group_id,
198            "groupName": sg.group_name,
199            "description": sg.description,
200            "vpcId": sg.vpc_id,
201            "ipPermissions": ingress,
202            "ipPermissionsEgress": egress,
203        });
204        out.push(DiscoveredResource {
205            resource_type: "AWS::EC2::SecurityGroup".into(),
206            resource_id: id.clone(),
207            resource_name: Some(sg.group_name.clone()),
208            region: region.to_string(),
209            availability_zone: "Regional".into(),
210            arn: format!("arn:aws:ec2:{region}:{account_id}:security-group/{id}"),
211            tags: tags_for(id),
212            configuration: config,
213        });
214    }
215    for (id, vpc) in &st.vpcs {
216        let config = json!({
217            "vpcId": id,
218            "cidrBlock": vpc.cidr_block,
219            "state": vpc.state,
220            "isDefault": vpc.is_default,
221        });
222        out.push(DiscoveredResource {
223            resource_type: "AWS::EC2::VPC".into(),
224            resource_id: id.clone(),
225            resource_name: tags_for(id).get("Name").cloned(),
226            region: region.to_string(),
227            availability_zone: "Regional".into(),
228            arn: format!("arn:aws:ec2:{region}:{account_id}:vpc/{id}"),
229            tags: tags_for(id),
230            configuration: config,
231        });
232    }
233}
234
235fn security_group_rule_json(r: &fakecloud_ec2::state::SecurityGroupRule) -> Value {
236    let mut ranges = Vec::new();
237    if let Some(c) = &r.cidr_ipv4 {
238        ranges.push(json!({ "cidrIp": c }));
239    }
240    json!({
241        "ipProtocol": r.ip_protocol,
242        "fromPort": r.from_port,
243        "toPort": r.to_port,
244        "ipRanges": ranges.iter().filter_map(|v| v.get("cidrIp").and_then(Value::as_str)).collect::<Vec<_>>(),
245        "ipv4Ranges": ranges,
246    })
247}
248
249fn discover_iam(
250    states: &CrossServiceStates,
251    account_id: &str,
252    _region: &str,
253    out: &mut Vec<DiscoveredResource>,
254) {
255    let Some(iam) = &states.iam else { return };
256    let guard = iam.read();
257    let Some(st) = guard.get(account_id) else {
258        return;
259    };
260    for (name, user) in &st.users {
261        let attached = st.user_policies.get(name).cloned().unwrap_or_default();
262        let inline: Vec<String> = st
263            .user_inline_policies
264            .get(name)
265            .map(|m| m.keys().cloned().collect())
266            .unwrap_or_default();
267        let config = json!({
268            "userName": name,
269            "userId": user.user_id,
270            "arn": user.arn,
271            "path": user.path,
272            "attachedManagedPolicies": attached,
273            "userPolicyList": inline,
274        });
275        out.push(DiscoveredResource {
276            resource_type: "AWS::IAM::User".into(),
277            resource_id: user.user_id.clone(),
278            resource_name: Some(name.clone()),
279            region: "global".into(),
280            availability_zone: "Not Applicable".into(),
281            arn: user.arn.clone(),
282            tags: user
283                .tags
284                .iter()
285                .map(|t| (t.key.clone(), t.value.clone()))
286                .collect(),
287            configuration: config,
288        });
289    }
290    for (name, role) in &st.roles {
291        let attached = st.role_policies.get(name).cloned().unwrap_or_default();
292        let config = json!({
293            "roleName": name,
294            "roleId": role.role_id,
295            "arn": role.arn,
296            "path": role.path,
297            "assumeRolePolicyDocument": role.assume_role_policy_document,
298            "attachedManagedPolicies": attached,
299        });
300        out.push(DiscoveredResource {
301            resource_type: "AWS::IAM::Role".into(),
302            resource_id: role.role_id.clone(),
303            resource_name: Some(name.clone()),
304            region: "global".into(),
305            availability_zone: "Not Applicable".into(),
306            arn: role.arn.clone(),
307            tags: role
308                .tags
309                .iter()
310                .map(|t| (t.key.clone(), t.value.clone()))
311                .collect(),
312            configuration: config,
313        });
314    }
315    for (arn, policy) in &st.policies {
316        let config = json!({
317            "policyName": policy.policy_name,
318            "policyId": policy.policy_id,
319            "arn": arn,
320            "path": policy.path,
321            "defaultVersionId": policy.default_version_id,
322            "attachmentCount": policy.attachment_count,
323        });
324        out.push(DiscoveredResource {
325            resource_type: "AWS::IAM::Policy".into(),
326            resource_id: policy.policy_id.clone(),
327            resource_name: Some(policy.policy_name.clone()),
328            region: "global".into(),
329            availability_zone: "Not Applicable".into(),
330            arn: arn.clone(),
331            tags: policy
332                .tags
333                .iter()
334                .map(|t| (t.key.clone(), t.value.clone()))
335                .collect(),
336            configuration: config,
337        });
338    }
339}
340
341/// The `configuration` string of a discovered resource, keyed by its stable
342/// `resource_key`. Computed once so the read-only [`sync_would_change`] check
343/// and the mutating [`apply_recorded_items`] agree exactly.
344fn discovered_config(res: &DiscoveredResource) -> String {
345    serde_json::to_string(&res.configuration).unwrap_or_else(|_| "{}".into())
346}
347
348/// Read-only check: would folding `discovered` into `account`'s recorded
349/// history change anything? Lets the caller skip taking a write lock (and the
350/// full re-record) on a pure read when nothing has changed since last sync.
351pub fn sync_would_change(account: &AccountState, discovered: &[DiscoveredResource]) -> bool {
352    let mut seen = std::collections::BTreeSet::new();
353    for res in discovered {
354        let key = resource_key(&res.resource_type, &res.resource_id);
355        seen.insert(key.clone());
356        let changed = account
357            .config_items
358            .get(&key)
359            .and_then(|h| h.last())
360            .map(|last| {
361                last.configuration != discovered_config(res)
362                    || last.configuration_item_status == "ResourceDeleted"
363            })
364            .unwrap_or(true);
365        if changed {
366            return true;
367        }
368    }
369    // A natively-recorded resource that vanished needs a delete marker.
370    for (key, history) in &account.config_items {
371        let Some((rtype, _)) = key.split_once('\u{1}') else {
372            continue;
373        };
374        if !SUPPORTED_RESOURCE_TYPES.contains(&rtype) || seen.contains(key) {
375            continue;
376        }
377        match history.last() {
378            Some(last)
379                if !last.externally_recorded
380                    && !last
381                        .configuration_item_status
382                        .starts_with("ResourceDeleted") =>
383            {
384                return true;
385            }
386            _ => {}
387        }
388    }
389    false
390}
391
392/// Fold discovered cross-service resources into the account's recorded
393/// configuration-item history. A new item is appended only when a resource is
394/// new or its configuration changed since the last recorded item, so history
395/// grows exactly like real Config (one item per configuration state).
396/// Externally recorded items (`PutResourceConfig`) are never delete-marked here.
397pub fn apply_recorded_items(
398    account: &mut AccountState,
399    discovered: Vec<DiscoveredResource>,
400    account_id: &str,
401) {
402    let now = Utc::now();
403    let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
404    for res in discovered {
405        let key = resource_key(&res.resource_type, &res.resource_id);
406        seen.insert(key.clone());
407        let config_str = discovered_config(&res);
408        let history = account.config_items.entry(key).or_default();
409        let changed = history
410            .last()
411            .map(|last| {
412                last.configuration != config_str
413                    || last.configuration_item_status == "ResourceDeleted"
414            })
415            .unwrap_or(true);
416        if changed {
417            let state_id = (history.len() as u64 + 1).to_string();
418            history.push(ConfigurationItem {
419                version: "1.3".into(),
420                account_id: account_id.to_string(),
421                configuration_item_capture_time: now,
422                configuration_item_status: if history.is_empty() {
423                    "ResourceDiscovered".into()
424                } else {
425                    "OK".into()
426                },
427                configuration_state_id: state_id,
428                arn: res.arn,
429                resource_type: res.resource_type,
430                resource_id: res.resource_id,
431                resource_name: res.resource_name,
432                aws_region: res.region,
433                availability_zone: res.availability_zone,
434                resource_creation_time: Some(now),
435                tags: res.tags,
436                configuration: config_str,
437                supplementary_configuration: Default::default(),
438                externally_recorded: false,
439            });
440        }
441    }
442    // Mark natively-recorded resources that vanished from the live services as
443    // deleted. Externally recorded items (`PutResourceConfig`) are owned by the
444    // caller and left untouched — never clobbered with a spurious delete.
445    for (key, history) in account.config_items.iter_mut() {
446        let Some((rtype, _)) = key.split_once('\u{1}') else {
447            continue;
448        };
449        if !SUPPORTED_RESOURCE_TYPES.contains(&rtype) || seen.contains(key) {
450            continue;
451        }
452        let Some(last) = history.last() else { continue };
453        if last.externally_recorded
454            || last
455                .configuration_item_status
456                .starts_with("ResourceDeleted")
457        {
458            continue;
459        }
460        let last = last.clone();
461        let state_id = (history.len() as u64 + 1).to_string();
462        history.push(ConfigurationItem {
463            configuration_item_capture_time: now,
464            configuration_item_status: "ResourceDeleted".into(),
465            configuration_state_id: state_id,
466            ..last
467        });
468    }
469}
470
471// ─── Managed rule evaluation ─────────────────────────────────────────────
472
473/// The compliance verdict Config reports for a resource under a rule.
474pub struct RuleOutcome {
475    pub resource_type: String,
476    pub resource_id: String,
477    pub compliance_type: String,
478    pub annotation: Option<String>,
479}
480
481/// Evaluate one config rule against the recorded configuration items. Returns
482/// per-resource outcomes for the resource types the rule applies to. For AWS
483/// managed rules that are not implemented here, returns a single
484/// `INSUFFICIENT_DATA` outcome rather than falsely reporting COMPLIANT.
485pub fn evaluate_managed_rule(
486    source_identifier: &str,
487    input_parameters: &Value,
488    scope: &Value,
489    account: &AccountState,
490) -> Vec<RuleOutcome> {
491    // Latest recorded item per resource (skip deleted), restricted to the
492    // rule's Scope (ComplianceResourceTypes / ComplianceResourceId / TagKey)
493    // so a rule never evaluates resources outside its declared scope.
494    let latest: Vec<&ConfigurationItem> = account
495        .config_items
496        .values()
497        .filter_map(|h| h.last())
498        .filter(|ci| !ci.configuration_item_status.starts_with("ResourceDeleted"))
499        .filter(|ci| item_in_scope(ci, scope))
500        .collect();
501
502    let by_type = |t: &str| -> Vec<&&ConfigurationItem> {
503        latest.iter().filter(|ci| ci.resource_type == t).collect()
504    };
505    let cfg = |ci: &ConfigurationItem| -> Value {
506        serde_json::from_str(&ci.configuration).unwrap_or(Value::Null)
507    };
508
509    let mut out = Vec::new();
510    match source_identifier {
511        "S3_BUCKET_VERSIONING_ENABLED" => {
512            for ci in by_type("AWS::S3::Bucket") {
513                let status = cfg(ci)
514                    .pointer("/bucketVersioningConfiguration/status")
515                    .and_then(Value::as_str)
516                    .unwrap_or("Off")
517                    .to_string();
518                let compliant = status == "Enabled";
519                out.push(outcome(
520                    ci,
521                    compliant,
522                    if compliant {
523                        None
524                    } else {
525                        Some("Bucket versioning is not enabled".into())
526                    },
527                ));
528            }
529        }
530        "S3_BUCKET_SERVER_SIDE_ENCRYPTION_ENABLED" => {
531            for ci in by_type("AWS::S3::Bucket") {
532                let enc = cfg(ci)
533                    .get("serverSideEncryptionConfiguration")
534                    .map(|v| !v.is_null())
535                    .unwrap_or(false);
536                out.push(outcome(
537                    ci,
538                    enc,
539                    if enc {
540                        None
541                    } else {
542                        Some("Default server-side encryption is not configured".into())
543                    },
544                ));
545            }
546        }
547        "S3_BUCKET_PUBLIC_READ_PROHIBITED" | "S3_BUCKET_PUBLIC_WRITE_PROHIBITED" => {
548            let want_write = source_identifier == "S3_BUCKET_PUBLIC_WRITE_PROHIBITED";
549            for ci in by_type("AWS::S3::Bucket") {
550                let c = cfg(ci);
551                // A Public Access Block, when present, blocks public access, so
552                // the bucket is COMPLIANT regardless of grants/policy.
553                let pab = c
554                    .get("publicAccessBlockConfiguration")
555                    .map(|v| !v.is_null())
556                    .unwrap_or(false);
557                // Otherwise the bucket is NON_COMPLIANT only if it ACTUALLY
558                // grants public access via a public ACL grant or a bucket policy
559                // that allows the `*` principal. A normal private bucket with no
560                // Public Access Block is COMPLIANT (matches AWS).
561                let public_acl = c
562                    .get(if want_write {
563                        "grantsPublicWrite"
564                    } else {
565                        "grantsPublicRead"
566                    })
567                    .and_then(Value::as_bool)
568                    .unwrap_or(false);
569                let policy_public = c
570                    .get("bucketPolicy")
571                    .and_then(Value::as_str)
572                    .map(policy_allows_public_principal)
573                    .unwrap_or(false);
574                let actually_public = public_acl || policy_public;
575                let compliant = pab || !actually_public;
576                out.push(outcome(
577                    ci,
578                    compliant,
579                    if compliant {
580                        None
581                    } else {
582                        Some("Bucket grants public access".into())
583                    },
584                ));
585            }
586        }
587        "IAM_USER_NO_POLICIES_CHECK" => {
588            for ci in by_type("AWS::IAM::User") {
589                let c = cfg(ci);
590                let attached = c
591                    .get("attachedManagedPolicies")
592                    .and_then(Value::as_array)
593                    .map(|a| a.is_empty())
594                    .unwrap_or(true);
595                let inline = c
596                    .get("userPolicyList")
597                    .and_then(Value::as_array)
598                    .map(|a| a.is_empty())
599                    .unwrap_or(true);
600                let compliant = attached && inline;
601                out.push(outcome(
602                    ci,
603                    compliant,
604                    if compliant {
605                        None
606                    } else {
607                        Some("IAM policy attached directly to user".into())
608                    },
609                ));
610            }
611        }
612        "EC2_INSTANCE_NO_PUBLIC_IP" => {
613            for ci in by_type("AWS::EC2::Instance") {
614                let has_public = cfg(ci)
615                    .get("publicIpAddress")
616                    .map(|v| !v.is_null())
617                    .unwrap_or(false);
618                out.push(outcome(
619                    ci,
620                    !has_public,
621                    if has_public {
622                        Some("Instance has a public IP address".into())
623                    } else {
624                        None
625                    },
626                ));
627            }
628        }
629        "VPC_DEFAULT_SECURITY_GROUP_CLOSED" => {
630            for ci in by_type("AWS::EC2::SecurityGroup") {
631                let c = cfg(ci);
632                if c.get("groupName").and_then(Value::as_str) != Some("default") {
633                    continue;
634                }
635                let ingress_empty = c
636                    .get("ipPermissions")
637                    .and_then(Value::as_array)
638                    .map(|a| a.is_empty())
639                    .unwrap_or(true);
640                let egress_empty = c
641                    .get("ipPermissionsEgress")
642                    .and_then(Value::as_array)
643                    .map(|a| a.is_empty())
644                    .unwrap_or(true);
645                let compliant = ingress_empty && egress_empty;
646                out.push(outcome(
647                    ci,
648                    compliant,
649                    if compliant {
650                        None
651                    } else {
652                        Some("Default security group is not closed".into())
653                    },
654                ));
655            }
656        }
657        "INCOMING_SSH_DISABLED" | "RESTRICTED_INCOMING_TRAFFIC" => {
658            let blocked_port: i64 = if source_identifier == "INCOMING_SSH_DISABLED" {
659                22
660            } else {
661                input_parameters
662                    .get("blockedPort1")
663                    .and_then(param_as_i64)
664                    .unwrap_or(22)
665            };
666            for ci in by_type("AWS::EC2::SecurityGroup") {
667                let c = cfg(ci);
668                let open = c
669                    .get("ipPermissions")
670                    .and_then(Value::as_array)
671                    .map(|rules| rules.iter().any(|r| rule_opens_port(r, blocked_port)))
672                    .unwrap_or(false);
673                out.push(outcome(
674                    ci,
675                    !open,
676                    if open {
677                        Some(format!(
678                            "Security group allows unrestricted access to port {blocked_port}"
679                        ))
680                    } else {
681                        None
682                    },
683                ));
684            }
685        }
686        "REQUIRED_TAGS" => {
687            let required: Vec<String> = (1..=6)
688                .filter_map(|i| {
689                    input_parameters
690                        .get(format!("tag{i}Key"))
691                        .and_then(param_as_str)
692                })
693                .collect();
694            for ci in &latest {
695                if required.is_empty() {
696                    out.push(outcome(ci, true, None));
697                    continue;
698                }
699                let missing: Vec<&String> = required
700                    .iter()
701                    .filter(|k| !ci.tags.contains_key(*k))
702                    .collect();
703                let compliant = missing.is_empty();
704                out.push(outcome(
705                    ci,
706                    compliant,
707                    if compliant {
708                        None
709                    } else {
710                        Some(format!("Missing required tags: {missing:?}"))
711                    },
712                ));
713            }
714        }
715        _ => {
716            // Not implemented: report INSUFFICIENT_DATA honestly rather than
717            // faking a COMPLIANT verdict.
718            out.push(RuleOutcome {
719                resource_type: String::new(),
720                resource_id: String::new(),
721                compliance_type: "INSUFFICIENT_DATA".into(),
722                annotation: Some(format!(
723                    "Managed rule {source_identifier} is not evaluated by this implementation"
724                )),
725            });
726        }
727    }
728    out
729}
730
731fn outcome(ci: &ConfigurationItem, compliant: bool, annotation: Option<String>) -> RuleOutcome {
732    RuleOutcome {
733        resource_type: ci.resource_type.clone(),
734        resource_id: ci.resource_id.clone(),
735        compliance_type: if compliant {
736            "COMPLIANT".into()
737        } else {
738            "NON_COMPLIANT".into()
739        },
740        annotation,
741    }
742}
743
744fn rule_opens_port(rule: &Value, port: i64) -> bool {
745    let proto = rule.get("ipProtocol").and_then(Value::as_str).unwrap_or("");
746    let from = rule.get("fromPort").and_then(Value::as_i64).unwrap_or(0);
747    let to = rule.get("toPort").and_then(Value::as_i64).unwrap_or(65535);
748    // An all-traffic rule (`IpProtocol "-1"`, or `-1` port sentinels) opens
749    // every port, so it must be treated as covering the blocked port. Without
750    // this, a security group opening ALL ports to 0.0.0.0/0 would be reported
751    // COMPLIANT — a false all-clear on the worst case.
752    let all_ports = proto == "-1" || from == -1 || to == -1;
753    let covers = all_ports || (from <= port && port <= to);
754    let public = rule
755        .get("ipRanges")
756        .and_then(Value::as_array)
757        .map(|a| a.iter().any(|c| c.as_str() == Some("0.0.0.0/0")))
758        .unwrap_or(false);
759    covers && public
760}
761
762/// Whether an S3 ACL grantee URI targets the public / authenticated-users
763/// groups (i.e. a public grant).
764fn is_public_grantee_uri(uri: Option<&str>) -> bool {
765    uri.map(|u| u.contains("AllUsers") || u.contains("AuthenticatedUsers"))
766        .unwrap_or(false)
767}
768
769/// Parse a bucket policy JSON document and report whether any `Allow` statement
770/// grants access to the `*` principal (`"*"`, `{"AWS":"*"}`, or an array
771/// containing `"*"`). Replaces the previous whitespace-sensitive substring
772/// match, which missed the standard `"Principal": "*"` form the console/SDK
773/// emit.
774fn policy_allows_public_principal(policy: &str) -> bool {
775    let Ok(doc) = serde_json::from_str::<Value>(policy) else {
776        return false;
777    };
778    let statements = match doc.get("Statement") {
779        Some(Value::Array(a)) => a.clone(),
780        Some(obj @ Value::Object(_)) => vec![obj.clone()],
781        _ => return false,
782    };
783    statements.iter().any(|stmt| {
784        let effect = stmt
785            .get("Effect")
786            .and_then(Value::as_str)
787            .unwrap_or("Allow");
788        if !effect.eq_ignore_ascii_case("Allow") {
789            return false;
790        }
791        principal_is_public(stmt.get("Principal"))
792    })
793}
794
795fn principal_is_public(principal: Option<&Value>) -> bool {
796    match principal {
797        Some(Value::String(s)) => s == "*",
798        Some(Value::Object(map)) => map.values().any(value_contains_star),
799        Some(Value::Array(a)) => a.iter().any(|v| v.as_str() == Some("*")),
800        _ => false,
801    }
802}
803
804fn value_contains_star(v: &Value) -> bool {
805    match v {
806        Value::String(s) => s == "*",
807        Value::Array(a) => a.iter().any(|x| x.as_str() == Some("*")),
808        _ => false,
809    }
810}
811
812/// Whether a configuration item falls within a config rule's `Scope`. An empty
813/// or absent scope matches everything (the AWS default).
814fn item_in_scope(ci: &ConfigurationItem, scope: &Value) -> bool {
815    if scope.is_null() {
816        return true;
817    }
818    if let Some(types) = scope
819        .get("ComplianceResourceTypes")
820        .and_then(Value::as_array)
821    {
822        if !types.is_empty()
823            && !types
824                .iter()
825                .any(|t| t.as_str() == Some(ci.resource_type.as_str()))
826        {
827            return false;
828        }
829    }
830    if let Some(id) = scope.get("ComplianceResourceId").and_then(Value::as_str) {
831        if ci.resource_id != id {
832            return false;
833        }
834    }
835    if let Some(tag_key) = scope.get("TagKey").and_then(Value::as_str) {
836        match ci.tags.get(tag_key) {
837            None => return false,
838            Some(v) => {
839                if let Some(tag_value) = scope.get("TagValue").and_then(Value::as_str) {
840                    if v != tag_value {
841                        return false;
842                    }
843                }
844            }
845        }
846    }
847    true
848}
849
850fn param_as_i64(v: &Value) -> Option<i64> {
851    match v {
852        Value::Number(n) => n.as_i64(),
853        Value::String(s) => s.parse().ok(),
854        _ => None,
855    }
856}
857
858fn param_as_str(v: &Value) -> Option<String> {
859    v.as_str().map(|s| s.to_string())
860}
861
862/// Turn a [`RuleOutcome`] into a persisted [`EvaluationResult`].
863pub fn outcome_to_result(rule_name: &str, o: &RuleOutcome) -> EvaluationResult {
864    let now = Utc::now();
865    EvaluationResult {
866        resource_type: o.resource_type.clone(),
867        resource_id: o.resource_id.clone(),
868        rule_name: rule_name.to_string(),
869        compliance_type: o.compliance_type.clone(),
870        annotation: o.annotation.clone(),
871        result_recorded_time: now,
872        config_rule_invoked_time: now,
873        ordering_timestamp: now,
874    }
875}
876
877/// Proactively evaluate a single supplied resource configuration against the
878/// account's AWS-managed rules, without recording it into history. Used by
879/// `StartResourceEvaluation` so a proactive/detective evaluation reports the
880/// *real* compliance of the supplied config rather than a canned COMPLIANT.
881///
882/// `configuration` is the resource configuration document (the same shape the
883/// recorder stores). Returns one [`EvaluationResult`] per managed rule that
884/// applies to the resource's type. An empty result means no managed rule
885/// applied — the caller reports `INSUFFICIENT_DATA`, never a fabricated pass.
886pub fn evaluate_proactive(
887    account: &AccountState,
888    resource_type: &str,
889    resource_id: &str,
890    configuration: &str,
891) -> Vec<EvaluationResult> {
892    let now = Utc::now();
893    let item = ConfigurationItem {
894        version: "1.3".into(),
895        account_id: String::new(),
896        configuration_item_capture_time: now,
897        configuration_item_status: "ResourceDiscovered".into(),
898        configuration_state_id: now.timestamp_millis().to_string(),
899        arn: String::new(),
900        resource_type: resource_type.to_string(),
901        resource_id: resource_id.to_string(),
902        resource_name: None,
903        aws_region: String::new(),
904        availability_zone: "Regional".into(),
905        resource_creation_time: None,
906        tags: std::collections::BTreeMap::new(),
907        configuration: configuration.to_string(),
908        supplementary_configuration: std::collections::BTreeMap::new(),
909        externally_recorded: true,
910    };
911    let mut temp = AccountState::default();
912    temp.config_items
913        .insert(resource_key(resource_type, resource_id), vec![item]);
914
915    let mut results = Vec::new();
916    for rule in account.rules.values() {
917        let owner = rule
918            .source
919            .get("Owner")
920            .and_then(Value::as_str)
921            .unwrap_or("");
922        if owner != "AWS" {
923            continue;
924        }
925        let Some(sid) = rule.source.get("SourceIdentifier").and_then(Value::as_str) else {
926            continue;
927        };
928        let params: Value = rule
929            .input_parameters
930            .as_deref()
931            .and_then(|p| serde_json::from_str(p).ok())
932            .unwrap_or(Value::Null);
933        let scope = rule.scope.clone().unwrap_or(Value::Null);
934        for o in evaluate_managed_rule(sid, &params, &scope, &temp) {
935            if o.resource_id == resource_id && o.resource_type == resource_type {
936                results.push(outcome_to_result(&rule.name, &o));
937            }
938        }
939    }
940    results
941}
942
943// ─── SelectResourceConfig query subset ───────────────────────────────────
944
945/// Evaluate a Config advanced-query `SELECT` expression against recorded items.
946///
947/// Supported grammar (a real, documented subset of the Config query language):
948/// `SELECT <field>[, <field>...] [WHERE <cond> [AND <cond>...]]`, where each
949/// `<cond>` is `<field> = '<value>'` or `<field> IN ('a', 'b')`. Fields are
950/// dotted paths resolved against the item: bare `resourceId`, `resourceType`,
951/// `resourceName`, `arn`, `awsRegion`, `tags`, or `configuration.<path>`.
952/// Returns `(rows, error)`; on a parse error `rows` is empty and `error` is set.
953pub fn run_select(expression: &str, account: &AccountState) -> Result<Vec<Value>, String> {
954    let expr = expression.trim().trim_end_matches(';');
955    let lower = expr.to_ascii_lowercase();
956    if !lower.starts_with("select ") {
957        return Err("Query must start with SELECT".into());
958    }
959    let after_select = &expr[7..];
960    let (fields_part, where_part) = match after_select.to_ascii_lowercase().find(" where ") {
961        Some(idx) => (&after_select[..idx], Some(after_select[idx + 7..].trim())),
962        None => (after_select, None),
963    };
964    let fields: Vec<String> = fields_part
965        .split(',')
966        .map(|s| s.trim().to_string())
967        .filter(|s| !s.is_empty())
968        .collect();
969    if fields.is_empty() {
970        return Err("SELECT requires at least one field".into());
971    }
972    let conditions = match where_part {
973        Some(w) => parse_conditions(w)?,
974        None => Vec::new(),
975    };
976
977    let latest: Vec<&ConfigurationItem> = account
978        .config_items
979        .values()
980        .filter_map(|h| h.last())
981        .filter(|ci| !ci.configuration_item_status.starts_with("ResourceDeleted"))
982        .collect();
983
984    let mut rows = Vec::new();
985    for ci in latest {
986        if !conditions.iter().all(|c| c.matches(ci)) {
987            continue;
988        }
989        let mut row = serde_json::Map::new();
990        for f in &fields {
991            if f == "*" {
992                row.insert("resourceId".into(), json!(ci.resource_id));
993                row.insert("resourceType".into(), json!(ci.resource_type));
994                continue;
995            }
996            if let Some(v) = field_value(ci, f) {
997                insert_dotted(&mut row, f, v);
998            }
999        }
1000        rows.push(Value::Object(row));
1001    }
1002    Ok(rows)
1003}
1004
1005struct Condition {
1006    field: String,
1007    values: Vec<String>,
1008}
1009
1010impl Condition {
1011    fn matches(&self, ci: &ConfigurationItem) -> bool {
1012        match field_value(ci, &self.field) {
1013            Some(Value::String(s)) => self.values.iter().any(|v| v == &s),
1014            Some(other) => self.values.iter().any(|v| v == &other.to_string()),
1015            None => false,
1016        }
1017    }
1018}
1019
1020fn parse_conditions(w: &str) -> Result<Vec<Condition>, String> {
1021    let mut conds = Vec::new();
1022    for part in split_and(w) {
1023        let part = part.trim();
1024        if let Some((f, rest)) = part.split_once(" IN ").or_else(|| part.split_once(" in ")) {
1025            let list = rest.trim().trim_start_matches('(').trim_end_matches(')');
1026            let values: Vec<String> = list.split(',').map(|s| unquote(s.trim())).collect();
1027            conds.push(Condition {
1028                field: f.trim().to_string(),
1029                values,
1030            });
1031        } else if let Some((f, v)) = part.split_once('=') {
1032            conds.push(Condition {
1033                field: f.trim().to_string(),
1034                values: vec![unquote(v.trim())],
1035            });
1036        } else {
1037            return Err(format!("Unsupported condition: {part}"));
1038        }
1039    }
1040    Ok(conds)
1041}
1042
1043fn split_and(w: &str) -> Vec<String> {
1044    // Split on " AND " outside of quotes.
1045    let mut parts = Vec::new();
1046    let mut cur = String::new();
1047    let mut in_quote = false;
1048    let bytes: Vec<char> = w.chars().collect();
1049    let mut i = 0;
1050    while i < bytes.len() {
1051        let c = bytes[i];
1052        if c == '\'' {
1053            in_quote = !in_quote;
1054            cur.push(c);
1055            i += 1;
1056            continue;
1057        }
1058        if !in_quote {
1059            let rest: String = bytes[i..].iter().collect();
1060            let low = rest.to_ascii_lowercase();
1061            if low.starts_with(" and ") {
1062                parts.push(cur.trim().to_string());
1063                cur.clear();
1064                i += 5;
1065                continue;
1066            }
1067        }
1068        cur.push(c);
1069        i += 1;
1070    }
1071    if !cur.trim().is_empty() {
1072        parts.push(cur.trim().to_string());
1073    }
1074    parts
1075}
1076
1077fn unquote(s: &str) -> String {
1078    s.trim().trim_matches('\'').trim_matches('"').to_string()
1079}
1080
1081fn field_value(ci: &ConfigurationItem, field: &str) -> Option<Value> {
1082    match field {
1083        "resourceId" => Some(json!(ci.resource_id)),
1084        "resourceType" => Some(json!(ci.resource_type)),
1085        "resourceName" => ci.resource_name.clone().map(|v| json!(v)),
1086        "arn" => Some(json!(ci.arn)),
1087        "awsRegion" => Some(json!(ci.aws_region)),
1088        "availabilityZone" => Some(json!(ci.availability_zone)),
1089        "accountId" => Some(json!(ci.account_id)),
1090        _ => {
1091            if let Some(path) = field.strip_prefix("configuration.") {
1092                let cfg: Value = serde_json::from_str(&ci.configuration).ok()?;
1093                let pointer = format!("/{}", path.replace('.', "/"));
1094                cfg.pointer(&pointer).cloned()
1095            } else if let Some(tag) = field.strip_prefix("tags.") {
1096                ci.tags.get(tag).map(|v| json!(v))
1097            } else {
1098                None
1099            }
1100        }
1101    }
1102}
1103
1104fn insert_dotted(row: &mut serde_json::Map<String, Value>, field: &str, value: Value) {
1105    // Config returns dotted select fields as the leaf name.
1106    let leaf = field.rsplit('.').next().unwrap_or(field);
1107    row.insert(leaf.to_string(), value);
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112    use super::*;
1113    use crate::state::{resource_key, AccountState, ConfigurationItem};
1114    use chrono::Utc;
1115    use std::collections::BTreeMap;
1116
1117    fn item(rtype: &str, rid: &str, config: Value, tags: &[(&str, &str)]) -> ConfigurationItem {
1118        ConfigurationItem {
1119            version: "1.3".into(),
1120            account_id: "123456789012".into(),
1121            configuration_item_capture_time: Utc::now(),
1122            configuration_item_status: "OK".into(),
1123            configuration_state_id: "1".into(),
1124            arn: format!("arn:aws:::{rid}"),
1125            resource_type: rtype.into(),
1126            resource_id: rid.into(),
1127            resource_name: Some(rid.into()),
1128            aws_region: "us-east-1".into(),
1129            availability_zone: "Regional".into(),
1130            resource_creation_time: Some(Utc::now()),
1131            tags: tags
1132                .iter()
1133                .map(|(k, v)| (k.to_string(), v.to_string()))
1134                .collect(),
1135            configuration: serde_json::to_string(&config).unwrap(),
1136            supplementary_configuration: BTreeMap::new(),
1137            externally_recorded: false,
1138        }
1139    }
1140
1141    fn account_with(items: Vec<ConfigurationItem>) -> AccountState {
1142        let mut acc = AccountState::default();
1143        for it in items {
1144            let key = resource_key(&it.resource_type, &it.resource_id);
1145            acc.config_items.entry(key).or_default().push(it);
1146        }
1147        acc
1148    }
1149
1150    fn verdict(outcomes: &[RuleOutcome], rid: &str) -> Option<String> {
1151        outcomes
1152            .iter()
1153            .find(|o| o.resource_id == rid)
1154            .map(|o| o.compliance_type.clone())
1155    }
1156
1157    #[test]
1158    fn default_private_bucket_is_compliant() {
1159        // No public grants, no bucket policy, no Public Access Block.
1160        let acc = account_with(vec![item(
1161            "AWS::S3::Bucket",
1162            "priv",
1163            json!({ "name": "priv", "grantsPublicRead": false, "grantsPublicWrite": false }),
1164            &[],
1165        )]);
1166        let out = evaluate_managed_rule(
1167            "S3_BUCKET_PUBLIC_READ_PROHIBITED",
1168            &Value::Null,
1169            &Value::Null,
1170            &acc,
1171        );
1172        assert_eq!(verdict(&out, "priv").as_deref(), Some("COMPLIANT"));
1173        let out = evaluate_managed_rule(
1174            "S3_BUCKET_PUBLIC_WRITE_PROHIBITED",
1175            &Value::Null,
1176            &Value::Null,
1177            &acc,
1178        );
1179        assert_eq!(verdict(&out, "priv").as_deref(), Some("COMPLIANT"));
1180    }
1181
1182    #[test]
1183    fn public_acl_bucket_is_noncompliant() {
1184        let acc = account_with(vec![item(
1185            "AWS::S3::Bucket",
1186            "pub",
1187            json!({ "name": "pub", "grantsPublicRead": true, "grantsPublicWrite": false }),
1188            &[],
1189        )]);
1190        let out = evaluate_managed_rule(
1191            "S3_BUCKET_PUBLIC_READ_PROHIBITED",
1192            &Value::Null,
1193            &Value::Null,
1194            &acc,
1195        );
1196        assert_eq!(verdict(&out, "pub").as_deref(), Some("NON_COMPLIANT"));
1197    }
1198
1199    #[test]
1200    fn spaced_principal_policy_detected_public() {
1201        // The console/SDK emit `"Principal": "*"` with a space; the old
1202        // substring check missed it.
1203        assert!(policy_allows_public_principal(
1204            r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal": "*","Action":"s3:GetObject","Resource":"*"}]}"#
1205        ));
1206        assert!(policy_allows_public_principal(
1207            r#"{"Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"s3:*"}]}"#
1208        ));
1209        assert!(!policy_allows_public_principal(
1210            r#"{"Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123:root"},"Action":"s3:GetObject"}]}"#
1211        ));
1212        // And the rule flags a bucket whose policy uses the spaced form.
1213        let acc = account_with(vec![item(
1214            "AWS::S3::Bucket",
1215            "polpub",
1216            json!({ "name": "polpub", "bucketPolicy": "{\"Statement\":[{\"Effect\":\"Allow\",\"Principal\": \"*\",\"Action\":\"s3:GetObject\"}]}" }),
1217            &[],
1218        )]);
1219        let out = evaluate_managed_rule(
1220            "S3_BUCKET_PUBLIC_READ_PROHIBITED",
1221            &Value::Null,
1222            &Value::Null,
1223            &acc,
1224        );
1225        assert_eq!(verdict(&out, "polpub").as_deref(), Some("NON_COMPLIANT"));
1226    }
1227
1228    #[test]
1229    fn all_ports_open_sg_is_noncompliant_for_ssh() {
1230        // IpProtocol "-1" with -1 port sentinels = all traffic to the world.
1231        let acc = account_with(vec![item(
1232            "AWS::EC2::SecurityGroup",
1233            "sg-all",
1234            json!({
1235                "groupName": "wide-open",
1236                "ipPermissions": [{ "ipProtocol": "-1", "fromPort": -1, "toPort": -1, "ipRanges": ["0.0.0.0/0"] }],
1237            }),
1238            &[],
1239        )]);
1240        let out = evaluate_managed_rule("INCOMING_SSH_DISABLED", &Value::Null, &Value::Null, &acc);
1241        assert_eq!(verdict(&out, "sg-all").as_deref(), Some("NON_COMPLIANT"));
1242
1243        // A narrow non-SSH rule stays compliant for SSH.
1244        let acc2 = account_with(vec![item(
1245            "AWS::EC2::SecurityGroup",
1246            "sg-web",
1247            json!({
1248                "groupName": "web",
1249                "ipPermissions": [{ "ipProtocol": "tcp", "fromPort": 443, "toPort": 443, "ipRanges": ["0.0.0.0/0"] }],
1250            }),
1251            &[],
1252        )]);
1253        let out2 =
1254            evaluate_managed_rule("INCOMING_SSH_DISABLED", &Value::Null, &Value::Null, &acc2);
1255        assert_eq!(verdict(&out2, "sg-web").as_deref(), Some("COMPLIANT"));
1256    }
1257
1258    #[test]
1259    fn required_tags_respects_scope() {
1260        let acc = account_with(vec![
1261            item("AWS::S3::Bucket", "b1", json!({ "name": "b1" }), &[]),
1262            item(
1263                "AWS::EC2::Instance",
1264                "i-1",
1265                json!({ "instanceId": "i-1" }),
1266                &[("Env", "prod")],
1267            ),
1268        ]);
1269        let params = json!({ "tag1Key": "Env" });
1270        let scope = json!({ "ComplianceResourceTypes": ["AWS::S3::Bucket"] });
1271        let out = evaluate_managed_rule("REQUIRED_TAGS", &params, &scope, &acc);
1272        // Only the in-scope bucket is evaluated; the out-of-scope instance is not.
1273        assert_eq!(out.len(), 1);
1274        assert_eq!(out[0].resource_id, "b1");
1275        assert_eq!(out[0].compliance_type, "NON_COMPLIANT"); // bucket lacks the Env tag
1276    }
1277
1278    #[test]
1279    fn externally_recorded_items_are_not_delete_marked() {
1280        // A PutResourceConfig'd item of a native type must survive a sync that
1281        // does not rediscover it (no live S3/EC2/IAM states wired here).
1282        let mut ext = item(
1283            "AWS::S3::Bucket",
1284            "manual",
1285            json!({ "name": "manual" }),
1286            &[],
1287        );
1288        ext.externally_recorded = true;
1289        let mut acc = account_with(vec![ext]);
1290        apply_recorded_items(&mut acc, Vec::new(), "123456789012");
1291        let key = resource_key("AWS::S3::Bucket", "manual");
1292        let last = acc.config_items.get(&key).unwrap().last().unwrap();
1293        assert_ne!(last.configuration_item_status, "ResourceDeleted");
1294    }
1295}