@cantinasecurity/apex-cli 0.1.1 → 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 +6 -0
- package/README.md +30 -2
- package/dist/apex.js +30 -1
- package/dist/commands.js +178 -1
- package/dist/findings.js +132 -0
- package/dist/help.js +25 -0
- package/dist/mcp.js +58 -2
- package/dist/repo-discovery.js +2 -43
- package/dist/session.js +48 -1
- package/dist/shell.js +39 -2
- package/package.json +1 -1
- package/skills/apex-cli/SKILL.md +5 -0
|
@@ -19,6 +19,7 @@ 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
|
|
|
@@ -27,14 +28,19 @@ If the Apex MCP server is not configured, fall back to the local CLI:
|
|
|
27
28
|
- Use `--json` whenever structured output is helpful.
|
|
28
29
|
- `apex credits` reports standard credits and audit scan entitlements when Bedrock returns both balances.
|
|
29
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.
|
|
30
32
|
- `apex-doctor` reports whether Apex will use remote materialization or a local snapshot upload for each selected source.
|
|
31
33
|
- Plain local directories and dirty git worktrees can scan through local snapshot uploads without provider access.
|
|
32
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.
|
|
33
35
|
- `apex-workspace-use` accepts a workspace name, prefix, or ID.
|
|
34
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.
|
|
35
40
|
|
|
36
41
|
## Examples
|
|
37
42
|
|
|
38
43
|
- Start a scan for the current repository or directory: run `apex-doctor`, bind a workspace if needed, then call `apex-scan`.
|
|
39
44
|
- Check an active scan: call `apex-status`, then `apex-findings` when the scan completes.
|
|
40
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
|
@@ -101,6 +101,9 @@ Supported shell commands:
|
|
|
101
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`
|
|
@@ -149,9 +154,31 @@ Helpful workspace flags:
|
|
|
149
154
|
|
|
150
155
|
`apex credits` shows standard scan credits plus audit scan entitlements when the server returns them.
|
|
151
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
|
+
|
|
152
179
|
## Local Source Scans
|
|
153
180
|
|
|
154
|
-
`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:
|
|
155
182
|
|
|
156
183
|
- clean GitHub or GitLab checkouts can stay on the remote-materialization path
|
|
157
184
|
- dirty git worktrees fall back to a local snapshot upload by default
|
|
@@ -159,7 +186,7 @@ Helpful workspace flags:
|
|
|
159
186
|
|
|
160
187
|
Useful flags:
|
|
161
188
|
|
|
162
|
-
- `--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
|
|
163
190
|
- `--source-mode auto|remote|local` to control remote-first fallback behavior
|
|
164
191
|
- `--mode standard|audit` to choose the scan mode
|
|
165
192
|
|
|
@@ -237,6 +264,7 @@ The MCP server exposes Apex-specific tools for:
|
|
|
237
264
|
- doctor, credits, and provider connection URLs
|
|
238
265
|
- workspace inspection and workspace binding
|
|
239
266
|
- scan start, status, cancellation, findings, and findings export
|
|
267
|
+
- finding comments and valid/invalid feedback with `apex-finding-comment` and `apex-finding-feedback`
|
|
240
268
|
|
|
241
269
|
For repository-scoped operations, pass `cwd` explicitly so the server can resolve the right `.apex/workspace.json` binding and repository roots.
|
|
242
270
|
|
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) {
|
|
@@ -67,6 +80,33 @@ function formatAuditScanBalance(scanBalance) {
|
|
|
67
80
|
function normalizeRequestedScanMode(value) {
|
|
68
81
|
return value === "ultra" || value === "audit" ? "ultra" : "standard";
|
|
69
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
|
+
}
|
|
70
110
|
async function requireWorkspaceBinding(cwd) {
|
|
71
111
|
const binding = await loadWorkspaceBinding(cwd);
|
|
72
112
|
if (binding) {
|
|
@@ -74,6 +114,27 @@ async function requireWorkspaceBinding(cwd) {
|
|
|
74
114
|
}
|
|
75
115
|
throw new Error(`This directory is not bound to an Apex workspace yet. ${WORKSPACE_BINDING_GUIDANCE}`);
|
|
76
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
|
+
}
|
|
77
138
|
function canPromptForConfirmation(flags) {
|
|
78
139
|
return !isNonInteractive(flags) && process.stdin.isTTY && process.stdout.isTTY;
|
|
79
140
|
}
|
|
@@ -599,6 +660,122 @@ export async function commandFindings(client, cwd, flags) {
|
|
|
599
660
|
}
|
|
600
661
|
return payload;
|
|
601
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
|
+
}
|
|
602
779
|
export async function commandExportFindings(client, cwd, flags) {
|
|
603
780
|
await ensureAuthenticated(client, flags);
|
|
604
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
|
@@ -4,6 +4,10 @@ export const CLI_HELP_TEXT = `Usage:
|
|
|
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,6 +28,14 @@ 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
|
|
@@ -39,8 +51,12 @@ 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.
|
|
42
55
|
audit is the user-facing name for the legacy ultra scan mode; ultra remains accepted as an alias.
|
|
43
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.
|
|
44
60
|
Quote workspace names that contain spaces:
|
|
45
61
|
apex workspace use "Core Platform"
|
|
46
62
|
`;
|
|
@@ -51,6 +67,12 @@ Commands:
|
|
|
51
67
|
/scan [standard|audit] Start a new Apex scan for this workspace
|
|
52
68
|
/scans List scans for this workspace
|
|
53
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
|
|
54
76
|
/export [scan-id] Export findings for the latest or selected scan
|
|
55
77
|
/workspaces List accessible workspaces for the active company
|
|
56
78
|
/cancel-scan [scan-id] Cancel a running or most recent scan
|
|
@@ -73,5 +95,8 @@ Commands:
|
|
|
73
95
|
Tips:
|
|
74
96
|
audit is the user-facing name for the legacy ultra scan mode; ultra remains accepted as an alias.
|
|
75
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.
|
|
76
101
|
Quote workspace names that contain spaces: /workspace use "Core Platform"
|
|
77
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,6 +15,7 @@ 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.
|
|
@@ -321,7 +322,7 @@ function registerTools(server) {
|
|
|
321
322
|
}, (value) => `Bound ${String(value.cwd)} to an Apex workspace.`));
|
|
322
323
|
server.registerTool("apex-scan", {
|
|
323
324
|
title: "Start Apex Scan",
|
|
324
|
-
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.",
|
|
325
326
|
inputSchema: {
|
|
326
327
|
cwd: z.string().optional(),
|
|
327
328
|
company: z.string().optional(),
|
|
@@ -418,6 +419,61 @@ function registerTools(server) {
|
|
|
418
419
|
...payload,
|
|
419
420
|
};
|
|
420
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)}.`));
|
|
421
477
|
server.registerTool("apex-export-findings", {
|
|
422
478
|
title: "Export Apex Findings",
|
|
423
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/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,5 +1,5 @@
|
|
|
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";
|
|
@@ -8,7 +8,7 @@ const SHELL_COMPLETIONS = [
|
|
|
8
8
|
{ command: "credits" },
|
|
9
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" },
|
|
@@ -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,11 +17,13 @@ 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.
|
|
24
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.
|
|
25
27
|
- `apex-doctor` reports whether Apex will use remote materialization or a local snapshot upload for each selected source.
|
|
26
28
|
- Apex can scan plain local directories and dirty git worktrees without provider connections by using local snapshot uploads.
|
|
27
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.
|
|
@@ -29,3 +31,6 @@ Guidelines:
|
|
|
29
31
|
- Use `sourceMode: "remote"` only when the user explicitly wants to forbid local snapshot fallbacks.
|
|
30
32
|
- Use `force: true` on `apex-scan` only when the user explicitly wants to replace or overlap an active scan.
|
|
31
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.
|