add email notifications for risks

This commit is contained in:
Maidul Islam
2023-07-04 22:06:29 -04:00
parent 9f6aa6b13e
commit a63d179a0d
14 changed files with 406 additions and 142 deletions

View File

@@ -8,5 +8,6 @@
**/.env
**/.editorconfig
**/dist
**/lib
**/*.pem
Dockerfile

View File

@@ -13,7 +13,7 @@
# The list of events the GitHub App subscribes to.
# Uncomment the event names below to enable them.
default_events:
# - check_run
- check_run
# - check_suite
# - commit_comment
# - create
@@ -23,20 +23,20 @@ default_events:
# - fork
# - gollum
# - issue_comment
- issues
# - issues
- push
# - label
# - milestone
# - member
# - membership
# - org_block
# - organization
# - page_build
# - project
# - project_card
# - project_column
# - public
# - pull_request
# - label
# - milestone
# - member
# - membership
# - org_block
# - organization
# - page_build
# - project
# - project_card
# - project_column
# - public
- pull_request
# - pull_request_review
# - pull_request_review_comment
# - release
@@ -128,7 +128,7 @@ name: Infisical Radar
# The homepage of your GitHub App.
url: https://infisical.com/radar
# A description of the GitHub App.
description: A description of my awesome app
description: Scan your commits for leaked secrets
# Set to true when your GitHub App is available to the public or false when it is only accessible to the owner of the app.
# Default: true
# public: false

View File

@@ -9,7 +9,9 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"handlebars": "^4.7.7",
"mongoose": "^7.3.1",
"nodemailer": "^6.9.3",
"probot": "^12.2.4",
"ts-node": "^10.9.1"
},
@@ -6301,6 +6303,14 @@
"integrity": "sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==",
"dev": true
},
"node_modules/nodemailer": {
"version": "6.9.3",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.3.tgz",
"integrity": "sha512-fy9v3NgTzBngrMFkDsKEj0r02U7jm6XfC3b52eoNV+GCrGj+s8pt5OqhiJdWKuw51zCTdiNR/IUD1z33LIIGpg==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "2.0.22",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz",
@@ -13184,6 +13194,11 @@
"integrity": "sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==",
"dev": true
},
"nodemailer": {
"version": "6.9.3",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.3.tgz",
"integrity": "sha512-fy9v3NgTzBngrMFkDsKEj0r02U7jm6XfC3b52eoNV+GCrGj+s8pt5OqhiJdWKuw51zCTdiNR/IUD1z33LIIGpg=="
},
"nodemon": {
"version": "2.0.22",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz",

View File

@@ -12,13 +12,15 @@
"probot-app"
],
"scripts": {
"build": "tsc",
"build": "tsc && cp -R ./src/templates ./lib",
"dev": "npm run build && probot run ./lib/index.js",
"start": "nodemon -e ts,yml --watch './**/*.ts' --exec npm run dev",
"test": "jest"
},
"dependencies": {
"handlebars": "^4.7.7",
"mongoose": "^7.3.1",
"nodemailer": "^6.9.3",
"probot": "^12.2.4",
"ts-node": "^10.9.1"
},

View File

@@ -0,0 +1,45 @@
import fs from "fs";
import path from "path";
import handlebars from "handlebars";
import nodemailer from "nodemailer";
let smtpTransporter: nodemailer.Transporter;
/**
* @param {Object} obj
* @param {String} obj.template - email template to use from /templates folder (e.g. testEmail.handlebars)
* @param {String[]} obj.subjectLine - email subject line
* @param {String[]} obj.recipients - email addresses of people to send email to
* @param {Object} obj.substitutions - object containing template substitutions
*/
export const sendMail = async ({
template,
subjectLine,
recipients,
substitutions,
}: {
template: string;
subjectLine: string;
recipients: string[];
substitutions: any;
}) => {
const smtpConfigured = process.env.SMTP_HOST == "" || process.env.SMTP_HOST == undefined ? false : true
if (smtpConfigured) {
const html = fs.readFileSync(
path.resolve(__dirname, "../templates/" + template),
"utf8"
);
const temp = handlebars.compile(html);
const htmlToSend = temp(substitutions);
await smtpTransporter.sendMail({
from: `"${process.env.SMTP_FROM_NAME}" <${process.env.SMTP_FROM_ADDRESS}>`,
to: recipients.join(", "),
subject: subjectLine,
html: htmlToSend,
});
}
};
export const setTransporter = (transporter: nodemailer.Transporter) => {
smtpTransporter = transporter;
};

