tezu memo blog

日々行った作業をメモしていきます

zircote/swagger-php v4 upgradeでWarning: Required @OA\Info() not found

事象

Dependabotが作成するPRの対応で発生

./vendor/bin/openapi ./app/Http/ -o ./openapi.yml
Warning: Required @OA\Info() not found

3系から4系へのupgrade

原因

@OA\Infoを定義しているPHPがClassでは無くFile

app/Http/open-api.php

<?php
/**
 * @OA\Info(
 *     title="xxxx",
 *     version="v1.0"
 * )
 */

対応

Migrating to v4に書いてありました

github.com

Annotations now must be associated with either a class/trait/interface, method or property.

FileからClassに変更

app/Http/OpenApiSpec.php

<?php
namespace app\Http;

/**
 * @OA\Info(
 *     title="xxxx",
 *     version="v1.0"
 * )
 */
class OpenApiSpec
{
}

github.com

Migration to v4のリンクが404ですが、場所を移動したみたいです

OWASP ZAP Docker user-agentを変更

はじめに

CircleCIでテスト実行時にAWS WAFにIPを追加していたが、AWS WAF v2にupgradeしたところ、IPを認識する時間が長くなったので、Allowの条件をIPからuser-agentに変更した

github.com
を見る限りはoption指定出来そう

public static final String DEFAULT_USER_AGENT = CONNECTION_BASE_KEY + ".defaultUserAgent";

方法

-z "-config connection.defaultUserAgent='任意のUA'" を指定

例)

$ docker run -v $(pwd):/zap/wrk/:rw -t owasp/zap2docker-stable zap-baseline.py -t "https://tezu.hatenablog.com/" -z "-config connection.defaultUserAgent='app-name/1.0.0 (OWASP ZAP)'"

OWASP ZAP DockerをCircleCIで実行

はじめに

CircleCIで初めてリモートDocker(setup_remote_docker)を使った。それなりにハマったので整理

IPがプライマリとリモートで異なる

テスト対象はdevelop環境なので、AWS WAFでIP制限をしている

既にCypressでE2Eテストを実行しているので、実施前後でWAFにIP追加(削除)の実績有りだったが、IPが異なるのでハマった
WAFのIP追加はcommandsで共通化しているので、parametersにリモート有無を追加

  • リモート $DOCKER_HOST
  • プライマリ コンテナ curl -s ifconfig.me
commands:
  waf-add-address:
    parameters:
      is-remote:
        type: boolean
        default: false
    steps:
      - run:
          name: waf-add-address
          command: |
            if << parameters.is-remote >> ; then
                IP=`echo $DOCKER_HOST | grep -Eo '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}'`
            else
                IP=`curl -s ifconfig.me`
            fi
            IPSETID=`aws waf list-ip-sets | jq -r '.IPSets[] | select(.Name == "allow-ip-address-circleci") | .IPSetId'`
            TOKEN=`aws waf get-change-token | jq -r .ChangeToken`
            aws waf update-ip-set --ip-set-id ${IPSETID} --change-token ${TOKEN} --updates "Action="INSERT",IPSetDescriptor={Type="IPV4",Value="${IP}/32"}"
jobs:
  e2e-test:
    steps:
      - waf-add-address
      # Cypress実行
      - waf-delete-address
  penetration-test:
    steps:
      - waf-add-address:
          is-remote: true
      # OWASP ZAP実行
      - waf-delete-address:
          is-remote: true

環境変数が取得出来ない

circleci.com

CircleCI は環境変数の設定時の挿入をサポートしませんが、BASH_ENV を使用して、現在のシェルに変数を設定することは可能です。 これは、PATH を変更するときや、他の変数を参照する環境変数を設定するときに便利です。

PHPUnitカバレッジ計測した結果をSlack通知する際に網羅率を含めて、リポートを見るトリガーにしている(100%だったらリポートを見る必要が無いので)
XMLから取得して環境変数に設定後に、Slack通知するcommandの引数に指定している

