AWS CloudFrontとLambda勉強まとめ

CloudFront

CDN
– Contents Delivery Network
– エッジのキャパシティを利用して効率的かつ高速にコンテンツ配信
→ユーザに最も近いサーバに誘導して、配信を高速化
→エッジサーバではコンテンツのキャッシングを行い、オリジンに負荷をかけない

・最適なエッジへの誘導方法
①ドメイン名問い合わせ(クライアント→DNS)
②IPアドレス問い合わせ(DNS→CloudFrontDNS)
③最適なEdgeアドレス応答(CloudFrontDNS→DNS)
④最適なEdgeへアクセス(クライアントからEdge)
⑤キャッシュがある場合:コンテンツ配信
キャッシュがない場合:オリジンサーバから取得

・CloudFront特徴
– 84拠点のエッジサーバ
– 予測不可能なスパイクアクセスへの対応
– ビルトインのセキュリティ機能(WAF連携、DDoS対策)
– 充実したレポート

・動的コンテンツ:ELBでEC2に負荷分散。HTML
静的コンテンツ:S3などで保存

・84エッジロケーション→11リージョナルキャッシュ→オリジン
→オリジンに対するコンテンツ取得を削減

CloudFront Distribution
– ドメインごとに割り当てられるCloudFrontの設定
– 40Gbpsもしくは100,000RPSを超える場合上限申請必要
– HTTP/2対応
– IPv6対応
– CNAMEエイリアスを利用して独自ドメイン名の指定可能
→Route53と合わせたZone Apex(wwwがないもの)も利用可能

Gzip圧縮機能
エッジでコンテンツをGzip圧縮することでより高速にコンテンツ配信
※S3はGzip圧縮をサポートしていないので有効

キャッシュコントロール
– キャッシュヒット率の向上がCDNのポイント
→URLおよび有効化したパラメータ値の完全一致でキャッシュが再利用

キャッシュの無効化
コンテンツごとの無効化パス指定

ダイナミックコンテンツ機能
オリジンサーバに対して下記情報をフォワードすることで、動的なページの配信にも対応
– ヘッダー(必要最小限)
– Cookie(Cookie名と値をセットでCloudFrontがキャッシュ)
– クエリ文字列パラメータの値

ダイナミックキャッシング
リクエストパターンをもとにオリジンへのアクセスルールを個別指定可能

カスタムエラーページ
4xx系:クライアントエラー。オリジン側で対処
5xx系:サーバエラー。CloudFrontで対処
参考URL:https://goo.gl/NcUQiY

読み取りタイムアウト
CloudFrontがオリジンからの応答を待つ時間を指定
デフォルトは30秒

キープアライブタイムアウト
接続を閉じる前に、CloudFrontがオリジンとの接続を維持する最大時間
デフォルトは5秒

・セキュリティ
– HTTPS対応
– SSL証明書
→専用IPアドレスSSL証明書には申請必要
ビューワーSSLセキュリティポリシー
→クライアントとCloudFront間のSSL/TLSプロトコルとCipherの組み合わせを指定可能
– オリジン暗号化通信
– オリジンカスタムヘッダー
GEOリストリクション
→地域情報でアクセス判定。制御されたアクセスには403を応答
署名付きURL
→プライベートコンテンツ配信。
→署名付きURLを生成する認証サイトにクライアントから認証リクエスト
→認証サイトからEdgeにアクセス※署名付き出ない場合は、403を返す
-オリジンサーバーの保護
→Origin Access Identitiy(OAI)を利用
S3のバケットへのアクセスをCloudFrontからのみに制限
– AWS WAF連携
AWS ShieldによるDDoS攻撃対策
ブロック時は403応答
– AWS ShieldによるDDoS攻撃対策
デフォルトで有効

・CloudFrontレポート・アクセスログ機能
任意のS3バケットに出力可能

・CloudWatchアラームの活用
リアルタイム障害・異常検知

・S3オリジン自動キャッシュの無効化(Invalidation)
S3にアップロード→Lambdaファンクション呼び出し→CloudFront Invalidation APIの呼び出し→CloudFront上でキャッシュの無効化

Lambda

高度にパーソナライズされたウェブサイト
ビューワーリクエストに応じたレスポンス生成
URLの書き換え
エッジでのアクセスコントロール
リモートネットワークの呼び出し

参考URL:https://www.slideshare.net/AmazonWebServicesJapan/aws-blackbelt-online-seminar-2017-amazon-cloudfront-aws-lambdaedge

続きを読む

Alexa Home Skill では Payload Version v3 を利用する

Alexa Home Skill を使ってみようとした時に詰まったのでメモ.

Payload Version v2 のサイトは古い

いくつか説明サイトがあるがレスポンスの
Payload Versionv2にしているものは古いので公式説明ページを参照することをおすすめします.

現在(2018/01/12)は,Lambdaに用意されているalexa-smart-home-skill-adapterテンプレートも古いままで,これをそのまま使うと versionがv2なので Alexa スキルのデフォルト設定の v3 にしているとデバイスを見つけてくれないので注意が必要.公式説明ページにLambdaの書き換えるコードも書いてあります.

追記(実際にアレクサでスキルを試す場合)

Alexa Skill を作ってみる分には公式説明ページで十分でしたが,実際にコードをアレクサで試す場合には公式ページではレスポンスをうまく返せていないのでAlexaは「○○は応答していません」とエラーを返します.そこでLambdaの関数を書き換えました.基本は公式ページのコードを引用しています.

index.js
exports.handler = function(request, context) {
    if (request.directive.header.namespace === 'Alexa.Discovery' && request.directive.header.name === 'Discover') {
        log("DEGUG:", "Discover request", JSON.stringify(request));
        handleDiscovery(request, context, "");
    }
    else if (request.directive.header.namespace === 'Alexa.PowerController') {
        if (request.directive.header.name === 'TurnOn' || request.directive.header.name === 'TurnOff') {
            log("DEBUG:", "TurnOn or TurnOff Request", JSON.stringify(request));
            handlePowerControl(request, context);
        }
    }

    function handleDiscovery(request, context) {
        var payload = {
            "endpoints": [{
                "endpointId": "demo_id",
                "manufacturerName": "Smart Device Company",
                "friendlyName": "電気",
                "description": "smart switch",
                "displayCategories": ["SWITCH"],
                "cookie": {
                    "key1": "arbitrary key/value pairs for skill to reference this endpoint.",
                    "key2": "There can be multiple entries",
                    "key3": "but they should only be used for reference purposes.",
                    "key4": "This is not a suitable place to maintain current endpoint state."
                },
                "capabilities": [{
                        "type": "AlexaInterface",
                        "interface": "Alexa",
                        "version": "3"
                    },
                    {
                        "interface": "Alexa.PowerController",
                        "version": "3",
                        "type": "AlexaInterface",
                        "properties": {
                            "supported": [{
                                "name": "powerState"
                            }],
                            "retrievable": true
                        }
                    }
                ]
            }]
        };
        var header = request.directive.header;
        header.name = "Discover.Response";
        log("DEBUG", "Discovery Response: ", JSON.stringify({ header: header, payload: payload }));
        context.succeed({ event: { header: header, payload: payload } });
    }

    function log(message, message1, message2) {
        console.log(message + message1 + message2);
    }

    function handlePowerControl(request, context) {
        // get device ID passed in during discovery
        var requestMethod = request.directive.header.name;
        // get user token pass in request
        var requestToken = request.directive.endpoint.scope.token;
        var powerResult;

        if (requestMethod === "TurnOn") {

            // Make the call to your device cloud for control 
            // powerResult = stubControlFunctionToYourCloud(endpointId, token, request);
            powerResult = "ON";
        }
        else if (requestMethod === "TurnOff") {
            // Make the call to your device cloud for control and check for success 
            // powerResult = stubControlFunctionToYourCloud(endpointId, token, request);
            powerResult = "OFF";
        }

        var response = {
            "context": {
                "properties": [{
                    "namespace": "Alexa.PowerController",
                    "name": "powerState",
                    "value": powerResult,
                    "timeOfSample": "2017-02-03T16:20:50.52Z",
                    "uncertaintyInMilliseconds": 500
                }]
            },
            "event": {
                "header": {
                    "namespace": "Alexa",
                    "name": "Response",
                    "payloadVersion": "3",
                    "messageId": "5f8a426e-01e4-4cc9-8b79-65f8bd0fd8a4",
                    "correlationToken": "dFMb0z+PgpgdDmluhJ1LddFvSqZ/jCc8ptlAKulUj90jSqg=="
                },
                "payload": {}
            }
        };


        log("DEBUG", "Alexa.PowerController ", JSON.stringify(response));
        context.succeed(response);
    }
};

これで「電気をつけて」とAlexaにお願いすると「はい」と返答してくれます.

続きを読む

一時的なSSOサーバのログイン監視

一時的なSSOサーバのログイン監視

はじめに

AWSのEC2インスタンスにリバースプロキシ型のSSO(Single Sign On)をのせていて、
Zabbixでログイン監視をしていたのですが、
一旦、Zabbixを外すことにしたので、一時的にログイン監視を入れることになったお話です。
※ 説明ながい・・:unamused:

Zabbixってなんすか?

簡単に言いますと、
 サーバー動いてんの?
 ログイン出来ないよ・・
 アプリ死んでない?
 メモリ枯渇してるっぽい・・等、
統合的にまるっとサーバを監視するツールになります。
詳しくはWikiで。
https://ja.wikipedia.org/wiki/Zabbix

