Skip to content

Commit ffb4260

Browse files
committed
WIP: enterprise settings sync + enforcement
1 parent 21e4377 commit ffb4260

File tree

6 files changed

+171
-59
lines changed

6 files changed

+171
-59
lines changed

lib/commands/info/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { InputError } from '../../utils/errors.js'
1111
import { getSeverityCount, formatSeverityCount } from '../../utils/format-issues.js'
1212
import { printFlagList } from '../../utils/formatting.js'
1313
import { objectSome } from '../../utils/misc.js'
14-
import { setupSdk } from '../../utils/sdk.js'
14+
import { FREE_API_KEY, getDefaultKey, setupSdk } from '../../utils/sdk.js'
1515

1616
/** @type {import('../../utils/meow-with-subcommands').CliSubcommand} */
1717
export const info = {
@@ -124,7 +124,7 @@ function setupCommand (name, description, argv, importMeta) {
124124
* @returns {Promise<void|PackageData>}
125125
*/
126126
async function fetchPackageData (pkgName, pkgVersion, { includeAllIssues, strict }) {
127-
const socketSdk = await setupSdk()
127+
const socketSdk = await setupSdk(getDefaultKey() || FREE_API_KEY)
128128
const spinner = ora(`Looking up data for version ${pkgVersion} of ${pkgName}`).start()
129129
const result = await handleApiCall(socketSdk.getIssuesByNPMPackage(pkgName, pkgVersion), spinner, 'looking up package')
130130

lib/commands/login/index.js

Lines changed: 65 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import isInteractive from 'is-interactive'
22
import meow from 'meow'
33
import ora from 'ora'
44
import prompts from 'prompts'
5+
import terminalLink from 'terminal-link'
56

6-
import { ChalkOrMarkdown } from '../../utils/chalk-markdown.js'
77
import { AuthError, InputError } from '../../utils/errors.js'
88
import { setupSdk } from '../../utils/sdk.js'
99
import { getSetting, updateSetting } from '../../utils/settings.js'
@@ -29,38 +29,88 @@ export const login = {
2929
importMeta,
3030
})
3131

32+
/**
33+
* @param {{aborted: boolean}} state
34+
*/
35+
const promptAbortHandler = (state) => {
36+
if (state.aborted) {
37+
process.nextTick(() => process.exit(1))
38+
}
39+
}
40+
3241
if (cli.input.length) cli.showHelp()
3342

3443
if (!isInteractive()) {
3544
throw new InputError('cannot prompt for credentials in a non-interactive shell')
3645
}
37-
const format = new ChalkOrMarkdown(false)
3846
const { apiKey } = await prompts({
3947
type: 'password',
4048
name: 'apiKey',
41-
message: `Enter your ${format.hyperlink(
49+
message: `Enter your ${terminalLink(
4250
'Socket.dev API key',
4351
'https://docs.socket.dev/docs/api-keys'
4452
)}`,
53+
onState: promptAbortHandler
4554
})
4655

47-
if (!apiKey) {
48-
ora('API key not updated').warn()
49-
return
50-
}
51-
5256
const spinner = ora('Verifying API key...').start()
5357

54-
const oldKey = getSetting('apiKey')
55-
updateSetting('apiKey', apiKey)
58+
/** @type {import('@socketsecurity/sdk').SocketSdkReturnType<'getSettings'>['data']} */
59+
let settings
60+
5661
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'}`)
62+
const sdk = await setupSdk(apiKey)
63+
const result = await sdk.getSettings()
64+
if (!result.success) throw new AuthError()
65+
settings = result.data
66+
spinner.succeed('API key verified\n')
6167
} catch (e) {
62-
updateSetting('apiKey', oldKey)
6368
spinner.fail('Invalid API key')
69+
return
6470
}
71+
72+
/** @type {prompts.Choice[]} */
73+
const orgChoices = Object.values(settings.organizations)
74+
.map(org => ({
75+
title: org.name,
76+
description: `${org.plan.tier} tier`,
77+
selected: true,
78+
value: org.id
79+
}))
80+
81+
/** @type {string[]} */
82+
let orgIDs = []
83+
84+
if (orgChoices.length > 1) {
85+
const { ids } = await prompts({
86+
type: 'multiselect',
87+
name: 'ids',
88+
instructions: '',
89+
hint: '\n Use ←/→/space to select and deselect, then hit enter to submit\n',
90+
message: 'Which organizations\' policies would you like Socket to enforce?',
91+
choices: orgChoices,
92+
min: 0,
93+
onState: promptAbortHandler
94+
})
95+
orgIDs = ids
96+
} else if (orgChoices.length) {
97+
const { confirmOrg } = await prompts({
98+
type: 'confirm',
99+
name: 'confirmOrg',
100+
message: `Enforce organization policies for ${orgChoices[0]?.title}?`,
101+
initial: true,
102+
onState: promptAbortHandler
103+
})
104+
if (confirmOrg) {
105+
orgIDs = [orgChoices[0]?.value]
106+
}
107+
}
108+
updateSetting('orgs', orgIDs.map(id => ({
109+
id,
110+
issueRules: settings.organizations[id].issueRules
111+
})))
112+
const oldKey = getSetting('apiKey')
113+
updateSetting('apiKey', apiKey)
114+
spinner.succeed(`API credentials ${oldKey ? 'updated' : 'set'}`)
65115
}
66116
}