jobs:
  unit-test-and-report:
      - run:
          name: Run PHPUnit
          command: |
            mkdir -p reports
            vendor/bin/phpunit --dump-xdebug-filter xdebug-filter.php
            vendor/bin/phpunit --configuration phpunit.xml --prepend xdebug-filter.php --log-junit reports/junit/results.xml --coverage-html coverage --coverage-xml coverage -d memory_limit=-1
            echo "export COVERAGE_PERCENT=`xmllint --xpath "string(/*[local-name()='phpunit']/*[local-name()='project']/*[local-name()='directory']/*[local-name()='totals']/*[local-name()='lines']/@percent)" coverage/index.xml`" >> $BASH_ENV
      - notify-slack-for-coverage:
          url: https://$CIRCLE_BUILD_NUM-XXXXXXXXX-gh.circle-artifacts.com/0/coverage/index.html
          percent: $COVERAGE_PERCENT

同じ要領でリモートDockerのIPを取得して、環境変数に設定したがcommands側で取得出来ない。CIDR表記不正でエラー

上述の通り、$DOCKER_HOSTからIPを取得で解決

/32' at 'updates.1.member.iPSetDescriptor.value' failed to satisfy constraint: 
Member must satisfy regular expression pattern: .*\S.*
commands:
  waf-add-address:
    parameters:
      ip:
        type: string
    steps:
      - run:
          name: waf-add-address
          command: |
            IPSETID=`aws waf list-ip-sets | jq -r '.IPSets[] | select(.Name == "allow-ip-address-circleci") | .IPSetId'`
            TOKEN=`aws waf get-change-token | jq -r .ChangeToken`
            aws waf update-ip-set --ip-set-id ${IPSETID} --change-token ${TOKEN} --updates "Action="INSERT",IPSetDescriptor={Type="IPV4",Value="<< parameters.ip >>/32"}"

jobs:
  penetration-test:
    steps:
      - run:
          name: Set IP Address
          command: |
            echo "export IP_ADDR=`docker run -t owasp/zap2docker-stable curl inet-ip.info`" >> $BASH_ENV
      - waf-add-address:
          ip: $IP_ADDR

OWASP ZAPアラート有りでCircleCIのJobがFail

OWASP ZAPでアラートがある場合はstatusが0以外となりJobがFailする
後続Jobを実行することは when: always 指定で回避出来るが、CircleCI管理コンソールで実行結果を見た際に真っ赤になるので、exit 0としたい

FAIL-NEW: 0  FAIL-INPROG: 0  WARN-NEW: 1 WARN-INPROG: 0  INFO: 0 IGNORE: 0   PASS: 37

Exited with code exit status 2
CircleCI received exit code 2

www.zaproxy.org

-I do not return failure on warning

-Iを指定することで回避

コードを見て理解できました
https://github.com/zaproxy/zaproxy/blob/main/docker/zap-baseline.py#L443 https://github.com/zaproxy/zaproxy/blob/main/docker/zap-baseline.py#L646

shell: /bin/shset +e はダメでした

リモートDockerのボリュームをマウントできない

circleci.com

ジョブ空間からリモート Docker 内のコンテナにボリュームをマウントすること (およびその逆) はできません。

SSHで直接実行するとNGのケースでも動作したので少しハマった

NG

jobs:
  penetration-test:
      - run:
          name: Run OWASP ZIP
          working_directory: ./test-penetration
          command: |
            docker run -u root -v $(pwd):/zap/wrk/:rw -t owasp/zap2docker-stable zap-baseline.py -t "テスト対象のURL" -I -r owasp-report.html -J owasp-report.json
      - run:
          name: copy report remote to local
          working_directory: ./test-penetration
          command: |
             docker run -u root -v $(pwd):/zap/wrk/:rw -t owasp/zap2docker-stable cat /zap/wrk/project/test-penetration/owasp-report.html > owasp-report.html
             docker run -u root -v $(pwd):/zap/wrk/:rw -t owasp/zap2docker-stable cat /zap/wrk/project/test-penetration/owasp-report.json > owasp-report.json

OK

jobs:
  penetration-test:
      - run:
          name: Run OWASP ZIP
          working_directory: ./test-penetration
          command: |
            docker create -v /zap/wrk --name remote-owasp alpine:3.4 /bin/true
            docker run -u root --volumes-from remote-owasp owasp/zap2docker-stable zap-baseline.py -t "テスト対象のURL" -I -r owasp-report.html -J owasp-report.json
      - run:
          name: copy report remote to local
          working_directory: ./test-penetration
          command: |
            docker cp remote-owasp:/zap/wrk/owasp-report.html owasp-report.html
            docker cp remote-owasp:/zap/wrk/owasp-report.json owasp-report.json

Laravel Validation Test Requestクラスのみを対象

はじめに

バリデーションのテストをしたいのですが、 テスト 5.2 Laravel だと、bladeファイルも必要なので ちょっと違うんですよね

テスト対象

一からコードを書くのが面倒なので、以下を購入してイジって遊んでます

codecanyon.net

BlogRequestはjosh adminのコード

<?php

namespace App\Http\Requests;

use App\Http\Requests\Request;

class BlogRequest extends Request {

    /**
    * Determine if the user is authorized to make this request.
    *
    * @return bool
    */
    public function authorize()
    {
        return true;
    }

    /**
    * Get the validation rules that apply to the request.
    *
    * @return array
    */
    public function rules()
    {
        return [
            'title' => 'required|min:3',
            'content' => 'required|min:3',
            'blog_category_id' => 'required',
        ];
    }

}

