@cdot65/prisma-airs-cli 5.8.3 → 5.8.5

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/cli/index.js +162 -27
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -9710,6 +9710,7 @@ async function backupDlpResources(api, tsgId, options = {}) {
9710
9710
  const kinds = options.resources ?? [...DLP_RESOURCE_KINDS];
9711
9711
  if (!kinds.length || kinds.some((kind) => !DLP_RESOURCE_KINDS.includes(kind)))
9712
9712
  throw new Error("resources must name dictionaries, patterns and/or profiles");
9713
+ options.onProgress?.({ phase: "inventories" });
9713
9714
  const inventory2 = await inventories(api, maxPages3);
9714
9715
  tenantIdentity([...inventory2.patterns, ...inventory2.profiles]);
9715
9716
  const dictionaries = /* @__PURE__ */ new Map();
@@ -9718,11 +9719,19 @@ async function backupDlpResources(api, tsgId, options = {}) {
9718
9719
  if (!record.id) throw new Error("Inventory dictionary has no id");
9719
9720
  if (record.type !== "predefined" && !Array.isArray(record.keywords))
9720
9721
  throw new Error(`Dictionary keywords were not returned by the region: ${record.name}`);
9721
- dictionaries.set(record.id, structuredClone(record));
9722
+ const stored = record.type === "predefined" ? { id: record.id, name: record.name, type: record.type } : record;
9723
+ dictionaries.set(record.id, structuredClone(stored));
9722
9724
  };
9723
9725
  const addPattern = (record) => {
9724
9726
  if (!record.id) throw new Error("Inventory data pattern has no id");
9725
- patterns.set(record.id, structuredClone(record));
9727
+ const stored = record.type === "predefined" ? {
9728
+ id: record.id,
9729
+ name: record.name,
9730
+ type: record.type,
9731
+ ...record.version != null ? { version: record.version } : {},
9732
+ ...record.detection_config?.technique ? { detection_config: { technique: record.detection_config.technique } } : {}
9733
+ } : record;
9734
+ patterns.set(record.id, structuredClone(stored));
9726
9735
  };
9727
9736
  if (kinds.includes("dictionaries")) {
9728
9737
  for (const record of inventory2.dictionaries)
@@ -9884,14 +9893,15 @@ function normalizedName(value) {
9884
9893
  }
9885
9894
  function predefinedCandidates(source, records) {
9886
9895
  const wanted = normalizedName(source.name ?? "");
9887
- const wantedTokens = new Set(wanted.split(" "));
9888
- return records.filter(
9889
- (record) => record.type === "predefined" && record.name != null && !RETIRED_PATTERN.has(record.status ?? "active") && record.detection_config?.technique === source.detection_config?.technique
9890
- ).map((record) => {
9896
+ const scored = /* @__PURE__ */ new Map();
9897
+ for (const record of records) {
9898
+ if (record.type !== "predefined" || record.name == null || RETIRED_PATTERN.has(record.status ?? "active") || record.detection_config?.technique !== source.detection_config?.technique)
9899
+ continue;
9891
9900
  const name = normalizedName(record.name);
9892
- const score = name === wanted ? 3 : name.includes(wanted) || wanted.includes(name) ? 2 : name.split(" ").some((token) => wantedTokens.has(token)) ? 1 : 0;
9893
- return { name: record.name, score };
9894
- }).filter((item) => item.score > 0).sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)).slice(0, 3).map((item) => item.name);
9901
+ const score = name === wanted ? 2 : name.includes(wanted) || wanted.includes(name) ? 1 : 0;
9902
+ if (score > 0) scored.set(record.name, Math.max(scored.get(record.name) ?? 0, score));
9903
+ }
9904
+ return [...scored.entries()].sort(([aName, aScore], [bName, bScore]) => bScore - aScore || aName.localeCompare(bName)).slice(0, 3).map(([name]) => name);
9895
9905
  }