lib/commands/logout/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const logout = {
2727
if (cli.input.length) cli.showHelp()
2828

2929
updateSetting('apiKey', null)
30+
updateSetting('orgs', null)
3031
ora('Successfully logged out').succeed()
3132
}
3233
}

lib/shadow/npm-injection.cjs

Lines changed: 83 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const isInteractivePromise = import('is-interactive')
1313
const chalkPromise = import('chalk')
1414
const chalkMarkdownPromise = import('../utils/chalk-markdown.js')
1515
const settingsPromise = import('../utils/settings.js')
16+
const sdkPromise = import('../utils/sdk.js')
1617
const ipc_version = require('../../package.json').version
1718

1819
try {
@@ -33,9 +34,49 @@ try {
3334
* @typedef {import('stream').Writable} Writable
3435
*/
3536

36-
const pubTokenPromise = settingsPromise.then(({ getSetting }) =>
37-
getSetting('apiKey') || 'sktsec_t_--RAN5U4ivauy4w37-6aoKyYPDt5ZbaT5JBVMqiwKo_api'
38-
);
37+
const pubTokenPromise = sdkPromise.then(({ getDefaultKey, FREE_API_KEY }) => getDefaultKey() || FREE_API_KEY)
38+
const apiKeySettingsPromise = sdkPromise.then(async ({ setupSdk }) => {
39+
const sdk = await setupSdk();
40+
const result = await sdk.getSettings()
41+
if (!result.success) throw new Error('failed to fetch API key settings')
42+
return result.data;
43+
})
44+
45+
/** @type {Promise<{ id: string, issueRules: import('../utils/settings.js').IssueRules }[]>} */
46+
const orgSettingsPromise = settingsPromise.then(async ({ getSetting, updateSetting }) => {
47+
const orgs = getSetting('orgs') || [];
48+
if (!orgs.length) return [];
49+
50+
const settings = await apiKeySettingsPromise
51+
const newOrgs = orgs.filter(org => settings.organizations[org.id])
52+
.map(org => {
53+
const curOrg = settings.organizations[org.id];
54+
return {
55+
id: org.id,
56+
issueRules: curOrg.plan.tier === 'enterprise'
57+
? curOrg.issueRules
58+
: org.issueRules
59+
}
60+
})
61+
62+
updateSetting('orgs', newOrgs);
63+
64+
return newOrgs.map(({ id, issueRules }) => {
65+
const defaultedRules = { ...issueRules };
66+
for (const rule in settings.defaultIssueRules) {
67+
if (!(rule in defaultedRules) || (
68+
typeof defaultedRules[rule] === 'object' &&
69+
defaultedRules[rule].action === 'defer'
70+
)) {
71+
defaultedRules[rule] = settings.defaultIssueRules[rule]
72+
}
73+
}
74+
return {
75+
id,
76+
issueRules: defaultedRules
77+
}
78+
})
79+
})
3980

4081
// shadow `npm` and `npx` to mitigate subshells
4182
require('./link.cjs')(fs.realpathSync(path.join(__dirname, 'bin')), 'npm')
@@ -76,6 +117,7 @@ async function * batchScan (
76117
}
77118
})
78119
}
120+
// TODO: migrate to SDK
79121
const pkgDataReq = https.request(
80122
'https://api.socket.dev/v0/scan/batch',
81123
{
@@ -245,7 +287,7 @@ class SafeArborist extends Arborist {
245287
}
246288
} else {
247289
if (await packagesHaveRiskyIssues(this.registry, diff, null, null, output)) {
248-
throw new Error('Socket npm Unable to prompt to accept risk, need TTY to do so')
290+
throw new Error('Socket npm unable to prompt to accept risk, need TTY to do so')
249291
}
250292
return true
251293
}
@@ -357,7 +399,7 @@ function walk (diff, needInfoOn = []) {
357399
* @param {InstallEffect[]} pkgs
358400
* @param {import('ora')['default'] | null} ora
359401
* @param {Readable | null} input
360-
* @param {Writable} ora
402+
* @param {Writable} output
361403
* @returns {Promise<boolean>}
362404
*/
363405
async function packagesHaveRiskyIssues (registry, pkgs, ora = null, input, output) {
@@ -374,64 +416,70 @@ async function packagesHaveRiskyIssues (registry, pkgs, ora = null, input, outpu
374416
const spinner = ora ? ora().start(getText()) : null
375417
const pkgDatas = []
376418
try {
419+
const orgSettings = await orgSettingsPromise
420+
if (orgSettings.length > 1) {
421+
throw new Error('multi-organization API keys not supported')
422+
}
423+
// TODO: determine org based on cwd
424+
const rules = orgSettings.length
425+
? orgSettings[0].issueRules
426+
: (await apiKeySettingsPromise).defaultIssueRules
427+
377428
for await (const pkgData of batchScan(pkgs.map(pkg => pkg.pkgid))) {
378429
let failures = []
430+
let warns = [];
379431
if (pkgData.type === 'missing') {
380432
failures.push({
381433
type: 'missingDependency'
382434
})
383435
continue
384436
}
385437
for (const issue of (pkgData.value?.issues ?? [])) {
386-
if ([
387-
'shellScriptOverride',
388-
'gitDependency',
389-
'httpDependency',
390-
'installScripts',
391-
'malware',
392-
'didYouMean',
393-
'hasNativeCode',
394-
'troll',
395-
'telemetry',
396-
'invalidPackageJSON',
397-
'unresolvedRequire',
398-
].includes(issue.type)) {
399-
failures.push(issue)
438+
if (rules[issue.type]) {
439+
if (typeof rules[issue.type] == 'boolean' || rules[issue.type].action === 'error') {
440+
failures.push(issue)
441+
} else if (rules[issue.type].action == 'warn') {
442+
warns.push(issue);
443+
}
400444
}
401445
}
402446
// before we ask about problematic issues, check to see if they already existed in the old version
403447
// if they did, be quiet
404-
if (failures.length) {
448+
if (failures.length || warns.length) {
405449
const pkg = pkgs.find(pkg => pkg.pkgid === `${pkgData.pkg}@${pkgData.ver}` && pkg.existing?.startsWith(pkgData.pkg))
406450
if (pkg?.existing) {
407451
for await (const oldPkgData of batchScan([pkg.existing])) {
408452
if (oldPkgData.type === 'success') {
409-
failures = failures.filter(
410-
issue => oldPkgData.value.issues.find(oldIssue => oldIssue.type === issue.type) == null
411-
)
453+
const issueFilter = issue => oldPkgData.value.issues.find(oldIssue => oldIssue.type === issue.type) == null
454+
failures = failures.filter(issueFilter)
455+
warns = warns.filter(issueFilter)
412456
}
413457
}
414458
}
415459
}
416-
if (failures.length) {
417-
failed = true
460+
if (failures.length || warns.length) {
461+
failed = failures.length > 0
418462
spinner?.stop()
419463
translations ??= JSON.parse(fs.readFileSync(path.join(__dirname, '/translations.json'), 'utf-8'))
420464
formatter ??= new ((await chalkMarkdownPromise).ChalkOrMarkdown)(false)
421465
const name = pkgData.pkg
422466
const version = pkgData.ver
423-
output.write(`(socket) ${formatter.hyperlink(`${name}@${version}`, `https://socket.dev/npm/package/${name}/overview/${version}`)} contains risks:\n`)
467+
output.write(`(socket) ${formatter.hyperlink(`${name}@${version}`, `https://socket.dev/npm/package/${name}/overview/${version}`)} contains ${failures.length ? 'serious ' : ''}risks:\n`)
424468
if (translations) {
425469
for (const failure of failures) {
426-
const type = failure.type
427-
if (type) {
428-
// @ts-ignore
429-
const issueTypeTranslation = translations.issues[type]
430-
// TODO: emoji seems to misalign terminals sometimes
431-
// @ts-ignore
432-
const msg = ` ${issueTypeTranslation.title} - ${issueTypeTranslation.description}\n`
433-
output.write(msg)
434-
}
470+
// @ts-ignore
471+
const issueTypeTranslation = translations.issues[failure.type]
472+
// TODO: emoji seems to misalign terminals sometimes
473+
// @ts-ignore
474+
const msg = ` ${formatter.bold(issueTypeTranslation.title)} - ${issueTypeTranslation.description}\n`
475+
output.write(msg)
476+
}
477+
for (const warn of warns) {
478+
// @ts-ignore
479+
const issueTypeTranslation = translations.issues[warn.type]
480+
// @ts-ignore
481+
const msg = ` ${issueTypeTranslation.title} - ${issueTypeTranslation.description}\n`
482+
output.write(msg)
435483
}
436484
}
437485
spinner?.start()

lib/utils/sdk.js

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,34 @@ import prompts from 'prompts'
99
import { AuthError } from './errors.js'
1010
import { getSetting } from './settings.js'
1111

12+
export const FREE_API_KEY = 'sktsec_t_--RAN5U4ivauy4w37-6aoKyYPDt5ZbaT5JBVMqiwKo_api'
13+
1214
/**
1315
* This API key should be stored globally for the duration of the CLI execution
1416
*
1517
* @type {string | undefined}
1618
*/
17-
let sessionAPIKey
19+
let defaultKey
1820

19-
/** @returns {Promise<import('@socketsecurity/sdk').SocketSdk>} */
20-
export async function setupSdk () {
21-
let apiKey = getSetting('apiKey') || process.env['SOCKET_SECURITY_API_KEY'] || sessionAPIKey
21+
/** @returns {string | undefined} */
22+
export function getDefaultKey () {
23+
defaultKey = getSetting('apiKey') || process.env['SOCKET_SECURITY_API_KEY'] || defaultKey
24+
return defaultKey
25+
}
2226

23-
if (!apiKey && isInteractive()) {
27+
/**
28+
* @param {string} [apiKey]
29+
* @returns {Promise<import('@socketsecurity/sdk').SocketSdk>}
30+
*/
31+
export async function setupSdk (apiKey = getDefaultKey()) {
32+
if (apiKey == null && isInteractive()) {
2433
const input = await prompts({
2534
type: 'password',
2635
name: 'apiKey',
2736
message: 'Enter your Socket.dev API key (not saved)',
2837
})
2938

30-
apiKey = sessionAPIKey = input.apiKey
39+
apiKey = defaultKey = input.apiKey
3140
}
3241

3342
if (!apiKey) {

lib/utils/settings.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ if (!dataHome) {
1919

2020
const settingsPath = path.join(dataHome, 'socket', 'settings')
2121

22-
/** @type {{apiKey?: string | null}} */
22+
/**
23+
* @typedef {import('@socketsecurity/sdk').SocketSdkReturnType<'getSettings'>['data']['organizations'][string]['issueRules']} IssueRules
24+
*/
25+
26+
/** @type {{apiKey?: string | null, orgs?: { id: string, issueRules: IssueRules }[] | null}} */
2327
let settings = {}
2428

2529
if (fs.existsSync(settingsPath)) {

0 commit comments

Comments
 (0)