0

Upsert security rule

by
Published today

Declaratively create or update a security policy rule in a firewall vsys, or a pre or post rule in a Panorama device group or the shared location (PAN-OS REST API). Reads the current rule, returns the difference and, unless dry_run is true (the default), applies only that difference to the candidate configuration. Empty inputs leave the current values unchanged; on creation empty lists default to any (service to application-default) and action is required. New rules are added at the bottom of the rulebase. Changes take effect after a commit.

Script palo_alto_panos Verified

The script

Submitted by hugo989 Typescript (fetch-only)
Verified 7 hours ago
1
//native
2

3
/**
4
 * Upsert security rule
5
 * Declaratively create or update a security policy rule in a firewall vsys, or a pre or post rule in a Panorama device group or the shared location (PAN-OS REST API). Reads the current rule, returns the difference and, unless dry_run is true (the default), applies only that difference to the candidate configuration. Empty inputs leave the current values unchanged; on creation empty lists default to any (service to application-default) and action is required. New rules are added at the bottom of the rulebase. Changes take effect after a commit.
6
 */
7
export async function main(
8
  auth: RT.PaloAltoPanos,
9
  name: string,
10
  location: "vsys" | "device-group" | "shared" | undefined,
11
  vsys: string | undefined,
12
  device_group: string | undefined,
13
  rulebase: "pre" | "post" | undefined,
14
  action:
15
    | "allow"
16
    | "deny"
17
    | "drop"
18
    | "reset-client"
19
    | "reset-server"
20
    | "reset-both"
21
    | undefined,
22
  from_zones: string[] | undefined,
23
  to_zones: string[] | undefined,
24
  source: string[] | undefined,
25
  destination: string[] | undefined,
26
  source_user: string[] | undefined,
27
  application: string[] | undefined,
28
  service: string[] | undefined,
29
  category: string[] | undefined,
30
  tags: string[] | undefined,
31
  description: string | undefined,
32
  disabled: "keep" | "yes" | "no" | undefined,
33
  log_setting: string | undefined,
34
  profile_group: string | undefined,
35
  dry_run: boolean = true
36
) {
37
  const version = await restApiVersion(auth)
38
  const url = new URL(
39
    `/restapi/${version}/Policies/${ruleResource(location, rulebase)}`,
40
    auth.base_url
41
  )
42
  const attrs = setLocation(url, location, vsys, device_group)
43

44
  // Read the current state: list the rulebase and find the rule by name.
45
  const current = ((await rest(auth, url, "GET")).result?.entry ?? []).find(
46
    (e: any) => e["@name"] === name
47
  )
48

49
  const desired: { [key: string]: any } = {}
50
  const lists: { [key: string]: string[] | undefined } = {
51
    from: from_zones,
52
    to: to_zones,
53
    source,
54
    destination,
55
    "source-user": source_user,
56
    application,
57
    service,
58
    category,
59
    tag: tags,
60
  }
61
  for (const [key, members] of Object.entries(lists)) {
62
    if (members?.length) desired[key] = { member: members }
63
  }
64
  if (action) desired.action = action
65
  if (description) desired.description = description
66
  if (disabled === "yes" || disabled === "no") desired.disabled = disabled
67
  if (log_setting) desired["log-setting"] = log_setting
68
  if (profile_group) {
69
    desired["profile-setting"] = { group: { member: [profile_group] } }
70
  }
71

72
  if (!current) {
73
    if (!desired.action) throw new Error("action is required to create a rule")
74
    for (const key of REQUIRED_LISTS) {
75
      desired[key] ??= {
76
        member: [key === "service" ? "application-default" : "any"],
77
      }
78
    }
79
  }
80

81
  const diff: { [key: string]: { from: any; to: any } } = {}
82
  for (const [key, to] of Object.entries(desired)) {
83
    const from = current?.[key] ?? (current && key === "disabled" ? "no" : null)
84
    if (canon(from) !== canon(to)) diff[key] = { from, to }
85
  }
86

87
  const changed = Object.keys(diff).length > 0
88
  const operation = !current ? "create" : changed ? "update" : "none"
89
  if (!changed || dry_run !== false) {
90
    return {
91
      changed,
92
      operation,
93
      diff,
94
      applied: false,
95
      dry_run: dry_run !== false,
96
    }
97
  }
98

99
  // The docs build a PUT body from the GET response, modified as needed: keep
100
  // everything the action does not manage, attributes (e.g. @uuid) included.
101
  const entry: { [key: string]: any } = { ...(current ?? {}) }
102
  Object.assign(entry, { "@name": name, ...attrs }, desired)
103

104
  url.searchParams.set("name", name)
105
  await rest(auth, url, current ? "PUT" : "POST", { entry: [entry] })
106
  return { changed, operation, diff, applied: true, dry_run: false }
107
}
108