このZabbixを使ってログイン監視をしていたけれど、
今回、この便利なZabbixを停止して、
取り急ぎ、ログイン監視だけ組み込んでみました。
※メモリや容量辺りはAWSのCloudWatchでOK:thumbsup:

Jenkins使うかな

やっぱりここはCIツール界のエース Jenkins を活用して、
定期的な監視運用を進める事にしました。
※ cronは古すぎます・・・

ざっくりアーキテクト

Jenkinsで定期的にログイン監視を行い、
ログイン失敗したらSNSへ通知 ⇒ Lamdbaへ依頼 ⇒ Twilioから電話 ⇒ がっかり・・
という流れにしました。
※ Lamdba ⇒ Twilio連携は元から使っていたので

image.png

ログイン監視シェル

Jenkinsから叩くログイン監視シェルはこんな感じ。
ログインしてログアウトして問題無ければ終了ですね。
※ログアウトしとかないとセッション溜まるし。

SNSへの通知もシェルの中で実行しています:v_tone3:


#!/bin/sh

# Topic ARN指定 -> Lamdbaへ依頼するためのTopic
snsname="arn:aws:sns:ap-northeast-1:xxxxxxxxxxxx:telephone-call"

# 第一パラメータ:証明書チェック有無(0:無、1:有)
# 第二パラメータ:SSOサーバURL
# 今回はgetoptも使わず直で指定
if [ $# -ne 2 ]; then
  exit 9
fi

# 第一パラメータが 0 なら証明書チェックをSKIP(-kオプション追加)
sslopt=""
if [ ${1} -eq 0 ]; then
  sslopt="-k"
fi
# 第二パラメータ格納
sso_url=${2}

# cookeファイルパス定義
cookie_file=$(cd $(dirname $0) && pwd)"/tmp/cookie.txt"

# sso login 実行
# ログイン後のページに "SSO TOP Page" が含まれていればログイン成功
# 諸事情によりステータスコードは見ていない
login=`curl -v ${sso_url} -d 'ACCOUNTUID=ADMIN' -d 'PASSWORD=ADMINPASSWD' ${sslopt} -c ${cookie_file} -L | grep "SSO TOP Page" | wc -l`

# login失敗したらSNS Topicへ通知
if [ ${login} != 1 ]; then
  # SNSへ通知 「SSOへのログインに失敗しました。」と電話で連絡
  aws sns publish --region ap-northeast-1 
      --message "SSOへのログインに失敗しました。" 
      --topic-arn ${snsname}
  exit 9;
fi

# logout 実行
logout=`curl ${sso_url} -d 'LOGOUT=SSO_LOGOUT' -b ${cookie_file} ${sslopt} -o /dev/null -w '%{http_code}n'`

# ステータスコードが200以外はエラー
# logout 失敗したらSNS Topicへ通知
if [ ${logout} != 200 ]; then
  # SNSへ通知 「SSOへのログアウトに失敗しました。」と電話で連絡
  aws sns publish --region ap-northeast-1 
      --message "SSOへのログアウトに失敗しました。" 
      --topic-arn ${snsname}
  exit 9;
fi

exit 0;

おわり

Lambda ⇒ Twilio で電話するところは元々存在していたので、
説明は省きましたがざっとこんな感じです。
もっと色々方法あると思いますが、サクッと対応という感じであれば良い方法かなと。

続きを読む

Amazon Athenaではじめるログ分析入門

はじめに

Amazon AthenaはAWSの分析関連サービスの1つで、S3に保存・蓄積したログに対してSQLクエリを投げて分析を行えるサービスです。分析基盤を整えたり分析サービスにログを転送したりする必要が無いため、簡単に利用できるのが特長です。

今回はAthenaを使ってこんなことできるよー、というのを紹介したいと思います。

※社内勉強会向け資料をQiita向けに修正して公開しています

ログ分析とAmazon Athena

ログ分析は定量的にユーザ行動を分析してサービスの改善に役立つだけでなく、障害時の調査にも役立つなど非常に便利です。ログ分析に利用されるサービスとしてはGoogle BigQueryやAmazon Redshiftなど様々なものがありますが、その中でAmazon Athenaの立ち位置を確認したいと思います。

ログ分析の流れ

ログ分析の基盤の概念図は下記のようになります。

Screen Shot 2017-12-11 at 17.22.23.png

この図からわかるように、考えることは多く、どのようなログを出すのか、どのように分析用のDBやストレージにデータを移すのか(ETL)、分析エンジンは何を使うのか、可視化のためにどのようなツールを利用するかなどの検討が必要です。

また初期は問題なく動作していてもデータ量が増えるうちに障害が起きたりログがドロップしてしまうなど多くの問題が出てきます。

そのため、多くの場合専門のチームが苦労をしながら分析基盤を構築・運用しています。

Amazon Athenaとは

Amazon Athenaはサーバレスな分析サービスで、S3に直接クエリを投げることができます。AWS上での分析としてはEMRやRedShiftなどがありますが、インスタンス管理などの手間があり導入にはハードルがありました。

Athenaでは分析エンジンがフルマネージドであり、ログ収集の仕組みやサーバ管理の必要がなく分析クエリを投げられます。

Screen Shot 2017-12-11 at 17.22.30.png

RDBのテーブルなどとのJOINは(データをS3に持ってこないと)できないためあくまで簡易的な分析にとどまりますが、ログを集計するというだけであればとても簡単に分析の仕組みが構築できます。

Screen Shot 2017-12-05 at 14.28.56.png

料金について

BigQueryなどと同じクエリ課金です。一歩間違えると爆死するので注意は必要です。

  • ストレージ

    • S3を利用するのでS3の保存料金のみ
  • クエリ
    • スキャンされたデータ 1 TB あたり 5 USD
  • データ読み込み
    • S3からの通常のデータ転送料金。AthenaとS3が同一リージョンなら転送料金はかからない。

使ってみる – ELB(CLB/ALB)のログを分析

Athenaのユースケースとして一番簡単で有用なのはELBのログ分析だと思っています。ここでは簡単に利用手順を紹介します。

1. S3バケットを作成し適切なアクセス権を付与

ドキュメントを参考にS3のバケットを作成し、アクセスログを有効化すると、15分毎にアクセスログが出力されます。

http://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/application/load-balancer-access-logs.html#enable-access-logging

2. Athena でテーブルを作る

公式ドキュメントにそのまま利用できるクエリが載っているので、AWSマネージメントコンソールからAthenaのQueryエディタを開きこれを実行します。

http://docs.aws.amazon.com/athena/latest/ug/application-load-balancer-logs.html

3. クエリを実行

Athenaの実行エンジンPrestoは標準SQLなので通常のRDBMSへのクエリのように実行できます。

SELECT count(*)
FROM alb_logs

使ってみる – ELBログから様々なデータを取得する

ELBのログに出力されている内容はそこまで多くないものの、意外と色々取れたりします。

1. あるエンドポイントの日別アクセス数を調べる

SELECT date(from_iso8601_timestamp(time)),
         count(*)
FROM default.alb_logs
WHERE request_url LIKE '%/users/sign_in'
        AND date(from_iso8601_timestamp(time)) >= date('2017-12-01')
GROUP BY  1
ORDER BY  1;

2. 直近24時間の500エラーの発生数を調べる

SELECT elb_status_code,
         count(*)
FROM default.alb_logs
WHERE from_iso8601_timestamp(time) >= date_add('day', -1, now())
        AND elb_status_code >= '500'
GROUP BY  1
ORDER BY  1;

3. レスポンスに1.0s以上時間がかかっているエンドポイント一覧を出す

SELECT request_url,
         count(*)
FROM alb_logs
WHERE target_processing_time >= 1
GROUP BY  1
ORDER BY  2 DESC ;

4. ユーザ単位の行動ログを出す

Cookieは出せないのでIPから。連続したアクセスだとポートも同じになるのでそこまで入れても良い。

SELECT *
FROM alb_logs
WHERE client_ip = 'xx.xxx.xxx.xxx'
        AND timestamp '2017-12-24 21:00' <= from_iso8601_timestamp(time)
        AND from_iso8601_timestamp(time) <= timestamp '2017-12-25 06:00';

5. あるページにアクセスした後、次にどのページに移動しているかを調べる

3回joinしているのでちょっとわかりづらい。これもIPがユーザー毎にユニークと仮定しているので正確ではない。

SELECT d.*
FROM 
    (SELECT b.client_ip,
         min(b.time) AS time
    FROM 
        (SELECT *
        FROM alb_logs
        WHERE request_url LIKE '%/users/sign_in') a
        JOIN alb_logs b
            ON a.time < b.time
        GROUP BY  1 ) c
    JOIN alb_logs d
    ON c.client_ip = d.client_ip
        AND c.time = d.time
ORDER BY  d.time

運用してみる

Athenaについて何となくわかってもらえたでしょうか。次に、実際に運用するときのためにいくつか補足しておきます。

パーティションを切る

ログデータはすぐにTB級の大きなデータとなります。データをWHERE句で絞るにしてもデータにアクセスしないことには絞込はできませんので、Athenaは基本的には保存されている全てのデータにアクセスしてしまいます。

これを防ぐためにパーティションを作って運用します。パーティションを作成するにはCREATE TABLEでPARTITIONED BYでパーティションのキーを指定しておきます。

CREATE EXTERNAL TABLE IF NOT EXISTS table_name (
  type string,
  `timestamp` string,
  elb string,
  client_ip string,
  client_port int,
  target_ip string,
  target_port int,
  request_processing_time double,
  target_processing_time double,
  response_processing_time double,
  elb_status_code string,
  target_status_code string,
  received_bytes bigint,
  sent_bytes bigint,
  request_verb string,
  url string,
  protocol string,
  user_agent string,
  ssl_cipher string,
  ssl_protocol string,
  target_group_arn string,
  trace_id string )
 PARTITIONED BY(d string)
 ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.RegexSerDe' WITH SERDEPROPERTIES (  'serialization.format' = '1',
  'input.regex' = '([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*):([0-9]*) ([^ ]*):([0-9]*) ([.0-9]*) ([.0-9]*) ([.0-9]*) (-|[0-9]*) (-|[0-9]*) ([-0-9]*) ([-0-9]*) \"([^ ]*) ([^ ]*) (- |[^ ]*)\" ("[^"]*") ([A-Z0-9-]+) ([A-Za-z0-9.-]*) ([^ ]*) ([^ ]*)$' )