テストケース

サービスコンテナにMockのリクエストをセットするのがポイント

<?php
use App\Http\Requests\BlogRequest;
use Illuminate\Http\Exception\HttpResponseException;
use Illuminate\Http\Request;

class BlogRequestTest extends TestCase
{
// php artisan make:test App\Http\Requests\BlogRequestTest

    /**
     * @return void
     * @test
     */
    public function 未入力()
    {
        try {
            // App::makeでvalidationが実行されてしまう。。
            App::make(BlogRequest::class);
        } catch (HttpResponseException $e) {
            $messages = $this->getMessages();
            $this->assertEquals('The title field is required.', $messages['title'][0]);
            $this->assertEquals('The content field is required.', $messages['content'][0]);
            $this->assertEquals('The blog category id field is required.', $messages['blog_category_id'][0]);
        } catch (Exception $e) {
            $this->fail("HttpResponseExceptionが発生すべき");
        }
    }

    /**
     * @return void
     * @test
     */
    public function 最小値_直前の値()
    {
        $this->bindRequest([
            'title' => '11',
            'content' => '22',
            'blog_category_id' => 1
        ]);

        try {
            App::make(BlogRequest::class);
        } catch (HttpResponseException $e) {
            $messages = $this->getMessages();
            $this->assertEquals('The title must be at least 3 characters.', $messages['title'][0]);
            $this->assertEquals('The content must be at least 3 characters.', $messages['content'][0]);
            $this->assertFalse(isset($messages['blog_category_id']));
        } catch (Exception $e) {
            $this->fail("HttpResponseExceptionが発生すべき");
        }
    }

    /**
     * @return void
     * @test
     */
    public function 最小値()
    {
        $this->bindRequest([
            'title' => '111',
            'content' => '222',
            'blog_category_id' => 1
        ]);

        try {
            App::make(BlogRequest::class);
        } catch (HttpResponseException $e) {
            $this->fail("全て正常値。HttpResponseExceptionは発生しない");
        } catch (Exception $e) {
            $this->fail("全て正常値。Exceptionは発生しない");
        }
    }

