Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): Add import sub commmand for project. #594

Open
wants to merge 6 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()
]
}
}
182 changes: 182 additions & 0 deletions apps/cli/src/commands/project/import.project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
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 { envFilePath } = await this.parseOptions(options)
if (!envFilePath) return
const envFileContent = await fs.readFile(envFilePath, 'utf-8')
// Logger.info('File contents:\n' + envFileContent)

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.detectJsObject(envVariables)

Logger.info(
'Detected secrets:\n' +
Object.entries(secretsAndVariables.secrets)
.map(([key, value]) => key + ' = ' + JSON.stringify(value))
.join('\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 | undefined
}> {
const { envFile } = options
if (!envFile) {
Logger.error('No .env file path provided.')
return { envFilePath: undefined }
}
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 { envFilePath: undefined }
}

return { envFilePath: resolvedPath }
}
}
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, ScanJsObjectResult } 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 `ScanJsObjectResult` object containing the secrets and variables found in the object.
*/
detectJsObject(input: Record<string, string>): ScanJsObjectResult {
const result: ScanJsObjectResult = {
secrets: {},
variables: {}
}
for (const [key, value] of Object.entries(input)) {
const secretResult = this.detect(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/detect-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.detectJsObject(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.detectJsObject(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.detectJsObject(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.detectJsObject(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 ScanJsObjectResult {
secrets: Record<string, string>
variables: Record<string, string>
}
Loading
Loading