1 | |
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 | |
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 | |
100 | |
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 | |
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 | |
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 |
|