@socketsecurity/cli 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,3 +2,5 @@ export * from './info/index.js'
2
2
  export * from './report/index.js'
3
3
  export * from './npm/index.js'
4
4
  export * from './npx/index.js'
5
+ export * from './login/index.js'
6
+ export * from './logout/index.js'
@@ -0,0 +1,66 @@
1
+ import isInteractive from 'is-interactive'
2
+ import meow from 'meow'
3
+ import ora from 'ora'
4
+ import prompts from 'prompts'
5
+
6
+ import { ChalkOrMarkdown } from '../../utils/chalk-markdown.js'
7
+ import { AuthError, InputError } from '../../utils/errors.js'
8
+ import { setupSdk } from '../../utils/sdk.js'
9
+ import { getSetting, updateSetting } from '../../utils/settings.js'
10
+
11
+ const description = 'Socket API login'
12
+
13
+ /** @type {import('../../utils/meow-with-subcommands').CliSubcommand} */
14
+ export const login = {
15
+ description,
16
+ run: async (argv, importMeta, { parentName }) => {
17
+ const name = parentName + ' login'
18
+ const cli = meow(`
19
+ Usage
20
+ $ ${name}
21
+
22
+ Logs into the Socket API by prompting for an API key
23
+
24
+ Examples
25
+ $ ${name}
26
+ `, {
27
+ argv,
28
+ description,
29
+ importMeta,
30
+ })
31
+
32
+ if (cli.input.length) cli.showHelp()
33
+
34
+ if (!isInteractive()) {
35
+ throw new InputError('cannot prompt for credentials in a non-interactive shell')
36
+ }
37
+ const format = new ChalkOrMarkdown(false)
38
+ const { apiKey } = await prompts({
39
+ type: 'password',
40
+ name: 'apiKey',
41
+ message: `Enter your ${format.hyperlink(
42
+ 'Socket.dev API key',
43
+ 'https://docs.socket.dev/docs/api-keys'
44
+ )}`,
45
+ })
46
+
47
+ if (!apiKey) {
48
+ ora('API key not updated').warn()
49
+ return
50
+ }
51
+
52
+ const spinner = ora('Verifying API key...').start()
53
+
54
+ const oldKey = getSetting('apiKey')
55
+ updateSetting('apiKey', apiKey)
56
+ try {
57
+ const sdk = await setupSdk()
58
+ const quota = await sdk.getQuota()
59
+ if (!quota.success) throw new AuthError()
60
+ spinner.succeed(`API key ${oldKey ? 'updated' : 'set'}`)
61
+ } catch (e) {
62
+ updateSetting('apiKey', oldKey)
63
+ spinner.fail('Invalid API key')
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,32 @@
1
+ import meow from 'meow'
2
+ import ora from 'ora'
3
+
4
+ import { updateSetting } from '../../utils/settings.js'
5
+
6
+ const description = 'Socket API logout'
7
+
8
+ /** @type {import('../../utils/meow-with-subcommands').CliSubcommand} */
9
+ export const logout = {
10
+ description,
11
+ run: async (argv, importMeta, { parentName }) => {
12
+ const name = parentName + ' logout'
13
+ const cli = meow(`
14
+ Usage
15
+ $ ${name}
16
+
17
+ Logs out of the Socket API and clears all Socket credentials from disk
18
+
19
+ Examples
20
+ $ ${name}
21
+ `, {
22
+ argv,
23
+ description,
24
+ importMeta,
25
+ })
26
+
27
+ if (cli.input.length) cli.showHelp()
28
+
29
+ updateSetting('apiKey', null)
30
+ ora('Successfully logged out').succeed()
31
+ }
32
+ }
@@ -181,7 +181,17 @@ async function setupCommand (name, description, argv, importMeta) {
181
181
  }
182
182
  })
183
183
 
184
- const packagePaths = await getPackageFiles(cwd, cli.input, config, debugLog)
184
+ // TODO: setupSdk(getDefaultKey() || FREE_API_KEY)
185
+ const socketSdk = await setupSdk()
186
+ const supportedFiles = await socketSdk.getReportSupportedFiles()
187
+ .then(res => {
188
+ if (!res.success) handleUnsuccessfulApiResponse('getReportSupportedFiles', res, ora())
189
+ return res.data
190
+ }).catch(cause => {
191
+ throw new ErrorWithCause('Failed getting supported files for report', { cause })
192
+ })
193
+
194
+ const packagePaths = await getPackageFiles(cwd, cli.input, config, supportedFiles, debugLog)
185
195
 
186
196
  return {
187
197
  config,
@@ -12,6 +12,7 @@ const oraPromise = import('ora')
12
12
  const isInteractivePromise = import('is-interactive')
13
13
  const chalkPromise = import('chalk')
14
14
  const chalkMarkdownPromise = import('../utils/chalk-markdown.js')
15
+ const settingsPromise = import('../utils/settings.js')
15
16
  const ipc_version = require('../../package.json').version
16
17
 
17
18
  try {
@@ -32,7 +33,9 @@ try {
32
33
  * @typedef {import('stream').Writable} Writable
33
34
  */
34
35
 
35
- const pubToken = 'sktsec_t_--RAN5U4ivauy4w37-6aoKyYPDt5ZbaT5JBVMqiwKo_api'
36
+ const pubTokenPromise = settingsPromise.then(({ getSetting }) =>
37
+ getSetting('apiKey') || 'sktsec_t_--RAN5U4ivauy4w37-6aoKyYPDt5ZbaT5JBVMqiwKo_api'
38
+ );
36
39
 
37
40
  // shadow `npm` and `npx` to mitigate subshells
38
41
  require('./link.cjs')(fs.realpathSync(path.join(__dirname, 'bin')), 'npm')
@@ -64,6 +67,7 @@ const pkgidParts = (pkgid) => {
64
67
  async function * batchScan (
65
68
  pkgids
66
69
  ) {
70
+ const pubToken = await pubTokenPromise
67
71
  const query = {
68
72
  packages: pkgids.map(pkgid => {
69
73
  const { name, version } = pkgidParts(pkgid)
@@ -8,7 +8,7 @@ import { AuthError } from './errors.js'
8
8
  * @param {T} _name
9
9
  * @param {import('@socketsecurity/sdk').SocketSdkErrorType<T>} result
10
10
  * @param {import('ora').Ora} spinner
11
- * @returns {void}
11
+ * @returns {never}
12
12
  */
13
13
  export function handleUnsuccessfulApiResponse (_name, result, spinner) {
14
14
  const resultError = 'error' in result && result.error && typeof result.error === 'object' ? result.error : {}
@@ -5,17 +5,12 @@ import { globby } from 'globby'
5
5
  import ignore from 'ignore'
6
6
  // @ts-ignore This package provides no types
7
7
  import { directories } from 'ignore-by-default'
8
+ import micromatch from 'micromatch'
8
9
  import { ErrorWithCause } from 'pony-cause'
9
10
 
10
11
  import { InputError } from './errors.js'
11
12
  import { isErrnoException } from './type-helpers.js'
12
13
 
13
- /** @type {readonly string[]} */
14
- const SUPPORTED_LOCKFILES = [
15
- 'package-lock.json',
16
- 'yarn.lock',
17
- ]
18
-
19
14
  /**
20
15
  * There are a lot of possible folders that we should not be looking in and "ignore-by-default" helps us with defining those
21
16
  *
@@ -23,10 +18,17 @@ const SUPPORTED_LOCKFILES = [
23
18
  */
24
19
  const ignoreByDefault = directories()
25
20
 
26
- /** @type {readonly string[]} */
27
- const GLOB_IGNORE = [
28
- ...ignoreByDefault.map(item => '**/' + item)
29
- ]
21
+ /** @type {import('globby').Options} */
22
+ const BASE_GLOBBY_OPTS = {
23
+ absolute: true,
24
+ expandDirectories: false,
25
+ gitignore: true,
26
+ ignore: [
27
+ ...ignoreByDefault.map(item => '**/' + item)
28
+ ],
29
+ markDirectories: true,
30
+ unique: true,
31
+ }
30
32
 
31
33
  /**
32
34
  * Resolves package.json and lockfiles from (globbed) input paths, applying relevant ignores
@@ -34,28 +36,24 @@ const GLOB_IGNORE = [
34
36
  * @param {string} cwd The working directory to use when resolving paths
35
37
  * @param {string[]} inputPaths A list of paths to folders, package.json files and/or recognized lockfiles. Supports globs.
36
38
  * @param {import('@socketsecurity/config').SocketYml|undefined} config
39
+ * @param {import('@socketsecurity/sdk').SocketSdkReturnType<"getReportSupportedFiles">['data']} supportedFiles
37
40
  * @param {typeof console.error} debugLog
38
41
  * @returns {Promise<string[]>}
39
42
  * @throws {InputError}
40
43
  */
41
- export async function getPackageFiles (cwd, inputPaths, config, debugLog) {
44
+ export async function getPackageFiles (cwd, inputPaths, config, supportedFiles, debugLog) {
42
45
  debugLog(`Globbed resolving ${inputPaths.length} paths:`, inputPaths)
43
46
 
44
47
  // TODO: Does not support `~/` paths
45
48
  const entries = await globby(inputPaths, {
46
- absolute: true,
49
+ ...BASE_GLOBBY_OPTS,
47
50
  cwd,
48
- expandDirectories: false,
49
- gitignore: true,
50
- ignore: [...GLOB_IGNORE],
51
- markDirectories: true,
52
- onlyFiles: false,
53
- unique: true,
51
+ onlyFiles: false
54
52
  })
55
53
 
56
54
  debugLog(`Globbed resolved ${inputPaths.length} paths to ${entries.length} paths:`, entries)
57
55
 
58
- const packageFiles = await mapGlobResultToFiles(entries)
56
+ const packageFiles = await mapGlobResultToFiles(entries, supportedFiles)
59
57
 
60
58
  debugLog(`Mapped ${entries.length} entries to ${packageFiles.length} files:`, packageFiles)
61
59
 
@@ -73,11 +71,14 @@ export async function getPackageFiles (cwd, inputPaths, config, debugLog) {
73
71
  * Takes paths to folders, package.json and/or recognized lock files and resolves them to package.json + lockfile pairs (where possible)
74
72
  *
75
73
  * @param {string[]} entries
74
+ * @param {import('@socketsecurity/sdk').SocketSdkReturnType<"getReportSupportedFiles">['data']} supportedFiles
76
75
  * @returns {Promise<string[]>}
77
76
  * @throws {InputError}
78
77
  */
79
- export async function mapGlobResultToFiles (entries) {
80
- const packageFiles = await Promise.all(entries.map(mapGlobEntryToFiles))
78
+ export async function mapGlobResultToFiles (entries, supportedFiles) {
79
+ const packageFiles = await Promise.all(
80
+ entries.map(entry => mapGlobEntryToFiles(entry, supportedFiles))
81
+ )
81
82
 
82
83
  const uniquePackageFiles = [...new Set(packageFiles.flat())]
83
84
 
@@ -88,46 +89,58 @@ export async function mapGlobResultToFiles (entries) {
88
89
  * Takes a single path to a folder, package.json or a recognized lock file and resolves to a package.json + lockfile pair (where possible)
89
90
  *
90
91
  * @param {string} entry
92
+ * @param {import('@socketsecurity/sdk').SocketSdkReturnType<'getReportSupportedFiles'>['data']} supportedFiles
91
93
  * @returns {Promise<string[]>}
92
94
  * @throws {InputError}
93
95
  */
94
- export async function mapGlobEntryToFiles (entry) {
96
+ export async function mapGlobEntryToFiles (entry, supportedFiles) {
95
97
  /** @type {string|undefined} */
96
- let pkgFile
97
- /** @type {string|undefined} */
98
- let lockFile
99
-
98
+ let pkgJSFile
99
+ /** @type {string[]} */
100
+ let jsLockFiles = []
101
+ /** @type {string[]} */
102
+ let pyFiles = []
103
+
104
+ const jsSupported = supportedFiles['npm'] || {}
105
+ const jsLockFilePatterns = Object.keys(jsSupported)
106
+ .filter(key => key !== 'packagejson')
107
+ .map(key => /** @type {{ pattern: string }} */ (jsSupported[key]).pattern)
108
+
109
+ const pyFilePatterns = Object.values(supportedFiles['pypi'] || {}).map(p => p.pattern)
100
110
  if (entry.endsWith('/')) {
101
111
  // If the match is a folder and that folder contains a package.json file, then include it
102
112
  const filePath = path.resolve(entry, 'package.json')
103
- pkgFile = await fileExists(filePath) ? filePath : undefined
104
- } else if (path.basename(entry) === 'package.json') {
105
- // If the match is a package.json file, then include it
106
- pkgFile = entry
107
- } else if (SUPPORTED_LOCKFILES.includes(path.basename(entry))) {
108
- // If the match is a lock file, include both it and the corresponding package.json file
109
- lockFile = entry
110
- pkgFile = path.resolve(path.dirname(entry), 'package.json')
113
+ if (await fileExists(filePath)) pkgJSFile = filePath
114
+ pyFiles = await globby(pyFilePatterns, {
115
+ ...BASE_GLOBBY_OPTS,
116
+ cwd: entry
117
+ })
118
+ } else {
119
+ const entryFile = path.basename(entry)
120
+
121
+ if (entryFile === 'package.json') {
122
+ // If the match is a package.json file, then include it
123
+ pkgJSFile = entry
124
+ } else if (micromatch.isMatch(entryFile, jsLockFilePatterns)) {
125
+ jsLockFiles = [entry]
126
+ pkgJSFile = path.resolve(path.dirname(entry), 'package.json')
127
+ if (!(await fileExists(pkgJSFile))) return []
128
+ } else if (micromatch.isMatch(entryFile, pyFilePatterns)) {
129
+ pyFiles = [entry]
130
+ }
111
131
  }
112
132
 
113
133
  // If we will include a package.json file but don't already have a corresponding lockfile, then look for one
114
- if (!lockFile && pkgFile) {
115
- const pkgDir = path.dirname(pkgFile)
116
-
117
- for (const name of SUPPORTED_LOCKFILES) {
118
- const lockFileAlternative = path.resolve(pkgDir, name)
119
- if (await fileExists(lockFileAlternative)) {
120
- lockFile = lockFileAlternative
121
- break
122
- }
123
- }
124
- }
134
+ if (!jsLockFiles.length && pkgJSFile) {
135
+ const pkgDir = path.dirname(pkgJSFile)
125
136
 
126
- if (pkgFile && lockFile) {
127
- return [pkgFile, lockFile]
137
+ jsLockFiles = await globby(jsLockFilePatterns, {
138
+ ...BASE_GLOBBY_OPTS,
139
+ cwd: pkgDir
140
+ })
128
141
  }
129
142
 
130
- return pkgFile ? [pkgFile] : []
143
+ return [...jsLockFiles, ...pyFiles].concat(pkgJSFile ? [pkgJSFile] : [])
131
144
  }
132
145
 
133
146
  /**
package/lib/utils/sdk.js CHANGED
@@ -7,28 +7,27 @@ import isInteractive from 'is-interactive'
7
7
  import prompts from 'prompts'
8
8
 
9
9
  import { AuthError } from './errors.js'
10
+ import { getSetting } from './settings.js'
10
11
 
11
12
  /**
12
- * The API key should be stored globally for the duration of the CLI execution
13
+ * This API key should be stored globally for the duration of the CLI execution
13
14
  *
14
15
  * @type {string | undefined}
15
16
  */
16
- let apiKey
17
+ let sessionAPIKey
17
18
 
18
19
  /** @returns {Promise<import('@socketsecurity/sdk').SocketSdk>} */
19
20
  export async function setupSdk () {
20
- if (!apiKey) {
21
- apiKey = process.env['SOCKET_SECURITY_API_KEY']
22
- }
21
+ let apiKey = getSetting('apiKey') || process.env['SOCKET_SECURITY_API_KEY'] || sessionAPIKey
23
22
 
24
23
  if (!apiKey && isInteractive()) {
25
24
  const input = await prompts({
26
25
  type: 'password',
27
26
  name: 'apiKey',
28
- message: 'Enter your Socket.dev API key',
27
+ message: 'Enter your Socket.dev API key (not saved)',
29
28
  })
30
29
 
31
- apiKey = input.apiKey
30
+ apiKey = sessionAPIKey = input.apiKey
32
31
  }
33
32
 
34
33
  if (!apiKey) {
@@ -0,0 +1,57 @@
1
+ import * as fs from 'fs'
2
+ import * as os from 'os'
3
+ import * as path from 'path'
4
+
5
+ import ora from 'ora'
6
+
7
+ let dataHome = process.platform === 'win32'
8
+ ? process.env['LOCALAPPDATA']
9
+ : process.env['XDG_DATA_HOME']
10
+
11
+ if (!dataHome) {
12
+ if (process.platform === 'win32') throw new Error('missing %LOCALAPPDATA%')
13
+ const home = os.homedir()
14
+ dataHome = path.join(home, ...(process.platform === 'darwin'
15
+ ? ['Library', 'Application Support']
16
+ : ['.local', 'share']
17
+ ))
18
+ }
19
+
20
+ const settingsPath = path.join(dataHome, 'socket', 'settings')
21
+
22
+ /** @type {{apiKey?: string | null}} */
23
+ let settings = {}
24
+
25
+ if (fs.existsSync(settingsPath)) {
26
+ const raw = fs.readFileSync(settingsPath, 'utf-8')
27
+ try {
28
+ settings = JSON.parse(Buffer.from(raw, 'base64').toString())
29
+ } catch (e) {
30
+ ora(`Failed to parse settings at ${settingsPath}`).warn()
31
+ }
32
+ } else {
33
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true })
34
+ }
35
+
36
+ /**
37
+ * @template {keyof typeof settings} Key
38
+ * @param {Key} key
39
+ * @returns {typeof settings[Key]}
40
+ */
41
+ export function getSetting (key) {
42
+ return settings[key]
43
+ }
44
+
45
+ /**
46
+ * @template {keyof typeof settings} Key
47
+ * @param {Key} key
48
+ * @param {typeof settings[Key]} value
49
+ * @returns {void}
50
+ */
51
+ export function updateSetting (key, value) {
52
+ settings[key] = value
53
+ fs.writeFileSync(
54
+ settingsPath,
55
+ Buffer.from(JSON.stringify(settings)).toString('base64')
56
+ )
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@socketsecurity/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "CLI tool for Socket.dev",
5
5
  "homepage": "http://github.com/SocketDev/socket-cli-js",
6
6
  "repository": {
@@ -43,10 +43,11 @@
43
43
  "test": "run-s check test:*"
44
44
  },
45
45
  "devDependencies": {
46
- "@socketsecurity/eslint-config": "^2.0.0",
46
+ "@socketsecurity/eslint-config": "^3.0.1",
47
47
  "@tsconfig/node14": "^1.0.3",
48
48
  "@types/chai": "^4.3.3",
49
49
  "@types/chai-as-promised": "^7.1.5",
50
+ "@types/micromatch": "^4.0.2",
50
51
  "@types/mocha": "^10.0.1",
51
52
  "@types/mock-fs": "^4.13.1",
52
53
  "@types/node": "^14.18.31",
@@ -84,7 +85,7 @@
84
85
  "dependencies": {
85
86
  "@apideck/better-ajv-errors": "^0.3.6",
86
87
  "@socketsecurity/config": "^2.0.0",
87
- "@socketsecurity/sdk": "^0.5.4",
88
+ "@socketsecurity/sdk": "^0.6.0",
88
89
  "chalk": "^5.1.2",
89
90
  "globby": "^13.1.3",
90
91
  "hpagent": "^1.2.0",
@@ -93,6 +94,7 @@
93
94
  "is-interactive": "^2.0.0",
94
95
  "is-unicode-supported": "^1.3.0",
95
96
  "meow": "^11.0.0",
97
+ "micromatch": "^4.0.5",
96
98
  "ora": "^6.1.2",
97
99
  "pony-cause": "^2.1.8",
98
100
  "prompts": "^2.4.2",