    /**
     * @param $params array
     */
    private function bindRequest($params)
    {
        // vendor/laravel/framework/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php
        // でサービスコンテナからrequestを取り出してRequestクラスにパラメータをセットしている
        //
        // $this->initializeRequest($request, $app['request']);
        //
        // protected function initializeRequest(FormRequest $form, Request $current)
        // $form->initialize(
        //    $current->query->all(), $current->request->all(), $current->attributes->all(),
        //    $current->cookies->all(), $files, $current->server->all(), $current->getContent()
        // );

        // サービスコンテナにMockのリクエストをセット
        App::instance('request', Request::create('', 'POST', $params, [], [], [], []));
    }

    /**
     * @return array
     */
    private function getMessages()
    {
        /**
         * @var \Illuminate\Support\ViewErrorBag $viewErrorBag
         * @var \Illuminate\Support\MessageBag $messageBag
         */
        $viewErrorBag = Session::get('errors');
        $messageBag = $viewErrorBag->getBag('default');
        return $messageBag->getMessages();
    }
}

Laravel Monolog Cascadeを使用してログレベル毎に出力するファイルを変更する

概要

ログレベル毎に出力するファイルを変更したい

Laravel標準ではdailyでログファイルをローテートは出来る程度

.env

APP_LOG=daily

storage/logs

laravel-2016-10-04.log
laravel-2016-10-05.log
laravel-2016-10-06.log

Monolog Cascadeを使用して、ログレベル毎に出力するファイルを変更する github.com

手順

インストール

composer.jsonに以下を追記後、composer updateを実行

"require": {
    "theorchard/monolog-cascade": "^0.4.0"
}

設定

https://github.com/theorchard/monolog-cascade Configuration structureを参考に作成

config/monolog.php

<?php

return [
    'version' => 1,

    'formatters' => [
        'dashed' => [
            'format' => "%datetime%-%channel%.%level_name% - %message% %context% %extra%\n",
            'include_stacktraces' => true
        ],
    ],
    'handlers' => [
        //-------------------------------------------------
        // Admin
        // ------------------------------------------------
        'admin_info' => [
            'class' => 'Monolog\Handler\RotatingFileHandler',
            'level' => 'INFO',
            'filename' => env('LOG_ADMIN_INFO_PATH', storage_path('logs/admin_info.log')),
            'maxFiles' => 5,
            'formatter' => 'dashed',
            'processors' => ['web_processor', 'introspection_processor', 'login_user_processor'],
        ],

        'admin_error' => [
            'class' => 'Monolog\Handler\RotatingFileHandler',
            'level' => 'ERROR',
            'filename' => env('LOG_ADMIN_ERROR_PATH', storage_path('logs/admin_error.log')),
            'maxFiles' => 5,
            'formatter' => 'dashed',
            'processors' => ['web_processor', 'introspection_processor', 'login_user_processor'],
        ],

        // WARNINGのみ出力
        '_admin_warning' => [
            'class' => 'Monolog\Handler\RotatingFileHandler',
            'level' => 'WARNING',
            'filename' => env('LOG_ADMIN_WARNING_PATH', storage_path('logs/admin_warning.log')),
            'maxFiles' => 5,
            'formatter' => 'dashed',
            'processors' => ['web_processor', 'introspection_processor', 'login_user_processor'],
        ],
        'admin_warning' => [
            'class' => 'Monolog\Handler\FilterHandler',
            'handler' => '_admin_warning',
            'minLevelOrList' => ['WARNING'],
        ],

        //-------------------------------------------------
        // Front
        // ------------------------------------------------
        'front_info' => [
            'class' => 'Monolog\Handler\RotatingFileHandler',
            'level' => 'INFO',
            'filename' => env('LOG_FRONT_INFO_PATH', storage_path('logs/front_info.log')),
            'maxFiles' => 5,
            'formatter' => 'dashed',
            'processors' => ['web_processor', 'introspection_processor'],
        ],

        'front_error' => [
            'class' => 'Monolog\Handler\RotatingFileHandler',
            'level' => 'ERROR',
            'filename' => env('LOG_FRONT_ERROR_PATH', storage_path('logs/front_error.log')),
            'maxFiles' => 5,
            'formatter' => 'dashed',
            'processors' => ['web_processor', 'introspection_processor'],
        ],

        // WARNINGのみ出力
        '_front_warning' => [
            'class' => 'Monolog\Handler\RotatingFileHandler',
            'level' => 'WARNING',
            'filename' => env('LOG_FRONT_WARNING_PATH', storage_path('logs/front_warning.log')),
            'maxFiles' => 5,
            'formatter' => 'dashed',
            'processors' => ['web_processor', 'introspection_processor'],
        ],
        'front_warning' => [
            'class' => 'Monolog\Handler\FilterHandler',
            'handler' => '_front_warning',
            'minLevelOrList' => ['WARNING'],
        ],
    ],
    'processors' => [
        // IP,URL,HTTP METHOD,referrer
        'web_processor' => [
            'class' => 'Monolog\Processor\WebProcessor'
        ],
        // 出力ファイル名、出力行
        'introspection_processor' => [
            'class' => 'Monolog\Processor\IntrospectionProcessor',
            'skipClassesPartials' => [
                'Monolog\\',
                'Illuminate\\',
            ],
        ],
        // ログイン情報
        'login_user_processor' => [
            'class' => 'App\Services\Monolog\Processor\LoginUserProcessor'
        ],
    ],
    'loggers' => [
        // このキーをMonologSetting::change()で指定
        'admin' => [
            'handlers' => ['admin_info', 'admin_error', 'admin_warning']
        ],
        'front' => [
            'handlers' => ['front_info', 'front_error', 'front_warning']
        ]
    ]
];

