Edits history of script submission #24030 for ' Upsert security rule (palo_alto_panos)'

  • bunnative
    One script reply has been approved by the moderators
    Ap­pro­ved
    //native
    
    /**
     * Upsert security rule
     * 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.
     */
    export async function main(
      auth: RT.PaloAltoPanos,
      name: string,
      location: "vsys" | "device-group" | "shared" | undefined,
      vsys: string | undefined,
      device_group: string | undefined,
      rulebase: "pre" | "post" | undefined,
      action:
        | "allow"
        | "deny"
        | "drop"
        | "reset-client"
        | "reset-server"
        | "reset-both"
        | undefined,
      from_zones: string[] | undefined,
      to_zones: string[] | undefined,
      source: string[] | undefined,
      destination: string[] | undefined,
      source_user: string[] | undefined,
      application: string[] | undefined,
      service: string[] | undefined,
      category: string[] | undefined,
      tags: string[] | undefined,
      description: string | undefined,
      disabled: "keep" | "yes" | "no" | undefined,
      log_setting: string | undefined,
      profile_group: string | undefined,
      dry_run: boolean = true
    ) {
      const version = await restApiVersion(auth)
      const url = new URL(
        `/restapi/${version}/Policies/${ruleResource(location, rulebase)}`,
        auth.base_url
      )
      const attrs = setLocation(url, location, vsys, device_group)
    
      // Read the current state: list the rulebase and find the rule by name.
      const current = ((await rest(auth, url, "GET")).result?.entry ?? []).find(
        (e: any) => e["@name"] === name
      )
    
      const desired: { [key: string]: any } = {}
      const lists: { [key: string]: string[] | undefined } = {
        from: from_zones,
        to: to_zones,
        source,
        destination,
        "source-user": source_user,
        application,
        service,
        category,
        tag: tags,
      }
      for (const [key, members] of Object.entries(lists)) {
        if (members?.length) desired[key] = { member: members }
      }
      if (action) desired.action = action
      if (description) desired.description = description
      if (disabled === "yes" || disabled === "no") desired.disabled = disabled
      if (log_setting) desired["log-setting"] = log_setting
      if (profile_group) {
        desired["profile-setting"] = { group: { member: [profile_group] } }
      }
    
      if (!current) {
        if (!desired.action) throw new Error("action is required to create a rule")
        for (const key of REQUIRED_LISTS) {
          desired[key] ??= {
            member: [key === "service" ? "application-default" : "any"],
          }
        }
      }
    
      const diff: { [key: string]: { from: any; to: any } } = {}
      for (const [key, to] of Object.entries(desired)) {
        const from = current?.[key] ?? (current && key === "disabled" ? "no" : null)
        if (canon(from) !== canon(to)) diff[key] = { from, to }
      }
    
      const changed = Object.keys(diff).length > 0
      const operation = !current ? "create" : changed ? "update" : "none"
      if (!changed || dry_run !== false) {
        return {
          changed,
          operation,
          diff,
          applied: false,
          dry_run: dry_run !== false,
        }
      }
    
      // The docs build a PUT body from the GET response, modified as needed: keep
      // everything the action does not manage, attributes (e.g. @uuid) included.
      const entry: { [key: string]: any } = { ...(current ?? {}) }
      Object.assign(entry, { "@name": name, ...attrs }, desired)
    
      url.searchParams.set("name", name)
      await rest(auth, url, current ? "PUT" : "POST", { entry: [entry] })
      return { changed, operation, diff, applied: true, dry_run: false }
    }
    
    const REQUIRED_LISTS = [
      "from",
      "to",
      "source",
      "destination",
      "source-user",
      "application",
      "service",
      "category",
    ]
    
    // A firewall vsys has one rulebase (SecurityRules); Panorama has pre and post rulebases.
    function ruleResource(
      location: string | undefined,
      rulebase: string | undefined
    ) {
      if ((location || "vsys") === "vsys") return "SecurityRules"
      return rulebase === "post" ? "SecurityPostRules" : "SecurityPreRules"
    }
    
    // Order-insensitive comparison of member lists (a single member may come back as a string).
    function canon(v: any): string {
      if (Array.isArray(v)) return JSON.stringify(v.map(canon).sort())
      if (v && typeof v === "object") {
        return JSON.stringify(
          Object.keys(v)
            .sort()
            .map((k) => [
              k,
              canon(k === "member" && !Array.isArray(v[k]) ? [v[k]] : v[k]),
            ])
        )
      }
      return JSON.stringify(v ?? null)
    }
    
    async function rest(auth: RT.PaloAltoPanos, url: URL, method: string, body?: any) {
      const response = await fetch(url, {
        method,
        headers: {
          "X-PAN-KEY": auth.api_key,
          ...(body ? { "Content-Type": "application/json" } : {}),
          Accept: "application/json",
        },
        body: body ? JSON.stringify(body) : undefined,
      })
      const text = await response.text()
      if (!response.ok) {
        throw new Error(`${response.status} ${text}`)
      }
      const data = JSON.parse(text)
      if (data["@status"] !== "success") {
        throw new Error(`${response.status} ${text}`)
      }
      return data
    }
    
    function setLocation(
      url: URL,
      location: string | undefined,
      vsys: string | undefined,
      device_group: string | undefined
    ) {
      const loc = location || "vsys"
      url.searchParams.set("location", loc)
      if (loc === "vsys") {
        url.searchParams.set("vsys", vsys || "vsys1")
        return { "@location": loc, "@vsys": vsys || "vsys1" }
      }
      if (loc === "device-group") {
        if (!device_group) {
          throw new Error("device_group is required when location is device-group")
        }
        url.searchParams.set("device-group", device_group)
        return { "@location": loc, "@device-group": device_group }
      }
      return { "@location": loc }
    }
    
    async function restApiVersion(auth: RT.PaloAltoPanos) {
      if (auth.rest_api_version) return auth.rest_api_version
      const url = new URL("/api/", auth.base_url)
      url.searchParams.set("type", "op")
      url.searchParams.set("cmd", "<show><system><info></info></system></show>")
      const response = await fetch(url, { headers: { "X-PAN-KEY": auth.api_key } })
      const text = await response.text()
      if (!response.ok || text.includes('status="error"')) {
        throw new Error(`${response.status} ${text}`)
      }
      const match = text.match(/<sw-version>(\d+)\.(\d+)/)
      if (!match) {
        throw new Error(
          `Could not read sw-version from show system info; set rest_api_version on the resource. ${text}`
        )
      }
      return `v${match[1]}.${match[2]}`
    }
    

    Submitted by hugo989 7 hours ago