@aligent/cdk-header-change-detection 1.7.2 → 1.7.5
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 +18 -0
- package/README.md +2 -0
- package/lib/lambda/header-check.ts +360 -0
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @aligent/cdk-header-change-detection
|
|
2
2
|
|
|
3
|
+
## 1.7.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#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
|
|
8
|
+
|
|
9
|
+
## 1.7.4
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- [#1606](https://github.com/aligent/cdk-constructs/pull/1606) [`0e35d91`](https://github.com/aligent/cdk-constructs/commit/0e35d91ab5244d90625ebe19d943694af875a422) Thanks [@porhkz](https://github.com/porhkz)! - Update repository URLs in package.json to match npm provenance expectations
|
|
14
|
+
|
|
15
|
+
## 1.7.3
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- [#1601](https://github.com/aligent/cdk-constructs/pull/1601) [`1488e90`](https://github.com/aligent/cdk-constructs/commit/1488e90d7f468f7646142a9968a3d4e06389b358) Thanks [@porhkz](https://github.com/porhkz)! - Fix badges on readmes
|
|
20
|
+
|
|
3
21
|
## 1.7.2
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
+
  
|
|
6
|
+
|
|
5
7
|
Creates a Lambda function that periodically scans security headers and sends the results to SNS.
|
|
6
8
|
|
|
7
9
|
### Diagram
|
|
@@ -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,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aligent/cdk-header-change-detection",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.5",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"homepage": "https://github.com/aligent/
|
|
6
|
+
"homepage": "https://github.com/aligent/cdk-constructs/tree/main/packages/constructs/header-change-detection#readme",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/aligent/
|
|
9
|
+
"url": "git+https://github.com/aligent/cdk-constructs.git"
|
|
10
10
|
},
|
|
11
11
|
"bugs": {
|
|
12
|
-
"url": "https://github.com/aligent/
|
|
12
|
+
"url": "https://github.com/aligent/cdk-constructs/issues"
|
|
13
13
|
},
|
|
14
14
|
"types": "index.d.ts",
|
|
15
15
|
"scripts": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@types/jest": "^29.5.10",
|
|
22
|
-
"@types/node": "^20.
|
|
22
|
+
"@types/node": "^20.19.33",
|
|
23
23
|
"aws-cdk": "^2.1019.1",
|
|
24
24
|
"jest": "^29.7.0",
|
|
25
25
|
"ts-jest": "^29.1.1",
|