@cantinasecurity/apex-cli 0.1.36 → 0.1.38
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 +17 -1
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.mcp.claude.json +1 -1
- package/.mcp.codex.json +1 -1
- package/MARKETPLACE.md +1 -1
- package/README.md +63 -13
- package/dist/apex.js +7 -1
- package/dist/api-client.js +19 -9
- package/dist/args.js +14 -0
- package/dist/commands.js +85 -39
- package/dist/findings.js +6 -1
- package/dist/help.js +15 -4
- package/dist/local-source-scan.js +25 -5
- package/dist/mcp.js +23 -4
- package/dist/repo-discovery.js +69 -16
- package/dist/session.js +24 -4
- package/dist/shell.js +15 -8
- package/dist/telemetry.js +2 -2
- package/dist/threat-model.js +21 -3
- package/dist/types.js +4 -1
- package/package.json +9 -8
- package/skills/apex-cli/SKILL.md +17 -1
package/dist/commands.js
CHANGED
|
@@ -4,7 +4,8 @@ import { ApiError, formatApiError, getApiErrorCode, } from "./api-client.js";
|
|
|
4
4
|
import { openInBrowser } from "./browser.js";
|
|
5
5
|
import { getClaimProofState, isClaimProofAvailable, parseClaimDeadlineMs, } from "./claim-proof.js";
|
|
6
6
|
import { loadConfig, updateConfig } from "./config.js";
|
|
7
|
-
import { createFindingComment, fetchFindingTickets, linkFindingTicket, sendFindingToIssueTracker, fetchScanExport, fetchScanFindings, fetchWorkspaceFindings, isFindingUuid, normalizeFindingRef, resolveFindingSelection, resolveScanSelection, startFindingFixReviewScan, submitFindingFeedback, } from "./findings.js";
|
|
7
|
+
import { createFindingComment, fetchFindingTickets, linkFindingTicket, sendFindingToIssueTracker, fetchScanExport, fetchScanFindings, fetchWorkspaceFindings, isFindingUuid, normalizeFindingRef, resolveFindingSelection, resolveScanSelection, startFindingFixReviewScan, submitFindingFeedback, setFindingStatus, } from "./findings.js";
|
|
8
|
+
import { FINDING_LIFECYCLE_STATUSES } from "./types.js";
|
|
8
9
|
import { prepareLocalSourceArchives, prepareExplicitScanSources, supportsLegacyRemoteFlow, } from "./local-source-scan.js";
|
|
9
10
|
import { logLine, printJson, withLoadingIndicator } from "./output.js";
|
|
10
11
|
import { claimOnboardingSession, isProofOnlyOnboardingCredentials, redeemOnboardingCode, revokeGuestOnboardingSession, } from "./onboarding.js";
|
|
@@ -332,6 +333,8 @@ function formatLiteScanBalance(scanBalance) {
|
|
|
332
333
|
return `Lite scan credits: ${detailParts.join(", ")}`;
|
|
333
334
|
}
|
|
334
335
|
function normalizeRequestedScanMode(value) {
|
|
336
|
+
if (value === null || value === "standard")
|
|
337
|
+
return "standard";
|
|
335
338
|
if (value === "ultra" || value === "audit") {
|
|
336
339
|
return "audit";
|
|
337
340
|
}
|
|
@@ -341,7 +344,7 @@ function normalizeRequestedScanMode(value) {
|
|
|
341
344
|
if (value === "lite") {
|
|
342
345
|
return "lite";
|
|
343
346
|
}
|
|
344
|
-
|
|
347
|
+
throw new Error("--mode must be standard, audit, ultra, lite, or pr.");
|
|
345
348
|
}
|
|
346
349
|
function getFlagValues(flags, key) {
|
|
347
350
|
const values = getFlagList(flags, key);
|
|
@@ -1074,7 +1077,7 @@ export async function commandConnect(client, cwd, flags, provider) {
|
|
|
1074
1077
|
if (!isJsonMode(flags)) {
|
|
1075
1078
|
logLine(`Connection URL for ${provider}: ${response.url}`, flags);
|
|
1076
1079
|
}
|
|
1077
|
-
if (flags["no-open"] !== true) {
|
|
1080
|
+
if (flags["no-open"] !== true && canPromptInteractively(flags)) {
|
|
1078
1081
|
await openInBrowser(response.url);
|
|
1079
1082
|
}
|
|
1080
1083
|
if (isJsonMode(flags)) {
|
|
@@ -1097,7 +1100,23 @@ export async function commandScan(client, cwd, flags) {
|
|
|
1097
1100
|
const me = await ensureAuthenticated(client, flags);
|
|
1098
1101
|
const authState = await getCommandAuthState(client, me);
|
|
1099
1102
|
setTelemetryAuthKind(authState.authKind);
|
|
1103
|
+
if (authState.authKind === "guest" && requestedMode !== "standard") {
|
|
1104
|
+
throw new Error("Guest trials support standard scans only. Use --mode standard, or run `apex claim` before choosing another mode.");
|
|
1105
|
+
}
|
|
1106
|
+
const guestLocalSourceGuidance = "Guest trials require local snapshot uploads. Clone remote repositories locally, then scan a local path without --source-mode remote.";
|
|
1107
|
+
if (authState.authKind === "guest" && getFlagString(flags, "source-mode") === "remote") {
|
|
1108
|
+
throw new Error(guestLocalSourceGuidance);
|
|
1109
|
+
}
|
|
1100
1110
|
let selection = await selectWorkspaceTarget(cwd, me, flags);
|
|
1111
|
+
if (authState.authKind === "guest" && selection.sources.some((source) => source.absolutePath === null)) {
|
|
1112
|
+
throw new Error(guestLocalSourceGuidance);
|
|
1113
|
+
}
|
|
1114
|
+
const requiresLocalSnapshot = selection.sourceMode === "local" ||
|
|
1115
|
+
selection.sources.some((source) => source.kind !== "git_candidate" ||
|
|
1116
|
+
(selection.sourceMode !== "remote" && source.dirty));
|
|
1117
|
+
if ((requestedMode === "audit" || requestedMode === "lite") && requiresLocalSnapshot) {
|
|
1118
|
+
throw new Error(`${requestedMode === "audit" ? "Audit" : "Lite"} scans require provider-backed repositories without local changes. Use --mode standard for files, directories, or local changes.`);
|
|
1119
|
+
}
|
|
1101
1120
|
let preparedArchives = null;
|
|
1102
1121
|
if (authState.authKind === "guest" && requestedMode === "standard") {
|
|
1103
1122
|
preparedArchives = await withLoadingIndicator("Preparing local source archive...", flags, () => prepareLocalSourceArchives({
|
|
@@ -1139,6 +1158,21 @@ export async function commandScan(client, cwd, flags) {
|
|
|
1139
1158
|
let workspaceId = resolvedWorkspaceId;
|
|
1140
1159
|
let scan;
|
|
1141
1160
|
let prScanSource = null;
|
|
1161
|
+
const requestScanStart = async (endpoint, json) => {
|
|
1162
|
+
try {
|
|
1163
|
+
return normalizeScanStartResponse(await client.request(endpoint, { method: "POST", json }));
|
|
1164
|
+
}
|
|
1165
|
+
catch (error) {
|
|
1166
|
+
if (error instanceof ApiError && getApiErrorCode(error) === "AUDIT_SETUP_REQUIRED") {
|
|
1167
|
+
const quote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
|
|
1168
|
+
const mode = requestedMode === "audit" ? "audit" : "standard";
|
|
1169
|
+
const command = `apex scan --company ${quote(result.company.id)} --workspace-ref ${quote(workspaceId)} --mode ${mode} --setup guided`;
|
|
1170
|
+
const message = `This backend requires Audit Setup. For this workspace's saved repositories, run: ${command}. Setup uses saved workspace sources and does not preserve local target selections. An explicit Standard source scan requires an updated client and backend; contact support if it still returns AUDIT_SETUP_REQUIRED.`;
|
|
1171
|
+
throw new ApiError(message, error.status, { code: "AUDIT_SETUP_REQUIRED", error: message });
|
|
1172
|
+
}
|
|
1173
|
+
throw error;
|
|
1174
|
+
}
|
|
1175
|
+
};
|
|
1142
1176
|
if (requestedMode === "pr") {
|
|
1143
1177
|
let legacyResolve = await withLoadingIndicator("Preparing PR scan...", flags, () => resolveWorkspaceSelectionLegacy(client, selection, resolvedWorkspaceId));
|
|
1144
1178
|
if (!legacyResolve.workspaceId) {
|
|
@@ -1161,40 +1195,30 @@ export async function commandScan(client, cwd, flags) {
|
|
|
1161
1195
|
const prSource = resolvePrScanSource(legacyResolve.repositories);
|
|
1162
1196
|
ensurePrSelectionsMatchSource(prSelections, prSource);
|
|
1163
1197
|
prScanSource = prSource;
|
|
1164
|
-
scan = await withLoadingIndicator("Starting PR scan...", flags, () =>
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
pullRequests: prSelections,
|
|
1174
|
-
}),
|
|
1175
|
-
},
|
|
1176
|
-
})
|
|
1177
|
-
.then(normalizeScanStartResponse));
|
|
1198
|
+
scan = await withLoadingIndicator("Starting PR scan...", flags, () => requestScanStart(`/api/cli/v1/local-workspaces/${encodeURIComponent(workspaceId)}/scan`, {
|
|
1199
|
+
mode: "pr",
|
|
1200
|
+
scanType: "pr",
|
|
1201
|
+
force: forceRestart || flags.force === true,
|
|
1202
|
+
repositories: buildPrScanRepositoriesPayload({
|
|
1203
|
+
source: prSource,
|
|
1204
|
+
pullRequests: prSelections,
|
|
1205
|
+
}),
|
|
1206
|
+
}));
|
|
1178
1207
|
}
|
|
1179
1208
|
else if (requestedMode === "audit" || requestedMode === "lite") {
|
|
1180
1209
|
const modeLabel = requestedMode === "audit" ? "Audit" : "Lite";
|
|
1181
1210
|
if (!supportsLegacyRemoteFlow(result.resolve.plannedSources)) {
|
|
1182
|
-
throw new Error(`${modeLabel} scans currently require provider-backed GitHub or GitLab repositories without local snapshot fallbacks.`);
|
|
1211
|
+
throw new Error(`${modeLabel} scans currently require provider-backed GitHub or GitLab repositories without local snapshot fallbacks. Use --mode standard for local snapshots or public repositories without a provider connection.`);
|
|
1183
1212
|
}
|
|
1184
1213
|
const legacyResolve = await withLoadingIndicator(`Preparing ${modeLabel.toLowerCase()} scan...`, flags, () => resolveWorkspaceSelectionLegacy(client, selection, resolvedWorkspaceId));
|
|
1185
1214
|
if (!legacyResolve.workspaceId) {
|
|
1186
1215
|
throw new Error("Workspace resolution did not return a workspaceId.");
|
|
1187
1216
|
}
|
|
1188
1217
|
workspaceId = legacyResolve.workspaceId;
|
|
1189
|
-
scan = await withLoadingIndicator(`Starting ${modeLabel.toLowerCase()} scan...`, flags, () =>
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
mode: requestedMode,
|
|
1194
|
-
force: forceRestart || flags.force === true,
|
|
1195
|
-
},
|
|
1196
|
-
})
|
|
1197
|
-
.then(normalizeScanStartResponse));
|
|
1218
|
+
scan = await withLoadingIndicator(`Starting ${modeLabel.toLowerCase()} scan...`, flags, () => requestScanStart(`/api/cli/v1/local-workspaces/${encodeURIComponent(workspaceId)}/scan`, {
|
|
1219
|
+
mode: requestedMode,
|
|
1220
|
+
force: forceRestart || flags.force === true,
|
|
1221
|
+
}));
|
|
1198
1222
|
}
|
|
1199
1223
|
else {
|
|
1200
1224
|
try {
|
|
@@ -1206,15 +1230,11 @@ export async function commandScan(client, cwd, flags) {
|
|
|
1206
1230
|
detectedSources: result.sources,
|
|
1207
1231
|
preparedArchives,
|
|
1208
1232
|
});
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
force: forceRestart || flags.force === true,
|
|
1214
|
-
scanSources,
|
|
1215
|
-
},
|
|
1233
|
+
return requestScanStart(`/api/cli/v2/local-workspaces/${encodeURIComponent(resolvedWorkspaceId)}/scan`, {
|
|
1234
|
+
mode: requestedMode,
|
|
1235
|
+
force: forceRestart || flags.force === true,
|
|
1236
|
+
scanSources,
|
|
1216
1237
|
});
|
|
1217
|
-
return normalizeScanStartResponse(response);
|
|
1218
1238
|
});
|
|
1219
1239
|
}
|
|
1220
1240
|
finally {
|
|
@@ -1877,7 +1897,7 @@ export async function openCurrentApexView(client, cwd, flags) {
|
|
|
1877
1897
|
}
|
|
1878
1898
|
return url;
|
|
1879
1899
|
}
|
|
1880
|
-
async function
|
|
1900
|
+
async function resolveCommandFinding(client, cwd, flags, requestedFindingRef) {
|
|
1881
1901
|
const findingRef = normalizeFindingRefInput(requestedFindingRef);
|
|
1882
1902
|
const currentBinding = await loadWorkspaceBinding(cwd);
|
|
1883
1903
|
const binding = isFindingUuid(findingRef)
|
|
@@ -1888,7 +1908,7 @@ async function resolveTicketFinding(client, cwd, flags, requestedFindingRef) {
|
|
|
1888
1908
|
requestedScanId: getFlagString(flags, "scan") });
|
|
1889
1909
|
}
|
|
1890
1910
|
export async function commandFindingTickets(client, cwd, flags, findingRef) {
|
|
1891
|
-
const selection = await
|
|
1911
|
+
const selection = await resolveCommandFinding(client, cwd, flags, findingRef);
|
|
1892
1912
|
const result = await fetchFindingTickets(client, selection.findingId);
|
|
1893
1913
|
const payload = { findingId: selection.findingId, findingRef: selection.findingRef, ...result };
|
|
1894
1914
|
if (isJsonMode(flags))
|
|
@@ -1904,7 +1924,7 @@ export async function commandFindingLinkTicket(client, cwd, flags, findingRef, p
|
|
|
1904
1924
|
if ((provider !== "linear" && provider !== "jira") || !issueRef.trim() || issueRef.trim().length > 2048) {
|
|
1905
1925
|
throw new Error("Usage: apex findings link-ticket <finding-ref> <linear|jira> <issue-key|url>");
|
|
1906
1926
|
}
|
|
1907
|
-
const selection = await
|
|
1927
|
+
const selection = await resolveCommandFinding(client, cwd, flags, findingRef);
|
|
1908
1928
|
const result = await linkFindingTicket(client, selection.findingId, { provider, issueRef: issueRef.trim() });
|
|
1909
1929
|
const payload = { findingId: selection.findingId, findingRef: selection.findingRef, ...result };
|
|
1910
1930
|
if (isJsonMode(flags))
|
|
@@ -1917,7 +1937,7 @@ export async function commandFindingSend(client, cwd, flags, findingRef, provide
|
|
|
1917
1937
|
if (provider !== "linear" && provider !== "jira") {
|
|
1918
1938
|
throw new Error("Usage: apex findings send <finding-ref> <linear|jira>");
|
|
1919
1939
|
}
|
|
1920
|
-
const selection = await
|
|
1940
|
+
const selection = await resolveCommandFinding(client, cwd, flags, findingRef);
|
|
1921
1941
|
const result = await sendFindingToIssueTracker(client, selection.findingId, provider);
|
|
1922
1942
|
const payload = { findingId: selection.findingId, findingRef: selection.findingRef, ...result };
|
|
1923
1943
|
if (isJsonMode(flags))
|
|
@@ -1926,3 +1946,29 @@ export async function commandFindingSend(client, cwd, flags, findingRef, provide
|
|
|
1926
1946
|
logLine(`${result.created ? "Created" : "Already sent"}: ${result.export.issueKey ?? result.export.issueId} ${result.export.issueUrl}`, flags);
|
|
1927
1947
|
return payload;
|
|
1928
1948
|
}
|
|
1949
|
+
export async function commandFindingStatus(client, cwd, flags, findingRef, status, note, expectedUpdatedAt) {
|
|
1950
|
+
if (!FINDING_LIFECYCLE_STATUSES.includes(status)) {
|
|
1951
|
+
throw new Error(`Usage: apex findings status <finding-ref> <${FINDING_LIFECYCLE_STATUSES.join("|")}> [--note <rationale>]`);
|
|
1952
|
+
}
|
|
1953
|
+
const trimmedNote = note?.trim();
|
|
1954
|
+
if (trimmedNote && trimmedNote.length > 1000)
|
|
1955
|
+
throw new Error("Status notes must be 1000 characters or less.");
|
|
1956
|
+
if (expectedUpdatedAt && (!/^\d{4}-\d{2}-\d{2}T/.test(expectedUpdatedAt) || !Number.isFinite(Date.parse(expectedUpdatedAt)))) {
|
|
1957
|
+
throw new Error("--expected-updated-at must be an ISO timestamp from the finding's updatedAt.");
|
|
1958
|
+
}
|
|
1959
|
+
const selection = await resolveCommandFinding(client, cwd, flags, findingRef);
|
|
1960
|
+
const result = await setFindingStatus(client, selection.findingId, {
|
|
1961
|
+
status: status,
|
|
1962
|
+
...(trimmedNote ? { note: trimmedNote } : {}),
|
|
1963
|
+
...(expectedUpdatedAt ? { expectedUpdatedAt } : {}),
|
|
1964
|
+
});
|
|
1965
|
+
const payload = { ...result, findingRef: selection.findingRef };
|
|
1966
|
+
if (isJsonMode(flags))
|
|
1967
|
+
printJson(payload);
|
|
1968
|
+
else {
|
|
1969
|
+
logLine(`Finding: ${selection.findingRef}`, flags);
|
|
1970
|
+
logLine(`Status: ${result.status} (${result.reviewState})${result.changed ? "" : " — already set"}`, flags);
|
|
1971
|
+
logLine("Scanner validity and review feedback are unchanged.", flags);
|
|
1972
|
+
}
|
|
1973
|
+
return payload;
|
|
1974
|
+
}
|
package/dist/findings.js
CHANGED
|
@@ -5,7 +5,7 @@ const WORKSPACE_FINDING_FILTER_PATTERN = /^!?(severity|scan|repo|review|impact|l
|
|
|
5
5
|
const MAX_WORKSPACE_FINDING_FILTERS = 50;
|
|
6
6
|
const MAX_WORKSPACE_FINDING_FILTER_LENGTH = 128;
|
|
7
7
|
const CANTINA_AUTH_TOKEN_ENV = "CANTINA_AUTH_TOKEN";
|
|
8
|
-
const FINDING_WRITE_AUTH_ERROR = "Finding comments, feedback, ticket links, ticket creation, and fix review scans require Apex authentication. Run `apex login` or use MCP `apex-auth-start` followed by `apex-auth-wait`, then retry.";
|
|
8
|
+
const FINDING_WRITE_AUTH_ERROR = "Finding comments, feedback, status changes, ticket links, ticket creation, and fix review scans require Apex authentication. Run `apex login` or use MCP `apex-auth-start` followed by `apex-auth-wait`, then retry.";
|
|
9
9
|
function parsePositiveInt(value) {
|
|
10
10
|
if (!value)
|
|
11
11
|
return null;
|
|
@@ -321,6 +321,11 @@ export async function startFindingFixReviewScan(client, findingId, input = {}) {
|
|
|
321
321
|
export async function fetchFindingTickets(client, findingId) {
|
|
322
322
|
return requestFindingAppRoute(client, `/api/findings/${encodeURIComponent(findingId)}/tickets`);
|
|
323
323
|
}
|
|
324
|
+
export async function setFindingStatus(client, findingId, input) {
|
|
325
|
+
return requestFindingAppRoute(client, `/api/findings/${encodeURIComponent(findingId)}/status`, {
|
|
326
|
+
method: "PATCH", json: input,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
324
329
|
export async function linkFindingTicket(client, findingId, input) {
|
|
325
330
|
return requestFindingAppRoute(client, `/api/findings/${encodeURIComponent(findingId)}/tickets`, {
|
|
326
331
|
method: "POST", json: input,
|
package/dist/help.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const CLI_HELP_TEXT = `Usage:
|
|
2
2
|
apex Open the interactive Apex shell
|
|
3
3
|
apex credits Show standard, audit, fix review, and Lite scan entitlements for the active company
|
|
4
|
-
apex scan
|
|
4
|
+
apex scan [target...] 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
7
|
apex findings --workspace List findings across the workspace browser scan set
|
|
@@ -14,6 +14,8 @@ export const CLI_HELP_TEXT = `Usage:
|
|
|
14
14
|
Add a comment or note to a finding
|
|
15
15
|
apex findings feedback <finding-id|finding-identifier> <valid|invalid>
|
|
16
16
|
Leave feedback; invalid requires --dismissal-reason
|
|
17
|
+
apex findings status <finding-ref> <open|fixed|acknowledged|in_review|pending_merge|false_positive|wont_fix|skipped>
|
|
18
|
+
Close, reopen, or update client remediation status
|
|
17
19
|
apex findings fix-review <finding-id|finding-identifier>
|
|
18
20
|
Start a fix review scan for a Fix PR or current repository
|
|
19
21
|
apex export findings Export findings for the latest or selected scan
|
|
@@ -56,6 +58,8 @@ Flags:
|
|
|
56
58
|
--status valid|invalid Set the finding feedback status explicitly
|
|
57
59
|
--content <markdown> Supply comment content for apex findings comment
|
|
58
60
|
--comment <markdown> Supply rationale for apex findings feedback
|
|
61
|
+
--note <rationale> Audit note for a finding status change (max 1000 characters)
|
|
62
|
+
--expected-updated-at <timestamp> Reject a status change if the finding has changed since this updatedAt
|
|
59
63
|
--parent-comment <comment-id> Reply to an existing finding comment
|
|
60
64
|
--suggested-severity extreme|critical|high|medium|low|informational
|
|
61
65
|
Attach a suggested severity to valid feedback
|
|
@@ -75,7 +79,7 @@ Flags:
|
|
|
75
79
|
--source-mode auto|remote|local Control remote-vs-local source materialization
|
|
76
80
|
--force Start an overlapping non-PR scan, or confirm destructive guest logout
|
|
77
81
|
--replace Revoke and replace an existing guest trial session
|
|
78
|
-
--repo <path>
|
|
82
|
+
--repo <path-or-url> Select a file, directory, or HTTPS repository (repeatable)
|
|
79
83
|
--json Print machine-readable JSON output
|
|
80
84
|
--no-open Do not open browser flows automatically
|
|
81
85
|
--non-interactive Disable prompts and browser-dependent UX
|
|
@@ -83,7 +87,9 @@ Flags:
|
|
|
83
87
|
|
|
84
88
|
Tips:
|
|
85
89
|
apex scan uses the current directory name as the default workspace name unless you pass --workspace-name.
|
|
86
|
-
apex scan
|
|
90
|
+
apex scan defaults to the current directory; positional targets or --repo select files, directories, or HTTPS GitHub/GitLab repositories.
|
|
91
|
+
A file target scans only that file. Use --mode standard for local changes and public repositories without a provider connection.
|
|
92
|
+
Headless and --json commands require an existing session; run apex login explicitly first.
|
|
87
93
|
PR scans require --mode pr and at least one --pr selection for a GitHub repository.
|
|
88
94
|
audit is the current scan mode for audit scans; ultra remains accepted as a legacy alias.
|
|
89
95
|
Lite scans use --mode lite and require beta access plus provider-backed GitHub or GitLab sources.
|
|
@@ -132,6 +138,9 @@ Audit Setup beta:
|
|
|
132
138
|
--no-feedback-context Exclude prior feedback from this setup
|
|
133
139
|
--wait Wait for the next actionable setup state
|
|
134
140
|
--timeout <seconds> Bound next --wait (default 60, maximum 3600)
|
|
141
|
+
Setup uses saved workspace repositories and context, not local target selections.
|
|
142
|
+
Use the draft ID returned by setup: apex threat-model status --draft <uuid>.
|
|
143
|
+
Lite and PR scans do not use --setup.
|
|
135
144
|
`;
|
|
136
145
|
export function cliHelpText(auditSetupEnabled) {
|
|
137
146
|
return auditSetupEnabled
|
|
@@ -142,7 +151,7 @@ export const SHELL_HELP_TEXT = `Press Tab to autocomplete commands and common ar
|
|
|
142
151
|
|
|
143
152
|
Commands:
|
|
144
153
|
/credits Show standard, audit, fix review, and Lite scan entitlements for the active company
|
|
145
|
-
/scan [standard|audit|lite]
|
|
154
|
+
/scan [standard|audit|lite] [target...]
|
|
146
155
|
Start a new Apex scan for this workspace
|
|
147
156
|
/scan pr <pr-number> Start a PR scan for this workspace
|
|
148
157
|
/scans List scans for this workspace
|
|
@@ -158,6 +167,8 @@ Commands:
|
|
|
158
167
|
Add a comment or note to a finding
|
|
159
168
|
/findings feedback <finding-ref> valid [comment]
|
|
160
169
|
Leave valid feedback on a finding
|
|
170
|
+
/findings status <finding-ref> <open|fixed|acknowledged|in_review|pending_merge|false_positive|wont_fix|skipped> [note]
|
|
171
|
+
Close, reopen, or update client remediation status
|
|
161
172
|
/findings feedback <finding-ref> invalid <reason> [comment]
|
|
162
173
|
Leave invalid feedback; reason is false-positive, by-design, or not-relevant
|
|
163
174
|
/findings fix-review <finding-ref>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
3
|
import { createReadStream } from "node:fs";
|
|
4
|
-
import { lstat, mkdtemp, readFile, readdir, readlink, rm, stat, } from "node:fs/promises";
|
|
4
|
+
import { lstat, mkdtemp, readFile, readdir, readlink, realpath, rm, stat, } from "node:fs/promises";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { promisify } from "node:util";
|
|
@@ -11,6 +11,8 @@ const execFileAsync = promisify(execFile);
|
|
|
11
11
|
const MAX_ARCHIVE_ENTRIES = 100_000;
|
|
12
12
|
const MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
13
13
|
const BUILTIN_IGNORE_PATTERNS = [
|
|
14
|
+
".apex",
|
|
15
|
+
".apex/**",
|
|
14
16
|
".git",
|
|
15
17
|
".git/**",
|
|
16
18
|
"node_modules",
|
|
@@ -89,6 +91,9 @@ async function buildIgnoreMatcher(rootPath) {
|
|
|
89
91
|
throw new Error(".apexignore is not valid UTF-8.");
|
|
90
92
|
}
|
|
91
93
|
matcher.add(decoded);
|
|
94
|
+
// Local CLI state is never scan input, even when a broad negation in the
|
|
95
|
+
// user's ignore file re-includes ordinary generated files.
|
|
96
|
+
matcher.add([".apex", ".apex/**"]);
|
|
92
97
|
}
|
|
93
98
|
catch (error) {
|
|
94
99
|
if (error.code !== "ENOENT") {
|
|
@@ -207,10 +212,19 @@ async function collectDirectoryEntries(rootPath, matcher) {
|
|
|
207
212
|
return entries;
|
|
208
213
|
}
|
|
209
214
|
async function collectArchiveEntries(source) {
|
|
215
|
+
if (!source.absolutePath) {
|
|
216
|
+
throw new Error("A remote repository URL cannot use a local snapshot. Connect its provider or clone it locally, then retry.");
|
|
217
|
+
}
|
|
218
|
+
const resolvedRoot = await realpath(source.absolutePath);
|
|
219
|
+
if (resolvedRoot.split(path.sep).some((segment) => segment.toLowerCase() === ".apex")) {
|
|
220
|
+
throw new Error("Apex local state (.apex) cannot be selected as scan input. Select your source files or repository instead.");
|
|
221
|
+
}
|
|
210
222
|
const matcher = await buildIgnoreMatcher(source.absolutePath);
|
|
211
223
|
const gitEntries = await collectGitEntries(source.absolutePath);
|
|
212
|
-
const
|
|
213
|
-
|
|
224
|
+
const selectedFiles = source.kind === "directory_candidate" ? source.selectedFiles : undefined;
|
|
225
|
+
const rawEntries = selectedFiles
|
|
226
|
+
? selectedFiles.filter((entry) => !gitEntries || gitEntries.includes(entry))
|
|
227
|
+
: gitEntries ?? await collectDirectoryEntries(source.absolutePath, matcher);
|
|
214
228
|
const fileEntries = new Set();
|
|
215
229
|
let uncompressedBytes = 0;
|
|
216
230
|
for (const entry of rawEntries.sort(compareArchivePaths)) {
|
|
@@ -305,7 +319,13 @@ async function inspectCreatedArchive(archivePath, sourceRoot, expectedFiles, dis
|
|
|
305
319
|
return { entryCount, uncompressedBytes };
|
|
306
320
|
}
|
|
307
321
|
async function createDeterministicArchive(params) {
|
|
322
|
+
const sourceRoot = params.source.absolutePath;
|
|
323
|
+
if (!sourceRoot)
|
|
324
|
+
throw new Error("Remote repository targets require remote materialization.");
|
|
308
325
|
const { files, uncompressedBytes } = await collectArchiveEntries(params.source);
|
|
326
|
+
if (files.length === 0) {
|
|
327
|
+
throw new Error(`No scannable files found in ${params.source.displayName}. Check the target and ignore rules.`);
|
|
328
|
+
}
|
|
309
329
|
if (files.length > MAX_ARCHIVE_ENTRIES) {
|
|
310
330
|
throw new Error(`Apex local snapshots support at most ${MAX_ARCHIVE_ENTRIES.toLocaleString()} entries per source.`);
|
|
311
331
|
}
|
|
@@ -313,7 +333,7 @@ async function createDeterministicArchive(params) {
|
|
|
313
333
|
throw new Error(`Archive for ${params.source.displayName} exceeds the uncompressed upload limit.`);
|
|
314
334
|
}
|
|
315
335
|
await tar.create({
|
|
316
|
-
cwd:
|
|
336
|
+
cwd: sourceRoot,
|
|
317
337
|
file: params.archivePath,
|
|
318
338
|
gzip: true,
|
|
319
339
|
portable: true,
|
|
@@ -334,7 +354,7 @@ async function createDeterministicArchive(params) {
|
|
|
334
354
|
entry.gname = "root";
|
|
335
355
|
},
|
|
336
356
|
}, files);
|
|
337
|
-
const observedArchive = await inspectCreatedArchive(params.archivePath,
|
|
357
|
+
const observedArchive = await inspectCreatedArchive(params.archivePath, sourceRoot, files, params.source.displayName, params.maxUncompressedBytes);
|
|
338
358
|
const manifest = await createArchiveManifest(params.archivePath);
|
|
339
359
|
return {
|
|
340
360
|
...manifest,
|
package/dist/mcp.js
CHANGED
|
@@ -6,11 +6,12 @@ import { z } from "zod";
|
|
|
6
6
|
import { getMe, logout, readStableDeviceLoginState, startDeviceLogin, waitForDeviceLoginApproval, } from "./auth.js";
|
|
7
7
|
import { ApexApiClient, formatApiError } from "./api-client.js";
|
|
8
8
|
import { getClaimProofState, isClaimProofAvailable, } from "./claim-proof.js";
|
|
9
|
-
import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingTickets, commandFindingLinkTicket, commandFindingSend, commandFindingFeedback, commandFindingFixReview, commandFindings, commandWorkspaceFindings, commandScan, commandScans, commandStatus, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
|
|
9
|
+
import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingStatus, commandFindingTickets, commandFindingLinkTicket, commandFindingSend, commandFindingFeedback, commandFindingFixReview, commandFindings, commandWorkspaceFindings, commandScan, commandScans, commandStatus, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
|
|
10
10
|
import { cliHelpText } from "./help.js";
|
|
11
11
|
import { getLocalTrialStatus, isProofOnlyOnboardingCredentials, revokeGuestOnboardingSession, } from "./onboarding.js";
|
|
12
12
|
import { createMcpServerTelemetryInvocation, createMcpTelemetryInvocation, emitInvocationCompleted, emitInvocationStarted, emitMcpServerStarted, withTelemetryContext, } from "./telemetry.js";
|
|
13
13
|
import { APEX_CLI_VERSION } from "./version.js";
|
|
14
|
+
import { FINDING_LIFECYCLE_STATUSES } from "./types.js";
|
|
14
15
|
const MCP_WORKFLOW_GUIDE = `Use Apex through these tools instead of shelling out to the CLI.
|
|
15
16
|
|
|
16
17
|
Suggested workflow:
|
|
@@ -23,11 +24,11 @@ Suggested workflow:
|
|
|
23
24
|
7. Start and monitor scans with apex-scan, apex-status, apex-scans, apex-findings, and apex-export-findings. For cross-scan review queues, call apex-workspace-findings with company, workspaceRef, and findingState "open"; this direct selection does not require or change a local binding.
|
|
24
25
|
8. For PR scans, call apex-scan with mode "pr" and one or more pullRequests.
|
|
25
26
|
9. Send findings to Linear/Jira with apex-finding-send to create tickets using the frontend's saved destination. Link existing tickets with apex-finding-link-ticket and inspect links with apex-finding-tickets. The company integration must already be connected.
|
|
26
|
-
10.
|
|
27
|
+
10. Close or reopen findings with apex-finding-status using status fixed or open. This changes remediation, not scanner validity or feedback. Leave comments or valid/invalid feedback with apex-finding-comment and apex-finding-feedback when review collaboration is needed.
|
|
27
28
|
11. To review a fix, call apex-finding-fix-review. Pass fixPrUrls for PR-based fix reviews, or omit them to review the current repository state when Bedrock has original scan repository context.
|
|
28
29
|
|
|
29
30
|
Notes:
|
|
30
|
-
- Pass cwd explicitly for repository-specific operations.
|
|
31
|
+
- Pass cwd explicitly for repository-specific operations. repoPaths accepts exact files, directories, or HTTPS repository URLs; remote URLs never fall back to uploading cwd. Use Standard for local changes and unconnected public repositories.
|
|
31
32
|
- Tool calls are always non-interactive and never auto-open a browser.
|
|
32
33
|
- Finding write tools use the same Apex device-login credentials as read tools. Never ask for browser cookies or CANTINA_AUTH_TOKEN. An error requiring CANTINA_AUTH_TOKEN proves the MCP server is outdated; update Apex CLI, re-run apex setup for this client, restart it, and retry.
|
|
33
34
|
- Organization service-key creation, listing, and revocation are intentionally scripted-CLI-only and are not exposed as MCP tools. Never ask a user to paste a service key into a prompt or tool argument. A hosted MCP client supplies it only as the HTTP Authorization header.
|
|
@@ -42,6 +43,8 @@ const AUDIT_SETUP_MCP_GUIDE = `
|
|
|
42
43
|
|
|
43
44
|
Audit Setup beta:
|
|
44
45
|
- Use apex-scan-setup for a configured workspace, then apex-threat-model.
|
|
46
|
+
- Setup uses saved workspace repositories and context, not repoPaths or local file selections. Never substitute setup for an explicit source scan after AUDIT_SETUP_REQUIRED.
|
|
47
|
+
- Use the draft ID returned by setup for apex-threat-model; setup supports Standard and Audit, not Lite or PR.
|
|
45
48
|
- Read the complete model and get explicit approval before approve with the reviewed sha256 and startScan true.
|
|
46
49
|
- These tools use the local device account. Hosted service-key MCP does not expose Audit Setup.`;
|
|
47
50
|
function mcpWorkflowGuide(auditSetupEnabled) {
|
|
@@ -540,7 +543,7 @@ function registerTools(server, auditSetupEnabled = false) {
|
|
|
540
543
|
}, value => `Threat-model draft ${String(value.draftId)}: ${String(value.state)}.`, input));
|
|
541
544
|
server.registerTool("apex-scan", {
|
|
542
545
|
title: "Start Apex Scan",
|
|
543
|
-
description: "Start a new Apex scan for the provided cwd by default. Pass repoPaths
|
|
546
|
+
description: "Start a new Apex scan for the provided cwd by default. Pass repoPaths to select exact files, directories, or HTTPS GitHub/GitLab repository URLs. Remote URLs use the default branch; files never include siblings. Use mode audit for audit scans, mode lite for beta-gated provider-backed Lite scans, and mode pr for GitHub pull request scans.",
|
|
544
547
|
inputSchema: {
|
|
545
548
|
cwd: z.string().optional(),
|
|
546
549
|
company: z.string().optional(),
|
|
@@ -753,6 +756,22 @@ function registerTools(server, auditSetupEnabled = false) {
|
|
|
753
756
|
parentCommentId,
|
|
754
757
|
scanId,
|
|
755
758
|
}));
|
|
759
|
+
server.registerTool("apex-finding-status", {
|
|
760
|
+
title: "Change Apex Finding Status",
|
|
761
|
+
description: "Close a finding with fixed or reopen it with open. Changes client remediation only, preserving scanner validity, severity, feedback, and linked ticket status. Invalid or duplicate findings cannot be reopened until their classification is reviewed. Uses device login. Hosted MCP exposes the same tool with findingId and explicit findings:write. Optional expectedUpdatedAt prevents overwriting a newer edit.",
|
|
762
|
+
inputSchema: {
|
|
763
|
+
cwd: z.string().optional(),
|
|
764
|
+
findingRef: z.string(),
|
|
765
|
+
status: z.enum(FINDING_LIFECYCLE_STATUSES),
|
|
766
|
+
note: z.string().trim().max(1000).optional(),
|
|
767
|
+
expectedUpdatedAt: z.iso.datetime({ offset: true }).optional(),
|
|
768
|
+
scanId: z.string().optional(),
|
|
769
|
+
},
|
|
770
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
771
|
+
}, async ({ cwd, findingRef, status, note, expectedUpdatedAt, scanId }) => runTool("apex-finding-status", async () => {
|
|
772
|
+
const targetCwd = resolveCwd(cwd);
|
|
773
|
+
return { cwd: targetCwd, ...await commandFindingStatus(new ApexApiClient(), targetCwd, buildFlags({ scanId }), findingRef, status, note, expectedUpdatedAt) };
|
|
774
|
+
}, (value) => `Set finding ${String(value.findingRef)} to ${String(value.status)}.`, { cwd, findingRef, status, note, expectedUpdatedAt, scanId }));
|
|
756
775
|
server.registerTool("apex-finding-feedback", {
|
|
757
776
|
title: "Leave Apex Finding Feedback",
|
|
758
777
|
description: "Leave valid or invalid feedback on an Apex finding using the current Apex login. To attach a fix PR, send status valid with label fixed and fixPrUrls.",
|
package/dist/repo-discovery.js
CHANGED
|
@@ -7,6 +7,8 @@ const execFileAsync = promisify(execFile);
|
|
|
7
7
|
async function runGit(cwd, args) {
|
|
8
8
|
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
|
|
9
9
|
encoding: "utf8",
|
|
10
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
11
|
+
timeout: 30_000,
|
|
10
12
|
});
|
|
11
13
|
return stdout.trim();
|
|
12
14
|
}
|
|
@@ -49,18 +51,39 @@ async function chooseRemote(directory) {
|
|
|
49
51
|
return firstFetch.url;
|
|
50
52
|
return remotes[0]?.url ?? null;
|
|
51
53
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
function inspectRemoteTarget(value) {
|
|
55
|
+
if (!/^(?:[a-z][a-z0-9+.-]*:\/\/|git@|(?:github|gitlab)\.com\/)/i.test(value))
|
|
56
|
+
return null;
|
|
57
|
+
const target = value.replace(/^(github|gitlab)\.com\//i, "https://$1.com/");
|
|
58
|
+
let url;
|
|
59
|
+
try {
|
|
60
|
+
url = new URL(target);
|
|
56
61
|
}
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
catch {
|
|
63
|
+
throw new Error("Remote scan targets must be HTTPS GitHub or GitLab repository URLs.");
|
|
59
64
|
}
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
const segments = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean);
|
|
66
|
+
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.port ||
|
|
67
|
+
!["github.com", "gitlab.com"].includes(url.hostname) || segments.length < 2 ||
|
|
68
|
+
(url.hostname === "github.com" && segments.length !== 2) || segments.includes("-")) {
|
|
69
|
+
throw new Error("Remote scan targets must be HTTPS GitHub or GitLab repository URLs without credentials, query strings, or file/tree paths.");
|
|
62
70
|
}
|
|
63
|
-
|
|
71
|
+
const metadata = extractCanonicalRepoMetadata(url.href);
|
|
72
|
+
if (!metadata)
|
|
73
|
+
throw new Error("Unsupported remote repository target.");
|
|
74
|
+
return {
|
|
75
|
+
kind: "git_candidate",
|
|
76
|
+
path: `remote/${metadata.provider}/${segments.join("/").replace(/\.git$/i, "")}`,
|
|
77
|
+
absolutePath: null,
|
|
78
|
+
displayName: metadata.repoName,
|
|
79
|
+
repoUrl: metadata.repoUrl,
|
|
80
|
+
provider: metadata.provider,
|
|
81
|
+
branch: null,
|
|
82
|
+
commitSha: null,
|
|
83
|
+
dirty: false,
|
|
84
|
+
isRoot: false,
|
|
85
|
+
sourceType: "branch",
|
|
86
|
+
};
|
|
64
87
|
}
|
|
65
88
|
function normalizeSelectedRoots(cwd, selectedPaths) {
|
|
66
89
|
const resolved = selectedPaths
|
|
@@ -86,7 +109,7 @@ async function inspectGitRepository(rootCwd, directory) {
|
|
|
86
109
|
const metadata = remoteUrl ? extractCanonicalRepoMetadata(remoteUrl) : null;
|
|
87
110
|
const branchName = await runGit(directory, ["rev-parse", "--abbrev-ref", "HEAD"]).catch(() => "HEAD");
|
|
88
111
|
const commitSha = await runGit(directory, ["rev-parse", "HEAD"]).catch(() => null);
|
|
89
|
-
const dirtyOutput = await runGit(directory, ["status", "--porcelain"]).catch(() =>
|
|
112
|
+
const dirtyOutput = await runGit(directory, ["status", "--porcelain", "--", ".", ":(exclude).apex"]).catch(() => null);
|
|
90
113
|
const relativePath = path.relative(rootCwd, directory) || ".";
|
|
91
114
|
const detached = !branchName || branchName === "HEAD";
|
|
92
115
|
return {
|
|
@@ -98,13 +121,31 @@ async function inspectGitRepository(rootCwd, directory) {
|
|
|
98
121
|
provider: metadata?.provider ?? null,
|
|
99
122
|
branch: detached ? null : branchName,
|
|
100
123
|
commitSha: commitSha && commitSha.length > 0 ? commitSha : null,
|
|
101
|
-
|
|
124
|
+
// Unknown working-tree state must use a snapshot, never assume the remote
|
|
125
|
+
// commit contains the user's current files.
|
|
126
|
+
dirty: dirtyOutput === null || dirtyOutput.length > 0,
|
|
102
127
|
isRoot: relativePath === ".",
|
|
103
128
|
sourceType: detached ? "commit" : "branch",
|
|
104
129
|
};
|
|
105
130
|
}
|
|
106
131
|
async function inspectSource(rootCwd, requestedDirectory) {
|
|
107
|
-
const
|
|
132
|
+
const stats = await lstat(requestedDirectory).catch(() => null);
|
|
133
|
+
const requestedPath = path.relative(rootCwd, requestedDirectory) || ".";
|
|
134
|
+
if (!stats)
|
|
135
|
+
throw new Error(`Path does not exist: ${requestedPath}`);
|
|
136
|
+
if (stats.isFile()) {
|
|
137
|
+
return {
|
|
138
|
+
kind: "directory_candidate",
|
|
139
|
+
path: requestedPath,
|
|
140
|
+
absolutePath: path.dirname(requestedDirectory),
|
|
141
|
+
displayName: path.basename(requestedDirectory),
|
|
142
|
+
isRoot: false,
|
|
143
|
+
selectedFiles: [path.basename(requestedDirectory)],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (!stats.isDirectory())
|
|
147
|
+
throw new Error(`Path is not a regular file or directory: ${requestedPath}`);
|
|
148
|
+
const directory = requestedDirectory;
|
|
108
149
|
const relativePath = path.relative(rootCwd, directory) || ".";
|
|
109
150
|
const gitTopLevel = await getGitTopLevel(directory);
|
|
110
151
|
const comparableDirectory = await resolveComparablePath(directory);
|
|
@@ -120,10 +161,22 @@ async function inspectSource(rootCwd, requestedDirectory) {
|
|
|
120
161
|
};
|
|
121
162
|
}
|
|
122
163
|
export async function discoverSources(cwd, selectedPaths = []) {
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
164
|
+
const remoteSources = [];
|
|
165
|
+
const localPaths = [];
|
|
166
|
+
for (const target of selectedPaths) {
|
|
167
|
+
const remote = inspectRemoteTarget(target);
|
|
168
|
+
if (remote) {
|
|
169
|
+
if (!remoteSources.some((source) => source.path === remote.path))
|
|
170
|
+
remoteSources.push(remote);
|
|
171
|
+
}
|
|
172
|
+
else
|
|
173
|
+
localPaths.push(target);
|
|
174
|
+
}
|
|
175
|
+
const discoveredRoots = selectedPaths.length === 0 ? [cwd] : normalizeSelectedRoots(cwd, localPaths);
|
|
176
|
+
const sources = [...remoteSources, ...await Promise.all(discoveredRoots.map((root) => inspectSource(cwd, root)))];
|
|
177
|
+
if (new Set(sources.map((source) => source.path)).size !== sources.length) {
|
|
178
|
+
throw new Error("Scan targets have conflicting source paths. Select them in separate scans.");
|
|
179
|
+
}
|
|
127
180
|
sources.sort((left, right) => left.path.localeCompare(right.path));
|
|
128
181
|
return { sources, scanned: discoveredRoots };
|
|
129
182
|
}
|