diff --git a/cliv2/pkg/core/help_docs_audit_test.go b/cliv2/pkg/core/help_docs_audit_test.go new file mode 100644 index 0000000000..0527bf178d --- /dev/null +++ b/cliv2/pkg/core/help_docs_audit_test.go @@ -0,0 +1,56 @@ +package core + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "testing" + + "github.com/snyk/go-application-framework/pkg/configuration" + "github.com/snyk/go-application-framework/pkg/workflow" + "github.com/stretchr/testify/require" +) + +type registeredCommandForHelpAudit struct { + Command string `json:"command"` + Visible bool `json:"visible"` +} + +func TestPrintRegisteredCommandTreeForHelpAudit(t *testing.T) { + if os.Getenv("SNYK_HELP_AUDIT_PRINT_COMMANDS") != "1" { + t.Skip("set SNYK_HELP_AUDIT_PRINT_COMMANDS=1 to print registered commands") + } + + config := configuration.New() + engine := workflow.NewWorkFlowEngine(config) + initExtensions(engine, config, nil) + require.NoError(t, engine.Init()) + + commands := []registeredCommandForHelpAudit{} + for _, workflowID := range engine.GetWorkflows() { + command := workflow.GetCommandFromWorkflowIdentifier(workflowID) + if command == "" { + continue + } + + entry, ok := engine.GetWorkflow(workflowID) + if !ok { + continue + } + + commands = append(commands, registeredCommandForHelpAudit{ + Command: command, + Visible: entry.IsVisible(), + }) + } + + sort.Slice(commands, func(i, j int) bool { + return commands[i].Command < commands[j].Command + }) + + output, err := json.Marshal(commands) + require.NoError(t, err) + + fmt.Printf("SNYK_HELP_AUDIT_COMMANDS=%s\n", output) +} diff --git a/help/cli-commands/ignore-create.md b/help/cli-commands/ignore-create.md new file mode 100644 index 0000000000..d890e73dbd --- /dev/null +++ b/help/cli-commands/ignore-create.md @@ -0,0 +1,61 @@ +# Ignore create + +## Usage + +`snyk ignore create []` + +## Description + +The `snyk ignore create` command creates an ignore request for a finding in a Snyk Organization. + +This command creates an ignore request through the Snyk ignore workflow. It does not edit the local `.snyk` policy file. To add ignores to a local policy file, use the `snyk ignore` command. + +In interactive mode, the command prompts for missing values. In non-interactive mode, provide the required options. + +## Options + +### `--finding-id=` + +The ID of the finding to ignore. Required when `--interactive=false`. + +### `--ignore-type=` + +The ignore type to create. Required when `--interactive=false`. + +Supported values: + +- `not-vulnerable` +- `wont-fix` +- `temporary-ignore` + +### `--reason=` + +The reason for the ignore. Required when `--interactive=false`. + +### `--expiration=` + +The ignore expiration date. Use `YYYY-MM-DD` format, or `never` for no expiration. Required when `--interactive=false`. + +### `--remote-repo-url=` + +The remote repository URL for the project. The command detects this value automatically when possible. + +### `--interactive=` + +Run the command in interactive mode. + +Default: `true` + +### `--org=` + +Specify the Snyk Organization ID to use for the ignore request. The value must be an Organization UUID. + +## Examples + +Create an ignore request interactively: + +`$ snyk ignore create` + +Create a temporary ignore request without prompts: + +`$ snyk ignore create --finding-id= --ignore-type=temporary-ignore --reason='Temporarily accepted risk' --expiration=2026-07-01 --interactive=false` diff --git a/help/cli-commands/ignore.md b/help/cli-commands/ignore.md index 3174174bd6..b3bfa2dc6a 100644 --- a/help/cli-commands/ignore.md +++ b/help/cli-commands/ignore.md @@ -10,6 +10,10 @@ The `snyk ignore` command modifies the `.snyk` policy file to ignore a specified **Note:** Ignoring issues or vulnerabilities using the `.snyk` file is not supported for Snyk Code. +### Create ignore requests + +[`snyk ignore create`](ignore-create.md); `snyk ignore create --help`: creates an ignore request for a finding. + ### Exclude `snyk ignore [--expiry=] [--reason=] [--policy-path=] [--file-path=] [OPTIONS]` diff --git a/package.json b/package.json index cc8a4d9c65..53f71f6bab 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "clean": "npx rimraf node_modules dist binary-releases test-results *.tgz tsconfig.tsbuildinfo .eslintcache pysrc packages/*/node_modules packages/*/dist packages/*/tsconfig.tsbuildinfo packages/*/*.tgz", "format": "prettier --write '**/*.{js,ts,json,yaml,yml,md}' && npm run lint:js -- --fix", "format:changes": "./scripts/format/prettier-changes.sh", + "help:audit": "node scripts/audit-cli-help-docs.js", "lint": "npm-run-all --serial --continue-on-error lint:*", "lint:js": "eslint --quiet --color --cache '**/*.{js,ts}'", "lint:formatting": "prettier --check '**/*.{js,ts,json,yaml,yml,md}'", diff --git a/scripts/audit-cli-help-docs.js b/scripts/audit-cli-help-docs.js new file mode 100644 index 0000000000..e9fc50f546 --- /dev/null +++ b/scripts/audit-cli-help-docs.js @@ -0,0 +1,167 @@ +#!/usr/bin/env node + +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.resolve(__dirname, '..'); +const helpDir = path.join(repoRoot, 'help', 'cli-commands'); +const failOnMissing = process.argv.includes('--fail-on-missing'); +const includeHidden = process.argv.includes('--include-hidden'); +const includeInternal = process.argv.includes('--include-internal'); + +const internalCommands = new Set([ + 'datatransformation', + 'filter', + 'help', + 'internal cleanup', + 'legacycli', + 'output', + 'reportanalytics', +]); + +function readFile(relativePath) { + return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); +} + +function expectedHelpFile(command) { + return `${command.replace(/\s+/g, '-')}.md`; +} + +function addCommand(commands, command, source, visible = true) { + const normalizedCommand = command.trim().replace(/\s+/g, ' '); + if (!normalizedCommand) { + return; + } + + if (!commands.has(normalizedCommand)) { + commands.set(normalizedCommand, { + command: normalizedCommand, + sources: new Set(), + visible, + }); + } + + const entry = commands.get(normalizedCommand); + entry.sources.add(source); + entry.visible = entry.visible || visible; +} + +function legacyCommands() { + const commands = new Map(); + const commandIndex = readFile('src/cli/commands/index.js'); + + for (const match of commandIndex.matchAll( + /^\s*(?:'([^']+)'|"([^"]+)"|([a-zA-Z][\w-]*)):\s*async/gm, + )) { + addCommand(commands, match[1] || match[2] || match[3], 'legacy command'); + } + + const modes = readFile('src/cli/modes.ts'); + for (const match of modes.matchAll( + /^\s{2}([a-zA-Z][\w-]*):\s*{\s*allowedCommands:\s*\[([^\]]*)\]/gm, + )) { + const mode = match[1]; + const allowedCommands = Array.from( + match[2].matchAll(/'([^']+)'|"([^"]+)"/g), + (allowedCommandMatch) => allowedCommandMatch[1] || allowedCommandMatch[2], + ); + + addCommand(commands, mode, 'legacy mode'); + for (const allowedCommand of allowedCommands) { + addCommand(commands, `${mode} ${allowedCommand}`, 'legacy mode'); + } + } + + return commands; +} + +function nativeCommands() { + const result = spawnSync( + 'go', + [ + 'test', + './pkg/core', + '-run', + 'TestPrintRegisteredCommandTreeForHelpAudit', + '-count=1', + '-v', + ], + { + cwd: path.join(repoRoot, 'cliv2'), + encoding: 'utf8', + env: { + ...process.env, + SNYK_HELP_AUDIT_PRINT_COMMANDS: '1', + }, + }, + ); + + if (result.status !== 0) { + process.stderr.write(result.stdout); + process.stderr.write(result.stderr); + process.exit(result.status || 1); + } + + const match = result.stdout.match(/SNYK_HELP_AUDIT_COMMANDS=(\[.*\])/); + if (!match) { + process.stderr.write(result.stdout); + process.stderr.write(result.stderr); + throw new Error('Could not find registered command audit output.'); + } + + return JSON.parse(match[1]); +} + +function helpFiles() { + return new Set( + fs + .readdirSync(helpDir) + .filter( + (fileName) => fileName.endsWith('.md') && fileName !== 'README.md', + ), + ); +} + +function collectCommands() { + const commands = legacyCommands(); + + for (const nativeCommand of nativeCommands()) { + addCommand( + commands, + nativeCommand.command, + nativeCommand.visible ? 'native workflow' : 'native workflow hidden', + nativeCommand.visible, + ); + } + + return Array.from(commands.values()).map((entry) => ({ + ...entry, + sources: Array.from(entry.sources).sort(), + })); +} + +const docs = helpFiles(); +const missing = collectCommands() + .filter((entry) => includeInternal || !internalCommands.has(entry.command)) + .filter((entry) => includeHidden || entry.visible) + .filter((entry) => !docs.has(expectedHelpFile(entry.command))) + .sort((a, b) => a.command.localeCompare(b.command)); + +if (missing.length === 0) { + console.log('All discovered CLI commands have help docs.'); + process.exit(0); +} + +console.log('CLI commands without help docs:'); +for (const entry of missing) { + console.log( + `- snyk ${entry.command} -> help/cli-commands/${expectedHelpFile( + entry.command, + )} (${entry.sources.join(', ')})`, + ); +} + +if (failOnMissing) { + process.exit(1); +} diff --git a/test/jest/acceptance/help.spec.ts b/test/jest/acceptance/help.spec.ts index 4398ef6dbd..a1df5aff78 100644 --- a/test/jest/acceptance/help.spec.ts +++ b/test/jest/acceptance/help.spec.ts @@ -57,6 +57,23 @@ describe('help', () => { expect(stderr).toBe(''); }); + it('prints specific help info for ignore', async () => { + const { stdout, code, stderr } = await runSnykCLI('ignore --help'); + + expect(stdout).toContain('snyk ignore create'); + expect(code).toBe(0); + expect(stderr).toBe(''); + }); + + it('prints specific help info for ignore create', async () => { + const { stdout, code, stderr } = await runSnykCLI('ignore create --help'); + + expect(stdout).toContain('Ignore create'); + expect(stdout).toContain('snyk ignore create []'); + expect(code).toBe(0); + expect(stderr).toBe(''); + }); + it('prints specific help info when called with flag and equals sign', async () => { const { stdout, code, stderr } = await runSnykCLI('--help=iac');