@cantinasecurity/apex-cli 0.1.0 → 0.1.2
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.
- package/.claude/skills/apex-cli/SKILL.md +8 -0
- package/README.md +36 -5
- package/dist/apex.js +30 -1
- package/dist/commands.js +222 -6
- package/dist/findings.js +132 -0
- package/dist/help.js +31 -4
- package/dist/mcp.js +61 -4
- package/dist/repo-discovery.js +2 -43
- package/dist/scan.js +5 -1
- package/dist/session.js +48 -1
- package/dist/shell.js +42 -5
- package/package.json +1 -1
- package/skills/apex-cli/SKILL.md +7 -0
|
@@ -19,20 +19,28 @@ Recommended workflow:
|
|
|
19
19
|
4. Call `apex-doctor` before starting a scan if workspace binding or source planning might be unclear.
|
|
20
20
|
5. If the directory is not bound to the right workspace, call `apex-workspaces` and `apex-workspace-use`.
|
|
21
21
|
6. Use `apex-scan`, `apex-status`, `apex-scans`, `apex-findings`, and `apex-export-findings` for the scan lifecycle.
|
|
22
|
+
7. Use `apex-finding-comment` and `apex-finding-feedback` when the user wants to leave review notes or valid/invalid feedback on a finding.
|
|
22
23
|
|
|
23
24
|
If the Apex MCP server is not configured, fall back to the local CLI:
|
|
24
25
|
|
|
25
26
|
- Prefer scripted commands over the interactive shell.
|
|
26
27
|
- Use `--non-interactive` and `--no-open` for automation-friendly CLI calls.
|
|
27
28
|
- Use `--json` whenever structured output is helpful.
|
|
29
|
+
- `apex credits` reports standard credits and audit scan entitlements when Bedrock returns both balances.
|
|
28
30
|
- Work from the target repository directory so Apex can resolve `.apex/workspace.json`.
|
|
31
|
+
- `apex scan` scans the current working directory by default; pass `--repo` only when the user asks to scan explicit alternate roots.
|
|
29
32
|
- `apex-doctor` reports whether Apex will use remote materialization or a local snapshot upload for each selected source.
|
|
30
33
|
- Plain local directories and dirty git worktrees can scan through local snapshot uploads without provider access.
|
|
34
|
+
- Audit scans use `--mode audit` in user-facing CLI calls. The legacy `ultra` mode remains accepted as an alias, but audit scans still require provider-backed GitHub or GitLab sources.
|
|
31
35
|
- `apex-workspace-use` accepts a workspace name, prefix, or ID.
|
|
32
36
|
- Use `sourceMode: "remote"` only when the user explicitly wants to forbid local snapshot fallbacks.
|
|
37
|
+
- Finding comments and feedback currently require `CANTINA_AUTH_TOKEN` in the MCP server environment because those writes go through the Cantina web-app routes instead of the Apex CLI bearer-token routes.
|
|
38
|
+
- Invalid finding feedback requires `dismissalReason`; valid feedback can include `suggestedSeverity`, including `extreme`.
|
|
39
|
+
- Finding identifiers such as `KERN2-25` resolve against the selected or latest scan for the current workspace binding. Pass an explicit `scanId` when needed, or use the finding UUID directly.
|
|
33
40
|
|
|
34
41
|
## Examples
|
|
35
42
|
|
|
36
43
|
- Start a scan for the current repository or directory: run `apex-doctor`, bind a workspace if needed, then call `apex-scan`.
|
|
37
44
|
- Check an active scan: call `apex-status`, then `apex-findings` when the scan completes.
|
|
38
45
|
- Export findings for review: call `apex-export-findings` with `format` set to `markdown`, `json`, or `gitlab-sast`.
|
|
46
|
+
- Leave review feedback: call `apex-finding-comment` to add a note, or `apex-finding-feedback` with `status` set to `valid` or `invalid`.
|
package/README.md
CHANGED
|
@@ -98,9 +98,12 @@ If Apex asks for a workspace name, that is the Apex workspace name for the curre
|
|
|
98
98
|
Supported shell commands:
|
|
99
99
|
|
|
100
100
|
- `/credits`
|
|
101
|
-
- `/scan [standard|
|
|
101
|
+
- `/scan [standard|audit]`
|
|
102
102
|
- `/scans`
|
|
103
103
|
- `/findings [scan-id]`
|
|
104
|
+
- `/findings comment <finding-id|finding-identifier> <comment>`
|
|
105
|
+
- `/findings feedback <finding-id|finding-identifier> valid [comment]`
|
|
106
|
+
- `/findings feedback <finding-id|finding-identifier> invalid <false-positive|by-design|not-relevant> [comment]`
|
|
104
107
|
- `/export [scan-id]`
|
|
105
108
|
- `/workspaces`
|
|
106
109
|
- `/cancel-scan [scan-id]`
|
|
@@ -128,6 +131,8 @@ Supported shell commands:
|
|
|
128
131
|
- `apex scan`
|
|
129
132
|
- `apex scans`
|
|
130
133
|
- `apex findings [--scan <scan-id>]`
|
|
134
|
+
- `apex findings comment <finding-id|finding-identifier> --content <markdown> [--parent-comment <comment-id>] [--scan <scan-id>]`
|
|
135
|
+
- `apex findings feedback <finding-id|finding-identifier> <valid|invalid> [comment] [--comment <markdown>] [--scan <scan-id>] [--suggested-severity extreme|critical|high|medium|low|informational] [--dismissal-reason false-positive|by-design|not-relevant]`
|
|
131
136
|
- `apex export findings [--scan <scan-id>] [--format markdown|json|gitlab-sast] [--output <path>]`
|
|
132
137
|
- `apex workspaces`
|
|
133
138
|
- `apex workspace`
|
|
@@ -147,9 +152,33 @@ Helpful workspace flags:
|
|
|
147
152
|
- `--company <id-or-handle>` to choose the Apex company when more than one is available
|
|
148
153
|
- `--workspace-name <name>` to set the Apex workspace name for this directory
|
|
149
154
|
|
|
155
|
+
`apex credits` shows standard scan credits plus audit scan entitlements when the server returns them.
|
|
156
|
+
|
|
157
|
+
## Finding Review Feedback
|
|
158
|
+
|
|
159
|
+
Finding review collaboration now has explicit write commands:
|
|
160
|
+
|
|
161
|
+
- `apex findings comment <finding-id|finding-identifier> --content "Needs auth check"`
|
|
162
|
+
- `apex findings feedback <finding-id|finding-identifier> valid --comment "Reproduced on latest build"`
|
|
163
|
+
- `apex findings feedback <finding-id|finding-identifier> invalid --dismissal-reason false-positive --comment "This path is unreachable"`
|
|
164
|
+
- `/findings comment <finding-ref> <comment>`
|
|
165
|
+
- `/findings feedback <finding-ref> valid [comment]`
|
|
166
|
+
- `/findings feedback <finding-ref> invalid <false-positive|by-design|not-relevant> [comment]`
|
|
167
|
+
|
|
168
|
+
Identifiers such as `KERN2-25` are resolved against the selected or latest scan for the current workspace binding. Pass `--scan <scan-id>` when you need a specific scan, or pass the finding UUID directly to skip workspace-based resolution.
|
|
169
|
+
|
|
170
|
+
Finding comments and valid/invalid feedback currently require a Cantina web session token in `CANTINA_AUTH_TOKEN`. Set it to the value of your logged-in `auth_token` cookie before using the write commands or the corresponding MCP tools.
|
|
171
|
+
|
|
172
|
+
Invalid feedback requires a dismissal reason. Valid feedback can include `--suggested-severity extreme|critical|high|medium|low|informational`.
|
|
173
|
+
|
|
174
|
+
This is intentionally documented as a separate auth requirement because the current Apex read APIs and finding review write APIs do not accept the same credentials:
|
|
175
|
+
|
|
176
|
+
- read operations such as `apex findings`, `apex export findings`, and `apex-findings` use the Apex CLI device-login bearer token
|
|
177
|
+
- finding comments and feedback currently go through the Cantina web-app routes and require `CANTINA_AUTH_TOKEN`
|
|
178
|
+
|
|
150
179
|
## Local Source Scans
|
|
151
180
|
|
|
152
|
-
`apex scan` now works against any local source root you point it at:
|
|
181
|
+
`apex scan` now works against any local source root you point it at. By default, that source root is the current working directory:
|
|
153
182
|
|
|
154
183
|
- clean GitHub or GitLab checkouts can stay on the remote-materialization path
|
|
155
184
|
- dirty git worktrees fall back to a local snapshot upload by default
|
|
@@ -157,12 +186,13 @@ Helpful workspace flags:
|
|
|
157
186
|
|
|
158
187
|
Useful flags:
|
|
159
188
|
|
|
160
|
-
- `--repo <path>` to scan one or more explicit local roots
|
|
189
|
+
- `--repo <path>` to scan one or more explicit local roots instead of the current directory
|
|
161
190
|
- `--source-mode auto|remote|local` to control remote-first fallback behavior
|
|
191
|
+
- `--mode standard|audit` to choose the scan mode
|
|
162
192
|
|
|
163
193
|
`auto` is the default. `remote` requires Apex to materialize from a remote repository. `local` forces a local snapshot upload even when a clean remote path is available.
|
|
164
194
|
|
|
165
|
-
|
|
195
|
+
Audit scans still require provider-backed GitHub or GitLab repositories that Apex can materialize remotely without a local snapshot fallback. `ultra` remains accepted as a backwards-compatible alias for the audit scan mode.
|
|
166
196
|
|
|
167
197
|
## LLM / MCP Usage
|
|
168
198
|
|
|
@@ -234,6 +264,7 @@ The MCP server exposes Apex-specific tools for:
|
|
|
234
264
|
- doctor, credits, and provider connection URLs
|
|
235
265
|
- workspace inspection and workspace binding
|
|
236
266
|
- scan start, status, cancellation, findings, and findings export
|
|
267
|
+
- finding comments and valid/invalid feedback with `apex-finding-comment` and `apex-finding-feedback`
|
|
237
268
|
|
|
238
269
|
For repository-scoped operations, pass `cwd` explicitly so the server can resolve the right `.apex/workspace.json` binding and repository roots.
|
|
239
270
|
|
|
@@ -243,7 +274,7 @@ For Claude Code, the packaged project skill can be installed into the current re
|
|
|
243
274
|
|
|
244
275
|
## Development Notes
|
|
245
276
|
|
|
246
|
-
The CLI uses the Apex `/api/cli/v2/**` local-source routes for scan planning and snapshot uploads, with legacy `/api/cli/v1/**` routes still used for provider-backed flows such as
|
|
277
|
+
The CLI uses the Apex `/api/cli/v2/**` local-source routes for scan planning and snapshot uploads, with legacy `/api/cli/v1/**` routes still used for provider-backed flows such as audit scans. Local state is stored under:
|
|
247
278
|
|
|
248
279
|
- `~/.config/apex/config.json`
|
|
249
280
|
- `~/.config/apex/credentials.json`
|
package/dist/apex.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ApexApiClient, formatApiError } from "./api-client.js";
|
|
2
2
|
import { parseArgs } from "./args.js";
|
|
3
|
-
import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindings, commandLogin, commandLogout, commandScan, commandScans, commandSetup, commandStatus, commandUpdate, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
|
|
3
|
+
import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingFeedback, commandFindings, commandLogin, commandLogout, commandScan, commandScans, commandSetup, commandStatus, commandUpdate, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
|
|
4
4
|
import { CLI_HELP_TEXT } from "./help.js";
|
|
5
5
|
import { runMcpServer } from "./mcp.js";
|
|
6
6
|
import { runInteractiveShell } from "./shell.js";
|
|
@@ -63,6 +63,32 @@ async function main() {
|
|
|
63
63
|
await commandWorkspace(cwd, parsed.flags);
|
|
64
64
|
return;
|
|
65
65
|
case "findings":
|
|
66
|
+
if (parsed.subcommand === "comment") {
|
|
67
|
+
const findingRef = parsed.args[0] ?? "";
|
|
68
|
+
const content = parsed.args.slice(1).join(" ").trim() || String(parsed.flags.content ?? "");
|
|
69
|
+
await commandFindingComment(client, cwd, parsed.flags, findingRef, content, typeof parsed.flags["parent-comment"] === "string"
|
|
70
|
+
? parsed.flags["parent-comment"]
|
|
71
|
+
: null);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (parsed.subcommand === "feedback") {
|
|
75
|
+
const findingRef = parsed.args[0] ?? "";
|
|
76
|
+
const feedbackType = parsed.args[1] ??
|
|
77
|
+
(typeof parsed.flags.status === "string"
|
|
78
|
+
? parsed.flags.status
|
|
79
|
+
: undefined);
|
|
80
|
+
if (feedbackType !== "valid" && feedbackType !== "invalid") {
|
|
81
|
+
throw new Error("Usage: apex findings feedback <finding-id|finding-identifier> <valid|invalid> [--dismissal-reason false-positive|by-design|not-relevant]");
|
|
82
|
+
}
|
|
83
|
+
await commandFindingFeedback(client, cwd, parsed.flags, {
|
|
84
|
+
requestedFindingRef: findingRef,
|
|
85
|
+
feedbackType,
|
|
86
|
+
comment: parsed.args.slice(2).join(" ").trim() || getFlagValue(parsed.flags.comment),
|
|
87
|
+
suggestedSeverity: getFlagValue(parsed.flags["suggested-severity"]),
|
|
88
|
+
dismissalReason: getFlagValue(parsed.flags["dismissal-reason"]),
|
|
89
|
+
});
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
66
92
|
await commandFindings(client, cwd, parsed.flags);
|
|
67
93
|
return;
|
|
68
94
|
case "export":
|
|
@@ -84,6 +110,9 @@ async function main() {
|
|
|
84
110
|
throw new Error(`Unknown command: ${parsed.command}`);
|
|
85
111
|
}
|
|
86
112
|
}
|
|
113
|
+
function getFlagValue(value) {
|
|
114
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
115
|
+
}
|
|
87
116
|
main().catch((error) => {
|
|
88
117
|
if (error instanceof Error && error.reported) {
|
|
89
118
|
process.exitCode = 1;
|
package/dist/commands.js
CHANGED
|
@@ -3,7 +3,7 @@ import { logout } from "./auth.js";
|
|
|
3
3
|
import { ApiError } from "./api-client.js";
|
|
4
4
|
import { openInBrowser } from "./browser.js";
|
|
5
5
|
import { loadConfig, saveConfig } from "./config.js";
|
|
6
|
-
import { fetchScanExport, fetchScanFindings, resolveScanSelection } from "./findings.js";
|
|
6
|
+
import { createFindingComment, fetchScanExport, fetchScanFindings, isFindingUuid, normalizeFindingRef, requireCantinaAuthToken, resolveFindingSelection, resolveScanSelection, submitFindingFeedback, } from "./findings.js";
|
|
7
7
|
import { prepareExplicitScanSources, supportsLegacyRemoteFlow, } from "./local-source-scan.js";
|
|
8
8
|
import { logLine, printJson, withLoadingIndicator } from "./output.js";
|
|
9
9
|
import { confirm } from "./prompt.js";
|
|
@@ -18,6 +18,19 @@ import path from "node:path";
|
|
|
18
18
|
const WORKSPACE_USE_PATTERN = "<workspace-name|workspace-prefix|workspace-id>";
|
|
19
19
|
const WORKSPACE_BINDING_GUIDANCE = "Run `apex workspaces` to list workspace names, prefixes, and IDs, then bind one with `apex workspace use \"<workspace name>\"`, or run `apex scan` to create or resolve one automatically.";
|
|
20
20
|
const WORKSPACE_SELECTION_GUIDANCE = "Run `apex workspaces` to list available workspace names, prefixes, and IDs, then retry `apex workspace use \"<workspace name>\"`.";
|
|
21
|
+
const FINDING_FEEDBACK_SEVERITIES = [
|
|
22
|
+
"extreme",
|
|
23
|
+
"critical",
|
|
24
|
+
"high",
|
|
25
|
+
"medium",
|
|
26
|
+
"low",
|
|
27
|
+
"informational",
|
|
28
|
+
];
|
|
29
|
+
const FINDING_DISMISSAL_REASONS = [
|
|
30
|
+
"false-positive",
|
|
31
|
+
"by-design",
|
|
32
|
+
"not-relevant",
|
|
33
|
+
];
|
|
21
34
|
async function saveCompanyDefault(companyId) {
|
|
22
35
|
const config = await loadConfig();
|
|
23
36
|
if (config.defaultCompanyId !== companyId) {
|
|
@@ -36,6 +49,64 @@ function getSuggestedWorkspaceRef(workspace) {
|
|
|
36
49
|
function formatWorkspaceUseCommand(workspace) {
|
|
37
50
|
return `apex workspace use ${formatCommandArgument(getSuggestedWorkspaceRef(workspace))}`;
|
|
38
51
|
}
|
|
52
|
+
function getOptionalCount(value) {
|
|
53
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
54
|
+
? Math.max(0, Math.floor(value))
|
|
55
|
+
: null;
|
|
56
|
+
}
|
|
57
|
+
function formatAuditScanBalance(scanBalance) {
|
|
58
|
+
const purchased = getOptionalCount(scanBalance.auditPurchased);
|
|
59
|
+
const used = getOptionalCount(scanBalance.auditUsed);
|
|
60
|
+
const remaining = getOptionalCount(scanBalance.auditRemaining);
|
|
61
|
+
const available = getOptionalCount(scanBalance.auditAvailable) ??
|
|
62
|
+
remaining ??
|
|
63
|
+
(purchased !== null && used !== null ? Math.max(0, purchased - used) : null);
|
|
64
|
+
if (purchased === null && used === null && available === null) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const detailParts = [];
|
|
68
|
+
if (purchased !== null) {
|
|
69
|
+
detailParts.push(`${purchased} purchased`);
|
|
70
|
+
}
|
|
71
|
+
if (used !== null) {
|
|
72
|
+
detailParts.push(`${used} used`);
|
|
73
|
+
}
|
|
74
|
+
if (available !== null) {
|
|
75
|
+
const detailSuffix = detailParts.length ? ` (${detailParts.join(", ")})` : "";
|
|
76
|
+
return `Audit scans: ${available} available${detailSuffix}`;
|
|
77
|
+
}
|
|
78
|
+
return `Audit scans: ${detailParts.join(", ")}`;
|
|
79
|
+
}
|
|
80
|
+
function normalizeRequestedScanMode(value) {
|
|
81
|
+
return value === "ultra" || value === "audit" ? "ultra" : "standard";
|
|
82
|
+
}
|
|
83
|
+
function normalizeFindingRefInput(value) {
|
|
84
|
+
const trimmed = normalizeFindingRef(value);
|
|
85
|
+
if (!trimmed) {
|
|
86
|
+
throw new Error("A finding ID or finding identifier is required.");
|
|
87
|
+
}
|
|
88
|
+
return trimmed;
|
|
89
|
+
}
|
|
90
|
+
function normalizeFeedbackSeverity(value) {
|
|
91
|
+
const normalized = value?.trim() ?? "";
|
|
92
|
+
if (!normalized) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (FINDING_FEEDBACK_SEVERITIES.includes(normalized)) {
|
|
96
|
+
return normalized;
|
|
97
|
+
}
|
|
98
|
+
throw new Error(`Suggested severity must be one of: ${FINDING_FEEDBACK_SEVERITIES.join(", ")}.`);
|
|
99
|
+
}
|
|
100
|
+
function normalizeDismissalReason(value) {
|
|
101
|
+
const normalized = value?.trim() ?? "";
|
|
102
|
+
if (!normalized) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
if (FINDING_DISMISSAL_REASONS.includes(normalized)) {
|
|
106
|
+
return normalized;
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`Dismissal reason must be one of: ${FINDING_DISMISSAL_REASONS.join(", ")}.`);
|
|
109
|
+
}
|
|
39
110
|
async function requireWorkspaceBinding(cwd) {
|
|
40
111
|
const binding = await loadWorkspaceBinding(cwd);
|
|
41
112
|
if (binding) {
|
|
@@ -43,6 +114,27 @@ async function requireWorkspaceBinding(cwd) {
|
|
|
43
114
|
}
|
|
44
115
|
throw new Error(`This directory is not bound to an Apex workspace yet. ${WORKSPACE_BINDING_GUIDANCE}`);
|
|
45
116
|
}
|
|
117
|
+
function requireLoadedWorkspaceBinding(binding) {
|
|
118
|
+
if (binding) {
|
|
119
|
+
return binding;
|
|
120
|
+
}
|
|
121
|
+
throw new Error(`This directory is not bound to an Apex workspace yet. ${WORKSPACE_BINDING_GUIDANCE}`);
|
|
122
|
+
}
|
|
123
|
+
async function maybePersistFindingSelectionBinding(params) {
|
|
124
|
+
if (!params.binding || !params.scan) {
|
|
125
|
+
return params.binding;
|
|
126
|
+
}
|
|
127
|
+
const nextBinding = {
|
|
128
|
+
...params.binding,
|
|
129
|
+
lastScanId: getScanDisplayId(params.scan),
|
|
130
|
+
lastScanUrl: params.response?.scan.scanUrl ??
|
|
131
|
+
params.scan.scanUrl ??
|
|
132
|
+
params.binding.lastScanUrl ??
|
|
133
|
+
null,
|
|
134
|
+
};
|
|
135
|
+
await saveWorkspaceBinding(params.cwd, nextBinding);
|
|
136
|
+
return nextBinding;
|
|
137
|
+
}
|
|
46
138
|
function canPromptForConfirmation(flags) {
|
|
47
139
|
return !isNonInteractive(flags) && process.stdin.isTTY && process.stdout.isTTY;
|
|
48
140
|
}
|
|
@@ -144,7 +236,15 @@ export async function commandCredits(client, cwd, flags) {
|
|
|
144
236
|
return payload;
|
|
145
237
|
}
|
|
146
238
|
logLine(`Company: ${payload.company.handle ?? payload.company.id}`, flags);
|
|
147
|
-
logLine(`
|
|
239
|
+
logLine(`Standard credits: ${payload.scanBalance.remaining} remaining (${payload.scanBalance.purchased} purchased, ${payload.scanBalance.used} used)`, flags);
|
|
240
|
+
const auditBalance = formatAuditScanBalance(payload.scanBalance);
|
|
241
|
+
if (auditBalance) {
|
|
242
|
+
logLine(auditBalance, flags);
|
|
243
|
+
}
|
|
244
|
+
const redeemableAuditScans = getOptionalCount(payload.scanBalance.auditRedeemableFromCredits);
|
|
245
|
+
if (redeemableAuditScans && redeemableAuditScans > 0) {
|
|
246
|
+
logLine(`Redeemable audit scans from standard credits: ${redeemableAuditScans}`, flags);
|
|
247
|
+
}
|
|
148
248
|
logLine(`Scans enabled: ${payload.scansEnabled ? "yes" : "no"}`, flags);
|
|
149
249
|
return payload;
|
|
150
250
|
}
|
|
@@ -244,7 +344,7 @@ export async function commandScan(client, cwd, flags) {
|
|
|
244
344
|
const me = await ensureAuthenticated(client, flags);
|
|
245
345
|
const selection = await selectWorkspaceTarget(cwd, me, flags);
|
|
246
346
|
const result = await withLoadingIndicator("Resolving workspace and scan plan...", flags, () => resolveWorkspaceSelection(client, selection));
|
|
247
|
-
const requestedMode = getFlagString(flags, "mode")
|
|
347
|
+
const requestedMode = normalizeRequestedScanMode(getFlagString(flags, "mode"));
|
|
248
348
|
if (!result.resolve.workspaceId) {
|
|
249
349
|
throw new Error("Workspace resolution did not return a workspaceId.");
|
|
250
350
|
}
|
|
@@ -255,14 +355,14 @@ export async function commandScan(client, cwd, flags) {
|
|
|
255
355
|
let scan;
|
|
256
356
|
if (requestedMode === "ultra") {
|
|
257
357
|
if (!supportsLegacyRemoteFlow(result.resolve.plannedSources)) {
|
|
258
|
-
throw new Error("
|
|
358
|
+
throw new Error("Audit scans currently require provider-backed GitHub or GitLab repositories without local snapshot fallbacks.");
|
|
259
359
|
}
|
|
260
|
-
const legacyResolve = await withLoadingIndicator("Preparing
|
|
360
|
+
const legacyResolve = await withLoadingIndicator("Preparing audit scan...", flags, () => resolveWorkspaceSelectionLegacy(client, selection, resolvedWorkspaceId));
|
|
261
361
|
if (!legacyResolve.workspaceId) {
|
|
262
362
|
throw new Error("Workspace resolution did not return a workspaceId.");
|
|
263
363
|
}
|
|
264
364
|
workspaceId = legacyResolve.workspaceId;
|
|
265
|
-
scan = await withLoadingIndicator("Starting
|
|
365
|
+
scan = await withLoadingIndicator("Starting audit scan...", flags, () => client.request(`/api/cli/v1/local-workspaces/${encodeURIComponent(workspaceId)}/scan`, {
|
|
266
366
|
method: "POST",
|
|
267
367
|
json: {
|
|
268
368
|
mode: requestedMode,
|
|
@@ -560,6 +660,122 @@ export async function commandFindings(client, cwd, flags) {
|
|
|
560
660
|
}
|
|
561
661
|
return payload;
|
|
562
662
|
}
|
|
663
|
+
export async function commandFindingComment(client, cwd, flags, requestedFindingRef, content, parentCommentId) {
|
|
664
|
+
const findingRef = normalizeFindingRefInput(requestedFindingRef);
|
|
665
|
+
const trimmedContent = content.trim();
|
|
666
|
+
if (!trimmedContent) {
|
|
667
|
+
throw new Error("Usage: apex findings comment <finding-id|finding-identifier> --content \"<markdown>\"");
|
|
668
|
+
}
|
|
669
|
+
requireCantinaAuthToken();
|
|
670
|
+
const currentBinding = await loadWorkspaceBinding(cwd);
|
|
671
|
+
const needsWorkspaceResolution = !isFindingUuid(findingRef);
|
|
672
|
+
const binding = needsWorkspaceResolution
|
|
673
|
+
? requireLoadedWorkspaceBinding(currentBinding)
|
|
674
|
+
: currentBinding;
|
|
675
|
+
if (needsWorkspaceResolution) {
|
|
676
|
+
await ensureAuthenticated(client, flags);
|
|
677
|
+
}
|
|
678
|
+
const selection = await withLoadingIndicator("Resolving finding...", flags, () => resolveFindingSelection({
|
|
679
|
+
client,
|
|
680
|
+
binding,
|
|
681
|
+
requestedFindingRef: findingRef,
|
|
682
|
+
requestedScanId: getFlagString(flags, "scan"),
|
|
683
|
+
}));
|
|
684
|
+
const response = await withLoadingIndicator("Adding finding comment...", flags, () => createFindingComment(client, selection.findingId, {
|
|
685
|
+
content: trimmedContent,
|
|
686
|
+
parentCommentId: parentCommentId?.trim() || null,
|
|
687
|
+
}));
|
|
688
|
+
const nextBinding = await maybePersistFindingSelectionBinding({
|
|
689
|
+
cwd,
|
|
690
|
+
binding,
|
|
691
|
+
scan: selection.scan,
|
|
692
|
+
response: selection.response,
|
|
693
|
+
});
|
|
694
|
+
const payload = {
|
|
695
|
+
binding: nextBinding,
|
|
696
|
+
scan: selection.scan,
|
|
697
|
+
findingId: selection.findingId,
|
|
698
|
+
findingRef: selection.findingRef,
|
|
699
|
+
finding: selection.finding,
|
|
700
|
+
comment: response.comment,
|
|
701
|
+
};
|
|
702
|
+
if (isJsonMode(flags)) {
|
|
703
|
+
printJson(payload);
|
|
704
|
+
return payload;
|
|
705
|
+
}
|
|
706
|
+
logLine(`Finding: ${selection.finding?.findingIdentifier ?? selection.findingId}`, flags);
|
|
707
|
+
logLine(`Comment: added (${response.comment.id})`, flags);
|
|
708
|
+
logLine(`Author: ${response.comment.user.name}`, flags);
|
|
709
|
+
logLine(`Created: ${response.comment.createdAt}`, flags);
|
|
710
|
+
return payload;
|
|
711
|
+
}
|
|
712
|
+
export async function commandFindingFeedback(client, cwd, flags, params) {
|
|
713
|
+
const findingRef = normalizeFindingRefInput(params.requestedFindingRef);
|
|
714
|
+
const suggestedSeverity = normalizeFeedbackSeverity(params.suggestedSeverity);
|
|
715
|
+
const dismissalReason = normalizeDismissalReason(params.dismissalReason);
|
|
716
|
+
if (params.feedbackType === "valid" &&
|
|
717
|
+
dismissalReason) {
|
|
718
|
+
throw new Error("Dismissal reasons are only valid for invalid feedback.");
|
|
719
|
+
}
|
|
720
|
+
if (params.feedbackType === "invalid" &&
|
|
721
|
+
suggestedSeverity) {
|
|
722
|
+
throw new Error("Suggested severity is only valid for valid feedback.");
|
|
723
|
+
}
|
|
724
|
+
if (params.feedbackType === "invalid" && !dismissalReason) {
|
|
725
|
+
throw new Error("Invalid feedback requires a dismissal reason.");
|
|
726
|
+
}
|
|
727
|
+
requireCantinaAuthToken();
|
|
728
|
+
const currentBinding = await loadWorkspaceBinding(cwd);
|
|
729
|
+
const needsWorkspaceResolution = !isFindingUuid(findingRef);
|
|
730
|
+
const binding = needsWorkspaceResolution
|
|
731
|
+
? requireLoadedWorkspaceBinding(currentBinding)
|
|
732
|
+
: currentBinding;
|
|
733
|
+
if (needsWorkspaceResolution) {
|
|
734
|
+
await ensureAuthenticated(client, flags);
|
|
735
|
+
}
|
|
736
|
+
const selection = await withLoadingIndicator("Resolving finding...", flags, () => resolveFindingSelection({
|
|
737
|
+
client,
|
|
738
|
+
binding,
|
|
739
|
+
requestedFindingRef: findingRef,
|
|
740
|
+
requestedScanId: getFlagString(flags, "scan"),
|
|
741
|
+
}));
|
|
742
|
+
const response = await withLoadingIndicator("Submitting finding feedback...", flags, () => submitFindingFeedback(client, selection.findingId, {
|
|
743
|
+
feedbackType: params.feedbackType,
|
|
744
|
+
comment: params.comment?.trim() || null,
|
|
745
|
+
suggestedSeverity,
|
|
746
|
+
dismissalReason,
|
|
747
|
+
}));
|
|
748
|
+
const nextBinding = await maybePersistFindingSelectionBinding({
|
|
749
|
+
cwd,
|
|
750
|
+
binding,
|
|
751
|
+
scan: selection.scan,
|
|
752
|
+
response: selection.response,
|
|
753
|
+
});
|
|
754
|
+
const payload = {
|
|
755
|
+
binding: nextBinding,
|
|
756
|
+
scan: selection.scan,
|
|
757
|
+
findingId: selection.findingId,
|
|
758
|
+
findingRef: selection.findingRef,
|
|
759
|
+
finding: selection.finding,
|
|
760
|
+
feedback: response.feedback,
|
|
761
|
+
};
|
|
762
|
+
if (isJsonMode(flags)) {
|
|
763
|
+
printJson(payload);
|
|
764
|
+
return payload;
|
|
765
|
+
}
|
|
766
|
+
logLine(`Finding: ${selection.finding?.findingIdentifier ?? selection.findingId}`, flags);
|
|
767
|
+
logLine(`Feedback: ${response.feedback.feedbackType ?? params.feedbackType}`, flags);
|
|
768
|
+
if (response.feedback.suggestedSeverity) {
|
|
769
|
+
logLine(`Suggested severity: ${response.feedback.suggestedSeverity}`, flags);
|
|
770
|
+
}
|
|
771
|
+
if (response.feedback.dismissalReason) {
|
|
772
|
+
logLine(`Dismissal reason: ${response.feedback.dismissalReason}`, flags);
|
|
773
|
+
}
|
|
774
|
+
if (response.feedback.comment) {
|
|
775
|
+
logLine("Comment: saved", flags);
|
|
776
|
+
}
|
|
777
|
+
return payload;
|
|
778
|
+
}
|
|
563
779
|
export async function commandExportFindings(client, cwd, flags) {
|
|
564
780
|
await ensureAuthenticated(client, flags);
|
|
565
781
|
const binding = await requireWorkspaceBinding(cwd);
|
package/dist/findings.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { fetchWorkspaceScans, getScanDisplayId, matchesScanId, } from "./scan.js";
|
|
2
|
+
import { ApiError } from "./api-client.js";
|
|
3
|
+
const FINDING_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
4
|
+
const CANTINA_AUTH_TOKEN_ENV = "CANTINA_AUTH_TOKEN";
|
|
5
|
+
const CANTINA_AUTH_TOKEN_ERROR = "Finding comments and feedback currently require `CANTINA_AUTH_TOKEN` from a logged-in Cantina/Apex browser session. Export `CANTINA_AUTH_TOKEN` and retry.";
|
|
2
6
|
function parsePositiveInt(value) {
|
|
3
7
|
if (!value)
|
|
4
8
|
return null;
|
|
@@ -8,6 +12,77 @@ function parsePositiveInt(value) {
|
|
|
8
12
|
}
|
|
9
13
|
return Math.floor(parsed);
|
|
10
14
|
}
|
|
15
|
+
async function parseWebRouteResponse(response) {
|
|
16
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
17
|
+
if (contentType.includes("application/json")) {
|
|
18
|
+
return response.json().catch(() => null);
|
|
19
|
+
}
|
|
20
|
+
return response.text().catch(() => "");
|
|
21
|
+
}
|
|
22
|
+
function normalizeCantinaAuthToken(value) {
|
|
23
|
+
if (!value) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
let token = value.trim();
|
|
27
|
+
if (!token) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
if (token.toLowerCase().startsWith("auth_token=")) {
|
|
31
|
+
token = token.slice("auth_token=".length).trim();
|
|
32
|
+
}
|
|
33
|
+
return token.length > 0 ? token : null;
|
|
34
|
+
}
|
|
35
|
+
export function requireCantinaAuthToken() {
|
|
36
|
+
const token = normalizeCantinaAuthToken(process.env[CANTINA_AUTH_TOKEN_ENV]);
|
|
37
|
+
if (!token) {
|
|
38
|
+
throw new Error(CANTINA_AUTH_TOKEN_ERROR);
|
|
39
|
+
}
|
|
40
|
+
return token;
|
|
41
|
+
}
|
|
42
|
+
export function isFindingUuid(value) {
|
|
43
|
+
return FINDING_UUID_PATTERN.test(value.trim());
|
|
44
|
+
}
|
|
45
|
+
export function normalizeFindingRef(value) {
|
|
46
|
+
const trimmed = value.trim();
|
|
47
|
+
if (!trimmed) {
|
|
48
|
+
return trimmed;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const url = new URL(trimmed);
|
|
52
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
53
|
+
return segments[segments.length - 1] ?? trimmed;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return trimmed;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function matchesFindingRef(finding, findingRef) {
|
|
60
|
+
const normalizedRef = findingRef.trim().toLowerCase();
|
|
61
|
+
return [finding.id, finding.findingIdentifier]
|
|
62
|
+
.filter((value) => typeof value === "string")
|
|
63
|
+
.some((value) => value.trim().toLowerCase() === normalizedRef);
|
|
64
|
+
}
|
|
65
|
+
async function requestFindingAppRoute(client, path, options = {}) {
|
|
66
|
+
const token = requireCantinaAuthToken();
|
|
67
|
+
const baseUrl = await client.getBaseUrl();
|
|
68
|
+
const headers = new Headers(options.headers ?? {});
|
|
69
|
+
// These routes still authenticate through the web app cookie, not Apex CLI bearer auth.
|
|
70
|
+
headers.set("Cookie", `auth_token=${token}`);
|
|
71
|
+
headers.set("Accept", "application/json");
|
|
72
|
+
if (options.json !== undefined) {
|
|
73
|
+
headers.set("Content-Type", "application/json");
|
|
74
|
+
}
|
|
75
|
+
const response = await fetch(new URL(path, baseUrl), {
|
|
76
|
+
...options,
|
|
77
|
+
headers,
|
|
78
|
+
body: options.json !== undefined ? JSON.stringify(options.json) : options.body,
|
|
79
|
+
});
|
|
80
|
+
const body = await parseWebRouteResponse(response);
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
throw new ApiError(`Request failed with ${response.status}`, response.status, body);
|
|
83
|
+
}
|
|
84
|
+
return body;
|
|
85
|
+
}
|
|
11
86
|
export async function fetchScanFindings(client, scanId, limit) {
|
|
12
87
|
const params = new URLSearchParams();
|
|
13
88
|
const parsedLimit = parsePositiveInt(limit);
|
|
@@ -48,3 +123,60 @@ export async function resolveScanSelection(params) {
|
|
|
48
123
|
export function describeScanSelection(scan) {
|
|
49
124
|
return scan.scanUrl ?? getScanDisplayId(scan);
|
|
50
125
|
}
|
|
126
|
+
export async function resolveFindingSelection(params) {
|
|
127
|
+
const findingRef = normalizeFindingRef(params.requestedFindingRef);
|
|
128
|
+
if (!findingRef) {
|
|
129
|
+
throw new Error("A finding ID or finding identifier is required.");
|
|
130
|
+
}
|
|
131
|
+
if (isFindingUuid(findingRef)) {
|
|
132
|
+
return {
|
|
133
|
+
findingId: findingRef,
|
|
134
|
+
findingRef,
|
|
135
|
+
finding: null,
|
|
136
|
+
scan: null,
|
|
137
|
+
response: null,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (!params.binding) {
|
|
141
|
+
throw new Error("A workspace binding is required to resolve finding identifiers. Bind this directory first or use the finding UUID directly.");
|
|
142
|
+
}
|
|
143
|
+
const scan = await resolveScanSelection({
|
|
144
|
+
client: params.client,
|
|
145
|
+
binding: params.binding,
|
|
146
|
+
requestedScanId: params.requestedScanId,
|
|
147
|
+
});
|
|
148
|
+
const response = await fetchScanFindings(params.client, scan.scanId, "1000");
|
|
149
|
+
const finding = response.findings.find((item) => matchesFindingRef(item, findingRef)) ?? null;
|
|
150
|
+
if (!finding) {
|
|
151
|
+
throw new Error(`Could not find finding "${findingRef}" in ${describeScanSelection(scan)}. Re-run with --scan to target a different scan, or use the finding UUID directly.`);
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
findingId: finding.id,
|
|
155
|
+
findingRef,
|
|
156
|
+
finding,
|
|
157
|
+
scan,
|
|
158
|
+
response,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
export async function createFindingComment(client, findingId, input) {
|
|
162
|
+
return requestFindingAppRoute(client, `/api/findings/${encodeURIComponent(findingId)}/comments`, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
json: {
|
|
165
|
+
content: input.content,
|
|
166
|
+
...(input.parentCommentId ? { parentId: input.parentCommentId } : {}),
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
export async function submitFindingFeedback(client, findingId, input) {
|
|
171
|
+
return requestFindingAppRoute(client, `/api/findings/${encodeURIComponent(findingId)}/feedback`, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
json: {
|
|
174
|
+
status: input.feedbackType,
|
|
175
|
+
...(input.comment ? { comment: input.comment } : {}),
|
|
176
|
+
...(input.suggestedSeverity
|
|
177
|
+
? { suggestedSeverity: input.suggestedSeverity }
|
|
178
|
+
: {}),
|
|
179
|
+
...(input.dismissalReason ? { dismissalReason: input.dismissalReason } : {}),
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
package/dist/help.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
export const CLI_HELP_TEXT = `Usage:
|
|
2
2
|
apex Open the interactive Apex shell
|
|
3
|
-
apex credits Show scan credits for the active company
|
|
3
|
+
apex credits Show scan credits and audit scan entitlements for the active company
|
|
4
4
|
apex scan Create or resolve a workspace for this directory and start a scan
|
|
5
5
|
apex scans List scans for the current workspace binding
|
|
6
6
|
apex findings List findings for the latest or selected scan
|
|
7
|
+
apex findings comment <finding-id|finding-identifier>
|
|
8
|
+
Add a comment or note to a finding
|
|
9
|
+
apex findings feedback <finding-id|finding-identifier> <valid|invalid>
|
|
10
|
+
Leave feedback; invalid requires --dismissal-reason
|
|
7
11
|
apex export findings Export findings for the latest or selected scan
|
|
8
12
|
apex workspaces List accessible workspaces for the active company
|
|
9
13
|
apex workspace Show the workspace currently bound to this directory
|
|
@@ -24,11 +28,19 @@ Flags:
|
|
|
24
28
|
--company <id-or-handle> Choose the Apex company to use
|
|
25
29
|
--workspace-name <name> Set the Apex workspace name for this directory
|
|
26
30
|
--scan <scan-id> Select a specific scan for findings or export
|
|
31
|
+
--status valid|invalid Set the finding feedback status explicitly
|
|
32
|
+
--content <markdown> Supply comment content for apex findings comment
|
|
33
|
+
--comment <markdown> Supply rationale for apex findings feedback
|
|
34
|
+
--parent-comment <comment-id> Reply to an existing finding comment
|
|
35
|
+
--suggested-severity extreme|critical|high|medium|low|informational
|
|
36
|
+
Attach a suggested severity to valid feedback
|
|
37
|
+
--dismissal-reason false-positive|by-design|not-relevant
|
|
38
|
+
Explain why invalid feedback is being left
|
|
27
39
|
--format markdown|json|gitlab-sast
|
|
28
40
|
Choose the export format for findings
|
|
29
41
|
--output <path> Write exported findings to this file path
|
|
30
42
|
--limit <count> Limit the number of findings returned
|
|
31
|
-
--mode standard|
|
|
43
|
+
--mode standard|audit Choose the scan mode
|
|
32
44
|
--source-mode auto|remote|local Control remote-vs-local source materialization
|
|
33
45
|
--force Start a new scan even if another scan is active
|
|
34
46
|
--repo <path> Include one or more explicit local source roots
|
|
@@ -39,17 +51,28 @@ Flags:
|
|
|
39
51
|
|
|
40
52
|
Tips:
|
|
41
53
|
apex scan uses the current directory name as the default workspace name unless you pass --workspace-name.
|
|
54
|
+
apex scan uses the current directory as the default source root unless you pass --repo.
|
|
55
|
+
audit is the user-facing name for the legacy ultra scan mode; ultra remains accepted as an alias.
|
|
42
56
|
apex workspace use accepts a workspace name, prefix, or ID.
|
|
57
|
+
Finding comments and feedback currently require CANTINA_AUTH_TOKEN from a logged-in Cantina/Apex browser session.
|
|
58
|
+
Invalid finding feedback requires --dismissal-reason.
|
|
59
|
+
Finding identifiers such as KERN2-25 resolve against the selected scan; pass --scan or use the finding UUID directly when needed.
|
|
43
60
|
Quote workspace names that contain spaces:
|
|
44
61
|
apex workspace use "Core Platform"
|
|
45
62
|
`;
|
|
46
63
|
export const SHELL_HELP_TEXT = `Press Tab to autocomplete commands and common arguments.
|
|
47
64
|
|
|
48
65
|
Commands:
|
|
49
|
-
/credits Show scan credits for the active company
|
|
50
|
-
/scan [standard|
|
|
66
|
+
/credits Show scan credits and audit scan entitlements for the active company
|
|
67
|
+
/scan [standard|audit] Start a new Apex scan for this workspace
|
|
51
68
|
/scans List scans for this workspace
|
|
52
69
|
/findings [scan-id] List findings for the latest or selected scan
|
|
70
|
+
/findings comment <finding-ref> <comment>
|
|
71
|
+
Add a comment or note to a finding
|
|
72
|
+
/findings feedback <finding-ref> valid [comment]
|
|
73
|
+
Leave valid feedback on a finding
|
|
74
|
+
/findings feedback <finding-ref> invalid <reason> [comment]
|
|
75
|
+
Leave invalid feedback; reason is false-positive, by-design, or not-relevant
|
|
53
76
|
/export [scan-id] Export findings for the latest or selected scan
|
|
54
77
|
/workspaces List accessible workspaces for the active company
|
|
55
78
|
/cancel-scan [scan-id] Cancel a running or most recent scan
|
|
@@ -70,6 +93,10 @@ Commands:
|
|
|
70
93
|
/exit Exit Apex
|
|
71
94
|
|
|
72
95
|
Tips:
|
|
96
|
+
audit is the user-facing name for the legacy ultra scan mode; ultra remains accepted as an alias.
|
|
73
97
|
/workspace use accepts a workspace name, prefix, or ID.
|
|
98
|
+
/findings comment and /findings feedback require CANTINA_AUTH_TOKEN in the shell environment.
|
|
99
|
+
Invalid finding feedback requires a dismissal reason.
|
|
100
|
+
Use scripted CLI flags for advanced feedback options such as suggested severity or dismissal reason.
|
|
74
101
|
Quote workspace names that contain spaces: /workspace use "Core Platform"
|
|
75
102
|
`;
|
package/dist/mcp.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { getMe, logout, startDeviceLogin, waitForDeviceLoginApproval } from "./auth.js";
|
|
6
6
|
import { ApexApiClient, formatApiError } from "./api-client.js";
|
|
7
|
-
import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindings, commandScan, commandScans, commandStatus, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
|
|
7
|
+
import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingFeedback, commandFindings, commandScan, commandScans, commandStatus, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
|
|
8
8
|
import { CLI_HELP_TEXT } from "./help.js";
|
|
9
9
|
import { APEX_CLI_VERSION } from "./version.js";
|
|
10
10
|
const MCP_WORKFLOW_GUIDE = `Use Apex through these tools instead of shelling out to the CLI.
|
|
@@ -15,10 +15,12 @@ Suggested workflow:
|
|
|
15
15
|
3. Call apex-doctor for the target repository cwd.
|
|
16
16
|
4. If the directory is not bound to the right workspace, call apex-workspaces and apex-workspace-use.
|
|
17
17
|
5. Start and monitor scans with apex-scan, apex-status, apex-scans, apex-findings, and apex-export-findings.
|
|
18
|
+
6. Leave finding comments or valid/invalid feedback with apex-finding-comment and apex-finding-feedback when review collaboration is needed.
|
|
18
19
|
|
|
19
20
|
Notes:
|
|
20
21
|
- Pass cwd explicitly for repository-specific operations.
|
|
21
22
|
- Tool calls are always non-interactive and never auto-open a browser.
|
|
23
|
+
- Use mode "audit" for audit scans; "ultra" remains accepted as a legacy alias.
|
|
22
24
|
- Doctor reports whether Apex will use remote materialization or a local snapshot upload for each source.`;
|
|
23
25
|
const WORKSPACE_BINDING_ERROR = "This directory is not bound to an Apex workspace yet. Run `apex workspaces` to list workspace names, prefixes, and IDs, then bind one with `apex workspace use \"<workspace name>\"`, or run `apex scan` to create or resolve one automatically.";
|
|
24
26
|
const MISSING_PROVIDER_CONNECTIONS_ERROR = "Missing provider connections. Re-run interactively or pre-connect providers.";
|
|
@@ -248,7 +250,7 @@ function registerTools(server) {
|
|
|
248
250
|
}, (value) => `Apex doctor completed for ${String(value.cwd)}.`));
|
|
249
251
|
server.registerTool("apex-credits", {
|
|
250
252
|
title: "Get Apex Credits",
|
|
251
|
-
description: "Show scan credits for the active or selected company.",
|
|
253
|
+
description: "Show scan credits and audit scan entitlements for the active or selected company.",
|
|
252
254
|
inputSchema: {
|
|
253
255
|
cwd: z.string().optional(),
|
|
254
256
|
company: z.string().optional(),
|
|
@@ -320,13 +322,13 @@ function registerTools(server) {
|
|
|
320
322
|
}, (value) => `Bound ${String(value.cwd)} to an Apex workspace.`));
|
|
321
323
|
server.registerTool("apex-scan", {
|
|
322
324
|
title: "Start Apex Scan",
|
|
323
|
-
description: "Start a new Apex scan for
|
|
325
|
+
description: "Start a new Apex scan for the provided cwd by default. Pass repoPaths only to scan explicit alternate local roots. Use mode audit for audit scans.",
|
|
324
326
|
inputSchema: {
|
|
325
327
|
cwd: z.string().optional(),
|
|
326
328
|
company: z.string().optional(),
|
|
327
329
|
workspaceName: z.string().optional(),
|
|
328
330
|
repoPaths: z.array(z.string()).optional(),
|
|
329
|
-
mode: z.enum(["standard", "ultra"]).optional(),
|
|
331
|
+
mode: z.enum(["standard", "audit", "ultra"]).optional(),
|
|
330
332
|
sourceMode: z.enum(["auto", "remote", "local"]).optional(),
|
|
331
333
|
force: z.boolean().optional(),
|
|
332
334
|
},
|
|
@@ -417,6 +419,61 @@ function registerTools(server) {
|
|
|
417
419
|
...payload,
|
|
418
420
|
};
|
|
419
421
|
}, (value) => `Fetched Apex findings for ${String(value.cwd)}.`));
|
|
422
|
+
server.registerTool("apex-finding-comment", {
|
|
423
|
+
title: "Add Apex Finding Comment",
|
|
424
|
+
description: "Add a comment or note to an Apex finding. Requires CANTINA_AUTH_TOKEN in the MCP server environment.",
|
|
425
|
+
inputSchema: {
|
|
426
|
+
cwd: z.string().optional(),
|
|
427
|
+
findingRef: z.string(),
|
|
428
|
+
content: z.string().min(1),
|
|
429
|
+
parentCommentId: z.string().optional(),
|
|
430
|
+
scanId: z.string().optional(),
|
|
431
|
+
},
|
|
432
|
+
}, async ({ cwd, findingRef, content, parentCommentId, scanId }) => runTool("apex-finding-comment", async () => {
|
|
433
|
+
const client = new ApexApiClient();
|
|
434
|
+
const targetCwd = resolveCwd(cwd);
|
|
435
|
+
const payload = await commandFindingComment(client, targetCwd, buildFlags({
|
|
436
|
+
scanId,
|
|
437
|
+
}), findingRef, content, parentCommentId);
|
|
438
|
+
return {
|
|
439
|
+
cwd: targetCwd,
|
|
440
|
+
...payload,
|
|
441
|
+
};
|
|
442
|
+
}, (value) => `Added a finding comment for ${String(value.findingRef)}.`));
|
|
443
|
+
server.registerTool("apex-finding-feedback", {
|
|
444
|
+
title: "Leave Apex Finding Feedback",
|
|
445
|
+
description: "Leave valid or invalid feedback on an Apex finding. Requires CANTINA_AUTH_TOKEN in the MCP server environment.",
|
|
446
|
+
inputSchema: {
|
|
447
|
+
cwd: z.string().optional(),
|
|
448
|
+
findingRef: z.string(),
|
|
449
|
+
status: z.enum(["valid", "invalid"]),
|
|
450
|
+
comment: z.string().optional(),
|
|
451
|
+
suggestedSeverity: z
|
|
452
|
+
.enum(["extreme", "critical", "high", "medium", "low", "informational"])
|
|
453
|
+
.optional(),
|
|
454
|
+
dismissalReason: z
|
|
455
|
+
.enum(["false-positive", "by-design", "not-relevant"])
|
|
456
|
+
.optional(),
|
|
457
|
+
scanId: z.string().optional(),
|
|
458
|
+
},
|
|
459
|
+
}, async ({ cwd, findingRef, status, comment, suggestedSeverity, dismissalReason, scanId, }) => runTool("apex-finding-feedback", async () => {
|
|
460
|
+
const client = new ApexApiClient();
|
|
461
|
+
const targetCwd = resolveCwd(cwd);
|
|
462
|
+
const payload = await commandFindingFeedback(client, targetCwd, buildFlags({
|
|
463
|
+
scanId,
|
|
464
|
+
}), {
|
|
465
|
+
requestedFindingRef: findingRef,
|
|
466
|
+
feedbackType: status,
|
|
467
|
+
comment,
|
|
468
|
+
suggestedSeverity,
|
|
469
|
+
dismissalReason,
|
|
470
|
+
});
|
|
471
|
+
return {
|
|
472
|
+
cwd: targetCwd,
|
|
473
|
+
...payload,
|
|
474
|
+
};
|
|
475
|
+
}, (value) => `Submitted ${String((value.feedback?.feedbackType ??
|
|
476
|
+
"finding"))} feedback for ${String(value.findingRef)}.`));
|
|
420
477
|
server.registerTool("apex-export-findings", {
|
|
421
478
|
title: "Export Apex Findings",
|
|
422
479
|
description: "Export findings for the latest or selected Apex scan to a file on disk.",
|
package/dist/repo-discovery.js
CHANGED
|
@@ -1,37 +1,15 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { lstat,
|
|
2
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { extractCanonicalRepoMetadata } from "./repo-url.js";
|
|
6
6
|
const execFileAsync = promisify(execFile);
|
|
7
|
-
const IGNORED_DIRECTORIES = new Set([
|
|
8
|
-
".git",
|
|
9
|
-
".next",
|
|
10
|
-
"node_modules",
|
|
11
|
-
"dist",
|
|
12
|
-
"build",
|
|
13
|
-
"coverage",
|
|
14
|
-
".turbo",
|
|
15
|
-
".cache",
|
|
16
|
-
".pnpm-store",
|
|
17
|
-
"tmp",
|
|
18
|
-
"vendor",
|
|
19
|
-
]);
|
|
20
7
|
async function runGit(cwd, args) {
|
|
21
8
|
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
|
|
22
9
|
encoding: "utf8",
|
|
23
10
|
});
|
|
24
11
|
return stdout.trim();
|
|
25
12
|
}
|
|
26
|
-
async function isGitRepository(directory) {
|
|
27
|
-
try {
|
|
28
|
-
await runGit(directory, ["rev-parse", "--is-inside-work-tree"]);
|
|
29
|
-
return true;
|
|
30
|
-
}
|
|
31
|
-
catch {
|
|
32
|
-
return false;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
13
|
async function getGitTopLevel(directory) {
|
|
36
14
|
try {
|
|
37
15
|
const topLevel = await runGit(directory, ["rev-parse", "--show-toplevel"]);
|
|
@@ -103,25 +81,6 @@ function normalizeSelectedRoots(cwd, selectedPaths) {
|
|
|
103
81
|
}
|
|
104
82
|
return disjoint;
|
|
105
83
|
}
|
|
106
|
-
export async function findGitRepositoryRoots(cwd) {
|
|
107
|
-
const discovered = new Set();
|
|
108
|
-
async function walk(directory) {
|
|
109
|
-
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
110
|
-
const hasGitMarker = entries.some((entry) => entry.name === ".git");
|
|
111
|
-
if (hasGitMarker && (await isGitRepository(directory))) {
|
|
112
|
-
discovered.add(directory);
|
|
113
|
-
}
|
|
114
|
-
for (const entry of entries) {
|
|
115
|
-
if (!entry.isDirectory())
|
|
116
|
-
continue;
|
|
117
|
-
if (IGNORED_DIRECTORIES.has(entry.name))
|
|
118
|
-
continue;
|
|
119
|
-
await walk(path.join(directory, entry.name));
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
await walk(cwd);
|
|
123
|
-
return Array.from(discovered).sort((left, right) => left.length - right.length);
|
|
124
|
-
}
|
|
125
84
|
async function inspectGitRepository(rootCwd, directory) {
|
|
126
85
|
const remoteUrl = await chooseRemote(directory);
|
|
127
86
|
const metadata = remoteUrl ? extractCanonicalRepoMetadata(remoteUrl) : null;
|
|
@@ -163,7 +122,7 @@ async function inspectSource(rootCwd, requestedDirectory) {
|
|
|
163
122
|
export async function discoverSources(cwd, selectedPaths = []) {
|
|
164
123
|
const discoveredRoots = selectedPaths.length > 0
|
|
165
124
|
? normalizeSelectedRoots(cwd, selectedPaths)
|
|
166
|
-
:
|
|
125
|
+
: [cwd];
|
|
167
126
|
const sources = await Promise.all(discoveredRoots.map((root) => inspectSource(cwd, root)));
|
|
168
127
|
sources.sort((left, right) => left.path.localeCompare(right.path));
|
|
169
128
|
return { sources, scanned: discoveredRoots };
|
package/dist/scan.js
CHANGED
|
@@ -30,6 +30,10 @@ function readNumber(...values) {
|
|
|
30
30
|
}
|
|
31
31
|
return null;
|
|
32
32
|
}
|
|
33
|
+
function readScanMode(...values) {
|
|
34
|
+
const mode = readString(...values);
|
|
35
|
+
return mode === "ultra" ? "audit" : mode;
|
|
36
|
+
}
|
|
33
37
|
function getScanListItems(payload) {
|
|
34
38
|
if (Array.isArray(payload)) {
|
|
35
39
|
return payload;
|
|
@@ -63,7 +67,7 @@ function normalizeScanRecord(payload) {
|
|
|
63
67
|
displayName: readString(record.displayName, record.display_name, record.name),
|
|
64
68
|
sequenceNumber: readNumber(record.sequenceNumber, record.sequence_number),
|
|
65
69
|
status: readString(record.status) ?? "unknown",
|
|
66
|
-
mode:
|
|
70
|
+
mode: readScanMode(record.mode, record.scanType, record.scan_type),
|
|
67
71
|
scanUrl: readString(record.scanUrl, record.url, record.scan_url),
|
|
68
72
|
createdAt: readTimestamp(record, "createdAt", "created_at"),
|
|
69
73
|
startedAt: readTimestamp(record, "startedAt", "started_at"),
|
package/dist/session.js
CHANGED
|
@@ -70,6 +70,52 @@ function getSourceMode(flags) {
|
|
|
70
70
|
const value = getFlagString(flags, "source-mode");
|
|
71
71
|
return value === "remote" || value === "local" ? value : "auto";
|
|
72
72
|
}
|
|
73
|
+
function buildFallbackLocalArchivePlan(source, sourceMode) {
|
|
74
|
+
const reason = sourceMode === "local"
|
|
75
|
+
? "user_requested_local_snapshot"
|
|
76
|
+
: source.kind === "directory_candidate"
|
|
77
|
+
? "non_git_directory"
|
|
78
|
+
: source.dirty
|
|
79
|
+
? "dirty_worktree"
|
|
80
|
+
: source.repoUrl
|
|
81
|
+
? "remote_access_unavailable"
|
|
82
|
+
: "missing_remote";
|
|
83
|
+
return {
|
|
84
|
+
path: source.path,
|
|
85
|
+
displayName: source.displayName,
|
|
86
|
+
relativePath: source.path,
|
|
87
|
+
sourceKind: "local_archive",
|
|
88
|
+
archiveRequired: true,
|
|
89
|
+
reason,
|
|
90
|
+
git: source.kind === "git_candidate"
|
|
91
|
+
? {
|
|
92
|
+
repoUrl: source.repoUrl,
|
|
93
|
+
provider: source.provider,
|
|
94
|
+
branch: source.branch,
|
|
95
|
+
commitSha: source.commitSha,
|
|
96
|
+
dirty: source.dirty,
|
|
97
|
+
}
|
|
98
|
+
: {
|
|
99
|
+
repoUrl: null,
|
|
100
|
+
provider: null,
|
|
101
|
+
branch: null,
|
|
102
|
+
commitSha: null,
|
|
103
|
+
dirty: false,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function ensurePlannedSources(selection, resolve) {
|
|
108
|
+
if (resolve.plannedSources.length > 0) {
|
|
109
|
+
return resolve;
|
|
110
|
+
}
|
|
111
|
+
if (selection.sourceMode === "remote") {
|
|
112
|
+
throw new Error("Workspace resolution did not return any remote scan sources.");
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
...resolve,
|
|
116
|
+
plannedSources: selection.sources.map((source) => buildFallbackLocalArchivePlan(source, selection.sourceMode)),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
73
119
|
export async function ensureAuthenticated(client, flags) {
|
|
74
120
|
return login(client, {
|
|
75
121
|
noOpen: flags["no-open"] === true,
|
|
@@ -99,7 +145,7 @@ export async function selectWorkspaceTarget(cwd, me, flags) {
|
|
|
99
145
|
};
|
|
100
146
|
}
|
|
101
147
|
export async function resolveWorkspaceSelection(client, selection) {
|
|
102
|
-
const
|
|
148
|
+
const rawResolve = await client.request("/api/cli/v2/local-workspaces/resolve", {
|
|
103
149
|
method: "POST",
|
|
104
150
|
json: {
|
|
105
151
|
companyId: selection.company.id,
|
|
@@ -124,6 +170,7 @@ export async function resolveWorkspaceSelection(client, selection) {
|
|
|
124
170
|
}),
|
|
125
171
|
},
|
|
126
172
|
});
|
|
173
|
+
const resolve = ensurePlannedSources(selection, rawResolve);
|
|
127
174
|
return {
|
|
128
175
|
company: selection.company,
|
|
129
176
|
workspaceName: selection.workspaceName,
|
package/dist/shell.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { SHELL_HELP_TEXT } from "./help.js";
|
|
2
|
-
import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindings, commandLogout, commandScan, commandScans, commandStatus, commandUpdate, commandWorkspace, commandWorkspaceUse, commandWorkspaces, initializeInteractiveSession, openCurrentApexView, } from "./commands.js";
|
|
2
|
+
import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingFeedback, commandFindings, commandLogout, commandScan, commandScans, commandStatus, commandUpdate, commandWorkspace, commandWorkspaceUse, commandWorkspaces, initializeInteractiveSession, openCurrentApexView, } from "./commands.js";
|
|
3
3
|
import { withFlag } from "./args.js";
|
|
4
4
|
import { printCompanyDetails, printInteractiveSessionSummary, printSourceList, printWorkspaceDetails, } from "./output.js";
|
|
5
5
|
import { readLine } from "./prompt.js";
|
|
6
6
|
import { formatApiError } from "./api-client.js";
|
|
7
7
|
const SHELL_COMPLETIONS = [
|
|
8
8
|
{ command: "credits" },
|
|
9
|
-
{ command: "scan", args: ["standard", "ultra"] },
|
|
9
|
+
{ command: "scan", args: ["standard", "audit", "ultra"] },
|
|
10
10
|
{ command: "scans" },
|
|
11
|
-
{ command: "findings" },
|
|
11
|
+
{ command: "findings", args: ["comment", "feedback"] },
|
|
12
12
|
{ command: "export" },
|
|
13
13
|
{ command: "workspaces" },
|
|
14
14
|
{ command: "cancel-scan" },
|
|
@@ -178,8 +178,8 @@ async function runShellCommand(client, cwd, parsed, shellFlags, session) {
|
|
|
178
178
|
return {};
|
|
179
179
|
case "scan": {
|
|
180
180
|
const mode = parsed.args[0];
|
|
181
|
-
if (mode && !["standard", "ultra"].includes(mode)) {
|
|
182
|
-
process.stderr.write("Usage: /scan [standard|ultra]\n");
|
|
181
|
+
if (mode && !["standard", "audit", "ultra"].includes(mode)) {
|
|
182
|
+
process.stderr.write("Usage: /scan [standard|audit|ultra]\n");
|
|
183
183
|
return {};
|
|
184
184
|
}
|
|
185
185
|
const result = await commandScan(client, cwd, mode ? withFlag(shellFlags, "mode", mode) : shellFlags);
|
|
@@ -200,6 +200,43 @@ async function runShellCommand(client, cwd, parsed, shellFlags, session) {
|
|
|
200
200
|
await commandScans(client, cwd, shellFlags);
|
|
201
201
|
return {};
|
|
202
202
|
case "findings": {
|
|
203
|
+
if (parsed.args[0] === "comment") {
|
|
204
|
+
if (parsed.args.length < 3) {
|
|
205
|
+
process.stderr.write("Usage: /findings comment <finding-id|finding-identifier> <comment>\n");
|
|
206
|
+
return {};
|
|
207
|
+
}
|
|
208
|
+
await commandFindingComment(client, cwd, shellFlags, parsed.args[1], parsed.args.slice(2).join(" "));
|
|
209
|
+
return {};
|
|
210
|
+
}
|
|
211
|
+
if (parsed.args[0] === "feedback") {
|
|
212
|
+
const findingRef = parsed.args[1];
|
|
213
|
+
const feedbackType = parsed.args[2];
|
|
214
|
+
if (!findingRef ||
|
|
215
|
+
(feedbackType !== "valid" && feedbackType !== "invalid")) {
|
|
216
|
+
process.stderr.write("Usage: /findings feedback <finding-id|finding-identifier> <valid|invalid> [comment]\n");
|
|
217
|
+
return {};
|
|
218
|
+
}
|
|
219
|
+
const dismissalReason = feedbackType === "invalid" ? parsed.args[3] : null;
|
|
220
|
+
if (feedbackType === "invalid" &&
|
|
221
|
+
!["false-positive", "by-design", "not-relevant"].includes(dismissalReason ?? "")) {
|
|
222
|
+
process.stderr.write("Usage: /findings feedback <finding-id|finding-identifier> invalid <false-positive|by-design|not-relevant> [comment]\n");
|
|
223
|
+
return {};
|
|
224
|
+
}
|
|
225
|
+
await commandFindingFeedback(client, cwd, shellFlags, {
|
|
226
|
+
requestedFindingRef: findingRef,
|
|
227
|
+
feedbackType,
|
|
228
|
+
comment: parsed.args
|
|
229
|
+
.slice(feedbackType === "invalid" ? 4 : 3)
|
|
230
|
+
.join(" ")
|
|
231
|
+
.trim() || null,
|
|
232
|
+
...(dismissalReason
|
|
233
|
+
? {
|
|
234
|
+
dismissalReason: dismissalReason,
|
|
235
|
+
}
|
|
236
|
+
: {}),
|
|
237
|
+
});
|
|
238
|
+
return {};
|
|
239
|
+
}
|
|
203
240
|
if (parsed.args.length > 1) {
|
|
204
241
|
process.stderr.write("Usage: /findings [scan-id]\n");
|
|
205
242
|
return {};
|
package/package.json
CHANGED
package/skills/apex-cli/SKILL.md
CHANGED
|
@@ -17,13 +17,20 @@ Workflow:
|
|
|
17
17
|
4. Call `apex-doctor` before starting a scan if workspace binding or source planning might be unclear.
|
|
18
18
|
5. If the directory is not bound to the right workspace, call `apex-workspaces` and `apex-workspace-use`.
|
|
19
19
|
6. Use `apex-scan`, `apex-status`, `apex-scans`, `apex-findings`, and `apex-export-findings` for the scan lifecycle.
|
|
20
|
+
7. Use `apex-finding-comment` and `apex-finding-feedback` when the user wants to leave review notes or valid/invalid feedback on a finding.
|
|
20
21
|
|
|
21
22
|
Guidelines:
|
|
22
23
|
|
|
23
24
|
- Do not rely on interactive CLI prompts. The MCP tools are intentionally non-interactive.
|
|
25
|
+
- `apex-credits` reports standard credits and audit scan entitlements when Bedrock returns both balances.
|
|
26
|
+
- `apex-scan` scans the provided `cwd` by default; pass `repoPaths` only when the user asks to scan explicit alternate roots.
|
|
24
27
|
- `apex-doctor` reports whether Apex will use remote materialization or a local snapshot upload for each selected source.
|
|
25
28
|
- Apex can scan plain local directories and dirty git worktrees without provider connections by using local snapshot uploads.
|
|
29
|
+
- Audit scans use `mode: "audit"` in user-facing instructions. The legacy `ultra` mode remains accepted as an alias, but audit scans still require provider-backed GitHub or GitLab sources.
|
|
26
30
|
- `apex-workspace-use` accepts a workspace name, prefix, or ID.
|
|
27
31
|
- Use `sourceMode: "remote"` only when the user explicitly wants to forbid local snapshot fallbacks.
|
|
28
32
|
- Use `force: true` on `apex-scan` only when the user explicitly wants to replace or overlap an active scan.
|
|
29
33
|
- Prefer `apex-findings` for quick inspection and `apex-export-findings` when the user needs a file artifact.
|
|
34
|
+
- Finding comments and feedback currently require `CANTINA_AUTH_TOKEN` in the MCP server environment because those writes go through the Cantina web-app routes instead of the Apex CLI bearer-token routes.
|
|
35
|
+
- Invalid finding feedback requires `dismissalReason`; valid feedback can include `suggestedSeverity`, including `extreme`.
|
|
36
|
+
- Finding identifiers such as `KERN2-25` resolve against the selected or latest scan for the current workspace binding. Pass an explicit scan when needed, or use the finding UUID directly.
|