Skip to content

Commit

Permalink
feat(cli): Add import sub commmand for project. (#594)
Browse files Browse the repository at this point in the history
Co-authored-by: Rajdip Bhattacharya <[email protected]>
  • Loading branch information
muntaxir4 and rajdip-b authored Jan 7, 2025
1 parent 5276bb8 commit 9896f27
Show file tree
Hide file tree
Showing 8 changed files with 424 additions and 369 deletions.
5 changes: 3 additions & 2 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,26 @@
"dependencies": {
"@clack/core": "^0.3.4",
"@clack/prompts": "^0.7.0",
"@keyshade/secret-scan": "workspace:*",
"chalk": "^4.1.2",
"cli-table": "^0.3.11",
"colors": "^1.4.0",
"commander": "^12.1.0",
"dotenv": "^16.4.7",
"eccrypto": "^1.1.6",
"figlet": "^1.7.0",
"fs": "0.0.1-security",
"glob": "^11.0.0",
"nodemon": "^3.1.4",
"@keyshade/secret-scan": "workspace:*",
"socket.io-client": "^4.7.5",
"typescript": "^5.5.2"
},
"devDependencies": {
"@swc/cli": "^0.4.0",
"@swc/core": "^1.6.13",
"@types/cli-table": "^0.3.4",
"@types/figlet": "^1.5.8",
"@types/eccrypto": "^1.1.6",
"@types/figlet": "^1.5.8",
"@types/node": "^20.14.10",
"eslint-config-standard-with-typescript": "^43.0.1",
"tsup": "^8.1.2"
Expand Down
4 changes: 3 additions & 1 deletion apps/cli/src/commands/project.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import CreateProject from './project/create.project'
import DeleteProject from './project/delete.project'
import ForkProject from './project/fork.project'
import GetProject from './project/get.project'
import ImportFromEnv from './project/import.project'
import ListProjectForks from './project/list-forks.project'
import ListProject from './project/list.project'
import SyncProject from './project/sync.project'
Expand All @@ -28,7 +29,8 @@ export default class ProjectCommand extends BaseCommand {
new ListProject(),
new SyncProject(),
new UnlinkProject(),
new UpdateProject()
new UpdateProject(),
new ImportFromEnv()
]
}
}
184 changes: 184 additions & 0 deletions apps/cli/src/commands/project/import.project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import type {
CommandActionData,
CommandArgument,
CommandOption
} from '@/types/command/command.types'
import BaseCommand from '../base.command'
import { confirm, text } from '@clack/prompts'
import ControllerInstance from '@/util/controller-instance'
import { Logger } from '@/util/logger'
import fs from 'node:fs/promises'
import path from 'node:path'
import dotenv from 'dotenv'
import secretDetector from '@keyshade/secret-scan'

export default class ImportFromEnv extends BaseCommand {
getName(): string {
return 'import'
}

getDescription(): string {
return 'Imports environment secrets and variables from .env file to a project.'
}

getArguments(): CommandArgument[] {
return [
{
name: '<Project Slug>',
description: 'Slug of the project where envs will be imported.'
}
]
}

getOptions(): CommandOption[] {
return [
{
short: '-f',
long: '--env-file <string>',
description: 'Path to the .env file'
}
]
}

canMakeHttpRequests(): boolean {
return true
}

async action({ args, options }: CommandActionData): Promise<void> {
const [projectSlug] = args

try {
const parsedOptions = await this.parseOptions(options)
if (!parsedOptions) return
const envFileContent = await fs.readFile(
parsedOptions.envFilePath,
'utf-8'
)

const envVariables = dotenv.parse(envFileContent)
if (Object.keys(envVariables).length === 0) {
Logger.warn('No environment variables found in the provided file')
return
}

const secretsAndVariables = secretDetector.scanJsObject(envVariables)

Logger.info(
'Detected secrets:\n' +
Object.entries(secretsAndVariables.secrets)
.map(([key, value]) => key + ' = ' + JSON.stringify(value))
.join('\n') +
'\n'
)
Logger.info(
'Detected variables:\n' +
Object.entries(secretsAndVariables.variables)
.map(([key, value]) => key + ' = ' + JSON.stringify(value))
.join('\n')
)

const confirmImport = await confirm({
message:
'Do you want to proceed with importing the environment variables? (y/N)',
initialValue: false
})

if (!confirmImport) {
Logger.info('Import cancelled by the user.')
return
}

const environmentSlug = (await text({
message: 'Enter the environment slug to import to:'
})) as string

Logger.info(
`Importing secrets and variables to project: ${projectSlug} and environment: ${environmentSlug} with default settings`
)

let noOfSecrets = 0
let noOfVariables = 0
const errors: string[] = []
for (const [key, value] of Object.entries(secretsAndVariables.secrets)) {
const { error, success } =
await ControllerInstance.getInstance().secretController.createSecret(
{
projectSlug,
name: key,
entries: [
{
value,
environmentSlug
}
]
},
this.headers
)

if (success) {
++noOfSecrets
} else {
errors.push(
`Failed to create secret for ${key}. Error: ${error.message}.`
)
}
}

for (const [key, value] of Object.entries(
secretsAndVariables.variables
)) {
const { error, success } =
await ControllerInstance.getInstance().variableController.createVariable(
{
projectSlug,
name: key,
entries: [
{
value,
environmentSlug
}
]
},
this.headers
)

if (success) {
++noOfVariables
} else {
errors.push(
`Failed to create variable for ${key}. Error: ${error.message}.`
)
}
}
Logger.info(
`Imported ${noOfSecrets} secrets and ${noOfVariables} variables.`
)
if (errors.length) Logger.error(errors.join('\n'))
} catch (error) {
const errorMessage = (error as Error)?.message
Logger.error(
`Failed to import secrets and variables.${errorMessage ? '\n' + errorMessage : ''}`
)
}
}

private async parseOptions(options: CommandActionData['options']): Promise<{
envFilePath: string
} | null> {
const { envFile } = options
if (!envFile) {
Logger.error('No .env file path provided.')
return null
}
const resolvedPath = path.resolve(envFile)
const exists = await fs
.access(resolvedPath)
.then(() => true)
.catch(() => false)
if (!exists) {
Logger.error(`The .env file does not exist at path: ${resolvedPath}`)
return null
}
return { envFilePath: resolvedPath }
}
}
40 changes: 40 additions & 0 deletions apps/cli/src/commands/scan.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,44 @@ import path from 'path'
import secretDetector from '@keyshade/secret-scan'
import { Logger } from '@/util/logger'

