├── .eslintrc.json ├── .github └── workflows │ ├── package.yml │ └── release.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── COLLAB_MODE.md ├── README.md ├── images ├── animation-diagram.gif ├── doyensec_logo.png ├── feature-comments.png ├── feature-diagram.png ├── feature-findings.png ├── feature-poi.png ├── logo-1.png ├── supported-lang-csp.png └── threadExample.png ├── inactive_rules ├── hcl-aws-dynamodb.yaml ├── hcl-aws-s3-bucket.yaml ├── hcl-aws-sns.yaml └── hcl-aws-sqs.yaml ├── media ├── infrastructure.svg ├── refresh-dark.svg ├── refresh-light.svg ├── remove-dark.svg └── remove-light.svg ├── package-lock.json ├── package.json ├── res ├── iacView.css ├── iacView.js ├── poiView.css └── poiView.js ├── rules ├── boto3-put_records.yaml ├── boto3-s3_put_obj.yaml ├── go-aws-generic1.yaml ├── go-awsv1-generic1.yaml ├── js-gcp-generic1.yaml ├── js-generic1.js ├── js-generic1.yaml ├── python-aws-generic1.yaml ├── python-aws-generic2.yaml ├── python-gcp-generic1.yaml └── ts-generic1.yaml ├── src ├── anchor.ts ├── comments.ts ├── constants.ts ├── db.ts ├── diagnostics.ts ├── encryption.ts ├── extension.ts ├── iacview.ts ├── poiview.ts ├── projects.ts ├── remote.ts ├── semgrep.ts ├── test │ ├── runTest.ts │ └── suite │ │ ├── extension.test.ts │ │ └── index.ts ├── tree.ts └── util.ts ├── tfparse_rules └── hcl-resource.yaml └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "plugins": [ 9 | "@typescript-eslint" 10 | ], 11 | "rules": { 12 | "@typescript-eslint/naming-convention": "warn", 13 | "@typescript-eslint/semi": "warn", 14 | "curly": "warn", 15 | "eqeqeq": "warn", 16 | "no-throw-literal": "warn", 17 | "semi": "off" 18 | }, 19 | "ignorePatterns": [ 20 | "out", 21 | "dist", 22 | "**/*.d.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/package.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | workflow_dispatch: 6 | 7 | name: Build VSIX file 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | - name: Install Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 16.x 19 | - run: npm install 20 | - run: ./node_modules/.bin/node-pre-gyp install --directory=./node_modules/sqlite3 --target_platform=darwin --target_arch=x64 --target_libc=unknown 21 | - run: ./node_modules/.bin/node-pre-gyp install --directory=./node_modules/sqlite3 --target_platform=darwin --target_arch=arm64 --target_libc=unknown 22 | - run: ./node_modules/.bin/node-pre-gyp install --directory=./node_modules/sqlite3 --target_platform=win32 --target_arch=ia32 --target_libc=unknown 23 | - run: ./node_modules/.bin/node-pre-gyp install --directory=./node_modules/sqlite3 --target_platform=win32 --target_arch=x64 --target_libc=unknown 24 | - run: ./node_modules/.bin/node-pre-gyp install --directory=./node_modules/sqlite3 --target_platform=linux --target_arch=x64 --target_libc=musl 25 | - run: ./node_modules/.bin/node-pre-gyp install --directory=./node_modules/sqlite3 --target_platform=linux --target_arch=arm64 --target_libc=musl 26 | - run: ./node_modules/.bin/node-pre-gyp install --directory=./node_modules/sqlite3 --target_platform=linux --target_arch=x64 --target_libc=glibc 27 | - run: ./node_modules/.bin/node-pre-gyp install --directory=./node_modules/sqlite3 --target_platform=linux --target_arch=arm64 --target_libc=glibc 28 | - run: xvfb-run -a ./node_modules/.bin/vsce package -o "poiex.vsix" 29 | - name: Archive VSIX file 30 | uses: actions/upload-artifact@v3 31 | with: 32 | name: poiex.vsix 33 | path: poiex.vsix 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'v*' 5 | workflow_dispatch: 6 | 7 | name: Create Release 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | - name: Install Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 16.x 19 | - run: npm install 20 | - run: ./node_modules/.bin/node-pre-gyp install --directory=./node_modules/sqlite3 --target_platform=darwin --target_arch=x64 --target_libc=unknown 21 | - run: ./node_modules/.bin/node-pre-gyp install --directory=./node_modules/sqlite3 --target_platform=darwin --target_arch=arm64 --target_libc=unknown 22 | - run: ./node_modules/.bin/node-pre-gyp install --directory=./node_modules/sqlite3 --target_platform=win32 --target_arch=ia32 --target_libc=unknown 23 | - run: ./node_modules/.bin/node-pre-gyp install --directory=./node_modules/sqlite3 --target_platform=win32 --target_arch=x64 --target_libc=unknown 24 | - run: ./node_modules/.bin/node-pre-gyp install --directory=./node_modules/sqlite3 --target_platform=linux --target_arch=x64 --target_libc=musl 25 | - run: ./node_modules/.bin/node-pre-gyp install --directory=./node_modules/sqlite3 --target_platform=linux --target_arch=arm64 --target_libc=musl 26 | - run: ./node_modules/.bin/node-pre-gyp install --directory=./node_modules/sqlite3 --target_platform=linux --target_arch=x64 --target_libc=glibc 27 | - run: ./node_modules/.bin/node-pre-gyp install --directory=./node_modules/sqlite3 --target_platform=linux --target_arch=arm64 --target_libc=glibc 28 | - run: xvfb-run -a ./node_modules/.bin/vsce package -o "poiex.vsix" 29 | - name: Create Release 30 | id: create_release 31 | uses: actions/create-release@v1 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | with: 35 | tag_name: ${{ github.ref }} 36 | release_name: Release ${{ github.ref }} 37 | draft: false 38 | prerelease: false 39 | - name: Upload Release Asset 40 | id: upload-release-asset 41 | uses: actions/upload-release-asset@v1 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | with: 45 | upload_url: ${{ steps.create_release.outputs.upload_url }} 46 | asset_path: ./poiex.vsix 47 | asset_name: poiex.vsix 48 | asset_content_type: application/octet-stream 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | .vscode-test/ 5 | *.vsix 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": [ 13 | "--extensionDevelopmentPath=${workspaceFolder}" 14 | ], 15 | "outFiles": [ 16 | "${workspaceFolder}/out/**/*.js" 17 | ], 18 | "preLaunchTask": "${defaultBuildTask}" 19 | }, 20 | { 21 | "name": "Extension Tests", 22 | "type": "extensionHost", 23 | "request": "launch", 24 | "args": [ 25 | "--extensionDevelopmentPath=${workspaceFolder}", 26 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 27 | ], 28 | "outFiles": [ 29 | "${workspaceFolder}/out/test/**/*.js" 30 | ], 31 | "preLaunchTask": "${defaultBuildTask}" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off" 11 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | src/** 4 | .gitignore 5 | .yarnrc 6 | vsc-extension-quickstart.md 7 | **/tsconfig.json 8 | **/.eslintrc.json 9 | **/*.map 10 | **/*.ts 11 | -------------------------------------------------------------------------------- /COLLAB_MODE.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | icon 4 | 5 | 6 |  PoiEx 7 | 8 |

9 | 10 | ## Collaboration Mode 11 | 12 | PoiEx allows for real-time synchronization of findings and comments with other users. This mode requires a MongoDB instance shared across all collaborators. See the MongoDB section below for how to deploy a MongoDB instance. 13 | 14 | Once you have a shared MongoDB instance ready, set your name in _Settings > Extensions > PoiEx > Author Name_ and the database URI, which should be the same across all collaborators.
15 | To create a project, the Project Manager should open the desired codebase in VS Code, then click _Init project_ in the PoiEx tab. If the project is encrypted, the automatically generated secret should be sent via a secure channel to all collaborators. 16 | To open an existing project, a collaborator should: 17 | - Ensure PoiEx is connected to the same MongoDB instance as the project manager 18 | - Ensure that in the PoiEx extension settings, the MongoDB database name specified is the same as the project manager 19 | - Open a VS Code workspace with the same codebase as the project manager (the codebase is never uploaded to MongoDB and needs to be shared separately) 20 | - Click _Open existing project_ in the PoiEx tab 21 | - Select the project based on project name and project UUID 22 | - Enter the project secret, as received by the project manager 23 | 24 | After this, all findings and notes will be synchronized in real-time across all collaborators. 25 | 26 | ### Shared MongoDB Instance 27 | 28 | To enable collaboration features, all collaborators should connect to a common MongoDB instance.
29 | All collaborators should have read and write access to the database configured in the `poiex.collab.database` field of the VSCode settings. To enable collaboration features, set `poiex.collab.enabled` to `true` and `poiex.collab.uri` to the MongoDB URI.
30 | Optionally, update `poiex.collab.database` if using a database name different from the default value. If credentials are required to connect to the database, the extension will prompt the user for credentials.
31 | The extension supports an auto-delete feature, if `poiex.collab.expireAfter` is set to a value higher than `0`, it will configure MongoDB to automatically delete projects that are not accessed for the specified number of seconds. The project expiration value is reset each time one of the collaborators accesses the project. The expiration value does not affect project data that is saved locally.
32 | If a local project is not found on the remote database, the extension will push the local version to the remote database. 33 | 34 | Example MongoDB deployment steps on Ubuntu 22.04: 35 | 36 | ```bash 37 | export ADMIN_USERNAME="username" 38 | export ADMIN_PASSWORD="$(openssl rand -base64 12)" 39 | export FQDN="$(hostname)" 40 | echo "Admin password is: $ADMIN_PASSWORD" 41 | 42 | # Install MongoDB from the official repository 43 | curl -fsSL https://pgp.mongodb.com/server-6.0.asc | \ 44 | sudo gpg -o /usr/share/keyrings/mongodb-server-6.0.gpg \ 45 | --dearmor 46 | echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-6.0.gpg ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/6.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-6.0.list 47 | apt update 48 | apt install -y mongodb-org 49 | 50 | # Create a new user and enable authentication 51 | systemctl enable mongod 52 | systemctl start mongod 53 | mongosh <<< "admin = db.getSiblingDB(\"admin\"); admin.createUser({ user: \"$ADMIN_USERNAME\", pwd: \"$ADMIN_PASSWORD\", roles: [ { role: \"root\", db: \"admin\" } ]});" 54 | systemctl stop mongod 55 | echo "security:" >> /etc/mongod.conf 56 | echo ' keyFile: "/etc/mongodb_keyfile"' >> /etc/mongod.conf 57 | echo " authorization: enabled" >> /etc/mongod.conf 58 | openssl rand -base64 756 > /etc/mongodb_keyfile 59 | chmod 400 /etc/mongodb_keyfile 60 | chown mongodb:mongodb /etc/mongodb_keyfile 61 | 62 | # Configure a replica set, we need this as the extension relies on changestreams 63 | echo "replication:" >> /etc/mongod.conf 64 | echo ' replSetName: "rs0"' >> /etc/mongod.conf 65 | sed -i "s/127.0.0.1/0.0.0.0/g" /etc/mongod.conf 66 | systemctl start mongod 67 | mongosh -u "$ADMIN_USERNAME" -p "$ADMIN_PASSWORD" --authenticationDatabase "admin" <<< "rs.initiate()" 68 | mongosh -u "$ADMIN_USERNAME" -p "$ADMIN_PASSWORD" --authenticationDatabase "admin" <<< "var x = rs.conf(); x.members[0].host = \"$FQDN:27017\"; rs.reconfig(x);" 69 | ``` 70 | 71 | **Security Note**: *The given deployment script is intended for plug&play purposes to test the extension and its collaboration capabilities. For production-safe usages, configure a hardened MongoDB instance machine to fit your needs by following the best practices (see the [documentation](https://www.mongodb.com/docs/manual/administration/security-checklist/))* 72 | 73 | After deployment create additional user(s) for the extension collaborators. Each user should have read/write access to one common database. Each collaborator should enter the same MongoDB URI and database name in the extension settings. 74 | 75 | ### Security Model 76 | 77 | Since the tool is intended for internal usage, currently the MongoDB users (testers) are required to have read and write permissions on the configured database.
78 | Consequently, everyone in the team can list, add or destroy projects.
79 | PoiEx stores the comment content and anchor as encrypted JSON objects as per the RFC7516 specification, using the AES-128-CBC algorithm. 80 | The key is never stored on the remote database and needs to be manually shared by the user to all collaborators via a safe channel. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PoiEx 2 | 3 | ## What's PoiEx? 4 | 5 | **PoiEx** is an experimental VS Code extension built to identify and visualize the Points of Intersection between a web application and the underlying infrastructure. 6 | 7 | Since it was designed to simplify manual code review activities, it was also packed with: [Semgrep](https://semgrep.dev/) support, notes taking and collaboration capabilities. 8 | 9 |
10 |

11 | PoiEx logo 12 |

13 |
14 | 15 | Read the launch post on the [Doyensec blog](https://blog.doyensec.com/2024/01/30/poiex-release.html) for practical examples and tips. 16 | 17 | ## Try It Out! 18 | 19 | Download and install the VSIX extension from GitHub Releases. Make sure you have [Semgrep](https://semgrep.dev/) installed before running PoiEx. 20 | 21 | ## Points of Intersection Explorer 22 | 23 | A Point of Intersection (PoI) marks where your code interacts with its underlying infrastructure, revealing connections between the implemented logic and the Infrastructure as Code (IaC). PoiEx identifies and visualizes PoIs, allowing testers and cloud security specialists to better understand and identify security vulnerabilities in your cloud applications.

24 | The extension relies on [Inframap](https://github.com/cycloidio/inframap/) to generate an interactive IaC diagram. Each entity in the diagram is clickable to reveal the PoIs, Semgrep results and the IaC definition, linked to the selected entity. By then clicking on a PoI, the user can jump to the relevant code section.
25 | 26 | Below an example infrastructure diagram generation and PoIs exploration. 27 | ![IaC Diagrams](images/animation-diagram.gif) 28 | 29 | **Note**: If you do not have a Terraform IaC definition file but you have access to the live infrastructure, you can use reverse-terraforming tools such as [terraformer](https://github.com/GoogleCloudPlatform/terraformer) to generate an IaC file from existing infrastructure.
30 | 31 | ### Core Features 32 | 33 | PoiEx allows users to: 34 | 35 | - Scan the application's code and the IaC definition at the same time with [Semgrep](https://semgrep.dev/), generating explorable results in a user-friendly view inside VS Code's Problems section 36 | 37 | - Visualize the infrastructure diagram generated with [Inframap](https://github.com/cycloidio/inframap/). Additionally, the diagram is enhanced to be interactive, each entity in the diagram is clickable and reveals the enumerated PoIs that are linked to the selected entity. By then clicking on a PoI, it jumps to the relevant code section to review the code in which it is embedded. 38 | 39 | - Take notes using the VS Code Comments API, allowing it to be used also as a standalone code-analysis tool 40 | 41 | - Collaborate with other reviewers on encrypted projects pushed to a [MongoDB](https://www.mongodb.com/) instance 42 | 43 | ### Supported CSPs & Languages 44 | 45 | A custom set of Semgrep rules has been developed. Each pair of language and Cloud Service Provider (CSP) needs a different ruleset, as a rule is generally able to match only one language and one CSP. 46 | 47 | The table below summarizes the currently supported CSPs and languages: 48 | 49 | | Language/CSP | GCP | AWS | 50 | |-----------------|-----|-----| 51 | | Python | Yes | Yes | 52 | | JavaScript | Yes | Yes | 53 | | Go | No | Yes | 54 | | TypeScript | No | Yes | 55 | 56 | 57 | ### Enhancing Reviews with PoiEx 58 | Non-IaC related features were added to support manual reviews in different ways. Such functionalities are unrelated to the cloud infrastructure analysis and they are usable in any code review activity.
59 | A few examples are attached below. Please refer to the [launch blogpost](https://blog.doyensec.com/2024/01/25/poiex-release.html) for detailed use cases. 60 | #### 1. Standalone Semgrep Integration 61 | Scan the application's code and the IaC definition at the same time with [Semgrep](https://semgrep.dev/), generating explorable results in a user-friendly view, inside VS Code's Problems section. The Semgrep ruleset is fully customizable via direct arguments in the Settings page. 62 | 63 | It is also possible to flag the findings as `false positive`,`hot` or `resolved` and have them synced in collaboration mode. 64 | ![Semgrep integration](images/feature-findings.png) 65 | 66 | #### 2. Notes Taking 67 | It is possible to start comment threads directly within the codebase for adding details and reactions.
68 | 69 | ![threadExample](images/threadExample.png) 70 | 71 | When collaboration mode is disabled, each project is stored in a local SQLite database. In this mode, projects are not synchronized or shared across different collaborators. 72 | 73 | #### 3. Collaboration with Peers 74 | PoiEx allows for real-time synchronization of findings and comments with other users. This mode requires a MongoDB instance shared across all collaborators. Read more in the [collaboration guide](./COLLAB_MODE.md). 75 | ## Extension Settings 76 | 77 | * `poiex.enableIaC`: Enable IaC features of this extension 78 | * `poiex.authorName`: Author name for comments 79 | * `poiex.semgrepArgs`: Semgrep command line arguments 80 | * `poiex.semgrepTimeout`: Semgrep execution timeout (in seconds) 81 | * `poiex.collab.enabled`: Enable collaboration via MongoDB 82 | * `poiex.collab.uri`: URI of the remote MongoDB server 83 | * `poiex.collab.database`: Name of the MongoDB database 84 | * `poiex.collab.expireAfter`: Auto-delete comments on the remote database after a certain amount of seconds (set to 0 to disable) 85 | 86 | ## Credits 87 | 88 | This project was made with love on the [Doyensec Research Island](https://doyensec.com/research.html) by [Michele Lizzit](https://www.linkedin.com/in/michelelizzit/) for his master's thesis at ETH Zurich, under the mentoring of [Francesco Lacerenza](https://twitter.com/lacerenza_fra). 89 | 90 | We took inspiration from [vscode-security-notes](https://github.com/RefactorSecurity/vscode-security-notes) by RefactorSecurity. 91 | 92 | Download the latest [release](https://github.com/doyensec/PoiEx/releases) and contribute with a star, [bug reports or suggestions](https://github.com/doyensec/PoiEx/issues). 93 | 94 | Doyensec Research 95 | -------------------------------------------------------------------------------- /images/animation-diagram.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doyensec/PoiEx/0c87db09698afd0f58707d76d1b2844e75924aa3/images/animation-diagram.gif -------------------------------------------------------------------------------- /images/doyensec_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doyensec/PoiEx/0c87db09698afd0f58707d76d1b2844e75924aa3/images/doyensec_logo.png -------------------------------------------------------------------------------- /images/feature-comments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doyensec/PoiEx/0c87db09698afd0f58707d76d1b2844e75924aa3/images/feature-comments.png -------------------------------------------------------------------------------- /images/feature-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doyensec/PoiEx/0c87db09698afd0f58707d76d1b2844e75924aa3/images/feature-diagram.png -------------------------------------------------------------------------------- /images/feature-findings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doyensec/PoiEx/0c87db09698afd0f58707d76d1b2844e75924aa3/images/feature-findings.png -------------------------------------------------------------------------------- /images/feature-poi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doyensec/PoiEx/0c87db09698afd0f58707d76d1b2844e75924aa3/images/feature-poi.png -------------------------------------------------------------------------------- /images/logo-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doyensec/PoiEx/0c87db09698afd0f58707d76d1b2844e75924aa3/images/logo-1.png -------------------------------------------------------------------------------- /images/supported-lang-csp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doyensec/PoiEx/0c87db09698afd0f58707d76d1b2844e75924aa3/images/supported-lang-csp.png -------------------------------------------------------------------------------- /images/threadExample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doyensec/PoiEx/0c87db09698afd0f58707d76d1b2844e75924aa3/images/threadExample.png -------------------------------------------------------------------------------- /inactive_rules/hcl-aws-dynamodb.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | - id: hcl-aws-dynamodb 3 | patterns: 4 | - pattern: | 5 | resource "aws_dynamodb_table" $B { 6 | ... 7 | } 8 | message: "IaC Point Of Intersection: definition of $B AWS DynamoDB" 9 | languages: 10 | - hcl 11 | severity: WARNING -------------------------------------------------------------------------------- /inactive_rules/hcl-aws-s3-bucket.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | - id: hcl-aws-s3-bucket 3 | patterns: 4 | - pattern: | 5 | resource "aws_s3_bucket_object" $B { 6 | ... 7 | } 8 | message: "IaC Point Of Intersection: definition of $B AWS S3 bucket" 9 | languages: 10 | - hcl 11 | severity: WARNING -------------------------------------------------------------------------------- /inactive_rules/hcl-aws-sns.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | - id: hcl-aws-sns 3 | patterns: 4 | - pattern: | 5 | resource "aws_sns_topic" $B { 6 | ... 7 | } 8 | message: "IaC Point Of Intersection: definition of $B AWS SNS topic" 9 | languages: 10 | - hcl 11 | severity: WARNING -------------------------------------------------------------------------------- /inactive_rules/hcl-aws-sqs.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | - id: hcl-aws-sqs 3 | patterns: 4 | - pattern: | 5 | resource "aws_sqs_queue" $B { 6 | ... 7 | } 8 | message: "IaC Point Of Intersection: definition of $B AWS SQS queue" 9 | languages: 10 | - hcl 11 | severity: WARNING -------------------------------------------------------------------------------- /media/infrastructure.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /media/refresh-dark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /media/refresh-light.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /media/remove-dark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /media/remove-light.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "poiex", 3 | "displayName": "PoiEx", 4 | "description": "Point of Intersection Explorer", 5 | "version": "1.0.0", 6 | "publisher": "doyensec", 7 | "contributors": [ 8 | { 9 | "name": "Michele Lizzit", 10 | "email": "michele@lizzit.it", 11 | "url": "https://lizzit.it/" 12 | }, 13 | { 14 | "name": "Francesco Lacerenza", 15 | "email": "francesco@doyensec.com", 16 | "url": "https://doyensec.com/" 17 | } 18 | ], 19 | "engines": { 20 | "vscode": "^1.85.0" 21 | }, 22 | "categories": [ 23 | "Other" 24 | ], 25 | "activationEvents": [ 26 | "onStartupFinished", 27 | "onView:poiex" 28 | ], 29 | "extensionDependencies": [ 30 | "mindaro-dev.file-downloader" 31 | ], 32 | "main": "./out/extension.js", 33 | "contributes": { 34 | "commands": [ 35 | { 36 | "command": "poiex.readSemgrepJson", 37 | "title": "Read Semgrep Json" 38 | }, 39 | { 40 | "command": "poiex.create_note", 41 | "title": "Create new comment", 42 | "enablement": "!commentIsEmpty" 43 | }, 44 | { 45 | "command": "poiex.replyNote", 46 | "title": "Reply", 47 | "enablement": "!commentIsEmpty" 48 | }, 49 | { 50 | "command": "poiex.editNote", 51 | "title": "Edit" 52 | }, 53 | { 54 | "command": "poiex.deleteNote", 55 | "title": "Delete" 56 | }, 57 | { 58 | "command": "poiex.deleteNoteComment", 59 | "title": "Delete" 60 | }, 61 | { 62 | "command": "poiex.saveNote", 63 | "title": "Save" 64 | }, 65 | { 66 | "command": "poiex.cancelsaveNote", 67 | "title": "Cancel" 68 | }, 69 | { 70 | "command": "poiex.dispose", 71 | "title": "Remove All Notes (DANGER)" 72 | }, 73 | { 74 | "command": "iacAudit.refreshProjectTree", 75 | "title": "Refresh", 76 | "icon": { 77 | "light": "media/refresh-dark.svg", 78 | "dark": "media/refresh-light.svg" 79 | } 80 | }, 81 | { 82 | "command": "iacAudit.deleteTreeProject", 83 | "title": "Delete project", 84 | "icon": { 85 | "light": "media/remove-dark.svg", 86 | "dark": "media/remove-light.svg" 87 | } 88 | } 89 | ], 90 | "configuration": { 91 | "title": "PoiEx", 92 | "properties": { 93 | "poiex.authorName": { 94 | "type": "string", 95 | "description": "Author name for comments.", 96 | "default": "No-name" 97 | }, 98 | "poiex.semgrepArgs": { 99 | "type": "string", 100 | "description": "Semgrep command line arguments.", 101 | "default": "--config auto" 102 | }, 103 | "poiex.semgrepTimeout": { 104 | "type": "number", 105 | "description": "Semgrep execution timeout in seconds.", 106 | "default": 240, 107 | "minimum": 10, 108 | "maximum": 7200 109 | }, 110 | "poiex.enableIaC": { 111 | "type": "boolean", 112 | "description": "Enable IaC-specific features.", 113 | "default": false 114 | }, 115 | "poiex.collab.enabled": { 116 | "type": "boolean", 117 | "description": "Enable collaboration via MongoDB.", 118 | "default": false 119 | }, 120 | "poiex.collab.uri": { 121 | "type": "string", 122 | "description": "URI of the remote MongoDB server.", 123 | "default": "mongodb://localhost:27017/" 124 | }, 125 | "poiex.collab.database": { 126 | "type": "string", 127 | "description": "Name of the MongoDB database.", 128 | "default": "poiex" 129 | }, 130 | "poiex.collab.expireAfter": { 131 | "type": "string", 132 | "description": "Auto-delete comments on remote database after a certain amount of seconds. (Set to 0 to disable)", 133 | "default": "0" 134 | } 135 | } 136 | }, 137 | "menus": { 138 | "comments/commentThread/title": [ 139 | { 140 | "command": "poiex.deleteNote", 141 | "group": "navigation", 142 | "when": "commentController == poiex && !commentThreadIsEmpty" 143 | } 144 | ], 145 | "comments/commentThread/context": [ 146 | { 147 | "command": "poiex.create_note", 148 | "group": "inline", 149 | "when": "commentController == poiex && commentThreadIsEmpty" 150 | }, 151 | { 152 | "command": "poiex.replyNote", 153 | "group": "inline", 154 | "when": "commentController == poiex && !commentThreadIsEmpty" 155 | } 156 | ], 157 | "comments/comment/title": [ 158 | { 159 | "command": "poiex.editNote", 160 | "group": "group@1", 161 | "when": "commentController == poiex" 162 | }, 163 | { 164 | "command": "poiex.deleteNoteComment", 165 | "group": "group@2", 166 | "when": "commentController == poiex && comment == canDelete" 167 | } 168 | ], 169 | "comments/comment/context": [ 170 | { 171 | "command": "poiex.cancelsaveNote", 172 | "group": "inline@1", 173 | "when": "commentController == poiex" 174 | }, 175 | { 176 | "command": "poiex.saveNote", 177 | "group": "inline@2", 178 | "when": "commentController == poiex" 179 | } 180 | ], 181 | "view/title": [ 182 | { 183 | "command": "iacAudit.refreshProjectTree", 184 | "when": "view == iacAudit && workspaceFolderCount > 0 && iacAudit.isProjectOpen == false", 185 | "group": "navigation" 186 | } 187 | ], 188 | "view/item/context": [ 189 | { 190 | "command": "iacAudit.deleteTreeProject", 191 | "when": "view == iacAudit && workspaceFolderCount > 0 && iacAudit.isProjectOpen == false", 192 | "group": "inline" 193 | } 194 | ] 195 | }, 196 | "viewsContainers": { 197 | "activitybar": [ 198 | { 199 | "id": "poiex", 200 | "title": "PoiEx", 201 | "icon": "media/infrastructure.svg" 202 | } 203 | ] 204 | }, 205 | "views": { 206 | "poiex": [ 207 | { 208 | "id": "iacAudit", 209 | "name": "PoiEx - Semgrep", 210 | "contextualTitle": "Explore Semgrep Findings" 211 | }, 212 | { 213 | "id": "iacProjectManager", 214 | "name": "PoiEx - Manage projects", 215 | "contextualTitle": "Manage IaC Audit projects" 216 | } 217 | ] 218 | }, 219 | "viewsWelcome": [ 220 | { 221 | "view": "iacAudit", 222 | "contents": "Automatically run Semgrep on the currently open workspace folder.\n[Run Semgrep on current Workspace](command:poiex.runSemgrep)\nManually provide a Semgrep JSON file. Make sure that you run Semgrep relative to the currently open workspace directory.\n[Provide Semgrep Json File](command:poiex.readSemgrepJson)\nDelete all findings generated with this extension.\n[Delete all diagnostics](command:poiex.deleteAllDiagnostics)", 223 | "when": "workspaceFolderCount == 1 && iacAudit.isProjectOpen == true" 224 | }, 225 | { 226 | "view": "iacAudit", 227 | "contents": "Analyze the current project and show IaC-specific diagram.\n[Show IaC diagram](command:poiex.showIaCwebview)", 228 | "when": "workspaceFolderCount == 1 && iacAudit.isProjectOpen == true && config.poiex.enableIaC == true" 229 | }, 230 | { 231 | "view": "iacAudit", 232 | "contents": "In order to use PoiEx open a workspace folder.\n[Open Folder](command:vscode.openFolder)", 233 | "when": "workspaceFolderCount == 0" 234 | }, 235 | { 236 | "view": "iacAudit", 237 | "contents": "Please open a project to use this extension.", 238 | "when": "workspaceFolderCount > 0 && iacAudit.isProjectOpen == false" 239 | }, 240 | { 241 | "view": "iacAudit", 242 | "contents": "This extension only works when a single workspace folder is open. Please close all other workspace folders.", 243 | "when": "workspaceFolderCount > 1" 244 | }, 245 | { 246 | "view": "iacProjectManager", 247 | "contents": "In order to use PoiEx open a workspace folder.\n[Open Folder](command:vscode.openFolder)", 248 | "when": "workspaceFolderCount == 0" 249 | }, 250 | { 251 | "view": "iacProjectManager", 252 | "contents": "This extension only works when a single workspace folder is open. Please close all other workspace folders.", 253 | "when": "workspaceFolderCount > 1" 254 | }, 255 | { 256 | "view": "iacProjectManager", 257 | "contents": "Initialize a new project on current Workspace.\n[Init project](command:poiex.initProject)\nOpen existing project from remote database.\n[Open existing project](command:poiex.openProject)", 258 | "when": "workspaceFolderCount == 1 && iacAudit.isProjectOpen == false" 259 | }, 260 | { 261 | "view": "iacProjectManager", 262 | "contents": "Close current project.\n[Close project](command:poiex.closeProject)\nExport currently open project to a file.\n[Export project](command:poiex.exportProject)\nDestroy current project on local database only.\n[Destroy local project](command:poiex.destroyLocalProject)", 263 | "when": "workspaceFolderCount == 1 && iacAudit.isProjectOpen == true" 264 | }, 265 | { 266 | "view": "iacProjectManager", 267 | "contents": "Destroy current project on local and remote database.\n[Destroy project](command:poiex.destroyProject)", 268 | "when": "workspaceFolderCount == 1 && iacAudit.isProjectOpen == true && iacAudit.isProjectCreator == true" 269 | }, 270 | { 271 | "view": "iacProjectManager", 272 | "contents": "Copy encryption key to clipboard.\n[Copy key to clipboard](command:poiex.copyKey)", 273 | "when": "workspaceFolderCount == 1 && iacAudit.isProjectOpen == true && iacAudit.isProjectEncrypted == true" 274 | } 275 | ] 276 | }, 277 | "scripts": { 278 | "vscode:prepublish": "npm run compile", 279 | "compile": "tsc -p ./", 280 | "watch": "tsc -watch -p ./", 281 | "pretest": "npm run compile && npm run lint", 282 | "lint": "eslint src --ext ts", 283 | "test": "node ./out/test/runTest.js" 284 | }, 285 | "devDependencies": { 286 | "@types/glob": "^8.1.0", 287 | "@types/mocha": "^10.0.6", 288 | "@types/node": "^16.18.70", 289 | "@types/vscode": "^1.85.0", 290 | "@typescript-eslint/eslint-plugin": "^5.62.0", 291 | "@typescript-eslint/parser": "^5.62.0", 292 | "@vscode/test-electron": "^2.3.8", 293 | "@vscode/vsce": "^2.22.0", 294 | "electron": "^26.6.4", 295 | "electron-rebuild": "^3.2.9", 296 | "eslint": "^8.56.0", 297 | "glob": "^8.1.0", 298 | "mocha": "^10.2.0", 299 | "typescript": "^4.9.5" 300 | }, 301 | "dependencies": { 302 | "@microsoft/vscode-file-downloader-api": "^1.0.1", 303 | "@types/sqlite3": "^3.1.11", 304 | "@types/ssri": "^7.1.5", 305 | "@types/tar": "^6.1.10", 306 | "@types/url-parse": "^1.4.11", 307 | "@types/which": "^3.0.3", 308 | "jose": "^4.15.4", 309 | "mongodb": "^5.9.2", 310 | "sqlite": "^4.2.1", 311 | "sqlite3": "=5.1.6", 312 | "ssri": "^10.0.5", 313 | "tar": "^6.2.0", 314 | "url-parse": "^1.5.10", 315 | "which": "^3.0.1" 316 | }, 317 | "repository": { 318 | "type": "git", 319 | "url": "https://github.com/doyensec/poiex.git" 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /res/iacView.css: -------------------------------------------------------------------------------- 1 | html, body, #diagram { 2 | width: 100%; 3 | height: 100%; 4 | font-family: 'Roboto', sans-serif; 5 | } 6 | #diagram { 7 | border: 1px solid lightgray; 8 | background-color: white; 9 | } 10 | 11 | @font-face { 12 | font-family: 'Roboto'; 13 | font-style: normal; 14 | font-weight: 400; 15 | font-display: swap; 16 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu4mxK.woff2) format('woff2'); 17 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 18 | } -------------------------------------------------------------------------------- /res/iacView.js: -------------------------------------------------------------------------------- 1 | var vscode = acquireVsCodeApi(); 2 | 3 | var parsedData = vis.parseDOTNetwork(DOTstring); 4 | 5 | var data = { 6 | nodes: parsedData.nodes, 7 | edges: parsedData.edges 8 | }; 9 | 10 | let counterMap = new Map(); 11 | for (let i = 0; i < parsedData.nodes.length; i++) { 12 | let node = parsedData.nodes[i].id; 13 | let res = 0; 14 | console.log(node); 15 | for (let poi of poiList) { 16 | let providerFilter = ""; 17 | let serviceFilter = ""; 18 | let poiFilter = node.split(".")[0]; 19 | let poiFilterParts = poiFilter.split("_"); 20 | if (poiFilterParts.length >= 1) { 21 | providerFilter = poiFilter.split("_")[0]; 22 | } 23 | if (poiFilterParts.length >= 2) { 24 | serviceFilter = poiFilter.split("_")[1]; 25 | } 26 | 27 | console.log(poi); 28 | if (poi.toLowerCase().includes(providerFilter) && poi.toLowerCase().includes(serviceFilter)) { 29 | console.log("Found POI for node: " + node); 30 | res++; 31 | } 32 | } 33 | 34 | for (let finding of findingsList) { 35 | if (finding.toLowerCase().includes(node.toLowerCase())) { 36 | res++; 37 | } 38 | } 39 | 40 | counterMap.set(node, res); 41 | } 42 | 43 | // create a network 44 | var container = document.getElementById('diagram'); 45 | 46 | var options = {}; 47 | 48 | // initialize your network! 49 | var network = new vis.Network(container, data, options); 50 | 51 | network.on("doubleClick", function (params) { 52 | let node = this.getNodeAt(params.pointer.DOM); 53 | vscode.postMessage({ 54 | command: 'nodeClicked', 55 | nodeId: node 56 | }); 57 | }); 58 | 59 | const BUBBLE_X_OFFSET = 20; 60 | const BUBBLE_Y_OFFSET = -20; 61 | 62 | const PALETTE = { 63 | "primary": "#E65100", 64 | "text": "#FAFAFA" 65 | }; 66 | 67 | network.on("afterDrawing", function (ctx) { 68 | for (let i = 0; i < parsedData.nodes.length; i++) { 69 | // Draw a bubble with the number of POIs 70 | var nodeId = parsedData.nodes[i].id; 71 | let res = counterMap.get(nodeId); 72 | if (res === undefined) { 73 | res = 0; 74 | } 75 | if (res > 0) { 76 | var nodePosition = network.getPositions([nodeId]); 77 | 78 | ctx.beginPath(); 79 | ctx.arc(nodePosition[nodeId].x + BUBBLE_X_OFFSET, nodePosition[nodeId].y + BUBBLE_Y_OFFSET, 13, 0, 2 * Math.PI, false); 80 | ctx.fillStyle = PALETTE.primary; 81 | ctx.fill(); 82 | 83 | ctx.font = "15px Roboto"; 84 | ctx.fillStyle = PALETTE.text; 85 | ctx.textAlign = "center"; 86 | ctx.textBaseline = "middle"; 87 | ctx.fillText(res, nodePosition[nodeId].x + BUBBLE_X_OFFSET, nodePosition[nodeId].y + BUBBLE_Y_OFFSET + 1); 88 | } 89 | } 90 | }); -------------------------------------------------------------------------------- /res/poiView.css: -------------------------------------------------------------------------------- 1 | .poitable, .iactable { 2 | width: 100%; 3 | border-collapse: collapse; 4 | border: 1px solid #ddd; 5 | font-size: 13px; 6 | line-height: 1.4em; 7 | --monaco-monospace-font: "Ubuntu Mono","Liberation Mono","DejaVu Sans Mono","Courier New",monospace; 8 | font-family: system-ui,Ubuntu,Droid Sans,sans-serif; 9 | } 10 | 11 | .poitr:hover, .iactr:hover { 12 | background-color: var(--vscode-inputOption-hoverBackground); 13 | } 14 | 15 | .poitd, .poith, .iactd, .iacth { 16 | padding-top: 0.25em; 17 | padding-bottom: 0.25em; 18 | padding-left: 0.5em; 19 | padding-right: 0.5em; 20 | } 21 | 22 | .iactable { 23 | margin-top: 1em; 24 | table-layout: fixed; 25 | } 26 | 27 | pre { 28 | text-wrap: wrap; 29 | } -------------------------------------------------------------------------------- /res/poiView.js: -------------------------------------------------------------------------------- 1 | var vscode = acquireVsCodeApi(); 2 | 3 | function openPoi(poiUUID) { 4 | console.log("[IaC PoiView (webview)] Opening POI: " + poiUUID); 5 | vscode.postMessage({ 6 | command: 'openPoi', 7 | poiUUID: poiUUID 8 | }); 9 | } 10 | 11 | function openIaC() { 12 | console.log("[IaC PoiView (webview)] Opening IaC definition"); 13 | vscode.postMessage({ 14 | command: 'openIaC' 15 | }); 16 | } 17 | 18 | var container = document.getElementById('container'); 19 | let res = ` 20 | 21 | 22 | `; 23 | // Show the list of POIs 24 | for (let i = 0; i < poiList.length; i++) { 25 | let poiName = poiList[i][0].replace("IaC Point Of Intersection: ", ""); 26 | res += `" + ""; 27 | } 28 | res += "
Points of Intersection and findings for: ${poiFilter}
` + poiList[i][1] + ` @ ${poiList[i][2]}:${poiList[i][3]}` + "
"; 29 | 30 | // Add IaC definition 31 | let iacRes = ` 32 | 33 | 34 | `; 35 | 36 | iacRes += `" + ""; 37 | 38 | res += iacRes; 39 | 40 | container.innerHTML = res; -------------------------------------------------------------------------------- /rules/boto3-put_records.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | - id: iac-poi-boto3_put_records 3 | languages: 4 | - python 5 | severity: WARNING 6 | message: "IaC Point Of Intersection: put_records $B" 7 | patterns: 8 | - pattern-either: 9 | - pattern: boto3.client(...).put_records(...) 10 | - pattern: | 11 | $B = boto3.client(...) 12 | $B.put_records(...) 13 | -------------------------------------------------------------------------------- /rules/boto3-s3_put_obj.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | - id: iac-poi-boto3_s3_put_obj 3 | languages: 4 | - python 5 | severity: WARNING 6 | message: "IaC Point Of Intersection: S3 Bucket put_object" 7 | patterns: 8 | - pattern-either: 9 | - pattern: $W.Bucket(...).put_object(...) 10 | - pattern: | 11 | $B = $W.Bucket(...) 12 | $B.put_object(...) -------------------------------------------------------------------------------- /rules/js-gcp-generic1.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | - id: js-gcp-generic1 3 | pattern-either: 4 | - patterns: 5 | - pattern-inside: | 6 | $V2 = require("$SN").v1.$V; 7 | ... 8 | - pattern: new $V2(...) 9 | - metavariable-regex: 10 | metavariable: $SN 11 | regex: \@google-cloud/([AZaz\-]*) 12 | - patterns: 13 | - pattern-inside: | 14 | var {$V} = require("$SN").v1; 15 | ... 16 | - pattern: new $V(...) 17 | - metavariable-regex: 18 | metavariable: $SN 19 | regex: \@google-cloud/([AZaz\-]*) 20 | - patterns: 21 | - pattern-inside: | 22 | $V2 = require("$SN").$V; 23 | ... 24 | - pattern: new $V2(...) 25 | - metavariable-regex: 26 | metavariable: $SN 27 | regex: \@google-cloud/([AZaz\-]*) 28 | - patterns: 29 | - pattern-inside: | 30 | var {$V} = require("$SN"); 31 | ... 32 | - pattern: new $V(...) 33 | - metavariable-regex: 34 | metavariable: $SN 35 | regex: \@google-cloud/([AZaz\-]*) 36 | message: 'IaC Point Of Intersection: google.cloud.$PN.$SN.$F call to GCP SDK $V' 37 | languages: [javascript] 38 | severity: WARNING -------------------------------------------------------------------------------- /rules/js-generic1.js: -------------------------------------------------------------------------------- 1 | // import entire SDK 2 | var AWS = require('aws-sdk'); 3 | // import AWS object without services 4 | var AWS = require('aws-sdk/global'); 5 | // import individual service 6 | var S3 = require('aws-sdk/clients/s3'); 7 | 8 | var s3 = new AWS.S3(); 9 | s3.abortMultipartUpload(params, function (err, data) { 10 | if (err) console.log(err, err.stack); // an error occurred 11 | else console.log(data); // successful response 12 | }); 13 | 14 | var s3 = new S3({apiVersion: '2006-03-01'}); -------------------------------------------------------------------------------- /rules/js-generic1.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | - id: js-generic1 3 | message: 'IaC Point Of Intersection: $SRV call to AWS SDK' 4 | languages: 5 | - javascript 6 | severity: WARNING 7 | pattern-either: 8 | - patterns: 9 | - pattern-inside: | 10 | ... $AWS = require('aws-sdk'); 11 | ... 12 | - pattern: new $AWS.$SRV(...) 13 | - patterns: 14 | - pattern-inside: | 15 | ... $AWS = require('aws-sdk'); 16 | ... 17 | - pattern: new $AWS.$SRV(...) 18 | - patterns: 19 | - pattern-inside: | 20 | ... $SRV = require("$SN"); 21 | ... 22 | - pattern: new $SRV(...) 23 | - metavariable-regex: 24 | metavariable: "$SN" 25 | regex: "^aws-sdk/clients/.*" -------------------------------------------------------------------------------- /rules/python-aws-generic1.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | - id: python-aws-generic1 3 | languages: 4 | - python 5 | severity: WARNING 6 | message: "IaC Point Of Intersection: $F call to AWS SDK; SN = $SN" 7 | patterns: 8 | - pattern-either: 9 | - pattern: boto3.client($SN, ...).$F(...) 10 | - pattern: | 11 | $B = boto3.client($SN, ...) 12 | $B.$F(...) 13 | - pattern: boto3.resource($SN, ...).$F(...) 14 | - pattern: | 15 | $B = boto3.resource($SN, ...) 16 | $B.$F(...) 17 | - patterns: 18 | - pattern-inside: | 19 | ... 20 | $B = boto3.client($SN, ...) 21 | ... 22 | - pattern: | 23 | $B.$F(...) -------------------------------------------------------------------------------- /rules/python-aws-generic2.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | - id: python-aws-generic2 3 | languages: 4 | - python 5 | severity: WARNING 6 | message: "IaC Point Of Intersection: Instantiation of AWS SDK; SN = $SN" 7 | patterns: 8 | - pattern-either: 9 | - pattern: boto3.client($SN, ...) 10 | - pattern: boto3.resource($SN, ...) -------------------------------------------------------------------------------- /rules/python-gcp-generic1.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | - id: python-gcp-generic1 3 | pattern-either: 4 | - patterns: 5 | - pattern-inside: | 6 | from google.cloud.$PN import $SN 7 | ... 8 | - pattern: $SN.$F(...) 9 | - patterns: 10 | - pattern-inside: | 11 | import google.cloud.$PN.$SN 12 | ... 13 | - pattern: google.cloud.$PN.$SN.$F(...) 14 | message: 'IaC Point Of Intersection: google.cloud.$PN.$SN.$F call to GCP SDK' 15 | languages: [python] 16 | severity: WARNING -------------------------------------------------------------------------------- /rules/ts-generic1.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | - id: ts-generic1 3 | message: 'IaC Point Of Intersection: $F call to AWS SDK' 4 | languages: 5 | - typescript 6 | severity: WARNING 7 | pattern-either: 8 | - patterns: 9 | - pattern-inside: | 10 | import AWS from 'aws-sdk' 11 | ... 12 | - pattern: new AWS.$F(...) 13 | - patterns: 14 | - pattern-inside: | 15 | import AWS from 'aws-sdk/global' 16 | ... 17 | - pattern: new AWS.$F(...) 18 | - patterns: 19 | - pattern-inside: | 20 | import * as $A from 'aws-sdk' 21 | ... 22 | - pattern: new $A.$F(...) 23 | - patterns: 24 | - pattern-inside: | 25 | import $C from "$SN" 26 | ... 27 | - pattern: new $C.$F(...) 28 | - metavariable-regex: 29 | metavariable: "$SN" 30 | regex: "^aws-sdk/clients/.*" -------------------------------------------------------------------------------- /src/anchor.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'console'; 2 | 3 | // Global constants 4 | export const ANCHOR_LINES = 5; 5 | const MINIMUM_MATCH_RATIO = 0.8; 6 | 7 | // Remove newlines, tabs and spaces from anchor text 8 | const NORMALIZE_CHARS = "\r\n\t"; 9 | 10 | function normalizeAnchor(anchor: string) { 11 | // Replace NORMALIZE_CHARS with empty string 12 | return anchor.replace(new RegExp("[" + NORMALIZE_CHARS + "]", "g"), ""); 13 | } 14 | 15 | // Rabin fingerprint 16 | // https://en.wikipedia.org/wiki/Rabin_fingerprint 17 | function rabinFingerprint(anchor: string, hashSize: number) { 18 | let p = 53; 19 | let m = 2 ** 20; 20 | let hash = 0; 21 | for (let i = 0; i < hashSize; i++) { 22 | hash = (hash * p + anchor.charCodeAt(i)) % m; 23 | } 24 | return hash; 25 | } 26 | 27 | // Fast modular exponentiation 28 | function modExp(base: number, exp: number, mod: number) { 29 | let result = 1; 30 | while (exp > 0) { 31 | if (exp % 2 === 1) { 32 | result = (result * base) % mod; 33 | } 34 | exp = Math.floor(exp / 2); 35 | base = (base * base) % mod; 36 | } 37 | return result; 38 | } 39 | 40 | // Update the rabin fingerprint of the anchor text 41 | function updateRabinFingerprint(oldHash: number, hashSize: number, oldChar: string, newChar: string) { 42 | let p = 53; 43 | let m = 2 ** 20; 44 | let hash = (oldHash - oldChar.charCodeAt(0) * modExp(p, (hashSize - 1), m)) % m; 45 | hash = (hash * p + newChar.charCodeAt(0)) % m; 46 | hash = (hash + m) % m; 47 | return hash; 48 | } 49 | 50 | // Use the rabin fingerprint to find the closest matching anchor in a larger text 51 | export function findClosestAnchor(anchor: string, text: string) { 52 | if (text.length < anchor.length) { 53 | console.log("Text is smaller than anchor"); 54 | return -1; 55 | } 56 | else if (text.length === 0) { 57 | console.log("Text is empty"); 58 | return -1; 59 | } 60 | else if (anchor.length <= 8) { 61 | console.log("Anchor is smaller than hash size"); 62 | return -1; 63 | } 64 | 65 | let numLinesInAnchor = anchor.split(/\r\n|\r|\n/).length; 66 | anchor = normalizeAnchor(anchor); 67 | 68 | // Build hash set of anchor 69 | let hashSize = 8; 70 | let anchorHashes = new Set(); 71 | let currentAnchorSample = anchor.substring(0, hashSize); 72 | let lastHash = rabinFingerprint(currentAnchorSample, hashSize); 73 | let cnt = 0; 74 | for (let i = 0; i <= anchor.length - hashSize; i++) { 75 | cnt += 1; 76 | anchorHashes.add(lastHash); 77 | lastHash = updateRabinFingerprint(lastHash, hashSize, currentAnchorSample[0], anchor[(i + hashSize) % anchor.length]); 78 | currentAnchorSample = currentAnchorSample.substring(1) + anchor[(i + hashSize) % anchor.length]; 79 | } 80 | assert(cnt === anchor.length - hashSize + 1); 81 | assert(anchorHashes.size <= anchor.length - hashSize + 1); 82 | 83 | // Build initial hash of text 84 | let textHashes = []; 85 | let start = 0; 86 | let currentLine = 0; 87 | let text2Hash = ""; 88 | while (text2Hash.length < hashSize) { 89 | if (!NORMALIZE_CHARS.includes(text[start])) { 90 | text2Hash += text[start]; 91 | } 92 | else if (text[start] === "\n") { 93 | currentLine++; 94 | } 95 | start++; 96 | if (start > text.length) { 97 | console.log("Could not find enough text for anchor"); 98 | return -1; 99 | } 100 | } 101 | start -= hashSize; 102 | 103 | // Build hash set of text and find best match with anchor hash set 104 | lastHash = rabinFingerprint(text2Hash, hashSize); 105 | textHashes.push(lastHash); 106 | let currentTextSample = text2Hash; 107 | let bestMatch = -1; 108 | let bestMatchScore = 0; 109 | for (let i = start; i <= text.length - hashSize; i++) { 110 | // Skip over characters that we don't care about, but keep track of line numbers 111 | if (NORMALIZE_CHARS.includes(text[(i + hashSize) % text.length])) { 112 | if (text[(i + hashSize) % text.length] === "\n") { 113 | currentLine++; 114 | } 115 | continue; 116 | } 117 | 118 | // Determine how many hashes match 119 | let numMatches = 0; 120 | for (let j = 0; j < textHashes.length; j++) { 121 | if (anchorHashes.has(textHashes[j])) { 122 | numMatches++; 123 | } 124 | } 125 | if (numMatches >= anchorHashes.size * MINIMUM_MATCH_RATIO) { 126 | if (numMatches > bestMatchScore) { 127 | bestMatchScore = numMatches; 128 | bestMatch = currentLine; 129 | } 130 | } 131 | 132 | // Update hash sets 133 | lastHash = updateRabinFingerprint(lastHash, hashSize, currentTextSample[0], text[(i + hashSize) % text.length]); 134 | currentTextSample = currentTextSample.substring(1) + text[(i + hashSize) % text.length]; 135 | textHashes.push(lastHash); 136 | if (textHashes.length > anchorHashes.size) { 137 | textHashes.shift(); 138 | } 139 | } 140 | 141 | if (bestMatch === -1) { 142 | console.log("Could not find match in text"); 143 | return -1; 144 | } 145 | return bestMatch - numLinesInAnchor + 1; 146 | } 147 | -------------------------------------------------------------------------------- /src/comments.ts: -------------------------------------------------------------------------------- 1 | import { LocalDB } from './db'; 2 | import { assert } from 'console'; 3 | import * as vscode from 'vscode'; 4 | import { ANCHOR_LINES, findClosestAnchor } from './anchor'; 5 | import { RemoteDB } from './remote'; 6 | import * as util from './util'; 7 | import * as constants from './constants'; 8 | 9 | let isProjectEncrypted: boolean | undefined = undefined; 10 | 11 | interface OurThread extends vscode.CommentThread { 12 | id: string; 13 | } 14 | 15 | // NoteComment 16 | class NoteComment implements vscode.Comment { 17 | id: string; 18 | label: string | undefined; 19 | savedBody: string | vscode.MarkdownString; // for the Cancel button 20 | lastModified: number; 21 | constructor( 22 | public body: string | vscode.MarkdownString, 23 | public mode: vscode.CommentMode, 24 | public author: vscode.CommentAuthorInformation, 25 | public parent?: vscode.CommentThread, 26 | public contextValue?: string 27 | ) { 28 | if (isProjectEncrypted === false) { 29 | this.label = "(not signed, not encrypted)"; 30 | } 31 | else if (isProjectEncrypted === true) { 32 | this.label = "(not signed, encrypted)"; 33 | } 34 | else { 35 | this.label = "(not signed, ???)"; 36 | } 37 | this.id = util.genUUID(); 38 | this.savedBody = this.body; 39 | this.lastModified = Date.now() / 1000; 40 | } 41 | } 42 | 43 | export class IaCComments { 44 | private db: LocalDB; 45 | private rdb: RemoteDB; 46 | private threads: OurThread[] = []; 47 | private threadIdMap: Map = new Map(); 48 | private commentController: vscode.CommentController; 49 | private lastRemoteSync: number = 0; 50 | private currentSync: undefined | Promise = undefined; 51 | private disposables: vscode.Disposable[] = []; 52 | private disposed: boolean = false; 53 | private context: vscode.ExtensionContext; 54 | private nextSync: boolean = false; 55 | 56 | constructor(context: vscode.ExtensionContext, db: LocalDB, rdb: RemoteDB) { 57 | this.db = db; 58 | this.rdb = rdb; 59 | this.context = context; 60 | 61 | // Check if project is encrypted, update global 62 | isProjectEncrypted = this.context.workspaceState.get('projectEncrypted', undefined); 63 | 64 | // Create a comment controller 65 | this.commentController = vscode.comments.createCommentController(constants.EXT_NAME, 'IaC Comments'); 66 | this.commentController.commentingRangeProvider = { 67 | provideCommentingRanges: (document: vscode.TextDocument, token: vscode.CancellationToken) => { 68 | // Allow commenting on all lines 69 | return [new vscode.Range(0, 0, document.lineCount - 1, 0)]; 70 | } 71 | }; 72 | 73 | context.subscriptions.push(this.commentController); 74 | this.disposables.push(this.commentController); 75 | 76 | // Create comment button, when comment thread is empty 77 | let disposableCommand1 = vscode.commands.registerCommand(`${constants.EXT_NAME}.create_note`, async (reply: vscode.CommentReply) => { 78 | await this.createComment(reply.text, reply.thread); 79 | this.safeSyncComments(); 80 | }); 81 | context.subscriptions.push(disposableCommand1); 82 | this.disposables.push(disposableCommand1); 83 | 84 | // Reply to comment, when comment thread is not empty 85 | let disposableCommand2 = vscode.commands.registerCommand(`${constants.EXT_NAME}.replyNote`, async (reply: vscode.CommentReply) => { 86 | await this.createComment(reply.text, reply.thread); 87 | this.safeSyncComments(); 88 | }); 89 | context.subscriptions.push(disposableCommand2); 90 | this.disposables.push(disposableCommand2); 91 | 92 | // Delete comment 93 | let disposableCommand3 = vscode.commands.registerCommand(`${constants.EXT_NAME}.deleteNoteComment`, async (comment: NoteComment) => { 94 | const thread = comment.parent; 95 | if (!thread) { return; } 96 | 97 | await this.deleteComment(thread, comment.id); 98 | this.safeSyncComments(); 99 | }); 100 | context.subscriptions.push(disposableCommand3); 101 | this.disposables.push(disposableCommand3); 102 | 103 | // Delete comment thread 104 | let disposableCommand4 = vscode.commands.registerCommand(`${constants.EXT_NAME}.deleteNote`, async (thread: vscode.CommentThread) => { 105 | // Delete from global thread list 106 | await this.deleteThread(thread); 107 | thread.dispose(); 108 | this.safeSyncComments(); 109 | }); 110 | context.subscriptions.push(disposableCommand4); 111 | this.disposables.push(disposableCommand4); 112 | 113 | // Cancel editing comment without saving 114 | let disposableCommand5 = vscode.commands.registerCommand(`${constants.EXT_NAME}.cancelsaveNote`, (comment: NoteComment) => { 115 | if (!comment.parent) { 116 | return; 117 | } 118 | 119 | comment.parent.comments = comment.parent.comments.map(cmt => { 120 | if ((cmt as NoteComment).id === comment.id) { 121 | cmt.body = (cmt as NoteComment).savedBody; 122 | cmt.mode = vscode.CommentMode.Preview; 123 | } 124 | 125 | return cmt; 126 | }); 127 | }); 128 | context.subscriptions.push(disposableCommand5); 129 | this.disposables.push(disposableCommand5); 130 | 131 | // Save edited comment 132 | let disposableCommand6 = vscode.commands.registerCommand(`${constants.EXT_NAME}.saveNote`, async (comment: NoteComment) => { 133 | if (!comment.parent) { 134 | return; 135 | } 136 | 137 | await this.updateComment((comment.parent as OurThread), comment, comment.body.toString()); 138 | this.safeSyncComments(); 139 | }); 140 | context.subscriptions.push(disposableCommand6); 141 | this.disposables.push(disposableCommand6); 142 | 143 | // Edit comment 144 | let disposableCommand7 = vscode.commands.registerCommand(`${constants.EXT_NAME}.editNote`, (comment: NoteComment) => { 145 | if (!comment.parent) { 146 | return; 147 | } 148 | 149 | comment.parent.comments = comment.parent.comments.map(cmt => { 150 | if ((cmt as NoteComment).id === comment.id) { 151 | cmt.mode = vscode.CommentMode.Editing; 152 | } 153 | 154 | return cmt; 155 | }); 156 | }); 157 | context.subscriptions.push(disposableCommand7); 158 | this.disposables.push(disposableCommand7); 159 | 160 | // Delete all comments 161 | // TODO: add confirmation dialog 162 | let disposableCommand8 = vscode.commands.registerCommand(`${constants.EXT_NAME}.deleteAllNotes`, async () => { 163 | this.threads.forEach(async (thread) => await this.deleteThread(thread)); 164 | // Never dispose of the comment controller 165 | // commentController.dispose(); 166 | }); 167 | context.subscriptions.push(disposableCommand8); 168 | this.disposables.push(disposableCommand8); 169 | 170 | // Load threads from local database 171 | this.initComments().then(() => { 172 | // Sync with remote, now or when db is ready 173 | this.rdb.onDbReady(async () => { 174 | if (this.disposed) { return; } 175 | this.safeSyncComments(); 176 | }); 177 | 178 | this.rdb.onCommentsUpdated(async () => { 179 | if (this.disposed) { return; } 180 | console.log("[IaC Comments] Comments updated"); 181 | this.safeSyncComments(); 182 | }); 183 | }); 184 | } 185 | 186 | // Will check if a sync is currently in progress, and if not, will start one 187 | private safeSyncComments() { 188 | if (this.disposed) { return; } 189 | if (this.currentSync !== undefined) { 190 | this.nextSync = true; 191 | console.log("[IaC Comments] Sync already in progress, syncing later"); 192 | return; 193 | } 194 | this.currentSync = this.syncComments().finally(() => { 195 | this.currentSync = undefined; 196 | if (this.nextSync) { 197 | console.log("[IaC Comments] Sync done, doing next sync"); 198 | this.nextSync = false; 199 | this.safeSyncComments(); 200 | } 201 | else { 202 | console.log("[IaC Comments] Sync done"); 203 | } 204 | }); 205 | } 206 | 207 | private async createComment(text: string, thread: vscode.CommentThread, author?: string | undefined, id?: string | undefined, lastModified?: number | undefined) { 208 | let commentAuthor = author ? { name: author } : { name: vscode.workspace.getConfiguration(`${constants.EXT_NAME}`).get("authorName", "") }; 209 | 210 | const newComment = new NoteComment( 211 | text, 212 | vscode.CommentMode.Preview, 213 | commentAuthor, 214 | thread, 215 | true ? 'canDelete' : undefined, 216 | ); 217 | if (id) { 218 | newComment.id = id; 219 | } 220 | if (lastModified) { 221 | newComment.lastModified = lastModified; 222 | } 223 | else { 224 | lastModified = await this.rdb.getRemoteTimestamp(); 225 | } 226 | thread.comments = [...thread.comments, newComment]; 227 | 228 | // Update global thread list 229 | await this.updateThreadInLocalDb(thread); 230 | } 231 | 232 | private async updateComment(thread: OurThread, comment: NoteComment, text: string, lastModified?: number | undefined) { 233 | comment.body = text; 234 | comment.savedBody = text; 235 | if (lastModified === undefined) { 236 | lastModified = await this.rdb.getRemoteTimestamp(); 237 | } 238 | 239 | // Update comment in the parent thread, not local object copy 240 | thread.comments = thread.comments.map(cmt => { 241 | if ((cmt as NoteComment).id !== comment.id) { return cmt; }; 242 | (cmt as NoteComment).savedBody = cmt.body; 243 | cmt.body = text; 244 | (cmt as NoteComment).lastModified = (lastModified as number); 245 | return cmt; 246 | }); 247 | 248 | // Update global thread list 249 | await this.updateThreadInLocalDb(thread); 250 | } 251 | 252 | async deleteComment(thread: vscode.CommentThread, commentId: string) { 253 | thread.comments = thread.comments.filter(cmt => (cmt as NoteComment).id !== commentId); 254 | 255 | // Update global thread list 256 | await this.updateThreadInLocalDb(thread, false, true); 257 | 258 | if (thread.comments.length === 0) { 259 | // Delete from global thread list 260 | this.deleteThread(thread); 261 | 262 | thread.dispose(); 263 | } 264 | } 265 | 266 | private async createThread(id: string, anchorLine: number, filePath: string) { 267 | let commentThread = this.commentController.createCommentThread( 268 | vscode.Uri.file(filePath), 269 | new vscode.Range(anchorLine, 0, anchorLine, 0), 270 | [] 271 | ) as OurThread; 272 | commentThread.id = id; 273 | console.log("[IaC Comments] Created thread with id " + id); 274 | await this.updateThreadInLocalDb(commentThread, false); 275 | return commentThread; 276 | } 277 | 278 | private anchor2anchorLine(rawAnchor: string, docdata: string) { 279 | let parsedAnchor = JSON.parse(rawAnchor); 280 | let anchorLineOrig = parsedAnchor["line"]; 281 | let anchorText = parsedAnchor["text"]; 282 | let numLinesInAnchor = parsedAnchor["num"]; 283 | 284 | let anchorLine = findClosestAnchor(anchorText, docdata); 285 | if (anchorLine === -1) { 286 | anchorLine = anchorLineOrig; 287 | } 288 | else { 289 | anchorLine = anchorLine + numLinesInAnchor; 290 | } 291 | console.log("[IaC Comments] Closest anchor: " + anchorLine); 292 | return anchorLine; 293 | }; 294 | 295 | private async initComments() { 296 | // Load database content into global thread list 297 | let [dbThreads, dbComments] = await this.db.dbGetThreads(); 298 | dbThreads.forEach((row: any) => { 299 | console.log("[IaC Comments] Creating thread at line " + row.anchor + " in file " + row.file_path); 300 | // Create comment thread 301 | //console.log("Anchor has lines: " + anchorText.split(/\r\n|\r|\n/).length); 302 | //console.log("Anchor numLinesInAnchor: " + numLinesInAnchor); 303 | vscode.workspace.fs.readFile(vscode.Uri.file(row.file_path)).then(async (data) => { 304 | let anchorLine = this.anchor2anchorLine(row.anchor, data.toString()); 305 | 306 | let commentThread = await this.createThread(row.id, anchorLine, row.file_path); 307 | 308 | // Load database content into comment thread 309 | let commentArr = dbComments.get(row.id); 310 | for (const comment of commentArr) { 311 | console.log("[IaC Comments] Creating comment " + comment.comment + " by " + comment.user_created); 312 | this.createComment(comment.comment, commentThread, comment.user_created, comment.id, comment.timestamp_updated); 313 | } 314 | }); 315 | }); 316 | } 317 | 318 | async updateThreadInLocalDb(thread: vscode.CommentThread, deleteEmptyThreads: boolean = true, processDeletions: boolean = false) { 319 | // Add thread to global list 320 | console.log("[IaC Comments] Updating thread in local db"); 321 | if (!this.threadIdMap.has(thread)) { 322 | let tid = util.genUUID(); 323 | let oThread = thread as OurThread; 324 | if (oThread.id) { 325 | tid = oThread.id; 326 | } 327 | console.log("[IaC Comments] Adding thread with id " + tid + " to global list"); 328 | oThread.id = tid; 329 | this.threads.push(oThread); 330 | this.threadIdMap.set(thread, tid); 331 | } 332 | 333 | // Update thread in the database 334 | let tid = this.threadIdMap.get(thread); 335 | if (tid === undefined) { 336 | // This should never happen 337 | return; 338 | } 339 | 340 | let oThread = thread as OurThread; 341 | oThread.id = tid; 342 | 343 | let anchor = this.thread2anchor(thread); 344 | 345 | let filePath = oThread.uri.path; 346 | await this.db.createOrReplaceThread(tid, anchor, filePath); 347 | 348 | if (deleteEmptyThreads && thread.comments.length === 0) { 349 | await this.deleteThread(thread); 350 | return; 351 | } 352 | 353 | // Update comments in the database 354 | for (let comment of thread.comments) { 355 | let oComment = comment as NoteComment; 356 | await this.db.dbCreateOrReplaceComment(oComment.id, tid, oComment.body.toString(), oComment.author.name, oComment.lastModified); 357 | console.log("[IaC Comments] Inserting comment " + oComment.body + " by " + oComment.author.name + " into thread " + tid); 358 | } 359 | 360 | if (!processDeletions) { 361 | return; 362 | } 363 | 364 | // Get a list of all comment ids in the thread from the database 365 | let commentIds: string[] = await this.db.getCommentsForThread(tid); 366 | // Filter only comment ids that are not in the thread. 367 | let newCommentIds = commentIds.filter(cid => !thread.comments.map(comment => (comment as NoteComment).id).includes(cid)); 368 | 369 | // Delete comments that are not in the thread 370 | if (newCommentIds.length > 0) { 371 | console.log("[IaC Comments] Deleting comments " + newCommentIds + " from thread " + tid + " because they are not in the thread"); 372 | await this.db.dbDeleteComments(newCommentIds); 373 | } 374 | } 375 | 376 | private thread2anchor(oThread: vscode.CommentThread) { 377 | // Compute anchor 378 | let curDocument = vscode.workspace.textDocuments.find((doc) => doc.uri.path === oThread.uri.path); 379 | let anchorLine = oThread.range.start.line; 380 | let anchorText = ""; 381 | let numLinesFromAnchor = 0; 382 | if (curDocument !== undefined) { 383 | let anchorTextLineBegin = Math.max(anchorLine - ANCHOR_LINES, 0); 384 | let anchorTextLineEnd = Math.min(anchorLine + ANCHOR_LINES, curDocument.lineCount); 385 | let anchorText1 = curDocument.getText(new vscode.Range(anchorTextLineBegin, 0, anchorLine, 0)); 386 | let anchorText2 = curDocument.getText(new vscode.Range(anchorLine, 0, anchorTextLineEnd, 0)); 387 | 388 | // Limit size of anchor text to 1000 characters 389 | anchorText1 = anchorText1.substring(Math.max(anchorText1.length - 500, 0)); 390 | anchorText2 = anchorText2.substring(0, 500); 391 | numLinesFromAnchor = anchorText1.split(/\r\n|\r|\n/).length - 1; 392 | anchorText = anchorText1 + anchorText2; 393 | } 394 | let anchor = JSON.stringify({ "line": anchorLine, "text": anchorText, "num": numLinesFromAnchor }); 395 | return anchor; 396 | } 397 | 398 | async deleteThread(thread: vscode.CommentThread) { 399 | // Remove thread from global list 400 | if (!this.threadIdMap.has(thread)) { 401 | return; 402 | } 403 | let tid = this.threadIdMap.get(thread); 404 | if (tid === undefined) { 405 | // This should never happen 406 | assert(false, "[IaC Comments] tid is undefined"); 407 | return; 408 | } 409 | this.threads = this.threads.filter(t => t.id !== tid); 410 | 411 | await this.db.dbDeleteTid(tid); 412 | } 413 | 414 | async syncComments() { 415 | let deletedComments = await this.db.dbGetDeletedComments(); 416 | let deletedThreads = await this.db.dbGetDeletedThreads(); 417 | 418 | console.log("[IaC comments] syncComments();"); 419 | if (!this.rdb.isRemoteReady()) { 420 | console.log("[IaC comments] Remote database not ready"); 421 | return; 422 | } 423 | 424 | // Fetch comments from remote database 425 | let [remoteObjects, remoteDeletions] = await this.rdb.getComments(this.lastRemoteSync); 426 | 427 | // Push local deletions to remote database 428 | let remoteDeletedComments = remoteDeletions.filter((deletion: any) => deletion.type === "comment").map((deletion: any) => deletion.cid); 429 | let remoteDeletedThreads = remoteDeletions.filter((deletion: any) => deletion.type === "thread").map((deletion: any) => deletion.tid); 430 | let reloadRemote = false; 431 | for (const [cid, tid] of deletedComments) { 432 | if (remoteDeletedComments.includes(cid)) { continue; }; 433 | await this.rdb.pushComment(cid, tid, null, null, null, true); 434 | reloadRemote = true; 435 | } 436 | for (const tid of deletedThreads) { 437 | if (remoteDeletedThreads.includes(tid)) { continue; }; 438 | await this.rdb.pushThread(tid, null, true, null, null); 439 | reloadRemote = true; 440 | } 441 | if (reloadRemote) { 442 | [remoteObjects, remoteDeletions] = await this.rdb.getComments(this.lastRemoteSync); 443 | } 444 | 445 | // TODO: fix issues in case local clock is not in sync with remote clock 446 | this.lastRemoteSync = await this.rdb.getRemoteTimestamp(); 447 | // Temporary fix: sync all comments all the time 448 | this.lastRemoteSync = 0; 449 | 450 | // If there are any deletions, delete them from the local database 451 | for (let deletion of remoteDeletions) { 452 | if (deletion.type === "thread") { 453 | // Get thread 454 | let thread = this.threads.find(t => t.id === deletion.tid); 455 | if (thread !== undefined) { 456 | console.log("[IaC Comments] Remote deletion, deleting thread " + thread.id); 457 | if (this.disposed) { return; } 458 | await this.deleteThread(thread); 459 | } 460 | } 461 | else if (deletion.type === "comment") { 462 | // Get comment 463 | let thread = this.threads.find(t => t.id === deletion.tid); 464 | if (thread !== undefined) { 465 | console.log("[IaC Comments] Remote deletion, deleting comment " + deletion.cid + " from thread " + thread.id); 466 | if (this.disposed) { return; } 467 | await this.deleteComment(thread, deletion.cid); 468 | } 469 | } 470 | } 471 | 472 | // If there is any new or edited comment or new thread, update the local database and the UI 473 | let remoteThreads = remoteObjects.filter((object: any) => object.type === "thread"); 474 | let remoteComments = remoteObjects.filter((object: any) => object.type === "comment"); 475 | 476 | for (const object of remoteThreads) { 477 | // Get thread 478 | let thread = this.threads.find(t => t.id === object.tid); 479 | if (thread === undefined) { 480 | // Create new thread 481 | const absPath = util.relPathToAbs(object.filePath); 482 | let data = await vscode.workspace.fs.readFile(vscode.Uri.file(absPath)); 483 | let anchorLine = this.anchor2anchorLine(object.anchor, data.toString()); 484 | if (this.disposed) { return; } 485 | await this.createThread(object.tid, anchorLine, absPath); 486 | console.log("[IaC Comments] List of thread ids after createThread:" + this.threads.map(t => t.id)); 487 | } 488 | else { 489 | // Update existing thread 490 | // TODO: update anchor 491 | // Thread id and file path are immutable and cannot be changed 492 | } 493 | } 494 | 495 | for (const object of remoteComments) { 496 | // Get thread 497 | let thread = this.threads.find(t => t.id === object.tid); 498 | if (thread === undefined) { 499 | console.error("[IaC Comments] This should never happen. Comment without thread. Remote database is corrupted."); 500 | console.log("[IaC Comments] List of thread ids:" + this.threads.map(t => t.id)); 501 | console.log("[IaC Comments] Comment: " + object.tid); 502 | return; 503 | } 504 | 505 | // Get comment 506 | let comment = thread.comments.find(c => (c as NoteComment).id === object.cid); 507 | if (comment === undefined) { 508 | // Create new comment 509 | if (this.disposed) { return; } 510 | await this.createComment(object.comment, thread, object.userCreated, object.cid, object.timestampModified); 511 | 512 | // Notify user 513 | let relPath = util.absPathToRel(thread.uri.fsPath); 514 | 515 | // Show a notification with a "Jump to comment" button 516 | const showCommentAction = 'Show comment'; 517 | vscode.window.showInformationMessage(`[${object.userCreated}] 📥 Added a comment on ${relPath}`, showCommentAction).then( 518 | (selection) => { 519 | if ((selection === showCommentAction) && (thread !== undefined)) { 520 | // Jump to comment 521 | vscode.window.showTextDocument(thread.uri).then((editor) => { 522 | editor.revealRange((thread as OurThread).range, vscode.TextEditorRevealType.InCenter); 523 | }); 524 | } 525 | } 526 | ); 527 | } 528 | else { 529 | // Update existing comment 530 | console.log("[IaC Comments] Remote comment updated an existing comment"); 531 | console.log("[IaC Comments] Comment last modified: " + (comment as NoteComment).lastModified); 532 | console.log("[IaC Comments] Remote last modified: " + object.timestampModified); 533 | if ((comment as NoteComment).lastModified + 1 < object.timestampModified) { 534 | console.log(`[IaC Comments] Updating comment ${object.cid} as it is newer than the local version`); 535 | if (this.disposed) { return; } 536 | await this.updateComment(thread, (comment as NoteComment), object.comment, object.timestampModified); 537 | 538 | // Notify user 539 | let relPath = util.absPathToRel(thread.uri.fsPath); 540 | 541 | // Show a notification with a "Jump to comment" button 542 | const showCommentAction = 'Show comment'; 543 | vscode.window.showInformationMessage(`[${object.userCreated}] ✍️ Updated a comment on ${relPath}`, showCommentAction).then( 544 | (selection) => { 545 | if ((selection === showCommentAction) && (thread !== undefined)) { 546 | // Jump to comment 547 | vscode.window.showTextDocument(thread.uri).then((editor) => { 548 | editor.revealRange((thread as OurThread).range, vscode.TextEditorRevealType.InCenter); 549 | }); 550 | } 551 | } 552 | ); 553 | } 554 | } 555 | } 556 | 557 | // Determine which comments and threads are not in the remote database or have been deleted or edited more recently than the remote database 558 | let localThreads = this.threads.filter((thread: vscode.CommentThread) => { 559 | let oThread = thread as OurThread; 560 | let remoteThread = remoteThreads.find((object: any) => object.tid === oThread.id); 561 | if (remoteThread === undefined) { 562 | return true; 563 | } 564 | // return remoteThread.last_modified < oThread.lastModified; 565 | // TODO: check if anchor has changed and update it 566 | return false; 567 | }); 568 | 569 | let localComments = this.threads.map((thread: vscode.CommentThread) => { 570 | return thread.comments.filter((comment: vscode.Comment) => { 571 | let oComment = comment as NoteComment; 572 | let remoteComment = remoteComments.find((object: any) => object.cid === oComment.id); 573 | if (remoteComment === undefined) { 574 | console.log(`[IaC Comments] Local comment ${oComment.id} not found in remote database, will push it`); 575 | return true; 576 | } 577 | console.log(`[IaC Comments] ${remoteComment.timestampModified} < ${oComment.lastModified}`); 578 | if (remoteComment.timestampModified < oComment.lastModified) { 579 | console.log(`[IaC Comments] Local comment ${oComment.id} is newer than remote version, will push it`); 580 | } else { 581 | console.log(`[IaC Comments] Remote comment ${oComment.id} is newer than local version, will not push it`); 582 | } 583 | return remoteComment.timestampModified < oComment.lastModified; 584 | }); 585 | }).reduce((a, b) => a.concat(b), []); 586 | 587 | // Push local changes to remote database 588 | for (const thread of localThreads) { 589 | let tid = this.threadIdMap.get(thread); 590 | if (tid === undefined) { 591 | // This should never happen 592 | assert(false, "[IaC Comments] tid is undefined"); 593 | return; 594 | } 595 | console.log("[IaC Comments] Pushing thread " + tid + " to remote database"); 596 | let anchor = this.thread2anchor(thread); 597 | let timestamp = await this.rdb.getRemoteTimestamp(); 598 | this.rdb.pushThread(tid, timestamp, false, util.absPathToRel(thread.uri.fsPath), anchor); 599 | } 600 | 601 | for (const comment of localComments) { 602 | let oComment = comment as NoteComment; 603 | if (oComment.parent === undefined) { 604 | // This should never happen 605 | assert(false, "[IaC Comments] oComment.parent is undefined"); 606 | return; 607 | } 608 | let tid = this.threadIdMap.get(oComment.parent); 609 | if (tid === undefined) { 610 | // This should never happen 611 | assert(false, "[IaC Comments] tid is undefined"); 612 | return; 613 | } 614 | console.log("[IaC Comments] Pushing comment " + oComment.id + " to remote database"); 615 | let timestamp = await this.rdb.getRemoteTimestamp(); 616 | this.rdb.pushComment(oComment.id, tid, oComment.body.toString(), oComment.author.name, timestamp, false); 617 | } 618 | } 619 | 620 | async dispose() { 621 | for (const disposable of this.disposables) { 622 | this.context.subscriptions.splice(this.context.subscriptions.indexOf(disposable), 1); 623 | await disposable.dispose(); 624 | } 625 | this.disposed = true; 626 | } 627 | } 628 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const EXT_COMMON_NAME = "PoiEx"; 2 | export const EXT_NAME = "poiex"; 3 | export const SEMGREP_TIMEOUT_MS = 240 * 1000; // Semgrep timeout to be used when scanning HCL files 4 | export const SEMGREP_MAX_BUFFER = 1024 * 1024 * 20; // 20MB 5 | export const REMOTEDB_INIT_TIMEOUT_MS = 10 * 1000; 6 | export const RW_CHECK_COLLECTION = "readWriteCheckCollection"; 7 | export const PROJECT_DIR_COLLECTION = "projectDir"; 8 | export const PROJECT_DIR_DB = "projectDirDB.db"; 9 | export const IAC_FOLDER_NAME = "poiex-data"; 10 | export const DIAGNOSTICS_COLLECTION_PREFIX = "diagnostics_"; 11 | export const COMMENTS_COLLECTION_PREFIX = "comments_"; 12 | export const DIAGNOSTICS_CODENAME = 'poiex'; 13 | export const IAC_POI_MESSAGE = "IaC Point Of Intersection:"; 14 | export const INFRAMAP_TIMEOUT_MS = 10 * 1000; 15 | export const INFRAMAP_DOWNLOADED_STATENAME = "inframapDownloading"; 16 | export const PROJECT_TREE_VIEW_TITLE = "PoiEx: Project List"; 17 | export const PROJECT_TREE_VIEW_NO_PROJECTS_MESSAGE = "No projects found."; 18 | export const PROJECT_TREE_VIEW_DB_ERROR_MESSAGE = "Invalid DB configuration."; 19 | 20 | export const FLAG_UNFLAGGED = 0; 21 | export const FLAG_FALSE = 1; 22 | export const FLAG_HOT = 2; 23 | export const FLAG_RESOLVED = 3; 24 | 25 | export const INFRAMAP_RELEASES: { [id: string] : any; } = { 26 | "linux": { 27 | "url": "https://github.com/cycloidio/inframap/releases/download/v0.6.7/inframap-linux-amd64.tar.gz", 28 | "integrity": "sha384-a8ANZlhXEgA64SjPBtbJiV3T33/wmxFImZMJTJpWJmffNGDJwnu6/knZDsHtZNz3" 29 | }, 30 | "darwin": { 31 | "url": "https://github.com/cycloidio/inframap/releases/download/v0.6.7/inframap-darwin-amd64.tar.gz", 32 | "integrity": "sha384-eSEnikqoFvap1D9sSl6Y7Ka9TEPnWBvvWJ9unHPMqJVcXeHgHMcmeTjZvJ4hYEZT" 33 | } 34 | }; 35 | 36 | // Set this to true to use <= MongoDB 4.X 37 | export const IS_MONGO_4 = false; -------------------------------------------------------------------------------- /src/db.ts: -------------------------------------------------------------------------------- 1 | import * as sqlite3 from 'sqlite3'; 2 | import * as sqlite from 'sqlite'; 3 | import { assert } from 'console'; 4 | 5 | import * as util from './util'; 6 | 7 | // Create a class called LocalDB 8 | export class LocalDB { 9 | // Create a private variable called db 10 | private db: sqlite.Database | null; 11 | path: string; 12 | 13 | // Create a constructor that takes a path to the database 14 | constructor(path: string) { 15 | // Create a new sqlite3 database 16 | this.db = null; 17 | this.path = path; 18 | } 19 | 20 | async init() { 21 | this.db = await sqlite.open({ 22 | filename: this.path, 23 | driver: sqlite3.Database 24 | }); 25 | await this.dbCreateTables(); 26 | } 27 | 28 | // Create a function called close that closes the database 29 | async close() { 30 | if (this.db === null) { 31 | return; 32 | } 33 | console.log('[IaC LocalDB] Closing'); 34 | let olddb = this.db; 35 | this.db = null; 36 | // Close, but not await. May at times create a memory leak 37 | // TODO: Fix memory leak 38 | olddb.close().then(() => { 39 | console.log('[IaC LocalDB] Closed'); 40 | }).catch((err: any) => { 41 | console.log('[IaC LocalDB] FIXME Error closing db: ' + err); 42 | }); 43 | } 44 | 45 | // Given a thread id, return a list of all comment ids that are children of that thread 46 | async getCommentsForThread(threadId: string): Promise { 47 | assert(this.db !== null, '[IaC LocalDB] db is null'); 48 | if (this.db === null) { return []; } 49 | 50 | let comments: string[] = []; 51 | let stmt = await this.db.prepare('SELECT id FROM comments WHERE thread_id = ?'); 52 | let rows = await stmt.all(threadId); 53 | await stmt.finalize(); 54 | rows.forEach((row: any) => { 55 | comments.push(row.id); 56 | }); 57 | return comments; 58 | } 59 | 60 | async dropAllTables() { 61 | assert(this.db !== null, '[IaC LocalDB] db is null'); 62 | if (this.db === null) { return []; } 63 | 64 | let stmt1 = await this.db.prepare('DROP TABLE IF EXISTS comments'); 65 | await stmt1.run(); 66 | await stmt1.finalize(); 67 | let stmt2 = await this.db.prepare('DROP TABLE IF EXISTS threads'); 68 | await stmt2.run(); 69 | await stmt2.finalize(); 70 | let stmt3 = await this.db.prepare('DROP TABLE IF EXISTS diagnostics'); 71 | await stmt3.run(); 72 | await stmt3.finalize(); 73 | } 74 | 75 | // DB 76 | async dbCreateTables() { 77 | assert(this.db !== null, '[IaC LocalDB] db is null'); 78 | if (this.db === null) { return []; } 79 | 80 | let stmt; 81 | stmt = await this.db.prepare('CREATE TABLE IF NOT EXISTS comments (id TEXT PRIMARY KEY, thread_id TEXT, comment TEXT, timestamp_updated DATETIME DEFAULT CURRENT_TIMESTAMP, timestamp_created DATETIME DEFAULT CURRENT_TIMESTAMP, user_created TEXT, deleted INTEGER DEFAULT 0)'); 82 | await stmt.run(); 83 | await stmt.finalize(); 84 | stmt = await this.db.prepare('CREATE TABLE IF NOT EXISTS threads (id TEXT PRIMARY KEY, anchor TEXT, file_path TEXT, deleted INTEGER DEFAULT 0)'); 85 | await stmt.run(); 86 | await stmt.finalize(); 87 | stmt = await this.db.prepare('CREATE TABLE IF NOT EXISTS diagnostics (id TEXT PRIMARY KEY, diagnostic TEXT, flag INTEGER, flag_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, anchor TEXT, file_path TEXT, timestamp_created DATETIME DEFAULT CURRENT_TIMESTAMP)'); 88 | await stmt.run(); 89 | await stmt.finalize(); 90 | } 91 | 92 | // DB 93 | async dbGetThreads(): Promise<[any[], Map]> { 94 | assert(this.db !== null, '[IaC LocalDB] db is null'); 95 | if (this.db === null) { return [[], new Map()]; } 96 | 97 | let threads: any[] = []; 98 | let comments: Map = new Map(); 99 | let stmt = await this.db.prepare('SELECT * FROM threads WHERE deleted = 0'); 100 | let rows = await stmt.all(); 101 | await stmt.finalize(); 102 | rows.forEach((row: any) => { 103 | threads.push(row); 104 | comments.set(row.id, []); 105 | }); 106 | stmt = await this.db.prepare('SELECT * FROM comments WHERE deleted = 0'); 107 | rows = await stmt.all(); 108 | rows.forEach((row: any) => { 109 | comments.get(row.thread_id).push(row); 110 | }); 111 | return [threads, comments]; 112 | } 113 | 114 | // DB 115 | async deleteOrphanComments(): Promise { 116 | assert(this.db !== null, '[IaC LocalDB] db is null'); 117 | if (this.db === null) { return; } 118 | 119 | // Delete comments that are not associated with a thread 120 | // let stmt = await this.db.prepare('DELETE FROM comments WHERE thread_id NOT IN (SELECT id FROM threads)'); 121 | let stmt = await this.db.prepare('UPDATE comments SET comment = NULL, timestamp_updated = NULL, timestamp_created = NULL, user_created = NULL, deleted = 1 WHERE thread_id NOT IN (SELECT id FROM threads)'); 122 | await stmt.run(); 123 | await stmt.finalize(); 124 | } 125 | 126 | // DB 127 | async createOrReplaceThread(tid: string, anchor: string, filePath: string): Promise { 128 | assert(this.db !== null, '[IaC LocalDB] db is null'); 129 | if (this.db === null) { return; } 130 | 131 | let stmt = await this.db.prepare('INSERT OR REPLACE INTO threads (id, anchor, file_path) VALUES (?, ?, ?)'); 132 | await stmt.run(tid, anchor, filePath); 133 | await stmt.finalize(); 134 | } 135 | 136 | // DB 137 | async dbCreateOrReplaceComment(cid: string, tid: string, comment: string, userCreated: string, lastModified: number | null = null): Promise { 138 | assert(this.db !== null, '[IaC LocalDB] db is null'); 139 | if (this.db === null) { return; } 140 | 141 | lastModified = lastModified === null ? (Date.now() / 1000) : lastModified; 142 | let stmt = await this.db.prepare('INSERT OR REPLACE INTO comments (id, thread_id, comment, user_created, timestamp_updated) VALUES (?, ?, ?, ?, ?)'); 143 | await stmt.run(cid, tid, comment, userCreated, lastModified); 144 | await stmt.finalize(); 145 | } 146 | 147 | // DB 148 | async dbDeleteComments(cids: string[]): Promise { 149 | assert(this.db !== null, '[IaC LocalDB] db is null'); 150 | if (this.db === null) { return; } 151 | 152 | // Delete comments from database 153 | // let stmt = await this.db.prepare('DELETE FROM comments WHERE id IN (?)'); 154 | let stmt = await this.db.prepare('UPDATE comments SET comment = NULL, timestamp_updated = NULL, timestamp_created = NULL, user_created = NULL, deleted = 1 WHERE id IN (?)'); 155 | await stmt.run(cids); 156 | await stmt.finalize(); 157 | } 158 | 159 | // DB 160 | async dbDeleteTid(tid: string): Promise { 161 | assert(this.db !== null, '[IaC LocalDB] db is null'); 162 | if (this.db === null) { return; } 163 | 164 | // Delete thread from database 165 | // let stmt = this.db.prepare('DELETE FROM threads WHERE id = ?'); 166 | let stmt = await this.db.prepare('UPDATE threads SET anchor = NULL, file_path = NULL, deleted = 1 WHERE id = ?'); 167 | await stmt.run(tid); 168 | await stmt.finalize(); 169 | 170 | // stmt = this.db.prepare('DELETE FROM comments WHERE thread_id = ?'); 171 | stmt = await this.db.prepare('UPDATE comments SET comment = NULL, timestamp_updated = NULL, timestamp_created = NULL, user_created = NULL, deleted = 1 WHERE thread_id = ?'); 172 | await stmt.run(tid); 173 | await stmt.finalize(); 174 | 175 | // Delete orphan comments 176 | await this.deleteOrphanComments(); 177 | } 178 | 179 | async dbGetDeletedThreads(): Promise { 180 | assert(this.db !== null, '[IaC LocalDB] db is null'); 181 | if (this.db === null) { return []; } 182 | 183 | let stmt = await this.db.prepare('SELECT id FROM threads WHERE deleted = 1'); 184 | let res = await stmt.all(); 185 | await stmt.finalize(); 186 | return res.map((row: any) => row.id); 187 | } 188 | 189 | async dbGetDeletedComments(): Promise<[string, string][]> { 190 | assert(this.db !== null, '[IaC LocalDB] db is null'); 191 | if (this.db === null) { return []; } 192 | 193 | let stmt = await this.db.prepare('SELECT id, thread_id FROM comments WHERE deleted = 1'); 194 | let rows = await stmt.all(); 195 | await stmt.finalize(); 196 | return rows.map((row: any) => [row.id, row.thread_id]); 197 | } 198 | 199 | async dbClearDiagnostics(): Promise { 200 | assert(this.db !== null, '[IaC LocalDB] db is null'); 201 | if (this.db === null) { return; } 202 | 203 | let stmt = await this.db.prepare('DELETE FROM diagnostics'); 204 | await stmt.run(); 205 | await stmt.finalize(); 206 | } 207 | 208 | async dbDeleteDiagnostic(uuid: string): Promise { 209 | assert(this.db !== null, '[IaC LocalDB] db is null'); 210 | if (this.db === null) { return; } 211 | 212 | let stmt = await this.db.prepare('DELETE FROM diagnostics WHERE id = ?'); 213 | await stmt.run(uuid); 214 | await stmt.finalize(); 215 | } 216 | 217 | async dbGetDiagnostics(): Promise { 218 | assert(this.db !== null, '[IaC LocalDB] db is null'); 219 | if (this.db === null) { return []; } 220 | 221 | let stmt = await this.db.prepare('SELECT id, diagnostic, flag, flag_timestamp, anchor, file_path, timestamp_created FROM diagnostics'); 222 | let res = await stmt.all(); 223 | await stmt.finalize(); 224 | return res; 225 | } 226 | 227 | async dbCreateOrReplaceDiagnosticWithId(uuid: string, diagnostic: string, anchor: string, filePath: string, flag: number, lastModified: number | null = null): Promise { 228 | assert(uuid !== null && uuid !== undefined, '[IaC LocalDB] uuid must be defined'); 229 | assert(diagnostic !== null && diagnostic !== undefined, '[IaC LocalDB] diagnostic must be defined'); 230 | assert(anchor !== null && anchor !== undefined, '[IaC LocalDB] anchor must be defined'); 231 | assert(filePath !== null && filePath !== undefined, '[IaC LocalDB] filePath must be defined'); 232 | assert(this.db !== null, '[IaC LocalDB] db is null'); 233 | if (this.db === null) { return; } 234 | 235 | lastModified = lastModified === null ? (Date.now() / 1000) : lastModified; 236 | let stmt = await this.db.prepare('INSERT OR REPLACE INTO diagnostics (id, diagnostic, flag, flag_timestamp, anchor, file_path, timestamp_created) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, ?, ?)'); 237 | console.log(`[IaC LocalDB] Binding sqlite parameters for diagnostic: ${uuid}, ${diagnostic}, ${anchor}, ${filePath}, ${lastModified}`); 238 | await stmt.run(uuid, diagnostic, flag, anchor, filePath, lastModified); 239 | await stmt.finalize(); 240 | } 241 | 242 | async dbUpdateDiagnosticFlag(uuid: string, flag: number): Promise { 243 | assert(uuid !== null && uuid !== undefined, '[IaC LocalDB] uuid must be defined'); 244 | assert(flag !== null && flag !== undefined, '[IaC LocalDB] flag must be defined'); 245 | assert(this.db !== null, '[IaC LocalDB] db is null'); 246 | if (this.db === null) { return; } 247 | 248 | let stmt = await this.db.prepare('UPDATE diagnostics SET flag = ?, flag_timestamp = CURRENT_TIMESTAMP WHERE id = ?'); 249 | await stmt.run(flag, uuid); 250 | await stmt.finalize(); 251 | } 252 | 253 | async dbCreateOrReplaceDiagnostic(diagnostic: string, anchor: string, filePath: string, flag: number): Promise { 254 | let uuid = util.genUUID(); 255 | await this.dbCreateOrReplaceDiagnosticWithId(uuid, diagnostic, anchor, filePath, flag); 256 | return uuid; 257 | } 258 | } -------------------------------------------------------------------------------- /src/diagnostics.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as path from 'path'; 3 | import { assert } from 'console'; 4 | import { LocalDB } from './db'; 5 | import { ANCHOR_LINES, findClosestAnchor } from './anchor'; 6 | import { RemoteDB } from './remote'; 7 | import * as util from './util'; 8 | import { DIAGNOSTICS_CODENAME } from './constants'; 9 | import * as constants from './constants'; 10 | import { cursorTo } from 'readline'; 11 | 12 | export class SastInfo implements vscode.CodeActionProvider { 13 | // Create a map of diagnostic to code action 14 | private _diagnosticCodeActions = new Map(); 15 | 16 | public static readonly providedCodeActionKinds = [ 17 | vscode.CodeActionKind.QuickFix 18 | ]; 19 | 20 | provideCodeActions(document: vscode.TextDocument, range: vscode.Range | vscode.Selection, context: vscode.CodeActionContext, token: vscode.CancellationToken): vscode.CodeAction[] { 21 | let codeActions: vscode.CodeAction[] = []; 22 | this._diagnosticCodeActions.forEach((value, key) => { 23 | if (context.diagnostics.includes(key)) { 24 | codeActions = codeActions.concat(value); 25 | } 26 | }); 27 | console.log(codeActions.map((codeAction) => codeAction.title)); 28 | return codeActions; 29 | /*return context.diagnostics 30 | .filter(diagnostic => diagnostic.code === DIAGNOSTICS_CODENAME) 31 | .filter(diagnostic => this._diagnosticCodeActions.has(diagnostic)) 32 | .map(diagnostic => this._diagnosticCodeActions.get(diagnostic) as vscode.CodeAction);*/ 33 | } 34 | } 35 | 36 | export class IaCDiagnostics { 37 | private _diagnostics: vscode.DiagnosticCollection; 38 | private _diagnosticsIds: Map = new Map(); 39 | private _rawDiagnostics: Map = new Map(); 40 | private _sastInfo: any; 41 | private _db: LocalDB; 42 | private _rdb: RemoteDB | undefined; 43 | private _currentlyPulling: boolean = false; 44 | private _disposables: vscode.Disposable[] = []; 45 | private _context: vscode.ExtensionContext; 46 | private _disposed: boolean = false; 47 | private _onDiagnosticUpdateList: Array<(diagnostics: Map) => void> = []; 48 | 49 | constructor(context: vscode.ExtensionContext, db: LocalDB, rdb: RemoteDB | undefined) { 50 | this._db = db; 51 | this._rdb = rdb; 52 | this._context = context; 53 | this._diagnostics = vscode.languages.createDiagnosticCollection(DIAGNOSTICS_CODENAME); 54 | context.subscriptions.push(this._diagnostics); 55 | this._disposables.push(this._diagnostics); 56 | 57 | 58 | this._sastInfo = new SastInfo(); 59 | let codeActionProvider = vscode.languages.registerCodeActionsProvider('*', this._sastInfo, { 60 | providedCodeActionKinds: SastInfo.providedCodeActionKinds 61 | }); 62 | context.subscriptions.push(codeActionProvider); 63 | 64 | // Command to delete an arbitrary finding 65 | let disposableCommand = vscode.commands.registerCommand(`${constants.EXT_NAME}.deleteFinding`, (uuid: string) => { 66 | this.deleteFinding(uuid); 67 | }); 68 | context.subscriptions.push(disposableCommand); 69 | this._disposables.push(disposableCommand); 70 | 71 | // Command to flag an arbitrary finding 72 | disposableCommand = vscode.commands.registerCommand(`${constants.EXT_NAME}.flagFinding`, (uuid: string, flag: number) => { 73 | this.flagFinding(uuid, flag); 74 | }); 75 | context.subscriptions.push(disposableCommand); 76 | this._disposables.push(disposableCommand); 77 | 78 | if (this._rdb === undefined) { 79 | return; 80 | } 81 | this._rdb.onDbReady(async () => { 82 | if (this._disposed) { return; } 83 | await this.syncRdb(); 84 | }); 85 | this._rdb.onDiagnosticsUpdate(async (diagnosticsToStore: any[]) => { 86 | if (this._disposed) { return; } 87 | await this.safeRdbPull(diagnosticsToStore); 88 | }); 89 | } 90 | 91 | public onDiagnosticsUpdate(callback: (diagnostics: Map) => void): void { 92 | this._onDiagnosticUpdateList.push(callback); 93 | callback(this._rawDiagnostics); 94 | } 95 | 96 | private diagnosticsUpdate(): void { 97 | this._onDiagnosticUpdateList.forEach((callback) => { 98 | callback(this._rawDiagnostics); 99 | }); 100 | } 101 | 102 | dispose() { 103 | this._diagnostics.clear(); 104 | this._disposables.forEach((disposable) => { 105 | this._context.subscriptions.splice(this._context.subscriptions.indexOf(disposable), 1); 106 | disposable.dispose(); 107 | }); 108 | this._disposed = true; 109 | } 110 | 111 | // Will check if a sync is currently in progress, and if not, will start one 112 | private async safeRdbPull(arg: any) { 113 | if (this._currentlyPulling) { 114 | console.log("[IaC Diagnostics] Sync already in progress, skipping"); 115 | return; 116 | } 117 | this._currentlyPulling = true; 118 | await this.rdbPull(arg); 119 | this._currentlyPulling = false; 120 | } 121 | 122 | private async rdbPull(diagnosticsToStore: any[], isReplaceAll: boolean = true): Promise { 123 | console.log("rdbPull"); 124 | // Clear and rewrite local sqlite database 125 | if (isReplaceAll) { 126 | await this._db.dbClearDiagnostics(); 127 | } 128 | for (let i = 0; i < diagnosticsToStore.length; i++) { 129 | await this._db.dbCreateOrReplaceDiagnosticWithId(diagnosticsToStore[i].id, diagnosticsToStore[i].diagnostic, diagnosticsToStore[i].anchor, diagnosticsToStore[i].file_path, diagnosticsToStore[i].flag); 130 | } 131 | // Load diagnostics from DB 132 | this.loadDiagnosticsFromDB(); 133 | } 134 | 135 | private async deleteFinding(uuid: string): Promise { 136 | console.log(`deleteFinding(${uuid})`); 137 | await this._db.dbDeleteDiagnostic(uuid); 138 | if ((this._rdb === undefined) || (!this._rdb.isRemoteReady())) { 139 | await this.loadDiagnosticsFromDB(); 140 | return; 141 | } 142 | await this._rdb.deleteDiagnostics(null, [uuid]); 143 | await this.loadDiagnosticsFromDB(); 144 | return; 145 | } 146 | 147 | private async flagFinding(uuid: string, flag: number): Promise { 148 | console.log(`flagFinding(${uuid}, ${flag})`); 149 | await this._db.dbUpdateDiagnosticFlag(uuid, flag); 150 | await this.syncRdb(); 151 | await this.loadDiagnosticsFromDB(); 152 | return; 153 | } 154 | 155 | private async syncRdb() { 156 | if (this._rdb === undefined) { 157 | return; 158 | } 159 | if (!this._rdb.isRemoteReady()) { 160 | return; 161 | } 162 | let diagnosticsToPush = await this._db.dbGetDiagnostics(); 163 | console.log(`syncRdb(${diagnosticsToPush.length})`); 164 | let [diagnostics, isReplaceAll] = await this._rdb.syncDiagnostics(diagnosticsToPush); 165 | if (diagnostics === undefined) { 166 | return; 167 | } 168 | await this.rdbPull(diagnostics, isReplaceAll); 169 | } 170 | 171 | // TODO: This is quadratical, make it linear 172 | private async refreshDiagnostics(doc: vscode.TextDocument, semgrepParsed: any): Promise { 173 | console.log("[Semgrep] OK4 "); 174 | 175 | const diagnostics: vscode.Diagnostic[] = []; 176 | 177 | // TODO: check these fields exists in the parsed json. Show error if not. 178 | for (let e of semgrepParsed["parsed"]["results"]) { 179 | if (vscode.workspace.workspaceFolders === undefined) { 180 | assert(false, "Workspace folder is undefined, but was checked before."); 181 | return; 182 | } 183 | const absPath = util.relPathToAbs(e["path"]); 184 | if (absPath !== doc.uri.fsPath) { 185 | continue; 186 | } 187 | let diagUuid = util.genUUID(); 188 | 189 | const diagnostic = this.createDiagnostic( 190 | diagUuid, 191 | e["start"]["line"] - 1, 192 | e["start"]["col"] - 1, 193 | e["end"]["line"] - 1, 194 | e["end"]["col"] - 1, 195 | e["extra"]["message"], 196 | e["extra"]["metadata"]["source"], 197 | e["extra"]["metadata"]["severity"], 198 | e["extra"]["metadata"]["references"], 199 | semgrepParsed["source"], 200 | constants.FLAG_UNFLAGGED 201 | ); 202 | diagnostics.push(diagnostic); 203 | console.log(e["path"]); 204 | 205 | let anchorLine = e["start"]["line"] - 1; 206 | let anchorText = ""; 207 | let numLinesFromAnchor = 0; 208 | let anchorTextLineBegin = Math.max(anchorLine - ANCHOR_LINES, 0); 209 | let anchorTextLineEnd = Math.min(anchorLine + ANCHOR_LINES, doc.lineCount); 210 | let anchorText1 = doc.getText(new vscode.Range(anchorTextLineBegin, 0, anchorLine, 0)); 211 | let anchorText2 = doc.getText(new vscode.Range(anchorLine, 0, anchorTextLineEnd, 0)); 212 | 213 | // Limit size of anchor text to 1000 characters 214 | anchorText1 = anchorText1.substring(Math.max(anchorText1.length - 500, 0)); 215 | anchorText2 = anchorText2.substring(0, 500); 216 | numLinesFromAnchor = anchorText1.split(/\r\n|\r|\n/).length - 1; 217 | anchorText = anchorText1 + anchorText2; 218 | let anchor = JSON.stringify({ "line": anchorLine, "text": anchorText, "num": numLinesFromAnchor }); 219 | 220 | // Serialize diagnostic 221 | let serializedDiagnostic = JSON.stringify({ 222 | "message": e["extra"]["message"], 223 | "severity": e["extra"]["metadata"]["severity"], 224 | "source": e["extra"]["metadata"]["source"], 225 | "relatedInformation": e["extra"]["metadata"]["references"] 226 | }); 227 | 228 | await this._db.dbCreateOrReplaceDiagnosticWithId(diagUuid, serializedDiagnostic, anchor, e["path"], constants.FLAG_UNFLAGGED); 229 | this._diagnosticsIds.set(diagUuid, diagnostic); 230 | 231 | let rawDiagnostic = { 232 | "id": diagUuid, 233 | "path": e["path"], 234 | "line": anchorLine, 235 | "message": e["extra"]["message"], 236 | "source": e["extra"]["metadata"]["source"], 237 | "severity": e["extra"]["metadata"]["severity"], 238 | "references": e["extra"]["metadata"]["references"], 239 | "from": semgrepParsed["source"] 240 | }; 241 | this._rawDiagnostics.set(diagUuid, rawDiagnostic); 242 | } 243 | 244 | this._diagnostics.set(doc.uri, diagnostics); 245 | this.diagnosticsUpdate(); 246 | console.log("[Semgrep] OK5 "); 247 | } 248 | 249 | async clearLocalDiagnostics(): Promise { 250 | this._diagnostics.clear(); 251 | this._sastInfo._diagnosticCodeActions.clear(); 252 | this._diagnosticsIds.clear(); 253 | this._rawDiagnostics.clear(); 254 | await this._db.dbClearDiagnostics(); 255 | this.diagnosticsUpdate(); 256 | } 257 | 258 | async clearDiagnostics(): Promise { 259 | await this.clearLocalDiagnostics(); 260 | await this.syncRdb(); 261 | } 262 | 263 | async loadDiagnostics(): Promise { 264 | await this.loadDiagnosticsFromDB(); 265 | await this.syncRdb(); 266 | } 267 | 268 | private msgToFlagId(msg: string): number { 269 | if (msg.startsWith("🆕")) { 270 | return constants.FLAG_UNFLAGGED; 271 | } 272 | else if (msg.startsWith("✅")) { 273 | return constants.FLAG_RESOLVED; 274 | } 275 | else if (msg.startsWith("❌")) { 276 | return constants.FLAG_FALSE; 277 | } 278 | else if (msg.startsWith("🔥")) { 279 | return constants.FLAG_HOT; 280 | } 281 | else { 282 | return constants.FLAG_UNFLAGGED; 283 | } 284 | } 285 | 286 | async loadDiagnosticsFromDB(): Promise { 287 | let dbDiagnostics = await this._db.dbGetDiagnostics(); 288 | let diagnosticsToClear = [...this._diagnosticsIds.keys()]; 289 | 290 | console.log("[Diagnostics] Db has "); 291 | console.log(dbDiagnostics); 292 | for (let i = 0; i < dbDiagnostics.length; i++) { 293 | let dbDiagnostic = dbDiagnostics[i].diagnostic; 294 | let dbAnchor = dbDiagnostics[i].anchor; 295 | let dbPath = dbDiagnostics[i].file_path; 296 | let dbId = dbDiagnostics[i].id; 297 | let dbFlag = dbDiagnostics[i].flag; 298 | 299 | console.log("Diagnostic id: " + dbId); 300 | console.log("All diagnostic ids: " + [...this._diagnosticsIds.keys()]); 301 | if (this._diagnosticsIds.has(dbId)) { 302 | let diagMsg = this._diagnosticsIds.get(dbId)?.message; 303 | diagnosticsToClear = diagnosticsToClear.filter((e) => e !== dbId); 304 | if ((diagMsg == undefined) || (dbFlag == this.msgToFlagId(diagMsg))) { 305 | console.log("Skipping updating an existing diagnostic"); 306 | continue; 307 | } 308 | else { 309 | console.log("Updating an existing diagnostic"); 310 | await this.clearOneDiagnostic(this._diagnosticsIds.get(dbId) as vscode.Diagnostic, dbId); 311 | } 312 | } 313 | 314 | // Deserialize diagnostic 315 | let deserializedDiagnostic = JSON.parse(dbDiagnostic); 316 | let deserializedAnchor = JSON.parse(dbAnchor); 317 | 318 | const doc = await this.getDocumentFromSemgrepPath(dbPath); 319 | if (doc === undefined) { 320 | vscode.window.showWarningMessage('Semgrep result references a file that is not present in workspace.'); 321 | return; 322 | } 323 | let anchorLine = findClosestAnchor(deserializedAnchor.text, doc.getText()); 324 | if (anchorLine === -1) { 325 | anchorLine = deserializedAnchor.line; 326 | } 327 | else { 328 | anchorLine = anchorLine + deserializedAnchor.num; 329 | } 330 | console.log("Closest anchor: " + anchorLine); 331 | 332 | // Create diagnostic 333 | const diagnostic = this.createDiagnostic( 334 | dbId, 335 | deserializedAnchor.line, 336 | 0, 337 | deserializedAnchor.line + 1, 338 | 12345, 339 | deserializedDiagnostic.message, 340 | deserializedDiagnostic.source, 341 | deserializedDiagnostic.severity, 342 | deserializedDiagnostic.relatedInformation, 343 | "semgrep-remote", 344 | dbFlag 345 | ); 346 | // Add diagnostic to collection 347 | let curDiag = this._diagnostics.get(doc.uri); 348 | if (curDiag !== undefined) { 349 | curDiag = curDiag.concat(diagnostic); 350 | } 351 | else { 352 | curDiag = [diagnostic]; 353 | } 354 | this._diagnostics.set(doc.uri, curDiag); 355 | 356 | // Add diagnostic to SastInfo 357 | let action = this.makeCodeActions(dbId, deserializedDiagnostic.source, diagnostic, dbFlag); 358 | this._sastInfo._diagnosticCodeActions.set(diagnostic, action); 359 | // Add diagnostic to list of diagnostics 360 | 361 | this._diagnosticsIds.set(dbId, diagnostic); 362 | 363 | let rawDiagnostic = { 364 | "id": dbId, 365 | "path": dbPath, 366 | "line": deserializedAnchor.line, 367 | "message": deserializedDiagnostic.message, 368 | "source": deserializedDiagnostic.source, 369 | "severity": deserializedDiagnostic.severity, 370 | "references": deserializedDiagnostic.relatedInformation, 371 | "from": "semgrep-remote" 372 | }; 373 | this._rawDiagnostics.set(dbId, rawDiagnostic); 374 | } 375 | 376 | // Clear diagnostics that are not in the db 377 | console.log("Diagnostics to clear: " + diagnosticsToClear); 378 | for (let i = 0; i < diagnosticsToClear.length; i++) { 379 | let diag = this._diagnosticsIds.get(diagnosticsToClear[i]); 380 | await this.clearOneDiagnostic(diag as vscode.Diagnostic, diagnosticsToClear[i]); 381 | } 382 | 383 | this.diagnosticsUpdate(); 384 | } 385 | 386 | async clearOneDiagnostic(diag: vscode.Diagnostic, uuid: string): Promise { 387 | console.log("Clearing diagnostic: " + diag); 388 | if (diag === undefined) { 389 | console.log("Diagnostic is undefined"); 390 | return; 391 | } 392 | let diagnosticUri = null; 393 | this._diagnostics.forEach((key, value) => { 394 | if (value.includes(diag as vscode.Diagnostic)) { 395 | diagnosticUri = key; 396 | } 397 | }); 398 | if (diagnosticUri !== null) { 399 | let diagnostics = this._diagnostics.get(diagnosticUri); 400 | if (diagnostics !== undefined) { 401 | console.log(`Removing diagnostic from collection ${diagnostics.length} > ${diagnostics.filter((e) => e !== diag).length}`); 402 | this._diagnostics.set(diagnosticUri, diagnostics.filter((e) => e !== diag)); 403 | } 404 | } 405 | 406 | this._diagnosticsIds.delete(uuid); 407 | this._rawDiagnostics.delete(uuid); 408 | this._sastInfo._diagnosticCodeActions.delete(diag); 409 | } 410 | 411 | // TODO: fetch from all workspaces, not just the first one 412 | async getDocumentFromSemgrepPath(semgrepPath: string): Promise { 413 | // Get document from path 414 | if (vscode.workspace.workspaceFolders === undefined) { 415 | return undefined; 416 | } 417 | console.log(vscode.workspace.workspaceFolders); 418 | const absPath = util.relPathToAbs(semgrepPath); 419 | 420 | // Match file only within workspace 421 | let relPattern = new vscode.RelativePattern(vscode.workspace.workspaceFolders[0], semgrepPath); 422 | let uris = await vscode.workspace.findFiles(relPattern, null, 1); 423 | console.log(uris); 424 | 425 | if (uris.length === 0) { 426 | console.log(absPath); 427 | return undefined; 428 | } 429 | const doc = await vscode.workspace.openTextDocument(uris[0]); 430 | return doc; 431 | } 432 | 433 | async loadDiagnosticsFromSemgrep(semgrepParsed: any): Promise { 434 | // Create set of paths 435 | const docs = new Set(); 436 | await Promise.all(semgrepParsed["parsed"]["results"].map(async (e: any) => { 437 | let doc = await this.getDocumentFromSemgrepPath(e["path"]); 438 | if (doc === undefined) { 439 | vscode.window.showWarningMessage('Semgrep result references a file that is not present in workspace.'); 440 | return; 441 | } 442 | docs.add(doc); 443 | })); 444 | 445 | for (let doc of docs) { 446 | await this.refreshDiagnostics(doc, semgrepParsed); 447 | } 448 | await this.syncRdb(); 449 | } 450 | 451 | private createDiagnostic(uuid: string, lineStart: number, colStart: number, lineEnd: number, colEnd: number, message: string, externalUrl: string, severity: string, references: string[], source: string, flag: number): vscode.Diagnostic { 452 | // create range that represents, where in the document the word is 453 | const range = new vscode.Range(lineStart, colStart, lineEnd, colEnd); 454 | 455 | // Convert severity to vscode.DiagnosticSeverity 456 | let severityVsc: vscode.DiagnosticSeverity; 457 | switch (severity) { 458 | case "ERROR": 459 | severityVsc = vscode.DiagnosticSeverity.Error; 460 | break; 461 | case "WARNING": 462 | severityVsc = vscode.DiagnosticSeverity.Warning; 463 | break; 464 | case "INFO": 465 | severityVsc = vscode.DiagnosticSeverity.Information; 466 | break; 467 | default: 468 | severityVsc = vscode.DiagnosticSeverity.Information; 469 | break; 470 | } 471 | 472 | // Add flag to message 473 | switch (flag) { 474 | case constants.FLAG_UNFLAGGED: 475 | message = "🆕 " + message; 476 | break; 477 | case constants.FLAG_RESOLVED: 478 | message = "✅ " + message; 479 | break; 480 | case constants.FLAG_FALSE: 481 | message = "❌ " + message; 482 | break; 483 | case constants.FLAG_HOT: 484 | message = "🔥 " + message; 485 | break; 486 | default: 487 | break; 488 | } 489 | 490 | const diagnostic = new vscode.Diagnostic(range, message, severityVsc); 491 | diagnostic.code = DIAGNOSTICS_CODENAME; 492 | switch (source) { 493 | case "semgrep-imported": 494 | diagnostic.source = "Semgrep [imported] "; 495 | break; 496 | case "semgrep-local": 497 | diagnostic.source = "Semgrep [local] "; 498 | break; 499 | case "semgrep-remote": 500 | diagnostic.source = "Semgrep [remote] "; 501 | break; 502 | default: 503 | diagnostic.source = "Unknown source "; 504 | break; 505 | } 506 | 507 | if (references === undefined) { 508 | references = []; 509 | } 510 | diagnostic.relatedInformation = references.map(ref => { 511 | return new vscode.DiagnosticRelatedInformation( 512 | new vscode.Location(vscode.Uri.parse(ref), new vscode.Position(0, 0)), 513 | 'External reference (open in browser)' 514 | ); 515 | }); 516 | diagnostic.tags = [vscode.DiagnosticTag.Unnecessary]; 517 | 518 | // Add a code action 519 | let action = this.makeCodeActions(uuid, externalUrl, diagnostic, flag); 520 | this._sastInfo._diagnosticCodeActions.set(diagnostic, action); 521 | 522 | return diagnostic; 523 | } 524 | 525 | private makeCodeActions(diagUuid: string, externalUrl: string, diagnostic: vscode.Diagnostic, flag: number): vscode.CodeAction[] { 526 | const action = new vscode.CodeAction('Learn more on this finding', vscode.CodeActionKind.QuickFix); 527 | action.command = { arguments: [externalUrl], command: `${constants.EXT_NAME}.openLink`, title: 'Learn more about this finding', tooltip: 'This will open an external page.' }; 528 | action.diagnostics = [diagnostic]; 529 | action.isPreferred = true; 530 | 531 | const action2 = new vscode.CodeAction('Delete this finding', vscode.CodeActionKind.QuickFix); 532 | action2.command = { arguments: [diagUuid], command: `${constants.EXT_NAME}.deleteFinding`, title: 'Delete this finding', tooltip: 'This will delete this finding from the database.' }; 533 | action2.diagnostics = [diagnostic]; 534 | action2.isPreferred = false; 535 | 536 | if (flag == constants.FLAG_UNFLAGGED) { 537 | const action3 = new vscode.CodeAction('Mark this finding as ✅ resolved', vscode.CodeActionKind.QuickFix); 538 | action3.command = { arguments: [diagUuid, constants.FLAG_RESOLVED], command: `${constants.EXT_NAME}.flagFinding`, title: 'Mark this finding as resolved', tooltip: 'This will mark this finding as resolved.' }; 539 | action3.diagnostics = [diagnostic]; 540 | action3.isPreferred = false; 541 | 542 | const action4 = new vscode.CodeAction('Mark this finding as ❌ false positive', vscode.CodeActionKind.QuickFix); 543 | action4.command = { arguments: [diagUuid, constants.FLAG_FALSE], command: `${constants.EXT_NAME}.flagFinding`, title: 'Mark this finding as false positive', tooltip: 'This will mark this finding as false positive.' }; 544 | action4.diagnostics = [diagnostic]; 545 | action4.isPreferred = false; 546 | 547 | const action5 = new vscode.CodeAction('Mark this finding as 🔥 hot', vscode.CodeActionKind.QuickFix); 548 | action5.command = { arguments: [diagUuid, constants.FLAG_HOT], command: `${constants.EXT_NAME}.flagFinding`, title: 'Mark this finding as hot', tooltip: 'This will mark this finding as hot.' }; 549 | action5.diagnostics = [diagnostic]; 550 | action5.isPreferred = false; 551 | 552 | return [action, action2, action3, action4, action5]; 553 | } 554 | else { 555 | const action3 = new vscode.CodeAction('Unmark ↩️ this finding', vscode.CodeActionKind.QuickFix); 556 | action3.command = { arguments: [diagUuid, constants.FLAG_UNFLAGGED], command: `${constants.EXT_NAME}.flagFinding`, title: 'Unmark this finding', tooltip: 'This will unmark this finding.' }; 557 | action3.diagnostics = [diagnostic]; 558 | action3.isPreferred = false; 559 | 560 | return [action, action2, action3]; 561 | } 562 | } 563 | } -------------------------------------------------------------------------------- /src/encryption.ts: -------------------------------------------------------------------------------- 1 | import * as jose from 'jose'; 2 | 3 | export class IaCEncryption { 4 | private secret: Uint8Array | jose.KeyLike | null = null; 5 | 6 | constructor() { 7 | 8 | } 9 | 10 | async setKey(b64secret: string | null = null) { 11 | if (b64secret === null) { 12 | this.secret = await jose.generateSecret('HS256', {extractable: true}); 13 | return; 14 | } 15 | this.secret = jose.base64url.decode(b64secret); 16 | } 17 | 18 | static async genKey(): Promise { 19 | let secret = await jose.generateSecret('HS256', {extractable: true}); 20 | let secretArr = (secret as any).export(); 21 | console.log(secretArr); 22 | return jose.base64url.encode(secretArr); 23 | } 24 | 25 | async checkKey(token: string): Promise { 26 | if (this.secret === null) { 27 | return null; 28 | } 29 | try { 30 | let payload: any = await this.decrypt(token); 31 | return payload.uuid; 32 | } catch (e) { 33 | return null; 34 | } 35 | } 36 | 37 | async encrypt(data: {}): Promise { 38 | if (this.secret === null) { 39 | throw new Error("Encryption key not set"); 40 | } 41 | const jwt = await new jose.EncryptJWT(data).setProtectedHeader({ alg: 'dir', enc: 'A128CBC-HS256' }).encrypt(this.secret); 42 | return jwt; 43 | } 44 | 45 | async decrypt(jwt: string): Promise<{}> { 46 | if (this.secret === null) { 47 | throw new Error("Encryption key not set"); 48 | } 49 | const { payload, protectedHeader } = await jose.jwtDecrypt(jwt, this.secret); 50 | return payload; 51 | } 52 | 53 | async optionallyEncryptString(data: string | null): Promise { 54 | if (this.secret === null) { 55 | return data; 56 | } 57 | if (data === null) { return null; } 58 | return await this.encrypt({"p": data}); 59 | } 60 | 61 | async optionallyDecryptString(data: string | null): Promise { 62 | if (this.secret === null) { 63 | return data; 64 | } 65 | if (data === null) { return null; } 66 | let payload: any = await this.decrypt(data); 67 | return payload.p; 68 | } 69 | 70 | async exportKey(): Promise { 71 | if (this.secret === null) { 72 | throw new Error("Encryption key not set"); 73 | } 74 | return jose.base64url.encode(this.secret as Uint8Array); 75 | } 76 | 77 | dispose() { 78 | this.secret = null; 79 | } 80 | } -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { assert } from 'console'; 3 | import * as path from 'path'; 4 | import * as fs from 'fs'; 5 | 6 | import { IaCProjectDir } from './projects'; 7 | import { IaCWebviewManager } from './iacview'; 8 | import * as constants from './constants'; 9 | import { handleOpenSemgrepJson, handleStartSemgrepScan } from './util'; 10 | import * as util from './util'; 11 | import { IaCDiagnostics } from './diagnostics'; 12 | import { LocalDB } from './db'; 13 | import { RemoteDB } from './remote'; 14 | import * as comments from './comments'; 15 | import { IaCEncryption } from './encryption'; 16 | import * as tree from './tree'; 17 | 18 | let db: LocalDB; 19 | let rdb: RemoteDB; 20 | let mComments: comments.IaCComments; 21 | let pdb: IaCProjectDir; 22 | let projectDisposables: vscode.Disposable[] = []; 23 | let projectClosing: boolean = false; 24 | let projectTreeView: tree.ProjectTreeViewManager; 25 | 26 | export async function initLocalDb(dbDir: string, projectUuid: string) { 27 | // Create sqlite3 database in storage directory 28 | let dbFilename = path.basename(`/${constants.EXT_NAME}-${projectUuid}.db`); 29 | let dbPath = path.join(dbDir, dbFilename); 30 | db = new LocalDB(dbPath); 31 | await db.init(); 32 | } 33 | 34 | function updateStatusBar(rdb: RemoteDB, remoteStatus: vscode.StatusBarItem) { 35 | remoteStatus.command = `${constants.EXT_NAME}.statusBarButton`; 36 | if (rdb.settingsEnabledAndConfigured()) { 37 | let dbName = rdb.getDbName(); 38 | remoteStatus.text = `$(compass-dot) PoiEx sync: $(ellipsis) Connecting (${dbName})`; 39 | if (rdb.isRemoteReady()) { 40 | remoteStatus.text = `$(compass-active) PoiEx sync: $(arrow-swap) Connected (${dbName})`; 41 | } 42 | remoteStatus.tooltip = 'Click to disable remote database'; 43 | } 44 | else { 45 | remoteStatus.text = '$(compass) PoiEx sync: $(circle-slash) Disabled'; 46 | remoteStatus.tooltip = 'Click to enable remote database'; 47 | } 48 | remoteStatus.show(); 49 | } 50 | 51 | async function statusBarButtonPressed(context: vscode.ExtensionContext, rdb: RemoteDB, remoteStatus: vscode.StatusBarItem) { 52 | if (rdb.settingsEnabledAndConfigured()) { 53 | console.log("[IaC Main] Status bar button pressed: disabling remote database"); 54 | await vscode.workspace.getConfiguration(`${constants.EXT_NAME}`).update('collab.enabled', false, true); 55 | rdb.disable(); 56 | updateStatusBar(rdb, remoteStatus); 57 | } 58 | else if (rdb.settingsConfigured()) { 59 | console.log("[IaC Main] Status bar button pressed: enabling remote database"); 60 | await vscode.workspace.getConfiguration(`${constants.EXT_NAME}`).update('collab.enabled', true, true); 61 | rdb.enable(); 62 | updateStatusBar(rdb, remoteStatus); 63 | } 64 | else { 65 | console.log("[IaC Main] Status bar button pressed: opening settings page"); 66 | // Open settings page 67 | vscode.commands.executeCommand('workbench.action.openSettings', `${constants.EXT_NAME}.collab`); 68 | } 69 | } 70 | 71 | export function initRemoteDB(context: vscode.ExtensionContext) { 72 | // Init status bar indicator for remote connection 73 | let remoteStatus = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); 74 | 75 | let rdb = new RemoteDB(context.secrets); 76 | 77 | // Register command for status bar button 78 | context.subscriptions.push(vscode.commands.registerCommand(`${constants.EXT_NAME}.statusBarButton`, () => { 79 | statusBarButtonPressed(context, rdb, remoteStatus); 80 | })); 81 | 82 | // Update status bar 83 | updateStatusBar(rdb, remoteStatus); 84 | rdb.onDisable(() => { 85 | // Update status bar 86 | updateStatusBar(rdb, remoteStatus); 87 | }); 88 | rdb.onEnable(() => { 89 | // Update status bar 90 | updateStatusBar(rdb, remoteStatus); 91 | }); 92 | 93 | rdb.onDbReady(() => { 94 | // Update status bar 95 | console.log("[IaC Main] Remote DB ready, updating status bar"); 96 | updateStatusBar(rdb, remoteStatus); 97 | }); 98 | 99 | return rdb; 100 | } 101 | 102 | // This method is called when your extension is activated 103 | // Your extension is activated the very first time the command is executed 104 | export function activate(context: vscode.ExtensionContext) { 105 | let storageUri = context.globalStorageUri; 106 | let iacPath = path.join(storageUri.fsPath, constants.IAC_FOLDER_NAME); 107 | console.log(iacPath); 108 | 109 | // Set context variable to hide / show sidebar views 110 | vscode.commands.executeCommand('setContext', 'iacAudit.isProjectOpen', false); 111 | vscode.commands.executeCommand('setContext', 'iacAudit.isProjectCreator', false); 112 | vscode.commands.executeCommand('setContext', 'iacAudit.isProjectEncrypted', false); 113 | 114 | // Check if number of workspace folders is 1 115 | if (vscode.workspace.workspaceFolders === undefined || vscode.workspace.workspaceFolders.length !== 1) { 116 | console.log('[IaC Main] Number of workspace folders is not 1, skipping activation'); 117 | return; 118 | } 119 | 120 | // Ensure that the storage directory exists 121 | util.ensureDirExists(iacPath).then((res) => { 122 | if (res !== true) { 123 | console.log('[IaC Main] Could not create storage directory, skipping activation'); 124 | return; 125 | } 126 | return init1(context, iacPath); 127 | }); 128 | 129 | return; 130 | } 131 | 132 | async function init1(context: vscode.ExtensionContext, iacPath: string) { 133 | let iacUri = vscode.Uri.file(iacPath); 134 | pdb = new IaCProjectDir(context, iacUri); 135 | await pdb.init(); 136 | let projectUuidList = (await pdb.listProjects())?.map((project: any) => project.uuid); 137 | 138 | // Check if a project is already open in the workspace 139 | let projectName = context.workspaceState.get('projectName', undefined); 140 | let projectUuid = context.workspaceState.get('projectUuid', undefined); 141 | if (!projectUuidList?.includes(projectUuid)) { 142 | projectUuid = undefined; 143 | projectName = undefined; 144 | } 145 | 146 | if (projectUuid !== undefined) { 147 | // Initialize remote database 148 | if (rdb === undefined) { 149 | rdb = initRemoteDB(context); 150 | } 151 | 152 | console.log('[IaC Main] Project already open in workspace'); 153 | openProject(context, iacUri, projectUuid); 154 | } 155 | 156 | let ensureDbOk = () => { 157 | // Initialize remote database 158 | if (rdb === undefined) { 159 | rdb = initRemoteDB(context); 160 | } 161 | 162 | if (rdb.settingsEnabled() && !rdb.settingsConfigured()) { 163 | // Show notification 164 | vscode.window.showInformationMessage('Please configure the remote database settings first, or disable remote database.'); 165 | 166 | // Open settings page 167 | vscode.commands.executeCommand('workbench.action.openSettings', `${constants.EXT_NAME}.collab`); 168 | return false; 169 | } 170 | return true; 171 | }; 172 | 173 | // No project open 174 | // Register command to initialize project 175 | console.log(`[IaC Main] Registering ${constants.EXT_NAME}.initProject command`); 176 | context.subscriptions.push(vscode.commands.registerCommand(`${constants.EXT_NAME}.initProject`, () => { 177 | console.log("[IaC Main] Init project button pressed"); 178 | if (!ensureDbOk()) { 179 | console.log('[IaC Main] Remote database not configured, cannot initialize project'); 180 | return; 181 | } 182 | 183 | // Ask for project name 184 | vscode.window.showInputBox({ 185 | placeHolder: 'Please enter project name', 186 | prompt: 'Please enter project name', 187 | validateInput: (value: string) => { 188 | if (value === undefined || value === '') { 189 | return 'Project name cannot be empty'; 190 | } 191 | return undefined; 192 | } 193 | }).then((projectName) => { 194 | if (projectName === undefined) { 195 | return; 196 | } 197 | // Quickselect "Do you want to encrypt the project?" dialog 198 | vscode.window.showQuickPick(['Yes', 'No'], { 199 | placeHolder: 'Do you want to encrypt the project?' 200 | }).then(async (value) => { 201 | if (value === undefined) { 202 | return; 203 | } 204 | let encrypt = value === 'Yes'; 205 | // Push to local database 206 | let projectUuid = util.genUUID(); 207 | let projectSecret = encrypt ? await IaCEncryption.genKey() : null; 208 | await pdb.pushProject(projectUuid, projectName, projectSecret); 209 | pdb.safeSyncProjects(rdb); 210 | 211 | openProject(context, iacUri, projectUuid); 212 | }); 213 | }); 214 | })); 215 | 216 | // Register command to open an existing project 217 | context.subscriptions.push(vscode.commands.registerCommand(`${constants.EXT_NAME}.openProject`, async (projectUuid: string | undefined = undefined) => { 218 | console.log('[IaC Main] Open project button pressed'); 219 | if (!ensureDbOk()) { 220 | console.log('[IaC Main] Remote database not configured, cannot open project'); 221 | return; 222 | } 223 | 224 | let cb2 = async (choice: string | undefined) => { 225 | if (choice === undefined) { 226 | return; 227 | } 228 | let projectUuid = choice; 229 | if (choice.includes(' $ ')) { 230 | projectUuid = choice.split(' $ ')[1]; 231 | } 232 | let project = await pdb.getProject(projectUuid); 233 | assert(project !== null, "Project not found in local database"); 234 | if (project === null) { return; }; 235 | if (project[3] === null || project[2] !== null) { 236 | openProject(context, iacUri, projectUuid); 237 | return; 238 | } 239 | // Ask for project secret 240 | vscode.window.showInputBox({ 241 | placeHolder: 'Please enter project secret', 242 | prompt: 'Please enter project secret', 243 | password: true, 244 | validateInput: (value: string) => { 245 | if (value === undefined || value === '') { 246 | return 'Project secret cannot be empty'; 247 | } 248 | return undefined; 249 | } 250 | }).then((projectSecret) => { 251 | if (projectSecret === undefined) { 252 | return; 253 | } 254 | openProject(context, iacUri, projectUuid, projectSecret); 255 | }); 256 | }; 257 | 258 | let cb = async () => { 259 | console.log('[IaC Main] Open project ready, executing callback'); 260 | if (projectUuid !== undefined) { 261 | cb2(projectUuid); 262 | return; 263 | } 264 | 265 | // Show list of projects as quickpick 266 | let projectList = (await pdb.listProjects()) as {}[]; 267 | projectTreeView.update(projectList); 268 | let projectNames = projectList.map((project: any) => project.name + " $ " + project.uuid); 269 | console.log('[IaC Main] Open project got list of projects'); 270 | if (projectNames.length === 0) { 271 | vscode.window.showInformationMessage('No projects found, please create a new project first.'); 272 | return; 273 | } 274 | 275 | vscode.window.showQuickPick(projectNames, { 276 | placeHolder: 'Please select a project to open' 277 | }).then(cb2); 278 | }; 279 | 280 | if (rdb.settingsEnabled()) { 281 | rdb.onDbReadyOnce(async () => { await pdb.safeSyncProjects(rdb); await cb(); }); 282 | } 283 | else { 284 | await cb(); 285 | } 286 | })); 287 | 288 | // Experimental tree view 289 | if (ensureDbOk()) { 290 | projectTreeView = new tree.ProjectTreeViewManager(context, pdb, rdb); 291 | context.subscriptions.push(projectTreeView); 292 | projectTreeView.show(); 293 | projectTreeView.update(); 294 | } 295 | else { 296 | projectTreeView = new tree.ProjectTreeViewManager(context, pdb, undefined); 297 | context.subscriptions.push(projectTreeView); 298 | projectTreeView.showDbError(); 299 | console.log('[IaC Main] Remote database not configured, cannot show project list'); 300 | } 301 | } 302 | 303 | async function openProject(context: vscode.ExtensionContext, storageUri: vscode.Uri, projectUuid: string, projectSecret: string | null = null) { 304 | let projectAttrs = await pdb.getProject(projectUuid); 305 | assert(projectAttrs !== null, "Project not found in local database (2)"); 306 | if (projectAttrs === null) { 307 | return; 308 | } 309 | let [puuid, projectName, pkeys, jwt] = projectAttrs; 310 | 311 | let mIaCEncryption = new IaCEncryption(); 312 | if (projectSecret === null) { 313 | projectSecret = pkeys; 314 | } 315 | await mIaCEncryption.setKey(projectSecret); 316 | if (((pkeys === "") || (pkeys === null)) && (jwt !== "") && (jwt !== null)) { // If no key was in database and project uses encryption 317 | // Check that key is correct and add to database 318 | if (await mIaCEncryption.checkKey(jwt) !== puuid) { 319 | vscode.window.showErrorMessage('Incorrect project secret'); 320 | mIaCEncryption.dispose(); 321 | return; 322 | } 323 | // Save key to database 324 | await pdb.pushProject(puuid, projectName, projectSecret); 325 | } 326 | projectDisposables.push(mIaCEncryption); 327 | // If key is in database, assume it's correct 328 | 329 | // Set workspaceState variables to remember project 330 | context.workspaceState.update('projectName', projectName); 331 | context.workspaceState.update('projectUuid', projectUuid); 332 | context.workspaceState.update('projectEncrypted', projectSecret !== null); 333 | 334 | // Set context variables to show / hide sidebar views 335 | vscode.commands.executeCommand('setContext', 'iacAudit.isProjectOpen', true); 336 | vscode.commands.executeCommand('setContext', 'iacAudit.isProjectCreator', true); 337 | vscode.commands.executeCommand('setContext', 'iacAudit.isProjectEncrypted', projectSecret !== null); 338 | 339 | // Hide project tree view 340 | projectTreeView.hide(); 341 | 342 | rdb.setProjectUuid(projectUuid, projectSecret); 343 | 344 | await initLocalDb(storageUri.fsPath, projectUuid); 345 | 346 | // Continue with extension initialization 347 | mComments = new comments.IaCComments(context, db, rdb); 348 | 349 | // Init diagnostics 350 | let mIaCDiagnostics = new IaCDiagnostics(context, db, rdb); 351 | mIaCDiagnostics.loadDiagnostics(); // Do not await promise 352 | 353 | // Init IaC webview manager 354 | let mIacWebviewManager = new IaCWebviewManager(context, mIaCDiagnostics); 355 | 356 | // Commands to manually load Semgrep results 357 | let disposableCommand1 = vscode.commands.registerCommand(`${constants.EXT_NAME}.readSemgrepJson`, () => handleOpenSemgrepJson(context, mIaCDiagnostics)); 358 | context.subscriptions.push(disposableCommand1); projectDisposables.push(disposableCommand1); 359 | 360 | let disposableCommand3 = vscode.commands.registerCommand(`${constants.EXT_NAME}.deleteAllDiagnostics`, async () => await mIaCDiagnostics.clearDiagnostics()); 361 | context.subscriptions.push(disposableCommand3); projectDisposables.push(disposableCommand3); 362 | 363 | let disposableCommand4 = vscode.commands.registerCommand(`${constants.EXT_NAME}.runSemgrep`, () => handleStartSemgrepScan(context, mIaCDiagnostics)); 364 | context.subscriptions.push(disposableCommand4); projectDisposables.push(disposableCommand4); 365 | 366 | // Generic command to open an arbitrary link 367 | let disponsableCommand5 = vscode.commands.registerCommand(`${constants.EXT_NAME}.openLink`, (link: string) => { 368 | vscode.env.openExternal(vscode.Uri.parse(link)); 369 | }); 370 | context.subscriptions.push(disponsableCommand5); projectDisposables.push(disponsableCommand5); 371 | 372 | // Register command to close current project 373 | let disposableCommand6 = vscode.commands.registerCommand(`${constants.EXT_NAME}.closeProject`, async () => { 374 | await closeProject(context, projectUuid, db, mIaCDiagnostics, mComments, mIacWebviewManager); 375 | }); 376 | context.subscriptions.push(disposableCommand6); projectDisposables.push(disposableCommand6); 377 | 378 | // Register command to export current project 379 | let disposableCommand7 = vscode.commands.registerCommand(`${constants.EXT_NAME}.exportProject`, () => { 380 | let saveUri = vscode.Uri.file(`${projectName}.sqlite3`); 381 | vscode.window.showSaveDialog({ title: "Export to file", saveLabel: "Export project", defaultUri: saveUri }).then(fileInfos => { 382 | if (fileInfos === undefined) { 383 | return; 384 | } 385 | fs.copyFileSync(db.path, fileInfos.path); 386 | }); 387 | }); 388 | context.subscriptions.push(disposableCommand7); projectDisposables.push(disposableCommand7); 389 | 390 | // Register command to destroy current project 391 | let disposableCommand8 = vscode.commands.registerCommand(`${constants.EXT_NAME}.destroyProject`, async () => { 392 | vscode.window.showWarningMessage('Are you sure you want to destroy this project (remove also on remote)?', 'Yes', 'No').then(async (choice) => { 393 | if (await pdb.getProject(projectUuid) === null) { return; } 394 | if (choice === 'Yes') { 395 | await pdb.removeProject(projectUuid, rdb); 396 | await closeProject(context, projectUuid, db, mIaCDiagnostics, mComments, mIacWebviewManager); 397 | } 398 | }); 399 | }); 400 | context.subscriptions.push(disposableCommand8); projectDisposables.push(disposableCommand8); 401 | let disposableCommand9 = vscode.commands.registerCommand(`${constants.EXT_NAME}.destroyLocalProject`, async () => { 402 | vscode.window.showWarningMessage('Are you sure you want to destroy this project (remove only locally)?', 'Yes', 'No').then(async (choice) => { 403 | if (await pdb.getProject(projectUuid) === null) { return; } 404 | if (choice === 'Yes') { 405 | await pdb.removeProject(projectUuid, null); 406 | await closeProject(context, projectUuid, db, mIaCDiagnostics, mComments, mIacWebviewManager); 407 | } 408 | }); 409 | }); 410 | context.subscriptions.push(disposableCommand9); projectDisposables.push(disposableCommand9); 411 | 412 | // Register command to copy secret to the user's clipboard 413 | let disposableCommand10 = vscode.commands.registerCommand(`${constants.EXT_NAME}.copyKey`, () => { 414 | assert(projectSecret !== null, "Pressed copyKey on an unencrypted project"); 415 | if (projectSecret === null) { return; } 416 | 417 | // Copy to clipboard and Notify user 418 | vscode.env.clipboard.writeText(projectSecret); 419 | vscode.window.showInformationMessage('Copied project secret to clipboard'); 420 | }); 421 | context.subscriptions.push(disposableCommand10); projectDisposables.push(disposableCommand10); 422 | } 423 | 424 | async function closeProject(context: vscode.ExtensionContext, projectUuid: string, db: LocalDB, mIaCDiagnostics: IaCDiagnostics, mComments: comments.IaCComments, mIacWebviewManager: IaCWebviewManager) { 425 | // Prevent race conditions 426 | if (projectClosing) { 427 | console.log("[IaC Main] closeProject(): Project is already closing"); 428 | return; 429 | } 430 | projectClosing = true; 431 | 432 | await mComments.dispose(); 433 | mIacWebviewManager.dispose(); 434 | mIaCDiagnostics.dispose(); 435 | 436 | // Dispose all project disposables 437 | projectDisposables.forEach((disposable) => { 438 | context.subscriptions.splice(context.subscriptions.indexOf(disposable), 1); 439 | disposable.dispose(); 440 | }); 441 | 442 | rdb.setProjectUuid(null); 443 | console.log("[IaC Main] closeProject(): Closing project " + projectUuid); 444 | await db.close(); 445 | 446 | // Clear workspaceState variables 447 | context.workspaceState.update('projectName', undefined); 448 | context.workspaceState.update('projectUuid', undefined); 449 | context.workspaceState.update('projectEncrypted', undefined); 450 | 451 | // Set context variables to show / hide sidebar views 452 | vscode.commands.executeCommand('setContext', 'iacAudit.isProjectOpen', false); 453 | vscode.commands.executeCommand('setContext', 'iacAudit.isProjectCreator', false); 454 | vscode.commands.executeCommand('setContext', 'iacAudit.isProjectEncrypted', false); 455 | 456 | // Show project tree view 457 | projectTreeView.show().then(() => { 458 | projectTreeView.update(); 459 | }); 460 | 461 | // Race condition prevention 462 | projectClosing = false; 463 | } 464 | 465 | // This method is called when your extension is deactivated 466 | export function deactivate() { 467 | db.close(); 468 | // All disposables are automatically disposed when extension is deactivated 469 | } -------------------------------------------------------------------------------- /src/iacview.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as which from 'which'; 3 | import * as child_process from "child_process"; 4 | import { assert } from 'console'; 5 | import * as path from 'path'; 6 | import { Uri } from "vscode"; 7 | import * as fs from 'fs'; 8 | import * as ssri from 'ssri'; 9 | import { getApi, FileDownloader } from "@microsoft/vscode-file-downloader-api"; 10 | import * as tar from 'tar'; 11 | import * as os from 'os'; 12 | 13 | import { IaCDiagnostics } from './diagnostics'; 14 | import * as constants from './constants'; 15 | import * as util from './util'; 16 | import { IaCPoIViewManager } from './poiview'; 17 | import { runSemgrepHcl } from './semgrep'; 18 | 19 | 20 | export class IaCWebviewManager { 21 | private context: vscode.ExtensionContext; 22 | private mIaCDiagnostics: IaCDiagnostics; 23 | private disposables: vscode.Disposable[] = []; 24 | private currentPanel: vscode.WebviewPanel | undefined = undefined; 25 | private diagnostics: Map = new Map(); 26 | private disposed = false; 27 | private hideProgressBar: any = undefined; 28 | private inframapDownloading = false; 29 | private diagram: string | undefined = undefined; 30 | private resourceBlocks: Map = new Map; 31 | 32 | constructor(context: vscode.ExtensionContext, mIaCDiagnostics: IaCDiagnostics) { 33 | this.context = context; 34 | this.mIaCDiagnostics = mIaCDiagnostics; 35 | 36 | let disposableCommand1 = vscode.commands.registerCommand('poiex.showIaCwebview', () => { 37 | const options: vscode.OpenDialogOptions = { 38 | canSelectMany: false, 39 | openLabel: 'Select IaC definiton file', 40 | filters: { 41 | 'Terraform files': ['tf'], 42 | 'All files': ['*'] 43 | } 44 | }; 45 | 46 | vscode.window.showOpenDialog(options).then( 47 | fileUri => { 48 | if (!(fileUri && fileUri[0])) { 49 | return; 50 | } 51 | if (this.diagnostics.size > 0) { 52 | return fileUri; 53 | } 54 | return vscode.window.showInformationMessage("No findings. Do you want to run Semgrep?", "Yes", "No").then( 55 | async (ans) => { 56 | if (ans == "No") { 57 | return fileUri; 58 | } 59 | await util.handleStartSemgrepScan(context, mIaCDiagnostics); 60 | return fileUri; 61 | } 62 | ); 63 | } 64 | ).then(fileUri => { 65 | if (fileUri === undefined) return; 66 | if (fileUri === null) return; 67 | console.log('Selected file: ' + fileUri[0].fsPath); 68 | 69 | this.runInframap(fileUri[0].fsPath); 70 | }); 71 | }); 72 | context.subscriptions.push(disposableCommand1); 73 | this.disposables.push(disposableCommand1); 74 | mIaCDiagnostics.onDiagnosticsUpdate((diagnostics) => { this.diagnosticsUpdate(diagnostics); }); 75 | } 76 | 77 | dispose() { 78 | this.disposed = true; 79 | for (let d of this.disposables) { 80 | this.context.subscriptions.splice(this.context.subscriptions.indexOf(d), 1); 81 | d.dispose(); 82 | } 83 | if (this.currentPanel !== undefined) { this.currentPanel.dispose(); } 84 | } 85 | 86 | async downloadInframap(): Promise { 87 | assert(this.inframapDownloading === false); 88 | if (this.inframapDownloading) { return undefined; } 89 | 90 | let inframapUrl = constants.INFRAMAP_RELEASES[process.platform].url; 91 | if (inframapUrl === undefined) { 92 | // Show notification to user 93 | vscode.window.showInformationMessage( 94 | `Unable to install Inframap for platform ${process.platform}. Please install Inframap to use ${constants.EXT_COMMON_NAME}.`, 95 | ); 96 | return undefined; 97 | } 98 | 99 | this.inframapDownloading = true; 100 | const fileDownloader: FileDownloader = await getApi(); 101 | 102 | 103 | const file: Uri = await vscode.window.withProgress({ 104 | location: vscode.ProgressLocation.Notification, 105 | title: "Downloading Inframap...", 106 | cancellable: false 107 | }, async (progress, token) => { 108 | return await fileDownloader.downloadFile( 109 | Uri.parse(inframapUrl), 110 | `${util.genUUID()}.tar.gz`, 111 | this.context, 112 | undefined, 113 | (downloadedBytes: number | undefined, totalBytes: number | undefined) => { 114 | if (downloadedBytes === undefined || totalBytes === undefined) { 115 | return; 116 | } 117 | progress.report({ increment: (downloadedBytes / totalBytes) * 100 }); 118 | } 119 | ); 120 | }); 121 | 122 | console.log(file); 123 | return file; 124 | } 125 | 126 | async inframapIntegrityCheck(inframapPath: string): Promise { 127 | if (typeof inframapPath !== "string") { return false; } 128 | let integrity = constants.INFRAMAP_RELEASES[process.platform].integrity; 129 | try { 130 | let sri = await ssri.checkStream(fs.createReadStream(inframapPath), integrity); 131 | console.log(`[IaC View] Inframap integrity check passed: ${sri}`); 132 | return true; 133 | } catch (e) { 134 | console.log(`[IaC View] Inframap integrity check failed for ${inframapPath}`); 135 | return false; 136 | } 137 | } 138 | 139 | async getInframapPath(): Promise { 140 | if (this.inframapDownloading) { 141 | // No need to alert the user, there is already a progress notification shown 142 | return undefined; 143 | } 144 | 145 | let pathSetting: string | undefined = vscode.workspace.getConfiguration(`${constants.EXT_NAME}`).get('inframapPath'); 146 | if ((pathSetting !== undefined) && (pathSetting !== "")) { 147 | let res = which.sync(pathSetting, { nothrow: true }); 148 | if ((res === undefined) || (res === null)) { 149 | const resp = vscode.window.showInformationMessage( 150 | `Inframap not installed. Please install Inframap to use ${constants.EXT_COMMON_NAME}, or set Inframap path to empty.`, 151 | ); 152 | } else { 153 | return res; 154 | } 155 | } 156 | 157 | let inframapDownloaded = this.context.globalState.get(constants.INFRAMAP_DOWNLOADED_STATENAME, undefined) !== undefined; 158 | if (inframapDownloaded) { 159 | let inframapPath: string | undefined = this.context.globalState.get(constants.INFRAMAP_DOWNLOADED_STATENAME, undefined); 160 | if ((inframapPath !== undefined) && (await this.inframapIntegrityCheck(inframapPath))) { 161 | return inframapPath; 162 | } 163 | } 164 | 165 | console.log(`[IaC View] Inframap not found, downloading...`); 166 | let res = await this.downloadInframap(); 167 | if ((res === undefined) || (!await this.inframapIntegrityCheck(res.fsPath))) { 168 | vscode.window.showInformationMessage( 169 | `An error occurred while downloading Inframap. Please install Inframap to use ${constants.EXT_COMMON_NAME}.`, 170 | ); 171 | return undefined; 172 | } 173 | this.context.globalState.update(constants.INFRAMAP_DOWNLOADED_STATENAME, res.fsPath); 174 | return res.fsPath; 175 | } 176 | 177 | async inframapCallback(error: any, stdout: any, stderr: any, semgrepPromise: Promise): Promise { 178 | let semgrepOutputJson = await semgrepPromise; 179 | 180 | if (this.hideProgressBar) { 181 | this.hideProgressBar(); 182 | this.hideProgressBar = undefined; 183 | } 184 | 185 | if (semgrepOutputJson === null) { 186 | console.log(`[IaC View] semgrep output is null`); 187 | vscode.window.showErrorMessage(`Inframap error: Semgrep output is null`); 188 | return; 189 | } 190 | 191 | let semgrepOutput: any; 192 | try { 193 | semgrepOutput = JSON.parse(semgrepOutputJson); 194 | } catch (e) { 195 | console.log(`[IaC View] semgrep output is not JSON: ${semgrepOutputJson}`); 196 | vscode.window.showErrorMessage(`Inframap error: Semgrep output is not JSON`); 197 | return; 198 | } 199 | if (semgrepOutput === undefined) { assert(false, "semgrepOutputJson undefined"); return; } 200 | if (semgrepOutput.results === undefined) { assert(false, "semgrepOutputJson.results undefined"); return; } 201 | console.log(`[IaC View] Semgrep HCL parse found ${semgrepOutput.results.length} resource blocks`); 202 | 203 | // Convert results to a data structure. Map metavars $RT $RN to key = $RT.$RN Value = start line:start col, end line:end col 204 | let resourceBlocks: Map = new Map(); 205 | for (let result of semgrepOutput.results) { 206 | try { 207 | let startLine = result.start.line; 208 | let startCol = result.start.col; 209 | let endLine = result.end.line; 210 | let endCol = result.end.col; 211 | let filePath = result.path; 212 | let resourceName = result.extra.metavars.$RN.abstract_content; 213 | let resourceType = result.extra.metavars.$RT.abstract_content; 214 | let key = `${resourceType}.${resourceName}`; 215 | resourceBlocks.set(key, [filePath, startLine, startCol, endLine, endCol]); 216 | } catch (e) { 217 | console.log(`[IaC View] error: ${e}`); 218 | } 219 | } 220 | this.resourceBlocks = resourceBlocks; 221 | 222 | if (error) { 223 | console.log(`[IaC View] error: ${error.message}`); 224 | let timeoutFormatted = (constants.INFRAMAP_TIMEOUT_MS / 1000).toFixed(2); 225 | let msg = `Inframap timeout (${timeoutFormatted}s) exceeded or execution error. Error: ${error.message}`; 226 | vscode.window.showErrorMessage(msg); 227 | return; 228 | } 229 | if (stderr) { 230 | console.log(`[IaC View] stderr: ${stderr}`); 231 | vscode.window.showErrorMessage(`Inframap error: ${stderr}`); 232 | return; 233 | } 234 | console.log(`[IaC View] Inframap done, stdout: ${stdout}`); 235 | this.diagram = stdout; 236 | assert(this.diagram !== undefined); 237 | if (this.diagram === undefined) { return; } 238 | 239 | let localResourceRoots: Set | vscode.Uri[] = new Set(); 240 | let imagePaths = this.diagram?.matchAll(/image=\"(.*?)\"/g); 241 | for (let match of imagePaths) { 242 | let imagePath = match[1]; 243 | let imageUri = vscode.Uri.file(path.dirname(imagePath)); 244 | localResourceRoots.add(imageUri); 245 | } 246 | localResourceRoots.add(vscode.Uri.joinPath(this.context.extensionUri, 'res/')); 247 | localResourceRoots = Array.from(localResourceRoots); 248 | console.log(`[IaC View] localResourceRoots: ${localResourceRoots}`); 249 | 250 | this.currentPanel = vscode.window.createWebviewPanel( 251 | 'iacDiagram', 252 | 'IaC Analysis Diagram', 253 | vscode.ViewColumn.One, // Editor column to show the new webview panel in. 254 | { enableScripts: true, localResourceRoots: localResourceRoots } // Webview options. 255 | ); 256 | 257 | this.currentPanel.onDidDispose(() => { this.currentPanel = undefined; }, null, this.disposables); 258 | 259 | this.currentPanel.webview.html = this.getWebviewContent(); 260 | 261 | this.currentPanel.webview.onDidReceiveMessage(message => { 262 | switch (message.command) { 263 | case 'nodeClicked': 264 | // Sanitize message.nodeId 265 | if (message.nodeId === undefined) { return; } 266 | assert(this.resourceBlocks !== undefined); 267 | if (this.resourceBlocks === undefined) { return; } 268 | let serviceName = message.nodeId.replace(/[^0-9a-zA-Z_\.\-]/g, ""); 269 | new IaCPoIViewManager(this.context, this.mIaCDiagnostics, serviceName, this.resourceBlocks); 270 | return; 271 | } 272 | }); 273 | } 274 | 275 | async runInframap(codePath: string): Promise { 276 | let inframapPath = await this.getInframapPath(); 277 | if (inframapPath === undefined) { return; } 278 | 279 | const filenames: string[] = []; 280 | await tar.t({ 281 | file: inframapPath, 282 | onentry: entry => filenames.push(entry.path), 283 | }); 284 | if (filenames.length === 0) { return; }; 285 | let inframapExecName = filenames[0]; 286 | 287 | // Create a temporary directory 288 | const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'inframap-')); 289 | 290 | await tar.x( 291 | { 292 | file: inframapPath, 293 | cwd: tmpDir 294 | }, 295 | [inframapExecName] 296 | ); 297 | inframapPath = path.join(tmpDir, inframapExecName); 298 | 299 | if (this.hideProgressBar) { 300 | vscode.window.showErrorMessage("Inframap is already running."); 301 | return; 302 | } 303 | 304 | vscode.window.withProgress({ 305 | location: { viewId: 'iacAudit' } 306 | }, (progress, token) => { 307 | return new Promise((resolve, reject) => { 308 | this.hideProgressBar = resolve; 309 | }); 310 | }); 311 | 312 | let codeDir = path.dirname(codePath); 313 | let inframapArgs = "generate --clean=false --hcl"; 314 | let inframapArgsArray = inframapArgs.split(" ").concat(codeDir); 315 | console.log(`[IaC View] Running Inframap (${inframapPath}) with args: ${inframapArgsArray}`); 316 | 317 | // Start a Semgrep run to parse HCL files 318 | let semgrepPromise = util.handleStartSemgrepScan(this.context, null, codeDir); 319 | 320 | try { 321 | child_process.execFile(inframapPath, 322 | inframapArgsArray, 323 | { timeout: constants.INFRAMAP_TIMEOUT_MS }, 324 | (error, stdout, stderr) => { 325 | this.inframapCallback(error, stdout, stderr, semgrepPromise); 326 | } 327 | ); 328 | } catch (error) { 329 | const resp = vscode.window.showInformationMessage( 330 | `Error while running Inframap.`, 331 | ); 332 | return; 333 | } 334 | return; 335 | } 336 | 337 | 338 | diagnosticsUpdate(diagnostics: Map) { 339 | if (this.disposed) { return; } 340 | this.diagnostics = diagnostics; 341 | if (this.currentPanel !== undefined) { 342 | this.currentPanel.webview.html = this.getWebviewContent(); 343 | } 344 | } 345 | 346 | private getWebviewContent(): string { 347 | let diagram = ""; 348 | if (this.diagram !== undefined) { 349 | diagram = this.diagram; 350 | } 351 | if ((this.currentPanel !== undefined) && (this.currentPanel.webview !== undefined)) { 352 | diagram = diagram.replace(/image=\"(.*?)\"/g, (match, capture) => { 353 | if (this.currentPanel === undefined) { return match; } 354 | return `image="${this.currentPanel.webview.asWebviewUri(vscode.Uri.file(capture))}"`; 355 | }); 356 | } 357 | let formattedDiagram = diagram.replace(/\'/g, '').replace(/\n/g, ''); 358 | console.log(`[IaC View] Diagram: ${diagram}`); 359 | 360 | // For each resource node, count how many PoIs there are 361 | let poiList = []; 362 | for (let [key, value] of this.diagnostics) { 363 | if (!value.message.includes(constants.IAC_POI_MESSAGE)) { continue; }; 364 | poiList.push(value.message); 365 | } 366 | 367 | // For each resource node, count how many PoIs there are 368 | let findingsList = []; 369 | for (let [poiFilter, resourceBlock] of this.resourceBlocks) { 370 | let [path, startLine, startCol, endLine, endCol] = resourceBlock; 371 | // Semgrep lines are 1-indexed, vscode lines are 0-indexed 372 | startLine = startLine - 1; 373 | endLine = endLine - 1; 374 | for (let [key, value] of this.diagnostics) { 375 | if (value.path !== path) { continue; }; 376 | if ((value.line > endLine) || (value.line < startLine)) { continue; } 377 | findingsList.push(poiFilter); 378 | } 379 | } 380 | 381 | let scriptUri = vscode.Uri.joinPath(this.context.extensionUri, 'res', 'iacView.js'); 382 | let styleUri = vscode.Uri.joinPath(this.context.extensionUri, 'res', 'iacView.css'); 383 | let scriptWebUri = "./res/iacView.js"; 384 | let styleWebUri = "./res/iacView.css"; 385 | if (this.currentPanel !== undefined) { 386 | scriptWebUri = this.currentPanel.webview.asWebviewUri(scriptUri).toString(); 387 | styleWebUri = this.currentPanel.webview.asWebviewUri(styleUri).toString(); 388 | } 389 | 390 | let res = ` 391 | 392 | 393 | 394 | 395 | 402 | 403 | 404 |
405 | 406 | 407 | 408 | `; 409 | return res; 410 | } 411 | } -------------------------------------------------------------------------------- /src/poiview.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | import { IaCDiagnostics } from './diagnostics'; 4 | import * as constants from './constants'; 5 | 6 | 7 | export class IaCPoIViewManager { 8 | private context: vscode.ExtensionContext; 9 | private disposables: vscode.Disposable[] = []; 10 | private currentPanel: vscode.WebviewPanel | undefined = undefined; 11 | private diagnostics: Map = new Map(); 12 | private disposed = false; 13 | private poiFilter; 14 | private mIaCDiagnostics: IaCDiagnostics; 15 | private resourceBlocks: Map; 16 | 17 | constructor(context: vscode.ExtensionContext, mIaCDiagnostics: IaCDiagnostics, poiFilter: string, resourceBlocks: Map) { 18 | this.context = context; 19 | this.poiFilter = poiFilter; 20 | this.resourceBlocks = resourceBlocks; 21 | this.mIaCDiagnostics = mIaCDiagnostics; 22 | mIaCDiagnostics.onDiagnosticsUpdate((diagnostics) => { this.diagnosticsUpdate(diagnostics); }); 23 | 24 | this.currentPanel = vscode.window.createWebviewPanel( 25 | 'poiView', 26 | `IaC PoI (${poiFilter})`, 27 | vscode.ViewColumn.One, // Editor column to show the new webview panel in. 28 | { enableScripts: true } // Webview options. 29 | ); 30 | 31 | this.currentPanel.onDidDispose(() => { this.currentPanel = undefined; }, null, this.disposables); 32 | 33 | this.getWebviewContent().then( data => { 34 | if (this.currentPanel !== undefined) { 35 | this.currentPanel.webview.html = data; 36 | } 37 | }) 38 | 39 | this.currentPanel.webview.onDidReceiveMessage(async message => { 40 | console.log(message); 41 | switch (message.command) { 42 | case 'openPoi': 43 | let poiUUID = message.poiUUID; 44 | let poi = this.diagnostics.get(poiUUID); 45 | console.log(poi); 46 | if (poi === undefined) { 47 | vscode.window.showErrorMessage(`PoI ${poiUUID} not found`); 48 | return; 49 | } 50 | 51 | // Go to the file and line 52 | let doc = await mIaCDiagnostics.getDocumentFromSemgrepPath(poi.path); 53 | if (doc === undefined) { 54 | vscode.window.showErrorMessage(`File ${poi.path} not found`); 55 | return; 56 | } 57 | let line = poi.line; 58 | vscode.window.showTextDocument(doc).then((editor) => { 59 | let range = new vscode.Range(line, 0, line, 100); 60 | editor.selection = new vscode.Selection(range.start, range.end); 61 | editor.revealRange(range, vscode.TextEditorRevealType.InCenter); 62 | }); 63 | break; 64 | case 'openIaC': 65 | let resourceBlock = this.resourceBlocks.get(this.poiFilter); 66 | if (resourceBlock === undefined) { 67 | vscode.window.showErrorMessage(`No resource block found for ${this.poiFilter}`); 68 | return; 69 | } 70 | let [path, startLine, startCol, endLine, endCol] = resourceBlock; 71 | let doc2 = await mIaCDiagnostics.getDocumentFromSemgrepPath(path); 72 | if (doc2 === undefined) { 73 | vscode.window.showErrorMessage(`File ${path} not found`); 74 | return; 75 | } 76 | let range = new vscode.Range(startLine - 1, startCol - 1, endLine - 1, endCol - 1); 77 | vscode.window.showTextDocument(doc2).then((editor) => { 78 | editor.selection = new vscode.Selection(range.start, range.end); 79 | editor.revealRange(range, vscode.TextEditorRevealType.InCenter); 80 | }); 81 | break; 82 | default: 83 | console.error(`[PoI View] Unknown command ${message.command}`); 84 | break; 85 | } 86 | }); 87 | } 88 | 89 | dispose() { 90 | this.disposed = true; 91 | for (let d of this.disposables) { 92 | this.context.subscriptions.splice(this.context.subscriptions.indexOf(d), 1); 93 | d.dispose(); 94 | } 95 | if (this.currentPanel !== undefined) { this.currentPanel.dispose(); } 96 | } 97 | 98 | diagnosticsUpdate(diagnostics: Map) { 99 | if (this.disposed) { return; } 100 | this.diagnostics = diagnostics; 101 | this.getWebviewContent().then( data => { 102 | if (this.currentPanel !== undefined) { 103 | this.currentPanel.webview.html = data; 104 | } 105 | }) 106 | } 107 | 108 | private async getWebviewContent(): Promise { 109 | let poiList = []; 110 | let providerFilter = ""; 111 | let serviceFilter = ""; 112 | let poiFilterParts = this.poiFilter.split("_"); 113 | if (poiFilterParts.length >= 1) { 114 | providerFilter = this.poiFilter.split("_")[0]; 115 | } 116 | if (poiFilterParts.length >= 2) { 117 | serviceFilter = this.poiFilter.split("_")[1]; 118 | } 119 | 120 | // Filter Points of Intersections that are related to the selected cloud service 121 | for (let [key, value] of this.diagnostics) { 122 | if (!value.message.includes(constants.IAC_POI_MESSAGE)) { continue; }; 123 | if (!value.message.toLowerCase().includes(providerFilter.toLowerCase())) { continue; } 124 | if (!value.message.toLowerCase().includes(serviceFilter.toLowerCase())) { continue; } 125 | poiList.push([key, value.message, value.path, value.line]); 126 | } 127 | // Filter diagnostics that match the currently selected resource block 128 | let resourceBlock = this.resourceBlocks.get(this.poiFilter); 129 | if (resourceBlock !== undefined) { 130 | let [path, startLine, startCol, endLine, endCol] = resourceBlock; 131 | // Semgrep lines are 1-indexed, vscode lines are 0-indexed 132 | startLine = startLine - 1; 133 | endLine = endLine - 1; 134 | for (let [key, value] of this.diagnostics) { 135 | if (value.path !== path) { continue; }; 136 | if ((value.line > endLine) || (value.line < startLine)) { continue; } 137 | poiList.push([key, value.message, value.path, value.line]); 138 | } 139 | console.log(`[PoI View] Resource block found for ${this.poiFilter}`); 140 | } 141 | else { 142 | console.log(`[PoI View] No resource block found for ${this.poiFilter}`); 143 | } 144 | 145 | // Get IaC definition for selected resource block 146 | let iacDefinitionBlock: string | null = null; 147 | if (resourceBlock !== undefined) { 148 | let [path, startLine, startCol, endLine, endCol] = resourceBlock; 149 | 150 | // Document from semgrep path 151 | let doc = await this.mIaCDiagnostics.getDocumentFromSemgrepPath(path); 152 | if (doc === undefined) { 153 | vscode.window.showErrorMessage(`File ${path} not found`); 154 | } 155 | else { 156 | // Semgrep lines are 1-indexed, vscode lines are 0-indexed 157 | let r = new vscode.Range(startLine - 1, startCol - 1, endLine - 1, endCol - 1); 158 | iacDefinitionBlock = doc.getText(r); 159 | } 160 | } 161 | 162 | let scriptUri = vscode.Uri.joinPath(this.context.extensionUri, 'res', 'poiView.js'); 163 | let styleUri = vscode.Uri.joinPath(this.context.extensionUri, 'res', 'poiView.css'); 164 | let scriptWebUri = "./res/poiView.js"; 165 | let styleWebUri = "./res/poiView.css"; 166 | if (this.currentPanel !== undefined) { 167 | scriptWebUri = this.currentPanel.webview.asWebviewUri(scriptUri).toString(); 168 | styleWebUri = this.currentPanel.webview.asWebviewUri(styleUri).toString(); 169 | } 170 | 171 | let res = ` 172 | 173 | 174 | 175 | 181 | 182 | 183 |
184 | 185 | 186 | 187 | `; 188 | return res; 189 | } 190 | } -------------------------------------------------------------------------------- /src/projects.ts: -------------------------------------------------------------------------------- 1 | // IaC Project directory 2 | 3 | import * as path from "path"; 4 | import * as vscode from "vscode"; 5 | import * as sqlite3 from 'sqlite3'; 6 | import { open, Database } from 'sqlite'; 7 | import assert = require("assert"); 8 | 9 | import { RemoteDB } from "./remote"; 10 | import { LocalDB } from "./db"; 11 | import { IaCEncryption } from "./encryption"; 12 | import * as constants from "./constants"; 13 | 14 | export class IaCProjectDir { 15 | private pdb: Database | null; 16 | private context: vscode.ExtensionContext; 17 | private currentSync: Promise | undefined = undefined; 18 | private iacPath: vscode.Uri; 19 | 20 | constructor(context: vscode.ExtensionContext, iacPath: vscode.Uri) { 21 | // Create a new sqlite3 database in the global storage directory 22 | this.pdb = null; 23 | this.iacPath = iacPath; 24 | this.context = context; 25 | } 26 | 27 | async init(): Promise { 28 | assert(this.pdb === null, "ProjectDB init called twice"); 29 | console.log("[IaC ProjectDir] Init"); 30 | if (this.pdb !== null) { return; } 31 | this.pdb = await this.initProjectDb(this.iacPath); 32 | return; 33 | } 34 | 35 | async initProjectDb(iacPath: vscode.Uri) { 36 | let relDbPath = constants.PROJECT_DIR_DB; 37 | let dbPath = iacPath.fsPath + '/' + relDbPath; 38 | let db = await open({ 39 | filename: dbPath, 40 | driver: sqlite3.Database 41 | }); 42 | await this.createDbTables(db); 43 | return db; 44 | } 45 | 46 | async createDbTables(db: any) { 47 | await db.exec(`CREATE TABLE IF NOT EXISTS projects ( 48 | uuid TEXT NOT NULL PRIMARY KEY, 49 | name TEXT NOT NULL, 50 | keys TEXT NULL, 51 | jwt TEXT NULL DEFAULT NULL, 52 | deleted INTEGER DEFAULT 0 53 | )`); 54 | } 55 | 56 | async listProjects(): Promise { 57 | if (this.pdb === null) { 58 | return []; 59 | } 60 | let stmt = await this.pdb.prepare('SELECT uuid, name, keys FROM projects WHERE deleted = 0'); 61 | let rows = await stmt.all({}); 62 | await stmt.finalize(); 63 | return rows as any[]; 64 | } 65 | 66 | async pushProject(uuid: string, name: string, keys: string | null, jwt: string | null = null) { 67 | if (this.pdb === null) { 68 | return; 69 | } 70 | if (jwt === null && keys !== null) { 71 | let mIaCEncryption = new IaCEncryption(); 72 | await mIaCEncryption.setKey(keys); 73 | jwt = await mIaCEncryption.encrypt({ uuid: uuid }); 74 | } 75 | 76 | let stmt = await this.pdb.prepare('INSERT OR REPLACE INTO projects (uuid, name, keys, jwt) VALUES (?, ?, ?, ?)'); 77 | console.log(`[IaC Projects] pushProject(${uuid}, ${name}, ${keys})`); 78 | assert(uuid !== null); 79 | await stmt.run(uuid, name, keys, jwt); 80 | await stmt.finalize(); 81 | } 82 | 83 | async removeProject(uuid: string, rdb: RemoteDB | null = null) { 84 | if (this.pdb === null) { 85 | return; 86 | } 87 | let stmt = await this.pdb.prepare('UPDATE projects SET deleted = 1, keys = NULL, jwt = NULL, name = \'\' WHERE uuid = ?'); 88 | await stmt.run(uuid); 89 | await stmt.finalize(); 90 | 91 | if (rdb !== null) { 92 | await this.syncProjects(rdb); 93 | } 94 | } 95 | 96 | async getProject(uuid: string): Promise<[string, string, string, string] | null> { 97 | if (this.pdb === null) { 98 | return null; 99 | } 100 | console.log(`[IaC Projects] getProject(${uuid})`); 101 | console.log(`[IaC Projects] list of projects:`); 102 | (await this.listProjects()).forEach((project: any) => { 103 | console.log(`[IaC Projects] ${project.uuid} ${project.name} ${project.keys}`); 104 | }); 105 | 106 | 107 | let stmt = await this.pdb.prepare('SELECT uuid, name, keys, jwt FROM projects WHERE uuid = ? AND deleted = 0'); 108 | let row: any = await stmt.get(uuid); 109 | await stmt.finalize(); 110 | if (row === undefined || row === null) { 111 | return null; 112 | } 113 | return [row.uuid, row.name, row.keys, row.jwt]; 114 | } 115 | 116 | async close() { 117 | if (this.pdb === null) { 118 | return; 119 | } 120 | await this.pdb.close(); 121 | } 122 | 123 | async safeSyncProjects(rdb: RemoteDB) { 124 | if (this.currentSync !== undefined) { 125 | console.log("[IaC Projects] Sync already in progress, skipping"); 126 | return; 127 | } 128 | this.currentSync = this.syncProjects(rdb).finally(() => { 129 | this.currentSync = undefined; 130 | }); 131 | await this.currentSync; 132 | } 133 | 134 | async syncProjects(rdb: RemoteDB): Promise { 135 | console.log(`[IaC Projects] syncProjects()`); 136 | if (!rdb.settingsEnabledAndConfigured()) { return; } 137 | if (this.pdb === null) { return; } 138 | let res = await rdb.listProjects(); 139 | if (res === null) { return; } 140 | let [remoteProjects, remoteDeletedProjects] = res; 141 | remoteDeletedProjects = remoteDeletedProjects.map((row: any) => row.uuid); 142 | 143 | let remoteProjectUuids = remoteProjects.map((row: any) => row.uuid); 144 | console.log(`[IaC Projects] remoteProjects: ${remoteProjectUuids} total ${remoteProjects.length}`); 145 | 146 | let stmt = await this.pdb.prepare('SELECT uuid, name, jwt, deleted FROM projects'); 147 | let rows = await stmt.all(); 148 | await stmt.finalize(); 149 | let localProjects = rows.filter((row: any) => row.deleted !== 1); 150 | let localDeletedProjects = rows.filter((row: any) => row.deleted === 1).map((row: any) => row.uuid); 151 | 152 | // Delete local projects that have been deleted remotely 153 | console.log(`[IaC Projects] remoteDeletedProjects: ${remoteDeletedProjects}`); 154 | for (let uuid of remoteDeletedProjects) { 155 | console.log(`[IaC Projects] uuid: ${uuid}`); 156 | if (localProjects.some((row: any) => row.uuid === uuid)) { 157 | await this.removeProject(uuid); 158 | // Remove the project from the local list so it doesn't get added again 159 | localProjects = localProjects.filter((row: any) => row.uuid !== uuid); 160 | // Remove local project database 161 | // TODO: remove duplicate code from main file 162 | let dbDir = path.join(this.context.globalStorageUri.fsPath, constants.IAC_FOLDER_NAME); 163 | let dbFilename = path.basename(`/${constants.EXT_NAME}-${uuid}.db`); 164 | let dbPath = path.join(dbDir, dbFilename); 165 | let db = new LocalDB(dbPath); 166 | await db.init(); 167 | await db.dropAllTables(); 168 | await db.close(); 169 | } else if (rows.filter((row: any) => row.uuid === uuid).length === 0) { 170 | // If the project doesn't exist locally, add it as deleted 171 | console.log(`[IaC Projects] Adding remote project as deleted: ${uuid}`); 172 | await this.pushProject(uuid, "", null); 173 | await this.removeProject(uuid); 174 | } 175 | } 176 | 177 | // Delete remote projects that have been deleted locally 178 | for (let uuid of localDeletedProjects) { 179 | if (remoteProjects.some((proj: any) => proj.uuid === uuid) || (!remoteDeletedProjects.some((uuid2: any) => uuid2 === uuid))) { 180 | console.log(`[IaC Projects] Removing remote project ${uuid} as was deleted locally`); 181 | await rdb.pushProject(uuid, "", null, true); 182 | 183 | // Remove the project from the remote list so it doesn't get added again 184 | remoteProjects = remoteProjects.filter((proj: any) => proj.uuid !== uuid); 185 | } 186 | } 187 | 188 | // Push local projects that don't exist remotely 189 | for (let row of localProjects) { 190 | if (!remoteProjects.some((proj: any) => proj.uuid === row.uuid)) { 191 | console.log(`[IaC Projects] Pushing local project: ${row.uuid}`); 192 | await rdb.pushProject(row.uuid, row.name, row.jwt, false); 193 | } 194 | } 195 | 196 | // Pull remote projects that don't exist locally 197 | for (let proj of remoteProjects) { 198 | let uuid = proj.uuid; 199 | if (localProjects.some((row: any) => row.uuid === uuid)) { 200 | console.log(`[IaC Projects] Project already exists locally: ${uuid}`); 201 | continue; 202 | } 203 | console.log(`[IaC Projects] Pulling remote project into local db: ${uuid}`); 204 | await this.pushProject(proj.uuid, proj.name, null, proj.jwt); 205 | } 206 | 207 | console.log(`[IaC Projects] syncProjects() done`); 208 | 209 | return; 210 | } 211 | } -------------------------------------------------------------------------------- /src/semgrep.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs"; 2 | import * as path from 'path'; 3 | import { EXT_COMMON_NAME, SEMGREP_TIMEOUT_MS } from './constants'; 4 | import * as which from "which"; 5 | import * as child_process from "child_process"; 6 | import * as vscode from "vscode"; 7 | import * as constants from "./constants"; 8 | import { IaCDiagnostics } from "./diagnostics"; 9 | import * as util from "util"; 10 | import { assert } from "console"; 11 | 12 | export function getSemgrepPath(): string | undefined { 13 | let res = which.sync("semgrep", { nothrow: true }); 14 | if ((res === undefined) || (res === null)) { 15 | const resp = vscode.window.showInformationMessage( 16 | `Semgrep not installed. Please install Semgrep to use ${EXT_COMMON_NAME}.`, 17 | ); 18 | return undefined; 19 | } else { 20 | return res; 21 | } 22 | } 23 | 24 | let hideProgressBar: any = undefined; 25 | let gmIaCDiagnostics: IaCDiagnostics; 26 | 27 | export async function runSemgrep(context: vscode.ExtensionContext, path: string, mIaCDiagnostics: IaCDiagnostics): Promise { 28 | let semgrepPath = getSemgrepPath(); 29 | if (semgrepPath === undefined) { return; } 30 | if (hideProgressBar) { 31 | vscode.window.showErrorMessage("Semgrep is already running."); 32 | return; 33 | } 34 | 35 | vscode.window.withProgress({ 36 | location: { viewId: 'iacAudit' } 37 | }, (progress, token) => { 38 | return new Promise((resolve, reject) => { 39 | hideProgressBar = resolve; 40 | }); 41 | }); 42 | 43 | gmIaCDiagnostics = mIaCDiagnostics; 44 | // Load semgrepArgs from settings 45 | let semgrepArgs: string | undefined = vscode.workspace.getConfiguration(`${constants.EXT_NAME}`).get('semgrepArgs'); 46 | if ((semgrepArgs === undefined) || (semgrepArgs.trim() === "")) { 47 | semgrepArgs = "--config auto"; 48 | } 49 | let semgrepArgsArray = semgrepArgs.split(" "); 50 | if (vscode.workspace.getConfiguration(`${constants.EXT_NAME}`).get('enableIaC')) { 51 | console.log(`[IaC Semgrep] Running Semgrep with IaC rules`); 52 | semgrepArgsArray = semgrepArgsArray.concat(["--config", context.asAbsolutePath("rules/")]); 53 | } 54 | else { 55 | console.log(`[IaC Semgrep] Running Semgrep WITHOUT IaC rules`); 56 | } 57 | semgrepArgsArray = ["--json", "--quiet"].concat(semgrepArgsArray).concat(["./"]); 58 | console.log(`[IaC Semgrep] Running Semgrep with args: ${semgrepArgsArray}`); 59 | 60 | // Load semgrepTimeout from settings 61 | let semgrepTimeout: number | undefined = vscode.workspace.getConfiguration(`${constants.EXT_NAME}`).get('semgrepTimeout'); 62 | if ((semgrepTimeout === undefined) || (semgrepTimeout < 0)) { 63 | assert(false, `Invalid semgrepTimeout value: ${semgrepTimeout}`); 64 | console.error(`[IaC Semgrep] Invalid semgrepTimeout value: ${semgrepTimeout}`); 65 | return; 66 | } 67 | 68 | let execFile = util.promisify(child_process.execFile); 69 | try { 70 | let {stderr, stdout} = await execFile(semgrepPath, semgrepArgsArray, { timeout: semgrepTimeout * 1000, cwd: path, maxBuffer: constants.SEMGREP_MAX_BUFFER }); 71 | if (stderr) { 72 | console.log(`[IaC Semgrep] stderr: ${stderr}`); 73 | vscode.window.showErrorMessage(`Semgrep error: ${stderr}`); 74 | return; 75 | } 76 | console.log(`[IaC Semgrep] Semgrep done, stdout: ${stdout}`); 77 | 78 | let jsonParsed = { parsed: JSON.parse(stdout), source: "semgrep-local" }; 79 | await gmIaCDiagnostics.clearDiagnostics(); 80 | await gmIaCDiagnostics.loadDiagnosticsFromSemgrep(jsonParsed); 81 | } 82 | catch (error: any) { 83 | console.log(`[IaC Semgrep] error: ${error.message}`); 84 | let timeoutFormatted = semgrepTimeout.toFixed(2); 85 | let msg = `Semgrep timeout (${timeoutFormatted}s) exceeded or execution error. Error: ${error.message}`; 86 | vscode.window.showErrorMessage(msg); 87 | } 88 | finally { 89 | if (hideProgressBar) { 90 | hideProgressBar(); 91 | hideProgressBar = undefined; 92 | } 93 | } 94 | } 95 | 96 | export async function runSemgrepHcl(context: vscode.ExtensionContext, wspath: string, iacpath: string): Promise { 97 | let semgrepPath = getSemgrepPath(); 98 | if (semgrepPath === undefined) { return null; } 99 | 100 | console.log(`[IaC Semgrep] Semgrep iacpath: ${iacpath}`); 101 | console.log(`[IaC Semgrep] Semgrep wspath: ${wspath}`); 102 | iacpath = path.relative(wspath, iacpath); 103 | console.log(`[IaC Semgrep] Semgrep CWD: ${iacpath}`); 104 | 105 | if (iacpath.trim() === "") { 106 | iacpath = "./"; 107 | } 108 | 109 | let semgrepArgsArray: string[] = ["--config", context.asAbsolutePath("tfparse_rules/")]; 110 | semgrepArgsArray = ["--no-git-ignore", "--json", "--quiet"].concat(semgrepArgsArray).concat([iacpath]); 111 | console.log(`[IaC Semgrep] Running Semgrep with args: ${semgrepArgsArray}`); 112 | 113 | let execFile = util.promisify(child_process.execFile); 114 | 115 | try { 116 | let {stderr, stdout} = await execFile(semgrepPath, semgrepArgsArray, { timeout: SEMGREP_TIMEOUT_MS, cwd: wspath, maxBuffer: constants.SEMGREP_MAX_BUFFER }); 117 | if (stderr) { 118 | console.log(`[IaC Semgrep] stderr: ${stderr}`); 119 | vscode.window.showErrorMessage(`Semgrep error: ${stderr}`); 120 | return null; 121 | } 122 | console.log(`[IaC Semgrep] Semgrep done, stdout: ${stdout}`); 123 | return stdout; 124 | } 125 | catch (error: any) { 126 | console.log(`[IaC Semgrep] error: ${error.message}`); 127 | let timeoutFormatted = (SEMGREP_TIMEOUT_MS / 1000).toFixed(2); 128 | let msg = `Semgrep timeout (${timeoutFormatted}s) exceeded or execution error. Error: ${error.message}`; 129 | vscode.window.showErrorMessage(msg); 130 | return null; 131 | } 132 | } 133 | 134 | export function parseSemgrep(jsonPath: any): any { 135 | let jsonString = readFileSync(jsonPath, { encoding: "utf8" }); 136 | let jsonParsed = JSON.parse(jsonString); 137 | return { parsed: jsonParsed, path: path.dirname(jsonPath) + '/', source: "semgrep-imported" }; 138 | } -------------------------------------------------------------------------------- /src/test/runTest.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | import { runTests } from '@vscode/test-electron'; 4 | 5 | async function main() { 6 | try { 7 | // The folder containing the Extension Manifest package.json 8 | // Passed to `--extensionDevelopmentPath` 9 | const extensionDevelopmentPath = path.resolve(__dirname, '../../'); 10 | 11 | // The path to test runner 12 | // Passed to --extensionTestsPath 13 | const extensionTestsPath = path.resolve(__dirname, './suite/index'); 14 | 15 | // Download VS Code, unzip it and run the integration test 16 | await runTests({ extensionDevelopmentPath, extensionTestsPath }); 17 | } catch (err) { 18 | console.error('Failed to run tests', err); 19 | process.exit(1); 20 | } 21 | } 22 | 23 | main(); 24 | -------------------------------------------------------------------------------- /src/test/suite/extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | 3 | // You can import and use all API from the 'vscode' module 4 | // as well as import your extension to test it 5 | import * as vscode from 'vscode'; 6 | // import * as myExtension from '../../extension'; 7 | 8 | suite('Extension Test Suite', () => { 9 | vscode.window.showInformationMessage('Start all tests.'); 10 | 11 | test('Sample test', () => { 12 | assert.strictEqual(-1, [1, 2, 3].indexOf(5)); 13 | assert.strictEqual(-1, [1, 2, 3].indexOf(0)); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/test/suite/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as Mocha from 'mocha'; 3 | import * as glob from 'glob'; 4 | 5 | export function run(): Promise { 6 | // Create the mocha test 7 | const mocha = new Mocha({ 8 | ui: 'tdd', 9 | color: true 10 | }); 11 | 12 | const testsRoot = path.resolve(__dirname, '..'); 13 | 14 | return new Promise((c, e) => { 15 | glob('**/**.test.js', { cwd: testsRoot }, (err, files) => { 16 | if (err) { 17 | return e(err); 18 | } 19 | 20 | // Add files to the test suite 21 | files.forEach(f => mocha.addFile(path.resolve(testsRoot, f))); 22 | 23 | try { 24 | // Run the mocha test 25 | mocha.run(failures => { 26 | if (failures > 0) { 27 | e(new Error(`${failures} tests failed.`)); 28 | } else { 29 | c(); 30 | } 31 | }); 32 | } catch (err) { 33 | console.error(err); 34 | e(err); 35 | } 36 | }); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /src/tree.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import * as util from 'util'; 5 | 6 | import * as projects from './projects'; 7 | import * as constants from './constants'; 8 | import * as remote from './remote'; 9 | 10 | export class ProjectTreeViewManager { 11 | private context: vscode.ExtensionContext; 12 | private pdb: projects.IaCProjectDir; 13 | private rdb: remote.RemoteDB | undefined; 14 | private projectTreeView: vscode.TreeView | undefined = undefined; 15 | private projectTreeDataProvider: ProjectSelectorProvider | undefined = undefined; 16 | private originalViewTitle: string | undefined = undefined; 17 | private _disposables: vscode.Disposable[] = []; 18 | private hideProgressBar: any = undefined; 19 | 20 | constructor(context: vscode.ExtensionContext, pbd: projects.IaCProjectDir, rdb: remote.RemoteDB | undefined) { 21 | this.context = context; 22 | this.pdb = pbd; 23 | this.rdb = rdb; 24 | 25 | this._disposables.push(vscode.commands.registerCommand('iacAudit.refreshProjectTree', async () => { 26 | if (this.hideProgressBar !== undefined) { 27 | console.log("[IaC Tree] Already refreshing project list"); 28 | return; 29 | } 30 | 31 | vscode.window.withProgress({ 32 | location: { viewId: 'iacAudit' } 33 | }, (progress, token) => { 34 | return new Promise((resolve, reject) => { 35 | this.hideProgressBar = resolve; 36 | }); 37 | }); 38 | 39 | try { 40 | await this.update(); 41 | } 42 | catch (err: any) { 43 | console.error(`[IaC Tree] Error refreshing project list: ${err}`); 44 | } 45 | finally { 46 | if (this.hideProgressBar !== undefined) { 47 | this.hideProgressBar(); 48 | this.hideProgressBar = undefined; 49 | } 50 | } 51 | })); 52 | 53 | this._disposables.push(vscode.commands.registerCommand('iacAudit.deleteTreeProject', async (project: ProjectItem) => { 54 | console.log(`[IaC Tree] Deleting project ${project.uuid}`); 55 | vscode.window.showWarningMessage('Are you sure you want to destroy the selected project (remove also on remote)?', 'Yes', 'No').then(async (choice) => { 56 | if (choice === 'Yes') { 57 | await this.pdb.removeProject(project.uuid, rdb); 58 | await this.update(); 59 | } 60 | }); 61 | })); 62 | } 63 | 64 | async show() { 65 | this.projectTreeDataProvider = new ProjectSelectorProvider([]); 66 | this.projectTreeView = vscode.window.createTreeView('iacAudit', { 67 | treeDataProvider: this.projectTreeDataProvider, 68 | }); 69 | this.originalViewTitle = this.projectTreeView.title; 70 | this.projectTreeView.title = constants.PROJECT_TREE_VIEW_TITLE; 71 | this.projectTreeView.message = undefined; 72 | this.projectTreeView.onDidChangeSelection(async (e) => { 73 | if (e.selection.length !== 1) { 74 | console.log(`[IaC Tree] Invalid selection length: ${e.selection}`); 75 | return; 76 | } 77 | let project = e.selection[0]; 78 | if (project instanceof ProjectItem) { 79 | console.log(`[IaC Tree] Opening project ${project.uuid}`); 80 | await vscode.commands.executeCommand(`${constants.EXT_NAME}.openProject`, project.uuid); 81 | return; 82 | } 83 | console.log(`[IaC Tree] Invalid selection: ${e.selection}`); 84 | }); 85 | } 86 | 87 | async hide() { 88 | if (this.projectTreeView === undefined) { 89 | return; 90 | } 91 | this.projectTreeView.title = this.originalViewTitle || ""; 92 | this.projectTreeView.message = undefined; 93 | await this.projectTreeView?.dispose(); 94 | } 95 | 96 | async showDbError() { 97 | if (this.projectTreeView === undefined) { 98 | await this.show(); 99 | } 100 | (this.projectTreeView as vscode.TreeView).message = constants.PROJECT_TREE_VIEW_DB_ERROR_MESSAGE; 101 | } 102 | 103 | private async syncRemoteDB(): Promise { 104 | if (this.rdb === undefined) { 105 | return false; 106 | } 107 | 108 | if (this.rdb.settingsEnabled()) { 109 | // Promisify onDbReadyOnce 110 | let onDbReadyOnce = (): Promise => { 111 | let rrdb = this.rdb; 112 | return new Promise(function(resolve, reject) { 113 | if (rrdb === undefined) { 114 | resolve(); 115 | } 116 | else { 117 | rrdb.onDbReadyOnce(resolve); 118 | } 119 | }); 120 | } 121 | 122 | try { 123 | await onDbReadyOnce(); 124 | await this.pdb.safeSyncProjects(this.rdb); 125 | } 126 | catch (err) { 127 | console.error(`[IaC Tree] Error syncing remote DB: ${err}`); 128 | return false; 129 | } 130 | return true; 131 | } 132 | else { 133 | return false; 134 | } 135 | } 136 | 137 | async update(projectList: {}[] | null = null) { 138 | if (projectList == null) { 139 | await this.syncRemoteDB(); 140 | projectList = (await this.pdb.listProjects()) as {}[]; 141 | } 142 | if (this.projectTreeView !== undefined) { 143 | this.projectTreeDataProvider?.update(projectList); 144 | if (projectList.length === 0) { 145 | this.projectTreeView.message = constants.PROJECT_TREE_VIEW_NO_PROJECTS_MESSAGE; 146 | } 147 | } 148 | } 149 | 150 | async dispose() { 151 | await this.hide(); 152 | 153 | this._disposables.forEach((disposable) => { 154 | disposable.dispose(); 155 | }); 156 | } 157 | } 158 | 159 | class ProjectSelectorProvider implements vscode.TreeDataProvider { 160 | private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); 161 | readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; 162 | 163 | constructor(private projectList: any[]) { } 164 | 165 | update(projectList: any[]) { 166 | this.projectList = projectList; 167 | this._onDidChangeTreeData.fire(); 168 | } 169 | 170 | getTreeItem(element: ProjectItem): vscode.TreeItem { 171 | return element; 172 | } 173 | 174 | getChildren(element?: ProjectItem): Thenable { 175 | if (element) { 176 | return Promise.resolve([]); 177 | } else { 178 | return Promise.resolve(this.getProjects()); 179 | } 180 | } 181 | 182 | private getProjects(): ProjectItem[] { 183 | return this.projectList.map( 184 | (project: any) => 185 | new ProjectItem(project.uuid, project.name, vscode.TreeItemCollapsibleState.None) 186 | ); 187 | } 188 | } 189 | 190 | class ProjectItem extends vscode.TreeItem { 191 | constructor( 192 | public uuid: string, 193 | private pname: string, 194 | public readonly collapsibleState: vscode.TreeItemCollapsibleState 195 | ) { 196 | const label = pname; 197 | super(label, collapsibleState); 198 | this.tooltip = `${this.label} $ ${this.uuid}`; 199 | this.description = this.uuid; 200 | } 201 | 202 | // TODO: add icons for projects 203 | //iconPath = { 204 | // light: path.join(__filename, '..', '..', 'resources', 'light', 'dependency.svg'), 205 | // dark: path.join(__filename, '..', '..', 'resources', 'dark', 'dependency.svg') 206 | //}; 207 | } -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as path from 'path'; 3 | import { parseSemgrep, runSemgrep, runSemgrepHcl } from './semgrep'; 4 | import { IaCDiagnostics } from './diagnostics'; 5 | import { RemoteDB } from './remote'; 6 | import { assert } from 'console'; 7 | 8 | export async function handleOpenSemgrepJson(context: vscode.ExtensionContext, mIaCDiagnostics: IaCDiagnostics) { 9 | let jsonFile = await chooseJsonFile(); 10 | console.log("[Semgrep] " + jsonFile?.fsPath); 11 | if (!jsonFile) { return; } 12 | console.log("[Semgrep] OK1 "); 13 | let jsonParsed = parseSemgrep(jsonFile?.fsPath); 14 | console.log("[Semgrep] OK2 "); 15 | 16 | await mIaCDiagnostics.loadDiagnosticsFromSemgrep(jsonParsed); 17 | console.log("[Semgrep] OK3 "); 18 | } 19 | 20 | export async function handleStartSemgrepScan(context: vscode.ExtensionContext, mIaCDiagnostics: IaCDiagnostics | null = null, iacPath: string | null = null): Promise { 21 | console.log("Starting semgrep scan"); 22 | // Run semgrep on current workspace 23 | // TODO: run on all workspace folders, not just first one 24 | if (vscode.workspace.workspaceFolders === undefined) { 25 | vscode.window.showErrorMessage("Cannot run Semgrep. No workspace folder is open."); 26 | return null; 27 | } 28 | let wspath = vscode.workspace.workspaceFolders[0].uri.fsPath; 29 | if ((iacPath == null) && (mIaCDiagnostics != null)) { 30 | await runSemgrep(context, wspath, mIaCDiagnostics); 31 | return null; 32 | } 33 | else if (iacPath != null) { 34 | return await runSemgrepHcl(context, wspath, iacPath); 35 | } 36 | else { 37 | assert(false, "[Semgrep:handleStartSemgrepScan]: either mIaCDiagnostics or iacPath must be provided"); 38 | return null; 39 | } 40 | } 41 | 42 | async function chooseJsonFile(): Promise { 43 | return vscode.window.showOpenDialog({ 44 | openLabel: "Choose Semgrep result", 45 | canSelectFiles: true, 46 | canSelectFolders: false, 47 | canSelectMany: false, 48 | filters: { JsonFiles: ['json', 'txt'] } 49 | }) 50 | .then((chosen) => chosen && chosen?.length > 0 ? chosen[0] : undefined); 51 | } 52 | 53 | export function genUUID(): string { 54 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 55 | let r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); 56 | return v.toString(16); 57 | }); 58 | } 59 | 60 | export function relPathToAbs(relPath: string) { 61 | if (vscode.workspace.workspaceFolders === undefined) { 62 | return relPath; 63 | } 64 | 65 | const absPath = path.join(vscode.workspace.workspaceFolders[0].uri.fsPath, relPath); 66 | return absPath; 67 | } 68 | 69 | export function absPathToRel(absPath: string) { 70 | if (vscode.workspace.workspaceFolders === undefined) { 71 | return absPath; 72 | } 73 | 74 | const relPath = path.relative(vscode.workspace.workspaceFolders[0].uri.fsPath, absPath); 75 | return relPath; 76 | } 77 | 78 | export async function ensureDirExists(dirPath: string): Promise { 79 | let dirUri = vscode.Uri.file(dirPath); 80 | 81 | return await vscode.workspace.fs.stat(dirUri).then( 82 | async () => { 83 | console.log('[IaC Utils] Storage directory exists'); 84 | return true; 85 | }, 86 | async () => { 87 | console.log('[IaC Utils] Storage directory does not exist'); 88 | await vscode.workspace.fs.createDirectory(dirUri); 89 | 90 | return await vscode.workspace.fs.stat(dirUri).then( 91 | () => { 92 | console.log('[IaC Main] Storage directory created'); 93 | return true; 94 | }, 95 | () => { 96 | console.log('[IaC Main] Storage directory could not be created'); 97 | 98 | // Show an error message 99 | vscode.window.showErrorMessage('Storage directory could not be created'); 100 | 101 | return false; 102 | } 103 | ); 104 | } 105 | ); 106 | } -------------------------------------------------------------------------------- /tfparse_rules/hcl-resource.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | - id: hcl-resource 3 | patterns: 4 | - pattern: | 5 | resource $RT $RN { 6 | ... 7 | } 8 | message: "IaC resource block: $RT $RN" 9 | languages: 10 | - hcl 11 | severity: WARNING -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2020", 5 | "outDir": "out", 6 | "lib": [ 7 | "ES2020" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | "strict": true, /* enable all strict type-checking options */ 12 | /* Additional Checks */ 13 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 14 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 15 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 16 | } 17 | } 18 | --------------------------------------------------------------------------------
IaC definition for: ${poiFilter}
` + iacResource + "