move secret scanning to main container

git-scanning-app
Maidul Islam 10 months ago
parent d69465517f
commit 8c844fb188

@ -10,7 +10,6 @@
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/no-empty-function": "off",
"no-console": 2,
"quotes": [
"error",
@ -35,11 +34,6 @@
"argsIgnorePattern": "^_"
}
],
"sort-imports": [
"error",
{
"ignoreDeclarationSort": true
}
]
"sort-imports": 1
}
}

@ -19,6 +19,10 @@ RUN npm ci --only-production
COPY --from=build /app .
RUN apk add --no-cache bash curl && curl -1sLf \
'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.alpine.sh' | bash \
&& apk add infisical=0.8.1
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s \
CMD node healthcheck.js

File diff suppressed because it is too large Load Diff

@ -36,6 +36,7 @@
"passport": "^0.6.0",
"passport-google-oauth20": "^2.0.0",
"posthog-node": "^2.6.0",
"probot": "^12.3.1",
"query-string": "^7.1.3",
"rate-limit-mongo": "^2.3.2",
"rimraf": "^3.0.2",
@ -103,6 +104,7 @@
"jest-junit": "^15.0.0",
"nodemon": "^2.0.19",
"npm": "^8.19.3",
"smee-client": "^1.2.3",
"supertest": "^6.3.3",
"ts-jest": "^29.0.3",
"ts-node": "^10.9.1"