View File

@@ -6,6 +6,10 @@ import { join } from "path"
import mongoose from "mongoose";
import GitRisks from "./models/gitRisks";
import GitAppOrganizationInstallation from "./models/gitAppOrganizationInstallation";
import { sendMail, setTransporter } from "./helper/nodemailer";
import { initSmtp } from "./service/smtp";
import MembershipOrg, { ADMIN, OWNER } from "./models/membershipOrg";
import User from "./models/user";
type SecretMatch = {
Description: string;
@@ -28,10 +32,12 @@ type SecretMatch = {
Fingerprint: string;
};
export = (app: Probot) => {
export = async (app: Probot) => {
// connect to DB
initDatabase()
setTransporter(await initSmtp());
app.on("installation.created", async (context) => {
const { payload } = context;
// console.log("payload==>", payload.installation.repository_selection)
@@ -52,7 +58,7 @@ export = (app: Probot) => {
app.on("push", async (context) => {
const { payload } = context;
const { commits, repository, installation, } = payload;
const { commits, repository, installation, pusher } = payload;
const [owner, repo] = repository.full_name.split('/');
const installationLinkToOrgExists = await GitAppOrganizationInstallation.findOne({ installationId: installation.id }).lean()
@@ -60,8 +66,6 @@ export = (app: Probot) => {
return
}
console.log("installation link does exist!")
const findingsByFingerprint: { [key: string]: SecretMatch; } = {}
for (const commit of commits) {
@@ -107,6 +111,53 @@ export = (app: Probot) => {
upsert: true
})
}
// get emails of admins
const adminsOfWork = await MembershipOrg.find({
organization: installationLinkToOrgExists.organizationId,
$or: [
{ role: OWNER },
{ role: ADMIN }
]
}).lean()
const userEmails = await User.find({
_id: {
$in: [adminsOfWork.map(orgMembership => orgMembership.user)]
}
}).select("email").lean()
const adminOrOwnerEmails = userEmails.map(userObject => userObject.email)
await sendMail({
template: "secretLeakIncident.handlebars",
subjectLine: `Incident alert: leaked secrets found in Github repository ${repository.full_name}`,
recipients: [pusher.email, ...adminOrOwnerEmails],
substitutions: {
numberOfSecrets: Object.keys(findingsByFingerprint).length,
pusher_email: pusher.email,
pusher_name: pusher.name
}
});
});
app.on(['pull_request.opened', 'pull_request.synchronize'], async (context) => {
const { payload } = context;
const { pull_request } = payload
if (false) {
const check = {
owner: pull_request.head.repo.owner.login,
repo: pull_request.head.repo.name,
name: 'Secret Detection',
head_sha: pull_request.head.sha,
status: 'completed',
conclusion: 'failure',
output: {
title: `X Secrets detected`,
summary: 'We detected potential leaked secret(s) in your pull request.',
},
};
return context.octokit.checks.create(check);
}
});
};

View File

@@ -0,0 +1,56 @@
import { Document, Schema, Types, model } from "mongoose";
// membership roles
export const OWNER = "owner";
export const ADMIN = "admin";
export const MEMBER = "member";
// membership statuses
export const INVITED = "invited";
// -- organization
export const ACCEPTED = "accepted";
export interface IMembershipOrg extends Document {
_id: Types.ObjectId;
user: Types.ObjectId;
inviteEmail: string;
organization: Types.ObjectId;
role: "owner" | "admin" | "member";
status: "invited" | "accepted";
}
const membershipOrgSchema = new Schema(
{
user: {
type: Schema.Types.ObjectId,
ref: "User",
},
inviteEmail: {
type: String,
},
organization: {
type: Schema.Types.ObjectId,
ref: "Organization",
},
role: {
type: String,
enum: [OWNER, ADMIN, MEMBER],
required: true,
},
status: {
type: String,
enum: [INVITED, ACCEPTED],
required: true,
},
},
{
timestamps: true,
}
);
const MembershipOrg = model<IMembershipOrg>(
"MembershipOrg",
membershipOrgSchema
);
export default MembershipOrg;

View File

@@ -0,0 +1,116 @@
import { Document, Schema, Types, model } from "mongoose";
export enum AuthProvider {
GOOGLE = "google",
}
export interface IUser extends Document {
_id: Types.ObjectId;
authId?: string;
authProvider?: AuthProvider;
email: string;
firstName?: string;
lastName?: string;
encryptionVersion: number;
protectedKey: string;
protectedKeyIV: string;
protectedKeyTag: string;
publicKey?: string;
encryptedPrivateKey?: string;
iv?: string;
tag?: string;
salt?: string;
verifier?: string;
isMfaEnabled: boolean;
mfaMethods: boolean;
devices: {
ip: string;
userAgent: string;
}[];
}
const userSchema = new Schema<IUser>(
{
authId: {
type: String,
},
authProvider: {
type: String,
enum: AuthProvider,
},
email: {
type: String,
required: true,
unique: true,
},
firstName: {
type: String,
},
lastName: {
type: String,
},
encryptionVersion: {
type: Number,
select: false,
default: 1, // to resolve backward-compatibility issues
},
protectedKey: { // introduced as part of encryption version 2
type: String,
select: false,
},
protectedKeyIV: { // introduced as part of encryption version 2
type: String,
select: false,
},
protectedKeyTag: { // introduced as part of encryption version 2
type: String,
select: false,
},
publicKey: {
type: String,
select: false,
},
encryptedPrivateKey: {
type: String,
select: false,
},
iv: { // iv of [encryptedPrivateKey]
type: String,
select: false,
},
tag: { // tag of [encryptedPrivateKey]
type: String,
select: false,
},
salt: {
type: String,
select: false,
},
verifier: {
type: String,
select: false,
},
isMfaEnabled: {
type: Boolean,
default: false,
},
mfaMethods: [{
type: String,
}],
devices: {
type: [{
ip: String,
userAgent: String,
}],
default: [],
select: false,
},
},
{
timestamps: true,
}
);
const User = model<IUser>("User", userSchema);
export default User;

View File

@@ -0,0 +1,77 @@
import nodemailer from "nodemailer";
import SMTPConnection from "nodemailer/lib/smtp-connection";
export const SMTP_HOST_SENDGRID = "smtp.sendgrid.net";
export const SMTP_HOST_MAILGUN = "smtp.mailgun.org";
export const SMTP_HOST_SOCKETLABS = "smtp.socketlabs.com";
export const SMTP_HOST_ZOHOMAIL = "smtp.zoho.com";
export const SMTP_HOST_GMAIL = "smtp.gmail.com";
export const initSmtp = async () => {
const mailOpts: SMTPConnection.Options = {
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
};
if ((process.env.SMTP_USERNAME) && (process.env.SMTP_PASSWORD)) {
mailOpts.auth = {
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD,
};
}
if ((process.env.SMTP_SECURE) ? (process.env.SMTP_SECURE) : false) {
switch (process.env.SMTP_HOST) {
case SMTP_HOST_SENDGRID:
mailOpts.requireTLS = true;
break;
case SMTP_HOST_MAILGUN:
mailOpts.requireTLS = true;
mailOpts.tls = {
ciphers: "TLSv1.2",
}
break;
case SMTP_HOST_SOCKETLABS:
mailOpts.requireTLS = true;
mailOpts.tls = {
ciphers: "TLSv1.2",
}
break;
case SMTP_HOST_ZOHOMAIL:
mailOpts.requireTLS = true;
mailOpts.tls = {
ciphers: "TLSv1.2",
}
break;
case SMTP_HOST_GMAIL:
mailOpts.requireTLS = true;
mailOpts.tls = {
ciphers: "TLSv1.2",
}
break;
default:
if ((process.env.SMTP_HOST).includes("amazonaws.com")) {
mailOpts.tls = {
ciphers: "TLSv1.2",
}
} else {
mailOpts.secure = true;
}
break;
}
}
const transporter = nodemailer.createTransport(mailOpts);
transporter
.verify()
.then((err) => {
console.log("SMTP - Successfully connected")
})
.catch(async (err) => {
console.log(
`SMTP - Failed to connect to ${process.env.SMTP_HOST}:${process.env.SMTP_PORT} \n\t${err}`
);
});
return transporter;
};

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Incident alert: secret leaked</title>
</head>
<body>
<h3>Infisical has uncovered {{numberOfSecrets}} secret(s) from your recent push</h3>
<p><a href="https://app.infisical.com/secret-scanning"><strong>View leaked secrets</strong></a></p>
<p>One or more secret leaks have been detected in a recent commit pushed by {{pusher_name}} ({{pusher_email}}). If
the secrets are test secrets, please mark them as false positives in the <a
href="https://app.infisical.com/">Infisical dashboard</a>.
Otherwise, please rotate the secrets immediately.</p>
</body>
</html>

View File

@@ -1,18 +0,0 @@
{
"action": "opened",
"issue": {
"number": 1,
"user": {
"login": "hiimbex"
}
},
"repository": {
"name": "testing-things",
"owner": {
"login": "hiimbex"
}
},
"installation": {
"id": 2
}
}

View File

@@ -1,27 +0,0 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAli7V49NdZe+XYC1pLaHM0te8kiDmZBJ1u2HJHN8GdbROB6NO
VpC3xK7NxQn6xpvZ9ux20NvcDvGle+DOptZztBH+np6h2jZQ1/kD1yG1eQvVH4th
/9oqHuIjmIfO8lIe4Hyd5Fw5xHkGqVETTGR+0c7kdZIlHmkOregUGtMYZRUi4YG+
q0w+uFemiHpGKXbeCIAvkq7aIkisEzvPWfSyYdA6WJHpxFk7tD7D8VkzABLVRHCq
AuyqPG39BhGZcGLXx5rGK56kDBJkyTR1t3DkHpwX+JKNG5UYNwOG4LcQj1fteeta
TdkYUMjIyWbanlMYyC+dq7B5fe7el99jXQ1gXwIDAQABAoIBADKfiPOpzKLOtzzx
MbHzB0LO+75aHq7+1faayJrVxqyoYWELuB1P3NIMhknzyjdmU3t7S7WtVqkm5Twz
lBUC1q+NHUHEgRQ4GNokExpSP4SU63sdlaQTmv0cBxmkNarS6ZuMBgDy4XoLvaYX
MSUf/uukDLhg0ehFS3BteVFtdJyllhDdTenF1Nb1rAeN4egt8XLsE5NQDr1szFEG
xH5lb+8EDtzgsGpeIddWR64xP0lDIKSZWst/toYKWiwjaY9uZCfAhvYQ1RsO7L/t
sERmpYgh+rAZUh/Lr98EI8BPSPhzFcSHmtqzzejvC5zrZPHcUimz0CGA3YBiLoJX
V1OrxmECgYEAxkd8gpmVP+LEWB3lqpSvJaXcGkbzcDb9m0OPzHUAJDZtiIIf0UmO
nvL68/mzbCHSj+yFjZeG1rsrAVrOzrfDCuXjAv+JkEtEx0DIevU1u60lGnevOeky
r8Be7pmymFB9/gzQAd5ezIlTv/COgoO986a3h1yfhzrrzbqSiivw308CgYEAwecI
aZZwqH3GifR+0+Z1B48cezA5tC8LZt5yObGzUfxKTWy30d7lxe9N59t0KUVt/QL5
qVkd7mqGzsUMyxUN2U2HVnFTWfUFMhkn/OnCnayhILs8UlCTD2Xxoy1KbQH/9FIr
xf0pbMNJLXeGfyRt/8H+BzSZKBw9opJBWE4gqfECgYBp9FdvvryHuBkt8UQCRJPX
rWsRy6pY47nf11mnazpZH5Cmqspv3zvMapF6AIxFk0leyYiQolFWvAv+HFV5F6+t
Si1mM8GCDwbA5zh6pEBDewHhw+UqMBh63HSeUhmi1RiOwrAA36CO8i+D2Pt+eQHv
ir52IiPJcs4BUNrv5Q1BdwKBgBHgVNw3LGe8QMOTMOYkRwHNZdjNl2RPOgPf2jQL
d/bFBayhq0jD/fcDmvEXQFxVtFAxKAc+2g2S8J67d/R5Gm/AQAvuIrsWZcY6n38n
pfOXaLt1x5fnKcevpFlg4Y2vM4O416RHNLx8PJDehh3Oo/2CSwMrDDuwbtZAGZok
icphAoGBAI74Tisfn+aeCZMrO8KxaWS5r2CD1KVzddEMRKlJvSKTY+dOCtJ+XKj1
OsZdcDvDC5GtgcywHsYeOWHldgDWY1S8Z/PUo4eK9qBXYBXp3JEZQ1dqzFdz+Txi
rBn2WsFLsxV9j2/ugm0PqWVBcU2bPUCwvaRu3SOms2teaLwGCkhr
-----END RSA PRIVATE KEY-----

View File

@@ -1,74 +0,0 @@
// You can import your modules
// import index from '../src/index'
import nock from "nock";
// Requiring our app implementation
import myProbotApp from "../src";
import { Probot, ProbotOctokit } from "probot";
// Requiring our fixtures
import payload from "./fixtures/issues.opened.json";
const issueCreatedBody = { body: "Thanks for opening this issue!" };
const fs = require("fs");
const path = require("path");
const privateKey = fs.readFileSync(
path.join(__dirname, "fixtures/mock-cert.pem"),
"utf-8"
);
describe("My Probot app", () => {
let probot: any;
beforeEach(() => {
nock.disableNetConnect();
probot = new Probot({
appId: 123,
privateKey,
// disable request throttling and retries for testing
Octokit: ProbotOctokit.defaults({
retry: { enabled: false },
throttle: { enabled: false },
}),
});
// Load our app into probot
probot.load(myProbotApp);
});
test("creates a comment when an issue is opened", async () => {
const mock = nock("https://api.github.com")
// Test that we correctly return a test token
.post("/app/installations/2/access_tokens")
.reply(200, {
token: "test",
permissions: {
issues: "write",
},
})
// Test that a comment is posted
.post("/repos/hiimbex/testing-things/issues/1/comments", (body: any) => {
expect(body).toMatchObject(issueCreatedBody);
return true;
})
.reply(200);
// Receive a webhook event
await probot.receive({ name: "issues", payload });
expect(mock.pendingMocks()).toStrictEqual([]);
});
afterEach(() => {
nock.cleanAll();
nock.enableNetConnect();
});
});
// For more information about testing with Jest see:
// https://facebook.github.io/jest/
// For more information about using TypeScript in your tests, Jest recommends:
// https://github.com/kulshekhar/ts-jest
// For more information about testing with Nock see:
// https://github.com/nock/nock

View File

@@ -50,7 +50,7 @@
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
"sourceRoot": "src", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
@@ -64,7 +64,8 @@
"skipLibCheck": true
},
"include": [
"src/"
"src/**/*",
"src/templates/**/*"
],
"compileOnSave": false
// "compileOnSave": false
}