109
const REQUIRED_LISTS = [
110
  "from",
111
  "to",
112
  "source",
113
  "destination",
114
  "source-user",
115
  "application",
116
  "service",
117
  "category",
118
]
119

120
// A firewall vsys has one rulebase (SecurityRules); Panorama has pre and post rulebases.
121
function ruleResource(
122
  location: string | undefined,
123
  rulebase: string | undefined
124
) {
125
  if ((location || "vsys") === "vsys") return "SecurityRules"
126
  return rulebase === "post" ? "SecurityPostRules" : "SecurityPreRules"
127
}
128

129
// Order-insensitive comparison of member lists (a single member may come back as a string).
130
function canon(v: any): string {
131
  if (Array.isArray(v)) return JSON.stringify(v.map(canon).sort())
132
  if (v && typeof v === "object") {
133
    return JSON.stringify(
134
      Object.keys(v)
135
        .sort()
136
        .map((k) => [
137
          k,
138
          canon(k === "member" && !Array.isArray(v[k]) ? [v[k]] : v[k]),
139
        ])
140
    )
141
  }
142
  return JSON.stringify(v ?? null)
143
}
144

145
async function rest(auth: RT.PaloAltoPanos, url: URL, method: string, body?: any) {
146
  const response = await fetch(url, {
147
    method,
148
    headers: {
149
      "X-PAN-KEY": auth.api_key,
150
      ...(body ? { "Content-Type": "application/json" } : {}),
151
      Accept: "application/json",
152
    },
153
    body: body ? JSON.stringify(body) : undefined,
154
  })
155
  const text = await response.text()
156
  if (!response.ok) {
157
    throw new Error(`${response.status} ${text}`)
158
  }
159
  const data = JSON.parse(text)
160
  if (data["@status"] !== "success") {
161
    throw new Error(`${response.status} ${text}`)
162
  }
163
  return data
164
}
165

166
function setLocation(
167
  url: URL,
168
  location: string | undefined,
169
  vsys: string | undefined,
170
  device_group: string | undefined
171
) {
172
  const loc = location || "vsys"
173
  url.searchParams.set("location", loc)
174
  if (loc === "vsys") {
175
    url.searchParams.set("vsys", vsys || "vsys1")
176
    return { "@location": loc, "@vsys": vsys || "vsys1" }
177
  }
178
  if (loc === "device-group") {
179
    if (!device_group) {
180
      throw new Error("device_group is required when location is device-group")
181
    }
182
    url.searchParams.set("device-group", device_group)
183
    return { "@location": loc, "@device-group": device_group }
184
  }
185
  return { "@location": loc }
186
}
187

188
async function restApiVersion(auth: RT.PaloAltoPanos) {
189
  if (auth.rest_api_version) return auth.rest_api_version
190
  const url = new URL("/api/", auth.base_url)
191
  url.searchParams.set("type", "op")
192
  url.searchParams.set("cmd", "<show><system><info></info></system></show>")
193
  const response = await fetch(url, { headers: { "X-PAN-KEY": auth.api_key } })
194
  const text = await response.text()
195
  if (!response.ok || text.includes('status="error"')) {
196
    throw new Error(`${response.status} ${text}`)
197
  }
198
  const match = text.match(/<sw-version>(\d+)\.(\d+)/)
199
  if (!match) {
200
    throw new Error(
201
      `Could not read sw-version from show system info; set rest_api_version on the resource. ${text}`
202
    )
203
  }
204
  return `v${match[1]}.${match[2]}`
205
}
206