1import * as securityhub from "@distilled.cloud/aws/securityhub";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { createPhysicalName } from "../../PhysicalName.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8
9export interface InsightProps {
10 /**
11 * Name of the insight. If omitted, a unique name is generated. Updatable
12 * in place.
13 */
14 name?: string;
15
16 /**
17 * The finding filters the insight aggregates over, e.g.
18 * `{ SeverityLabel: [{ Value: "CRITICAL", Comparison: "EQUALS" }] }`.
19 * Updatable in place.
20 */
21 filters: securityhub.AwsSecurityFindingFilters;
22
23 /**
24 * The finding attribute results are grouped by, e.g. `ResourceId`,
25 * `SeverityLabel`, `AwsAccountId`. Updatable in place.
26 */
27 groupByAttribute: string;
28}
29
30/** @resource */
31export interface Insight extends Resource<
32 "AWS.SecurityHub.Insight",
33 InsightProps,
34 {
35 /** ARN of the insight (its identity). */
36 insightArn: string;
37 /** Name of the insight. */
38 name: string;
39 /** The attribute results are grouped by. */
40 groupByAttribute: string;
41 },
42 never,
43 Providers
44> {}
45
46/**
47 * A Security Hub custom insight — a saved finding query grouped by an
48 * attribute. Read its aggregated results at runtime with the
49 * {@link GetInsightResults} binding.
50 *
51 * ### Creating an Insight
52 * **Example:** Critical Findings by Resource
53 * ```typescript
54 * const insight = yield* AWS.SecurityHub.Insight("CriticalByResource", {
55 * filters: {
56 * SeverityLabel: [{ Value: "CRITICAL", Comparison: "EQUALS" }],
57 * RecordState: [{ Value: "ACTIVE", Comparison: "EQUALS" }],
58 * },
59 * groupByAttribute: "ResourceId",
60 * });
61 * ```
62 *
63 * **Example:** Read Insight Results at Runtime
64 * ```typescript
65 * const getInsightResults = yield* AWS.SecurityHub.GetInsightResults();
66 * const { InsightResults } = yield* getInsightResults({
67 * InsightArn: insight.insightArn,
68 * });
69 * ```
70 */
71const InsightResource = Resource<Insight>("AWS.SecurityHub.Insight");
72
73export { InsightResource as Insight };
74
75export const InsightProvider = () =>
76 Provider.effect(
77 InsightResource,
78 Effect.gen(function* () {
79 const toName = (id: string, props: { name?: string }) =>
80 props.name
81 ? Effect.succeed(props.name)
82 : createPhysicalName({ id, maxLength: 128 });
83
84 const getInsight = (arn: string) =>
85 securityhub.getInsights({ InsightArns: [arn] }).pipe(
86 Effect.map((r) => r.Insights?.[0]),
87 Effect.catchTag("ResourceNotFoundException", () =>
88 Effect.succeed(undefined),
89 ),
90 // The whole hub may be disabled — the insight is gone too.
91 Effect.catchTag("InvalidAccessException", () =>
92 Effect.succeed(undefined),
93 ),
94 // Security Hub reports an unknown insight ARN as invalid input.
95 Effect.catchTag("InvalidInputException", () =>
96 Effect.succeed(undefined),
97 ),
98 );
99
100 const listInsights = securityhub.getInsights.pages({}).pipe(
101 Stream.runCollect,
102 Effect.map((pages) =>
103 Array.from(pages).flatMap((page) => page.Insights ?? []),
104 ),
105 Effect.catchTag("InvalidAccessException", () =>
106 Effect.succeed([] as securityhub.Insight[]),
107 ),
108 );
109
110 const buildAttrs = (i: securityhub.Insight) => ({
111 insightArn: i.InsightArn!,
112 name: i.Name ?? "",
113 groupByAttribute: i.GroupByAttribute ?? "",
114 });
115
116 return {
117 stables: ["insightArn"],
118 read: Effect.fn(function* ({ id, olds, output }) {
119 if (output?.insightArn) {
120 const live = yield* getInsight(output.insightArn);
121 return live ? buildAttrs(live) : undefined;
122 }
123 // No prior state — find by (deterministic) name. Insights cannot be
124 // tagged; the generated name embeds the app/stage physical-name
125 // hash, so a match is ours.
126 const name = yield* toName(id, olds ?? {});
127 const all = yield* listInsights;
128 const match = all.find((i) => i.Name === name);
129 return match ? buildAttrs(match) : undefined;
130 }),
131 list: () => listInsights.pipe(Effect.map((all) => all.map(buildAttrs))),
132 reconcile: Effect.fn(function* ({ id, news, output, session }) {
133 const name = yield* toName(id, news);
134
135 // 1. OBSERVE — by cached ARN first, then by name.
136 let live: securityhub.Insight | undefined = output?.insightArn
137 ? yield* getInsight(output.insightArn)
138 : undefined;
139 if (!live) {
140 const all = yield* listInsights;
141 live = all.find((i) => i.Name === name);
142 }
143
144 let final: securityhub.Insight;
145 if (!live) {
146 // 2. ENSURE.
147 const { InsightArn } = yield* securityhub.createInsight({
148 Name: name,
149 Filters: news.filters,
150 GroupByAttribute: news.groupByAttribute,
151 });
152 // The read-after-create may lag — fall back to the desired state.
153 final = (yield* getInsight(InsightArn)) ?? {
154 InsightArn,
155 Name: name,
156 Filters: news.filters,
157 GroupByAttribute: news.groupByAttribute,
158 };
159 } else if (
160 live.Name !== name ||
161 live.GroupByAttribute !== news.groupByAttribute ||
162 JSON.stringify(live.Filters) !== JSON.stringify(news.filters)
163 ) {
164 // 3. SYNC — observed ↔ desired.
165 const arn = live.InsightArn!;
166 yield* securityhub.updateInsight({
167 InsightArn: arn,
168 Name: name,
169 Filters: news.filters,
170 GroupByAttribute: news.groupByAttribute,
171 });
172 final = (yield* getInsight(arn)) ?? {
173 InsightArn: arn,
174 Name: name,
175 Filters: news.filters,
176 GroupByAttribute: news.groupByAttribute,
177 };
178 } else {
179 final = live;
180 }
181
182 // 4. RETURN fresh attributes.
183 yield* session.note(final.InsightArn!);
184 return buildAttrs(final);
185 }),
186 delete: Effect.fn(function* ({ output }) {
187 // Idempotent — the insight (or the whole hub) may already be gone.
188 yield* securityhub
189 .deleteInsight({ InsightArn: output.insightArn })
190 .pipe(
191 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
192 Effect.catchTag("InvalidAccessException", () => Effect.void),
193 Effect.catchTag("InvalidInputException", () => Effect.void),
194 );
195 }),
196 };
197 }),
198 );