@zivis/cli 0.1.0-alpha.46 → 0.1.0-alpha.52
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/dist/commands/assurance/index.d.ts +2 -0
- package/dist/commands/assurance/index.js +6 -1
- package/dist/commands/run/index.js +74 -1
- package/dist/commands/share/index.d.ts +14 -0
- package/dist/commands/share/index.js +100 -0
- package/dist/commands/sync/index.js +8 -2
- package/dist/commands/test/index.js +85 -3
- package/dist/index.js +2 -0
- package/dist/internal/change-recommendation.d.ts +6 -0
- package/dist/internal/change-recommendation.js +113 -0
- package/dist/internal/devx-run.d.ts +61 -0
- package/dist/internal/devx-run.js +3 -0
- package/dist/internal/git.d.ts +1 -0
- package/dist/internal/git.js +15 -0
- package/dist/internal/packs/index.d.ts +1 -1
- package/dist/internal/packs/resolve.js +1 -1
- package/dist/internal/packs/types.d.ts +7 -0
- package/dist/internal/security-context.d.ts +1 -0
- package/dist/internal/test-scope.d.ts +32 -1
- package/dist/internal/test-scope.js +35 -3
- package/dist/internal/trust-room-link.d.ts +10 -0
- package/dist/internal/trust-room-link.js +15 -0
- package/package.json +2 -2
|
@@ -2,6 +2,7 @@ import { ApiClient, ApiError } from "@zivis/mcp/api-client";
|
|
|
2
2
|
import { resolveConfigFromBinding } from "@zivis/mcp/project-binding";
|
|
3
3
|
import { requireApplicationId } from "@zivis/mcp/resolve-application-id";
|
|
4
4
|
import { jsonEnvelope, printJson, failCommand, EXIT } from "../../internal/cli-output.js";
|
|
5
|
+
import { trustRoomJsonFields, trustRoomHumanLines } from "../../internal/trust-room-link.js";
|
|
5
6
|
function buildClient(opts) {
|
|
6
7
|
const config = opts.session
|
|
7
8
|
? { ...resolveConfigFromBinding(process.cwd()), session: opts.session }
|
|
@@ -50,12 +51,14 @@ export function registerAssuranceCommands(program) {
|
|
|
50
51
|
query.set("category", opts.category);
|
|
51
52
|
const status = await client.get(`/api/assurance/status?${query.toString()}`);
|
|
52
53
|
if (json) {
|
|
53
|
-
printJson(jsonEnvelope("assurance status", { result: "ok", ...status }));
|
|
54
|
+
printJson(jsonEnvelope("assurance status", { result: "ok", ...status, ...trustRoomJsonFields(status) }));
|
|
54
55
|
return;
|
|
55
56
|
}
|
|
56
57
|
console.log(`Model: ${status.model.displayName} (${status.model.key})${status.model.version ? ` v${status.model.version}` : ""}`);
|
|
57
58
|
if (!status.evaluatedAt) {
|
|
58
59
|
console.log("No completed evaluation for this Application/model yet.");
|
|
60
|
+
for (const line of trustRoomHumanLines(status))
|
|
61
|
+
console.log(line);
|
|
59
62
|
return;
|
|
60
63
|
}
|
|
61
64
|
console.log(`Overall: score_raw=${fmtScore(status.overall.scoreRaw)} coverage=${fmtPct(status.overall.coverage)} (${status.overall.determinedCount}/${status.overall.totalCount} determined)`);
|
|
@@ -69,6 +72,8 @@ export function registerAssuranceCommands(program) {
|
|
|
69
72
|
console.log(`Last evaluated git SHA: ${status.lastEvaluatedGitSha ?? "unknown"}`);
|
|
70
73
|
console.log(`Framework id: ${status.model.frameworkId ?? "—"}`);
|
|
71
74
|
}
|
|
75
|
+
for (const line of trustRoomHumanLines(status))
|
|
76
|
+
console.log(line);
|
|
72
77
|
}
|
|
73
78
|
catch (err) {
|
|
74
79
|
if (isAmbiguousModelError(err)) {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import { ApiClient } from "@zivis/mcp/api-client";
|
|
3
3
|
import { resolveConfigFromBinding } from "@zivis/mcp/project-binding";
|
|
4
|
-
import { DEVX_RUN_CONTRACT_VERSION, completeDevXRunRemote, cancelDevXRunRemote, isRetryableDevXRunError, } from "../../internal/devx-run.js";
|
|
4
|
+
import { DEVX_RUN_CONTRACT_VERSION, completeDevXRunRemote, cancelDevXRunRemote, reportDevXRunItems, isRetryableDevXRunError, } from "../../internal/devx-run.js";
|
|
5
5
|
import { writeDevXRunCompleteOutboxEntry } from "../../internal/sync-outbox.js";
|
|
6
6
|
import { jsonEnvelope, printJson, EXIT, errorMessage, failCommand } from "../../internal/cli-output.js";
|
|
7
|
+
import { trustRoomJsonFields, trustRoomHumanLines } from "../../internal/trust-room-link.js";
|
|
7
8
|
async function readInput(inputArg) {
|
|
8
9
|
const raw = inputArg === "-" ? await readStdin() : await fs.readFile(inputArg, "utf-8");
|
|
9
10
|
try {
|
|
@@ -59,6 +60,7 @@ export function registerRunCommands(program) {
|
|
|
59
60
|
coverage: reconciliation.coverage,
|
|
60
61
|
}
|
|
61
62
|
: {}),
|
|
63
|
+
...trustRoomJsonFields(response),
|
|
62
64
|
});
|
|
63
65
|
if (json) {
|
|
64
66
|
printJson(output);
|
|
@@ -78,6 +80,12 @@ export function registerRunCommands(program) {
|
|
|
78
80
|
if (reconciliation.evidence.length)
|
|
79
81
|
console.log(` ${reconciliation.evidence.length} artifact(s) persisted`);
|
|
80
82
|
}
|
|
83
|
+
const trustRoomLines = trustRoomHumanLines(response);
|
|
84
|
+
if (trustRoomLines.length) {
|
|
85
|
+
console.log("");
|
|
86
|
+
for (const line of trustRoomLines)
|
|
87
|
+
console.log(line);
|
|
88
|
+
}
|
|
81
89
|
}
|
|
82
90
|
process.exit(EXIT.OK);
|
|
83
91
|
}
|
|
@@ -101,6 +109,71 @@ export function registerRunCommands(program) {
|
|
|
101
109
|
failCommand("run complete", err, json);
|
|
102
110
|
}
|
|
103
111
|
});
|
|
112
|
+
run
|
|
113
|
+
.command("report <runId>")
|
|
114
|
+
.description("post confirmed findings/evidence/observations against a pending run as you go, without completing it (ZIV-332)")
|
|
115
|
+
.requiredOption("--input <path>", "path to a JSON item-batch file ({ findings?, evidence?, observations? }), or '-' for stdin")
|
|
116
|
+
.option("--json", "emit JSON only on stdout")
|
|
117
|
+
.action(async (runId, opts) => {
|
|
118
|
+
const json = !!opts.json;
|
|
119
|
+
let items;
|
|
120
|
+
try {
|
|
121
|
+
items = await readInput(opts.input);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
failCommand("run report", err, json);
|
|
125
|
+
}
|
|
126
|
+
if (!items || typeof items !== "object" || Array.isArray(items)) {
|
|
127
|
+
failCommand("run report", new Error("--input must be a JSON object with findings[], evidence[], and/or observations[]"), json);
|
|
128
|
+
}
|
|
129
|
+
const config = resolveConfigFromBinding(process.cwd());
|
|
130
|
+
const client = new ApiClient(config);
|
|
131
|
+
try {
|
|
132
|
+
const response = await reportDevXRunItems(client, { runId, items: items });
|
|
133
|
+
const output = jsonEnvelope("run report", {
|
|
134
|
+
run_id: runId,
|
|
135
|
+
outcome: response.outcome,
|
|
136
|
+
findings: response.items.findings,
|
|
137
|
+
evidence: response.items.evidence,
|
|
138
|
+
observations: response.items.observations,
|
|
139
|
+
...(response.rejectedObservations?.length ? { rejected_observations: response.rejectedObservations } : {}),
|
|
140
|
+
run_totals: response.runTotals,
|
|
141
|
+
});
|
|
142
|
+
if (json) {
|
|
143
|
+
printJson(output);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
const findings = response.items.findings;
|
|
147
|
+
const evidence = response.items.evidence;
|
|
148
|
+
const observations = response.items.observations;
|
|
149
|
+
const created = findings.filter((f) => f.action === "created").length;
|
|
150
|
+
const matched = findings.length - created;
|
|
151
|
+
const retest = findings.filter((f) => f.retestRecommendation !== null).length;
|
|
152
|
+
const withHistory = findings.filter((f) => f.dispositionHistory.length > 0).length;
|
|
153
|
+
const total = findings.length + evidence.length + observations.length;
|
|
154
|
+
console.log(`zivis run report: applied ${total} item${total === 1 ? "" : "s"} (run ${runId} stays pending)`);
|
|
155
|
+
if (created)
|
|
156
|
+
console.log(` + ${created} new finding${created === 1 ? "" : "s"}`);
|
|
157
|
+
if (matched)
|
|
158
|
+
console.log(` = ${matched} matched existing finding${matched === 1 ? "" : "s"}`);
|
|
159
|
+
if (retest)
|
|
160
|
+
console.log(` ↻ ${retest} retest recommendation${retest === 1 ? "" : "s"} — cite in retests[] on run complete`);
|
|
161
|
+
if (withHistory)
|
|
162
|
+
console.log(` ⚑ ${withHistory} finding${withHistory === 1 ? "" : "s"} with prior disposition history — surface to the human; never auto-apply`);
|
|
163
|
+
if (evidence.length)
|
|
164
|
+
console.log(` ${evidence.length} evidence item${evidence.length === 1 ? "" : "s"} persisted`);
|
|
165
|
+
if (observations.length)
|
|
166
|
+
console.log(` ${observations.length} observation${observations.length === 1 ? "" : "s"} recorded`);
|
|
167
|
+
if (response.rejectedObservations?.length)
|
|
168
|
+
console.log(` ! ${response.rejectedObservations.length} observation(s) rejected — see --json for reasons`);
|
|
169
|
+
console.log(` run totals so far: ${response.runTotals.findings} findings / ${response.runTotals.evidence} evidence / ${response.runTotals.observations} observations`);
|
|
170
|
+
}
|
|
171
|
+
process.exit(EXIT.OK);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
failCommand("run report", err, json);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
104
177
|
run
|
|
105
178
|
.command("cancel <runId>")
|
|
106
179
|
.description("cancel a pending run")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
export interface ShareResponse {
|
|
3
|
+
trustRoomId: string;
|
|
4
|
+
trustRoomName: string;
|
|
5
|
+
trustRoomUrl: string;
|
|
6
|
+
accessUrl: string;
|
|
7
|
+
email: string;
|
|
8
|
+
permission: string;
|
|
9
|
+
status: "shared" | "already_shared";
|
|
10
|
+
isNewUser: boolean;
|
|
11
|
+
emailSent: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function registerShareCommands(program: Command): void;
|
|
14
|
+
export default registerShareCommands;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import readline from "node:readline/promises";
|
|
2
|
+
import { ApiClient } from "@zivis/mcp/api-client";
|
|
3
|
+
import { resolveConfigFromBinding } from "@zivis/mcp/project-binding";
|
|
4
|
+
import { requireApplicationId } from "@zivis/mcp/resolve-application-id";
|
|
5
|
+
import { jsonEnvelope, printJson, failCommand, EXIT } from "../../internal/cli-output.js";
|
|
6
|
+
const COMMAND = "share";
|
|
7
|
+
const PERMISSIONS = ["view", "comment", "edit", "admin"];
|
|
8
|
+
async function prompt(question) {
|
|
9
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
10
|
+
try {
|
|
11
|
+
return (await rl.question(question)).trim();
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
rl.close();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function isValidEmail(value) {
|
|
18
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
19
|
+
}
|
|
20
|
+
export function registerShareCommands(program) {
|
|
21
|
+
program
|
|
22
|
+
.command("share")
|
|
23
|
+
.description("share this Application's current Assurance Room with someone by email")
|
|
24
|
+
.argument("[email]", "who to share with (prompted for when omitted)")
|
|
25
|
+
.option("-s, --session <name>", "named session for multi-tenant")
|
|
26
|
+
.option("--app <id>", "Application id (defaults to the bound Application)")
|
|
27
|
+
.option("--permission <level>", `access level: ${PERMISSIONS.join(" | ")}`, "view")
|
|
28
|
+
.option("--json", "emit machine-readable JSON")
|
|
29
|
+
.action(async (emailArg, opts) => {
|
|
30
|
+
const json = Boolean(opts.json);
|
|
31
|
+
try {
|
|
32
|
+
const permission = (opts.permission ?? "view").toLowerCase();
|
|
33
|
+
if (!PERMISSIONS.includes(permission)) {
|
|
34
|
+
throw new Error(`--permission must be one of: ${PERMISSIONS.join(", ")}`);
|
|
35
|
+
}
|
|
36
|
+
const resolvedApp = requireApplicationId(opts.app, process.cwd());
|
|
37
|
+
if (!resolvedApp.ok) {
|
|
38
|
+
if (json) {
|
|
39
|
+
printJson(jsonEnvelope(COMMAND, { result: "error", error: resolvedApp.message }));
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
console.error(`zivis ${COMMAND}: ${resolvedApp.message}`);
|
|
43
|
+
}
|
|
44
|
+
process.exit(EXIT.INVALID_OR_INDETERMINATE);
|
|
45
|
+
}
|
|
46
|
+
let email = (emailArg ?? "").trim();
|
|
47
|
+
if (!email) {
|
|
48
|
+
if (json) {
|
|
49
|
+
throw new Error("An email argument is required with --json (no prompting).");
|
|
50
|
+
}
|
|
51
|
+
email = await prompt("Share this Assurance Room with (email): ");
|
|
52
|
+
}
|
|
53
|
+
if (!isValidEmail(email)) {
|
|
54
|
+
throw new Error(`'${email}' does not look like an email address.`);
|
|
55
|
+
}
|
|
56
|
+
const config = opts.session
|
|
57
|
+
? { ...resolveConfigFromBinding(process.cwd()), session: opts.session }
|
|
58
|
+
: resolveConfigFromBinding(process.cwd());
|
|
59
|
+
const client = new ApiClient(config);
|
|
60
|
+
const res = await client.post("/api/assurance/share", {
|
|
61
|
+
applicationId: resolvedApp.id,
|
|
62
|
+
email,
|
|
63
|
+
permission,
|
|
64
|
+
});
|
|
65
|
+
if (json) {
|
|
66
|
+
printJson(jsonEnvelope(COMMAND, {
|
|
67
|
+
result: "ok",
|
|
68
|
+
status: res.status,
|
|
69
|
+
email: res.email,
|
|
70
|
+
permission: res.permission,
|
|
71
|
+
is_new_user: res.isNewUser,
|
|
72
|
+
email_sent: res.emailSent,
|
|
73
|
+
trust_room_id: res.trustRoomId,
|
|
74
|
+
trust_room_url: res.trustRoomUrl,
|
|
75
|
+
access_url: res.accessUrl,
|
|
76
|
+
}));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (res.status === "already_shared") {
|
|
80
|
+
console.log(`${res.email} already has access to ${res.trustRoomName}.`);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
console.log(`Shared ${res.trustRoomName} with ${res.email} (${res.permission}).`);
|
|
84
|
+
}
|
|
85
|
+
if (res.isNewUser) {
|
|
86
|
+
console.log(`${res.email} has no ZIVIS account yet — they'll create one when they open the link.`);
|
|
87
|
+
}
|
|
88
|
+
if (!res.emailSent) {
|
|
89
|
+
console.log("Access was granted, but the invitation email could not be sent.");
|
|
90
|
+
console.log("Pass this link on directly:");
|
|
91
|
+
}
|
|
92
|
+
console.log("");
|
|
93
|
+
console.log(`Assurance Room: ${res.accessUrl}`);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
failCommand(COMMAND, err, json);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
export default registerShareCommands;
|
|
@@ -121,10 +121,14 @@ async function runFlush(opts) {
|
|
|
121
121
|
const client = new ApiClient(config);
|
|
122
122
|
let succeeded = 0;
|
|
123
123
|
let failed = 0;
|
|
124
|
+
const trustRoomUrls = [];
|
|
124
125
|
for (const { filePath, entry } of pending) {
|
|
125
126
|
try {
|
|
126
127
|
if (entry.kind === "devx-run-complete") {
|
|
127
|
-
await completeDevXRunRemote(client, { runId: entry.runId, contractVersion: entry.contractVersion, result: entry.result });
|
|
128
|
+
const completed = await completeDevXRunRemote(client, { runId: entry.runId, contractVersion: entry.contractVersion, result: entry.result });
|
|
129
|
+
if (completed.trustRoomUrl && !trustRoomUrls.includes(completed.trustRoomUrl)) {
|
|
130
|
+
trustRoomUrls.push(completed.trustRoomUrl);
|
|
131
|
+
}
|
|
128
132
|
}
|
|
129
133
|
else {
|
|
130
134
|
await syncApplicationInventory({
|
|
@@ -143,10 +147,12 @@ async function runFlush(opts) {
|
|
|
143
147
|
}
|
|
144
148
|
}
|
|
145
149
|
if (format === "json") {
|
|
146
|
-
process.stdout.write(JSON.stringify({ succeeded, failed, remaining: failed }, null, 2) + "\n");
|
|
150
|
+
process.stdout.write(JSON.stringify({ succeeded, failed, remaining: failed, ...(trustRoomUrls.length ? { trust_room_urls: trustRoomUrls } : {}) }, null, 2) + "\n");
|
|
147
151
|
}
|
|
148
152
|
else {
|
|
149
153
|
process.stdout.write(`\nFlushed outbox: ${succeeded} succeeded, ${failed} still failing (left queued).\n`);
|
|
154
|
+
for (const url of trustRoomUrls)
|
|
155
|
+
process.stdout.write(`Trust Room: ${url}\n`);
|
|
150
156
|
}
|
|
151
157
|
return failed > 0 ? 1 : 0;
|
|
152
158
|
}
|
|
@@ -5,7 +5,7 @@ import { ApiClient } from "@zivis/mcp/api-client";
|
|
|
5
5
|
import { resolveConfigFromBinding } from "@zivis/mcp/project-binding";
|
|
6
6
|
import { requireApplicationId } from "@zivis/mcp/resolve-application-id";
|
|
7
7
|
import { renderSecurityContext } from "../../internal/security-context.js";
|
|
8
|
-
import { startTestRun, listTestScopes } from "../../internal/test-scope.js";
|
|
8
|
+
import { startTestRun, listTestScopes, tryBuildChangeRecommendation, } from "../../internal/test-scope.js";
|
|
9
9
|
import { LocalPackRegistryClient, registryDirOverride } from "../../internal/packs/registry-client.js";
|
|
10
10
|
import { NpmPackRegistryClient } from "../../internal/packs/npm-registry-client.js";
|
|
11
11
|
import { PackUnavailableError, PackIntegrityError, UnknownScopeError } from "../../internal/packs/types.js";
|
|
@@ -38,9 +38,10 @@ function getCliVersion() {
|
|
|
38
38
|
export function registerTestCommand(program) {
|
|
39
39
|
program
|
|
40
40
|
.command("test [scope]")
|
|
41
|
-
.description("run a Zivis-guided AppSec review — full/adaptive
|
|
41
|
+
.description("run a Zivis-guided AppSec review — change-aware by default once a prior run exists, full/adaptive on the first run or with --full, or targeted at a stable scope")
|
|
42
42
|
.option("--list", "list available scopes from the verified AppSec methodology pack")
|
|
43
43
|
.option("--app <id>", "Application id (defaults to the bound Application)")
|
|
44
|
+
.option("--full", "skip the change-aware recommendation and always run a full adaptive review")
|
|
44
45
|
.option("--json", "emit JSON only on stdout")
|
|
45
46
|
.action(async (scope, opts) => {
|
|
46
47
|
const json = !!opts.json;
|
|
@@ -49,6 +50,7 @@ export function registerTestCommand(program) {
|
|
|
49
50
|
const apiClient = new ApiClient(config);
|
|
50
51
|
const registryClient = buildRegistryClient();
|
|
51
52
|
const cliVersion = getCliVersion();
|
|
53
|
+
const deps = { apiClient, registryClient };
|
|
52
54
|
if (opts.list) {
|
|
53
55
|
try {
|
|
54
56
|
const listing = await listTestScopes(registryClient, cliVersion);
|
|
@@ -70,7 +72,30 @@ export function registerTestCommand(program) {
|
|
|
70
72
|
if (!resolvedApp.ok) {
|
|
71
73
|
throw new UsageError(resolvedApp.message);
|
|
72
74
|
}
|
|
73
|
-
|
|
75
|
+
let precomputedContext;
|
|
76
|
+
if (!scope && !opts.full) {
|
|
77
|
+
const changeOutcome = await tryBuildChangeRecommendation(deps, { applicationId: resolvedApp.id, cwd, cliVersion });
|
|
78
|
+
if (changeOutcome.kind === "recommendation" || changeOutcome.kind === "up-to-date" || changeOutcome.kind === "no-scope-match") {
|
|
79
|
+
if (json) {
|
|
80
|
+
printJson(jsonEnvelope("test", changeOutcomeToJson(changeOutcome)));
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
printChangeOutcomeHuman(changeOutcome);
|
|
84
|
+
}
|
|
85
|
+
process.exit(EXIT.OK);
|
|
86
|
+
}
|
|
87
|
+
precomputedContext = changeOutcome.contextOutcome;
|
|
88
|
+
if (!json && changeOutcome.kind === "sha-not-found") {
|
|
89
|
+
console.error(`[zivis] Last-tested commit ${shortSha(changeOutcome.sha)} not found locally (shallow clone, force-push, or unrelated history?) — running a full adaptive review.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const { envelope, resolvedScope, contextOutcome } = await startTestRun(deps, {
|
|
93
|
+
applicationId: resolvedApp.id,
|
|
94
|
+
scopeId: scope,
|
|
95
|
+
cwd,
|
|
96
|
+
cliVersion,
|
|
97
|
+
precomputedContext,
|
|
98
|
+
});
|
|
74
99
|
const output = jsonEnvelope("test", { ...envelope });
|
|
75
100
|
if (json) {
|
|
76
101
|
printJson(output);
|
|
@@ -114,3 +139,60 @@ function printRunHuman(envelope, resolvedScope, contextOutcome) {
|
|
|
114
139
|
}
|
|
115
140
|
console.log(`\nWhen finished: ${envelope.result_contract.complete_command}`);
|
|
116
141
|
}
|
|
142
|
+
function shortSha(sha) {
|
|
143
|
+
return sha.slice(0, 8);
|
|
144
|
+
}
|
|
145
|
+
function joinWithAnd(items) {
|
|
146
|
+
if (items.length <= 1)
|
|
147
|
+
return items[0] ?? "";
|
|
148
|
+
if (items.length === 2)
|
|
149
|
+
return `${items[0]} and ${items[1]}`;
|
|
150
|
+
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
|
151
|
+
}
|
|
152
|
+
function filesLabel(count) {
|
|
153
|
+
return `${count} file${count === 1 ? "" : "s"}`;
|
|
154
|
+
}
|
|
155
|
+
function printChangeOutcomeHuman(outcome) {
|
|
156
|
+
switch (outcome.kind) {
|
|
157
|
+
case "up-to-date":
|
|
158
|
+
console.log(`zivis test: no files changed since last-tested commit ${shortSha(outcome.sha)}. Nothing to recommend.`);
|
|
159
|
+
console.log(`Run \`zivis test --full\` for a full adaptive review anyway, or \`zivis test <scope>\` for a specific one.`);
|
|
160
|
+
return;
|
|
161
|
+
case "no-scope-match":
|
|
162
|
+
console.log(`${filesLabel(outcome.changedFileCount)} changed since ${shortSha(outcome.sha)}, but none map to a known methodology scope.`);
|
|
163
|
+
console.log(`Run \`zivis test --full\` for a full adaptive review, \`zivis test <scope>\` for a specific one, or \`zivis test --list\` to see all scopes.`);
|
|
164
|
+
return;
|
|
165
|
+
case "recommendation": {
|
|
166
|
+
const { sha, changedFileCount, scopes, testIds } = outcome;
|
|
167
|
+
console.log(`${filesLabel(changedFileCount)} changed since ${shortSha(sha)}.`);
|
|
168
|
+
console.log(`Changes touch ${joinWithAnd(scopes.map((s) => s.label))}.`);
|
|
169
|
+
console.log("");
|
|
170
|
+
console.log("Recommended:");
|
|
171
|
+
for (const s of scopes)
|
|
172
|
+
console.log(` zivis test ${s.id}`);
|
|
173
|
+
if (testIds.length > 0) {
|
|
174
|
+
console.log("");
|
|
175
|
+
console.log(`${testIds.join(", ")} ${testIds.length === 1 ? "is" : "are"} especially relevant.`);
|
|
176
|
+
}
|
|
177
|
+
console.log("");
|
|
178
|
+
console.log(`Run \`zivis test --full\` for a full adaptive review instead.`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function changeOutcomeToJson(outcome) {
|
|
184
|
+
switch (outcome.kind) {
|
|
185
|
+
case "up-to-date":
|
|
186
|
+
return { result: "up_to_date", since: outcome.sha };
|
|
187
|
+
case "no-scope-match":
|
|
188
|
+
return { result: "no_scope_match", since: outcome.sha, changed_file_count: outcome.changedFileCount };
|
|
189
|
+
case "recommendation":
|
|
190
|
+
return {
|
|
191
|
+
result: "recommendation",
|
|
192
|
+
since: outcome.sha,
|
|
193
|
+
changed_file_count: outcome.changedFileCount,
|
|
194
|
+
scopes: outcome.scopes,
|
|
195
|
+
test_ids: outcome.testIds,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { registerCreditsCommands } from "./commands/credits/index.js";
|
|
|
15
15
|
import { registerOpenCommands } from "./commands/open/index.js";
|
|
16
16
|
import { registerSyncCommands } from "./commands/sync/index.js";
|
|
17
17
|
import { registerAssuranceCommands } from "./commands/assurance/index.js";
|
|
18
|
+
import { registerShareCommands } from "./commands/share/index.js";
|
|
18
19
|
import { registerGateCommand } from "./commands/gate/index.js";
|
|
19
20
|
import { registerRunCommands } from "./commands/run/index.js";
|
|
20
21
|
import { registerTestCommand } from "./commands/test/index.js";
|
|
@@ -54,6 +55,7 @@ registerCreditsCommands(program);
|
|
|
54
55
|
registerOpenCommands(program);
|
|
55
56
|
registerSyncCommands(program);
|
|
56
57
|
registerAssuranceCommands(program);
|
|
58
|
+
registerShareCommands(program);
|
|
57
59
|
registerGateCommand(program);
|
|
58
60
|
registerRunCommands(program);
|
|
59
61
|
registerTestCommand(program);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PackScopeEntry } from "./packs/types.js";
|
|
2
|
+
export interface ChangeRecommendation {
|
|
3
|
+
scopeIds: string[];
|
|
4
|
+
testIds: string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function buildChangeRecommendation(changedFiles: string[], resolvedScopes: Pick<PackScopeEntry, "id" | "label" | "tests">[]): ChangeRecommendation | null;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
function globToRegex(glob) {
|
|
2
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
3
|
+
let pattern = escaped;
|
|
4
|
+
const leadingDoubleStarSlash = pattern.startsWith("**/");
|
|
5
|
+
if (leadingDoubleStarSlash)
|
|
6
|
+
pattern = pattern.slice(3);
|
|
7
|
+
const DOUBLE_STAR_TOKEN = "@@DOUBLESTAR@@";
|
|
8
|
+
pattern = pattern
|
|
9
|
+
.replace(/\*\*/g, DOUBLE_STAR_TOKEN)
|
|
10
|
+
.replace(/\*/g, "[^/]*")
|
|
11
|
+
.replace(new RegExp(DOUBLE_STAR_TOKEN, "g"), ".*");
|
|
12
|
+
if (leadingDoubleStarSlash)
|
|
13
|
+
pattern = `(?:.*/)?${pattern}`;
|
|
14
|
+
return new RegExp(`^${pattern}$`, "i");
|
|
15
|
+
}
|
|
16
|
+
const REGEX_CACHE = new Map();
|
|
17
|
+
function compiledGlob(glob) {
|
|
18
|
+
let rx = REGEX_CACHE.get(glob);
|
|
19
|
+
if (!rx) {
|
|
20
|
+
rx = globToRegex(glob);
|
|
21
|
+
REGEX_CACHE.set(glob, rx);
|
|
22
|
+
}
|
|
23
|
+
return rx;
|
|
24
|
+
}
|
|
25
|
+
function matchesAnyGlob(filePath, globs) {
|
|
26
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
27
|
+
return globs.some((glob) => compiledGlob(glob).test(normalized));
|
|
28
|
+
}
|
|
29
|
+
const CHANGE_SCOPE_HEURISTICS = [
|
|
30
|
+
{ scopeId: "auth", globs: ["**/*password*", "**/*passwd*", "**/*bcrypt*", "**/*argon2*", "**/*scrypt*", "**/*hash*"], testIds: ["AUTH-001"] },
|
|
31
|
+
{ scopeId: "auth", globs: ["**/*login*", "**/*signin*", "**/*sign-in*", "**/*credential*"], testIds: ["AUTH-003"] },
|
|
32
|
+
{ scopeId: "auth", globs: ["**/*mfa*", "**/*otp*", "**/*two-factor*", "**/*2fa*", "**/*step-up*"], testIds: ["AUTH-004"] },
|
|
33
|
+
{ scopeId: "auth", globs: ["**/*reset-password*", "**/*forgot-password*", "**/*password-reset*", "**/*account-recovery*"], testIds: ["AUTH-005"] },
|
|
34
|
+
{ scopeId: "auth", globs: ["**/*rate-limit*", "**/*throttle*", "**/*lockout*"], testIds: ["AUTH-006"] },
|
|
35
|
+
{ scopeId: "auth", globs: ["**/auth/**", "**/authentication/**", "**/*oauth*", "**/*sso*", "**/*saml*"] },
|
|
36
|
+
{ scopeId: "authorization", globs: ["**/*rbac*", "**/*permission*", "**/*acl*"], testIds: ["AUTHZ-001", "AUTHZ-002"] },
|
|
37
|
+
{ scopeId: "authorization", globs: ["**/*tenant*", "**/*multi-tenant*", "**/*org-scope*"], testIds: ["AUTHZ-003"] },
|
|
38
|
+
{ scopeId: "authorization", globs: ["**/authz/**", "**/authorization/**", "**/*entitlement*", "**/*role*"] },
|
|
39
|
+
{ scopeId: "session", globs: ["**/*jwt*"], testIds: ["SESSION-006"] },
|
|
40
|
+
{ scopeId: "session", globs: ["**/*csrf*"], testIds: ["SESSION-005"] },
|
|
41
|
+
{ scopeId: "session", globs: ["**/*cookie*"], testIds: ["SESSION-004"] },
|
|
42
|
+
{ scopeId: "session", globs: ["**/session/**", "**/*sessions*", "**/*token*"] },
|
|
43
|
+
{ scopeId: "api", globs: ["**/routes/**", "**/route/**", "**/controllers/**", "**/api/**", "**/*.graphql", "**/openapi*.*", "**/swagger*.*"] },
|
|
44
|
+
{ scopeId: "input", globs: ["**/*sql*", "**/*query-builder*", "**/*raw-query*"], testIds: ["INPUT-001"] },
|
|
45
|
+
{ scopeId: "input", globs: ["**/*template*"], testIds: ["INPUT-003"] },
|
|
46
|
+
{ scopeId: "input", globs: ["**/*path-traversal*", "**/*path-join*"], testIds: ["INPUT-004"] },
|
|
47
|
+
{ scopeId: "input", globs: ["**/*deserializ*", "**/*unmarshal*", "**/*pickle*"], testIds: ["INPUT-005"] },
|
|
48
|
+
{ scopeId: "input", globs: ["**/*sanitiz*", "**/*validat*", "**/*escape*"] },
|
|
49
|
+
{ scopeId: "data", globs: ["**/*encrypt*", "**/*crypto*", "**/*cipher*"], testIds: ["DATA-003", "DATA-005"] },
|
|
50
|
+
{ scopeId: "data", globs: ["**/models/**", "**/entities/**", "**/schema.prisma", "**/*.sql"] },
|
|
51
|
+
{ scopeId: "secrets", globs: ["**/*.env*", "**/*secret*", "**/*credentials*"], testIds: ["SECRETS-001"] },
|
|
52
|
+
{ scopeId: "secrets", globs: ["**/.github/workflows/**"], testIds: ["SECRETS-006"] },
|
|
53
|
+
{
|
|
54
|
+
scopeId: "dependencies",
|
|
55
|
+
globs: [
|
|
56
|
+
"**/package.json",
|
|
57
|
+
"**/package-lock.json",
|
|
58
|
+
"**/pnpm-lock.yaml",
|
|
59
|
+
"**/yarn.lock",
|
|
60
|
+
"**/go.mod",
|
|
61
|
+
"**/go.sum",
|
|
62
|
+
"**/requirements*.txt",
|
|
63
|
+
"**/Pipfile*",
|
|
64
|
+
"**/Gemfile*",
|
|
65
|
+
"**/*.csproj",
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
{ scopeId: "ssrf", globs: ["**/*webhook*"], testIds: ["SSRF-001"] },
|
|
69
|
+
{ scopeId: "ssrf", globs: ["**/*fetch*", "**/*http-client*", "**/*outbound*", "**/*proxy*"] },
|
|
70
|
+
{ scopeId: "uploads", globs: ["**/*content-type*", "**/*mime-type*", "**/*filetype*"], testIds: ["UPLOAD-001"] },
|
|
71
|
+
{ scopeId: "uploads", globs: ["**/*blob-storage*", "**/*s3*", "**/*azurite*", "**/*storage-service*"], testIds: ["UPLOAD-002"] },
|
|
72
|
+
{ scopeId: "uploads", globs: ["**/*filename*"], testIds: ["UPLOAD-003"] },
|
|
73
|
+
{ scopeId: "uploads", globs: ["**/*svg*"], testIds: ["UPLOAD-006"] },
|
|
74
|
+
{ scopeId: "uploads", globs: ["**/*upload*", "**/*multipart*", "**/*attachment*"] },
|
|
75
|
+
{ scopeId: "frontend", globs: ["**/*.tsx", "**/*.jsx", "**/components/**", "**/*.vue", "**/*.svelte"] },
|
|
76
|
+
{ scopeId: "infra", globs: ["**/*.tf", "**/Dockerfile*", "**/docker-compose*.yml", "**/docker-compose*.yaml", "**/k8s/**", "**/helm/**", "**/.github/workflows/**"] },
|
|
77
|
+
{ scopeId: "ai", globs: ["**/*llm*", "**/*prompt*", "**/*agent*", "**/*anthropic*", "**/*openai*", "**/*rag*", "**/*embedding*"] },
|
|
78
|
+
];
|
|
79
|
+
export function buildChangeRecommendation(changedFiles, resolvedScopes) {
|
|
80
|
+
const scopesById = new Map(resolvedScopes.map((s) => [s.id, s]));
|
|
81
|
+
const matchedScopeIds = new Set();
|
|
82
|
+
const matchedTestIdsByScope = new Map();
|
|
83
|
+
for (const rule of CHANGE_SCOPE_HEURISTICS) {
|
|
84
|
+
if (!scopesById.has(rule.scopeId))
|
|
85
|
+
continue;
|
|
86
|
+
if (!changedFiles.some((file) => matchesAnyGlob(file, rule.globs)))
|
|
87
|
+
continue;
|
|
88
|
+
matchedScopeIds.add(rule.scopeId);
|
|
89
|
+
if (rule.testIds?.length) {
|
|
90
|
+
const set = matchedTestIdsByScope.get(rule.scopeId) ?? new Set();
|
|
91
|
+
for (const id of rule.testIds)
|
|
92
|
+
set.add(id);
|
|
93
|
+
matchedTestIdsByScope.set(rule.scopeId, set);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (matchedScopeIds.size === 0)
|
|
97
|
+
return null;
|
|
98
|
+
const scopeIds = resolvedScopes.filter((s) => matchedScopeIds.has(s.id)).map((s) => s.id);
|
|
99
|
+
const testIds = [];
|
|
100
|
+
for (const scopeId of scopeIds) {
|
|
101
|
+
const candidateIds = matchedTestIdsByScope.get(scopeId);
|
|
102
|
+
if (!candidateIds)
|
|
103
|
+
continue;
|
|
104
|
+
const tests = scopesById.get(scopeId)?.tests ?? [];
|
|
105
|
+
for (const test of tests) {
|
|
106
|
+
if (test.deprecated)
|
|
107
|
+
continue;
|
|
108
|
+
if (candidateIds.has(test.id))
|
|
109
|
+
testIds.push(test.id);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { scopeIds, testIds };
|
|
113
|
+
}
|
|
@@ -51,10 +51,13 @@ export interface DevXRunReconciliationSummary {
|
|
|
51
51
|
applicability: string;
|
|
52
52
|
}>;
|
|
53
53
|
}
|
|
54
|
+
export type TrustRoomLinkSource = string;
|
|
54
55
|
export interface DevXRunOutcome {
|
|
55
56
|
outcome: "completed" | "cancelled" | "idempotent-replay";
|
|
56
57
|
run: DevXRunWire;
|
|
57
58
|
reconciliation?: DevXRunReconciliationSummary;
|
|
59
|
+
trustRoomUrl?: string | null;
|
|
60
|
+
trustRoomSource?: TrustRoomLinkSource | null;
|
|
58
61
|
}
|
|
59
62
|
export declare function isRetryableDevXRunError(err: unknown): boolean;
|
|
60
63
|
export declare function completeDevXRunRemote(client: DevXRunApi, params: {
|
|
@@ -66,3 +69,61 @@ export declare function cancelDevXRunRemote(client: DevXRunApi, params: {
|
|
|
66
69
|
runId: string;
|
|
67
70
|
reason?: string | null;
|
|
68
71
|
}): Promise<DevXRunOutcome>;
|
|
72
|
+
export interface DevXRunItemsPayload {
|
|
73
|
+
findings?: unknown[];
|
|
74
|
+
evidence?: unknown[];
|
|
75
|
+
observations?: unknown[];
|
|
76
|
+
}
|
|
77
|
+
export interface DevXRunItemFindingWire {
|
|
78
|
+
index: number;
|
|
79
|
+
action: "created" | "updated" | "matched_existing";
|
|
80
|
+
findingId: string;
|
|
81
|
+
match: {
|
|
82
|
+
basis: "fingerprint";
|
|
83
|
+
fingerprint: string;
|
|
84
|
+
};
|
|
85
|
+
retestRecommendation: {
|
|
86
|
+
recommended: true;
|
|
87
|
+
reason: string;
|
|
88
|
+
instruction: string;
|
|
89
|
+
} | null;
|
|
90
|
+
dispositionHistory: Array<Record<string, unknown>>;
|
|
91
|
+
}
|
|
92
|
+
export interface DevXRunItemEvidenceWire {
|
|
93
|
+
index: number;
|
|
94
|
+
action: "created" | "matched_existing";
|
|
95
|
+
evidenceId: string;
|
|
96
|
+
match: {
|
|
97
|
+
basis: "content_hash";
|
|
98
|
+
contentHash: string;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export interface DevXRunItemObservationWire {
|
|
102
|
+
testId: string;
|
|
103
|
+
action: "created" | "matched_existing";
|
|
104
|
+
observationId: string;
|
|
105
|
+
scopeId: string;
|
|
106
|
+
result: string;
|
|
107
|
+
}
|
|
108
|
+
export interface DevXRunItemsOutcome {
|
|
109
|
+
runId: string;
|
|
110
|
+
outcome: "applied";
|
|
111
|
+
items: {
|
|
112
|
+
findings: DevXRunItemFindingWire[];
|
|
113
|
+
evidence: DevXRunItemEvidenceWire[];
|
|
114
|
+
observations: DevXRunItemObservationWire[];
|
|
115
|
+
};
|
|
116
|
+
rejectedObservations?: Array<{
|
|
117
|
+
test_id: string;
|
|
118
|
+
reason: string;
|
|
119
|
+
}>;
|
|
120
|
+
runTotals: {
|
|
121
|
+
findings: number;
|
|
122
|
+
evidence: number;
|
|
123
|
+
observations: number;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export declare function reportDevXRunItems(client: DevXRunApi, params: {
|
|
127
|
+
runId: string;
|
|
128
|
+
items: DevXRunItemsPayload;
|
|
129
|
+
}): Promise<DevXRunItemsOutcome>;
|
package/dist/internal/git.d.ts
CHANGED
|
@@ -6,3 +6,4 @@ export interface GitMetadata {
|
|
|
6
6
|
}
|
|
7
7
|
export declare function readGitMetadata(rootDir?: string): Promise<GitMetadata>;
|
|
8
8
|
export declare function getCurrentGitSha(rootDir?: string): Promise<string | undefined>;
|
|
9
|
+
export declare function getChangedFilesSince(cwd: string, sha: string): Promise<string[] | undefined>;
|
package/dist/internal/git.js
CHANGED
|
@@ -18,6 +18,21 @@ export async function readGitMetadata(rootDir = process.cwd()) {
|
|
|
18
18
|
export async function getCurrentGitSha(rootDir = process.cwd()) {
|
|
19
19
|
return tryGit(rootDir, "rev-parse HEAD");
|
|
20
20
|
}
|
|
21
|
+
const GIT_SHA_PATTERN = /^[0-9a-f]{7,40}$/i;
|
|
22
|
+
export async function getChangedFilesSince(cwd, sha) {
|
|
23
|
+
if (!GIT_SHA_PATTERN.test(sha))
|
|
24
|
+
return undefined;
|
|
25
|
+
try {
|
|
26
|
+
const { stdout } = await execAsync(`git diff --name-only ${sha} HEAD`, { cwd });
|
|
27
|
+
return stdout
|
|
28
|
+
.split("\n")
|
|
29
|
+
.map((line) => line.trim())
|
|
30
|
+
.filter(Boolean);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
21
36
|
async function tryGit(cwd, args) {
|
|
22
37
|
try {
|
|
23
38
|
const { stdout } = await execAsync(`git ${args}`, { cwd });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { PackType, PackContentEntry, PackScopeEntry, PackSignature, PackManifestV1, PackVersionSummary, PackVersionListing, PackSource, ResolvedScope, ResolvedPack, PackRegistryClient, } from "./types.js";
|
|
1
|
+
export type { PackType, PackContentEntry, PackScopeEntry, PackTestEntry, PackSignature, PackManifestV1, PackVersionSummary, PackVersionListing, PackSource, ResolvedScope, ResolvedPack, PackRegistryClient, } from "./types.js";
|
|
2
2
|
export { PackIntegrityError, PackUnavailableError, UnknownScopeError } from "./types.js";
|
|
3
3
|
export { resolveCompatiblePack, resolveScope, listAvailableScopes } from "./resolve.js";
|
|
4
4
|
export type { ResolveOptions } from "./resolve.js";
|
|
@@ -82,7 +82,7 @@ export async function resolveScope(resolved, scopeId) {
|
|
|
82
82
|
}
|
|
83
83
|
const fs = await import("node:fs/promises");
|
|
84
84
|
const content = await fs.readFile(`${resolved.cacheDir}/${scope.path}`, "utf-8");
|
|
85
|
-
return { id: scope.id, label: scope.label, description: scope.description, content };
|
|
85
|
+
return { id: scope.id, label: scope.label, description: scope.description, content, tests: scope.tests ?? [] };
|
|
86
86
|
}
|
|
87
87
|
export function listAvailableScopes(resolved) {
|
|
88
88
|
return resolved.scopes.map(({ id, label, description }) => ({ id, label, description }));
|
|
@@ -3,10 +3,16 @@ export interface PackContentEntry {
|
|
|
3
3
|
path: string;
|
|
4
4
|
sha256: string;
|
|
5
5
|
}
|
|
6
|
+
export interface PackTestEntry {
|
|
7
|
+
id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
deprecated?: boolean;
|
|
10
|
+
}
|
|
6
11
|
export interface PackScopeEntry extends PackContentEntry {
|
|
7
12
|
id: string;
|
|
8
13
|
label: string;
|
|
9
14
|
description: string;
|
|
15
|
+
tests?: PackTestEntry[];
|
|
10
16
|
}
|
|
11
17
|
export interface PackSignature {
|
|
12
18
|
algorithm: "ed25519";
|
|
@@ -41,6 +47,7 @@ export interface ResolvedScope {
|
|
|
41
47
|
label: string;
|
|
42
48
|
description: string;
|
|
43
49
|
content: string;
|
|
50
|
+
tests: PackTestEntry[];
|
|
44
51
|
}
|
|
45
52
|
export interface ResolvedPack {
|
|
46
53
|
packId: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type DevXRunApi } from "./devx-run.js";
|
|
2
2
|
import { type SecurityContextApi, type SecurityContextOutcome } from "./security-context.js";
|
|
3
|
-
import type { PackRegistryClient } from "./packs/types.js";
|
|
3
|
+
import type { PackRegistryClient, PackTestEntry } from "./packs/types.js";
|
|
4
4
|
export interface TestRunDeps {
|
|
5
5
|
apiClient: DevXRunApi & SecurityContextApi;
|
|
6
6
|
registryClient: PackRegistryClient;
|
|
@@ -10,10 +10,12 @@ export interface StartTestRunParams {
|
|
|
10
10
|
scopeId?: string;
|
|
11
11
|
cwd: string;
|
|
12
12
|
cliVersion: string;
|
|
13
|
+
precomputedContext?: SecurityContextOutcome;
|
|
13
14
|
}
|
|
14
15
|
export interface ResolvedScopeSummary {
|
|
15
16
|
id: string;
|
|
16
17
|
label: string;
|
|
18
|
+
tests: PackTestEntry[];
|
|
17
19
|
}
|
|
18
20
|
export interface TestRunEnvelope {
|
|
19
21
|
run_id: string;
|
|
@@ -56,6 +58,35 @@ export interface StartTestRunResult {
|
|
|
56
58
|
contextOutcome: SecurityContextOutcome;
|
|
57
59
|
}
|
|
58
60
|
export declare function startTestRun(deps: TestRunDeps, params: StartTestRunParams): Promise<StartTestRunResult>;
|
|
61
|
+
export type ChangeRecommendationOutcome = {
|
|
62
|
+
kind: "no-prior-context";
|
|
63
|
+
contextOutcome: SecurityContextOutcome;
|
|
64
|
+
} | {
|
|
65
|
+
kind: "sha-not-found";
|
|
66
|
+
sha: string;
|
|
67
|
+
contextOutcome: SecurityContextOutcome;
|
|
68
|
+
} | {
|
|
69
|
+
kind: "up-to-date";
|
|
70
|
+
sha: string;
|
|
71
|
+
} | {
|
|
72
|
+
kind: "no-scope-match";
|
|
73
|
+
sha: string;
|
|
74
|
+
changedFileCount: number;
|
|
75
|
+
} | {
|
|
76
|
+
kind: "recommendation";
|
|
77
|
+
sha: string;
|
|
78
|
+
changedFileCount: number;
|
|
79
|
+
scopes: {
|
|
80
|
+
id: string;
|
|
81
|
+
label: string;
|
|
82
|
+
}[];
|
|
83
|
+
testIds: string[];
|
|
84
|
+
};
|
|
85
|
+
export declare function tryBuildChangeRecommendation(deps: TestRunDeps, params: {
|
|
86
|
+
applicationId: string;
|
|
87
|
+
cwd: string;
|
|
88
|
+
cliVersion: string;
|
|
89
|
+
}): Promise<ChangeRecommendationOutcome>;
|
|
59
90
|
export interface TestScopeListing {
|
|
60
91
|
pack: string;
|
|
61
92
|
version: string;
|
|
@@ -1,34 +1,48 @@
|
|
|
1
|
-
import { readGitMetadata } from "./git.js";
|
|
1
|
+
import { readGitMetadata, getChangedFilesSince } from "./git.js";
|
|
2
2
|
import { startDevXRun, DEVX_RUN_CONTRACT_VERSION } from "./devx-run.js";
|
|
3
3
|
import { fetchSecurityContext } from "./security-context.js";
|
|
4
4
|
import { resolveCompatiblePack, resolveScope, listAvailableScopes } from "./packs/resolve.js";
|
|
5
5
|
import { writeRunWorkFile } from "./run-workdir.js";
|
|
6
|
+
import { buildChangeRecommendation } from "./change-recommendation.js";
|
|
6
7
|
function scopeContentPath(resolved, scopeId) {
|
|
7
8
|
const entry = resolved.scopes.find((s) => s.id === scopeId);
|
|
8
9
|
return entry ? `${resolved.cacheDir}/${entry.path}` : resolved.cacheDir;
|
|
9
10
|
}
|
|
11
|
+
function formatDeclaredTestCatalog(tests) {
|
|
12
|
+
return tests
|
|
13
|
+
.filter((t) => !t.deprecated)
|
|
14
|
+
.map((t) => `${t.id} — ${t.title}`)
|
|
15
|
+
.join("; ");
|
|
16
|
+
}
|
|
10
17
|
function buildAgentInstructions(resolvedScope) {
|
|
11
18
|
if (resolvedScope) {
|
|
19
|
+
const catalog = formatDeclaredTestCatalog(resolvedScope.tests);
|
|
12
20
|
return [
|
|
13
21
|
`Read the base methodology and the "${resolvedScope.label}" scope guidance at methodology.scope.path.`,
|
|
14
22
|
`Focus this review on the "${resolvedScope.label}" scope. Requested intent is scope=${resolvedScope.id} — this does not mean other areas were evaluated.`,
|
|
23
|
+
...(catalog
|
|
24
|
+
? [
|
|
25
|
+
`This scope's declared test catalog: ${catalog}. When you emit observations[].test_id, use exactly one of these declared ids for the check you actually ran — never invent a slug from the prose. If none of them matches what you ran, do not fabricate a new id; describe it in coverage_note instead.`,
|
|
26
|
+
]
|
|
27
|
+
: []),
|
|
15
28
|
`You may report incidental findings outside this scope if you actually find them, but keep result provenance honest: do not claim coverage you did not perform.`,
|
|
16
29
|
`Use your own repository understanding, shell, and tools to perform the review. When finished, complete the run using result_contract.complete_command.`,
|
|
17
30
|
];
|
|
18
31
|
}
|
|
19
32
|
return [
|
|
20
33
|
`Read the methodology at methodology.path and perform a full, adaptive AppSec review — decide applicability yourself rather than mechanically executing every category.`,
|
|
34
|
+
`Each scope you apply declares its own stable, permanent test catalog (tests[] in the pack manifest, e.g. AUTH-001, UPLOAD-002 — see the scope's manifest entry). When you emit observations[].test_id, use the declared id for the check you actually ran — never invent a slug from the prose.`,
|
|
21
35
|
`Use your own repository understanding, shell, and tools to perform the review. When finished, complete the run using result_contract.complete_command.`,
|
|
22
36
|
];
|
|
23
37
|
}
|
|
24
38
|
export async function startTestRun(deps, params) {
|
|
25
39
|
const [git, contextOutcome, resolvedPack] = await Promise.all([
|
|
26
40
|
readGitMetadata(params.cwd),
|
|
27
|
-
fetchSecurityContext(deps.apiClient, params.applicationId),
|
|
41
|
+
params.precomputedContext ?? fetchSecurityContext(deps.apiClient, params.applicationId),
|
|
28
42
|
resolveCompatiblePack("appsec", { registryClient: deps.registryClient, cliVersion: params.cliVersion }),
|
|
29
43
|
]);
|
|
30
44
|
const scope = params.scopeId ? await resolveScope(resolvedPack, params.scopeId) : null;
|
|
31
|
-
const resolvedScope = scope ? { id: scope.id, label: scope.label } : null;
|
|
45
|
+
const resolvedScope = scope ? { id: scope.id, label: scope.label, tests: scope.tests } : null;
|
|
32
46
|
const started = await startDevXRun(deps.apiClient, {
|
|
33
47
|
applicationId: params.applicationId,
|
|
34
48
|
runType: "test",
|
|
@@ -59,6 +73,24 @@ export async function startTestRun(deps, params) {
|
|
|
59
73
|
};
|
|
60
74
|
return { envelope, resolvedScope, contextOutcome };
|
|
61
75
|
}
|
|
76
|
+
export async function tryBuildChangeRecommendation(deps, params) {
|
|
77
|
+
const contextOutcome = await fetchSecurityContext(deps.apiClient, params.applicationId);
|
|
78
|
+
const sha = contextOutcome.available ? contextOutcome.context.lastTestedGitSha : null;
|
|
79
|
+
if (!sha)
|
|
80
|
+
return { kind: "no-prior-context", contextOutcome };
|
|
81
|
+
const changedFiles = await getChangedFilesSince(params.cwd, sha);
|
|
82
|
+
if (changedFiles === undefined)
|
|
83
|
+
return { kind: "sha-not-found", sha, contextOutcome };
|
|
84
|
+
if (changedFiles.length === 0)
|
|
85
|
+
return { kind: "up-to-date", sha };
|
|
86
|
+
const resolvedPack = await resolveCompatiblePack("appsec", { registryClient: deps.registryClient, cliVersion: params.cliVersion });
|
|
87
|
+
const recommendation = buildChangeRecommendation(changedFiles, resolvedPack.scopes);
|
|
88
|
+
if (!recommendation)
|
|
89
|
+
return { kind: "no-scope-match", sha, changedFileCount: changedFiles.length };
|
|
90
|
+
const scopesById = new Map(resolvedPack.scopes.map((s) => [s.id, s]));
|
|
91
|
+
const scopes = recommendation.scopeIds.map((id) => ({ id, label: scopesById.get(id).label }));
|
|
92
|
+
return { kind: "recommendation", sha, changedFileCount: changedFiles.length, scopes, testIds: recommendation.testIds };
|
|
93
|
+
}
|
|
62
94
|
export async function listTestScopes(registryClient, cliVersion) {
|
|
63
95
|
const resolvedPack = await resolveCompatiblePack("appsec", { registryClient, cliVersion });
|
|
64
96
|
return {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface TrustRoomLinkFields {
|
|
2
|
+
trustRoomUrl?: string | null;
|
|
3
|
+
trustRoomSource?: string | null;
|
|
4
|
+
}
|
|
5
|
+
export interface TrustRoomJsonFields {
|
|
6
|
+
trust_room_url?: string;
|
|
7
|
+
trust_room_source?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function trustRoomJsonFields(link: TrustRoomLinkFields | null | undefined): TrustRoomJsonFields;
|
|
10
|
+
export declare function trustRoomHumanLines(link: TrustRoomLinkFields | null | undefined): string[];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function trustRoomJsonFields(link) {
|
|
2
|
+
const url = link?.trustRoomUrl;
|
|
3
|
+
if (!url)
|
|
4
|
+
return {};
|
|
5
|
+
return {
|
|
6
|
+
trust_room_url: url,
|
|
7
|
+
...(link?.trustRoomSource ? { trust_room_source: link.trustRoomSource } : {}),
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export function trustRoomHumanLines(link) {
|
|
11
|
+
const url = link?.trustRoomUrl;
|
|
12
|
+
if (!url)
|
|
13
|
+
return [];
|
|
14
|
+
return [`Trust Room: ${url}`];
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zivis/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.52",
|
|
4
4
|
"description": "ZIVIS CLI — threat modeling, scans, and MCP server for IDE integration",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://zivis.ai",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"open": "^10.1.0",
|
|
35
35
|
"tar": "^7.5.22",
|
|
36
36
|
"web-tree-sitter": "^0.26.8",
|
|
37
|
-
"@zivis/mcp": "0.1.
|
|
37
|
+
"@zivis/mcp": "0.1.10"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/js-yaml": "^4.0.9",
|