LOCATION
 's3://bucket_name/prefix/AWSLogs/123456789012/elasticloadbalancing/ap-northeast-1/'

実際のパーティションの構築るには2種類の方法があります。

http://docs.aws.amazon.com/athena/latest/ug/partitions.html

1.ファイルの保存パスにパーティションの情報を入れる(Hiveフォーマット)

s3のkeyを例えば s3://bucket_name/AWSLogs/123456789012/d=2017-12-24/asdf.logという形で保存します。

この状態で次のコマンドを実行すると自動でパーティションが認識されます。

MSCK REPAIR TABLE impressions

2.ALTER TABLEを実行する

ELBのログなどAWSが自動で保存するログは上記のような形式で保存できないので、直接パーティションを作成します。

ALTER TABLE elb_logs
ADD PARTITION (d='2017-12-24')
LOCATION 's3://bucket/prefix/AWSLogs/123456789012/elasticloadbalancing/ap-northeast-1/2017/12/24/'

これらを毎日実行するようなLambdaなどを作成して運用することになります。

BIツールを利用する

Athenaを実行するだけだと生データが手に入るだけなので、必要に応じてグラフにしたりといったことが必要になるかと思います。

ExcelやGoogle SpreadSheetでも良いですが、BI(Business Intelligence)ツールと呼ばれるものを使って定期的なクエリ実行やその可視化、通知などを実現できます。

Athenaに対応しているものではAWSのサービスの一つであるQuickSightやRedashがあります。

列指向フォーマットについて

データ量が増大してくると、クエリの実行時間が増えると同時にお金もかかるようになってきます。そんな時に利用を検討したいのが列指向フォーマットです。

列指向フォーマットでは列単位でデータを取り出せるため、JOINやWHEREを行う際にすべてのデータを取り出す必要がありません。そのため、高速にかつ低料金でクエリを実行できます。

通常のログは行単位で出力されているため、あらかじめ変換処理を行う必要があります。これにはEMRを利用する方法がAWSで解説されているので、大量データに対して頻繁にクエリを行う際は利用を検討してみてください。

https://aws.amazon.com/jp/blogs/news/analyzing-data-in-s3-using-amazon-athena/

アプリケーションのログを分析する(CloudWatch Logsの場合)

ここまではELBのログでここまでできる的な内容でしたが、実際にはアプリケーションが出力したログを利用したくなります。とにかくS3に集めれば良いのでFluentdなどで集めればOKです。

ただ最近はECSやElastic Beanstalkを使っているとCloudWatch Logsに集約しているケースも増えてきているのではないかと思います。この場合S3に持っていくのが微妙に手間になってきます。CloudWatch LogsではS3にログをエクスポートできますが、通常では特定のログをフィルタをして出力したいと思うので、少し工夫が必要になります。

例えば下記のような構成です。

Screen Shot 2017-12-11 at 17.43.23.png

こちらについては https://aws.amazon.com/jp/blogs/news/analyzing-vpc-flow-logs-with-amazon-kinesis-firehose-amazon-athena-and-amazon-quicksight/ この記事などが参考になります。

おわりに

Amazon Athenaの使い方について自分の持っている知識をまとめてみました。Athenaの利用までの簡単さはログ分析の導入としては非常にハードルが低く、とても有用だと思っています。

他の分析サービスとどう違うのかとかは難しいところですが、AthenaはManagedなPrestoであってそれ以上ではなく、EMRやRedshiftの方が上位互換的に機能が多いので、Athenaで出来ないことが出てきたら他を使うとかでも良いのかなぁと思っています。(ここは詳しい人に教えて貰いたいところ。。)

S3 Selectという機能も出てきてS3ログの分析が更に柔軟になっていく予感もありますのでその導入としてのAmazon Athenaを触ってみてはいかがでしょうか。

注意事項

  • ELBログについて

    • ベストエフォート型のため、全てのログの取得は保証されていません
    • ELBのログはUTCで保存するのでJSTの日付とはパーティションがずれます
    • ログは15分ごとにまとめられるので 23:45〜23:59 頃のログは翌日のパーティションに入ってしまいます

参考資料

続きを読む

Istio Ingress on Kubernetes on AWS

自己紹介


Istio?

  • “An open platform to connect, manage, and secure microservices. https://istio.io
  • Kubernetesで動くService Meshの一つ

Istio Ingress?

  • istio-ingress-controller
  • KubernetesのIngress Controllerの一種
  • Ingress … L7ロードバランシング(の設定)
  • Ingress Controller … Ingressリソースの内容に応じてL7ロードバランサをプロビジョニングする

アーキテクチャ

image.png

ref: @kelseyhightower-san, https://github.com/kelseyhightower/istio-ingress-tutorial#architecture

  • Nginx Ingresesとかと同じ構成
  • Istio Ingress Node Pool
    • 例: インターネットに公開する場合

      • インターネットに公開したL4、L7ロードバランサ経由(Classic Load Balancer, Application Load Balancer)で、プライベートサブネット内ノードのIstio Ingress Controllerへ
      • AWS Network Load Balancer経由でパブリックサブネット内ノードのIstio Ingress Controllerへ

Istio Ingressでできないこと

以下はIstio Ingressの責任範囲外

  • AWS Security Groupの設定

    • 手動、Terraform, CloudFormation、kube-awsなどのクラスタプロビジョニングツールを使う
  • AWS ELB, ALBのプロビジョニング
    • 手動、Terraform, CloudFormationなどで管理
    • Istio Ingressと他のIngress Controllerを併用する(kube-ingress-aws-controller)
  • AWS Route53 Record Setの作成

典型的なistioのインストール手順

前提として、RBACは有効に、istio専用のネームスペースをつくり、デフォルトのzipkin以外で分散トレースするとして・・・

helm installと、立て続けにhelm upgradeを実行する必要があるところがわかりづらい。


まずhelm installでCRD(Customer Resource Definition)をつくり、

$ helm install incubator/istio --set rbac.install=true --namespace istio-system --devel

helm upgradeでCRD以外をつくる。

helm upgrade eponymous-billygoat incubator/istio --reuse-values --set istio.install=true --devel

kubectlを打つのが面倒なので、kubensでistio-systemを選択。

alias k=kubectl
alias kn=kubens

kn istio-system

istio configmapにistioが接続するzipkinのホスト名が書いてあるので、それを変更。

k edit configmap istio

デフォルトでは以下のようになっている。

# Zipkin trace collector
zipkinAddress: zipkin:9411

Stackdriverにトレースを流す場合

zipkinAddress: stackdriver-zipkin.kube-system:9411

dd-zipkin-proxy+Datadog APMにトレースを流す場合、
iptablesで$MAGIC_IP:9411->$HOST_IP:9411にredirectさせてから、

zipkinAddress: 169.254.169.256:9411

Istio Ingressを試す


Ingressのプロキシ先バックエンドサービスをデプロイする

いつものKubernetesへのDeployment + istioctl kube-inject

httpbinをデプロイする。

$ git clone git@github.com:istio/istio.git
$ cd istio/samples/httpbin
$ kn istio-ingress-test
$ k apply -f <(istioctl kube-inject -f samples/httpbin/httpbin.yaml)

k apply -f httpbin.yaml のかわりに k apply -f <(istioctl kube-inject -f httpbin.yaml)としてIstio Sidecarの定義を付け加えている。


Kubernetes Ingressリソースの作成

cat <<EOF | kubectl create -f -
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: simple-ingress
  annotations:
    kubernetes.io/ingress.class: istio
spec:
  rules:
  - http:
      paths:
      - path: /.*
        backend:
          serviceName: httpbin
          servicePort: 8000
EOF

kind: IngressとあるとおりKubenetesの一般的なIngressリソースをつくるだけ。

Istio Ingress ControllerがこのIngressを検知して、よしなにEnvoyの設定変更をしてくれる。Nginx Ingress Controllerの場合よしなに設定変更されるのがNginxという違い。


Istio Ingressからバックエンドへのルーティング

あとは、Istioで一般に使うRouteRuleを設定することで、リクエストをルーティングする。Istio “Ingress”に特別なことはない。


istioのRouteRuleとは?

RouteRuleはistio管理下のService A・B間でどういうルーティングをするかという設定を表す。

Service A —istio w/ RouteRule—> Service B


Istio Ingress + RouteRule

Istio Ingressに対してRouteRuleを設定する場合、基本的にsourceがistio-ingressになる(kubectl –namespace istio get svcするとistio-ingressというServiceがいるが、それ)。

