@cdot65/prisma-airs-cli 5.8.2 → 5.8.4

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 +173 -20
  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();
@@ -9879,6 +9880,20 @@ function patternRequest(source, name) {
9879
9880
  function portablePattern(record) {
9880
9881
  return PORTABLE_TECHNIQUES.has(record.detection_config?.technique ?? "") && record.detection_config?.technique !== void 0;
9881
9882
  }
9883
+ function normalizedName(value) {
9884
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
9885
+ }
9886
+ function predefinedCandidates(source, records) {
9887
+ const wanted = normalizedName(source.name ?? "");
9888
+ const wantedTokens = new Set(wanted.split(" "));
9889
+ return records.filter(
9890
+ (record) => record.type === "predefined" && record.name != null && !RETIRED_PATTERN.has(record.status ?? "active") && record.detection_config?.technique === source.detection_config?.technique
9891
+ ).map((record) => {
9892
+ const name = normalizedName(record.name);
9893
+ const score = name === wanted ? 3 : name.includes(wanted) || wanted.includes(name) ? 2 : name.split(" ").some((token) => wantedTokens.has(token)) ? 1 : 0;
9894
+ return { name: record.name, score };
9895
+ }).filter((item) => item.score > 0).sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)).slice(0, 3).map((item) => item.name);
9896
+ }
9882
9897
  function matchByName(records, name, retired, label) {
9883
9898
  const matches = records.filter((record) => record.name === name && !retired(record));
9884
9899
  if (matches.length > 1) throw new Error(`Ambiguous destination ${label} name: ${name}`);
@@ -9904,6 +9919,8 @@ async function planDlpResourcesRestore(api, input2, destinationTsgId, options =
9904
9919
  if (unknownMapped.length)
9905
9920
  throw new Error(`--pattern-map names absent from the backup: ${unknownMapped.join(", ")}`);
9906
9921
  const crossTenant = backup.source.tsgId !== destinationTsgId;
9922
+ const unresolved = [];
9923
+ options.onProgress?.({ phase: "inventories" });
9907
9924
  const destination = await inventories(api, maxPages3);
9908
9925
  const sourceTenant = tenantIdentity([...backup.patterns, ...backup.profiles]);
9909
9926
  const destinationTenant = tenantIdentity([...destination.patterns, ...destination.profiles]);
@@ -9922,8 +9939,18 @@ async function planDlpResourcesRestore(api, input2, destinationTsgId, options =
9922
9939
  if (!source.id || !source.name) throw new Error("Backup dictionary lacks identity");
9923
9940
  if (source.type === "predefined") {
9924
9941
  const match2 = matchByName(destination.dictionaries, source.name, () => false, "dictionary");
9925
- if (!match2?.id || match2.type !== "predefined")
9942
+ if (!match2?.id || match2.type !== "predefined") {
9943
+ if (options.skipUnresolved) {
9944
+ unresolved.push({
9945
+ kind: "dictionary",
9946
+ sourceId: source.id,
9947
+ name: source.name,
9948
+ reason: "missing predefined destination dictionary"
9949
+ });
9950
+ continue;
9951
+ }
9926
9952
  throw new Error(`Missing predefined destination dictionary: ${source.name}`);
9953
+ }
9927
9954
  dictionaryPlans.push({
9928
9955
  sourceId: source.id,
9929
9956
  name: source.name,
@@ -9957,18 +9984,31 @@ async function planDlpResourcesRestore(api, input2, destinationTsgId, options =
9957
9984
  });
9958
9985
  }
9959
9986
  const patternPlans = [];
9987
+ const missingPredefined = [];
9960
9988
  for (const source of backup.patterns) {
9961
9989
  if (!source.id || !source.name) throw new Error("Backup data pattern lacks identity");
9962
9990
  const retired = (record) => RETIRED_PATTERN.has(record.status ?? "active");
9963
9991
  if (source.type === "predefined" && !Object.hasOwn(patternMap2, source.name)) {
9964
- const match2 = matchByName(destination.patterns, source.name, retired, "data pattern");
9965
- if (!match2?.id || match2.type !== "predefined")
9966
- throw new Error(
9967
- `Missing predefined destination data pattern: ${source.name}; bind an equivalent destination pattern with --pattern-map "${source.name}=<destination-name>"`
9968
- );
9992
+ const byId = destination.patterns.find(
9993
+ (record) => record.id === source.id && record.type === "predefined" && !retired(record) && record.detection_config?.technique === source.detection_config?.technique
9994
+ );
9995
+ const match2 = byId ?? matchByName(destination.patterns, source.name, retired, "data pattern");
9996
+ if (!match2?.id || !match2.name || match2.type !== "predefined") {
9997
+ const candidates = predefinedCandidates(source, destination.patterns);
9998
+ if (options.skipUnresolved)
9999
+ unresolved.push({
10000
+ kind: "pattern",
10001
+ sourceId: source.id,
10002
+ name: source.name,
10003
+ reason: "no identity or name match in the destination catalog",
10004
+ candidates
10005
+ });
10006
+ else missingPredefined.push({ name: source.name, candidates });
10007
+ continue;
10008
+ }
9969
10009
  patternPlans.push({
9970
10010
  sourceId: source.id,
9971
- name: source.name,
10011
+ name: match2.name,
9972
10012
  action: "resolve",
9973
10013
  existing: structuredClone(match2)
9974
10014
  });
@@ -9986,10 +10026,20 @@ async function planDlpResourcesRestore(api, input2, destinationTsgId, options =
9986
10026
  });
9987
10027
  continue;
9988
10028
  }
9989
- if (!portablePattern(source))
10029
+ if (!portablePattern(source)) {
10030
+ if (options.skipUnresolved) {
10031
+ unresolved.push({
10032
+ kind: "pattern",
10033
+ sourceId: source.id,
10034
+ name: source.name,
10035
+ reason: "tenant-bound detection technique without --pattern-map"
10036
+ });
10037
+ continue;
10038
+ }
9990
10039
  throw new Error(
9991
10040
  `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>"`
9992
10041
  );
10042
+ }
9993
10043
  const name = `${prefix}${source.name}`;
9994
10044
  assertName(name);
9995
10045
  const body = patternRequest(source, name);
@@ -10012,6 +10062,12 @@ async function planDlpResourcesRestore(api, input2, destinationTsgId, options =
10012
10062
  existing: structuredClone(match)
10013
10063
  });
10014
10064
  }
10065
+ if (missingPredefined.length)
10066
+ throw new Error(
10067
+ `Missing predefined destination data patterns: ${missingPredefined.map(
10068
+ (item) => `${item.name}${item.candidates.length ? ` (candidates: ${item.candidates.map((name) => `"${name}"`).join(", ")})` : " (no candidates)"}`
10069
+ ).join("; ")}; bind each with --pattern-map "<source-name>=<destination-name>"`
10070
+ );
10015
10071
  const profilePlans = [];
10016
10072
  for (const source of backup.profiles) {
10017
10073
  if (!source.name) throw new Error("Backup data profile lacks identity");
@@ -10042,6 +10098,7 @@ async function planDlpResourcesRestore(api, input2, destinationTsgId, options =
10042
10098
  dictionaries: dictionaryPlans,
10043
10099
  patterns: patternPlans,
10044
10100
  profiles: profilePlans,
10101
+ unresolved,
10045
10102
  maxPages: maxPages3
10046
10103
  };
10047
10104
  const bindings = planBindings(plan);
@@ -10049,14 +10106,24 @@ async function planDlpResourcesRestore(api, input2, destinationTsgId, options =
10049
10106
  ...dictionaryPlans.map((d) => d.sourceId),
10050
10107
  ...patternPlans.map((p) => p.sourceId)
10051
10108
  ]);
10109
+ const unresolvedById = new Map(unresolved.map((item) => [item.sourceId, item]));
10052
10110
  for (const item of profilePlans) {
10053
10111
  if (item.action === "skip") continue;
10112
+ const missing = /* @__PURE__ */ new Set();
10054
10113
  for (const leaf of ruleLeaves(item.source)) {
10055
10114
  const id = leaf.value.id;
10056
- if (id != null && id !== "" && !planned.has(String(id)))
10057
- throw new Error(
10058
- `Backup data profile references a resource absent from the backup: ${item.name}`
10059
- );
10115
+ if (id != null && id !== "" && !planned.has(String(id))) missing.add(String(id));
10116
+ }
10117
+ if (missing.size) {
10118
+ const skippable = [...missing].filter((id) => unresolvedById.has(id));
10119
+ if (skippable.length === missing.size) {
10120
+ item.action = "skip";
10121
+ item.reason = `unresolved references: ${skippable.map((id) => unresolvedById.get(id)?.name).join(", ")}`;
10122
+ continue;
10123
+ }
10124
+ throw new Error(
10125
+ `Backup data profile references a resource absent from the backup: ${item.name}`
10126
+ );
10060
10127
  }
10061
10128
  const body = profileRequest(item, bindings, crossTenant, item.action === "verify");
10062
10129
  if (item.action === "verify" && body && !compareEcho(body, item.existing).matches)
@@ -10126,7 +10193,7 @@ function assertUnchanged(current, prior, label) {
10126
10193
  if (canonical(current ?? null) !== canonical(prior ?? null))
10127
10194
  throw new Error(`Destination ${label} changed after planning`);
10128
10195
  }
10129
- async function restoreDlpResources(api, plan) {
10196
+ async function restoreDlpResources(api, plan, options = {}) {
10130
10197
  const result = {
10131
10198
  sourceTsgId: plan.sourceTsgId,
10132
10199
  destinationTsgId: plan.destinationTsgId,
@@ -10134,10 +10201,14 @@ async function restoreDlpResources(api, plan) {
10134
10201
  dictionaries: [],
10135
10202
  patterns: [],
10136
10203
  profiles: [],
10137
- serverAdded: []
10204
+ serverAdded: [],
10205
+ unresolved: structuredClone(plan.unresolved ?? [])
10138
10206
  };
10139
10207
  const crossTenant = plan.sourceTsgId !== plan.destinationTsgId;
10208
+ const emit2 = options.onProgress;
10209
+ const emitItem = (stage, name, action, index, total) => emit2?.({ phase: "item", stage, name, action, index, total });
10140
10210
  try {
10211
+ emit2?.({ phase: "recheck" });
10141
10212
  const current = await inventories(api, plan.maxPages);
10142
10213
  for (const item of plan.dictionaries) {
10143
10214
  const match = matchByName(current.dictionaries, item.name, () => false, "dictionary");
@@ -10164,11 +10235,20 @@ async function restoreDlpResources(api, plan) {
10164
10235
  throw new Error(`Destination data profile changed after planning: ${item.name}`);
10165
10236
  }
10166
10237
  const bindings = planBindings(plan);
10238
+ if (plan.dictionaries.length)
10239
+ emit2?.({ phase: "stage", stage: "dictionaries", total: plan.dictionaries.length });
10167
10240
  for (const item of plan.dictionaries) {
10168
10241
  if (item.action !== "create") {
10169
10242
  const id = item.existing?.id;
10170
10243
  if (!id) throw new Error(`Destination dictionary has no identity: ${item.name}`);
10171
10244
  result.dictionaries.push({ name: item.name, action: DEPENDENCY_ACTIONS[item.action], id });
10245
+ emitItem(
10246
+ "dictionaries",
10247
+ item.name,
10248
+ DEPENDENCY_ACTIONS[item.action],
10249
+ result.dictionaries.length,
10250
+ plan.dictionaries.length
10251
+ );
10172
10252
  continue;
10173
10253
  }
10174
10254
  if (!item.metadata || !item.keywords) throw new Error("Dictionary plan is incomplete");
@@ -10187,12 +10267,28 @@ async function restoreDlpResources(api, plan) {
10187
10267
  result.serverAdded.push({ resource: `dictionary ${item.name}`, fields: echo.serverAdded });
10188
10268
  bindings.set(item.sourceId, { id: created.id, name: item.name });
10189
10269
  result.dictionaries.push({ name: item.name, action: "created", id: created.id });
10270
+ emitItem(
10271
+ "dictionaries",
10272
+ item.name,
10273
+ "created",
10274
+ result.dictionaries.length,
10275
+ plan.dictionaries.length
10276
+ );
10190
10277
  }
10278
+ if (plan.patterns.length)
10279
+ emit2?.({ phase: "stage", stage: "patterns", total: plan.patterns.length });
10191
10280
  for (const item of plan.patterns) {
10192
10281
  if (item.action !== "create") {
10193
10282
  const id = item.existing?.id;
10194
10283
  if (!id) throw new Error(`Destination data pattern has no identity: ${item.name}`);
10195
10284
  result.patterns.push({ name: item.name, action: DEPENDENCY_ACTIONS[item.action], id });
10285
+ emitItem(
10286
+ "patterns",
10287
+ item.name,
10288
+ DEPENDENCY_ACTIONS[item.action],
10289
+ result.patterns.length,
10290
+ plan.patterns.length
10291
+ );
10196
10292
  continue;
10197
10293
  }
10198
10294
  if (!item.body) throw new Error("Data pattern plan is incomplete");
@@ -10213,14 +10309,19 @@ async function restoreDlpResources(api, plan) {
10213
10309
  version: readBack.version ?? void 0
10214
10310
  });
10215
10311
  result.patterns.push({ name: item.name, action: "created", id: created.id });
10312
+ emitItem("patterns", item.name, "created", result.patterns.length, plan.patterns.length);
10216
10313
  }
10314
+ if (plan.profiles.length)
10315
+ emit2?.({ phase: "stage", stage: "profiles", total: plan.profiles.length });
10217
10316
  for (const item of plan.profiles) {
10218
10317
  if (item.action === "skip") {
10219
10318
  result.profiles.push({
10220
10319
  name: item.name,
10221
10320
  action: "skipped",
10222
- id: item.existing?.id ?? void 0
10321
+ id: item.existing?.id ?? void 0,
10322
+ ...item.reason ? { reason: item.reason } : {}
10223
10323
  });
10324
+ emitItem("profiles", item.name, "skipped", result.profiles.length, plan.profiles.length);
10224
10325
  continue;
10225
10326
  }
10226
10327
  const body = profileRequest(item, bindings, crossTenant, true);
@@ -10239,6 +10340,7 @@ async function restoreDlpResources(api, plan) {
10239
10340
  action: "verified",
10240
10341
  id: item.existing?.id ?? void 0
10241
10342
  });
10343
+ emitItem("profiles", item.name, "verified", result.profiles.length, plan.profiles.length);
10242
10344
  continue;
10243
10345
  }
10244
10346
  const created = await api.profiles.create(body);
@@ -10253,6 +10355,7 @@ async function restoreDlpResources(api, plan) {
10253
10355
  fields: echo.serverAdded
10254
10356
  });
10255
10357
  result.profiles.push({ name: item.name, action: "created", id: created.id });
10358
+ emitItem("profiles", item.name, "created", result.profiles.length, plan.profiles.length);
10256
10359
  }
10257
10360
  result.complete = true;
10258
10361
  } catch (error) {
@@ -10322,6 +10425,31 @@ function failure(error) {
10322
10425
  }
10323
10426
  process.exitCode = 1;
10324
10427
  }
10428
+ var STAGE_LABELS = {
10429
+ dictionaries: "dictionaries",
10430
+ patterns: "data patterns",
10431
+ profiles: "data profiles"
10432
+ };
10433
+ var ITEM_LABELS = {
10434
+ dictionaries: "dictionary",
10435
+ patterns: "data pattern",
10436
+ profiles: "data profile"
10437
+ };
10438
+ function progressRenderer(format) {
10439
+ if (format !== "pretty") return void 0;
10440
+ return (event) => {
10441
+ if (event.phase === "inventories")
10442
+ ui.info("Reading inventories (dictionaries, data patterns, data profiles)\u2026");
10443
+ else if (event.phase === "recheck") ui.info("Rechecking destination state before writing\u2026");
10444
+ else if (event.phase === "stage")
10445
+ ui.info(`Restoring ${STAGE_LABELS[event.stage]} (${event.total})\u2026`);
10446
+ else
10447
+ ui.bullet(
10448
+ `${ITEM_LABELS[event.stage]} ${event.action}: ${event.name} (${event.index}/${event.total})`,
10449
+ event.action === "skipped" ? "skip" : "success"
10450
+ );
10451
+ };
10452
+ }
10325
10453
  function renderRestore(value, columns, format) {
10326
10454
  if (format !== "pretty" && format !== "table" && format !== "markdown")
10327
10455
  return formatOutput([value], columns, format);
@@ -10353,6 +10481,11 @@ ${markdown ? "\n" : ""}Destination TSG: ${value.destinationTsgId}`
10353
10481
  ) || "(none)"
10354
10482
  );
10355
10483
  }
10484
+ if (rows("unresolved").length) {
10485
+ blocks.push(heading("Unresolved references \u2014 dependent profiles skipped"));
10486
+ for (const entry of rows("unresolved"))
10487
+ blocks.push(`${entry.kind} ${entry.name}: ${entry.reason}`);
10488
+ }
10356
10489
  if (rows("serverAdded").length) {
10357
10490
  blocks.push(heading("Verified server-added fields (source omitted these)"));
10358
10491
  for (const item of rows("serverAdded"))
@@ -10405,7 +10538,8 @@ function register6(dlp) {
10405
10538
  const { backup: data, skipped } = await backupDlpResources(api, tsgId, {
10406
10539
  resources: opts.resources,
10407
10540
  maxPages: opts.maxPages,
10408
- skipUnsupported: Boolean(opts.skipUnsupported)
10541
+ skipUnsupported: Boolean(opts.skipUnsupported),
10542
+ onProgress: progressRenderer(format)
10409
10543
  });
10410
10544
  for (const item of skipped)
10411
10545
  ui.warning(`Skipped unsupported data profile: ${item.profile} (${item.reason})`);
@@ -10449,9 +10583,12 @@ function register6(dlp) {
10449
10583
  "error"
10450
10584
  ).option(
10451
10585
  "--pattern-map <source=destination>",
10452
- "Bind a tenant-bound source pattern to an existing destination pattern (repeatable)",
10586
+ "Bind a tenant-bound or predefined source pattern to an existing destination pattern (repeatable)",
10453
10587
  (value, prior) => [...prior, value],
10454
10588
  []
10589
+ ).option(
10590
+ "--skip-unresolved",
10591
+ "Skip references with no destination match, and every profile depending on them; whole profiles only, each reported"
10455
10592
  ).option(
10456
10593
  "--max-pages <n>",
10457
10594
  "Maximum pages per inventory; incomplete inventories fail",
@@ -10496,19 +10633,34 @@ function register6(dlp) {
10496
10633
  throw new CliUsageError(
10497
10634
  "--expect-tsg does not match the selected destination tenant; no API request was made"
10498
10635
  );
10636
+ const onProgress = progressRenderer(format);
10499
10637
  const plan = await planDlpResourcesRestore(api, data, tsgId, {
10500
10638
  namePrefix: opts.namePrefix,
10501
10639
  onConflict: opts.onConflict,
10502
10640
  patternMap: mappings,
10503
- maxPages: opts.maxPages
10641
+ maxPages: opts.maxPages,
10642
+ skipUnresolved: Boolean(opts.skipUnresolved),
10643
+ onProgress
10504
10644
  });
10645
+ for (const entry of plan.unresolved)
10646
+ ui.warning(
10647
+ `Unresolved ${entry.kind === "dictionary" ? "dictionary" : "data pattern"}: ${entry.name} (${entry.reason})${entry.candidates?.length ? `; candidates: ${entry.candidates.map((name) => `"${name}"`).join(", ")}` : ""}`
10648
+ );
10649
+ for (const profile of plan.profiles)
10650
+ if (profile.reason)
10651
+ ui.warning(`Skipping data profile: ${profile.name} (${profile.reason})`);
10505
10652
  const summary = {
10506
10653
  sourceTsgId: plan.sourceTsgId,
10507
10654
  destinationTsgId: plan.destinationTsgId,
10508
10655
  dryRun: Boolean(opts.dryRun),
10509
10656
  dictionaries: plan.dictionaries.map((d) => ({ name: d.name, action: d.action })),
10510
10657
  patterns: plan.patterns.map((p) => ({ name: p.name, action: p.action })),
10511
- profiles: plan.profiles.map((p) => ({ name: p.name, action: p.action }))
10658
+ profiles: plan.profiles.map((p) => ({
10659
+ name: p.name,
10660
+ action: p.action,
10661
+ ...p.reason ? { reason: p.reason } : {}
10662
+ })),
10663
+ unresolved: plan.unresolved.map(({ kind, name, reason }) => ({ kind, name, reason }))
10512
10664
  };
10513
10665
  const columns = [
10514
10666
  { key: "sourceTsgId", label: "Source TSG" },
@@ -10516,6 +10668,7 @@ function register6(dlp) {
10516
10668
  { key: "dictionaries", label: "Dictionaries" },
10517
10669
  { key: "patterns", label: "Data patterns" },
10518
10670
  { key: "profiles", label: "Data profiles" },
10671
+ { key: "unresolved", label: "Unresolved references" },
10519
10672
  { key: "serverAdded", label: "Verified server-added fields" },
10520
10673
  { key: "dryRun", label: "Dry run" },
10521
10674
  { key: "complete", label: "Complete" }
@@ -10531,7 +10684,7 @@ function register6(dlp) {
10531
10684
  Boolean(opts.force),
10532
10685
  { action: `restore DLP resources into TSG ${tsgId}` }
10533
10686
  );
10534
- const result = await restoreDlpResources(api, plan);
10687
+ const result = await restoreDlpResources(api, plan, { onProgress });
10535
10688
  console.log(renderRestore({ ...result }, columns, format));
10536
10689
  if (!result.complete) {
10537
10690
  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.2",
4
+ "version": "5.8.4",
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",