1use 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#[derive(Clone, Default)]
24pub struct CrossServiceStates {
25 pub s3: Option<SharedS3State>,
26 pub iam: Option<SharedIamState>,
27 pub ec2: Option<SharedEc2State>,
28}
29
30pub 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
41pub 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 pub configuration: Value,
53}
54
55pub 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
68pub 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 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
341fn discovered_config(res: &DiscoveredResource) -> String {
345 serde_json::to_string(&res.configuration).unwrap_or_else(|_| "{}".into())
346}
347
348pub 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 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
392pub 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 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
471pub struct RuleOutcome {
475 pub resource_type: String,
476 pub resource_id: String,
477 pub compliance_type: String,
478 pub annotation: Option<String>,
479}
480
481pub fn evaluate_managed_rule(
486 source_identifier: &str,
487 input_parameters: &Value,
488 scope: &Value,
489 account: &AccountState,
490) -> Vec<RuleOutcome> {
491 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 let pab = c
554 .get("publicAccessBlockConfiguration")
555 .map(|v| !v.is_null())
556 .unwrap_or(false);
557 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 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 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
762fn is_public_grantee_uri(uri: Option<&str>) -> bool {
765 uri.map(|u| u.contains("AllUsers") || u.contains("AuthenticatedUsers"))
766 .unwrap_or(false)
767}
768
769fn 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
812fn 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
862pub 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
877pub 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, ¶ms, &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
943pub 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 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 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 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 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 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 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 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", ¶ms, &scope, &acc);
1272 assert_eq!(out.len(), 1);
1274 assert_eq!(out[0].resource_id, "b1");
1275 assert_eq!(out[0].compliance_type, "NON_COMPLIANT"); }
1277
1278 #[test]
1279 fn externally_recorded_items_are_not_delete_marked() {
1280 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}