Istio Ingress —istio w/ RouteRule—> Backend Service A


Istio Ingress + RouteRuleの例

例えば、バックエンド障害時のIstio Ingressの挙動を確認したい場合。以下のようなRouteRuleで、istio ingressから全バックエンドへのリクエストを遮断する・・・というのもルーティングの範疇。

cat <<EOF | istioctl create -f -
## Deny all access from istio-ingress
apiVersion: config.istio.io/v1alpha2
kind: RouteRule
metadata:
  name: deny-route
spec:
  destination:
    name: httpbin
  match:
    # Limit this rule to istio ingress pods only
    source:
      name: istio-ingress
      labels:
        istio: ingress
  precedence: 1
  route:
  - weight: 100
  httpFault:
    abort:
      percent: 100
      httpStatus: 403 #Forbidden for all URLs
EOF

Istio Ingressでできること

クラスタ内部向けのRouteRule同様、specに色々かいて活用すると以下のようなことができる。

  • Cookieが特定のパターンにマッチしたらサービスv2、そうでなければサービスv1にルーティング

    • ユーザがアクセスするWebサービス、Web APIのA/Bテスト
    • β版Web UIの先行公開
  • バックエンドの重み付け(route[].weight)
    • ミドルウェアやライブラリの更新を一部リクエストで試したい
  • レートリミット
    • 重いWeb APIに全体で100 req/sec以上のリクエストが送られないように
  • リトライ(httpReqRetries)
    • 不安定なサービスへのリクエストが失敗したら、Istio Ingressレイヤーでリトライ。クライアント側でリトライしなくて済む
  • タイムアウト(httpReqTimeout)

あわせて読みたい: Istio / Rules Configuration


まとめ

  • 同じL7のALBなど単体ではできないL7ロードバランシングが実現できます
  • User-Facingなサービスへのデプロイを柔軟にできて、アプリやミドルウェアの更新起因の大きな障害の予防になる

AWSの場合は使わなさそうな機能

使わなくていいものをうっかり使ってしまわないように。


Secure Ingress

https://istio.io/docs/tasks/traffic-management/ingress.html#configuring-secure-ingress-https

Istio IngressがTLS終端してくれる機能。SNIに対応してない(Envoyレベルで)とか、証明書の管理が面倒(AWS ACMよりも)などの点から、使うことはなさそう。


あわせて読みたい

続きを読む

slackの絵文字登録を楽にするslash-command実装

はじめに

slackの絵文字登録、面倒ですよね。
画像幅やサイズの制限もあり、毎回ローカルでリサイズしてWebフォームからアップロードしていました。

現状、slackのカスタム絵文字登録はAPIが存在しませんが、
ブラウザcookieをそのまま利用するアップロードスクリプトが公開されていました。

https://github.com/slackhq/slack-api-docs/issues/28
https://github.com/smashwilson/slack-emojinator

せっかくなのでSlash Command化して、誰でも叩けるようにしてしまいましょう。

構成

    (Slack) #Slash Command
       ↓
     POST
       ↓
 (API Gateway)
       ↓
RequestResponse
       ↓
   (Lambda1) #コマンド名のチェックと分岐のみ
       ↓
     Event
       ↓
   (Lambda2) #画像のGET, リサイズ, 絵文字アップロード
       ↓
     POST
       ↓
    (Slack) #結果の通知
  • SlackAppsのSlash Commandを使います
  • AWSのAPI Gatewayを介して、コマンド分岐用のLambda1を呼び出します
  • 絵文字アップロード用のLambda2をEventモードで呼び出します

ポイントはAPI-Gatewayから起動したLambda1から別の絵文字アップロード用のLambda2を呼び出しているところです。
Slash Commandには、レスポンスタイム3000ms以内という制限があり、
画像をGETしてリサイズして絵文字アップロードする処理はその制限を超えてしまいます。

Lambda1で最小限のコマンド名のチェックをした後、
呼び出しモードをEvent(非同期)にして絵文字アップロードのLambda2を呼び出します。

実装

Lambda1

# -*- coding: utf-8 -*-

import urlparse
import boto3
import json

def lambda_handler(event, context):
    parameters = parse_parameters(event["body"])
    payload = command(parameters)
    return { "statusCode": 200, "body": json.dumps(payload) }

def parse_parameters(token):
    parsed = urlparse.parse_qs(token)
    args = parsed["text"][0].split(" ")
    return {
        "user_id": parsed["user_id"][0],
        "channel_id": parsed["channel_id"][0],
        "image_url": args[0],
        "emoji_name": args[1],
        "response_url": parsed["response_url"][0],
        "team_id": parsed["team_id"][0],
        "channel_name": parsed["channel_name"][0],
        "token": parsed["token"][0],
        "command": parsed["command"][0],
        "team_domain": parsed["team_domain"][0],
        "user_name": parsed["user_name"][0],
    }

def command(parameters):
    if parameters["command"] == "/emoji-san":
        response = boto3.client("lambda").invoke(
            FunctionName="emoji-san",
            InvocationType="Event",
            Payload=json.dumps(parameters)
        )
        return {
            "text": "Thanks %s! Your emoji is now uploading." % parameters["user_name"]
        }
    else:
        return {
            "text": "Not supported command: %s" % parameters["command"]
        }

ざっくり説明するとSlackからWebhookされたデータから、
commandを取り出してboto3を使って別のLambdaを呼び出しています。
Eventモードで呼び出しているので、結果を待たずにレスポンスを返しています。

引数はtextに空白区切りで入っているためそのまま取り出します。
引数チェックしてヒントなど返してあげるとさらに良いですね。

後はデプロイ(自分はlamveryを使っています)してAPI-Gatewayからトリガーします。
また、このLambdaは別のLambdaを呼び出す権限が必要ですので設定しましょう(lambda:InvokeFunction)。
この辺りは調べると記事が充実しているので割愛します。

Lambda2

以下の.envファイルをスクリプトから読んでいます。
Cookieの取得方法は、smashwilson/slack-emojinatorのREADMEに書かれています。

SLACK_TEAM=xxxxx
SLACK_COOKIE="..."
SLACK_API_TOKEN=xxxxxxxxxxxxx
# -*- coding: utf-8 -*-

import os
import json
import upload
import urlparse
import requests
import commands
import threading
from PIL import Image
from StringIO import StringIO
from os.path import join, dirname
from dotenv import load_dotenv
from bs4 import BeautifulSoup

URL = "https://{team_name}.slack.com/customize/emoji"

def lambda_handler(event, context):
    load_dotenv(join(dirname(__file__), ".env"))
    command_emojisan(event)

def command_emojisan(parameters):
    image = download_image(parameters["image_url"])
    image = resize_image(image)
    image.save("/tmp/temp.jpg", "JPEG")
    session = requests.session()
    session.headers = {"Cookie": os.environ["SLACK_COOKIE"]}
    session.url = URL.format(team_name=os.environ["SLACK_TEAM"])
    upload_emoji(session, parameters["emoji_name"], "/tmp/temp.jpg")
    notify_slack(parameters)

def download_image(url):
    response = requests.get(url)
    return Image.open(StringIO(response.content))

def resize_image(image):
    image.thumbnail((128, 128), Image.ANTIALIAS)
    return image

def upload_emoji(session, emoji_name, filename):
    # Fetch the form first, to generate a crumb.
    r = session.get(session.url)
    r.raise_for_status()
    soup = BeautifulSoup(r.text, "html.parser")
    crumb = soup.find("input", attrs={"name": "crumb"})["value"]

    data = {
        'add': 1,
        'crumb': crumb,
        'name': emoji_name,
        'mode': 'data',
    }
    files = {'img': open(filename, 'rb')}
    return session.post(session.url, data=data, files=files, allow_redirects=False)

def notify_slack(parameters):
    payload = {
        "text": "Successfully upload: [:%s:]" % parameters["emoji_name"]
    }
    requests.post(parameters["response_url"], data=json.dumps(payload))

こちらもざっくり説明すると、
引数で受け取った画像URLから画像を取得し、Pillowを利用してリサイズしています。
リサイズされた画像はupload_emoji関数を通ってSlackにアップロードされます。
アップロードされたらresponse_urlに対して、完了の通知を行います。

※現状、絵文字のアップロードが成功でも失敗(例えば認証失敗や無効な絵文字名など)でも200が返ってくるため、簡単に失敗判定はできません。
その為、最後のslack通知に絵文字名を含んで、正しく表示される事を確認できるようにします。

後はこちらもデプロイしたら完了です。

Slash Commandの設定

ほぼ割愛です。
SlackAppsからSlash Commandを追加します。
WebhookのURLにAPI-Gatewayで作られたURLを設定します。
コマンド名やアイコンなどはご自由に(当記事では/emoji-san [Image URL] [Emoji Name])

まとめとハマりどころ

後はslackで/emoji-san https://example.com/image.jpg emoji1等すると、絵文字が登録されていきます。

  • いざ記事にすると手順が多かったのと、コードも直しつつ書いたのでそのまま動く保証はできません・・
  • Slackのレスポンスタイム制限に気づかずに途中で構造を変える事になり時間がかかった
    (複数Lambda以外に方法はないのでしょうか?)
  • コマンド名での分岐をLambda1で行えるので、新しいコマンドを作りたい時にはこっちの方が便利かも
  • Pillowはpip installした環境に依存してしまいます
    長くなるので省きますが、amazon-linuxのDockerコンテナを立ち上げてその中でインストールしたパッケージ一式をLambdaパッケージに含みました。
    CircleCIを通してデプロイするなど回避方法は色々あります。

