diff --git a/README.md b/README.md index 61ea94f0c..3dc570368 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,19 @@ use of the `projectIgnorePaths` to excludes files when creating a report. ## Contributing +### Setup + +To run dev locally you can run these steps + +``` +npm install +npm run build:dist +npm exec socket +``` + +That should invoke it from local sources. If you make changes you run +`build:dist` again. + ### Environment variables for development - `SOCKET_SECURITY_API_BASE_URL` - if set, this will be the base for all diff --git a/src/commands/index.ts b/src/commands/index.ts index ec6163fe5..16f95bc05 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -19,3 +19,4 @@ export * from './dependencies' export * from './analytics' export * from './diff-scan' export * from './threat-feed' +export * from './manifest' diff --git a/src/commands/manifest/auto.ts b/src/commands/manifest/auto.ts new file mode 100644 index 000000000..7793adf51 --- /dev/null +++ b/src/commands/manifest/auto.ts @@ -0,0 +1,78 @@ +import fs from 'node:fs' + +import meow from 'meow' + +import { scala } from './scala.ts' + +import type { CliSubcommand } from '../../utils/meow-with-subcommands' + +const description = 'Auto-detect build and attempt to generate manifest file' + +const help = (name: string) => ` + Usage + $ ${name} + + Tries to figure out what language your current repo uses. If it finds a + supported case then it will try to generate the manifest file for that + language with the default or detected settings. + + This command takes no arguments except --verbose. +` + +export const auto: CliSubcommand = { + description, + async run(argv, importMeta, { parentName }) { + // Allow `--verbose` to pass through + let verbose = false + const args = argv.filter(arg => { + if (arg === '--verbose') { + verbose = true + return false + } + return true + }) + + const name = `${parentName} auto` + if (args.length) { + // note: meow will exit if it prints the --help screen + meow(help(name), { + argv: ['--help'], + description, + importMeta + }) + } + + const subArgs = [] + if (verbose) subArgs.push('--verbose', '1') + const scalaDir = '.' + if (fs.existsSync(scalaDir)) { + console.log( + 'Detected a Scala sbt build, running default Scala generator...' + ) + subArgs.push(scalaDir) + await scala.run(subArgs, importMeta, { parentName }) + return + } + + // Show new help screen and exit + meow( + ` + $ ${name} + + Unfortunately this script did not discover a supported language in the + current folder. + + - Make sure this script would work with your target build + - Make sure to run it from the correct folder + - Make sure the necessary build tools are available (\`PATH\`) + + If that doesn't work, see \`${name} --help\` for config details + `, + { + argv: ['--help'], + description, + importMeta + } + ) + } +} diff --git a/src/commands/manifest/index.ts b/src/commands/manifest/index.ts new file mode 100644 index 000000000..6cc5a4625 --- /dev/null +++ b/src/commands/manifest/index.ts @@ -0,0 +1,70 @@ +import meow from 'meow' + +import { auto } from './auto.ts' +import { scala } from './scala' +import { meowWithSubcommands } from '../../utils/meow-with-subcommands' + +import type { CliSubcommand } from '../../utils/meow-with-subcommands' + +const description = 'Generate a dependency manifest for given file or dir' +const help = (name: string) => ` + Usage + + $ ${name} + + Generates a declarative dependency manifest (like a package.json for Node.JS + or requirements.txt for PyPi), but for certain supported ecosystems + where it's common to use a dynamic manifest, like Scala's sbt. + + Only certain languages are supported and there may be language specific + configurations available. See \`manifest --help\` for usage details + per language. + + Currently supported language: scala + + Examples + + $ ${name} scala . + + To have it auto-detect and attempt to run: + + $ ${name} yolo +` + +export const manifest: CliSubcommand = { + description, + hidden: true, + async run(argv, importMeta, { parentName }) { + const name = `${parentName} manifest` + + // Note: this won't catch `socket manifest -xyz --help` sort of cases which + // would fallback to the default meow help behavior. That's fine. + if (argv.length === 0 || argv[0] === '--help') { + meow(help(name), { + argv: ['--help'] as const, // meow will exit() when --help is passed + description, + importMeta + }) + } + + await meowWithSubcommands( + { + scala, + auto + }, + { + argv, + aliases: { + yolo: { + description: auto.description, + hidden: true, + argv: ['auto'] + } + }, + description, + importMeta, + name + } + ) + } +} diff --git a/src/commands/manifest/scala.ts b/src/commands/manifest/scala.ts new file mode 100644 index 000000000..52b283017 --- /dev/null +++ b/src/commands/manifest/scala.ts @@ -0,0 +1,249 @@ +import fs from 'node:fs' +import path from 'node:path' +import util from 'node:util' + +import spawn from '@npmcli/promise-spawn' +import meow from 'meow' + +import { Spinner } from '@socketsecurity/registry/lib/spinner' + +import { safeReadFile } from '../../utils/fs' +import { getFlagListOutput } from '../../utils/output-formatting.ts' + +import type { CliSubcommand } from '../../utils/meow-with-subcommands' + +type ListDescription = + | string + | { description: string; type?: string; default?: string } + +const renamep = util.promisify(fs.rename) + +const description = + "Generate a manifest file (`pom.xml`) from Scala's `build.sbt` file" + +const scalaCmdFlags: Record = { + bin: { + type: 'string', + default: 'sbt', + description: 'Location of sbt binary to use' + }, + out: { + type: 'string', + default: './socket.pom.xml', + description: + 'Path of output file; where to store the resulting manifest, see also --stdout' + }, + stdout: { + type: 'boolean', + description: 'Print resulting pom.xml to stdout (supersedes --out)' + }, + sbtOpts: { + type: 'string', + default: '', + description: 'Additional options to pass on to sbt, as per `sbt --help`' + }, + verbose: { + type: 'boolean', + description: 'Print debug messages' + } +} + +const help = (name: string, flags: Record) => ` + Usage + $ ${name} [--sbt=path/to/sbt/binary] [--out=path/to/result] FILE|DIR + + Options + ${getFlagListOutput(flags, 6)} + + Uses \`sbt makePom\` to generate a \`pom.xml\` from your \`build.sbt\` file. + This xml file is the dependency manifest (like a package.json + for Node.js or requirements.txt for PyPi), but specifically for Scala. + + There are some caveats with \`build.sbt\` to \`pom.xml\` conversion: + + - the xml is exported as socket.pom.xml as to not confuse existing build tools + but it will first hit your /target/sbt folder (as a different name) + + - the pom.xml format (standard by Scala) does not support certain sbt features + - \`excludeAll()\`, \`dependencyOverrides\`, \`force()\`, \`relativePath\` + - For details: https://www.scala-sbt.org/1.x/docs/Library-Management.html + + - it uses your sbt settings and local configuration verbatim + + - it can only export one target per run, so if you have multiple targets like + development and production, you must run them separately. + + You can optionally configure the path to the \`sbt\` bin to invoke. + + Examples + + $ ${name} ./build.sbt + $ ${name} --bin=/usr/bin/sbt ./build.sbt +` + +export const scala: CliSubcommand = { + description, + async run(argv, importMeta, { parentName }) { + // console.log('scala', argv, parentName) + const name = `${parentName} scala` + // note: meow will exit if it prints the --help screen + const cli = meow(help(name, scalaCmdFlags), { + flags: <{ [key: string]: any }>scalaCmdFlags, + argv: argv.length === 0 ? ['--help'] : argv, + description, + allowUnknownFlags: false, + importMeta + }) + + if (cli.flags['verbose']) { + console.log('[VERBOSE] cli.flags:', cli.flags, ', cli.input:', cli.input) + } + + const target = cli.input[0] + if (!target) { + // will exit. + new Spinner() + .start('Parsing...') + .error( + `Failure: Missing FILE|DIR argument. See \`${name} --help\` for details.` + ) + process.exit(1) + } + + if (cli.input.length > 1) { + // will exit. + new Spinner() + .start('Parsing...') + .error( + `Failure: Can only accept one FILE or DIR, received ${cli.input.length} (make sure to escape spaces!). See \`${name} --help\` for details.` + ) + process.exit(1) + } + + let bin: string = 'sbt' + if (cli.flags['bin']) { + bin = cli.flags['bin'] as string + } + + let out: string = './socket.pom.xml' + if (cli.flags['out']) { + out = cli.flags['out'] as string + } + if (cli.flags['stdout']) { + out = '-' + } + + // TODO: we can make `-` (accept from stdin) work by storing it into /tmp + if (target === '-') { + new Spinner() + .start('Parsing...') + .error( + `Failure: Currently source code from stdin is not supported. See \`${name} --help\` for details.` + ) + process.exit(1) + } + + const verbose = (cli.flags['verbose'] as boolean) ?? false + + let sbtOpts: Array = [] + if (cli.flags['sbtOpts']) { + sbtOpts = (cli.flags['sbtOpts'] as string) + .split(' ') + .map(s => s.trim()) + .filter(Boolean) + } + + await startConversion(target, bin, out, verbose, sbtOpts) + } +} + +async function startConversion( + target: string, + bin: string, + out: string, + verbose: boolean, + sbtOpts: Array +) { + const rbin = path.resolve(bin) + const rtarget = path.resolve(target) + const rout = out === '-' ? '-' : path.resolve(out) + + if (verbose) { + console.log(`[VERBOSE] - Absolute bin path: \`${rbin}\``) + console.log(`[VERBOSE] - Absolute target path: \`${rtarget}\``) + console.log(`[VERBOSE] - Absolute out path: \`${rout}\``) + } else { + console.log(`- executing: \`${bin}\``) + console.log(`- src dir: \`${target}\``) + console.log(`- dst dir: \`${out}\``) + } + + const spinner = new Spinner() + + spinner.start(`Running sbt from \`${bin}\` on \`${target}\`...`) + + try { + // We must now run sbt, pick the generated xml from the /target folder (the stdout should tell you the location upon success) and store it somewhere else. + // TODO: Not sure what this somewhere else might be tbh. + + const output = await spawn(bin, ['makePom'].concat(sbtOpts), { + cwd: target || '.' + }) + spinner.success() + if (verbose) { + console.group('[VERBOSE] sbt stdout:') + console.log(output) + console.groupEnd() + } + + if (output.stderr) { + spinner.error('There were errors while running sbt') + // (In verbose mode, stderr was printed above, no need to repeat it) + if (!verbose) { + console.group('[VERBOSE] stderr:') + console.error(output.stderr) + console.groupEnd() + } + process.exit(1) + } + + const loc = output.stdout?.match(/Wrote (.*?.pom)\n/)?.[1]?.trim() + if (!loc) { + spinner.error( + 'There were no errors from sbt but could not find the location of resulting .pom file either' + ) + process.exit(1) + } + + // Move the pom file to ...? initial cwd? loc will be an absolute path, or dump to stdout + if (out === '-') { + spinner.start('Result:\n```').success() + console.log(await safeReadFile(loc, 'utf8')) + console.log('```') + spinner.start().success(`OK`) + } else { + if (verbose) { + spinner.start( + `Moving manifest file from \`${loc.replace(/^\/home\/[^/]*?\//, '~/')}\` to \`${out}\`` + ) + } else { + spinner.start('Moving output pom file') + } + // TODO: do we prefer fs-extra? renaming can be gnarly on windows and fs-extra's version is better + await renamep(loc, out) + spinner.success() + spinner.start().success(`OK. File should be available in \`${out}\``) + } + } catch (e) { + spinner.error( + 'There was an unexpected error while running this' + + (verbose ? '' : ' (use --verbose for details)') + ) + if (verbose) { + console.group('[VERBOSE] error:') + console.log(e) + console.groupEnd() + } + process.exit(1) + } +}