securewithtrace 0.46.0 → 0.47.0

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.
Files changed (2) hide show
  1. package/dist/index.js +1278 -269
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -24693,6 +24693,10 @@ var PentestFindingRepositorySchema = external_exports.object({
24693
24693
  startLine: external_exports.number().nullable(),
24694
24694
  endLine: external_exports.number().nullable()
24695
24695
  });
24696
+ var PentestFindingApplicationSchema = external_exports.object({
24697
+ applicationId: external_exports.uuid(),
24698
+ name: external_exports.string()
24699
+ });
24696
24700
  var PentestFindingResponseSchema = external_exports.object({
24697
24701
  pentestFindingId: external_exports.uuid(),
24698
24702
  organizationId: external_exports.uuid(),
@@ -24756,6 +24760,10 @@ var PentestFindingListItemSchema = PentestFindingResponseSchema.omit({
24756
24760
  attackScenario: true,
24757
24761
  severityConsiderations: true,
24758
24762
  verification: true
24763
+ }).extend({
24764
+ // Derived by `loadFindingApplications` for the list reads only; it backs the
24765
+ // application filter. `.default([])` keeps an API that predates it parsing.
24766
+ applications: external_exports.array(PentestFindingApplicationSchema).default([])
24759
24767
  });
24760
24768
  var ListPentestFindingsResponseSchema = external_exports.array(PentestFindingListItemSchema);
24761
24769
  var OperatorFindingEvidenceSchema = external_exports.object({
@@ -24804,7 +24812,8 @@ var OperatorFindingListSchema = PentestFindingListItemSchema.omit({
24804
24812
  createdByName: true,
24805
24813
  reportProseAt: true,
24806
24814
  reportProseStatus: true,
24807
- externalTicket: true
24815
+ externalTicket: true,
24816
+ applications: true
24808
24817
  });
24809
24818
  var CustomerPentestFindingRepositorySchema = PentestFindingRepositorySchema.omit({
24810
24819
  repositoryId: true
@@ -25086,6 +25095,30 @@ var PublishedFindingSchema = external_exports.object({
25086
25095
  var PublishAllFindingsResponseSchema = external_exports.object({
25087
25096
  published: external_exports.array(PublishedFindingSchema)
25088
25097
  });
25098
+ var CopyPentestFindingsRequestSchema = external_exports.object({
25099
+ sourcePentestId: external_exports.uuid(),
25100
+ pentestFindingIds: external_exports.array(external_exports.uuid()).min(1).max(200).optional(),
25101
+ applicationId: external_exports.uuid().optional(),
25102
+ /** Report the selection without writing anything. */
25103
+ dryRun: external_exports.boolean().optional()
25104
+ }).strict().refine((body) => body.pentestFindingIds === void 0 !== (body.applicationId === void 0), {
25105
+ message: "Provide exactly one of pentestFindingIds or applicationId."
25106
+ });
25107
+ var CopiedPentestFindingSchema = external_exports.object({
25108
+ sourcePentestFindingId: external_exports.uuid(),
25109
+ sourceDisplayId: external_exports.int().nullable(),
25110
+ /** The source's status; the copy itself is always OPEN. */
25111
+ sourceStatus: PentestFindingStatus,
25112
+ /** The new draft's id; null on a dry run. */
25113
+ pentestFindingId: external_exports.uuid().nullable(),
25114
+ title: external_exports.string(),
25115
+ severity: PentestFindingSeverity,
25116
+ evidenceCount: external_exports.int().nonnegative()
25117
+ });
25118
+ var CopyPentestFindingsResponseSchema = external_exports.object({
25119
+ dryRun: external_exports.boolean(),
25120
+ findings: external_exports.array(CopiedPentestFindingSchema)
25121
+ });
25089
25122
  var RecoverStrandedFindingsRequestSchema = external_exports.object({
25090
25123
  dryRun: external_exports.boolean().optional()
25091
25124
  });
@@ -26119,6 +26152,9 @@ var CredentialPasskeySummarySchema = external_exports.object({
26119
26152
  /** Moves only when the signature counter moved, the one observable sign of a use. */
26120
26153
  lastAssertedAt: external_exports.coerce.date().nullable()
26121
26154
  }).strict();
26155
+ var TestIntegrationPasskeySummarySchema = CredentialPasskeySummarySchema.omit({
26156
+ credentialPasskeyId: true
26157
+ }).extend({ testIntegrationPasskeyId: external_exports.uuid() }).strict();
26122
26158
  var CredentialMfaKindValues = ["TOTP"];
26123
26159
  var CredentialMfaKind = external_exports.enum(CredentialMfaKindValues);
26124
26160
  var MFA_CAPABLE_CREDENTIAL_TYPES = ["USERNAME_PASSWORD", "MAGIC_AUTH"];
@@ -26528,24 +26564,40 @@ var RecordCandidateVulnResponseSchema = external_exports.object({
26528
26564
  inserted: external_exports.boolean()
26529
26565
  });
26530
26566
  var ProductContextStatement = external_exports.string().min(1).max(8e3);
26567
+ var ProductEvidenceKindSchema = external_exports.enum(["docs", "source", "live"]);
26568
+ var EvidenceRefSchema = external_exports.object({
26569
+ ref: external_exports.string().min(1).max(512),
26570
+ why: external_exports.string().min(1).max(300)
26571
+ });
26572
+ var ProductFamilyEvidence = {
26573
+ evidence: ProductEvidenceKindSchema.optional(),
26574
+ refs: external_exports.array(EvidenceRefSchema).max(10).optional()
26575
+ };
26531
26576
  var RecordProductContextRequestSchema = external_exports.object({
26532
26577
  runVersion: external_exports.number().int().nonnegative(),
26533
26578
  payload: external_exports.object({
26534
- purpose: external_exports.object({ statement: ProductContextStatement }).optional(),
26535
- authModel: external_exports.object({ statement: ProductContextStatement }).optional(),
26579
+ purpose: external_exports.object({ statement: ProductContextStatement, ...ProductFamilyEvidence }).optional(),
26580
+ authModel: external_exports.object({ statement: ProductContextStatement, ...ProductFamilyEvidence }).optional(),
26536
26581
  techStack: external_exports.object({
26537
26582
  statement: ProductContextStatement,
26538
26583
  languages: external_exports.array(external_exports.string().min(1).max(80)).max(50).optional(),
26539
26584
  frameworks: external_exports.array(external_exports.string().min(1).max(80)).max(50).optional(),
26540
- services: external_exports.array(external_exports.string().min(1).max(80)).max(50).optional()
26585
+ services: external_exports.array(external_exports.string().min(1).max(80)).max(50).optional(),
26586
+ ...ProductFamilyEvidence
26541
26587
  }).optional(),
26542
26588
  dataClassification: external_exports.object({
26543
26589
  statement: ProductContextStatement,
26544
- classes: external_exports.array(DataClassificationClass).min(1).max(40)
26590
+ classes: external_exports.array(DataClassificationClass).min(1).max(40),
26591
+ ...ProductFamilyEvidence
26592
+ }).optional(),
26593
+ businessImpact: external_exports.object({
26594
+ statement: ProductContextStatement,
26595
+ level: BusinessImpactLevel,
26596
+ ...ProductFamilyEvidence
26545
26597
  }).optional(),
26546
- businessImpact: external_exports.object({ statement: ProductContextStatement, level: BusinessImpactLevel }).optional(),
26547
- deployment: external_exports.object({ statement: ProductContextStatement }).optional(),
26548
- architectureNotes: external_exports.array(external_exports.object({ statement: ProductContextStatement })).max(40).optional()
26598
+ deployment: external_exports.object({ statement: ProductContextStatement, ...ProductFamilyEvidence }).optional(),
26599
+ // The bare-array shape is kept for v1 payloads; each note carries its own evidence.
26600
+ architectureNotes: external_exports.array(external_exports.object({ statement: ProductContextStatement, ...ProductFamilyEvidence })).max(40).optional()
26549
26601
  })
26550
26602
  });
26551
26603
  var RecordProductContextResponseSchema = external_exports.object({
@@ -27278,18 +27330,54 @@ var AgentSessionStoredPasskeySchema = AgentSessionPasskeySchema.extend({
27278
27330
  privateKey: external_exports.string().min(1).max(4096),
27279
27331
  userHandle: external_exports.string().max(128)
27280
27332
  });
27281
- var AgentSessionListPasskeysResponseSchema = external_exports.object({
27282
- credentialId: external_exports.uuid(),
27283
- credentialLabel: external_exports.string().max(200),
27284
- /** The account types a browser logs in as; the same set that can carry a second factor. */
27285
- credentialType: external_exports.enum(MFA_CAPABLE_CREDENTIAL_TYPES),
27286
- passkeys: external_exports.array(AgentSessionStoredPasskeySchema),
27287
- /**
27288
- * Stored rows whose envelope could not be read. Reported rather than swallowed so a
27289
- * partial list is never presented to the agent as the whole store, and rather than
27290
- * failing the call so the readable passkeys still load.
27291
- */
27292
- unreadable: external_exports.number().int().min(0)
27333
+ var AgentSessionIntegrationPasskeySchema = AgentSessionPasskeySchema.extend({
27334
+ testIntegrationPasskeyId: external_exports.uuid(),
27335
+ privateKey: external_exports.string().min(1).max(4096),
27336
+ userHandle: external_exports.string().max(128)
27337
+ });
27338
+ var AgentSessionListPasskeysResponseSchema = external_exports.discriminatedUnion("owner", [
27339
+ external_exports.object({
27340
+ owner: external_exports.literal("credential"),
27341
+ credentialId: external_exports.uuid(),
27342
+ credentialLabel: external_exports.string().max(200),
27343
+ /** The account types a browser logs in as; the same set that can carry a second factor. */
27344
+ credentialType: external_exports.enum(MFA_CAPABLE_CREDENTIAL_TYPES),
27345
+ passkeys: external_exports.array(AgentSessionStoredPasskeySchema),
27346
+ /**
27347
+ * Stored rows whose envelope could not be read. Reported rather than swallowed so a
27348
+ * partial list is never presented to the agent as the whole store, and rather than
27349
+ * failing the call so the readable passkeys still load.
27350
+ */
27351
+ unreadable: external_exports.number().int().min(0)
27352
+ }),
27353
+ external_exports.object({
27354
+ owner: external_exports.literal("testIntegration"),
27355
+ testIntegrationId: external_exports.uuid(),
27356
+ /** The integration's `label`, under the field name the host already keys on. */
27357
+ credentialLabel: external_exports.string().max(200),
27358
+ passkeys: external_exports.array(AgentSessionIntegrationPasskeySchema),
27359
+ unreadable: external_exports.number().int().min(0)
27360
+ })
27361
+ ]);
27362
+ var AgentSessionTestIntegrationPasskeyUseSchema = AgentSessionPasskeySchema.pick({
27363
+ rpId: true,
27364
+ webauthnCredentialId: true,
27365
+ signCount: true,
27366
+ backupEligibility: true,
27367
+ backupState: true
27368
+ }).strict();
27369
+ var AgentSessionRecordTestIntegrationPasskeyUseRequestSchema = external_exports.object({
27370
+ runVersion: external_exports.number().int().nonnegative(),
27371
+ /** From the list response, never agent-supplied. */
27372
+ testIntegrationId: external_exports.uuid(),
27373
+ passkey: AgentSessionTestIntegrationPasskeyUseSchema
27374
+ });
27375
+ var AgentSessionRecordTestIntegrationPasskeyUseResponseSchema = external_exports.object({
27376
+ testIntegrationPasskeyId: external_exports.uuid(),
27377
+ /** The stored counter after the call, never lower than before. */
27378
+ signCount: external_exports.number().int().min(0),
27379
+ /** False when the stored counter was already at or past the reported one. */
27380
+ counterMoved: external_exports.boolean()
27293
27381
  });
27294
27382
  var AgentSessionUpsertPasskeyRequestSchema = external_exports.object({
27295
27383
  runVersion: external_exports.number().int().nonnegative(),
@@ -28304,6 +28392,27 @@ var agentSessionInternalContract = c4.router({
28304
28392
  500: ErrorResponseSchema
28305
28393
  }
28306
28394
  },
28395
+ // provision_passkey, integration write half: a stored test-integration passkey's
28396
+ // counter moved on the armed authenticator. Counter-only by shape (no key field on
28397
+ // the body); idempotent on (testIntegrationId, rpId, webauthnCredentialId).
28398
+ recordTestIntegrationPasskeyUse: {
28399
+ method: "POST",
28400
+ path: "/internal/v1/agent-sessions/:agentSessionId/test-integration-passkeys/use",
28401
+ pathParams: AgentSessionInternalParamsSchema,
28402
+ body: AgentSessionRecordTestIntegrationPasskeyUseRequestSchema,
28403
+ responses: {
28404
+ 200: AgentSessionRecordTestIntegrationPasskeyUseResponseSchema,
28405
+ 400: ErrorResponseSchema,
28406
+ 401: ErrorResponseSchema,
28407
+ // Session type receives no account credentials, or may not fetch test integrations.
28408
+ 403: ErrorResponseSchema,
28409
+ // No stored passkey with that natural key on that integration (staff deleted it).
28410
+ 404: ErrorResponseSchema,
28411
+ // RUN_REVOKED.
28412
+ 409: ErrorResponseSchema,
28413
+ 500: ErrorResponseSchema
28414
+ }
28415
+ },
28307
28416
  // renew_cloud_credentials (GCP integration PR 7): agent-invoked re-mint of the
28308
28417
  // ~1 h AWS/GCP recon creds. Run-token authed; org+pentest from the session row;
28309
28418
  // 403 for a non-credentialed session type (ADHOC/CODE_REVIEW/REPORT).
@@ -33673,6 +33782,14 @@ var CreateOrganizationRequestSchema = external_exports.object({
33673
33782
  homepageUrl: external_exports.url("Homepage must be a full URL, like https://acme.com").max(2048).nullable().optional()
33674
33783
  });
33675
33784
  var LogoFetchOutcomeValues = ["skipped", "stored", "not_found", "failed"];
33785
+ var RenameOrganizationRequestSchema = external_exports.object({
33786
+ name: external_exports.string().trim().min(2, "Organization name must be at least 2 characters").max(100, "Organization name must be 100 characters or fewer")
33787
+ }).strict();
33788
+ var RenameOrganizationResponseSchema = external_exports.object({
33789
+ organizationId: external_exports.uuid(),
33790
+ slug: external_exports.string(),
33791
+ name: external_exports.string()
33792
+ });
33676
33793
  var CreateOrganizationResponseSchema = external_exports.object({
33677
33794
  organizationId: external_exports.uuid(),
33678
33795
  slug: external_exports.string(),
@@ -33760,6 +33877,23 @@ var organizationContract = c27.router({
33760
33877
  },
33761
33878
  summary: "Create an organization (Trace staff only)"
33762
33879
  },
33880
+ // Staff-only, cross-org: renames the tenant in Trace and WorkOS together.
33881
+ // The slug stays as it was.
33882
+ renameOrganizationAsStaff: {
33883
+ method: "PATCH",
33884
+ path: "/staff/organizations/:organizationId",
33885
+ pathParams: OrgIdParamsSchema,
33886
+ body: RenameOrganizationRequestSchema,
33887
+ responses: {
33888
+ 200: RenameOrganizationResponseSchema,
33889
+ 400: ErrorResponseSchema,
33890
+ 401: ErrorResponseSchema,
33891
+ 403: ErrorResponseSchema,
33892
+ 404: ErrorResponseSchema,
33893
+ 500: ErrorResponseSchema
33894
+ },
33895
+ summary: "Rename an organization (Trace staff only)"
33896
+ },
33763
33897
  // Staff-only, cross-org: removes the tenant from Trace and WorkOS together.
33764
33898
  // 409 when members, repositories, or retained audit history still reference
33765
33899
  // the organization; those must be removed first (or cannot be, for WORM rows).
@@ -35756,6 +35890,23 @@ var pentestFindingContract = c37.router({
35756
35890
  500: ErrorResponseSchema
35757
35891
  }
35758
35892
  },
35893
+ // Staff-only. `:pentestId` is the target; the source is in the body.
35894
+ copyFindings: {
35895
+ method: "POST",
35896
+ path: "/organizations/:organizationId/pentests/:pentestId/findings/copies",
35897
+ pathParams: PentestFindingParamsSchema,
35898
+ body: CopyPentestFindingsRequestSchema,
35899
+ responses: {
35900
+ 200: CopyPentestFindingsResponseSchema,
35901
+ 201: CopyPentestFindingsResponseSchema,
35902
+ 400: ErrorResponseSchema,
35903
+ 401: ErrorResponseSchema,
35904
+ 403: ErrorResponseSchema,
35905
+ 404: ErrorResponseSchema,
35906
+ 409: ErrorResponseSchema,
35907
+ 500: ErrorResponseSchema
35908
+ }
35909
+ },
35759
35910
  recoverStrandedFindings: {
35760
35911
  method: "POST",
35761
35912
  path: "/organizations/:organizationId/pentests/:pentestId/findings/recover-stranded",
@@ -37449,6 +37600,9 @@ var SetStaffOrganizationPentestCustomerVisibilityRequestSchema = external_export
37449
37600
  /** false hides the engagement from the customer entirely; true shows it again. */
37450
37601
  visible: external_exports.boolean()
37451
37602
  }).strict();
37603
+ var RenameStaffOrganizationPentestRequestSchema = external_exports.object({
37604
+ title: external_exports.string().trim().min(1, "Title is required").max(500, "Title must be 500 characters or fewer")
37605
+ }).strict();
37452
37606
 
37453
37607
  // ../../packages/api-contracts/src/contracts/v1/staff-pentests.ts
37454
37608
  var c47 = initContract();
@@ -37479,6 +37633,22 @@ var staffPentestsContract = c47.router({
37479
37633
  },
37480
37634
  summary: "Soft-delete a pentest from the admin console (Trace staff only)"
37481
37635
  },
37636
+ renameStaffOrganizationPentest: {
37637
+ method: "PATCH",
37638
+ path: "/organizations/:organizationId/staff/pentests/:pentestId",
37639
+ pathParams: StaffOrganizationPentestParamsSchema,
37640
+ body: RenameStaffOrganizationPentestRequestSchema,
37641
+ responses: {
37642
+ 200: StaffOrganizationPentestSummarySchema,
37643
+ 400: ErrorResponseSchema,
37644
+ 401: ErrorResponseSchema,
37645
+ 403: ErrorResponseSchema,
37646
+ 404: ErrorResponseSchema,
37647
+ 409: ErrorResponseSchema,
37648
+ 500: ErrorResponseSchema
37649
+ },
37650
+ summary: "Rename a pentest from the admin console (Trace staff only)"
37651
+ },
37482
37652
  setStaffOrganizationPentestCustomerVisibility: {
37483
37653
  method: "POST",
37484
37654
  path: "/organizations/:organizationId/staff/pentests/:pentestId/customer-visibility",
@@ -37623,6 +37793,9 @@ var NOTES_MAX = 2e3;
37623
37793
  var TestIntegrationIdParamsSchema = external_exports.object({
37624
37794
  testIntegrationId: external_exports.uuid()
37625
37795
  });
37796
+ var TestIntegrationPasskeyIdParamsSchema = TestIntegrationIdParamsSchema.extend({
37797
+ testIntegrationPasskeyId: external_exports.uuid()
37798
+ });
37626
37799
  var TestIntegrationResponseSchema = external_exports.object({
37627
37800
  testIntegrationId: external_exports.uuid(),
37628
37801
  label: external_exports.string(),
@@ -37635,9 +37808,15 @@ var TestIntegrationResponseSchema = external_exports.object({
37635
37808
  * none (or its stored parameters are unreadable). The setup key is never
37636
37809
  * returned by any route.
37637
37810
  */
37638
- mfa: CredentialMfaSummarySchema.nullable()
37811
+ mfa: CredentialMfaSummarySchema.nullable(),
37812
+ /** Passkeys staff stored for the integration's account. A count only: nothing here is decrypted. */
37813
+ passkeyCount: external_exports.number().int().min(0)
37639
37814
  }).strict();
37640
37815
  var ListTestIntegrationsResponseSchema = external_exports.array(TestIntegrationResponseSchema);
37816
+ var TestIntegrationPasskeyImportSchema = AgentSessionPasskeySchema.required({
37817
+ privateKey: true,
37818
+ userHandle: true
37819
+ }).strict();
37641
37820
  var TestIntegrationLoginSchema = external_exports.object({
37642
37821
  username: external_exports.string().min(1).max(512),
37643
37822
  password: external_exports.string().min(1).max(1024)
@@ -37762,6 +37941,54 @@ var testIntegrationContract = c49.router({
37762
37941
  404: ErrorResponseSchema,
37763
37942
  500: ErrorResponseSchema
37764
37943
  }
37944
+ },
37945
+ // -- Passkeys (org-less, verified staff) -----------------------------------
37946
+ // Staff enroll a passkey on the integration's account with the capture script
37947
+ // and import the captured file here. Agents never reach these routes; the agent
37948
+ // read path is provision_passkey on the run-token channel. A stored key signs in
37949
+ // as a Trace-owned account, so every route takes the MFA routes' verified-staff
37950
+ // bar. Import is never an upsert: a duplicate is a mistake to surface.
37951
+ importTestIntegrationPasskey: {
37952
+ method: "POST",
37953
+ path: "/staff/test-integrations/:testIntegrationId/passkeys",
37954
+ pathParams: TestIntegrationIdParamsSchema,
37955
+ body: TestIntegrationPasskeyImportSchema,
37956
+ responses: {
37957
+ 201: TestIntegrationPasskeySummarySchema,
37958
+ 400: ErrorResponseSchema,
37959
+ 401: ErrorResponseSchema,
37960
+ 403: ErrorResponseSchema,
37961
+ 404: ErrorResponseSchema,
37962
+ 409: ErrorResponseSchema,
37963
+ // TEST_INTEGRATION_PASSKEY_EXISTS
37964
+ 500: ErrorResponseSchema
37965
+ }
37966
+ },
37967
+ listTestIntegrationPasskeys: {
37968
+ method: "GET",
37969
+ path: "/staff/test-integrations/:testIntegrationId/passkeys",
37970
+ pathParams: TestIntegrationIdParamsSchema,
37971
+ responses: {
37972
+ 200: external_exports.array(TestIntegrationPasskeySummarySchema),
37973
+ 401: ErrorResponseSchema,
37974
+ 403: ErrorResponseSchema,
37975
+ 404: ErrorResponseSchema,
37976
+ 500: ErrorResponseSchema
37977
+ }
37978
+ },
37979
+ // Not idempotent: the id names a specific row, and a second delete should say it
37980
+ // is gone. The passkey on the site's side stays.
37981
+ deleteTestIntegrationPasskey: {
37982
+ method: "DELETE",
37983
+ path: "/staff/test-integrations/:testIntegrationId/passkeys/:testIntegrationPasskeyId",
37984
+ pathParams: TestIntegrationPasskeyIdParamsSchema,
37985
+ responses: {
37986
+ 204: DeleteResponseSchema,
37987
+ 401: ErrorResponseSchema,
37988
+ 403: ErrorResponseSchema,
37989
+ 404: ErrorResponseSchema,
37990
+ 500: ErrorResponseSchema
37991
+ }
37765
37992
  }
37766
37993
  });
37767
37994
 
@@ -41908,22 +42135,30 @@ function toIso(value) {
41908
42135
  return Number.isNaN(date5.getTime()) ? String(value) : date5.toISOString();
41909
42136
  }
41910
42137
  async function confirmDeletion(description, skip) {
41911
- if (skip) {
42138
+ return confirmAction(`Delete ${description}?`, { skip, verb: "delete" });
42139
+ }
42140
+ async function confirmAction(question, options) {
42141
+ if (options.skip) {
41912
42142
  return true;
41913
42143
  }
41914
42144
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
41915
- console.error(colorError("Refusing to delete without --yes in a non-interactive shell."));
42145
+ console.error(
42146
+ colorError(`Refusing to ${options.verb} without --yes in a non-interactive shell.`)
42147
+ );
41916
42148
  process.exit(1);
41917
42149
  }
41918
42150
  const readline = createInterface({ input: process.stdin, output: process.stdout });
41919
42151
  try {
41920
- const answer = await readline.question(`Delete ${description}? [y/N] `);
42152
+ const answer = await readline.question(`${question} [y/N] `);
41921
42153
  const normalized = answer.trim().toLowerCase();
41922
42154
  return normalized === "y" || normalized === "yes";
41923
42155
  } finally {
41924
42156
  readline.close();
41925
42157
  }
41926
42158
  }
42159
+ function collectRepeatable(value, previous) {
42160
+ return [...previous ?? [], value];
42161
+ }
41927
42162
 
41928
42163
  // src/application/commands.ts
41929
42164
  var APPLICATION_TYPE_BY_INPUT = {
@@ -47544,11 +47779,46 @@ var CLI_VERSION = packageMetadata.version;
47544
47779
  // src/pentest/commands.ts
47545
47780
  import { readFileSync as readFileSync7 } from "fs";
47546
47781
 
47547
- // src/pentest/create-finding.ts
47782
+ // src/pentest/evidence.ts
47548
47783
  import { readFileSync as readFileSync5, statSync as statSync2 } from "fs";
47784
+ import { basename as basename2 } from "path";
47549
47785
 
47550
47786
  // src/pentest/resolve.ts
47551
47787
  var UUID_PATTERN2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
47788
+ async function resolveApplicationId(organizationId, identifier) {
47789
+ if (UUID_PATTERN2.test(identifier)) {
47790
+ return identifier;
47791
+ }
47792
+ const result = await authenticatedRequest(
47793
+ (client) => client.application.listApplications({ params: { organizationId } })
47794
+ );
47795
+ if (result.status === UNAUTHORIZED_STATUS2) {
47796
+ exitAuthFailed();
47797
+ }
47798
+ if (result.status !== 200) {
47799
+ console.error(colorError(`Failed to list applications (status ${result.status}).`));
47800
+ process.exit(1);
47801
+ }
47802
+ const wanted = identifier.trim().toLowerCase();
47803
+ const matches = result.body.filter((app) => app.name.trim().toLowerCase() === wanted);
47804
+ if (matches.length === 1 && matches[0]) {
47805
+ return matches[0].applicationId;
47806
+ }
47807
+ if (matches.length > 1) {
47808
+ console.error(
47809
+ colorError(
47810
+ `Application name '${sanitizeDisplay(identifier)}' is ambiguous. Pass the application ID instead (\`tracecli app list\`).`
47811
+ )
47812
+ );
47813
+ process.exit(1);
47814
+ }
47815
+ console.error(
47816
+ colorError(
47817
+ `Application '${sanitizeDisplay(identifier)}' not found. Run \`tracecli app list\` to see available applications.`
47818
+ )
47819
+ );
47820
+ process.exit(1);
47821
+ }
47552
47822
  async function resolvePentestId(organizationId, identifier) {
47553
47823
  if (UUID_PATTERN2.test(identifier)) {
47554
47824
  return identifier;
@@ -47585,61 +47855,360 @@ Run \`tracecli pentest list\` to see available pentests.`
47585
47855
  return pentest.pentestId;
47586
47856
  }
47587
47857
 
47588
- // src/pentest/create-finding.ts
47589
- var SEVERITY_LIST = PentestFindingSeverityValues.join(", ");
47590
- function collectCwe(value, previous) {
47591
- return [...previous ?? [], value];
47858
+ // src/pentest/evidence.ts
47859
+ var SECTION_LIST = ValidationEvidenceSectionValues.join(", ");
47860
+ var KIND_LIST = PentestExploitArtifactKindValues.join(", ");
47861
+ function isSection(value) {
47862
+ return ValidationEvidenceSectionValues.includes(value);
47592
47863
  }
47593
- function parseSeverity(raw) {
47594
- const parsed = PentestFindingSeverity.safeParse(raw.trim().toUpperCase());
47595
- if (parsed.success) {
47596
- return parsed.data;
47597
- }
47598
- return {
47599
- error: `Invalid --severity '${sanitizeDisplay(raw)}'. Use one of: ${SEVERITY_LIST}.`
47600
- };
47864
+ function isKind(value) {
47865
+ return PentestExploitArtifactKindValues.includes(value);
47601
47866
  }
47602
- function parseCvssScore(raw) {
47603
- const score = Number(raw);
47604
- if (!Number.isFinite(score) || score < 0 || score > 10) {
47605
- return {
47606
- error: `Invalid --cvss-score '${sanitizeDisplay(raw)}'. Expected a number between 0 and 10.`
47607
- };
47608
- }
47609
- return score;
47867
+ function formatBytes2(bytes) {
47868
+ if (bytes < 1024) return `${bytes} B`;
47869
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
47870
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
47610
47871
  }
47611
- function parseCreateFindingJsonFile(raw, sourceLabel) {
47612
- let parsed;
47613
- try {
47614
- parsed = JSON.parse(raw);
47615
- } catch {
47616
- return { error: `Invalid JSON in ${sourceLabel}.` };
47872
+ async function resolveFindingId(organizationId, pentestId, identifier) {
47873
+ if (isUuid(identifier)) {
47874
+ return identifier;
47617
47875
  }
47618
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
47619
- return {
47620
- error: `${sourceLabel} must be a JSON object of finding fields (not an array or null).`
47621
- };
47876
+ const numericMatch = identifier.match(/^(?:PTF-)?(\d+)$/i);
47877
+ if (!numericMatch?.[1]) {
47878
+ console.error(
47879
+ colorError(
47880
+ `Invalid finding identifier '${sanitizeDisplay(identifier)}'. Use a finding number (e.g. PTF-7) or UUID.`
47881
+ )
47882
+ );
47883
+ process.exit(1);
47622
47884
  }
47623
- return { data: parsed };
47885
+ const displayId = Number.parseInt(numericMatch[1], 10);
47886
+ const result = await authenticatedRequest(
47887
+ (client) => client.pentestFinding.listFindings({ params: { organizationId, pentestId } })
47888
+ );
47889
+ if (result.status === UNAUTHORIZED_STATUS2) {
47890
+ exitAuthFailed();
47891
+ }
47892
+ if (result.status !== 200) {
47893
+ console.error(colorError(`Failed to list findings (status ${result.status}).`));
47894
+ process.exit(1);
47895
+ }
47896
+ const match = result.body.find((f) => f.displayId === displayId);
47897
+ if (!match || !("pentestFindingId" in match)) {
47898
+ console.error(
47899
+ colorError(
47900
+ `No finding PTF-${displayId} in this pentest. Unpublished findings have no number yet \u2014 pass the finding UUID instead (\`tracecli pentest findings list --pentest <id> --output json\`).`
47901
+ )
47902
+ );
47903
+ process.exit(1);
47904
+ }
47905
+ return match.pentestFindingId;
47624
47906
  }
47625
- function readUtf8File(path, flag) {
47907
+ async function runAttachEvidence(options) {
47908
+ if (options.section !== void 0 && !isSection(options.section)) {
47909
+ console.error(colorError(`Invalid --section '${sanitizeDisplay(options.section)}'.`));
47910
+ console.error(`Valid sections: ${SECTION_LIST}`);
47911
+ process.exit(1);
47912
+ }
47913
+ if (options.kind !== void 0 && !isKind(options.kind)) {
47914
+ console.error(colorError(`Invalid --kind '${sanitizeDisplay(options.kind)}'.`));
47915
+ console.error(`Valid kinds: ${KIND_LIST}`);
47916
+ process.exit(1);
47917
+ }
47918
+ let sizeBytes;
47626
47919
  try {
47627
- const stats = statSync2(path);
47920
+ const stats = statSync2(options.file);
47628
47921
  if (!stats.isFile()) {
47629
- return { error: `${flag} '${sanitizeDisplay(path)}' is not a regular file.` };
47922
+ console.error(colorError(`--file '${sanitizeDisplay(options.file)}' is not a regular file.`));
47923
+ process.exit(1);
47630
47924
  }
47925
+ sizeBytes = stats.size;
47631
47926
  } catch {
47632
- return { error: `Cannot read ${flag} '${sanitizeDisplay(path)}'.` };
47927
+ console.error(colorError(`Cannot read --file '${sanitizeDisplay(options.file)}'.`));
47928
+ process.exit(1);
47633
47929
  }
47634
- const text = readFileSync5(path, "utf8");
47635
- if (text.trim().length === 0) {
47636
- return { error: `${flag} '${sanitizeDisplay(path)}' is empty.` };
47930
+ if (sizeBytes === 0) {
47931
+ console.error(colorError(`--file '${sanitizeDisplay(options.file)}' is empty.`));
47932
+ process.exit(1);
47637
47933
  }
47638
- return { text };
47639
- }
47640
- function exclusivePair(value, fileValue, flag) {
47641
- if (value !== void 0 && fileValue !== void 0) {
47642
- return { error: `Pass only one of --${flag} / --${flag}-file.` };
47934
+ if (sizeBytes > MAX_STAFF_EVIDENCE_UPLOAD_BYTES) {
47935
+ console.error(
47936
+ colorError(
47937
+ `File is ${formatBytes2(sizeBytes)}; the limit for one exhibit is ${formatBytes2(MAX_STAFF_EVIDENCE_UPLOAD_BYTES)}.`
47938
+ )
47939
+ );
47940
+ process.exit(1);
47941
+ }
47942
+ const organizationId = await resolveOrganizationId();
47943
+ const pentestId = await resolvePentestId(organizationId, options.pentest);
47944
+ const pentestFindingId = await resolveFindingId(organizationId, pentestId, options.finding);
47945
+ const params = { organizationId, pentestId, pentestFindingId };
47946
+ const filename = basename2(options.file);
47947
+ const slot = await authenticatedRequest(
47948
+ (client) => client.pentestFinding.createFindingEvidenceUpload({
47949
+ params,
47950
+ body: { filename, sizeBytes }
47951
+ })
47952
+ );
47953
+ if (slot.status === UNAUTHORIZED_STATUS2) {
47954
+ exitAuthFailed();
47955
+ }
47956
+ if (slot.status !== 201) {
47957
+ const { message } = readErrorFields(slot.body);
47958
+ console.error(
47959
+ colorError(message ?? `Failed to start the evidence upload (status ${slot.status}).`)
47960
+ );
47961
+ process.exit(1);
47962
+ }
47963
+ const body = readFileSync5(options.file);
47964
+ const put = await fetch(slot.body.uploadUrl, { method: "PUT", body: new Uint8Array(body) });
47965
+ if (!put.ok) {
47966
+ console.error(
47967
+ colorError(
47968
+ `Upload to storage failed (status ${put.status}). The upload link is short-lived \u2014 re-run the command.`
47969
+ )
47970
+ );
47971
+ process.exit(1);
47972
+ }
47973
+ const attached = await authenticatedRequest(
47974
+ (client) => client.pentestFinding.attachFindingEvidence({
47975
+ params,
47976
+ body: {
47977
+ key: slot.body.key,
47978
+ ...options.caption !== void 0 ? { caption: options.caption } : {},
47979
+ ...options.section !== void 0 && isSection(options.section) ? { section: options.section } : {},
47980
+ ...options.kind !== void 0 && isKind(options.kind) ? { kind: options.kind } : {},
47981
+ ...options.customerFacing ? { customerFacing: true } : {}
47982
+ }
47983
+ })
47984
+ );
47985
+ if (attached.status === UNAUTHORIZED_STATUS2) {
47986
+ exitAuthFailed();
47987
+ }
47988
+ if (attached.status !== 201) {
47989
+ const { message } = readErrorFields(attached.body);
47990
+ console.error(
47991
+ colorError(message ?? `Failed to attach the evidence (status ${attached.status}).`)
47992
+ );
47993
+ process.exit(1);
47994
+ }
47995
+ if (options.output === "json") {
47996
+ printJson(attached.body);
47997
+ return;
47998
+ }
47999
+ const visibility = attached.body.customerFacing ? "customer-facing" : "staff-internal";
48000
+ const placement = attached.body.section ? ` \xB7 ${attached.body.section}` : "";
48001
+ console.log(
48002
+ colorSuccess(
48003
+ `Attached ${attached.body.kind} \xB7 ${formatBytes2(attached.body.sizeBytes)} \xB7 ${visibility}${placement}`
48004
+ )
48005
+ );
48006
+ console.log(` artifact ${attached.body.artifactId}`);
48007
+ if (attached.body.kind === "HAR") {
48008
+ console.log(" HAR captures stay internal \u2014 they can carry a live session token.");
48009
+ } else if (!attached.body.customerFacing) {
48010
+ console.log(" Publish it from the finding\u2019s evidence panel in the staff console.");
48011
+ }
48012
+ }
48013
+ function createAttachEvidenceCommand() {
48014
+ return new Command("attach-evidence").description("Attach a screenshot, recording, or capture to a pentest finding (staff)").requiredOption("--pentest <id>", "Pentest display ID (e.g. PT-3) or UUID").requiredOption("--finding <id>", "Finding number (e.g. PTF-7) or UUID").requiredOption("--file <path>", "Path to the evidence file").option("--caption <text>", "What the exhibit shows; rendered as its label").option("--section <section>", `Report section it backs (${SECTION_LIST})`).option("--kind <kind>", `Override the derived exhibit kind (${KIND_LIST})`).option("--customer-facing", "Publish to the customer (not allowed for HAR or video)").option("-o, --output <format>", "Output format: text, json (default: text)", "text").addHelpText(
48015
+ "after",
48016
+ `
48017
+ The file uploads straight to storage, so recordings of any reasonable size work.
48018
+ Videos and HAR captures always land staff-internal \u2014 publish a video with the
48019
+ toggle in the staff console's evidence panel after reviewing it for redaction.
48020
+
48021
+ Examples:
48022
+ tracecli pentest findings attach-evidence --pentest PT-3 --finding 7 --file ./poc.png
48023
+ tracecli pentest findings attach-evidence --pentest PT-3 --finding PTF-7 --file ./poc.mov \\
48024
+ --caption "Cookie survives logout, replayed 40s later" --section proofOfConcept
48025
+ tracecli pentest findings attach-evidence --pentest PT-3 --finding 7 --file ./roster.json \\
48026
+ --caption "Full member roster returned to a non-member" --customer-facing --output json`
48027
+ ).action(async (options) => {
48028
+ await runAttachEvidence(options);
48029
+ });
48030
+ }
48031
+
48032
+ // src/pentest/copy-findings.ts
48033
+ function validateSelection(options) {
48034
+ const hasApplication = options.application !== void 0;
48035
+ const hasFindings = options.finding !== void 0 && options.finding.length > 0;
48036
+ if (hasApplication === hasFindings) {
48037
+ return {
48038
+ error: "Pass exactly one of --application <id|name> or --finding <id> (repeatable)."
48039
+ };
48040
+ }
48041
+ return null;
48042
+ }
48043
+ function failCopy(status, body) {
48044
+ const { message, errorCode } = readErrorFields(body);
48045
+ if (status === UNAUTHORIZED_STATUS2) {
48046
+ exitAuthFailed();
48047
+ }
48048
+ if (status === 403) {
48049
+ console.error(
48050
+ colorError(
48051
+ "Only Trace staff can copy pentest findings. Sign in with a staff account via `tracecli auth login`."
48052
+ )
48053
+ );
48054
+ process.exit(1);
48055
+ }
48056
+ if (status === 404) {
48057
+ console.error(colorError(message ?? "Pentest, application, or finding not found."));
48058
+ process.exit(1);
48059
+ }
48060
+ if (status === 400) {
48061
+ console.error(colorError(message ?? "Invalid request (status 400)."));
48062
+ process.exit(1);
48063
+ }
48064
+ if (status === 409) {
48065
+ console.error(
48066
+ colorError(
48067
+ errorCode === "PENTEST_CLOSED" ? message ?? "Closed pentests must be reopened before copying findings into them." : message ?? "Conflict (status 409)."
48068
+ )
48069
+ );
48070
+ process.exit(1);
48071
+ }
48072
+ console.error(
48073
+ colorError(`Failed to copy findings (status ${status})${message ? `: ${message}` : "."}`)
48074
+ );
48075
+ process.exit(1);
48076
+ }
48077
+ function formatCopiedFinding(finding) {
48078
+ const source = finding.sourceDisplayId === null ? "draft" : `PTF-${finding.sourceDisplayId}`;
48079
+ const status = sanitizeDisplay(finding.sourceStatus).toLowerCase().replace(/_/g, " ");
48080
+ const evidence = `${finding.evidenceCount} exhibit${finding.evidenceCount === 1 ? "" : "s"}`;
48081
+ const target = finding.pentestFindingId === null ? "" : ` -> ${finding.pentestFindingId}`;
48082
+ return ` [${sanitizeDisplay(finding.severity)}] ${sanitizeDisplay(finding.title)} (${source}, ${status}, ${evidence})${target}`;
48083
+ }
48084
+ async function copyFindings(options) {
48085
+ const selection = validateSelection(options);
48086
+ if (selection) {
48087
+ console.error(colorError(selection.error));
48088
+ process.exit(1);
48089
+ }
48090
+ const organizationId = await resolveOrganizationId();
48091
+ const sourcePentestId = await resolvePentestId(organizationId, options.from);
48092
+ const targetPentestId = await resolvePentestId(organizationId, options.to);
48093
+ const dryRun = options.dryRun ?? false;
48094
+ const body = options.application !== void 0 ? {
48095
+ sourcePentestId,
48096
+ applicationId: await resolveApplicationId(organizationId, options.application),
48097
+ dryRun
48098
+ } : {
48099
+ sourcePentestId,
48100
+ pentestFindingIds: await Promise.all(
48101
+ (options.finding ?? []).map(
48102
+ (id) => resolveFindingId(organizationId, sourcePentestId, id)
48103
+ )
48104
+ ),
48105
+ dryRun
48106
+ };
48107
+ const result = await authenticatedRequest(
48108
+ (client) => client.pentestFinding.copyFindings({
48109
+ params: { organizationId, pentestId: targetPentestId },
48110
+ body
48111
+ })
48112
+ );
48113
+ if (result.status !== CREATED_STATUS && result.status !== SUCCESS_STATUS) {
48114
+ failCopy(result.status, result.body);
48115
+ }
48116
+ if (options.output === "json") {
48117
+ printJson(result.body);
48118
+ return;
48119
+ }
48120
+ const { findings } = result.body;
48121
+ if (findings.length === 0) {
48122
+ console.log("No findings matched the selection. Nothing copied.");
48123
+ return;
48124
+ }
48125
+ const exhibits = findings.reduce((n3, f) => n3 + f.evidenceCount, 0);
48126
+ const summary = `${findings.length} finding${findings.length === 1 ? "" : "s"} with ${exhibits} exhibit${exhibits === 1 ? "" : "s"}`;
48127
+ console.log(
48128
+ result.body.dryRun ? `Dry run: ${summary} would be copied from ${sanitizeDisplay(options.from)} to ${sanitizeDisplay(options.to)}.` : colorSuccess(
48129
+ `Copied ${summary} from ${sanitizeDisplay(options.from)} to ${sanitizeDisplay(options.to)}.`
48130
+ )
48131
+ );
48132
+ for (const finding of findings) {
48133
+ console.log(formatCopiedFinding(finding));
48134
+ }
48135
+ if (!result.body.dryRun) {
48136
+ console.log(
48137
+ `The copies are unpublished drafts. Run \`tracecli pentest findings publish --pentest ${sanitizeDisplay(options.to)} --all\` to number them and show them to the customer.`
48138
+ );
48139
+ }
48140
+ }
48141
+ function createCopyFindingsCommand() {
48142
+ return new Command("copy").description(
48143
+ "Copy findings (with prose and evidence) from one pentest into another as unpublished drafts (staff)"
48144
+ ).requiredOption("--from <id>", "Source pentest display ID (e.g. PT-1) or UUID").requiredOption("--to <id>", "Target pentest display ID (e.g. PT-2) or UUID").option(
48145
+ "--application <id|name>",
48146
+ "Copy every source finding that cites a repository linked to this application"
48147
+ ).option(
48148
+ "--finding <id>",
48149
+ "Source finding number (e.g. PTF-7) or UUID (repeatable)",
48150
+ collectRepeatable
48151
+ ).option("--dry-run", "Show what would be copied without writing anything").option("-o, --output <format>", "Output format: text, json (default: text)", "text").action(async (options) => {
48152
+ await copyFindings(options);
48153
+ });
48154
+ }
48155
+
48156
+ // src/pentest/create-finding.ts
48157
+ import { readFileSync as readFileSync6, statSync as statSync3 } from "fs";
48158
+ var SEVERITY_LIST = PentestFindingSeverityValues.join(", ");
48159
+ function collectCwe(value, previous) {
48160
+ return [...previous ?? [], value];
48161
+ }
48162
+ function parseSeverity(raw) {
48163
+ const parsed = PentestFindingSeverity.safeParse(raw.trim().toUpperCase());
48164
+ if (parsed.success) {
48165
+ return parsed.data;
48166
+ }
48167
+ return {
48168
+ error: `Invalid --severity '${sanitizeDisplay(raw)}'. Use one of: ${SEVERITY_LIST}.`
48169
+ };
48170
+ }
48171
+ function parseCvssScore(raw) {
48172
+ const score = Number(raw);
48173
+ if (!Number.isFinite(score) || score < 0 || score > 10) {
48174
+ return {
48175
+ error: `Invalid --cvss-score '${sanitizeDisplay(raw)}'. Expected a number between 0 and 10.`
48176
+ };
48177
+ }
48178
+ return score;
48179
+ }
48180
+ function parseCreateFindingJsonFile(raw, sourceLabel) {
48181
+ let parsed;
48182
+ try {
48183
+ parsed = JSON.parse(raw);
48184
+ } catch {
48185
+ return { error: `Invalid JSON in ${sourceLabel}.` };
48186
+ }
48187
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
48188
+ return {
48189
+ error: `${sourceLabel} must be a JSON object of finding fields (not an array or null).`
48190
+ };
48191
+ }
48192
+ return { data: parsed };
48193
+ }
48194
+ function readUtf8File(path, flag) {
48195
+ try {
48196
+ const stats = statSync3(path);
48197
+ if (!stats.isFile()) {
48198
+ return { error: `${flag} '${sanitizeDisplay(path)}' is not a regular file.` };
48199
+ }
48200
+ } catch {
48201
+ return { error: `Cannot read ${flag} '${sanitizeDisplay(path)}'.` };
48202
+ }
48203
+ const text = readFileSync6(path, "utf8");
48204
+ if (text.trim().length === 0) {
48205
+ return { error: `${flag} '${sanitizeDisplay(path)}' is empty.` };
48206
+ }
48207
+ return { text };
48208
+ }
48209
+ function exclusivePair(value, fileValue, flag) {
48210
+ if (value !== void 0 && fileValue !== void 0) {
48211
+ return { error: `Pass only one of --${flag} / --${flag}-file.` };
47643
48212
  }
47644
48213
  return { value: fileValue ?? value };
47645
48214
  }
@@ -47859,15 +48428,139 @@ function createCreateFindingCommand() {
47859
48428
  });
47860
48429
  }
47861
48430
 
48431
+ // src/pentest/staff-errors.ts
48432
+ function failStaffPentestWrite(action, status, body, options = {}) {
48433
+ const { message, errorCode } = readErrorFields(body);
48434
+ if (status === UNAUTHORIZED_STATUS2) {
48435
+ exitAuthFailed();
48436
+ }
48437
+ if (status === 403) {
48438
+ const staffGuard = message === void 0 || /only trace staff/i.test(message);
48439
+ console.error(
48440
+ colorError(
48441
+ staffGuard ? `Only Trace staff can ${action}. Sign in with a staff account via \`tracecli auth login\`.` : `Cannot ${action}: ${message}`
48442
+ )
48443
+ );
48444
+ process.exit(1);
48445
+ }
48446
+ if (status === 404) {
48447
+ console.error(colorError(options.notFound ?? message ?? "Not found."));
48448
+ process.exit(1);
48449
+ }
48450
+ if (status === 400) {
48451
+ console.error(colorError(message ?? "Invalid request (status 400)."));
48452
+ process.exit(1);
48453
+ }
48454
+ if (status === 409) {
48455
+ console.error(
48456
+ colorError(
48457
+ errorCode === "PENTEST_CLOSED" ? options.closed ?? message ?? "Closed pentests must be reopened first." : message ?? "Conflict (status 409)."
48458
+ )
48459
+ );
48460
+ process.exit(1);
48461
+ }
48462
+ console.error(
48463
+ colorError(`Failed to ${action} (status ${status})${message ? `: ${message}` : "."}`)
48464
+ );
48465
+ process.exit(1);
48466
+ }
48467
+
48468
+ // src/pentest/create-pentest.ts
48469
+ var TEST_TYPE_LIST = PentestTestTypeValues.join(", ");
48470
+ function parseTestType(raw) {
48471
+ const parsed = PentestTestType.safeParse(raw.trim().toUpperCase().replace(/-/g, "_"));
48472
+ if (parsed.success) {
48473
+ return parsed.data;
48474
+ }
48475
+ return { error: `Invalid --test-type '${sanitizeDisplay(raw)}'. Use one of: ${TEST_TYPE_LIST}.` };
48476
+ }
48477
+ async function createPentest(options) {
48478
+ const title = options.title.trim();
48479
+ if (title.length === 0) {
48480
+ console.error(colorError("--title cannot be empty."));
48481
+ process.exit(1);
48482
+ }
48483
+ let testType;
48484
+ if (options.testType !== void 0) {
48485
+ const parsed = parseTestType(options.testType);
48486
+ if (typeof parsed !== "string") {
48487
+ console.error(colorError(parsed.error));
48488
+ process.exit(1);
48489
+ }
48490
+ testType = parsed;
48491
+ }
48492
+ const organizationId = await resolveOrganizationId();
48493
+ const primaryApplicationId = options.application === void 0 ? void 0 : await resolveApplicationId(organizationId, options.application);
48494
+ const created = await authenticatedRequest(
48495
+ (client) => client.pentest.createPentest({
48496
+ params: { organizationId },
48497
+ body: {
48498
+ title,
48499
+ ...options.description !== void 0 ? { description: options.description } : {},
48500
+ ...primaryApplicationId !== void 0 ? { primaryApplicationId } : {},
48501
+ ...testType !== void 0 ? { testType } : {}
48502
+ }
48503
+ })
48504
+ );
48505
+ if (created.status !== CREATED_STATUS) {
48506
+ failStaffPentestWrite("create pentests", created.status, created.body);
48507
+ }
48508
+ const pentest = created.body;
48509
+ let hiddenFromCustomerAt = null;
48510
+ if (options.hidden) {
48511
+ const remedy = `Created pentest PT-${pentest.displayId} (${pentest.pentestId}) but could not hide it, so the customer can see it. Run \`tracecli pentest visibility PT-${pentest.displayId} --hidden\`.`;
48512
+ let hidden;
48513
+ try {
48514
+ hidden = await hidePentest(organizationId, pentest.pentestId);
48515
+ } catch (error48) {
48516
+ console.error(
48517
+ colorError(`${remedy} (${error48 instanceof Error ? error48.message : String(error48)})`)
48518
+ );
48519
+ process.exit(1);
48520
+ }
48521
+ if (hidden.status !== SUCCESS_STATUS) {
48522
+ console.error(colorError(`${remedy} (status ${hidden.status})`));
48523
+ process.exit(1);
48524
+ }
48525
+ hiddenFromCustomerAt = hidden.body.hiddenFromCustomerAt;
48526
+ }
48527
+ if (options.output === "json") {
48528
+ printJson({ ...pentest, hiddenFromCustomerAt });
48529
+ return;
48530
+ }
48531
+ console.log(colorSuccess(`Created pentest PT-${pentest.displayId} (${pentest.pentestId})`));
48532
+ console.log(` ${sanitizeDisplay(pentest.title)}`);
48533
+ const applications = (pentest.applications ?? []).map((app) => sanitizeDisplay(app.name));
48534
+ if (applications.length > 0) {
48535
+ console.log(` Application: ${applications.join(", ")}`);
48536
+ }
48537
+ console.log(
48538
+ hiddenFromCustomerAt === null ? " Visible to the customer. Findings stay unpublished until you publish them." : ` Hidden from the customer until you run \`tracecli pentest visibility PT-${pentest.displayId} --visible\`.`
48539
+ );
48540
+ }
48541
+ function hidePentest(organizationId, pentestId) {
48542
+ return authenticatedRequest(
48543
+ (client) => client.staffPentests.setStaffOrganizationPentestCustomerVisibility({
48544
+ params: { organizationId, pentestId },
48545
+ body: { visible: false }
48546
+ })
48547
+ );
48548
+ }
48549
+ function createCreatePentestCommand() {
48550
+ return new Command("create").description("Create a pentest (staff)").requiredOption("--title <text>", "Engagement title").option("--application <id|name>", "Application under test (bound as the primary application)").option("--test-type <type>", `Declared test type (${TEST_TYPE_LIST}); default WHITE_BOX`).option("--description <text>", "Engagement description").option("--hidden", "Hide the pentest from the customer until you make it visible").option("-o, --output <format>", "Output format: text, json (default: text)", "text").action(async (options) => {
48551
+ await createPentest(options);
48552
+ });
48553
+ }
48554
+
47862
48555
  // src/pentest/deliverables.ts
47863
48556
  import { open as open2, rename, rm, stat } from "fs/promises";
47864
- import { basename as basename3, join as join6, resolve } from "path";
48557
+ import { basename as basename4, join as join6, resolve } from "path";
47865
48558
 
47866
48559
  // src/upload/prepare.ts
47867
48560
  import { createHash } from "crypto";
47868
- import { createReadStream, statSync as statSync3 } from "fs";
48561
+ import { createReadStream, statSync as statSync4 } from "fs";
47869
48562
  import { open } from "fs/promises";
47870
- import { basename as basename2 } from "path";
48563
+ import { basename as basename3 } from "path";
47871
48564
  var CodeUploadPrepareError = class extends Error {
47872
48565
  constructor(message) {
47873
48566
  super(message);
@@ -47966,7 +48659,7 @@ function assertPreparedMatchesUpload(upload, prepared) {
47966
48659
  async function prepareUploadFile(filePath, options = {}) {
47967
48660
  let sizeBytes;
47968
48661
  try {
47969
- const stats = statSync3(filePath);
48662
+ const stats = statSync4(filePath);
47970
48663
  if (!stats.isFile()) {
47971
48664
  throw new CodeUploadPrepareError("Path is not a regular file. Pass a .zip, .apk, or .ipa.");
47972
48665
  }
@@ -47981,7 +48674,7 @@ async function prepareUploadFile(filePath, options = {}) {
47981
48674
  if (sizeBytes > CODE_UPLOAD_MAX_BYTES) {
47982
48675
  throw new CodeUploadPrepareError(archiveTooLargeMessage());
47983
48676
  }
47984
- const originalFilename = basename2(filePath);
48677
+ const originalFilename = basename3(filePath);
47985
48678
  const inferred = inferArtifactKindFromFilename(originalFilename);
47986
48679
  const artifactKind = options.artifactKind ?? inferred;
47987
48680
  if (artifactKind == null) {
@@ -48228,7 +48921,7 @@ var PentestDeliverableDownloadError = class extends Error {
48228
48921
  }
48229
48922
  };
48230
48923
  function safeDeliverableFilename(documentId, extension, fallbackStem) {
48231
- const stripped = Array.from(basename3(documentId.replace(/\\/g, "/")), (char) => {
48924
+ const stripped = Array.from(basename4(documentId.replace(/\\/g, "/")), (char) => {
48232
48925
  const code = char.codePointAt(0) ?? 0;
48233
48926
  return code <= 31 || code === 127 ? "_" : char;
48234
48927
  }).join("").trim();
@@ -48704,191 +49397,15 @@ Examples:
48704
49397
  tracecli pentest attestation download PT-3 --attestation 6f1c... --out ./loa.pdf --force
48705
49398
  tracecli pentest attestation download PT-3 --output json | jq '.path'`
48706
49399
  ).action(async (pentestId, options) => {
48707
- await runAttestationDownload(pentestId, options);
48708
- });
48709
- }
48710
- function createAttestationCommand() {
48711
- const attestation = new Command("attestation").alias("loa").description("List and download letters of attestation");
48712
- attestation.addCommand(createAttestationListCommand());
48713
- attestation.addCommand(createAttestationDownloadCommand());
48714
- return attestation;
48715
- }
48716
-
48717
- // src/pentest/evidence.ts
48718
- import { readFileSync as readFileSync6, statSync as statSync4 } from "fs";
48719
- import { basename as basename4 } from "path";
48720
- var SECTION_LIST = ValidationEvidenceSectionValues.join(", ");
48721
- var KIND_LIST = PentestExploitArtifactKindValues.join(", ");
48722
- function isSection(value) {
48723
- return ValidationEvidenceSectionValues.includes(value);
48724
- }
48725
- function isKind(value) {
48726
- return PentestExploitArtifactKindValues.includes(value);
48727
- }
48728
- function formatBytes2(bytes) {
48729
- if (bytes < 1024) return `${bytes} B`;
48730
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
48731
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
48732
- }
48733
- async function resolveFindingId(organizationId, pentestId, identifier) {
48734
- if (isUuid(identifier)) {
48735
- return identifier;
48736
- }
48737
- const numericMatch = identifier.match(/^(?:PTF-)?(\d+)$/i);
48738
- if (!numericMatch?.[1]) {
48739
- console.error(
48740
- colorError(
48741
- `Invalid finding identifier '${sanitizeDisplay(identifier)}'. Use a finding number (e.g. PTF-7) or UUID.`
48742
- )
48743
- );
48744
- process.exit(1);
48745
- }
48746
- const displayId = Number.parseInt(numericMatch[1], 10);
48747
- const result = await authenticatedRequest(
48748
- (client) => client.pentestFinding.listFindings({ params: { organizationId, pentestId } })
48749
- );
48750
- if (result.status === UNAUTHORIZED_STATUS2) {
48751
- exitAuthFailed();
48752
- }
48753
- if (result.status !== 200) {
48754
- console.error(colorError(`Failed to list findings (status ${result.status}).`));
48755
- process.exit(1);
48756
- }
48757
- const match = result.body.find((f) => f.displayId === displayId);
48758
- if (!match || !("pentestFindingId" in match)) {
48759
- console.error(
48760
- colorError(
48761
- `No finding PTF-${displayId} in this pentest. Unpublished findings have no number yet \u2014 pass the finding UUID instead (\`tracecli pentest findings list --pentest <id> --output json\`).`
48762
- )
48763
- );
48764
- process.exit(1);
48765
- }
48766
- return match.pentestFindingId;
48767
- }
48768
- async function runAttachEvidence(options) {
48769
- if (options.section !== void 0 && !isSection(options.section)) {
48770
- console.error(colorError(`Invalid --section '${sanitizeDisplay(options.section)}'.`));
48771
- console.error(`Valid sections: ${SECTION_LIST}`);
48772
- process.exit(1);
48773
- }
48774
- if (options.kind !== void 0 && !isKind(options.kind)) {
48775
- console.error(colorError(`Invalid --kind '${sanitizeDisplay(options.kind)}'.`));
48776
- console.error(`Valid kinds: ${KIND_LIST}`);
48777
- process.exit(1);
48778
- }
48779
- let sizeBytes;
48780
- try {
48781
- const stats = statSync4(options.file);
48782
- if (!stats.isFile()) {
48783
- console.error(colorError(`--file '${sanitizeDisplay(options.file)}' is not a regular file.`));
48784
- process.exit(1);
48785
- }
48786
- sizeBytes = stats.size;
48787
- } catch {
48788
- console.error(colorError(`Cannot read --file '${sanitizeDisplay(options.file)}'.`));
48789
- process.exit(1);
48790
- }
48791
- if (sizeBytes === 0) {
48792
- console.error(colorError(`--file '${sanitizeDisplay(options.file)}' is empty.`));
48793
- process.exit(1);
48794
- }
48795
- if (sizeBytes > MAX_STAFF_EVIDENCE_UPLOAD_BYTES) {
48796
- console.error(
48797
- colorError(
48798
- `File is ${formatBytes2(sizeBytes)}; the limit for one exhibit is ${formatBytes2(MAX_STAFF_EVIDENCE_UPLOAD_BYTES)}.`
48799
- )
48800
- );
48801
- process.exit(1);
48802
- }
48803
- const organizationId = await resolveOrganizationId();
48804
- const pentestId = await resolvePentestId(organizationId, options.pentest);
48805
- const pentestFindingId = await resolveFindingId(organizationId, pentestId, options.finding);
48806
- const params = { organizationId, pentestId, pentestFindingId };
48807
- const filename = basename4(options.file);
48808
- const slot = await authenticatedRequest(
48809
- (client) => client.pentestFinding.createFindingEvidenceUpload({
48810
- params,
48811
- body: { filename, sizeBytes }
48812
- })
48813
- );
48814
- if (slot.status === UNAUTHORIZED_STATUS2) {
48815
- exitAuthFailed();
48816
- }
48817
- if (slot.status !== 201) {
48818
- const { message } = readErrorFields(slot.body);
48819
- console.error(
48820
- colorError(message ?? `Failed to start the evidence upload (status ${slot.status}).`)
48821
- );
48822
- process.exit(1);
48823
- }
48824
- const body = readFileSync6(options.file);
48825
- const put = await fetch(slot.body.uploadUrl, { method: "PUT", body: new Uint8Array(body) });
48826
- if (!put.ok) {
48827
- console.error(
48828
- colorError(
48829
- `Upload to storage failed (status ${put.status}). The upload link is short-lived \u2014 re-run the command.`
48830
- )
48831
- );
48832
- process.exit(1);
48833
- }
48834
- const attached = await authenticatedRequest(
48835
- (client) => client.pentestFinding.attachFindingEvidence({
48836
- params,
48837
- body: {
48838
- key: slot.body.key,
48839
- ...options.caption !== void 0 ? { caption: options.caption } : {},
48840
- ...options.section !== void 0 && isSection(options.section) ? { section: options.section } : {},
48841
- ...options.kind !== void 0 && isKind(options.kind) ? { kind: options.kind } : {},
48842
- ...options.customerFacing ? { customerFacing: true } : {}
48843
- }
48844
- })
48845
- );
48846
- if (attached.status === UNAUTHORIZED_STATUS2) {
48847
- exitAuthFailed();
48848
- }
48849
- if (attached.status !== 201) {
48850
- const { message } = readErrorFields(attached.body);
48851
- console.error(
48852
- colorError(message ?? `Failed to attach the evidence (status ${attached.status}).`)
48853
- );
48854
- process.exit(1);
48855
- }
48856
- if (options.output === "json") {
48857
- printJson(attached.body);
48858
- return;
48859
- }
48860
- const visibility = attached.body.customerFacing ? "customer-facing" : "staff-internal";
48861
- const placement = attached.body.section ? ` \xB7 ${attached.body.section}` : "";
48862
- console.log(
48863
- colorSuccess(
48864
- `Attached ${attached.body.kind} \xB7 ${formatBytes2(attached.body.sizeBytes)} \xB7 ${visibility}${placement}`
48865
- )
48866
- );
48867
- console.log(` artifact ${attached.body.artifactId}`);
48868
- if (attached.body.kind === "HAR") {
48869
- console.log(" HAR captures stay internal \u2014 they can carry a live session token.");
48870
- } else if (!attached.body.customerFacing) {
48871
- console.log(" Publish it from the finding\u2019s evidence panel in the staff console.");
48872
- }
48873
- }
48874
- function createAttachEvidenceCommand() {
48875
- return new Command("attach-evidence").description("Attach a screenshot, recording, or capture to a pentest finding (staff)").requiredOption("--pentest <id>", "Pentest display ID (e.g. PT-3) or UUID").requiredOption("--finding <id>", "Finding number (e.g. PTF-7) or UUID").requiredOption("--file <path>", "Path to the evidence file").option("--caption <text>", "What the exhibit shows; rendered as its label").option("--section <section>", `Report section it backs (${SECTION_LIST})`).option("--kind <kind>", `Override the derived exhibit kind (${KIND_LIST})`).option("--customer-facing", "Publish to the customer (not allowed for HAR or video)").option("-o, --output <format>", "Output format: text, json (default: text)", "text").addHelpText(
48876
- "after",
48877
- `
48878
- The file uploads straight to storage, so recordings of any reasonable size work.
48879
- Videos and HAR captures always land staff-internal \u2014 publish a video with the
48880
- toggle in the staff console's evidence panel after reviewing it for redaction.
48881
-
48882
- Examples:
48883
- tracecli pentest findings attach-evidence --pentest PT-3 --finding 7 --file ./poc.png
48884
- tracecli pentest findings attach-evidence --pentest PT-3 --finding PTF-7 --file ./poc.mov \\
48885
- --caption "Cookie survives logout, replayed 40s later" --section proofOfConcept
48886
- tracecli pentest findings attach-evidence --pentest PT-3 --finding 7 --file ./roster.json \\
48887
- --caption "Full member roster returned to a non-member" --customer-facing --output json`
48888
- ).action(async (options) => {
48889
- await runAttachEvidence(options);
49400
+ await runAttestationDownload(pentestId, options);
48890
49401
  });
48891
49402
  }
49403
+ function createAttestationCommand() {
49404
+ const attestation = new Command("attestation").alias("loa").description("List and download letters of attestation");
49405
+ attestation.addCommand(createAttestationListCommand());
49406
+ attestation.addCommand(createAttestationDownloadCommand());
49407
+ return attestation;
49408
+ }
48892
49409
 
48893
49410
  // src/pentest/get-finding.ts
48894
49411
  var OUTPUT_FORMATS2 = ["text", "json"];
@@ -48911,7 +49428,9 @@ function sanitizeMarkdown(value) {
48911
49428
  }
48912
49429
  function formatFindingMarkdown(finding) {
48913
49430
  const lines = [];
48914
- lines.push(`# PTF-${finding.displayId}: ${sanitizeDisplay(finding.title)}`);
49431
+ const unpublished = "publishedAt" in finding && finding.publishedAt === null;
49432
+ const label = finding.displayId === null ? "Draft" : `PTF-${finding.displayId}${unpublished ? " (unpublished)" : ""}`;
49433
+ lines.push(`# ${label}: ${sanitizeDisplay(finding.title)}`);
48915
49434
  lines.push("");
48916
49435
  lines.push(`- Severity: ${sanitizeDisplay(finding.severity)}`);
48917
49436
  lines.push(`- Status: ${sanitizeDisplay(finding.status)}`);
@@ -48970,6 +49489,18 @@ function failGetFinding(status, body) {
48970
49489
  );
48971
49490
  process.exit(1);
48972
49491
  }
49492
+ function readStaffFinding(organizationId, pentestId, pentestFindingId) {
49493
+ return authenticatedRequest(
49494
+ (client) => client.pentestFinding.getFinding({ params: { organizationId, pentestId, pentestFindingId } })
49495
+ );
49496
+ }
49497
+ function readCustomerFinding(organizationId, pentestId, pentestFindingId) {
49498
+ return authenticatedRequest(
49499
+ (client) => client.pentestFinding.getCustomerFinding({
49500
+ params: { organizationId, pentestId, pentestFindingId }
49501
+ })
49502
+ );
49503
+ }
48973
49504
  async function runGetFinding(options) {
48974
49505
  if (!isOutputFormat2(options.output)) {
48975
49506
  console.error(
@@ -48994,15 +49525,16 @@ async function runGetFinding(options) {
48994
49525
  process.exit(1);
48995
49526
  }
48996
49527
  const organizationId = await resolveOrganizationId();
48997
- const result = await authenticatedRequest(
48998
- (client) => client.pentestFinding.getCustomerFinding({
48999
- params: {
49000
- organizationId,
49001
- pentestId: options.pentest,
49002
- pentestFindingId: options.finding
49003
- }
49004
- })
49005
- );
49528
+ const me = requireAuth().kind === "user" ? await authenticatedRequestSafe((client) => client.user.getMyContext()) : null;
49529
+ const isStaff = me !== null && me.status === 200 && me.body.user.isStaff;
49530
+ let result;
49531
+ if (isStaff) {
49532
+ const pentestId = await resolvePentestId(organizationId, options.pentest);
49533
+ const pentestFindingId = await resolveFindingId(organizationId, pentestId, options.finding);
49534
+ result = await readStaffFinding(organizationId, pentestId, pentestFindingId);
49535
+ } else {
49536
+ result = await readCustomerFinding(organizationId, options.pentest, options.finding);
49537
+ }
49006
49538
  if (result.status !== 200) {
49007
49539
  failGetFinding(result.status, result.body);
49008
49540
  }
@@ -49031,6 +49563,185 @@ Examples:
49031
49563
  });
49032
49564
  }
49033
49565
 
49566
+ // src/pentest/publish-findings.ts
49567
+ var MAX_PUBLISH_BATCH = 200;
49568
+ function validatePublishSelection(options) {
49569
+ const all = options.all === true;
49570
+ const some = options.finding !== void 0 && options.finding.length > 0;
49571
+ if (all === some) {
49572
+ return { error: "Pass exactly one of --all or --finding <uuid> (repeatable)." };
49573
+ }
49574
+ return null;
49575
+ }
49576
+ function isPublishableDraft(finding) {
49577
+ return finding.publishedAt === null && finding.status !== "DISMISSED";
49578
+ }
49579
+ function formatCandidate(finding) {
49580
+ return ` [${sanitizeDisplay(finding.severity)}] ${sanitizeDisplay(finding.title)}`;
49581
+ }
49582
+ function draftsOutrankedBy(selected, all) {
49583
+ const rank = (severity) => SEVERITY_ORDER[severity] ?? Number.MAX_SAFE_INTEGER;
49584
+ const selectedIds = new Set(selected.map((f) => f.pentestFindingId));
49585
+ const leastSevereSelected = Math.max(...selected.map((f) => rank(f.severity)));
49586
+ return all.filter(
49587
+ (f) => !selectedIds.has(f.pentestFindingId) && isPublishableDraft(f) && // A numbered row that was unpublished keeps its number on republish, so it
49588
+ // cannot be pushed out of order by anything published before it.
49589
+ f.displayId === null && rank(f.severity) <= leastSevereSelected
49590
+ ).sort((a2, b) => rank(a2.severity) - rank(b.severity));
49591
+ }
49592
+ async function publishFindings(options) {
49593
+ const selection = validatePublishSelection(options);
49594
+ if (selection) {
49595
+ console.error(colorError(selection.error));
49596
+ process.exit(1);
49597
+ }
49598
+ const organizationId = await resolveOrganizationId();
49599
+ const pentestId = await resolvePentestId(organizationId, options.pentest);
49600
+ const pentestResult = await authenticatedRequest(
49601
+ (client) => client.pentest.getPentest({ params: { organizationId, pentestId } })
49602
+ );
49603
+ if (pentestResult.status !== SUCCESS_STATUS) {
49604
+ failStaffPentestWrite("read the pentest", pentestResult.status, pentestResult.body, {
49605
+ notFound: `Pentest '${sanitizeDisplay(options.pentest)}' not found. Verify the ID with \`tracecli pentest list\`.`
49606
+ });
49607
+ }
49608
+ const pentest = pentestResult.body;
49609
+ const label = `PT-${pentest.displayId}`;
49610
+ const listResult = await authenticatedRequest(
49611
+ (client) => client.pentestFinding.listFindings({ params: { organizationId, pentestId } })
49612
+ );
49613
+ if (listResult.status !== SUCCESS_STATUS) {
49614
+ failStaffPentestWrite("list the findings", listResult.status, listResult.body);
49615
+ }
49616
+ const findings = listResult.body;
49617
+ const byId = new Map(findings.map((f) => [f.pentestFindingId, f]));
49618
+ const drafts = findings.filter(isPublishableDraft);
49619
+ let candidates;
49620
+ if (options.all) {
49621
+ candidates = drafts;
49622
+ } else {
49623
+ const draftById = new Map(drafts.map((f) => [f.pentestFindingId, f]));
49624
+ candidates = (options.finding ?? []).map((id) => {
49625
+ const found = draftById.get(id.toLowerCase());
49626
+ if (!found) {
49627
+ const known = byId.get(id.toLowerCase());
49628
+ console.error(
49629
+ colorError(
49630
+ known ? `${sanitizeDisplay(id)} is ${known.publishedAt === null ? "dismissed" : "already published"} in ${label}, so it cannot be published.` : `${sanitizeDisplay(id)} is not an unpublished draft in ${label}. Drafts have no number yet; pass the UUID from \`tracecli pentest findings list --pentest ${label} --output json\`.`
49631
+ )
49632
+ );
49633
+ process.exit(1);
49634
+ }
49635
+ return found;
49636
+ });
49637
+ }
49638
+ if (candidates.length === 0) {
49639
+ if (options.output === "json") {
49640
+ printJson({ published: [] });
49641
+ } else {
49642
+ console.log(
49643
+ `Nothing to publish in ${label}: every non-dismissed finding is already published.`
49644
+ );
49645
+ }
49646
+ return;
49647
+ }
49648
+ if (candidates.length > MAX_PUBLISH_BATCH) {
49649
+ console.error(
49650
+ colorError(
49651
+ `${candidates.length} findings exceed the ${MAX_PUBLISH_BATCH} a single publish accepts. Publish from the staff pentest console for a set this large.`
49652
+ )
49653
+ );
49654
+ process.exit(1);
49655
+ }
49656
+ if (!options.all && options.outOfOrder !== true) {
49657
+ const leftBehind = draftsOutrankedBy(candidates, findings);
49658
+ if (leftBehind.length > 0) {
49659
+ console.error(
49660
+ colorError(
49661
+ `Publishing these now would number them ahead of ${leftBehind.length} unpublished finding${leftBehind.length === 1 ? "" : "s"} of equal or higher severity, and PTF numbers follow severity only when the set is published together:`
49662
+ )
49663
+ );
49664
+ for (const finding of leftBehind) {
49665
+ console.error(formatCandidate(finding));
49666
+ }
49667
+ console.error(
49668
+ colorError(
49669
+ "Use --all to publish them together, or --out-of-order to accept this numbering."
49670
+ )
49671
+ );
49672
+ process.exit(1);
49673
+ }
49674
+ }
49675
+ const hidden = "hiddenFromCustomerAt" in pentest && pentest.hiddenFromCustomerAt != null;
49676
+ if (options.output !== "json" || !options.yes) {
49677
+ console.log(
49678
+ `${candidates.length} finding${candidates.length === 1 ? "" : "s"} will be numbered and shown to the customer in ${label}:`
49679
+ );
49680
+ for (const finding of candidates) {
49681
+ console.log(formatCandidate(finding));
49682
+ }
49683
+ if (hidden) {
49684
+ console.log(
49685
+ colorWarning(
49686
+ `${label} is hidden from the customer, so nothing becomes visible until you run \`tracecli pentest visibility ${label} --visible\`.`
49687
+ )
49688
+ );
49689
+ }
49690
+ }
49691
+ const confirmed = await confirmAction("Publish to the customer?", {
49692
+ skip: options.yes === true,
49693
+ verb: "publish"
49694
+ });
49695
+ if (!confirmed) {
49696
+ console.log("Nothing published.");
49697
+ return;
49698
+ }
49699
+ const result = await authenticatedRequest(
49700
+ (client) => client.pentestFinding.publishAllFindings({
49701
+ params: { organizationId, pentestId },
49702
+ // Always the previewed ids, never the server's default set: a draft that lands
49703
+ // between the preview and the answer must not ride along unseen.
49704
+ body: { pentestFindingIds: candidates.map((f) => f.pentestFindingId) }
49705
+ })
49706
+ );
49707
+ if (result.status !== SUCCESS_STATUS) {
49708
+ failStaffPentestWrite("publish findings", result.status, result.body, {
49709
+ closed: "Closed pentests must be reopened before publishing findings."
49710
+ });
49711
+ }
49712
+ if (options.output === "json") {
49713
+ printJson(result.body);
49714
+ return;
49715
+ }
49716
+ const published = result.body.published;
49717
+ console.log(
49718
+ colorSuccess(
49719
+ `Published ${published.length} finding${published.length === 1 ? "" : "s"} in ${label}.`
49720
+ )
49721
+ );
49722
+ for (const entry of published) {
49723
+ const finding = byId.get(entry.pentestFindingId);
49724
+ console.log(
49725
+ ` PTF-${entry.displayId} ${finding ? sanitizeDisplay(finding.title) : entry.pentestFindingId}`
49726
+ );
49727
+ }
49728
+ if (hidden) {
49729
+ console.log(
49730
+ colorWarning(
49731
+ `${label} is still hidden from the customer. Run \`tracecli pentest visibility ${label} --visible\` when you are ready.`
49732
+ )
49733
+ );
49734
+ }
49735
+ }
49736
+ function createPublishFindingsCommand() {
49737
+ return new Command("publish").description("Number unpublished findings and show them to the customer (staff)").requiredOption("--pentest <id>", "Pentest display ID (e.g. PT-3) or UUID").option("--all", "Publish every unpublished, non-dismissed finding").option("--finding <uuid>", "Draft finding UUID to publish (repeatable)", collectRepeatable).option("-y, --yes", "Skip the confirmation prompt").option(
49738
+ "--out-of-order",
49739
+ "With --finding: accept numbering ahead of equal-or-higher-severity drafts left unpublished"
49740
+ ).option("-o, --output <format>", "Output format: text, json (default: text)", "text").action(async (options) => {
49741
+ await publishFindings(options);
49742
+ });
49743
+ }
49744
+
49034
49745
  // src/pentest/questionnaire.ts
49035
49746
  function buildUpsertQuestionnaireBody(answers) {
49036
49747
  if (answers === null) {
@@ -49142,6 +49853,56 @@ function surfaceSelectOptions() {
49142
49853
  }));
49143
49854
  }
49144
49855
 
49856
+ // src/pentest/visibility.ts
49857
+ function resolveVisibilityChoice(options) {
49858
+ const hidden = options.hidden === true;
49859
+ const visible = options.visible === true;
49860
+ if (hidden === visible) {
49861
+ return { error: "Pass exactly one of --hidden or --visible." };
49862
+ }
49863
+ return { visible };
49864
+ }
49865
+ async function setVisibility(pentestIdentifier, options) {
49866
+ const choice = resolveVisibilityChoice(options);
49867
+ if ("error" in choice) {
49868
+ console.error(colorError(choice.error));
49869
+ process.exit(1);
49870
+ }
49871
+ const organizationId = await resolveOrganizationId();
49872
+ const pentestId = await resolvePentestId(organizationId, pentestIdentifier);
49873
+ const result = await authenticatedRequest(
49874
+ (client) => client.staffPentests.setStaffOrganizationPentestCustomerVisibility({
49875
+ params: { organizationId, pentestId },
49876
+ body: { visible: choice.visible }
49877
+ })
49878
+ );
49879
+ if (result.status !== SUCCESS_STATUS) {
49880
+ failStaffPentestWrite("change pentest visibility", result.status, result.body, {
49881
+ notFound: `Pentest '${sanitizeDisplay(pentestIdentifier)}' not found. Verify the ID with \`tracecli pentest list\`.`
49882
+ });
49883
+ }
49884
+ if (options.output === "json") {
49885
+ printJson(result.body);
49886
+ return;
49887
+ }
49888
+ const label = `PT-${result.body.displayId}`;
49889
+ console.log(
49890
+ colorSuccess(
49891
+ result.body.hiddenFromCustomerAt === null ? `${label} is visible to the customer.` : `${label} is hidden from the customer.`
49892
+ )
49893
+ );
49894
+ if (result.body.hiddenFromCustomerAt === null) {
49895
+ console.log(" Published findings, reports, and attestations show in their dashboard again.");
49896
+ } else {
49897
+ console.log(" The customer no longer sees this pentest anywhere; staff views are unchanged.");
49898
+ }
49899
+ }
49900
+ function createVisibilityCommand() {
49901
+ return new Command("visibility").description("Hide a pentest from the customer or make it visible again (staff)").argument("<pentestId>", "Pentest display ID (e.g. PT-3) or UUID").option("--hidden", "Hide the pentest from the customer").option("--visible", "Make the pentest visible to the customer").option("-o, --output <format>", "Output format: text, json (default: text)", "text").action(async (pentestId, options) => {
49902
+ await setVisibility(pentestId, options);
49903
+ });
49904
+ }
49905
+
49145
49906
  // src/pentest/commands.ts
49146
49907
  var DEFAULT_LIMIT2 = 30;
49147
49908
  var MAX_LIMIT2 = 100;
@@ -49518,6 +50279,8 @@ function createFindingsCommand() {
49518
50279
  findings.addCommand(createStatusUpdateCommand());
49519
50280
  findings.addCommand(createAttachEvidenceCommand());
49520
50281
  findings.addCommand(createCreateFindingCommand(), { hidden: true });
50282
+ findings.addCommand(createCopyFindingsCommand(), { hidden: true });
50283
+ findings.addCommand(createPublishFindingsCommand(), { hidden: true });
49521
50284
  return findings;
49522
50285
  }
49523
50286
  var PRIORITY_SET = new Set(CustomerPriorityCategoryValues);
@@ -50001,6 +50764,8 @@ function createPentestCommand() {
50001
50764
  const pentest = new Command("pentest").description("Manage pentests");
50002
50765
  pentest.addCommand(createListCommand6());
50003
50766
  pentest.addCommand(createUpdateCommand4());
50767
+ pentest.addCommand(createCreatePentestCommand(), { hidden: true });
50768
+ pentest.addCommand(createVisibilityCommand(), { hidden: true });
50004
50769
  pentest.addCommand(createQuestionnaireCommand());
50005
50770
  pentest.addCommand(createFindingsCommand());
50006
50771
  pentest.addCommand(createReportCommand());
@@ -50008,8 +50773,251 @@ function createPentestCommand() {
50008
50773
  return pentest;
50009
50774
  }
50010
50775
 
50776
+ // src/test-integration/passkeys.ts
50777
+ import { readFileSync as readFileSync8, unlinkSync } from "fs";
50778
+
50779
+ // src/test-integration/resolve.ts
50780
+ async function resolveTestIntegrationId(label) {
50781
+ const result = await authenticatedRequest(
50782
+ (client) => client.testIntegration.listTestIntegrations()
50783
+ );
50784
+ if (result.status !== SUCCESS_STATUS) {
50785
+ failStaffPentestWrite("list test integrations", result.status, result.body);
50786
+ }
50787
+ const match = result.body.find((integration) => integration.label === label);
50788
+ if (match === void 0) {
50789
+ console.error(
50790
+ colorError(
50791
+ `No test integration named '${sanitizeDisplay(label)}'. Names are exact; the console's Test integrations page lists them.`
50792
+ )
50793
+ );
50794
+ process.exit(1);
50795
+ }
50796
+ return match.testIntegrationId;
50797
+ }
50798
+
50799
+ // src/test-integration/passkeys.ts
50800
+ function parseKeyFile(raw) {
50801
+ let parsed;
50802
+ try {
50803
+ parsed = JSON.parse(raw);
50804
+ } catch {
50805
+ return { error: "The key file is not JSON." };
50806
+ }
50807
+ if (Array.isArray(parsed)) {
50808
+ return {
50809
+ error: "The key file holds an array. Import takes one passkey per file: write the one entry you mean to import as a single object."
50810
+ };
50811
+ }
50812
+ const result = TestIntegrationPasskeyImportSchema.safeParse(parsed);
50813
+ if (!result.success) {
50814
+ const paths = result.error.issues.map((issue2) => issue2.path.map(String).join(".") || "(root)").filter((path, index, all) => all.indexOf(path) === index);
50815
+ return {
50816
+ error: `The key file is not a captured passkey. Problem fields: ${paths.join(", ")}.`
50817
+ };
50818
+ }
50819
+ return { passkey: result.data };
50820
+ }
50821
+ function describePasskey(passkey) {
50822
+ const user = passkey.userName ?? void 0;
50823
+ return user === void 0 || user.length === 0 ? `the ${sanitizeDisplay(passkey.rpId)} passkey` : `the ${sanitizeDisplay(passkey.rpId)} passkey for ${sanitizeDisplay(user)}`;
50824
+ }
50825
+ function importSuccessLine(input) {
50826
+ const head = `Imported ${describePasskey(input.passkey)} into ${sanitizeDisplay(input.label)} (id ${input.id}).`;
50827
+ return input.deleted ? `${head} Deleted the key file ${input.path}.` : `${head} Kept ${input.path}. It holds the private key, so delete it once every environment you need has it, or pass --delete-file on the last import.`;
50828
+ }
50829
+ function deleteSuccessLine(input) {
50830
+ return `Deleted ${describePasskey(input.passkey)} from ${sanitizeDisplay(input.label)}. The site still lists it on the account, so remove it there too, and do it now if the key may have leaked.`;
50831
+ }
50832
+ async function importPasskey(label, options) {
50833
+ let raw;
50834
+ try {
50835
+ raw = readFileSync8(options.file, "utf8");
50836
+ } catch {
50837
+ console.error(colorError(`Cannot read the key file ${options.file}.`));
50838
+ process.exit(1);
50839
+ }
50840
+ const parsed = parseKeyFile(raw);
50841
+ if ("error" in parsed) {
50842
+ console.error(colorError(parsed.error));
50843
+ process.exit(1);
50844
+ }
50845
+ const { passkey } = parsed;
50846
+ const testIntegrationId = await resolveTestIntegrationId(label);
50847
+ if (options.output !== "json") {
50848
+ console.log(`Integration ${sanitizeDisplay(label)}`);
50849
+ console.log(`Site ${sanitizeDisplay(passkey.rpId)}`);
50850
+ console.log(
50851
+ `User ${passkey.userName ? sanitizeDisplay(passkey.userName) : colorMuted("(none in file)")}`
50852
+ );
50853
+ console.log(`Counter ${passkey.signCount}`);
50854
+ }
50855
+ const confirmed = await confirmAction(`Import this passkey into ${sanitizeDisplay(label)}?`, {
50856
+ skip: options.yes === true,
50857
+ verb: "import"
50858
+ });
50859
+ if (!confirmed) {
50860
+ console.log("Nothing imported.");
50861
+ return;
50862
+ }
50863
+ const result = await authenticatedRequest(
50864
+ (client) => client.testIntegration.importTestIntegrationPasskey({
50865
+ params: { testIntegrationId },
50866
+ body: passkey
50867
+ })
50868
+ );
50869
+ if (result.status !== CREATED_STATUS) {
50870
+ if (result.status === 409) {
50871
+ const { message } = readErrorFields(result.body);
50872
+ console.error(colorError(message ?? "This passkey is already stored on the integration."));
50873
+ console.error(`The key file ${options.file} was kept.`);
50874
+ process.exit(1);
50875
+ }
50876
+ failStaffPentestWrite("import a passkey", result.status, result.body, {
50877
+ notFound: `No test integration named '${sanitizeDisplay(label)}'.`
50878
+ });
50879
+ }
50880
+ let deleted = false;
50881
+ if (options.deleteFile === true) {
50882
+ unlinkSync(options.file);
50883
+ deleted = true;
50884
+ }
50885
+ if (options.output === "json") {
50886
+ printJson({ ...result.body, keyFile: deleted ? null : options.file });
50887
+ return;
50888
+ }
50889
+ console.log(
50890
+ colorSuccess(
50891
+ importSuccessLine({
50892
+ passkey,
50893
+ label,
50894
+ id: result.body.testIntegrationPasskeyId,
50895
+ path: options.file,
50896
+ deleted
50897
+ })
50898
+ )
50899
+ );
50900
+ }
50901
+ async function listPasskeys(label, options) {
50902
+ const testIntegrationId = await resolveTestIntegrationId(label);
50903
+ const result = await authenticatedRequest(
50904
+ (client) => client.testIntegration.listTestIntegrationPasskeys({ params: { testIntegrationId } })
50905
+ );
50906
+ if (result.status !== SUCCESS_STATUS) {
50907
+ failStaffPentestWrite("list passkeys", result.status, result.body);
50908
+ }
50909
+ const rows = result.body;
50910
+ if (options.output === "json") {
50911
+ printJson(rows);
50912
+ return;
50913
+ }
50914
+ if (options.output === "tsv") {
50915
+ console.log("id site user imported lastUsed");
50916
+ for (const row of rows) {
50917
+ console.log(
50918
+ [
50919
+ row.testIntegrationPasskeyId,
50920
+ row.rpId,
50921
+ userColumn(row),
50922
+ toIso(row.createdAt),
50923
+ row.lastAssertedAt === null ? "" : toIso(row.lastAssertedAt)
50924
+ ].join(" ")
50925
+ );
50926
+ }
50927
+ return;
50928
+ }
50929
+ if (rows.length === 0) {
50930
+ console.log(`No passkeys stored for ${sanitizeDisplay(label)}.`);
50931
+ return;
50932
+ }
50933
+ for (const row of rows) {
50934
+ console.log(
50935
+ `${row.testIntegrationPasskeyId} ${sanitizeDisplay(row.rpId)} ${userColumn(row)} imported ${toIso(row.createdAt)} last used ${row.lastAssertedAt === null ? "never" : toIso(row.lastAssertedAt)}`
50936
+ );
50937
+ }
50938
+ }
50939
+ function userColumn(row) {
50940
+ if (row.unreadable) return "unreadable";
50941
+ return row.userName === null ? "" : sanitizeDisplay(row.userName);
50942
+ }
50943
+ async function deletePasskey(label, passkeyId, options) {
50944
+ const testIntegrationId = await resolveTestIntegrationId(label);
50945
+ const listed = await authenticatedRequest(
50946
+ (client) => client.testIntegration.listTestIntegrationPasskeys({ params: { testIntegrationId } })
50947
+ );
50948
+ if (listed.status !== SUCCESS_STATUS) {
50949
+ failStaffPentestWrite("list passkeys", listed.status, listed.body);
50950
+ }
50951
+ const row = listed.body.find(
50952
+ (r2) => r2.testIntegrationPasskeyId === passkeyId
50953
+ );
50954
+ if (row === void 0) {
50955
+ console.error(
50956
+ colorError(
50957
+ `No passkey with id ${sanitizeDisplay(passkeyId)} is stored on ${sanitizeDisplay(label)}. Run \`tracecli test-integration passkeys list ${sanitizeDisplay(label)}\`.`
50958
+ )
50959
+ );
50960
+ process.exit(1);
50961
+ }
50962
+ const confirmed = await confirmAction(
50963
+ `Delete ${describePasskey(row)} from ${sanitizeDisplay(label)}?`,
50964
+ { skip: options.yes === true, verb: "delete" }
50965
+ );
50966
+ if (!confirmed) {
50967
+ console.log("Nothing deleted.");
50968
+ return;
50969
+ }
50970
+ const result = await authenticatedRequest(
50971
+ (client) => client.testIntegration.deleteTestIntegrationPasskey({
50972
+ params: { testIntegrationId, testIntegrationPasskeyId: passkeyId }
50973
+ })
50974
+ );
50975
+ if (result.status !== NO_CONTENT_STATUS) {
50976
+ failStaffPentestWrite("delete a passkey", result.status, result.body, {
50977
+ notFound: `No passkey with id ${sanitizeDisplay(passkeyId)} is stored on ${sanitizeDisplay(label)}.`
50978
+ });
50979
+ }
50980
+ if (options.output === "json") {
50981
+ printJson({ deleted: passkeyId });
50982
+ return;
50983
+ }
50984
+ console.log(colorSuccess(deleteSuccessLine({ passkey: row, label })));
50985
+ }
50986
+ function createPasskeysCommand() {
50987
+ const passkeys = new Command("passkeys").description(
50988
+ "Passkeys stored on a test integration (staff)"
50989
+ );
50990
+ passkeys.addCommand(
50991
+ new Command("import").description("Store a passkey the capture script wrote (staff)").argument("<label>", "The test integration name, exactly as the console shows it").requiredOption("--file <path>", "The captured key file (one passkey)").option("--delete-file", "Delete the key file after a successful import").option("-y, --yes", "Skip the confirmation").option("-o, --output <format>", "Output format: text, json (default: text)", "text").action(async (label, options) => {
50992
+ await importPasskey(label, options);
50993
+ })
50994
+ );
50995
+ passkeys.addCommand(
50996
+ new Command("list").description("List a test integration's stored passkeys (staff)").argument("<label>", "The test integration name").option("-o, --output <format>", "Output format: text, json, tsv (default: text)", "text").action(async (label, options) => {
50997
+ await listPasskeys(label, options);
50998
+ })
50999
+ );
51000
+ passkeys.addCommand(
51001
+ new Command("delete").description("Remove one stored passkey from a test integration (staff)").argument("<label>", "The test integration name").argument("<passkeyId>", "The passkey id from `passkeys list`").option("-y, --yes", "Skip the confirmation").option("-o, --output <format>", "Output format: text, json (default: text)", "text").action(
51002
+ async (label, passkeyId, options) => {
51003
+ await deletePasskey(label, passkeyId, options);
51004
+ }
51005
+ )
51006
+ );
51007
+ return passkeys;
51008
+ }
51009
+
51010
+ // src/test-integration/commands.ts
51011
+ function createTestIntegrationCommand() {
51012
+ const group = new Command("test-integration").description(
51013
+ "Trace-owned test integrations (staff)"
51014
+ );
51015
+ group.addCommand(createPasskeysCommand());
51016
+ return group;
51017
+ }
51018
+
50011
51019
  // src/update/commands.ts
50012
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync8, writeFileSync as writeFileSync4 } from "fs";
51020
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "fs";
50013
51021
  import { dirname as dirname3, join as join7 } from "path";
50014
51022
  import { createInterface as createInterface4 } from "readline/promises";
50015
51023
  var UPDATE_CACHE_FILE = "update-check.json";
@@ -50027,7 +51035,7 @@ function readUpdateCheckCache() {
50027
51035
  return null;
50028
51036
  }
50029
51037
  try {
50030
- const rawCache = readFileSync8(cachePath, "utf-8");
51038
+ const rawCache = readFileSync9(cachePath, "utf-8");
50031
51039
  const parsed = JSON.parse(rawCache);
50032
51040
  if (typeof parsed !== "object" || parsed === null) {
50033
51041
  return null;
@@ -51858,6 +52866,7 @@ program2.addCommand(createVulnerabilitiesCommand());
51858
52866
  program2.addCommand(createCodeSizeCommand());
51859
52867
  program2.addCommand(createUploadCommand());
51860
52868
  program2.addCommand(createPentestCommand());
52869
+ program2.addCommand(createTestIntegrationCommand(), { hidden: true });
51861
52870
  program2.addCommand(createCompletionCommand(program2));
51862
52871
  program2.addCommand(createUpgradeCommand());
51863
52872
  await program2.parseAsync();