続きを読む

node.js + Expressでリクエスト単位でプロキシするにはrequestをpipeするだけで良い

概要

“express proxy” で普通に検索すると、express-http-proxyとか、express-request-proxyとか言うパッケージが見つかると思います。ただ、どちらもあるサイトをまるごと別のサイトにプロキシするという目的のために作られていて、キャッシュとかインジェクションの仕組みがあって便利なものの、「あるリクエストをプロキシしたい」というシンプルな用途には向いていません。また、いずれも、URLのエンコードにバグがあって、それで苦労させられたりしました。

最初、上記のパッケージをフォークしていろいろコードをいじっていたのですが、コードを見ていたら、単にリクエスト単位でプロキシをするだけなら、Expressの機能だけで、ものすごく簡単にできるということが分かりました。要するに、requestの結果をresにパイプするだけで、プロキシできるのです。まぁ、言われてみれば、Expressのresponseは、WritableStreamなので、そりゃそうか・・・という感じですが、これを知ったときは、拍子抜けしました。

当然、生のHTTPがそのまま返されるだけなので、レスポンスの加工には向いていませんが、レスポンスの加工が必要ないのなら、これで十分です(その気になれば、テキストレベルでのヘッダーの微調整くらいなら簡単にできる思いますが・・・・)。

サンプルコード

request側は、基本的には、req.headersをそのまま渡せば良いのですが、hostだけは削除しないといけないようです。セキュリティも考慮すると、 ‘authorization’と’cookie’も削除するのが妥当でしょう。用途によってはx-forwarded-* の変更(あるいは削除)が必要かもしれません。

const request = require('request');

function (req, res, next) {
  const proxyRequestHeaders = Object.assign({}, req.headers);
  for(key of ['host', 'authorization', 'cookie']){
    delete proxyRequestHeaders.headers[key]
  }
  const proxyUrl = 'http://calculated.proxy.url/';
  request({
    url: proxyUrl,
    method: req.method,
    headers: proxyRequestHeaders,
  }).pipe(res);
}

用途

ちなみに、なんでこんなことをやろうとしたかというと、AWS S3のsignedURLをプロキシで返したかったのです。基本的には、302 redirectで問題ないのですが、後方互換性のためproxyする必要がありました。proxyだと、S3側で、if-none-matchヘッダとかrangeヘッダの処理とか細かいことを全部やってくれるので便利です。

続きを読む

忙しい人のための Use AWS WAF to Mitigate OWASP’s Top 10 Web Application Vulnerabilities メモ

こんにちは、ひろかずです。

2017年7月に「Use AWS WAF to Mitigate OWASP’s Top 10 Web Application Vulnerabilities」が公開されました。
日本語翻訳されていないので、なかなか読めていない方もいると思います。
リリースから少し時間が経ってしまいましたが、読了したので一筆書きます。

どんなドキュメントか

OWASP Top10 で上っている各項目についての解説と、各項目についてAWS WAFでどのような対応ができるかを記載したもの。
Conditionを作る上でのアドバイスも記載されている。

参照ドキュメント

Use AWS WAF to Mitigate OWASP’s Top 10 Web Application Vulnerabilities

A1 – Injection

SQLインジェクション攻撃は、比較的簡単に検出できるが、バックエンドの構造によって複雑さが変わるため、アプリケーション側での対応も必要。
cookieやカスタムヘッダをデータベース参照に使う場合は、Conditionで検査対象とするように設定すること。
リクエスト中にクエリを流す事を是としているサービスの場合、対象URLを検知除外(バイパス)するような設定を検討すること。

A2 – Broken Authentication and Session Management

String Matchを活用して、盗まれた(怪しい)トークンを「安全のために」ブラックリストに登録する
デバイスのロケーションが異なるトークンを検出する等、ログから悪性利用をしているトークンを分析して、ブラックリストに登録するような使い方。
Rate-basedルールを用いて、認証URLを総当り攻撃を防ぐ施策も有効。

A3 – Cross-Site Scripting (XSS)

XSS攻撃は、HTTPリクエストで特定のキーHTMLタグ名を必要とするため、一般的なシナリオで比較的簡単に軽減できる。
BODYやQUERY_STRING, HEADER: Cookieを検査するのが推奨される。
URLを検査対象とすることは一般的ではないが、アプリケーションが短縮URL使っていると、パラメータはURLパスセグメントの一部として表示され、クエリ文字列には表示されない(後でサーバー側で書き換えられる)
CMSエディタ等、たくさんHTMLを生成するサービスには効果は薄い。
対象URLを検知除外(バイパス)する場合は、別途対策を検討すること。
加えて、<script>タグを多用するイメージ(svgグラフィックフォーマット)やカスタムデータも誤検知率が高いので調整が必要。

A4 – Broken Access Control

2017年から新たに登場
認証ユーザーの制限が適切に動作せず、必要以上に内部アプリケーションオブジェクト(不正なデータの公開、内部Webアプリケーション状態の操作、パストラバーサル、リモート/ローカルファイルの呼び出し)を操作できるというもの。
機能レベルのアクセス制御の欠陥は、一般的に後で追加されたアプリケーションで発生する。
アクセスレベルは、呼び出し時に一度検証されるが、その後に呼び出される様々なサブルーチンについては、都度検証されない。
呼び出し元のコードが、ユーザーの代わりに他のモジュールやコンポーネントを呼び出す暗黙的な信頼ができてしまう。
アプリケーションが、アクセスレベルやサブスクリプションレベルに応じてコンポーネントへアクセスさせる場合は、呼び出しの都度、検証する必要がある。

AWS WAFとしては、「../」や「://」をQUERY_STRINGやURIに含むかを検証することで、リモート/ローカルファイルの呼び出しを検出できる。
これも、リクエスト中に「../」や「://」を含む事を是とするアプリケーションには効果は薄い。
管理モジュール、コンポーネント、プラグイン、または関数へのアクセスが既知の特権ユーザーのセットに限定されている場合、Byte_MatchとIPSetの組み合わせでアクセスを制限することができる。

認証要求がHTTPリクエストの一部として送信され、JWTトークン(のようなもの)でカプセル化されている場合

  • Lambda@Edgeファンクションを用いて、関連リクエストパラメータがトークン内のアサーションおよび承認と一致することを確認することで、権限不適合リクエストはバックエンドに届く前に拒否することができる。

A5 – Security Misconfiguration

セキュリティの影響を受けるパラメータの設定ミスは、アプリケーションだけではなく、OSやミドルウェア、フレームワーク全てにおいて発生し得る。
セキュリティの影響を受けるパラメータの設定ミス例

  • ApacheのServerTokens Full(デフォルト)構成
  • 本番Webサーバーでデフォルトのディレクトリ一覧を有効にしたまま
  • エラーのスタックトレースをユーザーに戻すアプリケーション
  • PHPの脆弱なバージョンと組み合わせて、HTTPリクエストを介して内部サーバ変数を上書き

