@aligent/cdk-header-change-detection 1.7.4 → 1.7.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # @aligent/cdk-header-change-detection
2
2
 
3
+ ## 1.7.6
4
+
5
+ ### Patch Changes
6
+
7
+ - [#1622](https://github.com/aligent/cdk-constructs/pull/1622) [`57b9148`](https://github.com/aligent/cdk-constructs/commit/57b9148b8ed80bcc40ee5c7461b353289b87f659) Thanks [@dependabot](https://github.com/apps/dependabot)! - chore(deps-dev): bump the dev-tools group across 1 directory with 4 updates
8
+
9
+ - [#1633](https://github.com/aligent/cdk-constructs/pull/1633) [`9276087`](https://github.com/aligent/cdk-constructs/commit/927608749f1ac2340e0e2f758ba3ccf02e54d405) Thanks [@aikido-autofix](https://github.com/apps/aikido-autofix)! - [Aikido] Fix 5 security issues in yaml, minimatch, ajv
10
+
11
+ - [#1634](https://github.com/aligent/cdk-constructs/pull/1634) [`38d563f`](https://github.com/aligent/cdk-constructs/commit/38d563f2b5d67401b1234736f11cee446e5ae7d7) Thanks [@dependabot](https://github.com/apps/dependabot)! - chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates
12
+
13
+ - [#1635](https://github.com/aligent/cdk-constructs/pull/1635) [`a7baabb`](https://github.com/aligent/cdk-constructs/commit/a7baabbc02ba92979755b5fdff63aade4718c5e6) Thanks [@dependabot](https://github.com/apps/dependabot)! - chore(deps): bump the other-dependencies group across 1 directory with 4 updates
14
+
15
+ - [#1636](https://github.com/aligent/cdk-constructs/pull/1636) [`6805acb`](https://github.com/aligent/cdk-constructs/commit/6805acbb180625de5a494fd2fe11827520a77b76) Thanks [@aikido-autofix](https://github.com/apps/aikido-autofix)! - [Aikido] Fix security issue in aws-cdk-lib via minor version upgrade from 2.235.1 to 2.245.0
16
+
17
+ - [#1644](https://github.com/aligent/cdk-constructs/pull/1644) [`c6ed076`](https://github.com/aligent/cdk-constructs/commit/c6ed076a549842368da74eedb974cdab20764abe) Thanks [@aikido-autofix](https://github.com/apps/aikido-autofix)! - [Aikido] Fix critical issue in axios via minor version upgrade from 1.14.0 to 1.15.0
18
+
19
+ - [#1638](https://github.com/aligent/cdk-constructs/pull/1638) [`7a8d347`](https://github.com/aligent/cdk-constructs/commit/7a8d3470b97fbdc769f18d55ce1c1a35b96cdf18) Thanks [@TheOrangePuff](https://github.com/TheOrangePuff)! - Update constructs peer dependency from ^10.3.0/^10.4.2 to ^10.5.0 to match aws-cdk-lib@2.245.0 requirements
20
+
21
+ ## 1.7.5
22
+
23
+ ### Patch Changes
24
+
25
+ - [#1612](https://github.com/aligent/cdk-constructs/pull/1612) [`a6a99fe`](https://github.com/aligent/cdk-constructs/commit/a6a99fe2be8650b76c2147dcb166fc6891702f03) Thanks [@crispy101](https://github.com/crispy101)! - include lambda function source TS file
26
+
3
27
  ## 1.7.4
4
28
 
5
29
  ### Patch Changes
@@ -0,0 +1,360 @@
1
+ // We know the environment variables will exist so safe to ignore this
2
+ /* eslint-disable @typescript-eslint/no-non-null-assertion */
3
+
4
+ import axios from "axios";
5
+ import {
6
+ DynamoDBClient,
7
+ BatchGetItemCommand,
8
+ BatchGetItemCommandInput,
9
+ KeysAndAttributes,
10
+ UpdateItemCommandInput,
11
+ UpdateItemCommand,
12
+ AttributeValueUpdate,
13
+ UpdateItemCommandOutput,
14
+ } from "@aws-sdk/client-dynamodb";
15
+ import { PublishCommand, PublishInput, SNSClient } from "@aws-sdk/client-sns";
16
+
17
+ const URLS = process.env.URLS;
18
+ const HEADERS = process.env.HEADERS;
19
+ const TABLE = process.env.TABLE!;
20
+
21
+ const config = "";
22
+ const DB_CLIENT = new DynamoDBClient(config);
23
+
24
+ const securityHeaders = HEADERS?.split(",") || [];
25
+
26
+ // Accept status 200 default
27
+ const ACCEPTED_HTTP_STATUS = (process.env.ACCEPTED_HTTP_STATUS || "200")
28
+ .split(",")
29
+ .map(code => parseInt(code.trim(), 10));
30
+
31
+ console.log("ACCEPTED_HTTP_STATUS:", ACCEPTED_HTTP_STATUS);
32
+
33
+ type Headers = Map<string, string | undefined>;
34
+
35
+ // A map of URLs and their headers
36
+ type URLHeaders = Map<string, Headers>;
37
+
38
+ export const handler = async () => {
39
+ const urls = URLS?.split(",") || [];
40
+
41
+ // Fetch stored headers
42
+ const [storedUrlHeaders, currentUrlHeaders] = await Promise.all([
43
+ getStoredValues(urls),
44
+ fetchHeaders(urls),
45
+ ]);
46
+
47
+ // Find any differences between the headers
48
+ const headerDifferences = new Map<string, Difference[]>();
49
+ let differencesDetected = false;
50
+ const dbUpdates = urls.map(url => {
51
+ const currentHeaders = currentUrlHeaders.get(url);
52
+ const storedHeaders =
53
+ storedUrlHeaders.get(url) || new Map<string, string | undefined>();
54
+
55
+ if (!currentHeaders)
56
+ throw new Error(`Could not get current headers for ${url}`);
57
+
58
+ // Check all headers that we care about
59
+ headerDifferences.set(
60
+ url,
61
+ compareHeaders(securityHeaders, storedHeaders, currentHeaders)
62
+ );
63
+
64
+ const headersToUpdate: Headers = new Map<string, string | undefined>();
65
+ headerDifferences.get(url)?.forEach(difference => {
66
+ headersToUpdate.set(difference.header, difference.currentValue);
67
+ differencesDetected = true;
68
+ });
69
+
70
+ return updateStoredValues(url, headersToUpdate);
71
+ });
72
+
73
+ await Promise.all(dbUpdates);
74
+
75
+ if (differencesDetected)
76
+ await sendToSns(formatDifferences(headerDifferences));
77
+ };
78
+
79
+ /**
80
+ * Fetch security headers for the given urls
81
+ *
82
+ * @param urls list of urls to fetch headers from
83
+ */
84
+ const fetchHeaders = async (urls: string[]): Promise<URLHeaders> => {
85
+ const currentUrlHeaders: URLHeaders = new Map<string, Headers>();
86
+
87
+ // Make an axios request for each url
88
+ await Promise.all(
89
+ urls.map(url =>
90
+ axios.get(url, { validateStatus: () => true }).then(response => {
91
+ if (!ACCEPTED_HTTP_STATUS.includes(response.status)) {
92
+ console.warn(
93
+ `Skipping ${url} — status ${response.status} not allowed`
94
+ );
95
+ return;
96
+ }
97
+
98
+ const headers: Headers = new Map<string, string | undefined>();
99
+
100
+ Object.entries(response.headers).forEach(([headerName, value]) => {
101
+ if (securityHeaders?.includes(headerName))
102
+ headers.set(headerName, value as string);
103
+ });
104
+
105
+ currentUrlHeaders.set(url, headers);
106
+ })
107
+ )
108
+ );
109
+
110
+ return currentUrlHeaders;
111
+ };
112
+
113
+ /**
114
+ * Get values stored in DynamoDB table from a list of string keys.
115
+ * Assumes the call is made to the header change detection table.
116
+ * This table has a primary key of Url (string) with an unknown
117
+ * number of string fields.
118
+ *
119
+ * @param keys array of strings
120
+ */
121
+ const getStoredValues = async (keys: string[]): Promise<URLHeaders> => {
122
+ if (keys.length === 0) {
123
+ console.log("No keys were passed");
124
+ return new Map<string, Headers>();
125
+ }
126
+
127
+ // Construct the command input
128
+ const primaryKeys = keys.map(url => {
129
+ return {
130
+ Url: {
131
+ S: url,
132
+ },
133
+ };
134
+ });
135
+ const requestItems = {
136
+ [TABLE]: {
137
+ Keys: primaryKeys,
138
+ },
139
+ };
140
+
141
+ return dynamoBatchRequest(requestItems);
142
+ };
143
+
144
+ /**
145
+ * Update stored headers for the given url.
146
+ * If the header no longer has a value, delete it. Otherwise update it.
147
+ *
148
+ * @param url the url to update - this is the primary key
149
+ * @param headers record of headers to update
150
+ */
151
+ const updateStoredValues = async (
152
+ url: string,
153
+ headers: Headers
154
+ ): Promise<UpdateItemCommandOutput | undefined> => {
155
+ // Convert headers to attribute value update attributes
156
+ const attributes: Record<string, AttributeValueUpdate> = {};
157
+ headers.forEach((value, headerName) => {
158
+ // If the value exists update it, otherwise remove it
159
+ if (value) {
160
+ attributes[headerName] = {
161
+ Value: {
162
+ S: Array.isArray(value) ? value.join("; ") : value,
163
+ },
164
+ Action: "PUT",
165
+ };
166
+ } else {
167
+ attributes[headerName] = {
168
+ Action: "DELETE",
169
+ };
170
+ }
171
+ });
172
+
173
+ if (Object.values(attributes).length === 0) {
174
+ console.log(`No attribute value changes for ${url}`);
175
+ return;
176
+ }
177
+
178
+ return dynamoUpdateRequest(url, attributes);
179
+ };
180
+
181
+ /**
182
+ * Recursive function to get multiple items from a DynamoDB table
183
+ *
184
+ * @param requestItems
185
+ * @returns Promise<URLHeaders>
186
+ */
187
+ const dynamoBatchRequest = async (
188
+ requestItems: Record<string, KeysAndAttributes> | undefined
189
+ ): Promise<URLHeaders> => {
190
+ console.log(
191
+ `Starting batch request with items: ${JSON.stringify(requestItems)}`
192
+ );
193
+
194
+ // Validate that request items has values
195
+ if (Object.keys(requestItems || {})?.length === 0)
196
+ return new Map<string, Headers>();
197
+
198
+ const batchGetInput: BatchGetItemCommandInput = {
199
+ RequestItems: requestItems,
200
+ };
201
+ const batchGetCommand = new BatchGetItemCommand(batchGetInput);
202
+
203
+ // Fetch stored stored headers
204
+ const response = await DB_CLIENT.send(batchGetCommand);
205
+ const responses = response.Responses?.[TABLE];
206
+
207
+ if (!responses) return new Map<string, Headers>();
208
+
209
+ console.log(
210
+ `Got following data from dynamo table: ${JSON.stringify(responses)}`
211
+ );
212
+
213
+ const storedUrlHeaders: URLHeaders = new Map<string, Headers>();
214
+ Object.values(responses).forEach(headers => {
215
+ const urlHeaders: Headers = new Map<string, string | undefined>();
216
+
217
+ let url = "";
218
+ Object.entries(headers).forEach(([headerName, value]) => {
219
+ if (headerName === "Url") {
220
+ url = value.S!;
221
+ } else {
222
+ urlHeaders.set(headerName, value.S!);
223
+ }
224
+ });
225
+ storedUrlHeaders.set(url, urlHeaders);
226
+ });
227
+
228
+ // Process any remaining keys
229
+ const nextUrlHeaders = await dynamoBatchRequest(response.UnprocessedKeys);
230
+
231
+ // Merge data into one object and return
232
+ return new Map<string, Headers>([...storedUrlHeaders, ...nextUrlHeaders]);
233
+ };
234
+
235
+ /**
236
+ * Send an update command to DynamoDB
237
+ *
238
+ * @param url the url to update - this is the primary key
239
+ * @param attributes Record<string, AttributeValueUpdate>
240
+ */
241
+ const dynamoUpdateRequest = async (
242
+ url: string,
243
+ attributes: Record<string, AttributeValueUpdate>
244
+ ): Promise<UpdateItemCommandOutput> => {
245
+ console.log(
246
+ `Updating ${url} in table with: ${JSON.stringify(Object.entries(attributes))}`
247
+ );
248
+
249
+ const updateItemInput: UpdateItemCommandInput = {
250
+ TableName: TABLE,
251
+ Key: {
252
+ Url: {
253
+ S: url,
254
+ },
255
+ },
256
+ AttributeUpdates: attributes,
257
+ };
258
+ const updateItemCommand = new UpdateItemCommand(updateItemInput);
259
+
260
+ return DB_CLIENT.send(updateItemCommand);
261
+ };
262
+
263
+ interface Difference {
264
+ header: string;
265
+ storedValue: string | undefined;
266
+ currentValue: string | undefined;
267
+ }
268
+
269
+ /**
270
+ * Compare values of two lists of headers. Return any headers that have differences
271
+ * along with their stored and current values.
272
+ *
273
+ * @param headers list of headers we want to compare
274
+ * @param stored list of headers that were last found on the site
275
+ * @param current list of headers currently on the site
276
+ * @returns
277
+ */
278
+ const compareHeaders = (
279
+ headers: string[],
280
+ stored: Headers,
281
+ current: Headers
282
+ ): Difference[] => {
283
+ const differences: Difference[] = [];
284
+
285
+ headers.forEach(header => {
286
+ const currentValue = current.get(header);
287
+ const storedValue = stored.get(header);
288
+
289
+ if (currentValue !== storedValue) {
290
+ differences.push({
291
+ header,
292
+ storedValue: storedValue,
293
+ currentValue: currentValue,
294
+ });
295
+ }
296
+ });
297
+
298
+ return differences;
299
+ };
300
+
301
+ /**
302
+ * Format the differences so they can be easily read in an email.
303
+ *
304
+ * Outputs a string that looks like this:
305
+ *
306
+ * Headers differences found:
307
+ * == https://aligent.com.au/ ===
308
+ *
309
+ * Header: example-header-name
310
+ * Stored Value: No stored value
311
+ * Current Value: example-value
312
+ *
313
+ * === https://aligent.com.au/contact ===
314
+ *
315
+ * Header: example-header-name
316
+ * Stored Value: No stored value
317
+ * Current Value: example-value
318
+ *
319
+ * Header: example-header-name-2
320
+ * Stored Value: previous-value
321
+ * Current Value: new-example-value
322
+ *
323
+ * @param differences Map<string, Difference[]> where the key is the URL
324
+ */
325
+ const formatDifferences = (differences: Map<string, Difference[]>): string => {
326
+ const message = Array.from(differences.keys()).reduce((text, url) => {
327
+ console.log(text, url);
328
+ // Skip the url if there are no differences
329
+ if (differences.get(url)?.length === 0) {
330
+ return text;
331
+ }
332
+
333
+ // Format headers nicely
334
+ const headers = differences.get(url)?.reduce((headerText, header) => {
335
+ return (headerText += `\r\nHeader: ${header.header}\r\nStored Value: ${header.storedValue}\r\nCurrent Value: ${header.currentValue}\r\n`);
336
+ }, "");
337
+
338
+ return `${text}\r\n=== ${url} ===\r\n ${headers}`;
339
+ }, "");
340
+
341
+ return `Header differences found${message}`;
342
+ };
343
+
344
+ const TOPIC_ARN = process.env.TOPIC_ARN!;
345
+ const SNS_CLIENT = new SNSClient();
346
+
347
+ /**
348
+ * Send a message to the SNS topic
349
+ *
350
+ * @param message string to send to sns
351
+ */
352
+ const sendToSns = async (message: string) => {
353
+ const publishInput: PublishInput = {
354
+ TopicArn: TOPIC_ARN,
355
+ Message: message,
356
+ Subject: "Security Header Change detected",
357
+ };
358
+ const publishCommand = new PublishCommand(publishInput);
359
+ await SNS_CLIENT.send(publishCommand);
360
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aligent/cdk-header-change-detection",
3
- "version": "1.7.4",
3
+ "version": "1.7.6",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/aligent/cdk-constructs/tree/main/packages/constructs/header-change-detection#readme",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "devDependencies": {
21
21
  "@types/jest": "^29.5.10",
22
- "@types/node": "^20.19.33",
22
+ "@types/node": "^20.19.39",
23
23
  "aws-cdk": "^2.1019.1",
24
24
  "jest": "^29.7.0",
25
25
  "ts-jest": "^29.1.1",
@@ -29,11 +29,11 @@
29
29
  "dependencies": {
30
30
  "@aws-sdk/client-dynamodb": "^3.830.0",
31
31
  "@aws-sdk/client-sns": "3.830.0",
32
- "axios": "^1.8.3",
32
+ "axios": "^1.14.0",
33
33
  "source-map-support": "^0.5.21"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "aws-cdk-lib": "^2.168.0",
37
- "constructs": "^10.4.2"
37
+ "constructs": "^10.5.0"
38
38
  }
39
39
  }