Monolog Cascadeに差し替え

LaravelのMonolog Loggerと差し替える

app/Services/Monolog/MonologSetting.php

<?php
namespace App\Services\Monolog;

use Cascade\Cascade;
use Illuminate\Log\Writer;
use Log;

class MonologSetting
{
    /**
     * ログ設定を変更する
     * @param  string $logName 変更名 config/monolog.phpのhandlersキー
     */
    public static function change($logName)
    {
        // 登録済みインスタンスを削除
        Log::clearResolvedInstances();
        app()->instance('log', new Writer(Cascade::getLogger($logName)));
    }
}

Laravelの登録方法を参考にした

https://github.com/laravel/framework/blob/5.3/src/Illuminate/Foundation/Bootstrap/ConfigureLogging.php

<?php

namespace Illuminate\Foundation\Bootstrap;

use Illuminate\Log\Writer;
use Monolog\Logger as Monolog;
use Illuminate\Contracts\Foundation\Application;

class ConfigureLogging
{
    /**
     * Register the logger instance in the container.
     *
     * @param  \Illuminate\Contracts\Foundation\Application  $app
     * @return \Illuminate\Log\Writer
     */
    protected function registerLogger(Application $app)
    {
        $app->instance('log', $log = new Writer(
            new Monolog($app->environment()), $app['events'])
        );

        return $log;
    }
}

Facadeを追加

<?php
namespace App\Facades;

use Illuminate\Support\Facades\Facade;

class MonologSetting extends Facade {

    protected static function getFacadeAccessor() { return 'monolog_setting'; }

}

config\app.php

<?php

return [
    'aliases' => [
        'MonologSetting' => App\Facades\MonologSetting::class, // ここを追加
    ],
];

サービスプロバイダー

MonologSettingのバインドとFront/Admin/コンソールでロガーを切替

artisanコマンドで雛型作成

php artisan make:provider MonologSettingServiceProvider
Provider created successfully.
<?php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use MonologSetting;
use Cascade\Cascade;

class MonologSettingServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap the application services.
     *
     * @return void
     */
    public function boot()
    {
        Cascade::fileConfig(config_path('monolog.php'));

        $changeName = null;
        // コマンド実行時とwebアクセス時で設定を変更
        if (php_sapi_name() == 'cli') {
            $changeName = 'console';
        } elseif (str_is('/admin/*', env('REQUEST_URI'))) {
            $changeName = 'admin';
        }
        if (!is_null($changeName)) {
            MonologSetting::change($changeName);
        }
    }

    /**
     * Register the application services.
     *
     * @return void
     */
    public function register()
    {
        // キーはMonologSettingファサードのgetFacadeAccessor()の戻り値と一致
        \App::bind('monolog_setting', function()
        {
            return new \App\MonologSetting;
        });
    }
}

config\app.php

'providers' => [

    /*
     * Application Service Providers...
     */
    App\Providers\AppServiceProvider::class,
    App\Providers\AuthServiceProvider::class,
    App\Providers\EventServiceProvider::class,
    App\Providers\RouteServiceProvider::class,
    App\Providers\MonologSettingServiceProvider::class, // add
],

ログインユーザ名を出力

認証認可はSentinel Manual :: Cartalystを使用

<?php

namespace App\Services\Monolog\Processor;

use Sentinel;

class LoginUserProcessor
{
    /**
     * @param  array $record
     * @return array
     */
    public function __invoke(array $record)
    {
        if (Sentinel::check()) {
            $user = Sentinel::getUser();
            $record['extra']['username'] = $user->getUserLogin();
        }

        return $record;
    }
}

結果

変更前

storage/logs/laravel-2016-10-13.log

[2016-10-13 07:02:52] local.WARNING: array (
  '_method' => 'PUT',
  '_token' => 'E5ola1t7HaX79kfkEcZW3oNxhdYau10SdxDUuBDc',
  'first_name' => '',
  'last_name' => '',
  'email' => 'admin@xxxxx.xxx',
  'password' => 'xxxxx',
  'password_confirm' => '',
)  
[2016-10-13 07:02:52] local.WARNING: array (
  'first_name' => 
  array (
    0 => 'The first name field is required.',
  ),
  'last_name' => 
  array (
    0 => 'The last name field is required.',
  ),
  'email' => 
  array (
    0 => 'The email has already been taken.',
  ),
)  
[2016-10-13 07:05:51] local.ERROR: exception 'Symfony\Component\HttpKernel\Exception\NotFoundHttpException' in \vendor\laravel\framework\src\Illuminate\Routing\RouteCollection.php:161
Stack trace:
#0 \vendor\laravel\framework\src\Illuminate\Routing\Router.php(821): Illuminate\Routing\RouteCollection->match(Object(Illuminate\Http\Request))

変更後

errorレベル

storage/logs/front_error-2016-10-13.log

2016-10-13 07:31:04-front.ERROR - exception 'Symfony\Component\HttpKernel\Exception\NotFoundHttpException' in vendor\laravel\framework\src\Illuminate\Routing\RouteCollection.php:161
Stack trace:
#0 vendor\laravel\framework\src\Illuminate\Routing\Router.php(821): Illuminate\Routing\RouteCollection->match(Object(Illuminate\Http\Request))

warnレベル

storage/logs/front_warning-2016-10-13.log

2016-10-13 07:31:45-front.WARNING - array (
  '_method' => 'PUT',
  '_token' => 'E5ola1t7HaX79kfkEcZW3oNxhdYau10SdxDUuBDc',
  'first_name' => '',
  'last_name' => '',
  'email' => 'admin@xxxxx.xxx',
  'password' => 'xxxxx',
  'password_confirm' => '',
)
2016-10-13 07:31:45-front.WARNING - array (
  'first_name' => 
  array (
    0 => 'The first name field is required.',
  ),
  'last_name' => 
  array (
    0 => 'The last name field is required.',
  ),
  'email' => 
  array (
    0 => 'The email has already been taken.',
  ),
)