AWS WAFとしては、パラメータの設定ミスを悪用するHTTPリクエストパターンが認識可能な場合に限り対応可能。

  • A4 – Broken Access Controlと同様に、特定パスやコンポーネントに対してByte_MatchとIPSetの組み合わせでアクセスを制限を行う。(Wordpress管理画面 等)
  • 脆弱なPHPを利用している場合、QUERY_STRING中の“_SERVER[“を遮断する。

その他に考慮できること

  • Amazon Inspectorでの設定ミスの捜査(rootログイン許可や脆弱なミドルウェアバージョン、脆弱性の対応状況)
  • Amazon InspectorでのCISベンチマークとの適合性チェック
  • AWS ConfigやAmazon EC2 Systems Managerを用いた、システム構成変更の検出

A6 – Sensitive Data Exposure

アプリケーションの欠陥による機密情報の暴露をWAFで緩和するのは難しい。
この欠陥には、一般に不完全に実装された暗号化が含まれる(弱い暗号の利用を許容していること)

AWS WAFとしては、HTTPリクエスト中の機密情報を識別するパターンをString-Match Conditionで検出することで、機密情報へのアクセスを検出することができる。(ホストするアプリケーションに対する深い知識が必要)
アプリケーションが、アップロードを許容する場合、Base64を示す文字列の一部を検出するString-Match Conditionが有効(BodyはBase64でエンコードされているから)
一般的ではない手法

その他に考慮できること

  • AWS内の機能を使って、接続ポイントにて、ELBで強い暗号化suiteを使用するように制限する
  • クラシックロードバランサの場合、事前定義・カスタムセキュリティポリシーで制御できる。
  • アプリケーションロードバランサの場合、事前定義セキュリティポリシーで制御できる。
  • Cloud Frontでも利用するSSLプロトコルを制限できる。

A7 – Insufficient Attack Protection

2017年から新設
このカテゴリは、新しく発見された攻撃経路や異常なリクエストパターン、または発見されたアプリケーションの欠陥にタイムリーに対応する能力に重点を置いている。
幅広い攻撃ベクトルが含まれるので、他のカテゴリと重複する部分もある。

確認ポイント

  • アプリケーションに対して、異常なリクエストパターンや大量通信を検出できるか?
  • その検出を自動化できる仕組みはあるか?
  • 不要な通信に対して、ブロックできる仕組みはあるか?
  • 悪意のあるユーザーの攻撃開始を検出できるか?
  • アプリケーションの脆弱性に対するパッチ適用までの時間はどの程度かかる?
  • パッチ適用後の有効性を確認する仕組みはあるか?

AWS WAFで何ができるか

  • Size-Content Conditionを使うことで、アプリケーションで利用するサイズ以上の通信を検出・遮断できる。
  • URIやクエリ文字列のサイズを、アプリケーションにとって意味のある値に制限すること。
  • RESTful API用のAPIキーなどの特定のヘッダーが必要になるようにすることもできる。
  • 特定IPアドレスからの5分間隔での要求レートに対する閾値設定。(例えば、UserAgentとの組み合わせでのカウントも可能)
  • Web ACLはプログラマブルで反映が早いのが利点(CloudFrontに対しても約1分で反映)
  • ログの出力傾向から分析して、Web ACLを自動調整する機能も構築できる。

AWS WAFのセキュリティオートメーション機能の活用

  • Lambdaを使って、4xxエラーを多く出しているIPアドレスを特定してIPブラックリストに登録する。
  • 既知の攻撃者に対しては、外部ソースのIPブラックリストを活用する。
  • ボットとスクレイバに対しては、robots.txtファイルの ‘disallow’セクションにリストされている特定URLへのアクセスを行ったIPを、IPブラックリストに登録する。(ハニーポットと表現されています)

A8 – Cross-Site Request Forgery

CSRFは、WebアプリケーションのStateを変更する機能を対象としている。
State変更に係るURLやフォーム送信などのHTTPリクエストを考慮する。
「ユーザーがその行動を取っている」ということを必ず証明する機構がなければ、悪意のあるユーザーによるリクエスト偽造を判断する方法はない。

  • セッショントークンや送信元IPは偽造できるので、クライアント側の属性に頼るのは有効ではない。
  • CSRFは、特定のアクションのすべての詳細が予測可能であるという事実(フォームフィールド、クエリ文字列パラメータ)を利用する。
  • 攻撃は、クロスサイトスクリプティングやファイルインクルージョンなどの他の脆弱性を利用する方法で実行される。

CSRF攻撃への対処

  • アクションをトリガーするHTTPリクエストに予測困難なトークンを含める
  • ユーザー認証プロンプトの要求
  • アクション要求時のキャプチャの提示

AWS WAFで何ができるか

  • 固有トークンの存在確認
  • 例えば、UUIDv4を活用して、x-csrf-tokenという名前のカスタムHTTPヘッダーの値を期待する場合は、Byte-Size Conditionが使える。
  • ブロックルールには、POST HTTPリクエスト条件に加える

その他にできること

  • 上記のようなルールでは、古い/無効な/盗まれた トークンの検出については、アプリケーションでの対応が必要
  • 例えばの仕組み
  • サーバが、一意のトークンを隠しフィールドとしてブラウザに送信し、ユーザーのフォーム送信時の期待値にする。
  • 期待したトークンが含まれないPOSTリクエストを安全に破棄できる。
  • 処理後のセッションストアからトークンは削除しておくことで、再利用がされないことを保証。

A9 – Using Components with Known Vulnerabilities

既知の脆弱性を持つコンポーネントの利用
ソースや、商用/オープンソースのコンポーネント、フレームワークを最新に保つことが重要
最も簡単な攻撃ベクトルで、その他の攻撃手法の突破口にもなる。
脆弱なサブコンポーネントに依存するコンポーネントの利用しているケース。
コンポーネントの脆弱性は、CVEにて管理/追跡されないので、緩和が難しい。

  • アプリケーション開発者は、それぞれのベンダー、作成者、またはプロバイダとのコンポーネントのステータスを個別に追跡する責任を負う。
  • 脆弱性は、既存のバージョンを修正するのではなく、新機能を含む新しいバージョンのコンポーネントで解決される。
  • そのため、アプリケーション開発者は、新バージョンの実装、テスト、デプロイの工数を負担する。

基本的な対処に

  • アプリケーションの依存関係と基礎となるコンポーネントの依存関係の識別と追跡
  • コンポーネントのセキュリティを追跡するための監視プロセス
  • コンポーネントのパッチやリリース頻度、許容されるライセンスモデルを考慮したソフトウェア開発プロセスとポリシーの確立
  • これらにより、コンポーネントプロバイダーがコード内の脆弱性に対処する際に、迅速に対応できる。

AWS WAFで何ができるか

  • アプリケーションで使用していないコンポーネントの機能に対するHTTPリクエストをフィルタリングおよびブロックによる、攻撃面の削減
  • 例)HTTPリクエストを直接/間接的にアセンブルするコードへのアクセスのブロックする
  • String-matchコンディションを用いたURI検査(例えば、”/includes/”の制限)
  • アプリケーションでサードパーティのコンポーネントを使用しているが機能のサブセットのみを使用する場合は、同様のAWS WAF条件を使って、使用しないコンポーネントの機能への公開URLパスをブロックする。

その他にできること

  • ペネトレーションテスト。マーケットプレイスで買える。申請が必要だが、一部事前承認を受けているサービスでは申請が不要なこともある。(マーケットプレイス上のマークで識別可能)
  • 展開とテストのプロセスに統合して、潜在的な脆弱性を検出するとともに、展開されたパッチが対象となるアプリケーションの問題を適切に緩和する。

A10 – Underprotected APIs

2017年版の新カテゴリ
特定アプリケーションの欠陥パターンではなく、潜在的な攻撃ターゲットを示す

  • UIを持たないアプリケーションは増えてきている。(UI/API両方使えるサービスも同様)
  • 攻撃ベクトルは、A1-A9までと変わらないことが多い。
  • APIはプログラムからのアクセスのために設計されているので、セキュリティテストでは、追加の考慮事項がある。
  • APIは、(Rest,SOAP問わず)複雑なデータ構造での動作とより広い範囲の要求頻度と入力値を使用するように設計される事が多い。

AWS WAFでできること

  • A1-A9での対応と変わらないが、対APIとして追加でできる事がある。
  • 強化が必要な主要コンポーネントは、プロトコルパーサ自体(XML,YAML,JSON 等)
  • パーサーの欠陥を悪用しようとする特定の入力パターンをString-match Condition、またはByte-Size Conditionを使用して、そのような要求パターンをブロックする。

旧OWASP Top10 A10 – Unvalidated Redirects and Forwards

リダイレクトや転送要求を検証しない場合、悪意のある当事者が正当なドメインを使用してユーザーを不要な宛先に誘導する可能性がある。

  • 例)短縮URLを生成する機能がある場合、URLジェネレータがターゲットドメインを検証しないことで、悪意を持ったユーザーが正式サービス上から悪意のあるサイトへの短縮URLを発行できる。

AWS WAFでできること

  • まず、アプリケーションでリダイレクトとフォワードがどこで発生するのかを理解する(リクエストパターン等)
  • アプリケーションが使用する、サードパーティコンポーネントも同様にチェックする。
  • エンドユーザからのHTTPリクエストに応じてリダイレクトと転送が生成された場合は、AWS WAFでリクエストをQUERY_STRINGやURIに対するString-Match Conditionでフィルタリングする(リダイレクト/転送の目的で信頼されているドメインのホワイトリストを維持する)

Cloud Formationテンプレート

Web ACLと、このドキュメントで推奨されている条件タイプとルールを含むAWS CloudFormationテンプレートが用意されています。
今回は、解説を割愛します。

所感

AWS WAFの設定は基本的にDIYですが、実装のポイントをOWASP Top10になぞらえて、その対応を解説する良いヒント集だと思いました。
AWS WAFを設定する人は、一読するのがいいですね。
外部にAWS WAFの設定を依頼する人は、アプリケーションの特性をよく理解している人を立てて、設定する人とよくコミュニケーションを取ることが重要だとわかります。
セキュリティ製品の真価を発揮するには、保護する対象のサービスへの理解とサービスへの適合化が必要なのは、どんなソリューションにも共通しますね。

今日はここまでです。
お疲れ様でした。

続きを読む

WebSocketをLoadBalancer経由で使う時にsocket-io-sticky-sessionを使うと、keep-aliveを有効にしているとSession ID unknownになることがある

はじめに

npmの socket-io-sticky-session (以降、sio-sticky)が、stickyを有効にしたAWSのALBなどのLoadBalancerの後ろにいて、
LoadBalancerからKeep-Alive接続される場合、X-Forwarded-Forなどのヘッダによって正しいWorkerに割り振られないことがある。

という話です。

説明

症状

sio-stickyX-Forwarded-ForなどのHTTPヘッダをみてNode.jsの Webサーバ/WebSocketサーバ などにStickyに接続を振り分けてくれます。これは、SocketIOのpollingやwebsocketで使われるsidというSessionIDを持つリクエストを同じプロセスのNode.jsに振り分ける時に大変重宝します(sidは異なるプロセスのNode.jsで共有されないので)。

しかし、以下のような構成の場合、
ブラウザがALBのCookieをちゃんと送っていても、Stickyが上手く行かずにエラー(BadRequest: Session ID unknown)になることがあります。

20170829_WebSocket400エラー問題の説明.png

原因

原因は、 ALB と sio-sticky の間の keep-alive 接続だと思われます。

sio-sticky は 新規のTCP接続があった時に、配下のWorker(Node.js)にその接続を渡すので、keep-aliveされたあとの2回目以降のHTTPリクエストのヘッダなどを見たりしません。
また、ALBはどのターゲットにリクエストを送るかは制御していますが、どのkeep-aliveされた接続にどのHTTPリクエストを流すかは気にしてはいないので、基本的に運任せになります。

