【OWASP ZAP】認証設定 (2) ログインスクリプトの作り方。Dockerでのスクリプト利用方法

# はじめに
OWASP ZAPを使用し、ログインの必要なサイトに対して診断を実施してきました。
その際に公式ドキュメントを見ても、どのように設定したらいいのか、実際の挙動がどうなるのか、わかりづらい点が多くありました。
本投稿では実際にやってみてわかった部分を記事にまとめてみようと思います。
今回はZAPの認証設定「Script-Based Authentication」で使用するスクリプトの作成方法や利用方法等について書きます。

次回以降、その他のスクリプトの利用方法や、「Auto-Detect Authentication」やAutomation Framework、クロールしにくいサイトに対するSpider, Ajax Spider等を工夫して行う方法もまとめていきたいと考えています。

認証の共通設定項目やフォーム認証、ベーシック認証の設定方法については前回の記事を見てください。
[【OWASP ZAP】認証設定 (1) Form認証、ベーシック認証のわかりづらいところ](https://qiita.com/Shcb8Qyoj021/items/8da1a7663b77c7f5dad4)

# 目次
- ZAPで使用するスクリプトについて
    - 種類
- スクリプトの作成方法
    - デバッグ
- スクリプトの利用方法
    - GUI
    - API
    - Automation Framework
- 認証スクリプト
    - 作成方法と使用方法
    - 利用例
        - Cookie
        - Amazon Cognito

# 動作環境
- ZAP GUI: 2.15.0
- Docker Image: zaproxy/zap-stable:20240902

# スクリプトについて
ZAPではスクリプトを自作して、複雑な認証設定に対応したり、自作の診断を実施したりできます。スクリプトをどのようなケースで利用できるのか、どのようにスクリプトを作成すればいいのかは、公式ドキュメントより、githubにアップロードされているサンプルスクリプトをまず見るとわかりやすいです。

[ZAP – Scripts](https://www.zaproxy.org/docs/desktop/start/features/scripts/)
[zaproxy/community-scripts: A collection of ZAP scripts and tips provided by the community - pull requests very welcome!](https://github.com/zaproxy/community-scripts/tree/main)

## 種類
スクリプトの目的や実行タイミングによって下記のいずれかを選択してスクリプトを作成します。
|種類||
|---|---|
|Active Rules|アクティブスキャンの診断を自作|
|Encode/Decode|
|Extender|GUIの項目やAPIの追加|
|Fuzzer HTTP Processor|
|Fuzzer WebSocket Processor|
|HTTP Sender|全リクエスト送信またはレスポンス受信時に実行可能。リクエスト送信時にCookieを追加したり、Authorizationをセットして認証対応したりできる。|
|Selenium|BrowserがZAPで起動されるときに実行。認証対応で利用される。|
|Stand Alone|API等でスクリプト開始の指示を行ったときに実行される。|
|Targeted|
|WebSocket Passive Rules|
|WebSocket Sender|
|セッション管理|セッション管理設定で「Script-Based Session Management」選択時に利用|
|プロキシ|
|ぺイロード生成|
|認証|「Script-Based Authentication」で利用。複雑な認証に対応|
|静的スキャンルール|静的スキャンの診断を自作|

本記事では、スクリプトの作成方法・利用方法、認証スクリプトの利用例をご紹介します。

# スクリプトの作成方法
スクリプトは下記言語で開発できますが、サンプルのほとんどがJavaScriptで作成されており、他言語での作成は参考にするものがあまりないため、JavaScriptを使用するのが無難です。本記事でもJavaScriptを利用します。

- ECMAScript / JavaScript (using Nashorn engine, included by default)
- Zest (included by default)
- Groovy
- Kotlin
- Python
- Ruby

[ZAP – Scripts](https://www.zaproxy.org/docs/desktop/start/features/scripts/)

GUIの左上の「ファイル」、「モード設定」の下に表示される「スクリプト」をクリックするとスクリプトの作成・編集等が行える画面を表示できます。

![ZAP-GUI-Script.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3877501/46339c1a-7488-4722-3764-cbce3eb888b0.png)

新しくスクリプトを作成するには、「プラス付きのスクリプトアイコン」をクリックするか、左側に表示されている階層構造になっている部分の「Scripting - スクリプト」または「Scripting - Templates」の中の項目にカーソルを合わせ、左クリックし、「新規スクリプト」をクリックします。そして表示される画面で下記項目を設定します。

|項目||
|---|---|
|スクリプト名||
|タイプ||
|Script Engine|デフォルトでは、Graal.js、Mozilla Zestが選択できます。一般的なJavaScriptのスクリプトを実行する際にはGraal.jsを選択します。ZestはGUIから「Record a new Zest script」機能を使うと便利です。（[ZAP – Zest](https://www.zaproxy.org/docs/desktop/addons/zest/)）|
|テンプレート||
|説明||
|開始時にロード|スクリプト開始時にスクリプトの読み込みを実行するので、スクリプト更新後であっても再ロードする必要がなくなります|
|有効|有効にすると、スクリプトが実行されます。スクリプトの種類によっては本項目は設定できません|

![new-script.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3877501/5c86fbf4-4155-a03a-b2fb-8f9597096691.png)

「保存」をクリックすると、選択した「テンプレート」が読み込まれ、作成したスクリプトが表示され、スクリプトコンソールで編集可能になります。
ZAP GUIのスクリプトコンソールで作成してもいいのですが、見づらいので私はVSCodeで作成して、そのあとスクリプトコンソールに貼りつけ、またはAPIでアップロードして利用しています。
## デバッグ
スクリプトを実行したときにログを見やすいのはZAP GUIなので、作成後ZAP GUIで実行して、意図した通りに動作しているかログを確認します。
スクリプト内でprint()を実行すると、スクリプトコンソール下部にprint関数の引数の文字列が表示されます。これを利用して、分岐が意図した通り動いているか、値が意図した値になっているか等を確認できます。
認証スクリプトを設定し、スパイダーを実行すると、認証スクリプトが沢山実行され、ログがめちゃくちゃ早く流れたりするので、そこも踏まえてログ出力します。

ログ日時がわかるとデバッグしやすいので、下記スクリプト内のdoLog(text)というfunctionを参考にログ出力するのがおすすめです。

[community-scripts/authentication/GetsWithRedirectThenPost.js at main · zaproxy/community-scripts](https://github.com/zaproxy/community-scripts/blob/main/authentication/GetsWithRedirectThenPost.js)

```
function getNow() {
  var objToday = new Date(),
      curYear = objToday.getFullYear(),
      curMonth = objToday.getMonth() < 10 ? "0" + objToday.getMonth() : objToday.getMonth(),
      dayOfMonth = (objToday.getDate() < 10) ? '0' + objToday.getDate() : objToday.getDate(),
      curHour = objToday.getHours() < 10 ? "0" + objToday.getHours() : objToday.getHours(),
      curMinute = objToday.getMinutes() < 10 ? "0" + objToday.getMinutes() : objToday.getMinutes(),
      curSeconds = objToday.getSeconds() < 10 ? "0" + objToday.getSeconds() : objToday.getSeconds(),
      today = curYear + '.' + curMonth + '.' + dayOfMonth + "_" + curHour + ":" + curMinute + ":" + curSeconds
  return today;
}

function doLog(text) {
  print(getNow() + " authent: " + text);
}
```

また、addAuthMessageToHistory()を実行すると、ZAP GUI下部に表示される「履歴」タブから、認証スクリプト内で実行したHTTPリクエストとレスポンスの内容を確認できます。
```
helper.sendAndReceive(msg, false);
AuthenticationHelper.addAuthMessageToHistory(msg);
```

# スクリプトの利用方法
次に作成したスクリプトをどのように利用するかをGUI、API、Automation Frameworkの順に説明します。
## GUI
Targeted, HUD, Stand Alone, セッション管理、認証以外の種類のスクリプトは作成時に「有効」を選択するか、作成後GUI上に表示される「Scripting - スクリプト - <種類>」内のファイル名を左クリックし、「有効にする」を選択することで、スクリプトの種類に応じたタイミングで実行されます。
Stand Aloneスクリプトはスクリプトを選択した状態でスクリプトコンソール上部に表示されている「実行」をクリックすることで、実行できます。セッション管理、認証はそれぞれの設定画面で「Script-based Session Management」、「Script-based Authentication」を選択することで作成したスクリプトが実行されるように設定できます。
## API
DockerでGUIなしで起動している場合には、APIまたはAutomation Frameworkでスクリプトの設定をする必要があります。
ZAP GUIでスクリプトを作成していない場合、まずスクリプトをAPIでアップロードする必要があります。ファイルアップロードは下記APIを使用します。アップロードAPIの利用はファイルアップロードを有効化する必要があります。GUIではオプションのAPIタブ内で「File Transfer Enabled」をチェックします。Dockerの場合、起動時に -configオプションでapi.filexfer=trueとします。

[coreOtherFileUpload – API Reference](https://www.zaproxy.org/docs/api/#coreotherfileupload)

PythonでスクリプトアップロードのAPIを実行する例です。
```py
import requests

local_filename = "" # ローカルにあるスクリプトファイルのパス
api_key = "" # ZAPのapi key
owasp_host = "" # ZAPのホスト名またはIPアドレス
request_url = f"http://{owasp_host}:8090/OTHER/core/other/fileUpload/"
files = {
    'fileContents': ("", open(local_filename, "rb"), "multipart/form-data"),
    'apikey': (None, api_key),
    'fileName': (None, script_name)
}
request_headers = {'Accept': 'application/json', 'X-ZAP-API-Key': api_key}
response = requests.post(request_url, files=files, headers=request_headers)
```

ファイルアップロード後のアップロード先ディレクトリはTransfer Directoryになります。
Transer Directoryの設定はGUIでは、オプションのAPIタブ内で設定でき、Dockerの場合 -configオプションでapi.xferdir=<Transer Directory> として設定できます。
下記はDockerコンテナ起動時のコマンド例です。

```bash
docker run -v $(pwd):/zap/wrk/:rw -t zaproxy/zap-stable zap.sh -daemon -config api.xferdir=/zap/scripts -config api.filexfer=true
```

ZAP内へのアップロードが完了後、スクリプトを利用するためにはスクリプトをロードする必要があります。ロードするAPIが公式ドキュメント内に見つからなかったのですが、PythonのSDK内のコードを見る限り、下記URL、パラメータで設定できるようです。

URL: /script/action/load/
パラメータ: {'scriptName': scriptname, 'scriptType': scripttype, 'scriptEngine': scriptengine, 'fileName': filename}

PythonのSDKを利用した例です。
```py
script_engine = 'Graal.js'
script_filename = os.path.join("/zap/scripts", script_name)
zap.script.remove(scriptname=script_name)
self.upload_script_file(script_name, script_type)
response = zap.script.load(
    script_name, script_type,
    script_engine, script_filename
)
```

スクリプトをロードすることで、GUIで「有効」にしたのと同じ状態になり、種類に応じたタイミングでスクリプトが実行されます。

## Automation Framework
Automation Framework (AF) でスクリプトを実行する場合も、GUIでスクリプトを作成するか、APIでアップロードする必要があります。APIではその後、スクリプトをロードするAPIを実行しましたが、AFでは設定ファイルに記述します。
下記は/zap/scripts/httpsender.jsというファイル名のhttpsenderのスクリプトをZAP内にロードする設定例です。actionでaddを選択することでスクリプトがロードされ、有効化されます。

```yaml
jobs:
  - type: script
    parameters:
      action: "add"
      type: "httpsender"  
      engine: "Graal.js"
      name: "httpsender.js"  
      source: "/zap/scripts/httpsender.js"
```

# 認証スクリプト
認証設定で認証方法を「Script-Based Authentication」に設定した場合に利用されます。
スパイダーやアクティブスキャン実行時、認証が必要なときに実行されます。
## 作成方法
作成は使用用途の近いcommunity-scriptsを参考にするのがいいと思います。

[community-scripts/authentication at main · zaproxy/community-scripts](https://github.com/zaproxy/community-scripts/tree/main/authentication)

下記項目を作成する必要があります。
- function authenticate(helper, paramsValues, credentials)
    この関数内にログインするために必要な処理を書きます
- function getRequiredParamsNames() { return []; }
    return内にauthenticate()内で必要なパラメータをArray形式で書きます
- function getOptionalParamsNames() { return []; }
    return内にauthenticate()内で使用するパラメータ（Optional）をArray形式で書きます
- function getCredentialsParamsNames() { return []; }
    return内にauthenticate()内で必要な認証パラメータ（username, password等）をArray形式で書きます

## 使用方法
### GUI
前回の記事で紹介した、他の認証設定と同じ画面で、認証方法「Script-based Authentication」を選択します。
GUIでは、作成したスクリプトがドロップダウン形式で選択できるので、使用するスクリプトを選択し、「Load」をクリックします。
getRequiredParamsNames()またはgetOptionalParamsNames()を設定した場合は、パラメータ名が表示されるので、それぞれの値を入力します。
getCredentialsParamsNames()については、他の認証設定と同様に、ユーザーを作成することで、ユーザーのusername/passwordがスクリプトで使用できるようになります。

### API
上記「スクリプトの利用方法 - API」内で記載した、スクリプトファイルのアップロードを行い、script typeをauthenticationとして、スクリプトのロードを行います。
次にスクリプトで使用するパラメータ等を設定する「authmethodconfigparams」の文字列を作成します。
username, password以外でスクリプト内で使用するパラメータのパラメータ名と値を設定します。
```authmethodconfigparams
scriptName=<script name>&<parameter name1>=<parameter value1>&<parameter name2>=<parameter value2>...
```
PythonのSDKを利用して設定した例です。
```py
from zapv2 import ZAPv2
zap = ZAPv2(apikey=api_key, proxies={'http': zap_url, 'https': zap_url})
zap.authentication.set_authentication_method(
    contextid=contextid, authmethodname="scriptBasedAuthentication",
    authmethodconfigparams=authmethodconfigparams
)   
```


### Automation Framework
下記は/zap/scripts/authentication_script.jsを認証スクリプトとして利用する例です。認証スクリプトで使用するユーザーの認証情報はusers内のcredentialsに設定します。スクリプト内で使用するほかのパラメータはcontexts-authentication-paramters内に定義できます。ここではparamaterName1, patameterName2というパラメータを定義しています。

```yaml
---
env:
  contexts:
    - name: "script-test"
      urls: []
      includePaths: []
      excludePaths: []
      authentication:
        method: "script"
        parameters:
          script: "/zap/scripts/authentication_script.js"
          scriptEngine: "Graal.js"
          browserId: "firefox-headless"
          parameterName1: "value1"
          parameterName2: "value2"
        verification:
          method: "response"
          loggedInRegex: ""
      sessionManagement:
        method: "cookie"
        parameters: {}
      technology:
        exclude: []
      users:
        - name: "test-user"
          credentials:
            username: ""
            password: ""
  parameters:
    failOnError: true
    failOnWarning: false
    progressToStdout: true
  vars: {}
```

あとは通常のAFでの診断やスパイダーを実行することで、ログイン処理を実行するときにスクリプトが実行されます。

## 利用例
### Cookie
ログインする際に、Cookieに特定の文字列を設定する必要のあるサイトに対しても、認証スクリプトを使用することで対応できます。
下記のスクリプトはパラメータとして、ログインURLとCookieの値を受けとって、それを基にCookieに値を設定して、ログインURLに対して認証情報をPOSTで送信しています。

```cookie_authentication.js
var HttpRequestHeader = Java.type('org.parosproxy.paros.network.HttpRequestHeader');
var HttpHeader = Java.type('org.parosproxy.paros.network.HttpHeader');
var URI = Java.type('org.apache.commons.httpclient.URI');
var AuthenticationHelper = Java.type('org.zaproxy.zap.authentication.AuthenticationHelper');
var ScriptVars = Java.type('org.zaproxy.zap.extension.script.ScriptVars');

function authenticate(helper, paramsValues, credentials) {
    doLog("Authenticating via JavaScript script...");
    var loginURL = paramsValues.get("Login URL");
    var cookieValue = paramsValues.get("Required Cookie Value");
    msg = doPost(helper, credentials, loginURL, cookieValue);
    AuthenticationHelper.addAuthMessageToHistory(msg);
    return msg;
}
            
function doPost(helper, credentials, postURL, cookieValue) {
    var requestUri = new URI(postURL, false);
    var requestMethod = HttpRequestHeader.POST;
    var requestBody = "username=" + encodeURIComponent(credentials.getParam("username"));
    requestBody += "&password=" + encodeURIComponent(credentials.getParam("password"));
    var requestHeader = new HttpRequestHeader(requestMethod, requestUri, HttpHeader.HTTP11);
    var msg = helper.prepareMessage();
    msg.setRequestHeader(requestHeader);
    msg.getRequestHeader().setHeader(HttpHeader.COOKIE, cookieValue)
    msg.setRequestBody(requestBody);
    msg.getRequestHeader().setContentLength(msg.getRequestBody().length());
    helper.sendAndReceive(msg, true);
    doLog("Received response status code: " + msg.getResponseHeader().getStatusCode());
    return msg;
}

function getRequiredParamsNames() {
    return [
        "Login URL",
        "Required Cookie Value"
        ];
}

function getOptionalParamsNames(){
    return [];
}

function getCredentialsParamsNames() {
    return ["username", "password"];
}

function getNow() {
    var objToday = new Date(),
        curYear = objToday.getFullYear(),
        curMonth = objToday.getMonth() < 10 ? "0" + objToday.getMonth() : objToday.getMonth(),
        dayOfMonth = (objToday.getDate() < 10) ? '0' + objToday.getDate() : objToday.getDate(),
        curHour = objToday.getHours() < 10 ? "0" + objToday.getHours() : objToday.getHours(),
        curMinute = objToday.getMinutes() < 10 ? "0" + objToday.getMinutes() : objToday.getMinutes(),
        curSeconds = objToday.getSeconds() < 10 ? "0" + objToday.getSeconds() : objToday.getSeconds(),
        today = curYear + '.' + curMonth + '.' + dayOfMonth + "_" + curHour + ":" + curMinute + ":" + curSeconds
    return today;
}

function doLog(text) {
    print(getNow() + " authent: " + text);
}

```
### Amazon Cognito
AmazonのCognitoのAPIを利用して認証しているサイトの場合にも認証スクリプトが使える場合があります。
下記は、SRP認証フローに対応したときのスクリプトです。（[SRP認証フロー](https://qiita.com/wanwan_fox/items/d9c3a5010f804dafc2d1#3-respondtoauthchallenge-password_verifier)）
認証スクリプト内でSRP_Aを計算するのが難しそうだったため、SRP_Aを計算するAPIを自作して、そこから値を取ってくる形にしています。
```cognito_authentication.js
var HttpRequestHeader = Java.type('org.parosproxy.paros.network.HttpRequestHeader');
var HttpHeader = Java.type('org.parosproxy.paros.network.HttpHeader');
var URI = Java.type('org.apache.commons.httpclient.URI');
var AuthenticationHelper = Java.type('org.zaproxy.zap.authentication.AuthenticationHelper');

function authenticate(helper, paramsValues, credentials) {
    var siteURL = paramsValues.get("site_url");
    var APIURL = paramsValues.get("api_url");
    var cognitoAuthURL = paramsValues.get("cognito_authenticatioin_url");;
    var msg = doGet(cognitoAPIURL + "srp_a", helper);
    var srp_a = msg.getResponseBody().toString();
    var client_id = paramsValues.get("client_id");
    var requestBody = JSON.stringify({
        AuthFlow: "USER_SRP_AUTH",
        ClientId: client_id,
        AuthParameters: {
            USERNAME: credentials.getParam("username"),
            SRP_A: srp_a
        },
        ClientMetadata: {}
    });

    msg = doPost(helper, cognitoAuthURL, requestBody);
    AuthenticationHelper.addAuthMessageToHistory(msg);
    var parsedResponse = JSON.parse(msg.getResponseBody().toString());
    var pool_id = paramsValues.get("pool_id");;
    var user_id_for_srp = parsedResponse.ChallengeParameters.USER_ID_FOR_SRP;
    var srp_b = parsedResponse.ChallengeParameters.SRP_B;
    var salt = parsedResponse.ChallengeParameters.SALT;
    var secret_block = parsedResponse.ChallengeParameters.SECRET_BLOCK;
    requestBody = "client_id=" + encodeURIComponent(client_id);
    requestBody += "&pool_id=" + encodeURIComponent(pool_id);
    requestBody += "&user_id_for_srp=" + encodeURIComponent(user_id_for_srp);
    requestBody += "&srp_b=" + encodeURIComponent(srp_b);
    requestBody += "&salt=" + encodeURIComponent(salt);
    requestBody += "&secret_block=" + encodeURIComponent(secret_block);

    msg = doPost(helper, cognitoAPIURL + "challenge_parameters", requestBody);
    AuthenticationHelper.addAuthMessageToHistory(msg);
    parsedResponse = JSON.parse(msg.getResponseBody().toString());

    var requestBody = JSON.stringify({
        ChallengeName: "PASSWORD_VERIFIER",
        ClientId: client_id,
        ChallengeResponses: {
            USERNAME: user_id_for_srp,
            PASSWORD_CLAIM_SECRET_BLOCK: parsedResponse.PASSWORD_CLAIM_SECRET_BLOCK,
            TIMESTAMP: parsedResponse.TIMESTAMP,
            PASSWORD_CLAIM_SIGNATURE: parsedResponse.PASSWORD_CLAIM_SIGNATURE
        },
        ClientMetadata: {}
    });
    msg = doPost(helper, credentials, cognitoAuthURL, requestBody);
    AuthenticationHelper.addAuthMessageToHistory(msg);
    msg = doGet(siteURL, helper);
    AuthenticationHelper.addAuthMessageToHistory(msg);
    return msg;
}

function doGet(url, helper) {
    var requestUri = new URI(decodeURIComponent(url), false);
    var requestMethod = HttpRequestHeader.GET;
    var requestHeader = new HttpRequestHeader(requestMethod, requestUri, HttpHeader.HTTP11);
    var msg = helper.prepareMessage();
    msg.setRequestHeader(requestHeader);
    msg.getRequestHeader().setContentLength(msg.getRequestBody().length());
    helper.sendAndReceive(msg, false);
    return msg;
}

function doPost(helper, postURL, requestBody) {
    var requestUri = new URI(postURL, false);
    var requestMethod = HttpRequestHeader.POST;
    var requestHeader = new HttpRequestHeader(requestMethod, requestUri, HttpHeader.HTTP11);
    var msg = helper.prepareMessage();
    msg.setRequestHeader(requestHeader);
    msg.getRequestHeader().setHeader(HttpHeader.CONTENT_TYPE, "application/json");
    msg.setRequestBody(requestBody);
    msg.getRequestHeader().setContentLength(msg.getRequestBody().length());
    helper.sendAndReceive(msg, true);
    return msg;
}

function getRequiredParamsNames() {
    return [
        "user_pool_id",
        "api_url",
        "cognito_authenticatioin_url",
        "client_id",
        "site_url"
        ];
}

function getOptionalParamsNames(){
    return [];
}

function getCredentialsParamsNames() {
    return ["username", "password"];
}

function getNow() {
    var objToday = new Date(),
        curYear = objToday.getFullYear(),
        curMonth = objToday.getMonth() < 10 ? "0" + objToday.getMonth() : objToday.getMonth(),
        dayOfMonth = (objToday.getDate() < 10) ? '0' + objToday.getDate() : objToday.getDate(),
        curHour = objToday.getHours() < 10 ? "0" + objToday.getHours() : objToday.getHours(),
        curMinute = objToday.getMinutes() < 10 ? "0" + objToday.getMinutes() : objToday.getMinutes(),
        curSeconds = objToday.getSeconds() < 10 ? "0" + objToday.getSeconds() : objToday.getSeconds(),
        today = curYear + '.' + curMonth + '.' + dayOfMonth + "_" + curHour + ":" + curMinute + ":" + curSeconds
    return today;
}

function doLog(text) {
    print(getNow() + " authent: " + text);
}

```