9896
9906
  function matchByName(records, name, retired, label) {
9897
9907
  const matches = records.filter((record) => record.name === name && !retired(record));
@@ -9918,6 +9928,8 @@ async function planDlpResourcesRestore(api, input2, destinationTsgId, options =
9918
9928
  if (unknownMapped.length)
9919
9929
  throw new Error(`--pattern-map names absent from the backup: ${unknownMapped.join(", ")}`);
9920
9930
  const crossTenant = backup.source.tsgId !== destinationTsgId;
9931
+ const unresolved = [];
9932
+ options.onProgress?.({ phase: "inventories" });
9921
9933
  const destination = await inventories(api, maxPages3);
9922
9934
  const sourceTenant = tenantIdentity([...backup.patterns, ...backup.profiles]);
9923
9935
  const destinationTenant = tenantIdentity([...destination.patterns, ...destination.profiles]);
@@ -9936,8 +9948,18 @@ async function planDlpResourcesRestore(api, input2, destinationTsgId, options =
9936
9948
  if (!source.id || !source.name) throw new Error("Backup dictionary lacks identity");
9937
9949
  if (source.type === "predefined") {
9938
9950
  const match2 = matchByName(destination.dictionaries, source.name, () => false, "dictionary");
9939
- if (!match2?.id || match2.type !== "predefined")
9951
+ if (!match2?.id || match2.type !== "predefined") {
9952
+ if (options.skipUnresolved) {
9953
+ unresolved.push({
9954
+ kind: "dictionary",
9955
+ sourceId: source.id,
9956
+ name: source.name,
9957
+ reason: "missing predefined destination dictionary"
9958
+ });
9959
+ continue;
9960
+ }
9940
9961
  throw new Error(`Missing predefined destination dictionary: ${source.name}`);
9962
+ }
9941
9963
  dictionaryPlans.push({
9942
9964
  sourceId: source.id,
9943
9965
  name: source.name,
@@ -9981,10 +10003,16 @@ async function planDlpResourcesRestore(api, input2, destinationTsgId, options =
9981
10003
  );
9982
10004
  const match2 = byId ?? matchByName(destination.patterns, source.name, retired, "data pattern");
9983
10005
  if (!match2?.id || !match2.name || match2.type !== "predefined") {
9984
- missingPredefined.push({
9985
- name: source.name,
9986
- candidates: predefinedCandidates(source, destination.patterns)
9987
- });
10006
+ const candidates = predefinedCandidates(source, destination.patterns);
10007
+ if (options.skipUnresolved)
10008
+ unresolved.push({
10009
+ kind: "pattern",
10010
+ sourceId: source.id,
10011
+ name: source.name,
10012
+ reason: "no identity or name match in the destination catalog",
10013
+ candidates
10014
+ });
10015
+ else missingPredefined.push({ name: source.name, candidates });
9988
10016
  continue;
9989
10017
  }
9990
10018
  patternPlans.push({
@@ -10007,10 +10035,20 @@ async function planDlpResourcesRestore(api, input2, destinationTsgId, options =
10007
10035
  });
10008
10036
  continue;
10009
10037
  }
10010
- if (!portablePattern(source))
10038
+ if (!portablePattern(source)) {
10039
+ if (options.skipUnresolved) {
10040
+ unresolved.push({
10041
+ kind: "pattern",
10042
+ sourceId: source.id,
10043
+ name: source.name,
10044
+ reason: "tenant-bound detection technique without --pattern-map"
10045
+ });
10046
+ continue;
10047
+ }
10011
10048
  throw new Error(
10012
10049
  `Data pattern uses a tenant-bound detection technique: ${source.name}; provision it in the destination and bind it with --pattern-map "${source.name}=<destination-name>"`
10013
10050
  );
10051
+ }
10014
10052
  const name = `${prefix}${source.name}`;
10015
10053
  assertName(name);
10016
10054
  const body = patternRequest(source, name);
@@ -10069,6 +10107,7 @@ async function planDlpResourcesRestore(api, input2, destinationTsgId, options =
10069
10107
  dictionaries: dictionaryPlans,
10070
10108
  patterns: patternPlans,
10071
10109
  profiles: profilePlans,
10110
+ unresolved,
10072
10111
  maxPages: maxPages3
10073
10112
  };
10074
10113
  const bindings = planBindings(plan);
@@ -10076,14 +10115,24 @@ async function planDlpResourcesRestore(api, input2, destinationTsgId, options =
10076
10115
  ...dictionaryPlans.map((d) => d.sourceId),
10077
10116
  ...patternPlans.map((p) => p.sourceId)
10078
10117
  ]);
10118
+ const unresolvedById = new Map(unresolved.map((item) => [item.sourceId, item]));
10079
10119
  for (const item of profilePlans) {
10080
10120
  if (item.action === "skip") continue;
10121
+ const missing = /* @__PURE__ */ new Set();
10081
10122
  for (const leaf of ruleLeaves(item.source)) {
10082
10123
  const id = leaf.value.id;
10083
- if (id != null && id !== "" && !planned.has(String(id)))
10084
- throw new Error(
10085
- `Backup data profile references a resource absent from the backup: ${item.name}`
10086
- );
10124
+ if (id != null && id !== "" && !planned.has(String(id))) missing.add(String(id));
10125
+ }
10126
+ if (missing.size) {
10127
+ const skippable = [...missing].filter((id) => unresolvedById.has(id));
10128
+ if (skippable.length === missing.size) {
10129
+ item.action = "skip";
10130
+ item.reason = `unresolved references: ${skippable.map((id) => unresolvedById.get(id)?.name).join(", ")}`;
10131
+ continue;
10132
+ }
10133
+ throw new Error(
10134
+ `Backup data profile references a resource absent from the backup: ${item.name}`
10135
+ );
10087
10136
  }
10088
10137
  const body = profileRequest(item, bindings, crossTenant, item.action === "verify");
10089
10138
  if (item.action === "verify" && body && !compareEcho(body, item.existing).matches)
@@ -10153,7 +10202,7 @@ function assertUnchanged(current, prior, label) {
10153
10202
  if (canonical(current ?? null) !== canonical(prior ?? null))
10154
10203
  throw new Error(`Destination ${label} changed after planning`);
10155
10204
  }
10156
- async function restoreDlpResources(api, plan) {
10205
+ async function restoreDlpResources(api, plan, options = {}) {
10157
10206
  const result = {
10158
10207
  sourceTsgId: plan.sourceTsgId,
10159
10208
  destinationTsgId: plan.destinationTsgId,
@@ -10161,10 +10210,14 @@ async function restoreDlpResources(api, plan) {
10161
10210
  dictionaries: [],
10162
10211
  patterns: [],
10163
10212
  profiles: [],
10164
- serverAdded: []
10213
+ serverAdded: [],
10214
+ unresolved: structuredClone(plan.unresolved ?? [])
10165
10215
  };
10166
10216
  const crossTenant = plan.sourceTsgId !== plan.destinationTsgId;
10217
+ const emit2 = options.onProgress;
10218
+ const emitItem = (stage, name, action, index, total) => emit2?.({ phase: "item", stage, name, action, index, total });
10167
10219
  try {
10220
+ emit2?.({ phase: "recheck" });
10168
10221
  const current = await inventories(api, plan.maxPages);
10169
10222
  for (const item of plan.dictionaries) {
10170
10223
  const match = matchByName(current.dictionaries, item.name, () => false, "dictionary");
@@ -10191,11 +10244,20 @@ async function restoreDlpResources(api, plan) {
10191
10244
  throw new Error(`Destination data profile changed after planning: ${item.name}`);
10192
10245
  }
10193
10246
  const bindings = planBindings(plan);
10247
+ if (plan.dictionaries.length)
10248
+ emit2?.({ phase: "stage", stage: "dictionaries", total: plan.dictionaries.length });
10194
10249
  for (const item of plan.dictionaries) {
10195
10250
  if (item.action !== "create") {
10196
10251
  const id = item.existing?.id;
10197
10252
  if (!id) throw new Error(`Destination dictionary has no identity: ${item.name}`);
10198
10253
  result.dictionaries.push({ name: item.name, action: DEPENDENCY_ACTIONS[item.action], id });
10254
+ emitItem(
10255
+ "dictionaries",
10256
+ item.name,
10257
+ DEPENDENCY_ACTIONS[item.action],
10258
+ result.dictionaries.length,
10259
+ plan.dictionaries.length
10260
+ );
10199
10261
  continue;
10200
10262
  }
10201
10263
  if (!item.metadata || !item.keywords) throw new Error("Dictionary plan is incomplete");
@@ -10214,12 +10276,28 @@ async function restoreDlpResources(api, plan) {
10214
10276
  result.serverAdded.push({ resource: `dictionary ${item.name}`, fields: echo.serverAdded });
10215
10277
  bindings.set(item.sourceId, { id: created.id, name: item.name });
10216
10278
  result.dictionaries.push({ name: item.name, action: "created", id: created.id });
10279
+ emitItem(
10280
+ "dictionaries",
10281
+ item.name,
10282
+ "created",
10283
+ result.dictionaries.length,
10284
+ plan.dictionaries.length
10285
+ );
10217
10286
  }
10287
+ if (plan.patterns.length)
10288
+ emit2?.({ phase: "stage", stage: "patterns", total: plan.patterns.length });
10218
10289
  for (const item of plan.patterns) {
10219
10290
  if (item.action !== "create") {
10220
10291
  const id = item.existing?.id;
10221
10292
  if (!id) throw new Error(`Destination data pattern has no identity: ${item.name}`);
10222
10293
  result.patterns.push({ name: item.name, action: DEPENDENCY_ACTIONS[item.action], id });
10294
+ emitItem(
10295
+ "patterns",
10296
+ item.name,
10297
+ DEPENDENCY_ACTIONS[item.action],
10298
+ result.patterns.length,
10299
+ plan.patterns.length
10300
+ );
10223
10301
  continue;
10224
10302
  }
10225
10303
  if (!item.body) throw new Error("Data pattern plan is incomplete");
@@ -10240,14 +10318,19 @@ async function restoreDlpResources(api, plan) {
10240
10318
  version: readBack.version ?? void 0
10241
10319
  });
10242
10320
  result.patterns.push({ name: item.name, action: "created", id: created.id });
10321
+ emitItem("patterns", item.name, "created", result.patterns.length, plan.patterns.length);
10243
10322
  }
10323
+ if (plan.profiles.length)
10324
+ emit2?.({ phase: "stage", stage: "profiles", total: plan.profiles.length });
10244
10325
  for (const item of plan.profiles) {
10245
10326
  if (item.action === "skip") {
10246
10327
  result.profiles.push({
10247
10328
  name: item.name,
10248
10329
  action: "skipped",
10249
- id: item.existing?.id ?? void 0
10330
+ id: item.existing?.id ?? void 0,
10331
+ ...item.reason ? { reason: item.reason } : {}
10250
10332
  });
10333
+ emitItem("profiles", item.name, "skipped", result.profiles.length, plan.profiles.length);
10251
10334
  continue;
10252
10335
  }
10253
10336
  const body = profileRequest(item, bindings, crossTenant, true);
@@ -10266,6 +10349,7 @@ async function restoreDlpResources(api, plan) {
10266
10349
  action: "verified",
10267
10350
  id: item.existing?.id ?? void 0
10268
10351
  });
10352
+ emitItem("profiles", item.name, "verified", result.profiles.length, plan.profiles.length);
10269
10353
  continue;
10270
10354
  }
10271
10355
  const created = await api.profiles.create(body);
@@ -10280,6 +10364,7 @@ async function restoreDlpResources(api, plan) {
10280
10364
  fields: echo.serverAdded
10281
10365
  });
10282
10366
  result.profiles.push({ name: item.name, action: "created", id: created.id });
10367
+ emitItem("profiles", item.name, "created", result.profiles.length, plan.profiles.length);
10283
10368
  }
10284
10369
  result.complete = true;
10285
10370
  } catch (error) {
@@ -10349,6 +10434,31 @@ function failure(error) {
10349
10434
  }
10350
10435
  process.exitCode = 1;
10351
10436
  }
10437
+ var STAGE_LABELS = {
10438
+ dictionaries: "dictionaries",
10439
+ patterns: "data patterns",
10440
+ profiles: "data profiles"
10441
+ };
10442
+ var ITEM_LABELS = {
10443
+ dictionaries: "dictionary",
10444
+ patterns: "data pattern",
10445
+ profiles: "data profile"
10446
+ };
10447
+ function progressRenderer(format) {
10448
+ if (format !== "pretty") return void 0;
10449
+ return (event) => {
10450
+ if (event.phase === "inventories")
10451
+ ui.info("Reading inventories (dictionaries, data patterns, data profiles)\u2026");
10452
+ else if (event.phase === "recheck") ui.info("Rechecking destination state before writing\u2026");
10453
+ else if (event.phase === "stage")
10454
+ ui.info(`Restoring ${STAGE_LABELS[event.stage]} (${event.total})\u2026`);
10455
+ else
10456
+ ui.bullet(
10457
+ `${ITEM_LABELS[event.stage]} ${event.action}: ${event.name} (${event.index}/${event.total})`,
10458
+ event.action === "skipped" ? "skip" : "success"
10459
+ );
10460
+ };
10461
+ }
10352
10462
  function renderRestore(value, columns, format) {
10353
10463
  if (format !== "pretty" && format !== "table" && format !== "markdown")
10354
10464
  return formatOutput([value], columns, format);
@@ -10380,6 +10490,11 @@ ${markdown ? "\n" : ""}Destination TSG: ${value.destinationTsgId}`
10380
10490
  ) || "(none)"
10381
10491
  );
10382
10492
  }
10493
+ if (rows("unresolved").length) {
10494
+ blocks.push(heading("Unresolved references \u2014 dependent profiles skipped"));
10495
+ for (const entry of rows("unresolved"))
10496
+ blocks.push(`${entry.kind} ${entry.name}: ${entry.reason}`);
10497
+ }
10383
10498
  if (rows("serverAdded").length) {
10384
10499
  blocks.push(heading("Verified server-added fields (source omitted these)"));
10385
10500
  for (const item of rows("serverAdded"))
@@ -10432,7 +10547,8 @@ function register6(dlp) {
10432
10547
  const { backup: data, skipped } = await backupDlpResources(api, tsgId, {
10433
10548
  resources: opts.resources,
10434
10549
  maxPages: opts.maxPages,
10435
- skipUnsupported: Boolean(opts.skipUnsupported)
10550
+ skipUnsupported: Boolean(opts.skipUnsupported),
10551
+ onProgress: progressRenderer(format)
10436
10552
  });
10437
10553
  for (const item of skipped)
10438
10554
  ui.warning(`Skipped unsupported data profile: ${item.profile} (${item.reason})`);
@@ -10476,9 +10592,12 @@ function register6(dlp) {
10476
10592
  "error"
10477
10593
  ).option(
10478
10594
  "--pattern-map <source=destination>",
10479
- "Bind a tenant-bound source pattern to an existing destination pattern (repeatable)",
10595
+ "Bind a tenant-bound or predefined source pattern to an existing destination pattern (repeatable)",
10480
10596
  (value, prior) => [...prior, value],
10481
10597
  []
10598
+ ).option(
10599
+ "--skip-unresolved",
10600
+ "Skip references with no destination match, and every profile depending on them; whole profiles only, each reported"
10482
10601
  ).option(
10483
10602
  "--max-pages <n>",
10484
10603
  "Maximum pages per inventory; incomplete inventories fail",
@@ -10523,19 +10642,34 @@ function register6(dlp) {
10523
10642
  throw new CliUsageError(
10524
10643
  "--expect-tsg does not match the selected destination tenant; no API request was made"
10525
10644
  );
10645
+ const onProgress = progressRenderer(format);
10526
10646
  const plan = await planDlpResourcesRestore(api, data, tsgId, {
10527
10647
  namePrefix: opts.namePrefix,
10528
10648
  onConflict: opts.onConflict,
10529
10649
  patternMap: mappings,
10530
- maxPages: opts.maxPages
10650
+ maxPages: opts.maxPages,
10651
+ skipUnresolved: Boolean(opts.skipUnresolved),
10652
+ onProgress
10531
10653
  });
10654
+ for (const entry of plan.unresolved)
10655
+ ui.warning(
10656
+ `Unresolved ${entry.kind === "dictionary" ? "dictionary" : "data pattern"}: ${entry.name} (${entry.reason})${entry.candidates?.length ? `; candidates: ${entry.candidates.map((name) => `"${name}"`).join(", ")}` : ""}`
10657
+ );
10658
+ for (const profile of plan.profiles)
10659
+ if (profile.reason)
10660
+ ui.warning(`Skipping data profile: ${profile.name} (${profile.reason})`);
10532
10661
  const summary = {
10533
10662
  sourceTsgId: plan.sourceTsgId,
10534
10663
  destinationTsgId: plan.destinationTsgId,
10535
10664
  dryRun: Boolean(opts.dryRun),
10536
10665
  dictionaries: plan.dictionaries.map((d) => ({ name: d.name, action: d.action })),
10537
10666
  patterns: plan.patterns.map((p) => ({ name: p.name, action: p.action })),
10538
- profiles: plan.profiles.map((p) => ({ name: p.name, action: p.action }))
10667
+ profiles: plan.profiles.map((p) => ({
10668
+ name: p.name,
10669
+ action: p.action,
10670
+ ...p.reason ? { reason: p.reason } : {}
10671
+ })),
10672
+ unresolved: plan.unresolved.map(({ kind, name, reason }) => ({ kind, name, reason }))
10539
10673
  };
10540
10674
  const columns = [
10541
10675
  { key: "sourceTsgId", label: "Source TSG" },
@@ -10543,6 +10677,7 @@ function register6(dlp) {
10543
10677
  { key: "dictionaries", label: "Dictionaries" },
10544
10678
  { key: "patterns", label: "Data patterns" },
10545
10679
  { key: "profiles", label: "Data profiles" },
10680
+ { key: "unresolved", label: "Unresolved references" },
10546
10681
  { key: "serverAdded", label: "Verified server-added fields" },
10547
10682
  { key: "dryRun", label: "Dry run" },
10548
10683
  { key: "complete", label: "Complete" }
@@ -10558,7 +10693,7 @@ function register6(dlp) {
10558
10693
  Boolean(opts.force),
10559
10694
  { action: `restore DLP resources into TSG ${tsgId}` }
10560
10695
  );
10561
- const result = await restoreDlpResources(api, plan);
10696
+ const result = await restoreDlpResources(api, plan, { onProgress });
10562
10697
  console.log(renderRestore({ ...result }, columns, format));
10563
10698
  if (!result.complete) {
10564
10699
  ui.error(result.error ?? "Restore incomplete");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cdot65/prisma-airs-cli",
3
3
  "packageManager": "pnpm@10.6.5",
4
- "version": "5.8.3",
4
+ "version": "5.8.5",
5
5
  "description": "CLI and library for Palo Alto Prisma AIRS — guardrail refinement, AI red teaming, model security scanning, profile audits",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",