const ignoredExtensions = [
'png',
'jpg',
'jpeg',
'gif',
'svg',
'ico',
'woff',
'woff2',
'ttf',
'eot',
'pdf',
'mp4',
'mp3',
'wav',
'avi',
'mov',
'webm',
'zip',
'tar',
'gz',
'7z',
'rar',
'iso',
'bin',
'exe',
'dll',
'so',
'a',
'o',
'dylib',
'lib',
'obj',
'jar',
'war',
'ear'
]

export default class ScanCommand extends BaseCommand {
getOptions(): CommandOption[] {
return [
Expand Down Expand Up @@ -65,6 +103,8 @@ export default class ScanCommand extends BaseCommand {
for (const file of allFiles) {
const stats = statSync(file)
if (stats.isFile()) {
// Skip the file if it has an ignored extension like images, videos, etc.
if (ignoredExtensions.includes(file.split('.').pop())) continue
const content = readFileSync(file, 'utf8').split(/\r?\n/)

// Skip the file if ignore comment is found in the first line
Expand Down
24 changes: 23 additions & 1 deletion packages/secret-scan/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import denylist from '@/denylist'
import type { SecretResult } from '@/types'
import type { SecretResult, JsObjectScanResult } from '@/types'

export type SecretConfig = Record<string, RegExp[]>

Expand All @@ -22,6 +22,28 @@ class SecretDetector {
}
return { found: false }
}

/**
* Detects if a given js object contains any secret patterns.
* @param input - The object to scan for secret patterns.
* @returns A `JsObjectScanResult` object containing the secrets and variables found in the object.
*/
scanJsObject(input: Record<string, string>): JsObjectScanResult {
const result: JsObjectScanResult = {
secrets: {},
variables: {}
}
for (const [key, value] of Object.entries(input)) {
const secretResult = this.detect(key + '=' + value)
if (secretResult.found) {
result.secrets[key] = value
} else {
result.variables[key] = value
}
}

return result
}
}

const createSecretDetector = (config: SecretConfig): SecretDetector => {
Expand Down
63 changes: 63 additions & 0 deletions packages/secret-scan/src/test/scan-js-object.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import secretDetector from '@/index'
import { aws, github, openAI } from '@/rules'

describe('Dectect Secrets and Variables from Object', () => {
it('should be able to differentiate variables from secrets', () => {
const input = {
GITHUB_KEY: github.testcases[0].input,
AWS_KEY: aws.testcases[0].input,
OPENAI_KEY: openAI.testcases[0].input,
NEXT_PUBLIC_API_KEY: 'this-is-some-key',
GOOGLE_ANALYTICS: 'UA-123456789-1',
API_PORT: '3000'
}
const result = secretDetector.scanJsObject(input)
expect(result.secrets).toEqual({
GITHUB_KEY: input.GITHUB_KEY,
AWS_KEY: input.AWS_KEY,
OPENAI_KEY: input.OPENAI_KEY
})
expect(result.variables).toEqual({
NEXT_PUBLIC_API_KEY: input.NEXT_PUBLIC_API_KEY,
GOOGLE_ANALYTICS: input.GOOGLE_ANALYTICS,
API_PORT: input.API_PORT
})
})

it('should return empty objects for secrets and variables when input is empty', () => {
const input = {}
const result = secretDetector.scanJsObject(input)
expect(result.secrets).toEqual({})
expect(result.variables).toEqual({})
})

it('should return only variables when there are no secrets', () => {
const input = {
NEXT_PUBLIC_API_KEY: 'this-is-some-key',
GOOGLE_ANALYTICS: 'UA-123456789-1',
API_PORT: '3000'
}
const result = secretDetector.scanJsObject(input)
expect(result.secrets).toEqual({})
expect(result.variables).toEqual({
NEXT_PUBLIC_API_KEY: input.NEXT_PUBLIC_API_KEY,
GOOGLE_ANALYTICS: input.GOOGLE_ANALYTICS,
API_PORT: input.API_PORT
})
})

it('should return only secrets when there are no variables', () => {
const input = {
GITHUB_KEY: github.testcases[0].input,
AWS_KEY: aws.testcases[0].input,
OPENAI_KEY: openAI.testcases[0].input
}
const result = secretDetector.scanJsObject(input)
expect(result.secrets).toEqual({
GITHUB_KEY: input.GITHUB_KEY,
AWS_KEY: input.AWS_KEY,
OPENAI_KEY: input.OPENAI_KEY
})
expect(result.variables).toEqual({})
})
})
5 changes: 5 additions & 0 deletions packages/secret-scan/src/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@ export interface SecretResult {
found: boolean
regex?: RegExp
}

export interface JsObjectScanResult {
secrets: Record<string, string>
variables: Record<string, string>
}
Loading

0 comments on commit 9896f27

Please sign in to comment.