@cdot65/prisma-airs-cli 5.6.0 → 5.7.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/cli/index.js +340 -57
  2. package/package.json +2 -2
package/dist/cli/index.js CHANGED
@@ -522,6 +522,10 @@ ${INDENT}${chalk3.bold(label)}
522
522
  warn(msg) {
523
523
  console.log(`${INDENT}${COLORS.warn(`${GLYPHS.warn} ${msg}`)}`);
524
524
  },
525
+ /** Safety warnings remain visible in quiet mode without corrupting structured stdout. */
526
+ warning(msg) {
527
+ console.error(`${INDENT}${COLORS.warn(`${GLYPHS.warn} ${msg}`)}`);
528
+ },
525
529
  /** Neutral informational callout. Quiet-suppressed. */
526
530
  info(msg) {
527
531
  if (quietMode) return;
@@ -9551,6 +9555,102 @@ import {
9551
9555
  SecurityProfileSchema
9552
9556
  } from "@cdot65/prisma-airs-sdk";
9553
9557
  import { z as z5 } from "zod";
9558
+
9559
+ // src/backup/runtime-policy.ts
9560
+ function object(value) {
9561
+ return value !== null && typeof value === "object" && !Array.isArray(value);
9562
+ }
9563
+ function compareRuntimePolicies(expected, actual) {
9564
+ const serverDefaults = [];
9565
+ const differences = [];
9566
+ const visit = (source, target, path3, insideToxicCategories = false, insideTopicGuardrails = false) => {
9567
+ if (source === target) return;
9568
+ if (Array.isArray(source) && Array.isArray(target)) {
9569
+ if (source.length !== target.length) differences.push(`${path3}.length`);
9570
+ source.forEach((item, index) => {
9571
+ visit(
9572
+ item,
9573
+ target[index],
9574
+ `${path3}[${index}]`,
9575
+ insideToxicCategories,
9576
+ insideTopicGuardrails
9577
+ );
9578
+ });
9579
+ return;
9580
+ }
9581
+ if (object(source) && object(target)) {
9582
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(source), ...Object.keys(target)])) {
9583
+ const field = `${path3}.${key}`;
9584
+ if (!Object.hasOwn(source, key) && Object.hasOwn(target, key)) {
9585
+ const model = /^policy\.ai-security-profiles\[\d+\]\.model-configuration/;
9586
+ const relative = path3.replace(model, "model");
9587
+ let allowed;
9588
+ if (relative === "model.data-protection" && key === "database-security") allowed = null;
9589
+ if (key === "severity") {
9590
+ if (/^model\.data-protection\.database-security\[\d+\]$/.test(relative)) {
9591
+ const values = {
9592
+ "database-security-create": "medium",
9593
+ "database-security-read": "low",
9594
+ "database-security-update": "medium",
9595
+ "database-security-delete": "high"
9596
+ };
9597
+ if (source.name === target.name && typeof source.name === "string")
9598
+ allowed = Object.hasOwn(values, source.name) ? values[source.name] : void 0;
9599
+ }
9600
+ if (relative === "model.data-protection.source-code-detection") allowed = "high";
9601
+ if (relative === "model.app-protection.malicious-code-protection" && source.name === "malicious-code" && target.name === source.name)
9602
+ allowed = "high";
9603
+ if (/^model\.model-protection\[\d+\]$/.test(relative) && (source.name === "prompt-injection" || source.name === "topic-guardrails" || source.name === "contextual-grounding" && source.action === "block") && target.name === source.name)
9604
+ allowed = "medium";
9605
+ if (insideTopicGuardrails && /^model\.model-protection\[\d+\]\.topic-list\[\d+\]\.topic\[\d+\]$/.test(relative) && typeof source.topic_id === "string" && source.topic_id.length > 0 && source.topic_id === target.topic_id && source.topic_name === target.topic_name && Number.isInteger(source.revision) && source.revision === target.revision)
9606
+ allowed = "medium";
9607
+ if (/^model\.agent-protection\[\d+\]$/.test(relative) && source.name === "agent-security" && target.name === source.name)
9608
+ allowed = "medium";
9609
+ }
9610
+ if (relative === "model.app-protection" && key === "url-detected-severity")
9611
+ allowed = "low";
9612
+ if (relative === "model.app-protection" && key === "default-url-category" && object(target[key])) {
9613
+ const value = target[key];
9614
+ if (Object.keys(value).length === 1 && value.member === null) {
9615
+ serverDefaults.push(field);
9616
+ continue;
9617
+ }
9618
+ }
9619
+ if ((/^model\.model-protection\[\d+\]$/.test(relative) && source.name === "toxic-content" && target.name === source.name || insideToxicCategories && /^model\.model-protection\[\d+\]\.toxic-category-list\[\d+\]$/.test(relative) && typeof source.category === "string" && source.category.length > 0 && source.category === target.category) && key === "severity-by-confidence" && object(target[key])) {
9620
+ const value = target[key];
9621
+ if (Object.keys(value).length === 2 && value.high === "medium" && value.moderate === "low") {
9622
+ serverDefaults.push(field);
9623
+ continue;
9624
+ }
9625
+ }
9626
+ if (allowed !== void 0 && target[key] === allowed) {
9627
+ serverDefaults.push(field);
9628
+ continue;
9629
+ }
9630
+ differences.push(field);
9631
+ } else if (!Object.hasOwn(target, key)) differences.push(field);
9632
+ else
9633
+ visit(
9634
+ source[key],
9635
+ target[key],
9636
+ field,
9637
+ key === "toxic-category-list" && source.name === "toxic-content" && target.name === source.name && /^policy\.ai-security-profiles\[\d+\]\.model-configuration\.model-protection\[\d+\]$/.test(
9638
+ path3
9639
+ ),
9640
+ key === "topic-list" && source.name === "topic-guardrails" && target.name === source.name && /^policy\.ai-security-profiles\[\d+\]\.model-configuration\.model-protection\[\d+\]$/.test(
9641
+ path3
9642
+ ) || insideTopicGuardrails && key === "topic" && source.action === "block" && target.action === "block"
9643
+ );
9644
+ }
9645
+ return;
9646
+ }
9647
+ differences.push(path3);
9648
+ };
9649
+ visit(expected, actual, "policy");
9650
+ return { matches: differences.length === 0, serverDefaults, differences };
9651
+ }
9652
+
9653
+ // src/backup/runtime-profiles.ts
9554
9654
  var RuntimeProfilesBackupSchema = z5.object({
9555
9655
  version: z5.literal(1),
9556
9656
  resourceType: z5.literal("runtime-security-profiles"),
@@ -9559,7 +9659,7 @@ var RuntimeProfilesBackupSchema = z5.object({
9559
9659
  profiles: z5.array(SecurityProfileSchema).min(1).max(1e4),
9560
9660
  topics: z5.array(CustomTopicSchema).max(1e4)
9561
9661
  }).strict();
9562
- function object(value) {
9662
+ function object2(value) {
9563
9663
  return value !== null && typeof value === "object" && !Array.isArray(value);
9564
9664
  }
9565
9665
  function pagesLimit(value) {
@@ -9626,7 +9726,7 @@ function topicReferences(policy) {
9626
9726
  value.forEach(visit);
9627
9727
  return;
9628
9728
  }
9629
- if (!object(value)) return;
9729
+ if (!object2(value)) return;
9630
9730
  if ("topic_id" in value) {
9631
9731
  if (typeof value.topic_id !== "string" || typeof value.topic_name !== "string" || !Number.isSafeInteger(value.revision) || Number(value.revision) < 0)
9632
9732
  throw new Error("Invalid topic identity in profile policy");
@@ -9679,26 +9779,48 @@ function sameTopic(a, b) {
9679
9779
  return a.description === b.description && JSON.stringify(a.examples) === JSON.stringify(b.examples) && (a.active ?? true) === (b.active ?? true);
9680
9780
  }
9681
9781
  function dlpReferences(policy) {
9682
- if (!object(policy)) return [];
9782
+ if (!object2(policy)) return [];
9683
9783
  const refs = [];
9684
9784
  for (const entry of Array.isArray(policy["dlp-data-profiles"]) ? policy["dlp-data-profiles"] : []) {
9685
- if (!object(entry) || typeof entry.name !== "string")
9785
+ if (!object2(entry) || typeof entry.name !== "string")
9686
9786
  throw new Error("Invalid embedded DLP reference");
9687
9787
  refs.push({ value: entry, name: entry.name, kind: "embedded" });
9688
9788
  }
9689
9789
  for (const entry of Array.isArray(policy["ai-security-profiles"]) ? policy["ai-security-profiles"] : []) {
9690
- if (!object(entry) || !object(entry["model-configuration"])) continue;
9790
+ if (!object2(entry) || !object2(entry["model-configuration"])) continue;
9691
9791
  const protection = entry["model-configuration"]["data-protection"];
9692
- if (!object(protection) || !object(protection["data-leak-detection"])) continue;
9792
+ if (!object2(protection) || !object2(protection["data-leak-detection"])) continue;
9693
9793
  const members = protection["data-leak-detection"].member;
9694
9794
  if (!Array.isArray(members)) continue;
9695
9795
  for (const member of members) {
9696
- if (!object(member) || typeof member.text !== "string") throw new Error("Invalid DLP member");
9796
+ if (!object2(member) || typeof member.text !== "string") throw new Error("Invalid DLP member");
9797
+ if (member.text.toLowerCase() === "sensitive content" && (member.id === "" || member.id === void 0) && (member.version === "2" || member.version === void 0) && !refs.some((ref) => ref.kind === "embedded" && ref.name === member.text))
9798
+ continue;
9697
9799
  refs.push({ value: member, name: member.text, kind: "member" });
9698
9800
  }
9699
9801
  }
9700
9802
  return refs;
9701
9803
  }
9804
+ function basicFallback(policy) {
9805
+ const result = structuredClone(policy);
9806
+ const customNames = new Set(dlpReferences(result).map((ref) => ref.name));
9807
+ let converted = false;
9808
+ for (const entry of result?.["ai-security-profiles"] ?? []) {
9809
+ const detection = entry["model-configuration"]?.["data-protection"]?.["data-leak-detection"];
9810
+ if (!detection?.member?.some((member) => customNames.has(member.text))) continue;
9811
+ if (!["block", "allow", ""].includes(detection.action))
9812
+ throw new Error("Cannot safely fall back to Basic: unsupported DLP action");
9813
+ if (detection["mask-data-inline"] && detection.action !== "block")
9814
+ throw new Error("Cannot safely fall back to Basic: inline masking requires a block action");
9815
+ detection.member = detection.action === "" ? null : [{ text: "sensitive content", id: "", version: "2" }];
9816
+ converted = true;
9817
+ }
9818
+ if (!converted)
9819
+ throw new Error("Cannot safely fall back to Basic: no matching DLP detection configuration");
9820
+ if (result && "dlp-data-profiles" in result) result["dlp-data-profiles"] = [];
9821
+ PolicySchema.parse(result);
9822
+ return result;
9823
+ }
9702
9824
  function assertKnownPolicyReferences(policy) {
9703
9825
  const known = new Map([
9704
9826
  ...topicReferences(policy).map((ref) => [ref, ["topic_id"]]),
@@ -9714,7 +9836,7 @@ function assertKnownPolicyReferences(policy) {
9714
9836
  });
9715
9837
  return;
9716
9838
  }
9717
- if (!object(value)) return;
9839
+ if (!object2(value)) return;
9718
9840
  for (const [key, child] of Object.entries(value)) {
9719
9841
  if (child !== null && child !== "" && /(?:^|[-_])(id|uuid)$|(?:Id|ID|Uuid|UUID)$/.test(key) && !known.get(value)?.includes(key))
9720
9842
  throw new Error(`Unsupported cross-tenant policy reference at ${path3}.${key}`);
@@ -9744,7 +9866,10 @@ async function planRuntimeProfilesRestore(api, input2, destinationTsgId, options
9744
9866
  if (prefix.length > 64 || [...prefix].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127))
9745
9867
  throw new Error("Invalid --name-prefix");
9746
9868
  const conflict = options.onConflict ?? "error";
9747
- if (!["error", "skip", "update"].includes(conflict)) throw new Error("Invalid conflict policy");
9869
+ if (!["error", "skip", "update", "verify"].includes(conflict))
9870
+ throw new Error("Invalid conflict policy");
9871
+ const missingDlp = options.onMissingDlp ?? "error";
9872
+ if (!["error", "basic"].includes(missingDlp)) throw new Error("Invalid missing DLP policy");
9748
9873
  const selected = latestProfiles(backup.profiles);
9749
9874
  assertTenant(selected, backup.source.tsgId);
9750
9875
  if (selected.length !== backup.profiles.length)
@@ -9766,14 +9891,14 @@ async function planRuntimeProfilesRestore(api, input2, destinationTsgId, options
9766
9891
  const match = existing.find((p) => p.profile_name === name);
9767
9892
  if (match && conflict === "error")
9768
9893
  throw new Error(
9769
- `Destination profile already exists: ${name}; choose --on-conflict skip or update`
9894
+ `Destination profile already exists: ${name}; choose --on-conflict verify, skip or update`
9770
9895
  );
9771
9896
  if (match && !match.profile_id) throw new Error("Destination profile has no identity");
9772
9897
  return {
9773
9898
  source: structuredClone(source),
9774
9899
  name,
9775
9900
  existing: match,
9776
- action: match ? conflict === "skip" ? "skip" : "update" : "create"
9901
+ action: match ? conflict === "skip" ? "skip" : conflict === "verify" ? "verify" : "update" : "create"
9777
9902
  };
9778
9903
  });
9779
9904
  const changing = profilePlans.filter((p) => p.action !== "skip");
@@ -9807,46 +9932,102 @@ async function planRuntimeProfilesRestore(api, input2, destinationTsgId, options
9807
9932
  );
9808
9933
  topicPlans.set(key, { key, name, source, existing: match });
9809
9934
  }
9810
- const dlpRefs = crossTenant ? changing.flatMap((p) => dlpReferences(p.source.policy)) : [];
9935
+ const dlpRefs = crossTenant || missingDlp === "basic" ? changing.flatMap((p) => dlpReferences(p.source.policy)) : [];
9811
9936
  const names = [...new Set(dlpRefs.map((ref) => ref.name))];
9812
- for (const name of names)
9813
- if (!options.dlpMap?.[name])
9937
+ const targetFor = (name) => Object.hasOwn(options.dlpMap ?? {}, name) ? options.dlpMap?.[name] : crossTenant ? void 0 : name;
9938
+ const unresolved = /* @__PURE__ */ new Set();
9939
+ for (const name of names) {
9940
+ if (!targetFor(name) && missingDlp === "error")
9814
9941
  throw new Error(
9815
- `Cross-tenant DLP reference requires explicit --dlp-map ${name}=<destination-name>; DLP resources are not cloned`
9942
+ `Cross-tenant DLP reference requires explicit --dlp-map "${name}=<destination-name>"; DLP resources are not cloned. Inspect destination resources with airs runtime dlp profiles list --all --output json, or explicitly accept --on-missing-dlp basic.`
9816
9943
  );
9817
- const catalog = names.length ? await dataProfiles(api, maxPages2) : [];
9818
- const dlpMappings = names.map((name) => {
9819
- const target = options.dlpMap?.[name];
9820
- const matches = catalog.filter((p) => p.name === target && p.profile_status !== "deleted");
9944
+ if (!targetFor(name)) unresolved.add(name);
9945
+ }
9946
+ const catalog = names.some((name) => targetFor(name)) ? await dataProfiles(api, maxPages2) : [];
9947
+ const resolved = [];
9948
+ for (const name of names) {
9949
+ const target = targetFor(name);
9950
+ if (!target) continue;
9951
+ const matches = catalog.filter(
9952
+ (p) => p.name === target && p.profile_status !== "deleted" && p.profile_status !== "disabled"
9953
+ );
9954
+ if (!matches.length && missingDlp === "basic") {
9955
+ unresolved.add(name);
9956
+ continue;
9957
+ }
9821
9958
  if (matches.length !== 1 || !matches[0].id)
9822
9959
  throw new Error(`Missing or ambiguous destination DLP profile: ${target}`);
9823
9960
  if (dlpRefs.some((ref) => ref.name === name && "version" in ref.value) && matches[0].version == null)
9824
9961
  throw new Error(`Destination DLP version is unknown: ${target}`);
9825
- return {
9962
+ resolved.push({
9826
9963
  source: name,
9827
9964
  destination: String(target),
9828
9965
  id: matches[0].id,
9829
9966
  version: matches[0].version ?? void 0
9830
- };
9831
- });
9832
- return {
9967
+ });
9968
+ }
9969
+ const dlpFallbacks = [];
9970
+ for (const item of changing) {
9971
+ const replaced = [...new Set(dlpReferences(item.source.policy).map((ref) => ref.name))];
9972
+ const missing = replaced.filter((name) => unresolved.has(name));
9973
+ if (!missing.length) continue;
9974
+ item.fallbackPolicy = basicFallback(item.source.policy);
9975
+ dlpFallbacks.push({
9976
+ profile: item.name,
9977
+ unresolved: missing,
9978
+ replaced,
9979
+ reason: "unmapped-or-missing",
9980
+ protection: "basic"
9981
+ });
9982
+ }
9983
+ const remaining = new Set(
9984
+ changing.flatMap(
9985
+ (p) => dlpReferences(p.fallbackPolicy ?? p.source.policy).map((ref) => ref.name)
9986
+ )
9987
+ );
9988
+ const dlpMappings = resolved.filter((mapping) => remaining.has(mapping.source));
9989
+ const plan = {
9833
9990
  sourceTsgId: backup.source.tsgId,
9834
9991
  destinationTsgId,
9835
9992
  profiles: profilePlans,
9836
9993
  topics: [...topicPlans.values()],
9837
9994
  dlpMappings,
9995
+ dlpFallbacks,
9996
+ serverDefaults: [],
9838
9997
  maxPages: maxPages2
9839
9998
  };
9999
+ const existingTopics = new Map(
10000
+ plan.topics.filter((t) => t.existing).map((t) => [t.key, t.existing])
10001
+ );
10002
+ for (const item of profilePlans.filter((p) => p.action === "verify")) {
10003
+ const expected = mappedPolicy(item, plan, existingTopics);
10004
+ const comparison = compareRuntimePolicies(expected, item.existing?.policy);
10005
+ if (!comparison.matches || item.source.active !== void 0 && item.existing?.active !== item.source.active)
10006
+ throw new Error(
10007
+ `Destination profile does not match restore plan: ${item.name}; verification made no changes`
10008
+ );
10009
+ if (comparison.serverDefaults.length)
10010
+ plan.serverDefaults.push({ profile: item.name, fields: comparison.serverDefaults });
10011
+ }
10012
+ return plan;
9840
10013
  }
9841
10014
  function canonical(value) {
9842
- const order = (v) => Array.isArray(v) ? v.map(order) : object(v) ? Object.fromEntries(
10015
+ const order = (v) => Array.isArray(v) ? v.map(order) : object2(v) ? Object.fromEntries(
9843
10016
  Object.entries(v).sort(([a], [b]) => a.localeCompare(b)).map(([k, child]) => [k, order(child)])
9844
10017
  ) : v;
9845
10018
  return JSON.stringify(order(value));
9846
10019
  }
10020
+ function canonicalPolicy(policy) {
10021
+ const value = structuredClone(policy);
10022
+ for (const entry of value?.["ai-security-profiles"] ?? []) {
10023
+ const protection = entry["model-configuration"]?.["data-protection"];
10024
+ if (protection && !("database-security" in protection)) protection["database-security"] = null;
10025
+ }
10026
+ return canonical(value);
10027
+ }
9847
10028
  function unchanged(current, prior) {
9848
10029
  return Boolean(
9849
- current && current.profile_id === prior.profile_id && current.revision === prior.revision && current.active === prior.active && canonical(current.policy) === canonical(prior.policy)
10030
+ current && current.profile_id === prior.profile_id && current.revision === prior.revision && current.active === prior.active && canonicalPolicy(current.policy) === canonicalPolicy(prior.policy)
9850
10031
  );
9851
10032
  }
9852
10033
  async function restoreRuntimeProfiles(api, plan) {
@@ -9854,6 +10035,8 @@ async function restoreRuntimeProfiles(api, plan) {
9854
10035
  sourceTsgId: plan.sourceTsgId,
9855
10036
  destinationTsgId: plan.destinationTsgId,
9856
10037
  complete: false,
10038
+ dlpFallbacks: structuredClone(plan.dlpFallbacks ?? []),
10039
+ serverDefaults: [],
9857
10040
  topics: [],
9858
10041
  profiles: []
9859
10042
  };
@@ -9864,7 +10047,7 @@ async function restoreRuntimeProfiles(api, plan) {
9864
10047
  const catalog = await dataProfiles(api, plan.maxPages);
9865
10048
  for (const mapping of plan.dlpMappings) {
9866
10049
  const matches = catalog.filter(
9867
- (p) => p.name === mapping.destination && p.profile_status !== "deleted"
10050
+ (p) => p.name === mapping.destination && p.profile_status !== "deleted" && p.profile_status !== "disabled"
9868
10051
  );
9869
10052
  if (matches.length !== 1 || matches[0].id !== mapping.id || (matches[0].version ?? void 0) !== mapping.version)
9870
10053
  throw new Error(`Destination DLP profile changed after planning: ${mapping.destination}`);
@@ -9910,27 +10093,7 @@ async function restoreRuntimeProfiles(api, plan) {
9910
10093
  result.profiles.push({ name: item.name, action: "skipped", id: item.existing?.profile_id });
9911
10094
  continue;
9912
10095
  }
9913
- const policy = structuredClone(item.source.policy);
9914
- for (const ref of topicReferences(policy)) {
9915
- const topic = mappedTopics.get(topicKey(ref));
9916
- if (!topic?.topic_id) throw new Error("Missing planned topic mapping");
9917
- ref.topic_id = topic.topic_id;
9918
- ref.topic_name = topic.topic_name;
9919
- ref.revision = topic.revision;
9920
- }
9921
- for (const ref of dlpReferences(policy)) {
9922
- const mapping = plan.dlpMappings.find((m) => m.source === ref.name);
9923
- if (!mapping) continue;
9924
- if (ref.kind === "embedded") {
9925
- ref.value.name = mapping.destination;
9926
- ref.value.uuid = mapping.id;
9927
- } else ref.value.text = mapping.destination;
9928
- if ("id" in ref.value) ref.value.id = mapping.id;
9929
- if ("version" in ref.value) {
9930
- if (mapping.version === void 0) throw new Error("Destination DLP version is unknown");
9931
- ref.value.version = String(mapping.version);
9932
- }
9933
- }
10096
+ const policy = mappedPolicy(item, plan, mappedTopics);
9934
10097
  const body = CreateSecurityProfileRequestSchema.parse({
9935
10098
  profile_name: item.name,
9936
10099
  active: item.source.active,
@@ -9941,6 +10104,14 @@ async function restoreRuntimeProfiles(api, plan) {
9941
10104
  const current = inventory2.find((p) => p.profile_name === item.name);
9942
10105
  if (item.existing ? !unchanged(current, item.existing) : Boolean(current))
9943
10106
  throw new Error(`Destination profile changed after planning: ${item.name}`);
10107
+ if (item.action === "verify") {
10108
+ const comparison2 = compareRuntimePolicies(policy, current?.policy);
10109
+ if (!comparison2.matches) throw new Error(`Restored profile did not verify: ${item.name}`);
10110
+ result.profiles.push({ name: item.name, action: "verified", id: current?.profile_id });
10111
+ if (comparison2.serverDefaults.length)
10112
+ result.serverDefaults.push({ profile: item.name, fields: comparison2.serverDefaults });
10113
+ continue;
10114
+ }
9944
10115
  const response = item.action === "update" && item.existing?.profile_id ? await api.profiles.update(item.existing.profile_id, body) : await api.profiles.create(body);
9945
10116
  result.profiles.push({
9946
10117
  name: item.name,
@@ -9951,8 +10122,11 @@ async function restoreRuntimeProfiles(api, plan) {
9951
10122
  (p) => p.profile_name === item.name
9952
10123
  );
9953
10124
  if (verified) assertTenant([verified], plan.destinationTsgId);
9954
- if (!verified?.profile_id || response.profile_id && response.profile_id !== verified.profile_id || item.source.active !== void 0 && verified.active !== item.source.active || canonical(verified.policy) !== canonical(policy))
10125
+ const comparison = compareRuntimePolicies(policy, verified?.policy);
10126
+ if (!verified?.profile_id || response.profile_id && response.profile_id !== verified.profile_id || item.source.active !== void 0 && verified.active !== item.source.active || !comparison.matches)
9955
10127
  throw new Error(`Restored profile did not verify: ${item.name}`);
10128
+ if (comparison.serverDefaults.length)
10129
+ result.serverDefaults.push({ profile: item.name, fields: comparison.serverDefaults });
9956
10130
  }
9957
10131
  result.complete = true;
9958
10132
  } catch (error) {
@@ -9963,10 +10137,97 @@ async function restoreRuntimeProfiles(api, plan) {
9963
10137
  }
9964
10138
  return result;
9965
10139
  }
10140
+ function mappedPolicy(item, plan, mappedTopics) {
10141
+ const policy = structuredClone(item.fallbackPolicy ?? item.source.policy);
10142
+ for (const ref of topicReferences(policy)) {
10143
+ const topic = mappedTopics.get(topicKey(ref));
10144
+ if (!topic?.topic_id)
10145
+ throw new Error(
10146
+ "Missing planned topic mapping; an existing profile cannot be verified without its destination dependencies"
10147
+ );
10148
+ ref.topic_id = topic.topic_id;
10149
+ ref.topic_name = topic.topic_name;
10150
+ ref.revision = topic.revision;
10151
+ }
10152
+ for (const ref of dlpReferences(policy)) {
10153
+ const mapping = plan.dlpMappings.find((m) => m.source === ref.name);
10154
+ if (!mapping) continue;
10155
+ if (ref.kind === "embedded") {
10156
+ ref.value.name = mapping.destination;
10157
+ ref.value.uuid = mapping.id;
10158
+ } else ref.value.text = mapping.destination;
10159
+ if ("id" in ref.value) ref.value.id = mapping.id;
10160
+ if ("version" in ref.value) {
10161
+ if (mapping.version === void 0) throw new Error("Destination DLP version is unknown");
10162
+ ref.value.version = String(mapping.version);
10163
+ }
10164
+ }
10165
+ return policy;
10166
+ }
9966
10167
 
9967
10168
  // src/cli/commands/profile-transfer.ts
9968
10169
  var MAX_BACKUP_BYTES = 20 * 1024 * 1024;
9969
10170
  var FORMATS = "Output format: pretty, table, markdown, csv, json, yaml";
10171
+ function renderRestore(value, columns, format) {
10172
+ if (format !== "pretty" && format !== "table" && format !== "markdown")
10173
+ return formatOutput([value], columns, format);
10174
+ const markdown = format === "markdown";
10175
+ const rows = (key) => Array.isArray(value[key]) ? value[key] : [];
10176
+ const heading = (text2) => markdown ? `### ${text2}
10177
+ ` : text2;
10178
+ const blocks = [
10179
+ heading(
10180
+ value.dryRun ? "Restore preview \u2014 no changes made" : value.complete ? "Restore complete" : "Restore incomplete \u2014 completed writes remain"
10181
+ ),
10182
+ `Source TSG: ${value.sourceTsgId}
10183
+ ${markdown ? "\n" : ""}Destination TSG: ${value.destinationTsgId}`
10184
+ ];
10185
+ for (const [key, title] of [
10186
+ ["profiles", "Profiles"],
10187
+ ["topics", "Topics"]
10188
+ ]) {
10189
+ blocks.push(heading(`${title} (${rows(key).length})`));
10190
+ blocks.push(
10191
+ formatOutput(
10192
+ rows(key),
10193
+ [
10194
+ { key: "action", label: "Action" },
10195
+ { key: "name", label: "Name" }
10196
+ ],
10197
+ markdown ? "markdown" : "table"
10198
+ ) || "(none)"
10199
+ );
10200
+ }
10201
+ if (rows("dlpMappings").length) {
10202
+ blocks.push(heading("DLP mappings"));
10203
+ blocks.push(
10204
+ formatOutput(
10205
+ rows("dlpMappings"),
10206
+ [
10207
+ { key: "source", label: "Source" },
10208
+ { key: "destination", label: "Destination" }
10209
+ ],
10210
+ markdown ? "markdown" : "table"
10211
+ )
10212
+ );
10213
+ }
10214
+ if (rows("dlpFallbacks").length) {
10215
+ blocks.push(heading("Basic DLP fallbacks \u2014 custom protection lost"));
10216
+ for (const fallback of rows("dlpFallbacks"))
10217
+ blocks.push(
10218
+ `${fallback.profile}: ${Array.isArray(fallback.replaced) ? fallback.replaced.join(", ") : ""}`
10219
+ );
10220
+ }
10221
+ if (rows("serverDefaults").length) {
10222
+ blocks.push(heading("Verified server-added defaults (source omitted these fields)"));
10223
+ for (const item of rows("serverDefaults"))
10224
+ blocks.push(
10225
+ `${item.profile}: ${Array.isArray(item.fields) ? item.fields.length : 0} fields; use --output json for paths`
10226
+ );
10227
+ }
10228
+ if (value.error) blocks.push(`Error: ${value.error}`);
10229
+ return blocks.join("\n\n");
10230
+ }
9970
10231
  function maxPages(value) {
9971
10232
  if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 1e3)
9972
10233
  usageError("--max-pages must be an integer from 1 to 1000");
@@ -10079,7 +10340,15 @@ function registerProfileTransferCommands(profiles2) {
10079
10340
  });
10080
10341
  const restore = profiles2.command("restore <file>").description(
10081
10342
  "Restore profile policies into the selected tenant; validate all dependencies before changes"
10082
- ).option("--dry-run", "Read and validate a restore plan without changing destination resources").option("--name-prefix <prefix>", "Prefix restored profile and topic names to avoid collisions").option("--on-conflict <policy>", "Existing profile names: error, skip or update", "error").option(
10343
+ ).option("--dry-run", "Read and validate a restore plan without changing destination resources").option("--name-prefix <prefix>", "Prefix restored profile and topic names to avoid collisions").option(
10344
+ "--on-conflict <policy>",
10345
+ "Existing profile names: error, verify (resume without writes), skip or update",
10346
+ "error"
10347
+ ).option(
10348
+ "--on-missing-dlp <policy>",
10349
+ "Unresolved custom DLP: error or basic (explicitly accepts loss of custom detection)",
10350
+ "error"
10351
+ ).option(
10083
10352
  "--dlp-map <source=destination>",
10084
10353
  "Explicit cross-tenant binding to an existing DLP data profile (repeatable)",
10085
10354
  (value, prior) => [...prior, value],
@@ -10096,12 +10365,15 @@ function registerProfileTransferCommands(profiles2) {
10096
10365
  "after",
10097
10366
  examples(
10098
10367
  "airs runtime profiles restore ./profiles.json --dry-run --output json",
10368
+ "airs runtime profiles restore ./profiles.json --on-missing-dlp basic --dry-run --output json",
10099
10369
  "airs runtime profiles restore ./profiles.json --name-prefix migrated- --expect-tsg <destination-tsg> --force"
10100
10370
  )
10101
10371
  ).action(async (file, opts) => {
10102
10372
  try {
10103
- if (!["error", "skip", "update"].includes(opts.onConflict))
10104
- throw new CliUsageError("--on-conflict must be error, skip or update");
10373
+ if (!["error", "skip", "update", "verify"].includes(opts.onConflict))
10374
+ throw new CliUsageError("--on-conflict must be error, verify, skip or update");
10375
+ if (!["error", "basic"].includes(opts.onMissingDlp))
10376
+ throw new CliUsageError("--on-missing-dlp must be error or basic");
10105
10377
  if (opts.force && !opts.expectTsg)
10106
10378
  throw new CliUsageError("--force requires --expect-tsg <destination-tsg>");
10107
10379
  const mappings = dlpMap(opts.dlpMap);
@@ -10131,7 +10403,8 @@ function registerProfileTransferCommands(profiles2) {
10131
10403
  namePrefix: opts.namePrefix,
10132
10404
  onConflict: opts.onConflict,
10133
10405
  maxPages: opts.maxPages,
10134
- dlpMap: mappings
10406
+ dlpMap: mappings,
10407
+ onMissingDlp: opts.onMissingDlp
10135
10408
  });
10136
10409
  const summary = {
10137
10410
  sourceTsgId: plan.sourceTsgId,
@@ -10145,7 +10418,9 @@ function registerProfileTransferCommands(profiles2) {
10145
10418
  dlpMappings: plan.dlpMappings.map((m) => ({
10146
10419
  source: m.source,
10147
10420
  destination: m.destination
10148
- }))
10421
+ })),
10422
+ dlpFallbacks: plan.dlpFallbacks,
10423
+ serverDefaults: plan.serverDefaults
10149
10424
  };
10150
10425
  const columns = [
10151
10426
  { key: "sourceTsgId", label: "Source TSG" },
@@ -10153,22 +10428,30 @@ function registerProfileTransferCommands(profiles2) {
10153
10428
  { key: "profiles", label: "Profiles" },
10154
10429
  { key: "topics", label: "Topics" },
10155
10430
  { key: "dlpMappings", label: "DLP mappings" },
10431
+ { key: "dlpFallbacks", label: "Basic DLP fallbacks (custom protection lost)" },
10432
+ { key: "serverDefaults", label: "Verified server-added defaults" },
10156
10433
  { key: "dryRun", label: "Dry run" },
10157
10434
  { key: "complete", label: "Complete" }
10158
10435
  ];
10436
+ for (const fallback of plan.dlpFallbacks)
10437
+ ui.warning(
10438
+ `DLP protection change: ${fallback.profile} will use Basic detection instead of ${fallback.replaced.join(", ")}. Custom rules are not preserved.`
10439
+ );
10159
10440
  if (opts.dryRun) {
10160
- console.log(formatOutput([summary], columns, format === "pretty" ? "table" : format));
10441
+ console.log(renderRestore(summary, columns, format));
10161
10442
  return;
10162
10443
  }
10163
- const changing = plan.profiles.filter((p) => p.action !== "skip");
10444
+ const changing = plan.profiles.filter(
10445
+ (p) => p.action === "create" || p.action === "update"
10446
+ );
10164
10447
  if (changing.length)
10165
10448
  await confirmOrAbort(
10166
- `Restore ${changing.length} profiles from TSG ${plan.sourceTsgId} to TSG ${tsgId} (${changing.filter((p) => p.action === "update").length} updates)?`,
10449
+ `Restore ${changing.length} profiles from TSG ${plan.sourceTsgId} to TSG ${tsgId} (${changing.filter((p) => p.action === "update").length} updates, ${plan.dlpFallbacks.length} Basic DLP fallbacks losing custom detection)?`,
10167
10450
  Boolean(opts.force),
10168
10451
  { action: `restore profiles into TSG ${tsgId}` }
10169
10452
  );
10170
10453
  const result = await restoreRuntimeProfiles(api, plan);
10171
- console.log(formatOutput([{ ...result }], columns, format === "pretty" ? "table" : format));
10454
+ console.log(renderRestore({ ...result }, columns, format));
10172
10455
  if (!result.complete) {
10173
10456
  ui.error(result.error ?? "Restore incomplete");
10174
10457
  process.exitCode = 1;
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.6.0",
4
+ "version": "5.7.0",
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",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "license": "MIT",
61
61
  "dependencies": {
62
- "@cdot65/prisma-airs-sdk": "0.29.0",
62
+ "@cdot65/prisma-airs-sdk": "0.30.0",
63
63
  "@inquirer/prompts": "^8.3.0",
64
64
  "chalk": "^5.6.2",
65
65
  "commander": "^14.0.3",