@ -10,7 +10,7 @@ export const getEncryptionKey = async () => {
return secretValue === "" ? undefined : secretValue;
}
export const getRootEncryptionKey = async () => {
const secretValue = (await client.getSecret("ROOT_ENCRYPTION_KEY")).secretValue;
const secretValue = (await client.getSecret("ROOT_ENCRYPTION_KEY")).secretValue;
return secretValue === "" ? undefined : secretValue;
}
export const getInviteOnlySignup = async () => (await client.getSecret("INVITE_ONLY_SIGNUP")).secretValue === "true"
@ -57,6 +57,11 @@ export const getSmtpPassword = async () => (await client.getSecret("SMTP_PASSWOR
export const getSmtpFromAddress = async () => (await client.getSecret("SMTP_FROM_ADDRESS")).secretValue;
export const getSmtpFromName = async () => (await client.getSecret("SMTP_FROM_NAME")).secretValue || "Infisical";
export const getSecretScanningWebhookProxy = async () => (await client.getSecret("SECRET_SCANNING_WEBHOOK_PROXY")).secretValue;
export const getSecretScanningWebhookSecret = async () => (await client.getSecret("SECRET_SCANNING_WEBHOOK_SECRET")).secretValue;
export const getSecretScanningGitAppId = async () => (await client.getSecret("SECRET_SCANNING_GIT_APP_ID")).secretValue;
export const getSecretScanningPrivateKey = async () => (await client.getSecret("SECRET_SCANNING_PRIVATE_KEY")).secretValue;
export const getLicenseKey = async () => {
const secretValue = (await client.getSecret("LICENSE_KEY")).secretValue;
return secretValue === "" ? undefined : secretValue;

@ -72,7 +72,7 @@ export const getCurrentOrganizationInstallationStatus = async (req: Request, res
export const getRisksForOrganization = async (req: Request, res: Response) => {
const { organizationId } = req.params
const risks = await GitRisks.find({ organization: organizationId, status: STATUS_UNRESOLVED }).lean()
const risks = await GitRisks.find({ organization: organizationId, status: STATUS_UNRESOLVED }).sort({ createdAt: -1 }).lean()
res.json({
risks: risks
})

@ -5,11 +5,12 @@ import express from "express";
require("express-async-errors");
import helmet from "helmet";
import cors from "cors";
import { DatabaseService } from "./services";
import { DatabaseService, GithubSecretScanningService } from "./services";
import { EELicenseService } from "./ee/services";
import { setUpHealthEndpoint } from "./services/health";
import cookieParser from "cookie-parser";
import swaggerUi = require("swagger-ui-express");
import { Probot, createNodeMiddleware } from "probot";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const swaggerFile = require("../spec.json");
// eslint-disable-next-line @typescript-eslint/no-var-requires
@ -44,9 +45,9 @@ import {
} from "./routes/v1";
import {
auth as v2AuthRouter,
organizations as v2OrganizationsRouter,
signup as v2SignupRouter,
users as v2UsersRouter,
organizations as v2OrganizationsRouter,
workspace as v2WorkspaceRouter,
secret as v2SecretRouter, // begin to phase out
secrets as v2SecretsRouter,
@ -65,10 +66,12 @@ import { healthCheck } from "./routes/status";
import { getLogger } from "./utils/logger";
import { RouteNotFoundError } from "./utils/errors";
import { requestErrorHandler } from "./middleware/requestErrorHandler";
import { getNodeEnv, getPort, getSiteURL } from "./config";
import { getNodeEnv, getPort, getSecretScanningGitAppId, getSecretScanningPrivateKey, getSecretScanningWebhookProxy, getSecretScanningWebhookSecret, getSiteURL } from "./config";
import { setup } from "./utils/setup";
const SmeeClient = require('smee-client') // eslint-disable-line
const main = async () => {
await setup();
await EELicenseService.initGlobalFeatureSet();
@ -84,6 +87,26 @@ const main = async () => {
})
);
if (await getSecretScanningGitAppId()) {
const probot = new Probot({
appId: await getSecretScanningGitAppId(),
privateKey: await getSecretScanningPrivateKey(),
secret: await getSecretScanningWebhookSecret(),
});
if ((await getNodeEnv()) != "production") {
const smee = new SmeeClient({
source: await getSecretScanningWebhookProxy(),
target: "http://backend:4000/ss-webhook",
logger: console
})
smee.start()
}
app.use(createNodeMiddleware(GithubSecretScanningService, { probot, webhooksPath: "/ss-webhook" })); // secret scanning webhook
}
if ((await getNodeEnv()) === "production") {
// enable app-wide rate-limiting + helmet security
// in production

@ -25,6 +25,7 @@ export type GitRisks = {
tags: string[];
ruleID: string;
fingerprint: string;
fingerPrintWithoutCommitId: string
isFalsePositive: boolean; // New field for marking risks as false positives
isResolved: boolean; // New field for marking risks as resolved
@ -94,6 +95,9 @@ const gitRisks = new Schema<GitRisks>({
type: String,
unique: true
},
fingerPrintWithoutCommitId: {
type: String,
},
isFalsePositive: {
type: Boolean,
default: false

@ -16,13 +16,14 @@ import ServiceAccountKey, { IServiceAccountKey } from "./serviceAccountKey"; //
import ServiceAccountOrganizationPermission, { IServiceAccountOrganizationPermission } from "./serviceAccountOrganizationPermission"; // new
import ServiceAccountWorkspacePermission, { IServiceAccountWorkspacePermission } from "./serviceAccountWorkspacePermission"; // new
import TokenData, { ITokenData } from "./tokenData";
import User,{ AuthProvider, IUser } from "./user";
import User, { AuthProvider, IUser } from "./user";
import UserAction, { IUserAction } from "./userAction";
import Workspace, { IWorkspace } from "./workspace";
import ServiceTokenData, { IServiceTokenData } from "./serviceTokenData";
import APIKeyData, { IAPIKeyData } from "./apiKeyData";
import LoginSRPDetail, { ILoginSRPDetail } from "./loginSRPDetail";
import TokenVersion, { ITokenVersion } from "./tokenVersion";
import GitRisks, { STATUS_RESOLVED_FALSE_POSITIVE } from "./gitRisks";
export {
AuthProvider,
@ -76,4 +77,6 @@ export {
ILoginSRPDetail,
TokenVersion,
ITokenVersion,
GitRisks,
STATUS_RESOLVED_FALSE_POSITIVE
};

@ -0,0 +1,250 @@
import { Probot } from "probot";
import { exec } from "child_process";
import { mkdir, readFile, rm, writeFile } from "fs";
import { tmpdir } from "os";
import { join } from "path"
import GitRisks, { STATUS_RESOLVED_FALSE_POSITIVE } from "../models/gitRisks";
import GitAppOrganizationInstallation from "../models/gitAppOrganizationInstallation";
import MembershipOrg from "../models/membershipOrg";
import { ADMIN, OWNER } from "../variables";
import User from "../models/user";
import { sendMail } from "../helpers";
type SecretMatch = {
Description: string;
StartLine: number;
EndLine: number;
StartColumn: number;
EndColumn: number;
Match: string;
Secret: string;
File: string;
SymlinkFile: string;
Commit: string;
Entropy: number;
Author: string;
Email: string;
Date: string;
Message: string;
Tags: string[];
RuleID: string;
Fingerprint: string;
FingerPrintWithoutCommitId: string
};
export default async (app: Probot) => {
app.on("installation.deleted", async (context) => {
const { payload } = context;
const { installation, repositories } = payload;
if (installation.repository_selection == "all") {
await GitRisks.deleteMany({ installationId: installation.id })
await GitAppOrganizationInstallation.deleteOne({ installationId: installation.id })
} else {
if (repositories) {
for (const repository of repositories) {
await GitRisks.deleteMany({ repositoryId: repository.id })
}
}
}
})
app.on("push", async (context) => {
const { payload } = context;
const { commits, repository, installation, pusher } = payload;
const [owner, repo] = repository.full_name.split("/");
if (!commits || !repository || !installation || !pusher) {
return
}
const installationLinkToOrgExists = await GitAppOrganizationInstallation.findOne({ installationId: installation?.id }).lean()
if (!installationLinkToOrgExists) {
return
}
const allFindingsByFingerprint: { [key: string]: SecretMatch; } = {}
for (const commit of commits) {
for (const filepath of [...commit.added, ...commit.modified]) {
try {
const fileContentsResponse = await context.octokit.repos.getContent({
owner,
repo,
path: filepath,
});
const data: any = fileContentsResponse.data;
const fileContent = Buffer.from(data.content, "base64").toString();
const findings = await scanContentAndGetFindings(`\n${fileContent}`) // extra line to count lines correctly
for (const finding of findings) {
const fingerPrintWithCommitId = `${commit.id}:${filepath}:${finding.RuleID}:${finding.StartLine}`
const fingerPrintWithoutCommitId = `${filepath}:${finding.RuleID}:${finding.StartLine}`
finding.Fingerprint = fingerPrintWithCommitId
finding.FingerPrintWithoutCommitId = fingerPrintWithoutCommitId
finding.Commit = commit.id
finding.File = filepath
finding.Author = commit.author.name
finding.Email = commit?.author?.email ? commit?.author?.email : ""
allFindingsByFingerprint[fingerPrintWithCommitId] = finding
}
} catch (error) {
console.error(`Error fetching content for ${filepath}`, error); // eslint-disable-line
}
}
}
// change to update
const noneFalsePositiveFindings: { [key: string]: SecretMatch; } = {}
for (const key in allFindingsByFingerprint) {
const risk = await GitRisks.findOneAndUpdate({ fingerprint: allFindingsByFingerprint[key].Fingerprint },
{
...convertKeysToLowercase(allFindingsByFingerprint[key]),
installationId: installation.id,
organization: installationLinkToOrgExists.organizationId,
repositoryFullName: repository.full_name,
repositoryId: repository.id
}, {
upsert: true
}).lean()
if (risk?.status == STATUS_RESOLVED_FALSE_POSITIVE) {
noneFalsePositiveFindings[key] = { ...convertKeysToLowercase(allFindingsByFingerprint[key]) }
}
}
// 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)
// TODO
// don't notify if the risk is marked as false positive
// loop through each finding and check if the finger print without commit has a status of false positive, if so don't add it to the list of risks that need to be notified
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(allFindingsByFingerprint).length,
pusher_email: pusher.email,
pusher_name: pusher.name
}
});
});
};
async function scanContentAndGetFindings(textContent: string): Promise<SecretMatch[]> {
const tempFolder = await createTempFolder();
const filePath = join(tempFolder, "content.txt");
const findingsPath = join(tempFolder, "findings.json");
try {
await writeTextToFile(filePath, textContent);
await runInfisicalScan(filePath, findingsPath);
const findingsData = await readFindingsFile(findingsPath);
return JSON.parse(findingsData);
} finally {
await deleteTempFolder(tempFolder);
}
}
function createTempFolder(): Promise<string> {
return new Promise((resolve, reject) => {
const tempDir = tmpdir()
const tempFolderName = Math.random().toString(36).substring(2);
const tempFolderPath = join(tempDir, tempFolderName);
mkdir(tempFolderPath, (err: any) => {
if (err) {
reject(err);
} else {
resolve(tempFolderPath);
}
});
});
}
function writeTextToFile(filePath: string, content: string): Promise<void> {
return new Promise((resolve, reject) => {
writeFile(filePath, content, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
function runInfisicalScan(inputPath: string, outputPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const command = `cat "${inputPath}" | infisical scan --exit-code=77 --pipe -r "${outputPath}"`;
exec(command, (error) => {
if (error && error.code != 77) {
reject(error);
} else {
resolve();
}
});
});
}
function readFindingsFile(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
readFile(filePath, "utf8", (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
}
function deleteTempFolder(folderPath: string): Promise<void> {
return new Promise((resolve, reject) => {
rm(folderPath, { recursive: true }, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
function convertKeysToLowercase<T>(obj: T): T {
const convertedObj = {} as T;
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const lowercaseKey = key.charAt(0).toLowerCase() + key.slice(1);
convertedObj[lowercaseKey as keyof T] = obj[key];
}
}
return convertedObj;
}

@ -6,6 +6,7 @@ import EventService from "./EventService";
import IntegrationService from "./IntegrationService";
import TokenService from "./TokenService";
import SecretService from "./SecretService";
import GithubSecretScanningService from "./GithubSecretScanningService"
export {
TelemetryService,
@ -15,4 +16,5 @@ export {
IntegrationService,
TokenService,
SecretService,
GithubSecretScanningService
}

@ -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>

@ -58,30 +58,6 @@ services:
networks:
- infisical-dev
git-app:
container_name: infisical-dev-git-app
restart: unless-stopped
depends_on:
- mongo
- smtp-server
- backend
- frontend
volumes:
- ./secret-engine/src:/app/src/ # mounted whole src to avoid missing reload on new files
ports:
- "3005:3005"
build:
context: ./secret-engine
dockerfile: Dockerfile.dev
env_file: ./secret-engine/.env
environment:
- NODE_ENV=development
- MONGO_URL=mongodb://root:example@mongo:27017/?authSource=admin
networks:
- infisical-dev
extra_hosts:
- "host.docker.internal:host-gateway"
mongo:
image: mongo
container_name: infisical-dev-mongo

@ -51,16 +51,6 @@ component: {{ .Values.frontend.name | quote }}
{{ include "infisical.common.matchLabels" . }}
{{- end -}}
{{- define "infisical.secretScanningGitApp.labels" -}}
{{ include "infisical.secretScanningGitApp.matchLabels" . }}
{{ include "infisical.common.metaLabels" . }}
{{- end -}}
{{- define "infisical.secretScanningGitApp.matchLabels" -}}
component: {{ .Values.secretScanningGitApp.name | quote }}
{{ include "infisical.common.matchLabels" . }}
{{- end -}}
{{- define "infisical.mongodb.labels" -}}
{{ include "infisical.mongodb.matchLabels" . }}
{{ include "infisical.common.metaLabels" . }}
@ -122,24 +112,6 @@ We truncate at 63 chars because some Kubernetes name fields are limited to this
{{- end -}}
{{- end -}}
{{/*
Create a fully qualified secretScanningGitApp name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
*/}}
{{- define "infisical.secretScanningGitApp.fullname" -}}
{{- if .Values.secretScanningGitApp.fullnameOverride -}}
{{- .Values.secretScanningGitApp.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- printf "%s-%s" .Release.Name .Values.secretScanningGitApp.name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s-%s" .Release.Name $name .Values.secretScanningGitApp.name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/*
Create the mongodb connection string.
*/}}

@ -44,13 +44,6 @@ spec:
name: {{ include "infisical.backend.fullname" . }}
port:
number: 4000
- path: {{ $ingress.secretScanningGitApp.path }}
pathType: {{ $ingress.secretScanningGitApp.pathType }}
backend:
service:
name: {{ include "infisical.secretScanningGitApp.fullname" . }}
port:
number: 3001
{{- if $ingress.hostName }}
host: {{ $ingress.hostName }}
{{- end }}

@ -1,69 +0,0 @@
{{- $secretScanningGitApp := .Values.secretScanningGitApp }}
{{- $backend := .Values.backend }}
{{- if .Values.secretScanningGitApp.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "infisical.secretScanningGitApp.fullname" . }}
annotations:
updatedAt: {{ now | date "2006-01-01 MST 15:04:05" | quote }}
{{- with $secretScanningGitApp.deploymentAnnotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
labels:
{{- include "infisical.secretScanningGitApp.labels" . | nindent 4 }}
spec:
replicas: {{ $secretScanningGitApp.replicaCount }}
selector:
matchLabels:
{{- include "infisical.secretScanningGitApp.matchLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "infisical.secretScanningGitApp.matchLabels" . | nindent 8 }}
annotations:
updatedAt: {{ now | date "2006-01-01 MST 15:04:05" | quote }}
{{- with $secretScanningGitApp.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
containers:
- name: {{ template "infisical.name" . }}-{{ $secretScanningGitApp.name }}
image: "{{ $secretScanningGitApp.image.repository }}:{{ $secretScanningGitApp.image.tag | default "latest" }}"
imagePullPolicy: {{ $secretScanningGitApp.image.pullPolicy }}
readinessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
ports:
- containerPort: 3000
envFrom:
- secretRef:
name: {{ $backend.kubeSecretRef | default (include "infisical.backend.fullname" .) }}
{{- end }}
---
{{- if .Values.secretScanningGitApp.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "infisical.secretScanningGitApp.fullname" . }}
labels:
{{- include "infisical.secretScanningGitApp.labels" . | nindent 4 }}
{{- with $secretScanningGitApp.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ $secretScanningGitApp.service.type }}
selector:
{{- include "infisical.secretScanningGitApp.matchLabels" . | nindent 8 }}
ports:
- protocol: TCP
port: 3001
targetPort: 3000 # container port
{{- if eq $secretScanningGitApp.service.type "NodePort" }}
nodePort: {{ $secretScanningGitApp.service.nodePort }}
{{- end }}
{{- end }}

@ -13,20 +13,6 @@ server {
proxy_cookie_path / "/; secure; HttpOnly; SameSite=strict";
}
location /git-app-api {
proxy_set_header X-Real-RIP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-NginX-Proxy true;
proxy_pass http://git-app:3005/;
proxy_redirect off;
# proxy_redirect http://localhost:8080/ http://frontend.example.com/;
proxy_cookie_path / "/; secure; HttpOnly; SameSite=strict";
}
location / {
include /etc/nginx/mime.types;

@ -32,7 +32,7 @@ type SecretMatch = {
Fingerprint: string;
};
export = async (app: Probot) => {
export const GithubSecretScanningApp = async (app: Probot) => {
// connect to DB
initDatabase()

Loading…
Cancel
Save