下の図で説明しますと、

20170829_WebSocket400エラー問題の説明.png

  • ① Browser1 と Browser2 は、異なるWorker(Node.js)に振り分けられていたとする
  • ② Browser2からのリクエストがしばらくなくても、ALBとWorkerの間のTCP接続は維持される(keep-alive)
  • ③ Browser1からALBにリクエストがある場合、この別のWorkerとkeep-aliveされた接続にリクエストが流されることがある。異なるworkerにリクエストが行くと、Session ID unknownと怒られてしまう。

というイメージです。

検証

sio-stickyのkeep-alive時の動作についての検証は下記を見てください。
https://github.com/mokemokechicken/socket-io-sticky-session/blob/feature/for_test_keep-alive/NOTE-keep-alive.md

ちなみに、ALBからsio-stickyへの接続は通常の設定ではkeep-aliveされますし、今回の現象も確認することができます。

さいごに

sio-stickyのような仕組みだとTCP接続時に振り分けるので辛いのでしょうね。
keep-aliveを無効にするのも非効率ですし、sio-stickyなどを使わずに1つのNode.jsを直接ALBにぶら下げるのがBetterなのかなぁ、と思います。

続きを読む

AWS EC2にLAMP環境を構築するまで

AWS EC2にLAMP環境を構築したときのメモです。
DBも、RDSを使用せずにEC2に入れてます。

作りたい環境

  • Amazon Linux
  • Apache2.4
  • MySQL 5.6
  • PHP7

前提

  • EC2インスタンスが起動している
  • ターミナルからSSHでログインできる

手順

必要なものをインストール

ログイン

~ $ ssh -i .ssh/your_key.pem ec2-user@{インスタンスのIP}

yumアップデート

[ec2-user@ip-***** ~]$ sudo yum update -y

Apache、MySQL、PHP、MySQLドライバのインストール

[ec2-user@ip-***** ~]$ sudo yum install -y httpd24 php70 mysql56-server php70-mysqlnd

Apache、PHP、MySQLのバージョン確認

# Apache
[ec2-user@ip-***** ~]$ httpd -v
Server version: Apache/2.4.25 (Amazon)
Server built:   Jan 19 2017 16:55:49

# PHP
[ec2-user@ip-***** ~]$ php -v
PHP 7.0.16 (cli) (built: Mar  6 2017 19:45:42) ( NTS )
Copyright (c) 1997-2017 The PHP Group
Zend Engine v3.0.0, Copyright (c) 1998-2017 Zend Technologies

# MySQL
[ec2-user@ip-***** ~]$ sudo service mysqld start
...
...
...
Starting mysqld:                                           [  OK  ]

[ec2-user@ip-***** ~]$ mysql --version
mysql  Ver 14.14 Distrib 5.6.36, for Linux (x86_64) using  EditLine wrapper

Apacheの設定

起動・スタートページの表示

# サービス開始 (起動はするが ServerNameを設定してください と出る(一旦スルー))
[ec2-user@ip-***** ~]$ sudo service httpd start
Starting httpd: AH00557: httpd: apr_sockaddr_info_get() failed for ip-******
AH00558: httpd: Could not reliably determine the server's fully qualified domain name, using 127.0.0.1. Set the 'ServerName' directive globally to suppress this message
                                                           [  OK  ]
#ブラウザで EC2のIPアドレスにアクセスして、Apaceのスタートページが表示されることを確認する

# ドキュメントルートの確認
[ec2-user@ip-***** ~]$ sudo cat /etc/httpd/conf/httpd.conf | less

# 「/DocumentRoot」 で検索
DocumentRoot "/var/www/html"

# 起動設定
[ec2-user@ip-***** ~]$ sudo chkconfig httpd on

# 確認 (2, 3, 4, 5がONになっていればOK)
[ec2-user@ip-***** ~]$ chkconfig
httpd           0:off   1:off   2:on    3:on    4:on    5:on    6:off

グループ設定

# ec2-user を apache グループに追加
[ec2-user@ip-***** ~]$ sudo usermod -a -G apache ec2-user

# 一旦ログアウトして再度ログイン
[ec2-user@ip-***** ~]$ groups
ec2-user wheel apache

# /var/www以下の権限確認
[ec2-user@ip-***** ~]$ ls -l /var/www/
total 20
drwxr-xr-x 2 root root 4096 Jan 19 16:56 cgi-bin
drwxr-xr-x 3 root root 4096 Jun 26 06:18 error
drwxr-xr-x 2 root root 4096 Jan 19 16:56 html
drwxr-xr-x 3 root root 4096 Jun 26 06:18 icons
drwxr-xr-x 2 root root 4096 Jun 26 06:18 noindex

# apacheグループに /var/www 所有・書き込み権限付与
[ec2-user@ip-***** ~]$ sudo chown -R ec2-user:apache /var/www
[ec2-user@ip-***** ~]$ sudo chmod 2775 /var/www/

# /var/www 以下のディレクトリの権限変更
[ec2-user@ip-***** ~]$ find /var/www -type d -exec sudo chmod 2775 {} ;

# /var/www 以下のファイルの権限変更
[ec2-user@ip-***** ~]$ find /var/www -type f -exec sudo chmod 0664 {} ;

httpd.confの設定

/etc/httpd/conf/httpd.conf.conf
# サーバ管理者メールアドレス 変更 87行目あたり
ServerAdmin your_email@example.com

# サーバ名 (ドメイン設定してから)

# クロスサイトトレーシング対策 追記
TraceEnable Off

# ディレクトリ一覧を非表示 変更 145行目あたり
Options -Indexes FollowSymLinks

# cgi-bin使用しない 248〜260行目あたり
#    ScriptAlias /cgi-bin/ "/var/www/cgi-bin/"

#<Directory "/var/www/cgi-bin">
#    AllowOverride None
#    Options None
#    Require all granted
#</Directory>

security.confの設定(新規作成)

/etc/httpd/conf.d/security.conf
ServerTokens Prod
Header unset X-Powered-By
# httpoxy 対策
RequestHeader unset Proxy
# クリックジャッキング対策
Header append X-Frame-Options SAMEORIGIN
# XSS対策
Header set X-XSS-Protection "1; mode=block"
Header set X-Content-Type-Options nosniff
# XST対策
TraceEnable Off

<Directory /var/www/html>
    # .htaccess の有効化
    AllowOverride All
    # ファイル一覧出力の禁止
    Options -Indexes
</Directory>

Welcomeページ非表示

/etc/httpd/conf.d/welcome.conf 中身をコメントアウト

Apache再起動

[ec2-user@ip-***** ~]$ sudo service httpd restart

phpinfoの設置

[ec2-user@ip-***** ~]$ vi /var/www/html/phpinfo.php
<?php
    echo phpinfo();
?>
# ブラウザで IPアドレス/phpinfo.php にアクセス
# 確認後、phpinfo.phpを削除
[ec2-user@ip-***** ~]$ rm /var/www/html/phpinfo.php

PHP設定

[ec2-user@ip-***** ~]$ sudo cp /etc/php.ini /etc/php.ini.org
/etc/php.ini
# タイムゾーン設定
- ;date.timezone =
+ date.timezone = Asia/Tokyo

# 日本語設定
- ;mbstring.internal_encoding =
+ mbstring.internal_encoding = UTF-8

- ;mbstring.language = Japanese
+ mbstring.language = Japanese

- ;mbstring.http_input =
+ mbstring.http_input = auto

- ;bstring.detect_order = auto
+ bstring.detect_order = auto

MySQLの設定

起動設定

[ec2-user@ip-***** ~]$ sudo chkconfig mysqld on

初期設定

[ec2-user@ip-***** ~]$ sudo mysql_secure_installation
...
...
Enter current password for root (enter for none):
# Enterキー

Set root password? [Y/n]
# rootのパスワードを変更するか。
# Y

New password:
# 任意のパスワード入力

Re-enter new password:
# もう一度入力

Password updated successfully!

Remove anonymous users? [Y/n]
# 匿名ユーザーを削除するかどうか。
# Y

Disallow root login remotely? [Y/n]
# rootユーザーのリモートホストからのログインを無効化するかどうか。
# Y

Remove test database and access to it? [Y/n]
# testデータベースを削除するか。
# Y

Reload privilege tables now? [Y/n]
# これらの変更を即座に反映するか。
# Y

...
Thanks for using MySQL!

Cleaning up...

mysqlへログイン

[ec2-user@ip-***** ~]$ mysql -u root -p
Enter password:
# 初期設定で設定したパスワード

Welcome to the MySQL monitor.  Commands end with ; or g.
Your MySQL connection id is 14
Server version: 5.6.36 MySQL Community Server (GPL)

Copyright (c) 2000, 2017, Oracle and/or its affiliates. All rights reserved.
...
mysql>

アプリケーション用データベース追加

# 現在の状態を確認
mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
+--------------------+

# データベース「test_app_db」を追加
mysql> create database test_app_db character set utf8;

# 確認
mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| test_app_db  |
+--------------------+

アプリケーションユーザ追加

