@infrascan/s3-connector 0.1.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/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @infrascan/s3-connector
2
+
3
+ Infrascan connectors act as the plumbing between the Infrascan SDK and wherever you persist your scan state.
4
+
5
+ The S3 connector uses an AWS S3 Bucket to store its state. This connector library exposes an S3Connector class as the default export, which wraps an cache internally to reduce the overhead of S3 reads.
6
+
7
+ All state files are written into the bucket in the format: /service-function/account/region.json.
8
+ This is to support both searching state globally and reading state by function call per region.
9
+
10
+ ## Quickstart
11
+
12
+ ```js
13
+ import { fromIni } from "@aws-sdk/credential-providers";
14
+ import { S3Client } from "@aws-sdk/s3-client";
15
+ import Infrascan from "@infrascan/sdk";
16
+ import S3Connector from "@infrascan/s3-connector";
17
+ import { registerAwsScanners } from "@infrascan/aws";
18
+
19
+ const credentials = fromIni({ profile: "dev" });
20
+ const s3Client = new S3Client({ credentials });
21
+ const connector = new S3Connector({
22
+ S3: s3Client,
23
+ prefix: 'my-scan-tenant',
24
+ bucket: 'my-scan-output-bucket'
25
+ });
26
+ const infrascan = registerAwsScanners(new Infrascan());
27
+
28
+ infrascan.performScan(
29
+ credentials,
30
+ connector
31
+ ).then(function (scanMetadata) {
32
+ console.log("Scan Complete!", scanMetadata);
33
+ return infrascan.generateGraph(
34
+ scanMetadata,
35
+ connector
36
+ );
37
+ }).then(function (graphData) {
38
+ console.log("Graph generated!", graphData);
39
+ }).catch(function (err) {
40
+ console.error("Failed to scan", err);
41
+ });
42
+ ```
@@ -0,0 +1,47 @@
1
+ import { S3Client, PutObjectCommandOutput } from '@aws-sdk/client-s3';
2
+
3
+ declare class Cache<T> {
4
+ sizeLimit: number;
5
+ size: number;
6
+ state: Record<string, T>;
7
+ keyOrders: Array<string>;
8
+ constructor(maxSize: number);
9
+ set(key: string, val: T): void;
10
+ get(key: string): T | undefined;
11
+ has(key: string): boolean;
12
+ }
13
+
14
+ interface ConstructorArgs {
15
+ S3: S3Client;
16
+ prefix: string;
17
+ bucket: string;
18
+ cacheLimit?: number;
19
+ }
20
+ declare class S3Connector {
21
+ S3: S3Client;
22
+ prefix: string;
23
+ bucket: string;
24
+ cache: Cache<any>;
25
+ constructor({ S3, prefix, bucket, cacheLimit }: ConstructorArgs);
26
+ recordServiceCall(filePath: string, state: any): Promise<PutObjectCommandOutput>;
27
+ readStateForCall(filePath: string): Promise<any>;
28
+ /**
29
+ * Format for file paths is somewhat unintuitive — the account and region are put at the end of the path
30
+ * This makes it easier for retrieving global state by service and function call where only list by prefix is supported (e.g. S3)
31
+ * Example:
32
+ * - /1234567890/us-east-1/Lambda/listFunctions.json cannot be globally listed by prefix, only by account and region
33
+ * - /Lambda-listFunctions/1234567890/us-east-1.json can be listed by function which allows for cross account resolution
34
+ */
35
+ buildFilePathForServiceCall(account: string, region: string, service: string, functionName: string): string;
36
+ onServiceScanCompleteCallback(account: string, region: string, service: string, functionName: string, functionState: any): Promise<PutObjectCommandOutput>;
37
+ resolveStateForServiceFunction(account: string, region: string, service: string, functionCall: string): Promise<any>;
38
+ listObjectsForService(service: string, functionCall: string): Promise<Array<string>>;
39
+ getGlobalStateForServiceFunction(service: string, functionCall: string): Promise<Array<any | undefined>>;
40
+ getMetadataPath(): string;
41
+ writeMetadata(metadata: any): Promise<PutObjectCommandOutput>;
42
+ readMetadata(): Promise<any>;
43
+ getGraphPath(): string;
44
+ writeGraphData(graphBlob: any): Promise<PutObjectCommandOutput>;
45
+ }
46
+
47
+ export { ConstructorArgs, S3Connector as default };
@@ -0,0 +1,47 @@
1
+ import { S3Client, PutObjectCommandOutput } from '@aws-sdk/client-s3';
2
+
3
+ declare class Cache<T> {
4
+ sizeLimit: number;
5
+ size: number;
6
+ state: Record<string, T>;
7
+ keyOrders: Array<string>;
8
+ constructor(maxSize: number);
9
+ set(key: string, val: T): void;
10
+ get(key: string): T | undefined;
11
+ has(key: string): boolean;
12
+ }
13
+
14
+ interface ConstructorArgs {
15
+ S3: S3Client;
16
+ prefix: string;
17
+ bucket: string;
18
+ cacheLimit?: number;
19
+ }
20
+ declare class S3Connector {
21
+ S3: S3Client;
22
+ prefix: string;
23
+ bucket: string;
24
+ cache: Cache<any>;
25
+ constructor({ S3, prefix, bucket, cacheLimit }: ConstructorArgs);
26
+ recordServiceCall(filePath: string, state: any): Promise<PutObjectCommandOutput>;
27
+ readStateForCall(filePath: string): Promise<any>;
28
+ /**
29
+ * Format for file paths is somewhat unintuitive — the account and region are put at the end of the path
30
+ * This makes it easier for retrieving global state by service and function call where only list by prefix is supported (e.g. S3)
31
+ * Example:
32
+ * - /1234567890/us-east-1/Lambda/listFunctions.json cannot be globally listed by prefix, only by account and region
33
+ * - /Lambda-listFunctions/1234567890/us-east-1.json can be listed by function which allows for cross account resolution
34
+ */
35
+ buildFilePathForServiceCall(account: string, region: string, service: string, functionName: string): string;
36
+ onServiceScanCompleteCallback(account: string, region: string, service: string, functionName: string, functionState: any): Promise<PutObjectCommandOutput>;
37
+ resolveStateForServiceFunction(account: string, region: string, service: string, functionCall: string): Promise<any>;
38
+ listObjectsForService(service: string, functionCall: string): Promise<Array<string>>;
39
+ getGlobalStateForServiceFunction(service: string, functionCall: string): Promise<Array<any | undefined>>;
40
+ getMetadataPath(): string;
41
+ writeMetadata(metadata: any): Promise<PutObjectCommandOutput>;
42
+ readMetadata(): Promise<any>;
43
+ getGraphPath(): string;
44
+ writeGraphData(graphBlob: any): Promise<PutObjectCommandOutput>;
45
+ }
46
+
47
+ export { ConstructorArgs, S3Connector as default };
package/dist/index.js ADDED
@@ -0,0 +1,153 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ default: () => S3Connector
24
+ });
25
+ module.exports = __toCommonJS(src_exports);
26
+ var import_client_s3 = require("@aws-sdk/client-s3");
27
+
28
+ // src/cache.ts
29
+ var Cache = class {
30
+ constructor(maxSize) {
31
+ if (maxSize < 1) {
32
+ throw new Error("Cannot create a cache with a size less than 1");
33
+ }
34
+ this.sizeLimit = maxSize;
35
+ this.size = 0;
36
+ this.state = {};
37
+ this.keyOrders = [];
38
+ }
39
+ set(key, val) {
40
+ if (this.size === this.sizeLimit) {
41
+ const keyToRemove = this.keyOrders.shift();
42
+ delete this.state[keyToRemove];
43
+ this.size -= 1;
44
+ }
45
+ this.keyOrders.push(key);
46
+ this.state[key] = val;
47
+ this.size += 1;
48
+ }
49
+ get(key) {
50
+ return this.state[key];
51
+ }
52
+ has(key) {
53
+ return this.state[key] != null;
54
+ }
55
+ };
56
+
57
+ // src/index.ts
58
+ var isNotNull = (value) => value != null;
59
+ var S3Connector = class {
60
+ constructor({ S3, prefix, bucket, cacheLimit }) {
61
+ this.S3 = S3;
62
+ this.prefix = prefix;
63
+ this.bucket = bucket;
64
+ this.cache = new Cache(cacheLimit ?? 10);
65
+ }
66
+ async recordServiceCall(filePath, state) {
67
+ const putObjectCmd = new import_client_s3.PutObjectCommand({
68
+ Bucket: this.bucket,
69
+ Key: filePath,
70
+ Body: JSON.stringify(state)
71
+ });
72
+ const putObjectResponse = await this.S3.send(putObjectCmd);
73
+ this.cache.set(filePath, state);
74
+ return putObjectResponse;
75
+ }
76
+ async readStateForCall(filePath) {
77
+ if (this.cache.has(filePath)) {
78
+ return this.cache.get(filePath);
79
+ }
80
+ const getStateCommand = new import_client_s3.GetObjectCommand({
81
+ Bucket: this.bucket,
82
+ Key: filePath
83
+ });
84
+ const rawState = await this.S3.send(getStateCommand);
85
+ const state = await rawState?.Body?.transformToString("utf8");
86
+ return state ? JSON.parse(state) : void 0;
87
+ }
88
+ /**
89
+ * Format for file paths is somewhat unintuitive — the account and region are put at the end of the path
90
+ * This makes it easier for retrieving global state by service and function call where only list by prefix is supported (e.g. S3)
91
+ * Example:
92
+ * - /1234567890/us-east-1/Lambda/listFunctions.json cannot be globally listed by prefix, only by account and region
93
+ * - /Lambda-listFunctions/1234567890/us-east-1.json can be listed by function which allows for cross account resolution
94
+ */
95
+ buildFilePathForServiceCall(account, region, service, functionName) {
96
+ return `${this.prefix}/${service}-${functionName}/${account}/${region}.json`;
97
+ }
98
+ onServiceScanCompleteCallback(account, region, service, functionName, functionState) {
99
+ const filePath = this.buildFilePathForServiceCall(
100
+ account,
101
+ region,
102
+ service,
103
+ functionName
104
+ );
105
+ return this.recordServiceCall(filePath, functionState);
106
+ }
107
+ resolveStateForServiceFunction(account, region, service, functionCall) {
108
+ const filePath = this.buildFilePathForServiceCall(
109
+ account,
110
+ region,
111
+ service,
112
+ functionCall
113
+ );
114
+ return this.readStateForCall(filePath);
115
+ }
116
+ async listObjectsForService(service, functionCall) {
117
+ const servicePrefix = `${this.prefix}/${service}-${functionCall}`;
118
+ const listObjectCommand = new import_client_s3.ListObjectsV2Command({
119
+ Bucket: this.bucket,
120
+ Prefix: servicePrefix
121
+ });
122
+ const response = await this.S3.send(listObjectCommand);
123
+ return response?.Contents?.map(({ Key }) => Key).filter(isNotNull) ?? [];
124
+ }
125
+ async getGlobalStateForServiceFunction(service, functionCall) {
126
+ const allKeys = await this.listObjectsForService(service, functionCall);
127
+ let globalState = [];
128
+ for (let idx = 0; idx < allKeys.length; idx += 1) {
129
+ const serviceState = await this.readStateForCall(allKeys[idx]);
130
+ if (serviceState != null) {
131
+ globalState = globalState.concat(serviceState);
132
+ } else {
133
+ console.log("No state returned for", allKeys[idx]);
134
+ }
135
+ }
136
+ return globalState;
137
+ }
138
+ getMetadataPath() {
139
+ return `${this.prefix}/metadata.json`;
140
+ }
141
+ writeMetadata(metadata) {
142
+ return this.recordServiceCall(this.getMetadataPath(), metadata);
143
+ }
144
+ readMetadata() {
145
+ return this.readStateForCall(this.getMetadataPath());
146
+ }
147
+ getGraphPath() {
148
+ return `${this.prefix}/graph.json`;
149
+ }
150
+ writeGraphData(graphBlob) {
151
+ return this.recordServiceCall(this.getGraphPath(), graphBlob);
152
+ }
153
+ };
package/dist/index.mjs ADDED
@@ -0,0 +1,136 @@
1
+ // src/index.ts
2
+ import {
3
+ PutObjectCommand,
4
+ GetObjectCommand,
5
+ ListObjectsV2Command
6
+ } from "@aws-sdk/client-s3";
7
+
8
+ // src/cache.ts
9
+ var Cache = class {
10
+ constructor(maxSize) {
11
+ if (maxSize < 1) {
12
+ throw new Error("Cannot create a cache with a size less than 1");
13
+ }
14
+ this.sizeLimit = maxSize;
15
+ this.size = 0;
16
+ this.state = {};
17
+ this.keyOrders = [];
18
+ }
19
+ set(key, val) {
20
+ if (this.size === this.sizeLimit) {
21
+ const keyToRemove = this.keyOrders.shift();
22
+ delete this.state[keyToRemove];
23
+ this.size -= 1;
24
+ }
25
+ this.keyOrders.push(key);
26
+ this.state[key] = val;
27
+ this.size += 1;
28
+ }
29
+ get(key) {
30
+ return this.state[key];
31
+ }
32
+ has(key) {
33
+ return this.state[key] != null;
34
+ }
35
+ };
36
+
37
+ // src/index.ts
38
+ var isNotNull = (value) => value != null;
39
+ var S3Connector = class {
40
+ constructor({ S3, prefix, bucket, cacheLimit }) {
41
+ this.S3 = S3;
42
+ this.prefix = prefix;
43
+ this.bucket = bucket;
44
+ this.cache = new Cache(cacheLimit ?? 10);
45
+ }
46
+ async recordServiceCall(filePath, state) {
47
+ const putObjectCmd = new PutObjectCommand({
48
+ Bucket: this.bucket,
49
+ Key: filePath,
50
+ Body: JSON.stringify(state)
51
+ });
52
+ const putObjectResponse = await this.S3.send(putObjectCmd);
53
+ this.cache.set(filePath, state);
54
+ return putObjectResponse;
55
+ }
56
+ async readStateForCall(filePath) {
57
+ if (this.cache.has(filePath)) {
58
+ return this.cache.get(filePath);
59
+ }
60
+ const getStateCommand = new GetObjectCommand({
61
+ Bucket: this.bucket,
62
+ Key: filePath
63
+ });
64
+ const rawState = await this.S3.send(getStateCommand);
65
+ const state = await rawState?.Body?.transformToString("utf8");
66
+ return state ? JSON.parse(state) : void 0;
67
+ }
68
+ /**
69
+ * Format for file paths is somewhat unintuitive — the account and region are put at the end of the path
70
+ * This makes it easier for retrieving global state by service and function call where only list by prefix is supported (e.g. S3)
71
+ * Example:
72
+ * - /1234567890/us-east-1/Lambda/listFunctions.json cannot be globally listed by prefix, only by account and region
73
+ * - /Lambda-listFunctions/1234567890/us-east-1.json can be listed by function which allows for cross account resolution
74
+ */
75
+ buildFilePathForServiceCall(account, region, service, functionName) {
76
+ return `${this.prefix}/${service}-${functionName}/${account}/${region}.json`;
77
+ }
78
+ onServiceScanCompleteCallback(account, region, service, functionName, functionState) {
79
+ const filePath = this.buildFilePathForServiceCall(
80
+ account,
81
+ region,
82
+ service,
83
+ functionName
84
+ );
85
+ return this.recordServiceCall(filePath, functionState);
86
+ }
87
+ resolveStateForServiceFunction(account, region, service, functionCall) {
88
+ const filePath = this.buildFilePathForServiceCall(
89
+ account,
90
+ region,
91
+ service,
92
+ functionCall
93
+ );
94
+ return this.readStateForCall(filePath);
95
+ }
96
+ async listObjectsForService(service, functionCall) {
97
+ const servicePrefix = `${this.prefix}/${service}-${functionCall}`;
98
+ const listObjectCommand = new ListObjectsV2Command({
99
+ Bucket: this.bucket,
100
+ Prefix: servicePrefix
101
+ });
102
+ const response = await this.S3.send(listObjectCommand);
103
+ return response?.Contents?.map(({ Key }) => Key).filter(isNotNull) ?? [];
104
+ }
105
+ async getGlobalStateForServiceFunction(service, functionCall) {
106
+ const allKeys = await this.listObjectsForService(service, functionCall);
107
+ let globalState = [];
108
+ for (let idx = 0; idx < allKeys.length; idx += 1) {
109
+ const serviceState = await this.readStateForCall(allKeys[idx]);
110
+ if (serviceState != null) {
111
+ globalState = globalState.concat(serviceState);
112
+ } else {
113
+ console.log("No state returned for", allKeys[idx]);
114
+ }
115
+ }
116
+ return globalState;
117
+ }
118
+ getMetadataPath() {
119
+ return `${this.prefix}/metadata.json`;
120
+ }
121
+ writeMetadata(metadata) {
122
+ return this.recordServiceCall(this.getMetadataPath(), metadata);
123
+ }
124
+ readMetadata() {
125
+ return this.readStateForCall(this.getMetadataPath());
126
+ }
127
+ getGraphPath() {
128
+ return `${this.prefix}/graph.json`;
129
+ }
130
+ writeGraphData(graphBlob) {
131
+ return this.recordServiceCall(this.getGraphPath(), graphBlob);
132
+ }
133
+ };
134
+ export {
135
+ S3Connector as default
136
+ };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@infrascan/s3-connector",
3
+ "version": "0.1.0",
4
+ "description": "Connector for the Infrascan SDK to read and write state to an S3 bucket",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "require": "./dist/index.js",
11
+ "import": "./dist/index.mjs",
12
+ "types": "./dist/index.d.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "rm -rf dist ; tsup",
20
+ "prepublish": "rm -rf dist ; tsup",
21
+ "lint": "tsc --noEmit ; eslint .",
22
+ "clean": "rm -rf dist node_modules",
23
+ "test": "tap test/*.test.ts --allow-incomplete-coverage -- --typescript"
24
+ },
25
+ "tsup": {
26
+ "clean": true,
27
+ "cjsInterop": true,
28
+ "splitting": false,
29
+ "dts": true,
30
+ "entry": [
31
+ "src/index.ts"
32
+ ],
33
+ "format": [
34
+ "esm",
35
+ "cjs"
36
+ ],
37
+ "target": "node18"
38
+ },
39
+ "keywords": [
40
+ "AWS",
41
+ "Infrastructure Diagram",
42
+ "Infrastructure Graph",
43
+ "Cloud Infrastructure",
44
+ "Connector"
45
+ ],
46
+ "author": "Liam Farrelly <liam@l3f7.dev> (https://whoisli.am/)",
47
+ "license": "MPL-2.0",
48
+ "devDependencies": {
49
+ "@aws-sdk/util-stream-node": "^3.374.0",
50
+ "@infrascan/shared-types": "^0.2.2",
51
+ "aws-sdk-client-mock": "^3.0.0",
52
+ "eslint-config-custom": "*",
53
+ "tap": "^18.5.7",
54
+ "tsconfig": "*",
55
+ "typescript": "^5.1.6"
56
+ },
57
+ "dependencies": {
58
+ "@aws-sdk/client-s3": "3.428.0",
59
+ "minimatch": "^9.0.3"
60
+ }
61
+ }