@intentius/chant-lexicon-aws 0.45.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/read-client.d.ts +10 -2
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/components/cloud-executor.d.ts.map +1 -1
- package/dist/composites/agentcore-agent.d.ts +26 -1
- package/dist/composites/agentcore-agent.d.ts.map +1 -1
- package/dist/composites/bucket-deployment.d.ts +82 -0
- package/dist/composites/bucket-deployment.d.ts.map +1 -0
- package/dist/composites/dynamodb-table.d.ts +45 -0
- package/dist/composites/dynamodb-table.d.ts.map +1 -0
- package/dist/composites/ec2-instance-bundle.d.ts +61 -0
- package/dist/composites/ec2-instance-bundle.d.ts.map +1 -0
- package/dist/composites/ecr-repository.d.ts +49 -0
- package/dist/composites/ecr-repository.d.ts.map +1 -0
- package/dist/composites/fargate-service.d.ts +16 -0
- package/dist/composites/fargate-service.d.ts.map +1 -1
- package/dist/composites/index.d.ts +16 -2
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/lambda-function.d.ts +4 -4
- package/dist/composites/lambda-function.d.ts.map +1 -1
- package/dist/composites/monitoring-stack.d.ts +99 -0
- package/dist/composites/monitoring-stack.d.ts.map +1 -0
- package/dist/composites/nlb-service.d.ts +48 -0
- package/dist/composites/nlb-service.d.ts.map +1 -0
- package/dist/composites/step-functions-workflow.d.ts +36 -0
- package/dist/composites/step-functions-workflow.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +45 -0
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/deep-topology.d.ts +104 -0
- package/dist/deep-topology.d.ts.map +1 -0
- package/dist/effect-receipt-row.d.ts +92 -0
- package/dist/effect-receipt-row.d.ts.map +1 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +9 -4
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/cf-refs.d.ts +24 -0
- package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/waw059.d.ts +36 -0
- package/dist/lint/post-synth/waw059.d.ts.map +1 -0
- package/dist/lint/post-synth/waw060.d.ts +16 -0
- package/dist/lint/post-synth/waw060.d.ts.map +1 -0
- package/dist/lint/post-synth/waw061.d.ts +19 -0
- package/dist/lint/post-synth/waw061.d.ts.map +1 -0
- package/dist/lint/post-synth/waw062.d.ts +19 -0
- package/dist/lint/post-synth/waw062.d.ts.map +1 -0
- package/dist/lint/post-synth/waw063.d.ts +35 -0
- package/dist/lint/post-synth/waw063.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +5 -0
- package/dist/okf/rules/WAW059.md +25 -0
- package/dist/okf/rules/WAW060.md +17 -0
- package/dist/okf/rules/WAW061.md +17 -0
- package/dist/okf/rules/WAW062.md +27 -0
- package/dist/okf/rules/WAW063.md +19 -0
- package/dist/okf/types/Action.md +2 -0
- package/dist/okf/types/AutoScalingGroup.md +1 -0
- package/dist/okf/types/Bucket.md +2 -0
- package/dist/okf/types/ECRRepository.md +1 -0
- package/dist/okf/types/Function.md +1 -0
- package/dist/okf/types/GlobalTable.md +4 -0
- package/dist/okf/types/IamPolicy.md +3 -0
- package/dist/okf/types/InstanceProfile.md +4 -0
- package/dist/okf/types/LaunchTemplate.md +1 -0
- package/dist/okf/types/LoadBalancer.md +1 -0
- package/dist/okf/types/ManagedPolicy.md +4 -0
- package/dist/okf/types/Map.md +2 -0
- package/dist/okf/types/Queue.md +1 -0
- package/dist/okf/types/Role.md +3 -0
- package/dist/okf/types/SecurityGroup.md +1 -0
- package/dist/okf/types/StateMachine.md +4 -0
- package/dist/okf/types/Subnet.md +4 -0
- package/dist/okf/types/Table.md +2 -0
- package/dist/okf/types/Type.md +5 -0
- package/dist/okf/types/Vpc.md +1 -0
- package/dist/op/activities/aws-apply.d.ts +48 -5
- package/dist/op/activities/aws-apply.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +11 -4
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/ownership.d.ts +18 -0
- package/dist/ownership.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/receipt-store.d.ts +109 -0
- package/dist/receipt-store.d.ts.map +1 -0
- package/dist/rules/cf-refs.ts +62 -0
- package/dist/rules/waw059.ts +353 -0
- package/dist/rules/waw060.ts +91 -0
- package/dist/rules/waw061.ts +67 -0
- package/dist/rules/waw062.ts +0 -0
- package/dist/rules/waw063.ts +0 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-aws-carve-terraform.md +26 -23
- package/dist/teardown.d.ts +85 -0
- package/dist/teardown.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/api/read-client.test.ts +9 -0
- package/src/api/read-client.ts +15 -2
- package/src/components/cloud-executor.ts +10 -1
- package/src/composites/agentcore-agent.ts +35 -13
- package/src/composites/bucket-deployment.test.ts +84 -0
- package/src/composites/bucket-deployment.ts +153 -0
- package/src/composites/composites.test.ts +325 -0
- package/src/composites/dynamodb-table.test.ts +154 -0
- package/src/composites/dynamodb-table.ts +124 -0
- package/src/composites/ec2-instance-bundle.ts +134 -0
- package/src/composites/ecr-repository.test.ts +118 -0
- package/src/composites/ecr-repository.ts +117 -0
- package/src/composites/fargate-service.ts +19 -3
- package/src/composites/index.ts +21 -2
- package/src/composites/monitoring-stack.test.ts +130 -0
- package/src/composites/monitoring-stack.ts +164 -0
- package/src/composites/nlb-service.ts +117 -0
- package/src/composites/step-functions-workflow.ts +127 -0
- package/src/deep-observe.test.ts +201 -1
- package/src/deep-observe.ts +204 -0
- package/src/deep-topology.test.ts +392 -0
- package/src/deep-topology.ts +237 -0
- package/src/effect-receipt-row.test.ts +164 -0
- package/src/effect-receipt-row.ts +147 -0
- package/src/index.ts +28 -7
- package/src/lifecycle-integration.test.ts +4 -0
- package/src/lint/audit-catalog.ts +11 -0
- package/src/lint/post-synth/cf-refs.ts +62 -0
- package/src/lint/post-synth/index.ts +10 -0
- package/src/lint/post-synth/waw059.test.ts +309 -0
- package/src/lint/post-synth/waw059.ts +353 -0
- package/src/lint/post-synth/waw060.test.ts +131 -0
- package/src/lint/post-synth/waw060.ts +91 -0
- package/src/lint/post-synth/waw061.test.ts +99 -0
- package/src/lint/post-synth/waw061.ts +67 -0
- package/src/lint/post-synth/waw062.test.ts +109 -0
- package/src/lint/post-synth/waw062.ts +0 -0
- package/src/lint/post-synth/waw063.test.ts +220 -0
- package/src/lint/post-synth/waw063.ts +0 -0
- package/src/op/activities/aws-apply.test.ts +111 -0
- package/src/op/activities/aws-apply.ts +100 -6
- package/src/op/activities/index.ts +20 -3
- package/src/ownership.test.ts +24 -1
- package/src/ownership.ts +37 -0
- package/src/plugin.ts +59 -10
- package/src/receipt-store.test.ts +204 -0
- package/src/receipt-store.ts +315 -0
- package/src/serializer-ownership.test.ts +18 -0
- package/src/serializer.ts +114 -3
- package/src/skills/chant-aws-carve-terraform.md +26 -23
- package/src/teardown.test.ts +258 -0
- package/src/teardown.ts +276 -0
package/src/deep-observe.test.ts
CHANGED
|
@@ -25,9 +25,10 @@ const {
|
|
|
25
25
|
awsDeepNormalizationHooks,
|
|
26
26
|
hasOwnershipMarker,
|
|
27
27
|
schemaReadOnlyPatterns,
|
|
28
|
+
outOfBandChildName,
|
|
28
29
|
} = await import("./deep-observe");
|
|
29
30
|
const { parseResourceDescription } = await import("./api/read-client");
|
|
30
|
-
const { deepDiffForLexicon } = await import("@intentius/chant/lifecycle/deep-observe");
|
|
31
|
+
const { deepDiffForLexicon, diffDeepObservation } = await import("@intentius/chant/lifecycle/deep-observe");
|
|
31
32
|
const { normalizeDeepObservation, normalizeDeepProperties, flattenDeepProperties } = await import("@intentius/chant/deep-observation");
|
|
32
33
|
|
|
33
34
|
const ok = (text: string) => ({ status: 200, text });
|
|
@@ -598,6 +599,205 @@ describe("observeResourcesDeepAws", () => {
|
|
|
598
599
|
});
|
|
599
600
|
});
|
|
600
601
|
|
|
602
|
+
/**
|
|
603
|
+
* The out-of-band pass (#1015): child resources that exist live under a
|
|
604
|
+
* declared parent and correspond to nothing in the stack, in the estate, or
|
|
605
|
+
* in source — the console-added SNS subscription and inline role policy the
|
|
606
|
+
* issue names. Cloud Control's `ListResources` is scoped to one parent by
|
|
607
|
+
* `ResourceModel`, and everything the listing returns is sorted into
|
|
608
|
+
* declared or out-of-band.
|
|
609
|
+
*/
|
|
610
|
+
describe("out-of-band children (#1015)", () => {
|
|
611
|
+
/** A Cloud Control `ListResources` body — each model a JSON string, like `GetResource`. */
|
|
612
|
+
const list = (descriptions: Array<[string, Record<string, unknown>]>) =>
|
|
613
|
+
ok(
|
|
614
|
+
JSON.stringify({
|
|
615
|
+
ResourceDescriptions: descriptions.map(([id, props]) => ({
|
|
616
|
+
Identifier: id,
|
|
617
|
+
Properties: JSON.stringify(props),
|
|
618
|
+
})),
|
|
619
|
+
}),
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
test("a console-added inline policy surfaces as an entity nobody declared", async () => {
|
|
623
|
+
const fake = httpFake((_identifier, call) => {
|
|
624
|
+
if (!call.target) {
|
|
625
|
+
return stackResources([
|
|
626
|
+
["AppRole", "AWS::IAM::Role", "app-role"],
|
|
627
|
+
// The stack's own inline policy, deployed under its Cloud Control
|
|
628
|
+
// identifier (`PolicyName|RoleName`, the schema's own order).
|
|
629
|
+
["Inline", "AWS::IAM::RolePolicy", "declared-pol|app-role"],
|
|
630
|
+
]);
|
|
631
|
+
}
|
|
632
|
+
if (call.target === "CloudApiService.ListResources") {
|
|
633
|
+
return list([
|
|
634
|
+
["declared-pol|app-role", { RoleName: "app-role", PolicyName: "declared-pol" }],
|
|
635
|
+
[
|
|
636
|
+
"debug-access|app-role",
|
|
637
|
+
{
|
|
638
|
+
RoleName: "app-role",
|
|
639
|
+
PolicyName: "debug-access",
|
|
640
|
+
PolicyDocument: { Statement: [{ Sid: "Debug", Effect: "Allow", Action: ["s3:*"], Resource: "*" }] },
|
|
641
|
+
},
|
|
642
|
+
],
|
|
643
|
+
]);
|
|
644
|
+
}
|
|
645
|
+
return cloudControl("app-role", { RoleName: "app-role" });
|
|
646
|
+
});
|
|
647
|
+
const result = normalizeDeepObservation(
|
|
648
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["AppRole", "Inline"], http: fake.http }),
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
const name = outOfBandChildName("AWS::IAM::RolePolicy", "debug-access|app-role");
|
|
652
|
+
expect(result.resources[name]).toMatchObject({
|
|
653
|
+
type: "AWS::IAM::RolePolicy",
|
|
654
|
+
physicalId: "debug-access|app-role",
|
|
655
|
+
});
|
|
656
|
+
expect(result.resources[name].properties.PolicyName).toBe("debug-access");
|
|
657
|
+
// The stack's own inline policy is declared, not a finding.
|
|
658
|
+
expect(Object.keys(result.resources)).not.toContain(
|
|
659
|
+
outOfBandChildName("AWS::IAM::RolePolicy", "declared-pol|app-role"),
|
|
660
|
+
);
|
|
661
|
+
// The listing was scoped to the parent the stack read resolved.
|
|
662
|
+
const listing = fake.calls.find((c) => c.target === "CloudApiService.ListResources");
|
|
663
|
+
expect(JSON.parse(listing?.body ?? "{}")).toEqual({
|
|
664
|
+
TypeName: "AWS::IAM::RolePolicy",
|
|
665
|
+
ResourceModel: JSON.stringify({ RoleName: "app-role" }),
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test("a console-added SNS subscription surfaces even though the topic has no deep reader", async () => {
|
|
670
|
+
const topicArn = "arn:aws:sns:us-east-1:111122223333:alerts";
|
|
671
|
+
const fake = httpFake((_identifier, call) => {
|
|
672
|
+
if (!call.target) {
|
|
673
|
+
return stackResources([
|
|
674
|
+
["Alerts", "AWS::SNS::Topic", topicArn],
|
|
675
|
+
["Mail", "AWS::SNS::Subscription", `${topicArn}:5d5a2c73`],
|
|
676
|
+
]);
|
|
677
|
+
}
|
|
678
|
+
if (call.target === "CloudApiService.ListResources") {
|
|
679
|
+
return list([
|
|
680
|
+
[`${topicArn}:5d5a2c73`, { TopicArn: topicArn, Protocol: "email" }],
|
|
681
|
+
[`${topicArn}:rogue`, { TopicArn: topicArn, Protocol: "https", Endpoint: "https://n.example" }],
|
|
682
|
+
]);
|
|
683
|
+
}
|
|
684
|
+
return apiError("ValidationException", "nothing else should be read");
|
|
685
|
+
});
|
|
686
|
+
const result = normalizeDeepObservation(
|
|
687
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Alerts", "Mail"], http: fake.http }),
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
// The topic itself stays a hole — no deep reader — but the listing under
|
|
691
|
+
// it needs only the physical id the stack already resolved.
|
|
692
|
+
expect(result.unobserved.Alerts?.reason).toBe("unsupported-kind");
|
|
693
|
+
expect(Object.keys(result.resources)).toEqual([
|
|
694
|
+
outOfBandChildName("AWS::SNS::Subscription", `${topicArn}:rogue`),
|
|
695
|
+
]);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test("a legacy AWS::IAM::Policy attachment is declared, not out-of-band", async () => {
|
|
699
|
+
const fake = httpFake((_identifier, call) => {
|
|
700
|
+
if (!call.target) {
|
|
701
|
+
return stackResources([
|
|
702
|
+
["AppRole", "AWS::IAM::Role", "app-role"],
|
|
703
|
+
// The legacy inline form: its physical id is the policy name, which
|
|
704
|
+
// is the first part of the RolePolicy identifier the listing returns.
|
|
705
|
+
["LegacyPol", "AWS::IAM::Policy", "stack-legacypol-1ABC"],
|
|
706
|
+
]);
|
|
707
|
+
}
|
|
708
|
+
if (call.target === "CloudApiService.ListResources") {
|
|
709
|
+
return list([["stack-legacypol-1ABC|app-role", { RoleName: "app-role", PolicyName: "stack-legacypol-1ABC" }]]);
|
|
710
|
+
}
|
|
711
|
+
return cloudControl("app-role", { RoleName: "app-role" });
|
|
712
|
+
});
|
|
713
|
+
const result = normalizeDeepObservation(
|
|
714
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["AppRole", "LegacyPol"], http: fake.http }),
|
|
715
|
+
);
|
|
716
|
+
expect(Object.keys(result.resources)).toEqual(["AppRole"]);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
test("an identity spelled in source counts as declared before it reaches any stack", async () => {
|
|
720
|
+
// The carve case: the policy is declared, its identity is spelled in
|
|
721
|
+
// props, and no stack carries it yet. Listing it is not a finding.
|
|
722
|
+
const declared = entities({
|
|
723
|
+
AppRole: { entityType: "AWS::IAM::Role", props: { RoleName: "app-role" } },
|
|
724
|
+
Carved: { entityType: "AWS::IAM::RolePolicy", props: { RoleName: "app-role", PolicyName: "carved" } },
|
|
725
|
+
});
|
|
726
|
+
const fake = httpFake((_identifier, call) => {
|
|
727
|
+
if (!call.target) return stackResources([["AppRole", "AWS::IAM::Role", "app-role"]]);
|
|
728
|
+
if (call.target === "CloudApiService.ListResources") {
|
|
729
|
+
return list([["carved|app-role", { RoleName: "app-role", PolicyName: "carved" }]]);
|
|
730
|
+
}
|
|
731
|
+
return cloudControl("app-role", { RoleName: "app-role" });
|
|
732
|
+
});
|
|
733
|
+
const result = normalizeDeepObservation(
|
|
734
|
+
await observeResourcesDeepAws({
|
|
735
|
+
environment: "prod",
|
|
736
|
+
entityNames: ["AppRole", "Carved"],
|
|
737
|
+
entities: declared,
|
|
738
|
+
http: fake.http,
|
|
739
|
+
}),
|
|
740
|
+
);
|
|
741
|
+
expect(Object.keys(result.resources)).toEqual(["AppRole"]);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test("a failed child listing is silence about children, never a fabricated finding", async () => {
|
|
745
|
+
const fake = httpFake((_identifier, call) => {
|
|
746
|
+
if (!call.target) return stackResources([["AppRole", "AWS::IAM::Role", "app-role"]]);
|
|
747
|
+
if (call.target === "CloudApiService.ListResources") {
|
|
748
|
+
// Floci's answer for an operation it does not serve.
|
|
749
|
+
return apiError("UnsupportedOperation", "ListResources is not supported for AWS::IAM::RolePolicy");
|
|
750
|
+
}
|
|
751
|
+
return cloudControl("app-role", { RoleName: "app-role" });
|
|
752
|
+
});
|
|
753
|
+
const result = normalizeDeepObservation(
|
|
754
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["AppRole"], http: fake.http }),
|
|
755
|
+
);
|
|
756
|
+
expect(Object.keys(result.resources)).toEqual(["AppRole"]);
|
|
757
|
+
expect(result.unobserved).toEqual({});
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
test("--owned: a withheld parent keeps its children out of the report too", async () => {
|
|
761
|
+
const fake = httpFake((_identifier, call) => {
|
|
762
|
+
if (!call.target) return stackResources([["AppRole", "AWS::IAM::Role", "app-role"]]);
|
|
763
|
+
if (call.target === "CloudApiService.ListResources") {
|
|
764
|
+
return list([["debug-access|app-role", { RoleName: "app-role", PolicyName: "debug-access" }]]);
|
|
765
|
+
}
|
|
766
|
+
// No ownership tag: the parent is not chant's.
|
|
767
|
+
return cloudControl("app-role", { RoleName: "app-role" });
|
|
768
|
+
});
|
|
769
|
+
const result = normalizeDeepObservation(
|
|
770
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["AppRole"], owned: true, http: fake.http }),
|
|
771
|
+
);
|
|
772
|
+
expect(result.unobserved.AppRole?.reason).toBe("filtered");
|
|
773
|
+
expect(Object.keys(result.resources)).toEqual([]);
|
|
774
|
+
expect(fake.calls.some((c) => c.target === "CloudApiService.ListResources")).toBe(false);
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
test("the deep diff reports the child as an undeclared entity, and the declared estate stays clean", async () => {
|
|
778
|
+
const declared = entities({ AppRole: { entityType: "AWS::IAM::Role", props: { RoleName: "app-role" } } });
|
|
779
|
+
const fake = httpFake((_identifier, call) => {
|
|
780
|
+
if (!call.target) return stackResources([["AppRole", "AWS::IAM::Role", "app-role"]]);
|
|
781
|
+
if (call.target === "CloudApiService.ListResources") {
|
|
782
|
+
return list([["debug-access|app-role", { RoleName: "app-role", PolicyName: "debug-access" }]]);
|
|
783
|
+
}
|
|
784
|
+
return cloudControl("app-role", { RoleName: "app-role" });
|
|
785
|
+
});
|
|
786
|
+
const live = normalizeDeepObservation(
|
|
787
|
+
await observeResourcesDeepAws({
|
|
788
|
+
environment: "prod",
|
|
789
|
+
entityNames: ["AppRole"],
|
|
790
|
+
entities: declared,
|
|
791
|
+
http: fake.http,
|
|
792
|
+
}),
|
|
793
|
+
);
|
|
794
|
+
const result = diffDeepObservation(declared, live, awsDeepNormalizationHooks);
|
|
795
|
+
expect(result.undeclaredEntities).toEqual([outOfBandChildName("AWS::IAM::RolePolicy", "debug-access|app-role")]);
|
|
796
|
+
expect(result.unchanged).toEqual(["AppRole"]);
|
|
797
|
+
expect(result.drifted).toEqual([]);
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
|
|
601
801
|
/**
|
|
602
802
|
* The acceptance test for #1015: the real plugin, a mutated live tree, a
|
|
603
803
|
* baseline, and exactly the genuine drift.
|
package/src/deep-observe.ts
CHANGED
|
@@ -56,10 +56,19 @@ import {
|
|
|
56
56
|
AwsReadError,
|
|
57
57
|
describeStackResources,
|
|
58
58
|
getResource,
|
|
59
|
+
listResources,
|
|
59
60
|
type AwsReadClientOptions,
|
|
60
61
|
type AwsReadHttp,
|
|
62
|
+
type StackResource,
|
|
61
63
|
} from "./api/read-client";
|
|
64
|
+
import { declaredIdentifier } from "./identity-observe";
|
|
62
65
|
import { AWS_TAG_OWNERSHIP_KEYS } from "./ownership";
|
|
66
|
+
import {
|
|
67
|
+
AWS_DEEP_BLIND_SPOTS,
|
|
68
|
+
EC2_TOPOLOGY_GENERATED_NAMES,
|
|
69
|
+
EC2_TOPOLOGY_SERVICE_DEFAULTS,
|
|
70
|
+
EC2_TOPOLOGY_SOURCES,
|
|
71
|
+
} from "./deep-topology";
|
|
63
72
|
import { applyAwsEndpointArgv } from "./components/cloud-executor";
|
|
64
73
|
import { toIngressRules } from "./dependencies";
|
|
65
74
|
import { createRequire } from "node:module";
|
|
@@ -106,6 +115,9 @@ export const DEEP_SOURCES: Record<string, DeepSource> = {
|
|
|
106
115
|
id: "GroupId",
|
|
107
116
|
toModel: securityGroupToModel,
|
|
108
117
|
},
|
|
118
|
+
// The EC2 topology types the folds fold over (#1269), sources and
|
|
119
|
+
// translations alongside their type in ./deep-topology.ts.
|
|
120
|
+
...EC2_TOPOLOGY_SOURCES,
|
|
109
121
|
};
|
|
110
122
|
|
|
111
123
|
/**
|
|
@@ -114,6 +126,74 @@ export const DEEP_SOURCES: Record<string, DeepSource> = {
|
|
|
114
126
|
*/
|
|
115
127
|
export const DEEP_READABLE_TYPES: ReadonlySet<string> = new Set(Object.keys(DEEP_SOURCES));
|
|
116
128
|
|
|
129
|
+
/**
|
|
130
|
+
* A child type this reader enumerates under one declared parent (#1015's
|
|
131
|
+
* out-of-band case) — the console-added SNS subscription or inline role
|
|
132
|
+
* policy that no property diff can see, because it is not a property of
|
|
133
|
+
* anything declared: it is a whole resource CloudFormation never made.
|
|
134
|
+
*
|
|
135
|
+
* Cloud Control's `ListResources` takes the parent as a `ResourceModel`
|
|
136
|
+
* scope, which is what `model` builds from the physical id the stack read
|
|
137
|
+
* already resolved. Every listed child is then sorted into declared (it is
|
|
138
|
+
* some stack resource's physical id, or some declared entity's spelled
|
|
139
|
+
* identity) or out-of-band; the out-of-band ones ride into the observation
|
|
140
|
+
* as live resources nobody declared, which the deep diff reports as
|
|
141
|
+
* undeclared entities.
|
|
142
|
+
*/
|
|
143
|
+
export interface DeepChildSource {
|
|
144
|
+
/** The child's CloudFormation type — what `ListResources` is asked for. */
|
|
145
|
+
childType: string;
|
|
146
|
+
/** The `ResourceModel` scoping the listing to one parent. */
|
|
147
|
+
model: (parentPhysicalId: string) => Record<string, unknown>;
|
|
148
|
+
/**
|
|
149
|
+
* Legacy declared forms of the same live child. `AWS::IAM::Policy` attached
|
|
150
|
+
* to a role creates the same inline policy `AWS::IAM::RolePolicy` lists, but
|
|
151
|
+
* under its own physical id — the policy name, which is the second part of
|
|
152
|
+
* the RolePolicy identifier. Without this leg every legacy inline policy
|
|
153
|
+
* reads as out-of-band on a stack that declared it.
|
|
154
|
+
*/
|
|
155
|
+
declaredVia?: Array<{
|
|
156
|
+
type: string;
|
|
157
|
+
/** The part of the child identifier that carries that type's physical id. */
|
|
158
|
+
part: (identifier: string) => string | undefined;
|
|
159
|
+
}>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Parent type → the child types worth listing under it. Scoped like
|
|
164
|
+
* {@link DEEP_SOURCES}: the high-signal cases first — the issue's own
|
|
165
|
+
* examples — and widened per type, not by wildcard. A child type Cloud
|
|
166
|
+
* Control cannot list parent-scoped does not belong here.
|
|
167
|
+
*/
|
|
168
|
+
export const DEEP_CHILD_SOURCES: Record<string, readonly DeepChildSource[]> = {
|
|
169
|
+
"AWS::IAM::Role": [
|
|
170
|
+
{
|
|
171
|
+
childType: "AWS::IAM::RolePolicy",
|
|
172
|
+
model: (roleName) => ({ RoleName: roleName }),
|
|
173
|
+
// A RolePolicy identifier is `PolicyName|RoleName` — the schema's own
|
|
174
|
+
// primaryIdentifier order, which is what the manifest records and what
|
|
175
|
+
// `declaredIdentifier` joins by. The legacy type's physical id is the
|
|
176
|
+
// policy name, the first part.
|
|
177
|
+
declaredVia: [{ type: "AWS::IAM::Policy", part: (id) => id.split("|")[0] }],
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
"AWS::SNS::Topic": [
|
|
181
|
+
{
|
|
182
|
+
childType: "AWS::SNS::Subscription",
|
|
183
|
+
model: (topicArn) => ({ TopicArn: topicArn }),
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The name an out-of-band child reports under. There is no chant entity name
|
|
190
|
+
* to use — nobody declared it — so the name has to carry the finding on its
|
|
191
|
+
* own: what kind of thing, and which one.
|
|
192
|
+
*/
|
|
193
|
+
export function outOfBandChildName(childType: string, identifier: string): string {
|
|
194
|
+
return `${childType}:${identifier}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
117
197
|
/**
|
|
118
198
|
* `describe-security-groups` -> the `AWS::EC2::SecurityGroup` resource model.
|
|
119
199
|
*
|
|
@@ -205,6 +285,7 @@ export const AWS_SERVICE_DEFAULTS: Record<string, Record<string, unknown>> = {
|
|
|
205
285
|
"SecurityGroupEgress[].CidrIp": "0.0.0.0/0",
|
|
206
286
|
"SecurityGroupEgress[].IpProtocol": "-1",
|
|
207
287
|
},
|
|
288
|
+
...EC2_TOPOLOGY_SERVICE_DEFAULTS,
|
|
208
289
|
};
|
|
209
290
|
|
|
210
291
|
/**
|
|
@@ -218,6 +299,7 @@ export const AWS_SERVICE_DEFAULTS: Record<string, Record<string, unknown>> = {
|
|
|
218
299
|
*/
|
|
219
300
|
export const AWS_GENERATED_NAMES: Record<string, ReadonlySet<string>> = {
|
|
220
301
|
"AWS::EC2::SecurityGroup": new Set(["GroupName"]),
|
|
302
|
+
...EC2_TOPOLOGY_GENERATED_NAMES,
|
|
221
303
|
};
|
|
222
304
|
|
|
223
305
|
/** Stable JSON with sorted keys — the fallback ordering key for a set-like array. */
|
|
@@ -303,6 +385,18 @@ export const awsDeepNormalizationHooks: DeepNormalizationHooks = {
|
|
|
303
385
|
if (schemaReadOnlyPatterns(node.entityType).has(node.pattern)) return true;
|
|
304
386
|
if (AWS_READ_ONLY_NAMES.has(lastSegment(node.pattern))) return true;
|
|
305
387
|
|
|
388
|
+
// A declared property this type's source cannot see (#1269). With nothing
|
|
389
|
+
// live to stand against it would report `value -> <absent>` on every clean
|
|
390
|
+
// apply — a blind spot rendered as drift. Counterpart-gated the other way
|
|
391
|
+
// round: a live tree that does carry the field is still compared.
|
|
392
|
+
if (
|
|
393
|
+
node.side === "declared" &&
|
|
394
|
+
node.counterpart === "absent" &&
|
|
395
|
+
AWS_DEEP_BLIND_SPOTS[node.entityType]?.has(node.pattern)
|
|
396
|
+
) {
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
|
|
306
400
|
// Provider defaults, on the live side only, and only where source is silent
|
|
307
401
|
// about the property. `"unknown"` (a one-sided normalization) never prunes:
|
|
308
402
|
// the reader must not decide this before the declared tree is in hand.
|
|
@@ -606,9 +700,119 @@ export async function observeResourcesDeepAws(
|
|
|
606
700
|
};
|
|
607
701
|
});
|
|
608
702
|
|
|
703
|
+
// The out-of-band pass (#1015): under each declared parent whose type names
|
|
704
|
+
// child sources, list what actually exists and keep what nothing declared.
|
|
705
|
+
// A parent `--owned` withheld keeps its children out too — reporting a
|
|
706
|
+
// foreign resource's children while withholding the resource itself would
|
|
707
|
+
// be the filter half-applied. A parent with no deep reader of its own
|
|
708
|
+
// (`unsupported-kind`) still gets its children enumerated: listing under it
|
|
709
|
+
// needs only the physical id the stack already resolved.
|
|
710
|
+
const parents: Array<{ resource: StackResource; sources: readonly DeepChildSource[] }> = [];
|
|
711
|
+
for (const entityName of options.entityNames) {
|
|
712
|
+
const resource = byLogicalId.get(entityName);
|
|
713
|
+
const sources = resource ? DEEP_CHILD_SOURCES[resource.type] : undefined;
|
|
714
|
+
if (!resource?.physicalId || !sources) continue;
|
|
715
|
+
if (unobserved[entityName]?.reason === "filtered") continue;
|
|
716
|
+
parents.push({ resource, sources });
|
|
717
|
+
}
|
|
718
|
+
const children = await enumerateOutOfBandChildren({
|
|
719
|
+
parents,
|
|
720
|
+
allStackResources: stackResources,
|
|
721
|
+
entities: options.entities,
|
|
722
|
+
client,
|
|
723
|
+
normalize: (childType, properties) =>
|
|
724
|
+
normalizeDeepProperties(properties, {
|
|
725
|
+
entityType: childType,
|
|
726
|
+
side: "live",
|
|
727
|
+
hooks: awsDeepNormalizationHooks,
|
|
728
|
+
}),
|
|
729
|
+
concurrently: boundedConcurrently,
|
|
730
|
+
});
|
|
731
|
+
for (const [name, child] of Object.entries(children)) {
|
|
732
|
+
if (!resources[name]) resources[name] = child;
|
|
733
|
+
}
|
|
734
|
+
|
|
609
735
|
return deepObservation(resources, unobserved);
|
|
610
736
|
}
|
|
611
737
|
|
|
738
|
+
/**
|
|
739
|
+
* List every child-sourced type under its declared parents and return the
|
|
740
|
+
* resources nothing declared, named by {@link outOfBandChildName}.
|
|
741
|
+
*
|
|
742
|
+
* "Declared" is answered three ways, in order of how directly the stack says
|
|
743
|
+
* it: the child identifier is some stack resource's physical id (the normal
|
|
744
|
+
* case — a declared `AWS::SNS::Subscription` deploys under its ARN); a
|
|
745
|
+
* `declaredVia` legacy form's physical id is the identifier's relevant part;
|
|
746
|
+
* or a declared entity's props spell the identifier (`declaredIdentifier`,
|
|
747
|
+
* the carve case — declared in source, not yet in any stack).
|
|
748
|
+
*
|
|
749
|
+
* A listing that fails is silence about that parent's children, never a
|
|
750
|
+
* finding and never a hole: there is no declared entity to hang a hole on,
|
|
751
|
+
* and fabricating one would put an undeclared name in the unobserved report.
|
|
752
|
+
* `UnsupportedOperation` — an emulator without parent-scoped listing — lands
|
|
753
|
+
* here too, which is what keeps the Floci lanes quiet.
|
|
754
|
+
*/
|
|
755
|
+
async function enumerateOutOfBandChildren(args: {
|
|
756
|
+
parents: Array<{ resource: StackResource; sources: readonly DeepChildSource[] }>;
|
|
757
|
+
allStackResources: StackResource[];
|
|
758
|
+
entities?: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
759
|
+
client: AwsReadClientOptions;
|
|
760
|
+
normalize: (childType: string, properties: Record<string, unknown>) => Record<string, unknown>;
|
|
761
|
+
concurrently: <T>(items: T[], fn: (item: T) => Promise<void>) => Promise<unknown>;
|
|
762
|
+
}): Promise<Record<string, DeepResourceObservation>> {
|
|
763
|
+
const out: Record<string, DeepResourceObservation> = {};
|
|
764
|
+
if (args.parents.length === 0) return out;
|
|
765
|
+
|
|
766
|
+
// Physical ids by type, over the WHOLE stack — a stack resource that is not
|
|
767
|
+
// a declared chant entity (a nested stack's row, a resource another tool
|
|
768
|
+
// added to the same stack) is still stack-managed, not out-of-band.
|
|
769
|
+
const addTo = (map: Map<string, Set<string>>, key: string, value: string) => {
|
|
770
|
+
const set = map.get(key) ?? new Set<string>();
|
|
771
|
+
set.add(value);
|
|
772
|
+
map.set(key, set);
|
|
773
|
+
};
|
|
774
|
+
const stackIdsByType = new Map<string, Set<string>>();
|
|
775
|
+
for (const r of args.allStackResources) {
|
|
776
|
+
if (r.physicalId) addTo(stackIdsByType, r.type, r.physicalId);
|
|
777
|
+
}
|
|
778
|
+
const declaredIdsByType = new Map<string, Set<string>>();
|
|
779
|
+
for (const entity of args.entities?.values() ?? []) {
|
|
780
|
+
const id = declaredIdentifier(entity.entityType, entity.props ?? {});
|
|
781
|
+
if (id) addTo(declaredIdsByType, entity.entityType, id);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const isDeclared = (source: DeepChildSource, identifier: string): boolean => {
|
|
785
|
+
if (stackIdsByType.get(source.childType)?.has(identifier)) return true;
|
|
786
|
+
if (declaredIdsByType.get(source.childType)?.has(identifier)) return true;
|
|
787
|
+
for (const via of source.declaredVia ?? []) {
|
|
788
|
+
const part = via.part(identifier);
|
|
789
|
+
if (part && stackIdsByType.get(via.type)?.has(part)) return true;
|
|
790
|
+
}
|
|
791
|
+
return false;
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
const listings = args.parents.flatMap(({ resource, sources }) =>
|
|
795
|
+
sources.map((source) => ({ physicalId: resource.physicalId as string, source })),
|
|
796
|
+
);
|
|
797
|
+
await args.concurrently(listings, async ({ physicalId, source }) => {
|
|
798
|
+
let listed;
|
|
799
|
+
try {
|
|
800
|
+
listed = await listResources(source.childType, args.client, source.model(physicalId));
|
|
801
|
+
} catch {
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
for (const child of listed) {
|
|
805
|
+
if (!child.identifier || isDeclared(source, child.identifier)) continue;
|
|
806
|
+
out[outOfBandChildName(source.childType, child.identifier)] = {
|
|
807
|
+
type: source.childType,
|
|
808
|
+
physicalId: child.identifier,
|
|
809
|
+
properties: args.normalize(source.childType, child.properties),
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
return out;
|
|
814
|
+
}
|
|
815
|
+
|
|
612
816
|
/**
|
|
613
817
|
* Read every `ec2`-sourced type in bulk, keyed by physical id.
|
|
614
818
|
*
|