# 現在のユーザ設定確認
mysql> select Host, User, Password  from mysql.user;
+-----------+------+-------------------------------------------+
| Host      | User | Password                                  |
+-----------+------+-------------------------------------------+
| localhost | root | *1234567890ABCDEFGHIJ0987654321KLMNOPQRST|
| 127.0.0.1 | root | *1234567890ABCDEFGHIJ0987654321KLMNOPQRST|
| ::1       | root | *1234567890ABCDEFGHIJ0987654321KLMNOPQRST|
+-----------+------+-------------------------------------------+

# ユーザ「app_user」を追加 (ユーザ名は16文字以内)
mysql> create user 'app_user'@'localhost' identified by  '任意のパスワード';

# データベース「test_app_db」にのみアクセス可能
# 権限は、ALL
mysql> grant ALL on test_app_db.* to 'app_user'@'localhost';

# 設定の反映
mysql> flush privileges;

# 権限の確認
mysql> show grants for 'app_user'@'localhost';
+--------------------------------------------------------------------------------------------------------------------+
| Grants for app_user@localhost                                                                                   |
+--------------------------------------------------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO 'app_user'@'localhost' IDENTIFIED BY PASSWORD '*ABCDEFGHIJ0987654321KLMNOPQRST1234567890' |
| GRANT ALL PRIVILEGES ON `test_app_db`.* TO 'app_user'@'localhost'                                         |
+--------------------------------------------------------------------------------------------------------------------+

#  ログアウト
mysql> quit
Bye

# アプリケーションユーザでログイン
[ec2-user@ip-***** ~]$ mysql -u app_user -p

# データベースの確認
mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| test_app_db  |
+--------------------+

mysql> quit

phpMyAdminのインストール

# 「EPEL」リポジトリ有効化
[ec2-user@ip-***** ~]$ sudo yum-config-manager --enable epel

# phpMyAdminインストール
[ec2-user@ip-***** ~]$ sudo yum install -y phpMyAdmin

# エラーになった
Error: php70-common conflicts with php-common-5.3.29-1.8.amzn1.x86_64
Error: php56-common conflicts with php-common-5.3.29-1.8.amzn1.x86_64
Error: php56-process conflicts with php-process-5.3.29-1.8.amzn1.x86_64
 You could try using --skip-broken to work around the problem
 You could try running: rpm -Va --nofiles --nodigest

# 手動でインストール
[ec2-user@ip-***** ~]$ cd /var/www/html
[ec2-user@ip-*****  html]$ sudo wget https://files.phpmyadmin.net/phpMyAdmin/4.6.6/phpMyAdmin-4.6.6-all-languages.tar.gz
[ec2-user@ip-*****  html]$ sudo tar xzvf phpMyAdmin-4.6.6-all-languages.tar.gz
[ec2-user@ip-*****  html]$ sudo mv phpMyAdmin-4.6.6-all-languages phpMyAdmin
[ec2-user@ip-*****  html]$ sudo rm phpMyAdmin-4.6.6-all-languages.tar.gz
[ec2-user@ip-*****  html]$ cd phpMyAdmin
[ec2-user@ip-*****  phpMyAdmin]$ sudo cp config.sample.inc.php config.inc.php

# IPアドレス/phpMyAdmin でアクセス
# PHPの「mbstring」拡張が無いというエラーが出るのでインストール
[ec2-user@ip-***** ~]$ sudo yum install -y php70-mbstring

# Apace再起動
[ec2-user@ip-***** ~]$ sudo service httpd restart

# .htaccessでphpMyAdminへのアクセス制限
[ec2-user@ip-*****  phpMyAdmin]$ sudo vi .htaccess

order deny,allow
deny from all
allow from **.***.***.***

# ブラウザで [IPアドレス]/phpMyAdmin にアクセスしてログインできるか確認

phpMyAdminの環境保護領域設定

ブラウザでアクセスした際、画面下に「phpMyAdmin 環境保管領域が完全に設定されていないため、いくつかの拡張機能が無効になっています。」というメッセージが表示されていた場合、初期設定が必要。


# rootアカウントでログインする
# phpMyAdminのディレクトリ内にある create_tables.sql を流す
# /phpMyAdmin/sql/create_tables.sql

# アプリケーションユーザに phpMyAdminテーブルへのアクセス権を付与する
[ec2-user@ip-***** ~]$ mysql -u root -p

# 現在の権限確認
mysql> show grants for 'app_user'@'localhost';
+--------------------------------------------------------------------------------------------------------------------+
| Grants for app_user@localhost                                                                                   |
+--------------------------------------------------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO 'app_user'@'localhost' IDENTIFIED BY PASSWORD '*ABCDEFGHIJ0987654321KLMNOPQRST1234567890' |
| GRANT ALL PRIVILEGES ON `test_app_db`.* TO 'app_user'@'localhost'                                         |
+--------------------------------------------------------------------------------------------------------------------+
2 rows in set (0.00 sec)

# select, insert, update ,deleteの権限を付与
mysql> grant select, insert, update, delete on phpmyadmin.* to 'app_user'@'localhost';

# 設定を反映
mysql> flush privileges;

# 確認
mysql> show grants for 'app_user'@'localhost';
+--------------------------------------------------------------------------------------------------------------------+
| Grants for app_user@localhost                                                                                   |
+--------------------------------------------------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO 'app_user'@'localhost' IDENTIFIED BY PASSWORD '*ABCDEFGHIJ0987654321KLMNOPQRST1234567890' |
| GRANT SELECT, INSERT, UPDATE, DELETE ON `phpmyadmin`.* TO 'app_user'@'localhost'                                |
| GRANT ALL PRIVILEGES ON `test_app_db`.* TO 'app_user'@'localhost'                                         |
+--------------------------------------------------------------------------------------------------------------------+
3 rows in set (0.00 sec)

# config.inc.php の編集
[ec2-user@ip-***** ~]$ sudo vi /var/www/html/phpMyAdmin/config.inc.php
# 以下の記述のコメントアウトを解除する
/* Storage database and tables */
// $cfg['Servers'][$i]['pmadb'] = 'phpmyadmin';
// $cfg['Servers'][$i]['bookmarktable'] = 'pma__bookmark';
// $cfg['Servers'][$i]['relation'] = 'pma__relation';
// $cfg['Servers'][$i]['table_info'] = 'pma__table_info';
// $cfg['Servers'][$i]['table_coords'] = 'pma__table_coords';
// $cfg['Servers'][$i]['pdf_pages'] = 'pma__pdf_pages';
// $cfg['Servers'][$i]['column_info'] = 'pma__column_info';
// $cfg['Servers'][$i]['history'] = 'pma__history';
// $cfg['Servers'][$i]['table_uiprefs'] = 'pma__table_uiprefs';
// $cfg['Servers'][$i]['tracking'] = 'pma__tracking';
// $cfg['Servers'][$i]['userconfig'] = 'pma__userconfig';
// $cfg['Servers'][$i]['recent'] = 'pma__recent';
// $cfg['Servers'][$i]['favorite'] = 'pma__favorite';
// $cfg['Servers'][$i]['users'] = 'pma__users';
// $cfg['Servers'][$i]['usergroups'] = 'pma__usergroups';
// $cfg['Servers'][$i]['navigationhiding'] = 'pma__navigationhiding';
// $cfg['Servers'][$i]['savedsearches'] = 'pma__savedsearches';
// $cfg['Servers'][$i]['central_columns'] = 'pma__central_columns';
// $cfg['Servers'][$i]['designer_settings'] = 'pma__designer_settings';
// $cfg['Servers'][$i]['export_templates'] = 'pma__export_templates';

設定ファイル用パスフレーズの設定

ログイン時に「設定ファイルに、暗号化 (blowfish_secret) 用の非公開パスフレーズの設定を必要とするようになりました。」というメッセージが表示されている場合、設定ファイルにパスフレーズを追記する必要がある。


[ec2-user@ip-***** ~]$ sudo vi /var/www/html/phpMyAdmin/config.inc.php

# 下記の部分に任意の文字列(32文字以上)を入力
/**
 * This is needed for cookie based authentication to encrypt password in
 * cookie. Needs to be 32 chars long.
 */
$cfg['blowfish_secret'] = ''; /* YOU MUST FILL IN THIS FOR COOKIE AUTH! */

パスワード自動生成

その他

時刻設定

[ec2-user@ip-***** ~]$ sudo vi /etc/sysconfig/clock
ZONE="Asia/Tokyo"
UTC=false

[ec2-user@ip-***** ~]$ sudo cp /usr/share/zoneinfo/Japan /etc/localtime
[ec2-user@ip-***** ~]$ sudo /etc/init.d/crond restart
[ec2-user@ip-***** ~]$ date
Tue Jun 27 10:20:25 JST 2017

git

[ec2-user@ip-***** ~]$ sudo yum install -y git
[ec2-user@ip-***** ~]$ git --version
git version 2.7.5

composer

[ec2-user@ip-***** ~]$ curl -sS https://getcomposer.org/installer | php
All settings correct for using Composer
Downloading...

Composer (version 1.4.2) successfully installed to: /home/ec2-user/composer.phar
Use it: php composer.phar

[ec2-user@ip-***** ~]$ sudo mv composer.phar /usr/local/bin/composer

[ec2-user@ip-***** ~]$ composer
   ______
  / ____/___  ____ ___  ____  ____  ________  _____
 / /   / __ / __ `__ / __ / __ / ___/ _ / ___/
/ /___/ /_/ / / / / / / /_/ / /_/ (__  )  __/ /
____/____/_/ /_/ /_/ .___/____/____/___/_/
                    /_/
Composer version 1.4.2 2017-05-17 08:17:52

参考URL

続きを読む