├── .github └── workflows │ ├── gosec.yml │ ├── lint.yml │ ├── publish.yml │ ├── test.yml │ └── trivy.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── action ├── README.md ├── action.go ├── bigquery │ ├── README.md │ ├── action.go │ └── action_test.go ├── chatgpt │ ├── README.md │ ├── analyst.go │ ├── analyst_test.go │ └── testdata │ │ └── aws_guardduty_example.json ├── github │ ├── README.md │ ├── comment.go │ ├── comment_test.go │ ├── export_test.go │ ├── issuer.go │ ├── issuer_template.md │ └── issuer_test.go ├── http │ ├── README.md │ ├── fetch.go │ └── fetch_test.go ├── jira │ ├── README.md │ ├── add_attachment.go │ ├── add_attachment_test.go │ ├── add_comment.go │ ├── add_comment_test.go │ ├── create_issue.go │ ├── create_issue_test.go │ ├── export_test.go │ └── issue_template.txt ├── opsgenie │ ├── README.md │ ├── create_alert.go │ └── create_alert_test.go ├── otx │ ├── README.md │ ├── action.go │ └── action_test.go └── slack │ ├── README.md │ ├── post.go │ └── post_test.go ├── docs ├── authz.md ├── deployment.md ├── examples │ └── alert-policy-testing │ │ ├── README.md │ │ ├── alert.rego │ │ ├── alert_test.rego │ │ └── test │ │ └── aws_guardduty │ │ └── data.json ├── getting_started.md ├── images │ └── action_policy_workflow.jpg ├── policy.md └── test.md ├── examples ├── basic │ ├── guardduty.json │ └── policy │ │ ├── action.rego │ │ └── alert.rego ├── e2e │ ├── action.rego │ └── alert.rego └── test │ ├── READNE.md │ ├── event │ └── guardduty.json │ ├── policy │ ├── action.rego │ └── alert.rego │ ├── results │ └── chatgpt.json │ ├── scenarios │ └── scenario.jsonnet │ └── test.rego ├── go.mod ├── go.sum ├── gqlgen.yml ├── graphql └── schema.graphqls ├── main.go ├── main_test.go └── pkg ├── chain ├── alert_test.go ├── chain.go ├── chain_test.go ├── embed_test.go ├── export_test.go ├── recorder.go ├── testdata │ ├── alert_feature │ │ ├── action.rego │ │ └── alert.rego │ ├── basic │ │ ├── action.rego │ │ ├── alert.rego │ │ ├── alert_test.rego │ │ └── input │ │ │ └── scc.json │ ├── control │ │ ├── action.rego │ │ └── alert.rego │ ├── countup │ │ ├── action.rego │ │ └── alert.rego │ ├── force_action │ │ ├── action.rego │ │ └── alert.rego │ ├── global_attr │ │ ├── action.rego │ │ └── alert.rego │ ├── loop │ │ ├── action.rego │ │ └── alert.rego │ ├── play │ │ ├── action.rego │ │ ├── alert.rego │ │ └── playbook.jsonnet │ └── play_workflow │ │ ├── action.rego │ │ └── playbook.jsonnet ├── workflow.go └── workflow_test.go ├── controller ├── cli │ ├── cmd.go │ ├── cmd_test.go │ ├── common.go │ ├── config │ │ ├── database.go │ │ ├── logger.go │ │ ├── policy.go │ │ └── sentry.go │ ├── enhance.go │ ├── new.go │ ├── play.go │ ├── run.go │ └── serve.go ├── graphql │ ├── generated.go │ ├── resolver.go │ └── schema.resolvers.go └── server │ ├── middleware.go │ ├── middleware_test.go │ ├── server.go │ ├── server_test.go │ └── testdata │ ├── action.rego │ ├── alert.rego │ ├── authz.rego │ └── scc.json ├── ctxutil └── ctxutil.go ├── domain ├── interfaces │ ├── infra.go │ └── interfaces.go ├── model │ ├── action.go │ ├── action_test.go │ ├── alert.go │ ├── attribute.go │ ├── attribute_test.go │ ├── clock.go │ ├── graphql.go │ ├── log.go │ ├── playbook.go │ ├── playbook_test.go │ ├── policy.go │ ├── policy_test.go │ ├── pubsub.go │ ├── references.go │ └── testdata │ │ ├── config │ │ ├── config1.jsonnet │ │ ├── config2.jsonnet │ │ └── config2_imported.jsonnet │ │ └── playbook │ │ ├── base.jsonnet │ │ └── input.json └── types │ ├── attribute.go │ ├── const.go │ ├── error.go │ └── types.go ├── infra ├── database_test.go ├── firestore │ ├── client.go │ └── client_test.go ├── gemini │ ├── client.go │ └── client_test.go ├── memory │ └── client.go ├── policy │ ├── client.go │ ├── client_test.go │ └── hook.go └── recorder │ ├── json.go │ ├── json_test.go │ └── memory.go ├── logging ├── logger.go └── logger_test.go ├── mock └── infra.go ├── service ├── action.go ├── service.go └── workflow.go ├── usecase ├── enhance_ignore.go ├── enhance_ignore_test.go ├── export_test.go ├── new.go ├── play.go ├── prompt │ ├── alert_slug.md │ └── new_ignore.md ├── templates │ ├── .gitignore │ ├── Dockerfile │ ├── Makefile │ ├── policy │ │ ├── action │ │ │ └── main.rego │ │ ├── alert │ │ │ ├── main.rego │ │ │ ├── main_test.rego │ │ │ └── testdata │ │ │ │ └── your_schema │ │ │ │ └── event.json │ │ ├── authz │ │ │ └── http.rego │ │ └── play │ │ │ └── test.rego │ └── scenario │ │ ├── data │ │ └── event.json │ │ ├── env.libsonnet │ │ └── my_first_scenario.jsonnet ├── testdata │ └── example_policy.rego └── usecase.go └── utils ├── convert.go ├── env.go ├── error.go ├── safe_func.go ├── tests.go └── utils.go /.github/workflows/gosec.yml: -------------------------------------------------------------------------------- 1 | name: Gosec 2 | 3 | # Run workflow each time code is pushed to your repository and on a schedule. 4 | # The scheduled workflow runs every at 00:00 on Sunday UTC time. 5 | on: 6 | push: 7 | 8 | jobs: 9 | tests: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | security-events: write 13 | actions: read 14 | contents: read 15 | env: 16 | GO111MODULE: on 17 | steps: 18 | - name: Checkout Source 19 | uses: actions/checkout@v4 20 | - name: Run Gosec Security Scanner 21 | uses: securego/gosec@master 22 | with: 23 | # we let the report trigger content trigger a failure using the GitHub Security features. 24 | args: "-no-fail -fmt sarif -out results.sarif ./..." 25 | - name: Upload SARIF file 26 | uses: github/codeql-action/upload-sarif@v2 27 | with: 28 | # Path to SARIF file relative to the root of the repository 29 | sarif_file: results.sarif 30 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | push: 4 | 5 | jobs: 6 | golangci: 7 | name: lint 8 | permissions: 9 | security-events: write 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Go 14 | uses: actions/setup-go@v5 15 | with: 16 | go-version-file: "go.mod" 17 | - name: golangci-lint 18 | uses: golangci/golangci-lint-action@v6 19 | with: 20 | version: latest 21 | args: --timeout 10m ./... 22 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | 6 | env: 7 | TAG_NAME: alertchain:${{ github.sha }} 8 | BUILD_VERSION: ${{ github.sha }} 9 | GITHUB_IMAGE_REPO: ghcr.io/${{ github.repository_owner }}/alertchain 10 | GITHUB_IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/alertchain:${{ github.sha }} 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | id-token: write 17 | contents: read 18 | packages: write 19 | 20 | steps: 21 | - name: checkout 22 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 23 | 24 | - name: Set up Docker buildx 25 | uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 26 | - name: Login to GitHub Container Registry 27 | uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 28 | with: 29 | registry: ghcr.io 30 | username: ${{ github.repository_owner }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Get the tag or commit id 34 | id: version 35 | run: | 36 | if [[ $GITHUB_REF == refs/tags/* ]]; then 37 | # If a tag is present, strip the 'refs/tags/' prefix 38 | TAG_OR_COMMIT=$(echo $GITHUB_REF | sed 's/refs\/tags\///') 39 | echo "This is a tag: $TAG_OR_COMMIT" 40 | else 41 | # If no tag is present, use the commit SHA 42 | TAG_OR_COMMIT=$(echo $GITHUB_SHA) 43 | echo "This is a commit SHA: $TAG_OR_COMMIT" 44 | fi 45 | # Set the variable for use in other steps 46 | echo "TAG_OR_COMMIT=$TAG_OR_COMMIT" >> $GITHUB_OUTPUT 47 | shell: bash 48 | 49 | - name: Build and push 50 | uses: docker/build-push-action@4a13e500e55cf31b7a5d59a38ab2040ab0f42f56 # v5.1.0 51 | with: 52 | context: . 53 | push: true 54 | tags: ${{ env.GITHUB_IMAGE_NAME }} 55 | build-args: | 56 | BUILD_VERSION=${{ steps.version.outputs.TAG_OR_COMMIT }} 57 | cache-from: type=gha 58 | cache-to: type=gha,mode=max 59 | platforms: linux/amd64 60 | - uses: m-mizutani/xroute-action@main 61 | with: 62 | url: ${{ vars.XROUTE_URL }} 63 | message: "Build and pushed image: ${{ env.GITHUB_IMAGE_NAME }}" 64 | 65 | release-ghcr: 66 | runs-on: ubuntu-latest 67 | permissions: 68 | id-token: write 69 | contents: read 70 | packages: write 71 | needs: build 72 | if: startsWith(github.ref, 'refs/tags/') 73 | steps: 74 | - name: extract tag 75 | id: tag 76 | run: | 77 | TAG=$(echo ${{ github.ref }} | sed -e "s#refs/tags/##g") 78 | echo "tag=$TAG" >> $GITHUB_OUTPUT 79 | - name: Login to GitHub Container Registry 80 | uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 81 | with: 82 | registry: ghcr.io 83 | username: ${{ github.repository_owner }} 84 | password: ${{ secrets.GITHUB_TOKEN }} 85 | - name: Pull Docker image 86 | run: docker pull ${{ env.GITHUB_IMAGE_NAME }} 87 | - name: Rename Docker image (tag name) 88 | run: docker tag ${{ env.GITHUB_IMAGE_NAME }} "${{ env.GITHUB_IMAGE_REPO }}:${{ steps.tag.outputs.tag }}" 89 | - name: Rename Docker image (latest) 90 | run: docker tag ${{ env.GITHUB_IMAGE_NAME }} "${{ env.GITHUB_IMAGE_REPO }}:latest" 91 | - name: Push Docker image (tag name) 92 | run: docker push "${{ env.GITHUB_IMAGE_REPO }}:${{ steps.tag.outputs.tag }}" 93 | - name: Push Docker image (latest) 94 | run: docker push "${{ env.GITHUB_IMAGE_REPO }}:latest" 95 | - uses: m-mizutani/xroute-action@main 96 | with: 97 | url: ${{ vars.XROUTE_URL }} 98 | message: "Build and pushed image: ${{ env.GITHUB_IMAGE_REPO }}" 99 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Unit test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | testing: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout upstream repo 11 | uses: actions/checkout@v3 12 | with: 13 | ref: ${{ github.head_ref }} 14 | - uses: actions/setup-go@v3 15 | with: 16 | go-version-file: "go.mod" 17 | - uses: google-github-actions/setup-gcloud@v0.5.0 18 | - run: go test ./pkg/... 19 | -------------------------------------------------------------------------------- /.github/workflows/trivy.yml: -------------------------------------------------------------------------------- 1 | name: trivy 2 | 3 | on: 4 | push: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | scan: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | security-events: write 14 | actions: read 15 | contents: read 16 | 17 | steps: 18 | - name: Checkout upstream repo 19 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 20 | with: 21 | ref: ${{ github.head_ref }} 22 | - id: scan 23 | name: Run Trivy vulnerability scanner in repo mode 24 | uses: aquasecurity/trivy-action@f3d98514b056d8c71a3552e8328c225bc7f6f353 # master 25 | with: 26 | scan-type: "fs" 27 | ignore-unfixed: true 28 | format: "sarif" 29 | output: "trivy-results.sarif" 30 | exit-code: 1 31 | env: 32 | TRIVY_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-db 33 | 34 | - name: Upload Trivy scan results to GitHub Security tab 35 | if: failure() && steps.scan.outcome == 'failure' 36 | uses: github/codeql-action/upload-sarif@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0 37 | with: 38 | sarif_file: "trivy-results.sarif" 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | tmp 3 | secrets 4 | .vscode 5 | .dccache 6 | output 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23.3 AS build-go 2 | ENV CGO_ENABLED=0 3 | ARG BUILD_VERSION 4 | COPY . /app 5 | WORKDIR /app 6 | RUN go build -o alertchain -ldflags "-X github.com/secmon-lab/alertchain/pkg/domain/types.AppVersion=${BUILD_VERSION}" . 7 | 8 | FROM gcr.io/distroless/base 9 | COPY --from=build-go /app/alertchain /alertchain 10 | 11 | ENTRYPOINT ["/alertchain"] 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: gql mock 2 | 3 | # --------------------------- 4 | gql: pkg/controller/graphql/generated.go 5 | 6 | pkg/controller/graphql/generated.go: graphql/schema.graphqls 7 | go run github.com/99designs/gqlgen@v0.17.61 generate 8 | 9 | # --------------------------- 10 | MOCK_OUT=pkg/mock/infra.go 11 | MOCK_SRC=./pkg/domain/interfaces 12 | MOCK_INTERFACES=GenAI Database 13 | 14 | mock: $(MOCK_OUT) 15 | 16 | $(MOCK_OUT): $(MOCK_SRC)/* 17 | go run github.com/matryer/moq@v0.5.1 -pkg mock -out $(MOCK_OUT) $(MOCK_SRC) $(MOCK_INTERFACES) 18 | 19 | clean: 20 | rm -f $(MOCK_OUT) 21 | -------------------------------------------------------------------------------- /action/README.md: -------------------------------------------------------------------------------- 1 | # Actions 2 | 3 | An Action is an element of a workflow that is executed within an AlertChain. By providing arguments within a policy, the action can be executed. For more information on how to write a policy, please refer to the [policy documentation](../../docs/policy.md). 4 | 5 | ## Supported actions 6 | 7 | - [github](./github/) 8 | - [jira](./jira/) 9 | - [opsgenie](./opsgenie/) 10 | - [chatgpt](./chatgpt/) 11 | - [slack](./slack/) 12 | - [http](./http) 13 | - [otx](./otx) 14 | - [bigquery](./bigquery) 15 | -------------------------------------------------------------------------------- /action/action.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "github.com/secmon-lab/alertchain/action/bigquery" 5 | "github.com/secmon-lab/alertchain/action/chatgpt" 6 | "github.com/secmon-lab/alertchain/action/github" 7 | "github.com/secmon-lab/alertchain/action/http" 8 | "github.com/secmon-lab/alertchain/action/jira" 9 | "github.com/secmon-lab/alertchain/action/opsgenie" 10 | "github.com/secmon-lab/alertchain/action/otx" 11 | "github.com/secmon-lab/alertchain/action/slack" 12 | "github.com/secmon-lab/alertchain/pkg/domain/model" 13 | "github.com/secmon-lab/alertchain/pkg/domain/types" 14 | ) 15 | 16 | var actionMap = map[types.ActionName]model.RunAction{ 17 | "github.create_issue": github.CreateIssue, 18 | "github.create_comment": github.CreateComment, 19 | "jira.create_issue": jira.CreateIssue, 20 | "jira.add_comment": jira.AddComment, 21 | "jira.add_attachment": jira.AddAttachment, 22 | `opsgenie.create_alert`: opsgenie.CreateAlert, 23 | "chatgpt.query": chatgpt.Query, 24 | "slack.post": slack.Post, 25 | "http.fetch": http.Fetch, 26 | "otx.indicator": otx.Indicator, 27 | "bigquery.insert_alert": bigquery.InsertAlert, 28 | "bigquery.insert_data": bigquery.InsertData, 29 | } 30 | 31 | func Map() map[types.ActionName]model.RunAction { 32 | var copied = make(map[types.ActionName]model.RunAction, len(actionMap)) 33 | for k, v := range actionMap { 34 | copied[k] = v 35 | } 36 | 37 | return copied 38 | } 39 | -------------------------------------------------------------------------------- /action/bigquery/README.md: -------------------------------------------------------------------------------- 1 | # BigQuery 2 | 3 | Actions for BigQuery data integration. 4 | 5 | ## Prerequisites 6 | 7 | - **Google Cloud account**: You need to have a Google Cloud account and the necessary permissions to create and modify BigQuery datasets and tables. 8 | - **BigQuery dataset**: You need to have a BigQuery dataset to insert the alert into. You can find instructions on how to create a dataset [here](https://cloud.google.com/bigquery/docs/datasets). 9 | 10 | ## `bigquery.insert_alert` 11 | 12 | This action inserts an alert into BigQuery. 13 | 14 | ### Arguments 15 | 16 | Example policy: 17 | 18 | ```rego 19 | run contains res if { 20 | res := { 21 | id: "your-action", 22 | uses: "bigquery.insert_alert", 23 | args: { 24 | "project_id": "my-project", 25 | "dataset_id": "my-dataset", 26 | "table_id": "my-table", 27 | }, 28 | }, 29 | } 30 | ``` 31 | 32 | - `project_id` (string, required): Specifies the ID of the Google Project to insert the alert into. 33 | - `dataset_id` (string, required): Specifies the ID of the BigQuery dataset to insert the alert into. 34 | - `table_id` (string, required): Specifies the ID of the BigQuery table to insert the alert into. If the table does not exist, it will be created. 35 | 36 | ### Table schema 37 | 38 | | Field name | Type | Mode | 39 | |------------|-----------|----------| 40 | | id | STRING | REQUIRED | 41 | | schema | STRING | REQUIRED | 42 | | created_at | TIMESTAMP | REQUIRED | 43 | | title | STRING | REQUIRED | 44 | | description| STRING | REQUIRED | 45 | | source | STRING | REQUIRED | 46 | | namespace | STRING | REQUIRED | 47 | | attrs | RECORD | REPEATED | 48 | | └─ id | STRING | REQUIRED | 49 | | └─ key | STRING | REQUIRED | 50 | | └─ value | STRING | REQUIRED | 51 | | └─ type | STRING | REQUIRED | 52 | | └─ ttl | INTEGER | REQUIRED | 53 | | └─ persist | BOOLEAN | REQUIRED | 54 | | refs | RECORD | REPEATED | 55 | | └─ Title | STRING | REQUIRED | 56 | | └─ URL | STRING | REQUIRED | 57 | | data | JSON | REQUIRED | 58 | 59 | 60 | ## `bigquery.insert_data` 61 | 62 | This action inserts any data into BigQuery. 63 | 64 | ### Arguments 65 | 66 | Example policy: 67 | 68 | ```rego 69 | run contains res if { 70 | res := { 71 | id: "your-action", 72 | uses: "bigquery.insert_data", 73 | args: { 74 | "project_id": "my-project", 75 | "dataset_id": "my-dataset", 76 | "table_id": "my-table", 77 | "data": { 78 | "name": "John Doe", 79 | "age": 42, 80 | }, 81 | }, 82 | }, 83 | } 84 | ``` 85 | 86 | - `project_id` (string, required): Specifies the ID of the Google Project to insert the data into. 87 | - `dataset_id` (string, required): Specifies the ID of the BigQuery dataset to insert the data into. 88 | - `table_id` (string, required): Specifies the ID of the BigQuery table to insert the data into. If the table does not exist, it will be created. 89 | - `tags` (array of string, optional): Specifies the tags to insert into the table. This field will be REPEATED STRING field type in BigQuery. 90 | - `data` (object, required): Specifies the data to insert into the table. This field will be JSON field type in BigQuery. 91 | 92 | ### Table schema 93 | 94 | | Field name | Type | Mode | 95 | |------------|-----------|----------| 96 | | id | STRING | REQUIRED | 97 | | alert_id | STRING | REQUIRED | 98 | | created_at | TIMESTAMP | REQUIRED | 99 | | tags | STRING | REPEATED | 100 | | data | JSON | REQUIRED | 101 | -------------------------------------------------------------------------------- /action/bigquery/action_test.go: -------------------------------------------------------------------------------- 1 | package bigquery_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/m-mizutani/gt" 9 | "github.com/secmon-lab/alertchain/action/bigquery" 10 | "github.com/secmon-lab/alertchain/pkg/domain/model" 11 | "github.com/secmon-lab/alertchain/pkg/domain/types" 12 | "github.com/secmon-lab/alertchain/pkg/utils" 13 | ) 14 | 15 | func TestInsertDataIntegration(t *testing.T) { 16 | var ( 17 | projectID string 18 | datasetID string 19 | tableID string 20 | ) 21 | 22 | if err := utils.LoadEnv( 23 | utils.EnvDef("TEST_BIGQUERY_PROJECT_ID", &projectID), 24 | utils.EnvDef("TEST_BIGQUERY_DATASET_ID", &datasetID), 25 | utils.EnvDef("TEST_BIGQUERY_DATA_TABLE_ID", &tableID), 26 | ); err != nil { 27 | t.Skipf("Skip test due to missing env: %v", err) 28 | } 29 | 30 | ctx := context.Background() 31 | args := model.ActionArgs{ 32 | "project_id": projectID, 33 | "dataset_id": datasetID, 34 | "table_id": tableID, 35 | "data": map[string]interface{}{ 36 | "color": "blue", 37 | "number": 5, 38 | "nested": map[string]interface{}{ 39 | "foo": "bar", 40 | "bar": "baz", 41 | }, 42 | }, 43 | } 44 | 45 | ret := gt.R1(bigquery.InsertData(ctx, model.Alert{ 46 | ID: types.NewAlertID(), 47 | }, args)).NoError(t) 48 | gt.V(t, ret).Nil() 49 | } 50 | 51 | func TestInsertAlertIntegration(t *testing.T) { 52 | var ( 53 | projectID string 54 | datasetID string 55 | tableID string 56 | ) 57 | 58 | if err := utils.LoadEnv( 59 | utils.EnvDef("TEST_BIGQUERY_PROJECT_ID", &projectID), 60 | utils.EnvDef("TEST_BIGQUERY_DATASET_ID", &datasetID), 61 | utils.EnvDef("TEST_BIGQUERY_ALERT_TABLE_ID", &tableID), 62 | ); err != nil { 63 | t.Skipf("Skip test due to missing env: %v", err) 64 | } 65 | 66 | ctx := context.Background() 67 | args := model.ActionArgs{ 68 | "project_id": projectID, 69 | "dataset_id": datasetID, 70 | "table_id": tableID, 71 | } 72 | alert := model.Alert{ 73 | ID: types.NewAlertID(), 74 | AlertMetaData: model.AlertMetaData{ 75 | Source: "test_source", 76 | Namespace: "test_namespace", 77 | Title: "test alert", 78 | Description: "test description", 79 | Attrs: model.Attributes{ 80 | { 81 | Key: "color", 82 | Value: "blue", 83 | }, 84 | }, 85 | Refs: model.References{ 86 | { 87 | Title: "test reference", 88 | URL: "https://example.com", 89 | }, 90 | }, 91 | }, 92 | Schema: "test_schema", 93 | CreatedAt: time.Now(), 94 | Data: map[string]interface{}{ 95 | "color": "blue", 96 | "number": 5, 97 | "nested": map[string]interface{}{ 98 | "foo": "bar", 99 | }, 100 | }, 101 | Raw: `{ "color": "blue", "number": 5, "nested": { "foo": "bar" } }`, 102 | } 103 | 104 | ret := gt.R1(bigquery.InsertAlert(ctx, alert, args)).NoError(t) 105 | gt.V(t, ret).Nil() 106 | } 107 | -------------------------------------------------------------------------------- /action/chatgpt/README.md: -------------------------------------------------------------------------------- 1 | # ChatGPT 2 | 3 | ## `chatgpt.query` 4 | 5 | This action provides a summary and suggested response to a security alert using ChatGPT. 6 | 7 | ## Prerequisite 8 | 9 | 1. Create an account on OpenAI. 10 | 2. Generate an API key at https://platform.openai.com/account/api-keys. 11 | 12 | ### Arguments 13 | 14 | Example policy: 15 | 16 | ```rego 17 | run contains res if { 18 | res := { 19 | "id": "your-action", 20 | "uses": "chatgpt.query", 21 | "args": { 22 | "secret_api_key": input.env.CHATGPT_API_KEY, 23 | }, 24 | }, 25 | } 26 | ``` 27 | 28 | - `secret_api_key` (required, string): The API key for OpenAI. 29 | - `prompt` (optional, string): The ChatGPT prompt. The default is: 30 | 31 | ``` 32 | Please analyze and summarize the given JSON-formatted security alert data, and suggest appropriate actions for the security administrator to respond to the alert: 33 | ``` 34 | and stringified JSON-formatted security alert data. 35 | 36 | ## Response 37 | 38 | The response format follows the OpenAI Chat API guidelines, which can be found at: 39 | https://platform.openai.com/docs/guides/chat/response-format -------------------------------------------------------------------------------- /action/chatgpt/analyst.go: -------------------------------------------------------------------------------- 1 | package chatgpt 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "encoding/json" 7 | 8 | openai "github.com/sashabaranov/go-openai" 9 | 10 | "github.com/m-mizutani/goerr/v2" 11 | "github.com/secmon-lab/alertchain/pkg/ctxutil" 12 | "github.com/secmon-lab/alertchain/pkg/domain/model" 13 | "github.com/secmon-lab/alertchain/pkg/domain/types" 14 | "github.com/secmon-lab/alertchain/pkg/utils" 15 | ) 16 | 17 | func Query(ctx context.Context, alert model.Alert, args model.ActionArgs) (any, error) { 18 | apiKey, ok := args["secret_api_key"].(string) 19 | if !ok { 20 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "secret_api_key is required") 21 | } 22 | 23 | client := openai.NewClient(apiKey) 24 | 25 | data, err := json.Marshal(alert.Data) 26 | if err != nil { 27 | return nil, goerr.Wrap(err, "Failed to marshal alert data") 28 | } 29 | 30 | prompt := "Please analyze and summarize the given JSON-formatted security alert data, and suggest appropriate actions for the security administrator to respond to the alert: " + string(data) 31 | 32 | if v, ok := args["prompt"].(string); ok { 33 | prompt = v 34 | } 35 | 36 | if ctxutil.IsDryRun(ctx) { 37 | return nil, nil 38 | } 39 | 40 | resp, err := client.CreateChatCompletion( 41 | ctx, 42 | openai.ChatCompletionRequest{ 43 | Model: openai.GPT4o, 44 | Messages: []openai.ChatCompletionMessage{ 45 | { 46 | Role: openai.ChatMessageRoleUser, 47 | Content: prompt, 48 | }, 49 | }, 50 | }, 51 | ) 52 | if err != nil { 53 | return nil, goerr.Wrap(err, "Failed to call OpenAI API") 54 | } 55 | 56 | return utils.ToAny(resp) 57 | } 58 | -------------------------------------------------------------------------------- /action/chatgpt/analyst_test.go: -------------------------------------------------------------------------------- 1 | package chatgpt_test 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "encoding/json" 7 | "os" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/m-mizutani/gt" 13 | "github.com/sashabaranov/go-openai" 14 | "github.com/secmon-lab/alertchain/action/chatgpt" 15 | "github.com/secmon-lab/alertchain/pkg/domain/model" 16 | ) 17 | 18 | //go:embed testdata/aws_guardduty_example.json 19 | var alertData []byte 20 | 21 | func TestAnalystInquiry(t *testing.T) { 22 | if _, ok := os.LookupEnv("TEST_CHATGPT_ANALYST"); !ok { 23 | t.Skip("Skipping test because TEST_CHATGPT_ANALYST is not set") 24 | } 25 | 26 | var body any 27 | gt.NoError(t, json.Unmarshal(alertData, &body)) 28 | 29 | ctx := context.Background() 30 | alert := model.Alert{ 31 | AlertMetaData: model.AlertMetaData{ 32 | Title: "test", 33 | Description: "test", 34 | }, 35 | Data: body, 36 | CreatedAt: time.Now(), 37 | } 38 | 39 | resp := gt.R1(chatgpt.Query(ctx, alert, model.ActionArgs{ 40 | "secret_api_key": strings.TrimSpace(os.Getenv("TEST_CHATGPT_API_KEY")), 41 | })).NoError(t) 42 | raw, err := json.Marshal(resp) 43 | gt.NoError(t, err) 44 | 45 | var data openai.ChatCompletionResponse 46 | gt.NoError(t, json.Unmarshal(raw, &data)) 47 | 48 | gt.A(t, data.Choices).Length(1) 49 | t.Log(data.Choices[0].Message.Content) 50 | } 51 | -------------------------------------------------------------------------------- /action/chatgpt/testdata/aws_guardduty_example.json: -------------------------------------------------------------------------------- 1 | { 2 | "Findings": [ 3 | { 4 | "Resource": { 5 | "ResourceType": "AccessKey", 6 | "AccessKeyDetails": { 7 | "UserName": "testuser", 8 | "UserType": "IAMUser", 9 | "PrincipalId": "AIDACKCEVSQ6C2EXAMPLE", 10 | "AccessKeyId": "ASIASZ4SI7REEEXAMPLE" 11 | } 12 | }, 13 | "Description": "APIs commonly used to discover the users, groups, policies and permissions in an account, was invoked by IAM principal testuser under unusual circumstances. Such activity is not typically seen from this principal.", 14 | "Service": { 15 | "Count": 5, 16 | "Archived": false, 17 | "ServiceName": "guardduty", 18 | "EventFirstSeen": "2020-05-26T22:02:24Z", 19 | "ResourceRole": "TARGET", 20 | "EventLastSeen": "2020-05-26T22:33:55Z", 21 | "DetectorId": "d4b040365221be2b54a6264dcexample", 22 | "Action": { 23 | "ActionType": "AWS_API_CALL", 24 | "AwsApiCallAction": { 25 | "RemoteIpDetails": { 26 | "GeoLocation": { 27 | "Lat": 51.5164, 28 | "Lon": -0.093 29 | }, 30 | "City": { 31 | "CityName": "London" 32 | }, 33 | "IpAddressV4": "52.94.36.7", 34 | "Organization": { 35 | "Org": "Amazon.com", 36 | "Isp": "Amazon.com", 37 | "Asn": "16509", 38 | "AsnOrg": "AMAZON-02" 39 | }, 40 | "Country": { 41 | "CountryName": "United Kingdom" 42 | } 43 | }, 44 | "Api": "ListPolicyVersions", 45 | "ServiceName": "iam.amazonaws.com", 46 | "CallerType": "Remote IP" 47 | } 48 | } 49 | }, 50 | "Title": "Unusual user permission reconnaissance activity by testuser.", 51 | "Type": "Recon:IAMUser/UserPermissions", 52 | "Region": "us-east-1", 53 | "Partition": "aws", 54 | "Arn": "arn:aws:guardduty:us-east-1:111122223333:detector/d4b040365221be2b54a6264dcexample/finding/1ab92989eaf0e742df4a014d5example", 55 | "UpdatedAt": "2020-05-26T22:55:21.703Z", 56 | "SchemaVersion": "2.0", 57 | "Severity": 5, 58 | "Id": "1ab92989eaf0e742df4a014d5example", 59 | "CreatedAt": "2020-05-26T22:21:48.385Z", 60 | "AccountId": "111122223333" 61 | } 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /action/github/README.md: -------------------------------------------------------------------------------- 1 | # GitHub 2 | 3 | Actions for github.com 4 | 5 | ## `github.create_issue` 6 | 7 | This action creates an issue in the specified GitHub repository to serve as an alert handling ticket. 8 | 9 | ### Prerequisite 10 | 11 | You need to create a GitHub App. You can find instructions on how to do so [here](https://docs.github.com/en/apps/creating-github-apps/creating-github-apps/creating-a-github-app). 12 | 13 | The GitHub App requires `Read and Write` permissions for `Issues`, and you need to install it into the target repository. 14 | 15 | ### Arguments 16 | 17 | Example policy: 18 | 19 | ```rego 20 | run contains res if { 21 | res := { 22 | id: "your-action", 23 | uses: "github.create_issue", 24 | args: { 25 | "app_id": 134650, 26 | "install_id": 19102538, 27 | "owner": "m-mizutani", 28 | "repo": "security-alert", 29 | "secret_private_key": input.env.GITHUB_PRIVATE_KEY, 30 | }, 31 | }, 32 | } 33 | ``` 34 | 35 | - `app_id` (number, required): Specifies the ID of the GitHub App. 36 | - `install_id` (number, required): Specifies the installation ID of the GitHub account where the action will be executed. 37 | - `owner` (string, required): Specifies the owner name of the GitHub account where the action will be executed. 38 | - `repo` (string, required): Specifies the repository name of the GitHub account where the action will be executed. 39 | - `secret_private_key` (string, required): Specifies the private key of the GitHub App. 40 | - `assignee` (string, optional): Specifies the GitHub user to be assigned to the issue. 41 | - `labels` (array of strings, optional): Specifies the labels to be applied to the issue. 42 | 43 | Note: If you wish to use `assignee` or `labels`, the GitHub App must also have `Read and Write` permissions for `Content`. 44 | 45 | ### Response 46 | 47 | See https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#create-an-issue 48 | 49 | ## `github.create_comment` 50 | 51 | This action creates an issue comment in the specified GitHub repository issue. 52 | 53 | ### Prerequisite 54 | 55 | You need to create a GitHub App. You can find instructions on how to do so [here](https://docs.github.com/en/apps/creating-github-apps/creating-github-apps/creating-a-github-app). 56 | 57 | The GitHub App requires `Read and Write` permissions for `Issues`, and you need to install it into the target repository. 58 | 59 | ### Arguments 60 | 61 | Example policy: 62 | 63 | ```rego 64 | run contains res if { 65 | res := { 66 | id: "your-action", 67 | uses: "github.create_issue", 68 | args: { 69 | "app_id": 134650, 70 | "install_id": 19102538, 71 | "owner": "m-mizutani", 72 | "repo": "security-alert", 73 | "issue_number": 1, 74 | "secret_private_key": input.env.GITHUB_PRIVATE_KEY, 75 | "body": "This is a test comment.", 76 | }, 77 | }, 78 | } 79 | ``` 80 | 81 | - `app_id` (number, required): Specifies the ID of the GitHub App. 82 | - `install_id` (number, required): Specifies the installation ID of the GitHub account where the action will be executed. 83 | - `owner` (string, required): Specifies the owner name of the GitHub account where the action will be executed. 84 | - `repo` (string, required): Specifies the repository name of the GitHub account where the action will be executed. 85 | - `secret_private_key` (string, required): Specifies the private key of the GitHub App. 86 | - `body` (string, required): Specifies the body of the comment. 87 | 88 | ### Response 89 | 90 | See https://docs.github.com/en/rest/issues/comments?apiVersion=2022-11-28#create-an-issue-comment 91 | -------------------------------------------------------------------------------- /action/github/comment.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | 7 | "net/http" 8 | 9 | "log/slog" 10 | 11 | "github.com/bradleyfalzon/ghinstallation/v2" 12 | "github.com/google/go-github/github" 13 | "github.com/m-mizutani/goerr/v2" 14 | "github.com/m-mizutani/gots/ptr" 15 | "github.com/secmon-lab/alertchain/pkg/ctxutil" 16 | "github.com/secmon-lab/alertchain/pkg/domain/model" 17 | "github.com/secmon-lab/alertchain/pkg/domain/types" 18 | "github.com/secmon-lab/alertchain/pkg/utils" 19 | ) 20 | 21 | func CreateComment(ctx context.Context, alert model.Alert, args model.ActionArgs) (any, error) { 22 | // Required arguments 23 | appID, ok := args["app_id"].(float64) 24 | if !ok { 25 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "app_id is required") 26 | } 27 | 28 | installID, ok := args["install_id"].(float64) 29 | if !ok { 30 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "install_id is required") 31 | } 32 | 33 | privateKey, ok := args["secret_private_key"].(string) 34 | if !ok { 35 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "private_key is required") 36 | } else if !isRSAPrivateKey(privateKey) { 37 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "private_key must be RSA private key") 38 | } 39 | 40 | owner, ok := args["owner"].(string) 41 | if !ok { 42 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "owner is required") 43 | } 44 | 45 | repo, ok := args["repo"].(string) 46 | if !ok { 47 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "repo is required") 48 | } 49 | 50 | issue_number, ok := args["issue_number"].(float64) 51 | if !ok { 52 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "issue_number is required") 53 | } 54 | 55 | body, ok := args["body"].(string) 56 | if !ok { 57 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "body is required") 58 | } 59 | 60 | if ctxutil.IsDryRun(ctx) { 61 | return nil, nil 62 | } 63 | 64 | req := &github.IssueComment{ 65 | Body: &body, 66 | } 67 | 68 | rt := http.DefaultTransport 69 | 70 | itr, err := ghinstallation.New(rt, int64(appID), int64(installID), []byte(privateKey)) 71 | if err != nil { 72 | return nil, goerr.Wrap(err, "Failed to create GitHub App installation transport") 73 | } 74 | 75 | client := github.NewClient(&http.Client{Transport: itr}) 76 | 77 | comment, resp, err := client.Issues.CreateComment(ctx, owner, repo, int(issue_number), req) 78 | if err != nil { 79 | return nil, goerr.Wrap(err, "Failed to create GitHub comment") 80 | } 81 | if resp.StatusCode != http.StatusCreated { 82 | return nil, goerr.New("Failed to create GitHub comment (unexpected status code)", goerr.V("status", resp.StatusCode)) 83 | } 84 | 85 | ctxutil.Logger(ctx).Debug("Created GitHub comment", 86 | slog.Any("comment_id", ptr.From(comment.ID)), 87 | ) 88 | 89 | return utils.ToAny(comment) 90 | } 91 | -------------------------------------------------------------------------------- /action/github/comment_test.go: -------------------------------------------------------------------------------- 1 | package github_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "strconv" 7 | "testing" 8 | 9 | "github.com/m-mizutani/gt" 10 | "github.com/secmon-lab/alertchain/action/github" 11 | "github.com/secmon-lab/alertchain/pkg/domain/model" 12 | ) 13 | 14 | func TestComment(t *testing.T) { 15 | if _, ok := os.LookupEnv("TEST_GITHUB_COMMENT"); !ok { 16 | t.Skip("Skipping test because TEST_GITHUB_ISSUER is not set") 17 | } 18 | 19 | args := model.ActionArgs{ 20 | "app_id": float64(gt.R1(strconv.Atoi(os.Getenv("TEST_GITHUB_APP_ID"))).NoError(t)), 21 | "install_id": float64(gt.R1(strconv.Atoi(os.Getenv("TEST_GITHUB_INSTALL_ID"))).NoError(t)), 22 | "secret_private_key": os.Getenv("TEST_GITHUB_PRIVATE_KEY"), 23 | "owner": os.Getenv("TEST_GITHUB_OWNER"), 24 | "repo": os.Getenv("TEST_GITHUB_REPO"), 25 | "issue_number": float64(gt.R1(strconv.Atoi(os.Getenv("TEST_GITHUB_ISSUE_NUMBER"))).NoError(t)), 26 | "body": "this is test comment\n", 27 | } 28 | alert := model.NewAlert(model.AlertMetaData{}, "test_schema", "test_raw") 29 | 30 | ctx := context.Background() 31 | resp := gt.R1(github.CreateComment(ctx, alert, args)).NoError(t) 32 | gt.V(t, resp).NotNil() 33 | } 34 | -------------------------------------------------------------------------------- /action/github/export_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/secmon-lab/alertchain/pkg/domain/model" 7 | ) 8 | 9 | func ExecuteTemplate(w io.Writer, alert model.Alert) error { 10 | return issueTemplate.Execute(w, alert) 11 | } 12 | -------------------------------------------------------------------------------- /action/github/issuer.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/x509" 7 | _ "embed" 8 | "encoding/pem" 9 | "text/template" 10 | 11 | "net/http" 12 | 13 | "log/slog" 14 | 15 | "github.com/bradleyfalzon/ghinstallation/v2" 16 | "github.com/google/go-github/github" 17 | "github.com/m-mizutani/goerr/v2" 18 | "github.com/m-mizutani/gots/ptr" 19 | "github.com/secmon-lab/alertchain/pkg/ctxutil" 20 | "github.com/secmon-lab/alertchain/pkg/domain/model" 21 | "github.com/secmon-lab/alertchain/pkg/domain/types" 22 | "github.com/secmon-lab/alertchain/pkg/utils" 23 | ) 24 | 25 | //go:embed issuer_template.md 26 | var issueTemplateData string 27 | 28 | var issueTemplate *template.Template 29 | 30 | func init() { 31 | funcMap := template.FuncMap{ 32 | "add": func(a, b int) int { return a + b }, 33 | } 34 | 35 | issueTemplate = template.Must(template.New("issue").Funcs(funcMap).Parse(issueTemplateData)) 36 | } 37 | 38 | func CreateIssue(ctx context.Context, alert model.Alert, args model.ActionArgs) (any, error) { 39 | // Create a new issue body from template 40 | var buf bytes.Buffer 41 | if err := issueTemplate.Execute(&buf, alert); err != nil { 42 | return nil, goerr.Wrap(err, "Failed to render issue template") 43 | } 44 | req := &github.IssueRequest{ 45 | Title: &alert.Title, 46 | Body: github.String(buf.String()), 47 | } 48 | 49 | // Required arguments 50 | appID, ok := args["app_id"].(float64) 51 | if !ok { 52 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "app_id is required") 53 | } 54 | 55 | installID, ok := args["install_id"].(float64) 56 | if !ok { 57 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "install_id is required") 58 | } 59 | 60 | privateKey, ok := args["secret_private_key"].(string) 61 | if !ok { 62 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "private_key is required") 63 | } else if !isRSAPrivateKey(privateKey) { 64 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "private_key must be RSA private key") 65 | } 66 | 67 | owner, ok := args["owner"].(string) 68 | if !ok { 69 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "owner is required") 70 | } 71 | 72 | repo, ok := args["repo"].(string) 73 | if !ok { 74 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "repo is required") 75 | } 76 | 77 | // Optional arguments 78 | if v, ok := args["assignee"].(string); ok && v != "" { 79 | req.Assignee = github.String(v) 80 | } 81 | if v, ok := args["labels"].([]string); ok && len(v) > 0 { 82 | req.Labels = &v 83 | } 84 | 85 | if ctxutil.IsDryRun(ctx) { 86 | return nil, nil 87 | } 88 | 89 | rt := http.DefaultTransport 90 | 91 | itr, err := ghinstallation.New(rt, int64(appID), int64(installID), []byte(privateKey)) 92 | if err != nil { 93 | return nil, goerr.Wrap(err, "Failed to create GitHub App installation transport") 94 | } 95 | 96 | client := github.NewClient(&http.Client{Transport: itr}) 97 | 98 | issue, resp, err := client.Issues.Create(ctx, owner, repo, req) 99 | if err != nil { 100 | return nil, goerr.Wrap(err, "Failed to create GitHub issue") 101 | } 102 | if resp.StatusCode != http.StatusCreated { 103 | return nil, goerr.New("unexpected status code in creating GitHub issue", goerr.V("status", resp.StatusCode)) 104 | } 105 | 106 | ctxutil.Logger(ctx).Debug("Created GitHub issue", 107 | slog.Any("issue_number", ptr.From(issue.Number)), 108 | slog.Any("title", ptr.From(issue.Title)), 109 | ) 110 | 111 | return utils.ToAny(issue) 112 | } 113 | 114 | func isRSAPrivateKey(s string) bool { 115 | block, _ := pem.Decode([]byte(s)) 116 | if block == nil { 117 | return false 118 | } 119 | 120 | _, err := x509.ParsePKCS1PrivateKey(block.Bytes) 121 | return err == nil 122 | } 123 | -------------------------------------------------------------------------------- /action/github/issuer_template.md: -------------------------------------------------------------------------------- 1 | {{$countMarkdown := 0}} 2 | - ID: {{ .ID }} 3 | - Created At: {{ .CreatedAt }} 4 | - Schema: {{ .Schema }} 5 | - Detected by: {{ .Source }} 6 | 7 | ## Description 8 | {{.Description}} 9 | 10 | ## Attributes 11 | 12 | | Name | Value | Type | 13 | |------|-------|------| 14 | {{range .Attrs}}{{ if ne .Type "markdown" }} | {{ .Key }} | `{{ .Value }}` | {{ .Type }} | 15 | {{else}}{{ $countMarkdown = add $countMarkdown 1 }}{{end}}{{end}} 16 | 17 | {{ if gt $countMarkdown 0 }} 18 | ## Comments 19 | 20 | {{range .Attrs}}{{ if eq .Type "markdown" }} 21 | ### {{ .Key }} 22 | 23 | {{ .Value }} 24 | 25 | {{end}}{{end}} 26 | {{end}} 27 | 28 | {{ if .Refs }} 29 | ## References 30 | 31 | {{range .Refs}} 32 | - [{{ .Title }}]({{ .URL }}) 33 | {{end}} 34 | {{end}} 35 | 36 | 37 | ## Alert 38 | 39 |
40 | Raw data 41 | 42 | ```json 43 | 44 | {{ .Raw }} 45 | 46 | ``` 47 | 48 |
49 | -------------------------------------------------------------------------------- /action/http/README.md: -------------------------------------------------------------------------------- 1 | # HTTP 2 | 3 | ## `http.fetch` 4 | 5 | This action fetches data from the specified URL using the specified HTTP method and returns the result. 6 | 7 | ### Arguments 8 | 9 | Example policy: 10 | 11 | ```rego 12 | run contains res if { 13 | res := { 14 | id: "your-action", 15 | uses: "http.fetch", 16 | args: { 17 | "method": "GET", 18 | "url": "https://api.example.com/data", 19 | "header": { 20 | "Authorization": "Bearer " + input.env.API_TOKEN, 21 | }, 22 | }, 23 | }, 24 | } 25 | ``` 26 | 27 | - `method` (string, required): Specifies the HTTP method to use for the request (e.g., "GET", "POST", "PUT", "DELETE"). 28 | - `url` (string, required): Specifies the URL to fetch the data from. 29 | - `header` (map of strings, optional): Specifies the HTTP headers to include in the request. 30 | - `data` (string, optional): Specifies the request body to include in the request (for methods like "POST" and "PUT"). 31 | 32 | ### Response 33 | 34 | The response depends on the `Content-Type` of the HTTP response. The following content types are supported: 35 | 36 | - `application/json`: The response body will be parsed as JSON and returned as an object. 37 | - `application/octet-stream`: The response body will be returned as a byte array. 38 | - Other content types: The response body will be returned as a string. 39 | 40 | In case of an error while making the HTTP request or processing the response, an error will be returned. -------------------------------------------------------------------------------- /action/http/fetch.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/m-mizutani/goerr/v2" 11 | "github.com/secmon-lab/alertchain/pkg/ctxutil" 12 | "github.com/secmon-lab/alertchain/pkg/domain/model" 13 | "github.com/secmon-lab/alertchain/pkg/domain/types" 14 | ) 15 | 16 | func Fetch(ctx context.Context, _ model.Alert, args model.ActionArgs) (any, error) { 17 | method, ok := args["method"].(string) 18 | if !ok { 19 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "method is required") 20 | } 21 | 22 | url, ok := args["url"].(string) 23 | if !ok { 24 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "url is required") 25 | } 26 | 27 | var reqBody io.Reader 28 | if data, ok := args["data"].(string); ok { 29 | reqBody = strings.NewReader(data) 30 | } 31 | 32 | req, err := http.NewRequestWithContext(ctx, method, url, reqBody) 33 | if err != nil { 34 | return nil, goerr.Wrap(err, "Fail to create HTTP request") 35 | } 36 | 37 | if v, ok := args["header"].(map[string]string); ok { 38 | for k, v := range v { 39 | req.Header.Add(k, v) 40 | } 41 | } 42 | 43 | resp, err := http.DefaultClient.Do(req) 44 | if err != nil { 45 | return nil, goerr.Wrap(err, "Fail to send HTTP request") 46 | } 47 | defer func() { 48 | if err := resp.Body.Close(); err != nil { 49 | ctxutil.Logger(ctx).Warn("Fail to close HTTP response body", "err", err) 50 | } 51 | }() 52 | 53 | var result any 54 | respBody, err := io.ReadAll(resp.Body) 55 | if err != nil { 56 | return nil, goerr.Wrap(err, "Fail to read HTTP response body") 57 | } 58 | 59 | switch resp.Header.Get("Content-Type") { 60 | case "application/json": 61 | if err := json.Unmarshal(respBody, &result); err != nil { 62 | return nil, goerr.Wrap(err, "Fail to parse JSON response") 63 | } 64 | 65 | case "application/octet-stream": 66 | result = respBody 67 | 68 | default: 69 | result = string(respBody) 70 | } 71 | 72 | return result, nil 73 | } 74 | -------------------------------------------------------------------------------- /action/http/fetch_test.go: -------------------------------------------------------------------------------- 1 | package http_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/m-mizutani/gt" 10 | httpaction "github.com/secmon-lab/alertchain/action/http" 11 | "github.com/secmon-lab/alertchain/pkg/domain/model" 12 | ) 13 | 14 | func TestFetch(t *testing.T) { 15 | t.Run("Success", func(t *testing.T) { 16 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | gt.Value(t, r.Method).Equal("GET") 18 | gt.Value(t, r.URL.Path).Equal("/test") 19 | w.Header().Add("Content-Type", "application/json") 20 | gt.R1(w.Write([]byte(`{"message":"Hello"}`))).NoError(t) 21 | })) 22 | defer ts.Close() 23 | 24 | ctx := context.Background() 25 | 26 | args := model.ActionArgs{ 27 | "method": "GET", 28 | "url": ts.URL + "/test", 29 | } 30 | 31 | result := gt.R1(httpaction.Fetch(ctx, model.Alert{}, args)).NoError(t) 32 | 33 | res := gt.Cast[map[string]interface{}](t, result) 34 | 35 | gt.Value(t, res["message"].(string)).Equal("Hello") 36 | }) 37 | 38 | t.Run("Invalid argument", func(t *testing.T) { 39 | ctx := context.Background() 40 | 41 | args := model.ActionArgs{} 42 | 43 | gt.R1(httpaction.Fetch(ctx, model.Alert{}, args)).Error(t) 44 | }) 45 | 46 | t.Run("Invalid JSON response", func(t *testing.T) { 47 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 | w.Header().Add("Content-Type", "application/json") 49 | gt.R1(w.Write([]byte(`{"message"}`))).NoError(t) 50 | })) 51 | defer ts.Close() 52 | 53 | ctx := context.Background() 54 | 55 | args := model.ActionArgs{ 56 | "method": "GET", 57 | "url": ts.URL + "/test", 58 | } 59 | 60 | gt.R1(httpaction.Fetch(ctx, model.Alert{}, args)).Error(t) 61 | }) 62 | 63 | t.Run("HTTP request error", func(t *testing.T) { 64 | ctx := context.Background() 65 | 66 | args := model.ActionArgs{ 67 | "method": "GET", 68 | "url": "http://example.invalid", 69 | } 70 | 71 | gt.R1(httpaction.Fetch(ctx, model.Alert{}, args)).Error(t) 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /action/jira/add_attachment.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/andygrunwald/go-jira" 8 | "github.com/m-mizutani/goerr/v2" 9 | "github.com/secmon-lab/alertchain/pkg/domain/model" 10 | "github.com/secmon-lab/alertchain/pkg/utils" 11 | ) 12 | 13 | func AddAttachment(ctx context.Context, alert model.Alert, args model.ActionArgs) (any, error) { 14 | var ( 15 | accountID string 16 | userName string 17 | token string 18 | baseURL string 19 | issueID string 20 | fileName string 21 | data string 22 | ) 23 | 24 | if err := args.Parse( 25 | model.ArgDef("account_id", &accountID), 26 | model.ArgDef("user", &userName), 27 | model.ArgDef("secret_token", &token), 28 | model.ArgDef("base_url", &baseURL), 29 | model.ArgDef("issue_id", &issueID), 30 | model.ArgDef("file_name", &fileName), 31 | model.ArgDef("data", &data), 32 | ); err != nil { 33 | return nil, err 34 | } 35 | 36 | tp := jira.BasicAuthTransport{ 37 | Username: userName, 38 | Password: token, 39 | } 40 | 41 | jiraClient, err := jira.NewClient(tp.Client(), baseURL) 42 | if err != nil { 43 | return nil, goerr.Wrap(err, "Failed to create JIRA client") 44 | } 45 | 46 | body := strings.NewReader(data) 47 | attach, _, err := jiraClient.Issue.PostAttachmentWithContext(ctx, issueID, body, fileName) 48 | if err != nil { 49 | return nil, goerr.Wrap(err, "Failed to post attachment") 50 | } 51 | 52 | return utils.ToAny(attach) 53 | } 54 | -------------------------------------------------------------------------------- /action/jira/add_attachment_test.go: -------------------------------------------------------------------------------- 1 | package jira_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | go_jira "github.com/andygrunwald/go-jira" 8 | 9 | "github.com/google/uuid" 10 | "github.com/m-mizutani/gt" 11 | "github.com/secmon-lab/alertchain/action/jira" 12 | "github.com/secmon-lab/alertchain/pkg/domain/model" 13 | "github.com/secmon-lab/alertchain/pkg/utils" 14 | ) 15 | 16 | func TestAddAttachment(t *testing.T) { 17 | var ( 18 | accountID string 19 | userName string 20 | token string 21 | baseURL string 22 | issueID string 23 | ) 24 | 25 | if err := utils.LoadEnv( 26 | utils.EnvDef("TEST_JIRA_ACCOUNT_ID", &accountID), 27 | utils.EnvDef("TEST_JIRA_USER", &userName), 28 | utils.EnvDef("TEST_JIRA_TOKEN", &token), 29 | utils.EnvDef("TEST_JIRA_BASE_URL", &baseURL), 30 | utils.EnvDef("TEST_JIRA_ISSUE_ID", &issueID), 31 | ); err != nil { 32 | t.Skipf("Skip test due to missing env: %v", err) 33 | } 34 | 35 | args := model.ActionArgs{ 36 | "account_id": accountID, 37 | "user": userName, 38 | "secret_token": token, 39 | "base_url": baseURL, 40 | "issue_id": issueID, 41 | "file_name": uuid.NewString() + ".txt", 42 | "data": "test comment", 43 | } 44 | 45 | ctx := context.Background() 46 | alert := model.NewAlert(model.AlertMetaData{ 47 | Title: "test_alert", 48 | Description: "test_description", 49 | Source: "test_source", 50 | }, "test_alert", struct{}{}) 51 | 52 | ret := gt.R1(jira.AddAttachment(ctx, alert, args)).NoError(t) 53 | attach := gt.Cast[*[]go_jira.Attachment](t, ret) 54 | gt.A(t, *attach).Length(1).At(0, func(t testing.TB, v go_jira.Attachment) { 55 | gt.Equal(t, v.Author.EmailAddress, userName) 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /action/jira/add_comment.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | 7 | "github.com/andygrunwald/go-jira" 8 | "github.com/m-mizutani/goerr/v2" 9 | "github.com/secmon-lab/alertchain/pkg/domain/model" 10 | "github.com/secmon-lab/alertchain/pkg/utils" 11 | ) 12 | 13 | func AddComment(ctx context.Context, alert model.Alert, args model.ActionArgs) (any, error) { 14 | var ( 15 | accountID string 16 | userName string 17 | token string 18 | baseURL string 19 | issueID string 20 | body string 21 | ) 22 | 23 | if err := args.Parse( 24 | model.ArgDef("account_id", &accountID), 25 | model.ArgDef("user", &userName), 26 | model.ArgDef("secret_token", &token), 27 | model.ArgDef("base_url", &baseURL), 28 | model.ArgDef("issue_id", &issueID), 29 | model.ArgDef("body", &body), 30 | ); err != nil { 31 | return nil, err 32 | } 33 | 34 | tp := jira.BasicAuthTransport{ 35 | Username: userName, 36 | Password: token, 37 | } 38 | 39 | jiraClient, err := jira.NewClient(tp.Client(), baseURL) 40 | if err != nil { 41 | return nil, goerr.Wrap(err, "Failed to create JIRA client") 42 | } 43 | 44 | input := &jira.Comment{ 45 | Author: jira.User{ 46 | AccountID: accountID, 47 | }, 48 | Body: body, 49 | } 50 | comment, _, err := jiraClient.Issue.AddCommentWithContext(ctx, issueID, input) 51 | if err != nil { 52 | return nil, goerr.Wrap(err, "Failed to add comment") 53 | } 54 | 55 | return utils.ToAny(comment) 56 | } 57 | -------------------------------------------------------------------------------- /action/jira/add_comment_test.go: -------------------------------------------------------------------------------- 1 | package jira_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | go_jira "github.com/andygrunwald/go-jira" 8 | 9 | "github.com/m-mizutani/gt" 10 | "github.com/secmon-lab/alertchain/action/jira" 11 | "github.com/secmon-lab/alertchain/pkg/domain/model" 12 | "github.com/secmon-lab/alertchain/pkg/utils" 13 | ) 14 | 15 | func TestAddComment(t *testing.T) { 16 | var ( 17 | accountID string 18 | userName string 19 | token string 20 | baseURL string 21 | issueID string 22 | ) 23 | 24 | if err := utils.LoadEnv( 25 | utils.EnvDef("TEST_JIRA_ACCOUNT_ID", &accountID), 26 | utils.EnvDef("TEST_JIRA_USER", &userName), 27 | utils.EnvDef("TEST_JIRA_TOKEN", &token), 28 | utils.EnvDef("TEST_JIRA_BASE_URL", &baseURL), 29 | utils.EnvDef("TEST_JIRA_ISSUE_ID", &issueID), 30 | ); err != nil { 31 | t.Skipf("Skip test due to missing env: %v", err) 32 | } 33 | 34 | args := model.ActionArgs{ 35 | "account_id": accountID, 36 | "user": userName, 37 | "secret_token": token, 38 | "base_url": baseURL, 39 | "issue_id": issueID, 40 | "body": "test comment", 41 | } 42 | 43 | ctx := context.Background() 44 | alert := model.NewAlert(model.AlertMetaData{ 45 | Title: "test_alert", 46 | Description: "test_description", 47 | Source: "test_source", 48 | }, "test_alert", struct{}{}) 49 | 50 | ret := gt.R1(jira.AddComment(ctx, alert, args)).NoError(t) 51 | comment := gt.Cast[*go_jira.Comment](t, ret) 52 | gt.Equal(t, comment.Author.EmailAddress, userName) 53 | } 54 | -------------------------------------------------------------------------------- /action/jira/create_issue.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | _ "embed" 7 | "fmt" 8 | "io" 9 | "strings" 10 | "text/template" 11 | 12 | "github.com/andygrunwald/go-jira" 13 | "github.com/m-mizutani/goerr/v2" 14 | "github.com/secmon-lab/alertchain/pkg/domain/model" 15 | "github.com/secmon-lab/alertchain/pkg/utils" 16 | ) 17 | 18 | //go:embed issue_template.txt 19 | var issueTemplateData string 20 | 21 | var issueTemplate *template.Template 22 | 23 | func init() { 24 | funcMap := template.FuncMap{ 25 | "add": func(a, b int) int { return a + b }, 26 | } 27 | 28 | issueTemplate = template.Must(template.New("issue").Funcs(funcMap).Parse(issueTemplateData)) 29 | } 30 | 31 | func execTemplate(data interface{}) (string, error) { 32 | var buf bytes.Buffer 33 | if err := issueTemplate.Execute(&buf, data); err != nil { 34 | return "", goerr.Wrap(err, "Failed to execute issue template") 35 | } 36 | return buf.String(), nil 37 | } 38 | 39 | func CreateIssue(ctx context.Context, alert model.Alert, args model.ActionArgs) (any, error) { 40 | var ( 41 | accountID string 42 | userName string 43 | token string 44 | baseURL string 45 | project string 46 | issueType string 47 | labels []string 48 | assignee string 49 | ) 50 | 51 | if err := args.Parse( 52 | model.ArgDef("account_id", &accountID), 53 | model.ArgDef("user", &userName), 54 | model.ArgDef("secret_token", &token), 55 | model.ArgDef("base_url", &baseURL), 56 | model.ArgDef("project", &project), 57 | model.ArgDef("issue_type", &issueType), 58 | model.ArgDef("labels", &labels, model.ArgOptional()), 59 | model.ArgDef("assignee", &assignee, model.ArgOptional()), 60 | ); err != nil { 61 | return nil, err 62 | } 63 | 64 | tp := jira.BasicAuthTransport{ 65 | Username: userName, 66 | Password: token, 67 | } 68 | 69 | jiraClient, err := jira.NewClient(tp.Client(), baseURL) 70 | if err != nil { 71 | return nil, goerr.Wrap(err, "Failed to create JIRA client") 72 | } 73 | 74 | buf, err := execTemplate(alert) 75 | if err != nil { 76 | return nil, goerr.Wrap(err, "Failed to execute issue template") 77 | } 78 | 79 | i := jira.Issue{ 80 | Fields: &jira.IssueFields{ 81 | Reporter: &jira.User{ 82 | AccountID: accountID, 83 | }, 84 | Type: jira.IssueType{ 85 | Name: issueType, 86 | }, 87 | Project: jira.Project{ 88 | Key: project, 89 | }, 90 | Summary: alert.Title, 91 | Description: buf, 92 | }, 93 | } 94 | if len(labels) > 0 { 95 | i.Fields.Labels = labels 96 | } 97 | if assignee != "" { 98 | i.Fields.Assignee = &jira.User{ 99 | AccountID: assignee, 100 | } 101 | } 102 | 103 | issue, resp, err := jiraClient.Issue.CreateWithContext(ctx, &i) 104 | if err != nil { 105 | data, _ := io.ReadAll(resp.Body) 106 | return nil, goerr.Wrap(err, "Failed to create issue", goerr.V("body", string(data))) 107 | } 108 | 109 | fname := fmt.Sprintf("alert-%s.json", alert.ID) 110 | body := strings.NewReader(alert.Raw) 111 | if _, _, err := jiraClient.Issue.PostAttachmentWithContext(ctx, issue.ID, body, fname); err != nil { 112 | return nil, goerr.Wrap(err, "Failed to post attachment") 113 | } 114 | 115 | return utils.ToAny(issue) 116 | } 117 | -------------------------------------------------------------------------------- /action/jira/create_issue_test.go: -------------------------------------------------------------------------------- 1 | package jira_test 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "testing" 7 | 8 | "github.com/m-mizutani/gt" 9 | "github.com/secmon-lab/alertchain/action/jira" 10 | "github.com/secmon-lab/alertchain/pkg/domain/model" 11 | "github.com/secmon-lab/alertchain/pkg/utils" 12 | ) 13 | 14 | func TestCreateIssue(t *testing.T) { 15 | var ( 16 | accountID string 17 | userName string 18 | token string 19 | baseURL string 20 | project string 21 | ) 22 | 23 | if err := utils.LoadEnv( 24 | utils.EnvDef("TEST_JIRA_ACCOUNT_ID", &accountID), 25 | utils.EnvDef("TEST_JIRA_USER", &userName), 26 | utils.EnvDef("TEST_JIRA_TOKEN", &token), 27 | utils.EnvDef("TEST_JIRA_BASE_URL", &baseURL), 28 | utils.EnvDef("TEST_JIRA_PROJECT", &project), 29 | ); err != nil { 30 | t.Skipf("Skip test due to missing env: %v", err) 31 | } 32 | 33 | args := model.ActionArgs{ 34 | "account_id": accountID, 35 | "user": userName, 36 | "secret_token": token, 37 | "base_url": baseURL, 38 | "project": project, 39 | "issue_type": "Task", 40 | "labels": []string{"test"}, 41 | "assignee": accountID, 42 | } 43 | 44 | ctx := context.Background() 45 | alert := model.NewAlert(model.AlertMetaData{ 46 | Title: "Alert testing", 47 | Description: "This is test alert", 48 | Source: "test_source", 49 | Attrs: model.Attributes{ 50 | { 51 | ID: "my_id", 52 | Key: "my_key", 53 | Value: "my_value", 54 | }, 55 | }, 56 | Refs: model.References{ 57 | { 58 | Title: "my_ref_title", 59 | URL: "https://example.com", 60 | }, 61 | }, 62 | }, "test_alert", struct { 63 | MyRecord string 64 | }{ 65 | MyRecord: "my_record", 66 | }) 67 | 68 | issue := gt.R1(jira.CreateIssue(ctx, alert, args)).NoError(t) 69 | gt.V(t, issue).NotNil() 70 | } 71 | 72 | func TestTemplate(t *testing.T) { 73 | alert := model.NewAlert(model.AlertMetaData{ 74 | Title: "my test alert", 75 | Description: "test_description", 76 | Source: "test_source", 77 | Attrs: model.Attributes{ 78 | { 79 | ID: "my_id", 80 | Key: "my_key", 81 | Value: "my_value", 82 | }, 83 | }, 84 | Refs: model.References{ 85 | { 86 | Title: "my_ref_title", 87 | URL: "my_ref_url", 88 | }, 89 | }, 90 | }, "test_alert", struct { 91 | MyRecord string 92 | }{ 93 | MyRecord: "my_record", 94 | }) 95 | 96 | buf := gt.R1(jira.ExecTemplate(alert)).NoError(t) 97 | gt.S(t, buf). 98 | NotContains("my test alert"). // should not contain title 99 | Contains("test_description"). 100 | NotContains("my_id"). 101 | Contains("my_key"). // should contain attribute key 102 | Contains("my_value"). 103 | Contains("my_ref_title"). 104 | Contains("my_ref_url") 105 | } 106 | -------------------------------------------------------------------------------- /action/jira/export_test.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | var ( 4 | ExecTemplate = execTemplate 5 | ) 6 | -------------------------------------------------------------------------------- /action/jira/issue_template.txt: -------------------------------------------------------------------------------- 1 | {{$countMarkdown := 0}} 2 | - ID: {{ .ID }} 3 | - Created At: {{ .CreatedAt }} 4 | - Schema: {{ .Schema }} 5 | - Detected by: {{ .Source }} 6 | 7 | h2. Description 8 | 9 | {{.Description}} 10 | 11 | h2. Attributes 12 | 13 | || Name || Value || Type || 14 | {{range .Attrs}}{{ if ne .Type "markdown" }} | {{ .Key }} | {{"{{"}}{{ .Value }}{{"}}"}} | {{ .Type }} | 15 | {{else}}{{ $countMarkdown = add $countMarkdown 1 }}{{end}}{{end}} 16 | 17 | {{ if gt $countMarkdown 0 }} 18 | h2. Comments 19 | 20 | {{range .Attrs}}{{ if eq .Type "markdown" }} 21 | .h2 {{ .Key }} 22 | 23 | {{ .Value }} 24 | 25 | {{end}}{{end}} 26 | {{end}} 27 | 28 | {{ if .Refs }} 29 | h2. References 30 | 31 | {{range .Refs}} 32 | - [{{ .Title }}|{{ .URL }}] 33 | {{end}} 34 | {{end}} 35 | -------------------------------------------------------------------------------- /action/opsgenie/README.md: -------------------------------------------------------------------------------- 1 | # Opsgenie 2 | 3 | Actions for [Opsgenie](https://www.atlassian.com/software/opsgenie) 4 | 5 | ## Prerequisites 6 | 7 | - **Opsgenie account**: You need to have an Opsgenie account and the necessary permissions to create alerts. 8 | - **API key**: You need to create an integration API key. You can find management console at `https:///settings/integrations/` 9 | 10 | ## `opsgenie.create_alert` 11 | 12 | This action creates an alert in Opsgenie. 13 | 14 | ### Arguments 15 | 16 | Example policy: 17 | 18 | ```rego 19 | run contains job if { 20 | job := { 21 | id: "your-action", 22 | uses: "opsgenie.create_alert", 23 | args: { 24 | "secret_api_key": input.env.OPSGENIE_API_KEY, 25 | "responders": [ 26 | { 27 | "id": "3f68caf0-xxxx-xxxx-xxxx-xxxxxxxxxxxx", 28 | "type": "team", 29 | }, 30 | ], 31 | } 32 | } 33 | } 34 | ``` 35 | 36 | - `secret_api_key` (string, required): Specifies the API key for Opsgenie. 37 | - `responders` (array of structure, optional): Specifies the responders of the alert. See https://docs.opsgenie.com/docs/alert-api#section-create-alert for details. 38 | - `id` (string): Specifies the ID of the responder. 39 | - `name` (string): Specifies the name of the responder. 40 | - `username` (string): Specifies the username of the responder. 41 | - `type` (string, required): Specifies the type of the responder. Possible values are `team`, `user`, `escalation` and `schedule`. 42 | -------------------------------------------------------------------------------- /action/opsgenie/create_alert.go: -------------------------------------------------------------------------------- 1 | package opsgenie 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/m-mizutani/goerr/v2" 8 | og_alert "github.com/opsgenie/opsgenie-go-sdk-v2/alert" 9 | "github.com/opsgenie/opsgenie-go-sdk-v2/client" 10 | "github.com/secmon-lab/alertchain/pkg/domain/model" 11 | "github.com/secmon-lab/alertchain/pkg/utils" 12 | ) 13 | 14 | type Responder struct { 15 | ID string `json:"id"` 16 | Name string `json:"name"` 17 | UserName string `json:"username"` 18 | Type string `json:"type"` 19 | } 20 | 21 | func CreateAlert(ctx context.Context, alert model.Alert, args model.ActionArgs) (any, error) { 22 | var ( 23 | apiKey string 24 | responders []Responder 25 | ) 26 | 27 | if err := args.Parse( 28 | model.ArgDef("secret_api_key", &apiKey), 29 | model.ArgDef("responder_teams", &responders, model.ArgOptional()), 30 | ); err != nil { 31 | return nil, err 32 | } 33 | 34 | c, err := og_alert.NewClient(&client.Config{ 35 | ApiKey: apiKey, 36 | }) 37 | if err != nil { 38 | return nil, goerr.Wrap(err, "Failed to create OpsGenie client") 39 | } 40 | 41 | req := &og_alert.CreateAlertRequest{ 42 | Message: alert.Title, 43 | Description: alert.Description, 44 | Alias: alert.ID.String(), 45 | Source: "alertchain", 46 | Details: map[string]string{}, 47 | } 48 | for _, attr := range alert.Attrs { 49 | req.Details[attr.Key.String()] = fmt.Sprintf("%+v", attr.Value) 50 | } 51 | 52 | for _, r := range responders { 53 | req.Responders = append(req.Responders, og_alert.Responder{ 54 | Type: og_alert.ResponderType(r.Type), 55 | Id: r.ID, 56 | Name: r.Name, 57 | Username: r.UserName, 58 | }) 59 | } 60 | 61 | resp, err := c.Create(ctx, req) 62 | if err != nil { 63 | return nil, goerr.Wrap(err, "Failed to create OpsGenie alert") 64 | } 65 | 66 | return utils.ToAny(resp) 67 | } 68 | -------------------------------------------------------------------------------- /action/opsgenie/create_alert_test.go: -------------------------------------------------------------------------------- 1 | package opsgenie_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/m-mizutani/gt" 8 | "github.com/opsgenie/opsgenie-go-sdk-v2/alert" 9 | "github.com/secmon-lab/alertchain/action/opsgenie" 10 | "github.com/secmon-lab/alertchain/pkg/domain/model" 11 | "github.com/secmon-lab/alertchain/pkg/utils" 12 | ) 13 | 14 | func TestOpsgenie(t *testing.T) { 15 | var ( 16 | apiKey string 17 | responderID string 18 | responderType string 19 | ) 20 | 21 | if err := utils.LoadEnv( 22 | utils.EnvDef("TEST_OPSGENIE_API_KEY", &apiKey), 23 | utils.EnvDef("TEST_OPSGENIE_RESPONDER_ID", &responderID), 24 | utils.EnvDef("TEST_OPSGENIE_RESPONDER_TYPE", &responderType), 25 | ); err != nil { 26 | t.Skipf("Skip test due to missing env: %v", err) 27 | } 28 | 29 | t.Run("Create alert", func(t *testing.T) { 30 | input := model.NewAlert(model.AlertMetaData{ 31 | Title: "test_alert", 32 | Description: "test_description", 33 | Source: "test_source", 34 | Attrs: model.Attributes{ 35 | { 36 | Key: "key1", 37 | Value: "val1", 38 | }, 39 | }, 40 | }, "test_alert", struct{}{}) 41 | ctx := context.Background() 42 | args := model.ActionArgs{ 43 | "secret_api_key": apiKey, 44 | "responders": []opsgenie.Responder{ 45 | { 46 | ID: responderID, 47 | Type: responderType, 48 | }, 49 | }, 50 | } 51 | 52 | ret := gt.R1(opsgenie.CreateAlert(ctx, input, args)).NoError(t) 53 | resp := gt.Cast[*alert.AsyncAlertResult](t, ret) 54 | gt.NotEqual(t, resp.RequestId, "") 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /action/otx/action.go: -------------------------------------------------------------------------------- 1 | package otx 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/m-mizutani/goerr/v2" 10 | "github.com/secmon-lab/alertchain/pkg/ctxutil" 11 | "github.com/secmon-lab/alertchain/pkg/domain/model" 12 | "github.com/secmon-lab/alertchain/pkg/domain/types" 13 | ) 14 | 15 | func ReplaceHTTPClient(client *http.Client) { 16 | httpClient = client 17 | } 18 | 19 | var httpClient = http.DefaultClient 20 | 21 | func Indicator(ctx context.Context, _ model.Alert, args model.ActionArgs) (any, error) { 22 | api_key, ok := args["secret_api_key"].(string) 23 | if !ok { 24 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "secret_api_key is required") 25 | } 26 | 27 | indicatorType, ok := args["type"].(string) 28 | if !ok { 29 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "type is required") 30 | } 31 | if !isValidType(indicatorType) { 32 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "type must be one of ipv4, ipv6, domain, hostname, file, url") 33 | } 34 | 35 | indicator, ok := args["indicator"].(string) 36 | if !ok { 37 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "indicator is required") 38 | } 39 | 40 | section, ok := args["section"].(string) 41 | if !ok { 42 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "section is required") 43 | } 44 | if !isValidSection(section) { 45 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "section must be one of general, reputation, geo, malware, url_list, passive_dns, http_scans") 46 | } 47 | 48 | url := "https://otx.alienvault.com/api/v1/indicators/" + indicatorType + "/" + indicator + "/" + section 49 | 50 | req, err := http.NewRequest("GET", url, nil) 51 | if err != nil { 52 | return nil, goerr.Wrap(err, "Fail to create HTTP request for OTX") 53 | } 54 | req.Header.Set("X-OTX-API-KEY", api_key) 55 | 56 | resp, err := httpClient.Do(req) 57 | if err != nil { 58 | return nil, goerr.Wrap(err, "Fail to send HTTP request to OTX") 59 | } 60 | defer func() { 61 | if err := resp.Body.Close(); err != nil { 62 | ctxutil.Logger(ctx).Warn("Fail to close HTTP response body", "err", err) 63 | } 64 | }() 65 | if resp.StatusCode != http.StatusOK { 66 | body, _ := io.ReadAll(resp.Body) 67 | return nil, goerr.New("OTX returns non-200 status code", 68 | goerr.V("status", resp.StatusCode), 69 | goerr.V("url", url), 70 | goerr.V("body", string(body)), 71 | ) 72 | } 73 | 74 | var result any 75 | respBody, err := io.ReadAll(resp.Body) 76 | if err != nil { 77 | return nil, goerr.Wrap(err, "Fail to read HTTP response body from OTX") 78 | } 79 | if err := json.Unmarshal(respBody, &result); err != nil { 80 | return nil, goerr.Wrap(err, "Fail to parse JSON response from OTX", goerr.V("body", string(respBody))) 81 | } 82 | 83 | return result, nil 84 | } 85 | 86 | func isValidSection(section string) bool { 87 | sections := []string{"general", "reputation", "geo", "malware", "url_list", "passive_dns", "http_scans"} 88 | for _, s := range sections { 89 | if section == s { 90 | return true 91 | } 92 | } 93 | return false 94 | } 95 | 96 | func isValidType(t string) bool { 97 | categories := []string{"IPv4", "IPv6", "domain", "hostname", "file", "url"} 98 | for _, c := range categories { 99 | if t == c { 100 | return true 101 | } 102 | } 103 | return false 104 | } 105 | -------------------------------------------------------------------------------- /action/otx/action_test.go: -------------------------------------------------------------------------------- 1 | package otx_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net/http" 8 | "os" 9 | "testing" 10 | 11 | "github.com/m-mizutani/gt" 12 | "github.com/secmon-lab/alertchain/action/otx" 13 | "github.com/secmon-lab/alertchain/pkg/domain/model" 14 | "github.com/secmon-lab/alertchain/pkg/domain/types" 15 | ) 16 | 17 | func TestIndicator(t *testing.T) { 18 | testCases := []struct { 19 | title string 20 | httpClient *http.Client 21 | args model.ActionArgs 22 | hasErr bool 23 | errType error 24 | }{ 25 | { 26 | title: "normal", 27 | httpClient: mockHTTPClient(func(req *http.Request) *http.Response { 28 | respJSON := `{"key": "value"}` 29 | return &http.Response{ 30 | StatusCode: 200, 31 | Body: io.NopCloser(bytes.NewReader([]byte(respJSON))), 32 | Header: make(http.Header), 33 | } 34 | }), 35 | args: model.ActionArgs{ 36 | "secret_api_key": "dummy", 37 | "type": "domain", 38 | "indicator": "example.com", 39 | "section": "general", 40 | }, 41 | hasErr: false, 42 | }, 43 | { 44 | title: "invalid api key", 45 | args: model.ActionArgs{ 46 | "type": "domain", 47 | "indicator": "example.com", 48 | "section": "general", 49 | }, 50 | hasErr: true, 51 | errType: types.ErrActionInvalidArgument, 52 | }, 53 | { 54 | title: "invalid type", 55 | args: model.ActionArgs{ 56 | "secret_api_key": "dummy", 57 | "type": "invalid", 58 | "indicator": "example.com", 59 | "section": "general", 60 | }, 61 | hasErr: true, 62 | errType: types.ErrActionInvalidArgument, 63 | }, 64 | { 65 | title: "invalid section", 66 | args: model.ActionArgs{ 67 | "secret_api_key": "dummy", 68 | "type": "domain", 69 | "indicator": "example.com", 70 | "section": "invalid", 71 | }, 72 | hasErr: true, 73 | errType: types.ErrActionInvalidArgument, 74 | }, 75 | { 76 | title: "http request error", 77 | httpClient: mockHTTPClient(func(req *http.Request) *http.Response { 78 | return &http.Response{ 79 | StatusCode: 500, 80 | Body: io.NopCloser(bytes.NewReader([]byte(""))), 81 | Header: make(http.Header), 82 | } 83 | }), 84 | args: model.ActionArgs{ 85 | "secret_api_key": "dummy", 86 | "type": "domain", 87 | "indicator": "example.com", 88 | "section": "general", 89 | }, 90 | hasErr: true, 91 | }, 92 | } 93 | 94 | for _, tt := range testCases { 95 | t.Run(tt.title, func(t *testing.T) { 96 | if tt.httpClient != nil { 97 | otx.ReplaceHTTPClient(tt.httpClient) 98 | t.Cleanup(func() { 99 | otx.ReplaceHTTPClient(http.DefaultClient) 100 | }) 101 | } 102 | 103 | ctx := context.Background() 104 | result, err := otx.Indicator(ctx, model.Alert{}, tt.args) 105 | 106 | if tt.hasErr { 107 | gt.Error(t, err).Must() 108 | if tt.errType != nil { 109 | gt.Error(t, err).Is(tt.errType) 110 | } 111 | } else { 112 | gt.NoError(t, err).Must() 113 | gt.V(t, result).NotNil() 114 | } 115 | }) 116 | } 117 | } 118 | 119 | func mockHTTPClient(doFunc func(*http.Request) *http.Response) *http.Client { 120 | return &http.Client{ 121 | Transport: mockRoundTripper(doFunc), 122 | } 123 | } 124 | 125 | type mockRoundTripper func(*http.Request) *http.Response 126 | 127 | func (mrt mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 128 | return mrt(req), nil 129 | } 130 | 131 | func TestIndicatorRun(t *testing.T) { 132 | apiKey := os.Getenv("TEST_OTX_API_KEY") 133 | if apiKey == "" { 134 | t.Skip("TEST_OTX_API_KEY is not set") 135 | } 136 | 137 | ctx := context.Background() 138 | args := model.ActionArgs{ 139 | "secret_api_key": apiKey, 140 | "type": "IPv4", 141 | "indicator": "87.236.176.4", 142 | "section": "general", 143 | } 144 | 145 | result, err := otx.Indicator(ctx, model.Alert{}, args) 146 | gt.NoError(t, err).Must() 147 | gt.V(t, result).NotNil() 148 | } 149 | -------------------------------------------------------------------------------- /action/slack/README.md: -------------------------------------------------------------------------------- 1 | # Slack Integration 2 | 3 | ## `slack.post` 4 | 5 | This action posts a message to a Slack channel. 6 | 7 | ## Prerequisites 8 | 9 | To use this action, you need to create a Slack App and obtain an incoming webhook URL. You can find instructions for setting up a Slack App and generating a webhook URL [here](https://api.slack.com/messaging/webhooks). 10 | 11 | ### Arguments 12 | 13 | Here's an example policy using the `slack.post` action: 14 | 15 | ```rego 16 | run contains res if { 17 | res := { 18 | "id": "your-action", 19 | "uses": "slack.post", 20 | "args": { 21 | "secret_url": input.env.SLACK_WEBHOOK_URL, 22 | "channel": "alert", 23 | }, 24 | }, 25 | } 26 | ``` 27 | 28 | - `secret_url` (required, string): The Slack webhook URL used to post messages to your Slack channel. 29 | - `channel` (required, string): The name of the Slack channel where the message will be posted. The `#` symbol is not required. 30 | - `text` (optional, string): The title of the Slack message. The default value is `Notification from AlertChain`. 31 | - `body` (optional, string): The body of the Slack message. The default value is the alert title and description. 32 | - `color` (optional, string): The color of the Slack message's banner. The default value is `#2EB67D`. 33 | 34 | ## Response 35 | 36 | This action does not return a response. 37 | -------------------------------------------------------------------------------- /action/slack/post.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/m-mizutani/goerr/v2" 9 | "github.com/secmon-lab/alertchain/pkg/ctxutil" 10 | "github.com/secmon-lab/alertchain/pkg/domain/model" 11 | "github.com/secmon-lab/alertchain/pkg/domain/types" 12 | "github.com/slack-go/slack" 13 | ) 14 | 15 | type notifyContents struct { 16 | Text string 17 | Body string 18 | Color string 19 | Fields []*notifyField 20 | Raw string 21 | } 22 | 23 | type notifyField struct { 24 | Name string 25 | Value string 26 | URL string 27 | } 28 | 29 | // Post is a function to post message to Slack via incoming webhook 30 | func Post(ctx context.Context, alert model.Alert, args model.ActionArgs) (any, error) { 31 | notify := ¬ifyContents{ 32 | Text: "Notification from AlertChain", 33 | Body: fmt.Sprintf("*%s*\n%s", alert.Title, alert.Description), 34 | Raw: alert.Raw, 35 | Fields: []*notifyField{ 36 | { 37 | Name: "schema", 38 | Value: string(alert.Schema), 39 | }, 40 | { 41 | Name: "source", 42 | Value: alert.Source, 43 | }, 44 | { 45 | Name: "created at", 46 | Value: alert.CreatedAt.Format("2006-01-02 15:04:05 MST"), 47 | }, 48 | }, 49 | } 50 | 51 | url, ok := args["secret_url"].(string) 52 | if !ok { 53 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "url is required") 54 | } 55 | channel, ok := args["channel"].(string) 56 | if !ok { 57 | return nil, goerr.Wrap(types.ErrActionInvalidArgument, "channel is required") 58 | } 59 | 60 | if v, ok := args["text"].(string); ok { 61 | notify.Text = v 62 | } 63 | if v, ok := args["body"].(string); ok { 64 | notify.Body = v 65 | } 66 | if v, ok := args["color"].(string); ok { 67 | notify.Color = v 68 | } 69 | 70 | for _, attr := range alert.Attrs { 71 | notify.Fields = append(notify.Fields, ¬ifyField{ 72 | Name: string(attr.Key), 73 | Value: fmt.Sprintf("%v", attr.Value), 74 | }) 75 | } 76 | 77 | msg := buildSlackMessage(notify, alert) 78 | msg.Channel = channel 79 | 80 | if ctxutil.IsDryRun(ctx) { 81 | return nil, nil 82 | } 83 | 84 | if err := slack.PostWebhookContext(ctx, url, msg); err != nil { 85 | raw, _ := json.Marshal(msg) 86 | return nil, goerr.Wrap(err, "failed to post slack message", goerr.V("body", string(raw)), goerr.T(types.ErrTagAction)) 87 | } 88 | 89 | return nil, nil 90 | } 91 | 92 | func buildSlackMessage(notify *notifyContents, _ interface{}) *slack.WebhookMessage { 93 | color := "#2EB67D" 94 | if notify.Color != "" { 95 | color = notify.Color 96 | } 97 | 98 | var blocks []slack.Block 99 | 100 | if notify.Body != "" { 101 | blocks = append(blocks, 102 | slack.NewDividerBlock(), 103 | slack.NewSectionBlock(&slack.TextBlockObject{ 104 | Type: slack.MarkdownType, 105 | Text: notify.Body, 106 | }, nil, nil), 107 | ) 108 | } 109 | 110 | var customFields []*slack.TextBlockObject 111 | for _, field := range notify.Fields { 112 | customFields = append(customFields, toBlock(field)) 113 | } 114 | if len(customFields) > 0 { 115 | blocks = append(blocks, 116 | slack.NewDividerBlock(), 117 | slack.NewSectionBlock(nil, customFields, nil), 118 | ) 119 | } 120 | 121 | raw := notify.Raw 122 | if len(raw) > 2990 { 123 | raw = raw[:2990] + "..." 124 | } 125 | 126 | blocks = append(blocks, 127 | slack.NewDividerBlock(), 128 | slack.NewSectionBlock(&slack.TextBlockObject{ 129 | Type: slack.MarkdownType, 130 | Text: fmt.Sprintf("```%s```", raw), 131 | }, nil, nil), 132 | ) 133 | 134 | msg := &slack.WebhookMessage{ 135 | Text: notify.Text, 136 | Attachments: []slack.Attachment{ 137 | { 138 | Color: color, 139 | Blocks: slack.Blocks{ 140 | BlockSet: blocks, 141 | }, 142 | }, 143 | }, 144 | } 145 | 146 | return msg 147 | } 148 | 149 | func toBlock(field *notifyField) *slack.TextBlockObject { 150 | text := fmt.Sprintf("*%s*: ", field.Name) 151 | if field.URL != "" { 152 | text += fmt.Sprintf("<%s|%s>", field.URL, field.Value) 153 | } else { 154 | text += field.Value 155 | } 156 | return slack.NewTextBlockObject(slack.MarkdownType, text, false, false) 157 | } 158 | -------------------------------------------------------------------------------- /action/slack/post_test.go: -------------------------------------------------------------------------------- 1 | package slack_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/m-mizutani/gt" 10 | "github.com/secmon-lab/alertchain/action/slack" 11 | "github.com/secmon-lab/alertchain/pkg/domain/model" 12 | ) 13 | 14 | func TestPost(t *testing.T) { 15 | if _, ok := os.LookupEnv("TEST_SLACK_POST"); !ok { 16 | t.Skip("TEST_SLACK_POST is not set") 17 | } 18 | 19 | url, ok := os.LookupEnv("TEST_SLACK_WEBHOOK_URL") 20 | if !ok { 21 | t.Skip("TEST_SLACK_WEBHOOK_URL is not set") 22 | } 23 | channel, ok := os.LookupEnv("TEST_SLACK_CHANNEL") 24 | if !ok { 25 | t.Skip("TEST_SLACK_CHANNEL is not set") 26 | } 27 | 28 | alert := model.Alert{ 29 | AlertMetaData: model.AlertMetaData{ 30 | Title: "test_title", 31 | Description: "test_description", 32 | Source: "test_source", 33 | Attrs: []model.Attribute{ 34 | { 35 | Key: "test_attr", 36 | Value: "test_value", 37 | }, 38 | }, 39 | }, 40 | CreatedAt: time.Now(), 41 | Schema: "test_schema", 42 | Raw: "test_raw", 43 | } 44 | 45 | args := model.ActionArgs{ 46 | "secret_url": url, 47 | "channel": channel, 48 | } 49 | 50 | ctx := context.Background() 51 | any, err := slack.Post(ctx, alert, args) 52 | gt.NoError(t, err) 53 | gt.V(t, any).Nil() 54 | } 55 | -------------------------------------------------------------------------------- /docs/authz.md: -------------------------------------------------------------------------------- 1 | # Authorization 2 | 3 | ## Introduction 4 | 5 | AlertChain receives alert data from various sources. For example, you can receive alerts from AWS GuardDuty, Google Cloud Security Command Center, or your own SIEM. AlertChain can also receive alerts from multiple sources at the same time. 6 | 7 | AlertChain receives alerts via HTTP API. Then, AlertChain requires to expose HTTP port to Internet for public SaaS. Therefore, AlertChain needs to authenticate and authorization mechanism for the sender of the alert data to prevent unauthorized data input. 8 | 9 | The authentication and authorization mechanism of AlertChain is based on [Open Policy Agent](https://www.openpolicyagent.org/). Open Policy Agent is a policy engine that can be used to implement fine-grained access control. AlertChain uses Open Policy Agent to implement authentication and authorization for alert data. 10 | 11 | ## Authorization Policy 12 | 13 | The authorization policy is written in [Rego](https://www.openpolicyagent.org/docs/latest/policy-language/). Here is HTTP authorization policy example: 14 | 15 | ```rego 16 | package authz.http 17 | 18 | default deny = false 19 | 20 | deny if { 21 | not net.cidr_contains("10.0.0.0/8", input.remote) 22 | } 23 | ``` 24 | 25 | This policy allows access from the `10.0.0.0/8`` network. 26 | 27 | ## Policy Specification 28 | 29 | ### Input 30 | 31 | The input of the authorization policy is as follows: 32 | 33 | - `input.remote` (string): IP address of the sender of the alert data 34 | - `input.method` (string): HTTP method of the request 35 | - `input.path` (string): HTTP path of the request 36 | - `input.query` (map of string array): HTTP query of the request 37 | - `input.header` (map of string array): HTTP headers of the request 38 | 39 | ### Output 40 | 41 | The output of the authorization policy is as follows: 42 | 43 | - `deny` (boolean): Deny access if `true` is returned. `false` and undefined are treated as allow. 44 | 45 | When `deny` is `true`, HTTP response is as follows: 46 | 47 | - Status code: 403 48 | - Message: `Access denied` 49 | 50 | ## Examples 51 | 52 | ### Validate Google Cloud Service 53 | 54 | ```rego 55 | package authz.http 56 | 57 | default deny = true 58 | 59 | deny := false { allow } 60 | 61 | jwks_request(url) := http.send({ 62 | "url": url, 63 | "method": "GET", 64 | "force_cache": true, 65 | "force_cache_duration_seconds": 3600 # Cache response for an hour 66 | }).raw_body 67 | 68 | allow if { 69 | startswith(input.path, "/alert/") 70 | 71 | ahthHdr := input.header["Authorization"] 72 | count(ahthHdr) == 1 73 | authHdrValues := split(ahthHdr[0], " ") 74 | count(authHdrValues) == 2 75 | lower(authHdrValues[0]) == "bearer" 76 | token := authHdrValues[1] 77 | 78 | jwks := jwks_request("https://www.googleapis.com/oauth2/v3/certs") 79 | 80 | io.jwt.verify_rs256(token, jwks) 81 | claims := io.jwt.decode(token) 82 | claims[1]["email"] == "xxxxxx-compute@developer.gserviceaccount.com" 83 | } 84 | ``` 85 | -------------------------------------------------------------------------------- /docs/deployment.md: -------------------------------------------------------------------------------- 1 | # Deployment 2 | 3 | ## Deploy as a container image 4 | 5 | There are several ways to create a container image, but in this section, we will introduce a method to create a container image based on the AlertChain image and add alert policies and action policies. First, create a Dockerfile like the following. 6 | 7 | ```dockerfile 8 | FROM ghcr.io/secmon-lab/alertchain:v0.0.2 9 | 10 | COPY policy /policy 11 | 12 | WORKDIR / 13 | EXPOSE 8080 14 | ENTRYPOINT ["/alertchain", "-d", "/policy", "--log-format", "json", "serve", "--addr", "0.0.0.0:8080"] 15 | ``` 16 | 17 | In this Dockerfile, we use the AlertChain image as a base and add alert policies and action policies to the container image. The alert policies and action policies are added to the container image by placing them in the `policy` directory. Also, when starting the AlertChain server, we specify the `--addr` option to make it accessible from outside the container. 18 | 19 | For instructions on how to deploy the created image to various runtime environments, please refer to the documentation for each runtime environment. 20 | 21 | ## Deploy to AWS Lambda 22 | 23 | For deploying to AWS Lambda, using CDK makes it easy to deploy. First, install CDK and create a CDK project. For instructions on how to create a project, please refer to [this guide](https://docs.aws.amazon.com/cdk/latest/guide/getting_started.html). 24 | 25 | AWS Lambda can be triggered by various events, but the schema of the data received varies depending on the type of event. Therefore, AlertChain needs to create handlers according to the event type. For example, to process events from SQS, specify `NewSQSHandler`, and to process events from Functional URL, specify `NewFunctionalURLHandler`. AlertChain provides functions to create handlers according to the event type. 26 | 27 | There are several ways to place policy files in the AWS Lambda execution environment, but in this case, we will use the Go embed package to embed them in the binary. First, write Go code like the following. 28 | 29 | ```go 30 | package main 31 | 32 | import ( 33 | "embed" 34 | 35 | "github.com/aws/aws-lambda-go/lambda" 36 | ac "github.com/secmon-lab/alertchain/pkg/controller/lambda" 37 | ) 38 | 39 | //go:embed policy/* 40 | var policyFS embed.FS 41 | 42 | func main() { 43 | lambda.Start(ac.New( 44 | // Register a handler to process events from SQS 45 | // Specify the guardduty schema and enable decoding events from SNS 46 | ac.NewSQSHandler("guardduty", ac.WithDecodeSNS()), 47 | 48 | // Use embed.FS for file reading 49 | ac.WithReadFile(policyFS.ReadFile), 50 | 51 | // Specify the policy file directory 52 | ac.WithAlertPolicyDir("policy"), 53 | ac.WithActionPolicyDir("policy"), 54 | )) 55 | } 56 | ``` 57 | 58 | This code works as follows: 59 | 60 | - Reads files under the `policy` directory, creates alert policies and action policies. 61 | - Processes GuardDuty findings sent to SQS via SNS using CloudWatch Event. 62 | 63 | Build this code for AWS Lambda and create a binary named `build/main`. 64 | 65 | ```bash 66 | $ env GOARCH=amd64 GOOS=linux go build -o ./build/main 67 | ``` 68 | 69 | Next, prepare the CDK code to deploy the created binary. The following is an example written in TypeScript. 70 | 71 | ```ts 72 | import * as cdk from "aws-cdk-lib"; 73 | import { Construct } from "constructs"; 74 | import * as lambda from "aws-cdk-lib/aws-lambda"; 75 | import * as sqs from "aws-cdk-lib/aws-sqs"; 76 | import * as eventSources from "aws-cdk-lib/aws-lambda-event-sources"; 77 | 78 | export class AlertchainCdkStack extends cdk.Stack { 79 | constructor(scope: Construct, id: string, props?: cdk.StackProps) { 80 | super(scope, id, props); 81 | 82 | const queue = sqs.Queue.fromQueueArn( 83 | this, 84 | "alertchain-queue", 85 | "arn:aws:sqs:ap-northeast-1:111111111:guardduty-alert-queue" 86 | ); 87 | 88 | const f = new lambda.Function(this, "alertchain", { 89 | runtime: lambda.Runtime.GO_1_X, 90 | handler: "main", 91 | code: lambda.Code.fromAsset("./build"), 92 | timeout: cdk.Duration.seconds(30), 93 | }); 94 | f.addEventSource(new eventSources.SqsEventSource(queue)); 95 | } 96 | } 97 | ``` 98 | 99 | By deploying this, an AWS Lambda function that processes GuardDuty findings will be created. -------------------------------------------------------------------------------- /docs/examples/alert-policy-testing/README.md: -------------------------------------------------------------------------------- 1 | # Example of alert policy testing 2 | 3 | This is an example of AWS GuardDuty alert policy and testing it. 4 | 5 | ## Files 6 | 7 | - [alert.rego](alert.rego): Alert policy 8 | - [alert_test.rego](alert_test.rego): Testing policy 9 | - [test/aws_guardduty/data.json](test/aws_guardduty/data.json): Testing data 10 | 11 | ## How to test 12 | 13 | ```bash 14 | $ opa test -v -b . 15 | alert_test.rego: 16 | data.alert.aws_guardduty.test_detect: PASS (407.709µs) 17 | data.alert.aws_guardduty.test_ignore_severity: PASS (235.25µs) 18 | data.alert.aws_guardduty.test_ignore_type: PASS (187.459µs) 19 | -------------------------------------------------------------------------------- 20 | PASS: 3/3 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/examples/alert-policy-testing/alert.rego: -------------------------------------------------------------------------------- 1 | package alert.aws_guardduty 2 | 3 | alert contains res if { 4 | startswith(input.Findings[x].Type, "Trojan:") 5 | input.Findings[_].Severity > 7 6 | res := { 7 | "title": input.Findings[x].Type, 8 | "source": "aws", 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /docs/examples/alert-policy-testing/alert_test.rego: -------------------------------------------------------------------------------- 1 | package alert.aws_guardduty 2 | 3 | # detect alert correctly 4 | test_detect if { 5 | result := alert with input as data.test.aws_guardduty 6 | count(result) == 1 7 | result[_].title == "Trojan:EC2/DriveBySourceTraffic!DNS" 8 | result[_].source == "aws" 9 | } 10 | 11 | # ignore if severity is 7 12 | test_ignore_severity if { 13 | result := alert with input as json.patch( 14 | data.test.aws_guardduty, 15 | [{ 16 | "op": "replace", 17 | "path": "/Findings/0/Severity", 18 | "value": 7, 19 | }], 20 | ) 21 | count(result) == 0 22 | } 23 | 24 | # ignore if prefix of Type does not match with "Trojan:" 25 | test_ignore_type if { 26 | result := alert with input as json.patch( 27 | data.test.aws_guardduty, 28 | [{ 29 | "op": "replace", 30 | "path": "/Findings/0/Type", 31 | "value": "Some alert", 32 | }], 33 | ) 34 | count(result) == 0 35 | } 36 | -------------------------------------------------------------------------------- /docs/images/action_policy_workflow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/secmon-lab/alertchain/6949ef8225718f36d9613a9f7c1dec38b8abc5d8/docs/images/action_policy_workflow.jpg -------------------------------------------------------------------------------- /examples/basic/policy/action.rego: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | run contains res if { 4 | input.alert.source == "aws" 5 | res := { 6 | "id": "ask-gpt", 7 | "uses": "chatgpt.comment_alert", 8 | "args": {"secret_api_key": input.env.CHATGPT_API_KEY}, 9 | } 10 | } 11 | 12 | run contains res if { 13 | gtp := input.called[_] 14 | gtp.id == "ask-gpt" 15 | 16 | res := { 17 | "id": "notify-slack", 18 | "uses": "slack.post", 19 | "args": { 20 | "secret_url": input.env.SLACK_WEBHOOK_URL, 21 | "channel": "alert", 22 | "body": gtp.result.choices[0].message.content, 23 | }, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/basic/policy/alert.rego: -------------------------------------------------------------------------------- 1 | package alert.aws_guardduty 2 | 3 | alert contains res if { 4 | f := input.Findings[_] 5 | startswith(f.Type, "Trojan:") 6 | f.Severity > 7 7 | 8 | res := { 9 | "title": f.Type, 10 | "source": "aws", 11 | "description": f.Description, 12 | "attrs": [{ 13 | "key": "instance ID", 14 | "value": f.Resource.InstanceDetails.InstanceId, 15 | }], 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/e2e/action.rego: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | count_attr := input.alert.attrs[x] if { 4 | input.alert.attrs[x].key == "count" 5 | } else := { 6 | "id": null, 7 | "value": 0, 8 | } 9 | 10 | run contains res if { 11 | res := { 12 | "commit": [{ 13 | "id": count_attr.id, 14 | "key": "count", 15 | "value": count_attr.value + 1, 16 | "persist": true, 17 | }], 18 | } 19 | } 20 | 21 | run contains job if { 22 | job := { 23 | "id": "test", 24 | "uses": "http.fetch", 25 | "args": { 26 | "method": "GET", 27 | "url": "http://localhost:9876/test", 28 | }, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/e2e/alert.rego: -------------------------------------------------------------------------------- 1 | package alert.my_alert 2 | 3 | alert contains msg if { 4 | input.color == "blue" 5 | 6 | msg := { 7 | "title": "Test alert", 8 | "namespace": "test_alert", 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /examples/test/READNE.md: -------------------------------------------------------------------------------- 1 | # Scenario Test 2 | 3 | This directory contains the scenario test for alertchain. Scenario test can be done by `play` command. The scenario test is a test that runs with `alert` and `action` policies and a scenario file as JSONNET format. 4 | 5 | ## Directory Structure 6 | 7 | - `event`: Event data for scenario test 8 | - `guardduty.json`: GuardDuty event data 9 | - `policy`: Policy data for scenario test 10 | - `alert.json`: Alert policy data 11 | - `action.json`: Action policy data 12 | - `scenario`: Scenario data for scenario test 13 | - `scenario.jsonnet`: Scenario data in JSONNET format 14 | - `results`: The respond data of each action. 15 | - `chatgpt.json`: The respond data of ChatGPT action 16 | - `output/scenario1/data.json`: The output data of scenario test (that will be created after running the scenario test) 17 | - `test.rego`: Rego policy for testing the output data of scenario test 18 | 19 | ## How to Run 20 | 21 | 1. Run the scenario test by `play` command 22 | ```bash 23 | # at root directory of the repository 24 | $ alertchain play \ 25 | -s ./examples/test/scenarios/scenario.jsonnet \ 26 | -d ./examples/test/policy \ 27 | -o ./examples/test/output 28 | ``` 29 | - `-s`: The path of the scenario file 30 | - `-d`: The path of the policy directory 31 | - `-o`: The path of the output directory 32 | 2. Check the output data 33 | ```bash 34 | $ cat examples/test/output/scenario1/data.json | jq 35 | 3. Test the output data 36 | ```bash 37 | $ opa test -v test.rego examples/test/output/scenario1/data.json 38 | ``` -------------------------------------------------------------------------------- /examples/test/policy/action.rego: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | run contains res if { 4 | input.alert.source == "aws" 5 | res := { 6 | "id": "ask-gpt", 7 | "uses": "chatgpt.query", 8 | "args": {"secret_api_key": input.env.CHATGPT_API_KEY}, 9 | "commit": [ 10 | { 11 | "key": "asked_gpt", 12 | "path": "choices[0].message.content", 13 | }, 14 | ] 15 | } 16 | } 17 | 18 | run contains res if { 19 | gtp := input.called[_] 20 | gtp.id == "ask-gpt" 21 | 22 | res := { 23 | "id": "notify-slack", 24 | "uses": "slack.post", 25 | "args": { 26 | "secret_url": input.env.SLACK_WEBHOOK_URL, 27 | "channel": "alert", 28 | "body": gtp.result.choices[0].message.content, 29 | }, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/test/policy/alert.rego: -------------------------------------------------------------------------------- 1 | package alert.aws_guardduty 2 | 3 | alert contains res if { 4 | f := input.Findings[_] 5 | startswith(f.Type, "Trojan:") 6 | f.Severity > 7 7 | 8 | res := { 9 | "title": f.Type, 10 | "source": "aws", 11 | "description": f.Description, 12 | "attrs": [{ 13 | "key": "instance ID", 14 | "value": f.Resource.InstanceDetails.InstanceId, 15 | }], 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/test/results/chatgpt.json: -------------------------------------------------------------------------------- 1 | { 2 | "choices": [ 3 | { 4 | "message": { 5 | "content": "This is a test message." 6 | } 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /examples/test/scenarios/scenario.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | id: 'scenario1', 3 | title: 'Test 1', 4 | events: [ 5 | { 6 | input: import '../event/guardduty.json', 7 | schema: 'aws_guardduty', 8 | actions: { 9 | 'chatgpt.query': [ 10 | import '../results/chatgpt.json', 11 | ], 12 | }, 13 | }, 14 | ], 15 | 16 | env: { 17 | CHATGPT_API_KEY: 'test_api_key_xxxxxxxxxx', 18 | SLACK_WEBHOOK_URL: 'https://hooks.slack.com/services/xxxxxxxxx', 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /examples/test/test.rego: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | test_play_result if { 4 | # Only one alert should be detected 5 | count(data.output.scenario1.results) == 1 6 | result := data.output.scenario1.results[0] 7 | 8 | # Alert should be of type "Trojan:EC2/DropPoint!DNS" 9 | result.alert.title == "Trojan:EC2/DropPoint!DNS" 10 | 11 | # The alert should trigger two actions 12 | count(result.actions) == 2 13 | 14 | # test first action 15 | result.actions[0].id == "ask-gpt" 16 | result.actions[0].args.secret_api_key == "test_api_key_xxxxxxxxxx" 17 | count(result.actions[0].commit) == 1 18 | result.actions[0].commit[0].key == "asked_gpt" 19 | result.actions[0].commit[0].value == "This is a test message." 20 | 21 | # test second action 22 | result.actions[1].id == "notify-slack" 23 | result.actions[1].args.secret_url == "https://hooks.slack.com/services/xxxxxxxxx" 24 | } 25 | -------------------------------------------------------------------------------- /gqlgen.yml: -------------------------------------------------------------------------------- 1 | # Where are all the schema files located? globs are supported eg src/**/*.graphqls 2 | schema: 3 | - graphql/*.graphqls 4 | 5 | # Where should the generated server code go? 6 | exec: 7 | filename: pkg/controller/graphql/generated.go 8 | package: graphql 9 | 10 | # Where should any generated models go? 11 | model: 12 | filename: pkg/domain/model/graphql.go 13 | package: model 14 | 15 | # Where should the resolver implementations go? 16 | resolver: 17 | layout: follow-schema 18 | dir: pkg/controller/graphql 19 | package: graphql 20 | filename_template: "{name}.resolvers.go" 21 | # Optional: turn on to not generate template comments above resolvers 22 | # omit_template_comment: false 23 | 24 | # gqlgen will search for any type names in the schema in these go packages 25 | # if they match it will use them, otherwise it will generate them. 26 | autobind: 27 | # - "github.com/m-mizutani/graphqltest/graph/model" 28 | 29 | # This section declares type mapping between the GraphQL and go type systems 30 | # 31 | # The first line in each type will be used as defaults for resolver arguments and 32 | # modelgen, the others will be allowed when binding to fields. Configure them to 33 | # your liking 34 | models: 35 | ID: 36 | model: 37 | - github.com/99designs/gqlgen/graphql.UUID 38 | UUID: 39 | model: 40 | - github.com/99designs/gqlgen/graphql.UUID 41 | WorkflowID: 42 | model: 43 | - github.com/secmon-lab/alertchain/pkg/domain/types.WorkflowID 44 | AlertID: 45 | model: 46 | - github.com/secmon-lab/alertchain/pkg/domain/types.AlertID 47 | Timestamp: 48 | model: 49 | - github.com/99designs/gqlgen/graphql.Time 50 | WorkflowRecord: 51 | fields: 52 | actions: 53 | resolver: true 54 | -------------------------------------------------------------------------------- /graphql/schema.graphqls: -------------------------------------------------------------------------------- 1 | # GraphQL schema 2 | 3 | scalar Timestamp # Represents time.Time 4 | scalar WorkflowID # Represents uuid.UUID 5 | scalar AlertID # Represents uuid.UUID 6 | type WorkflowRecord { 7 | id: WorkflowID! 8 | createdAt: Timestamp! 9 | alert: AlertRecord! 10 | actions: [ActionRecord!]! 11 | } 12 | 13 | type AlertRecord { 14 | id: AlertID! 15 | schema: String! 16 | data: String! 17 | createdAt: Timestamp! 18 | 19 | # From AlertMetaData 20 | title: String! 21 | description: String! 22 | source: String! 23 | namespace: String 24 | initAttrs: [AttributeRecord!]! 25 | lastAttrs: [AttributeRecord!]! 26 | refs: [ReferenceRecord!]! 27 | } 28 | 29 | type AttributeRecord { 30 | id: String! 31 | key: String! 32 | value: String! 33 | type: String 34 | persist: Boolean! 35 | ttl: Int! 36 | } 37 | 38 | type ReferenceRecord { 39 | title: String 40 | url: String 41 | } 42 | 43 | type ActionRecord { 44 | id: String! 45 | seq: Int! 46 | uses: String! 47 | args: [ArgumentRecord!]! 48 | result: String 49 | next: [NextRecord!]! 50 | error: String 51 | 52 | startedAt: Timestamp! 53 | finishedAt: Timestamp! 54 | } 55 | 56 | type ArgumentRecord { 57 | key: String! 58 | value: String! 59 | } 60 | 61 | type NextRecord { 62 | abort: Boolean! 63 | attrs: [AttributeRecord!]! 64 | } 65 | 66 | type Query { 67 | workflows(offset: Int, limit: Int): [WorkflowRecord!]! 68 | Workflow(id: String!): WorkflowRecord! 69 | } 70 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/secmon-lab/alertchain/pkg/controller/cli" 8 | ) 9 | 10 | func main() { 11 | ctx := context.Background() 12 | if err := cli.New().Run(ctx, os.Args); err != nil { 13 | os.Exit(1) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "io" 8 | "net/http" 9 | "testing" 10 | "time" 11 | 12 | "github.com/m-mizutani/gt" 13 | "github.com/secmon-lab/alertchain/pkg/controller/cli" 14 | "github.com/secmon-lab/alertchain/pkg/domain/model" 15 | ) 16 | 17 | func TestServe(t *testing.T) { 18 | ctx, cancel := context.WithCancel(context.Background()) 19 | defer cancel() 20 | 21 | go func() { 22 | args := []string{ 23 | "alertchain", 24 | "serve", 25 | "-p", 26 | "--addr", "127.0.0.1:6666", 27 | "-d", "examples/e2e", 28 | } 29 | gt.NoError(t, cli.New().Run(ctx, args)) 30 | }() 31 | 32 | var called int 33 | callbackHandler := func(w http.ResponseWriter, r *http.Request) { 34 | t.Log("called!") 35 | called++ 36 | w.WriteHeader(http.StatusOK) 37 | gt.R1(w.Write([]byte("OK"))).NoError(t) 38 | } 39 | go func() { 40 | gt.NoError(t, http.ListenAndServe("127.0.0.1:9876", http.HandlerFunc(callbackHandler))) 41 | }() 42 | 43 | send := func(t *testing.T) { 44 | body := bytes.NewReader([]byte(`{"color":"blue"}`)) 45 | req := gt.R1(http.NewRequest("POST", "http://127.0.0.1:6666/alert/raw/my_alert", body)).NoError(t) 46 | resp := gt.R1(http.DefaultClient.Do(req)).NoError(t) 47 | gt.N(t, resp.StatusCode).Equal(200) 48 | } 49 | sendIgnoredAlert := func(t *testing.T) { 50 | body := bytes.NewReader([]byte(`{"color":"red"}`)) 51 | req := gt.R1(http.NewRequest("POST", "http://127.0.0.1:6666/alert/raw/my_alert", body)).NoError(t) 52 | resp := gt.R1(http.DefaultClient.Do(req)).NoError(t) 53 | gt.N(t, resp.StatusCode).Equal(200) 54 | } 55 | 56 | time.Sleep(time.Second) 57 | 58 | send(t) // 1 59 | gt.N(t, called).Equal(1) 60 | send(t) // 2 61 | gt.N(t, called).Equal(2) 62 | sendIgnoredAlert(t) // ignored 63 | gt.N(t, called).Equal(2) 64 | send(t) // 3 65 | gt.N(t, called).Equal(3) 66 | } 67 | 68 | func TestPlay(t *testing.T) { 69 | ctx := context.Background() 70 | args := []string{ 71 | "alertchain", 72 | "-l", "debug", 73 | "play", 74 | "-d", "examples/test/policy", 75 | "-s", "examples/test/scenarios", 76 | "-o", "examples/test/output", 77 | } 78 | gt.NoError(t, cli.New().Run(ctx, args)) 79 | 80 | gt.F(t, "examples/test/output/scenario1/data.json").Reader(func(t testing.TB, r io.Reader) { 81 | var data model.ScenarioLog 82 | gt.NoError(t, json.NewDecoder(r).Decode(&data)) 83 | gt.Equal(t, data.ID, "scenario1") 84 | gt.Equal(t, data.Title, "Test 1") 85 | gt.A(t, data.Results).Length(1). 86 | At(0, func(t testing.TB, v *model.PlayLog) { 87 | gt.Equal(t, v.Alert.Title, "Trojan:EC2/DropPoint!DNS") 88 | 89 | gt.A(t, v.Actions).Length(2). 90 | At(0, func(t testing.TB, v *model.ActionLog) { 91 | gt.Equal(t, v.Seq, 0) 92 | gt.Equal(t, v.Uses, "chatgpt.query") 93 | }). 94 | At(1, func(t testing.TB, v *model.ActionLog) { 95 | gt.Equal(t, v.Seq, 1) 96 | gt.Equal(t, v.Uses, "slack.post") 97 | }) 98 | }) 99 | }) 100 | } 101 | -------------------------------------------------------------------------------- /pkg/chain/alert_test.go: -------------------------------------------------------------------------------- 1 | package chain_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "testing" 8 | 9 | "github.com/m-mizutani/gt" 10 | "github.com/secmon-lab/alertchain/pkg/chain" 11 | "github.com/secmon-lab/alertchain/pkg/domain/model" 12 | "github.com/secmon-lab/alertchain/pkg/infra/policy" 13 | ) 14 | 15 | func TestAlertRaw(t *testing.T) { 16 | alertData := `{"color": "blue"}` 17 | var alertDataPP bytes.Buffer 18 | enc := json.NewEncoder(&alertDataPP) 19 | enc.SetIndent("", " ") 20 | gt.NoError(t, enc.Encode(alertData)) 21 | 22 | alertPolicy := gt.R1(policy.New( 23 | policy.WithPackage("alert"), 24 | policy.WithFile("testdata/alert_feature/alert.rego"), 25 | policy.WithReadFile(read), 26 | )).NoError(t) 27 | 28 | actionPolicy := gt.R1(policy.New( 29 | policy.WithPackage("action"), 30 | policy.WithFile("testdata/alert_feature/action.rego"), 31 | policy.WithReadFile(read), 32 | )).NoError(t) 33 | 34 | var calledMock int 35 | mock := func(ctx context.Context, alert model.Alert, args model.ActionArgs) (any, error) { 36 | s := gt.Cast[string](t, args["raw"]) 37 | gt.V(t, s).Equal(alertDataPP.String()) 38 | calledMock++ 39 | return nil, nil 40 | } 41 | 42 | c := gt.R1(chain.New( 43 | chain.WithPolicyAlert(alertPolicy), 44 | chain.WithPolicyAction(actionPolicy), 45 | chain.WithExtraAction("test.output_raw", mock), 46 | )).NoError(t) 47 | 48 | ctx := context.Background() 49 | gt.R1(c.HandleAlert(ctx, "amber", alertData)).NoError(t) 50 | gt.N(t, calledMock).Equal(1) 51 | } 52 | -------------------------------------------------------------------------------- /pkg/chain/embed_test.go: -------------------------------------------------------------------------------- 1 | package chain_test 2 | 3 | import ( 4 | "embed" 5 | "path/filepath" 6 | ) 7 | 8 | //go:embed testdata/** 9 | var testDataFS embed.FS 10 | 11 | func read(path string) ([]byte, error) { 12 | return testDataFS.ReadFile(filepath.Clean(path)) 13 | } 14 | -------------------------------------------------------------------------------- /pkg/chain/export_test.go: -------------------------------------------------------------------------------- 1 | package chain 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/secmon-lab/alertchain/pkg/domain/model" 7 | "github.com/secmon-lab/alertchain/pkg/service" 8 | ) 9 | 10 | func (x *Chain) RunWorkflow(ctx context.Context, alert model.Alert, svc *service.Services) error { 11 | return x.runWorkflow(ctx, alert, svc) 12 | } 13 | -------------------------------------------------------------------------------- /pkg/chain/recorder.go: -------------------------------------------------------------------------------- 1 | package chain 2 | 3 | import ( 4 | "github.com/secmon-lab/alertchain/pkg/domain/interfaces" 5 | "github.com/secmon-lab/alertchain/pkg/domain/model" 6 | ) 7 | 8 | type dummyScenarioRecorder struct{} 9 | 10 | func (*dummyScenarioRecorder) NewAlertRecorder(alert *model.Alert) interfaces.AlertRecorder { 11 | return &dummyAlertRecorder{} 12 | } 13 | 14 | var _ interfaces.ScenarioRecorder = &dummyScenarioRecorder{} 15 | 16 | func (x *dummyScenarioRecorder) LogError(err error) {} 17 | func (x *dummyScenarioRecorder) Flush() error { return nil } 18 | 19 | type dummyAlertRecorder struct{} 20 | 21 | // NewActionRecorder implements interfaces.AlertRecorder. 22 | func (*dummyAlertRecorder) NewActionRecorder() interfaces.ActionRecorder { 23 | return &dummyActionRecorder{} 24 | } 25 | 26 | var _ interfaces.AlertRecorder = &dummyAlertRecorder{} 27 | 28 | type dummyActionRecorder struct{} 29 | 30 | func (*dummyActionRecorder) Add(log model.Action) {} 31 | 32 | var _ interfaces.ActionRecorder = &dummyActionRecorder{} 33 | -------------------------------------------------------------------------------- /pkg/chain/testdata/alert_feature/action.rego: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | run contains job if { 4 | input.seq == 0 5 | 6 | job := { 7 | "uses": "test.output_raw", 8 | "args": { 9 | "raw": input.alert.raw, 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /pkg/chain/testdata/alert_feature/alert.rego: -------------------------------------------------------------------------------- 1 | package alert.amber 2 | 3 | alert contains res if { 4 | res := { 5 | "title": "Amber Alert", 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /pkg/chain/testdata/basic/action.rego: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | run contains res if { 4 | res := { 5 | "id": "test", 6 | "uses": "mock", 7 | "args": { 8 | "s": "blue", 9 | "n": 5, 10 | }, 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /pkg/chain/testdata/basic/alert.rego: -------------------------------------------------------------------------------- 1 | package alert.scc 2 | 3 | alert contains msg if { 4 | msg := { 5 | "title": input.finding.category, 6 | "attrs": [ 7 | { 8 | "key": "db_name", 9 | "value": input.finding.database.displayName, 10 | }, 11 | { 12 | "key": "db_user", 13 | "value": input.finding.database.userName, 14 | }, 15 | { 16 | "key": "db_query", 17 | "value": input.finding.database.query, 18 | }, 19 | ], 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /pkg/chain/testdata/basic/alert_test.rego: -------------------------------------------------------------------------------- 1 | package alert.scc 2 | 3 | test_scc if { 4 | got := alert with input as data.input 5 | got[_].title == "Exfiltration: CloudSQL Over-Privileged Grant" 6 | print(got) 7 | } 8 | -------------------------------------------------------------------------------- /pkg/chain/testdata/basic/input/scc.json: -------------------------------------------------------------------------------- 1 | { 2 | "notificationConfigName": "organizations/000000123456/notificationConfigs/pubsub_notification", 3 | "finding": { 4 | "name": "organizations/000000123456/sources/12345678912345678/findings/62eed6ad005045e897e6b52d66212345", 5 | "parent": "organizations/000000123456/sources/12345678912345678", 6 | "resourceName": "//cloudsql.googleapis.com/projects/my-project/instances/my-db", 7 | "state": "ACTIVE", 8 | "category": "Exfiltration: CloudSQL Over-Privileged Grant", 9 | "sourceProperties": { 10 | "sourceId": { 11 | "projectNumber": "123412341234", 12 | "customerOrganizationNumber": "000000123456" 13 | }, 14 | "detectionCategory": { 15 | "ruleName": "cloudsql_exfil", 16 | "subRuleName": "user_granted_all_permissions" 17 | }, 18 | "detectionPriority": "LOW", 19 | "affectedResources": [ 20 | { 21 | "gcpResourceName": "//cloudresourcemanager.googleapis.com/projects/123412341234" 22 | }, 23 | { 24 | "gcpResourceName": "//cloudsql.googleapis.com/projects/my-project/instances/my-db" 25 | } 26 | ], 27 | "evidence": [ 28 | { 29 | "sourceLogId": { 30 | "projectId": "my-project", 31 | "resourceContainer": "projects/my-project", 32 | "timestamp": { 33 | "seconds": "1677835445", 34 | "nanos": 9.71e8 35 | }, 36 | "insertId": "2#428169219889#my-db#audit#1677835445971000000#00000000005fd5a8-0-0@a1" 37 | } 38 | } 39 | ], 40 | "properties": {}, 41 | "findingId": "62eed6ad005045e897e6b52d66212345", 42 | "contextUris": { 43 | "mitreUri": { 44 | "displayName": "MITRE Link", 45 | "url": "https://attack.mitre.org/techniques/T1567/" 46 | }, 47 | "cloudLoggingQueryUri": [ 48 | { 49 | "displayName": "Cloud Logging Query Link", 50 | "url": "https://console.cloud.google.com/logs/query;query\u003dtimestamp%3D%222023-03-03T09:24:05.971Z%22%0AinsertId%3D%222#428169219889#my-db#audit#1677835445971000000#00000000005fd5a8-0-0@a1%22%0Aresource.labels.project_id%3D%22my-project%22?project\u003dmy-project" 51 | } 52 | ], 53 | "relatedFindingUri": { 54 | "displayName": "Related CloudSQL Exfiltration Findings", 55 | "url": "https://console.cloud.google.com/security/command-center/findings..." 56 | } 57 | } 58 | }, 59 | "securityMarks": { 60 | "name": "organizations/000000123456/sources/12345678912345678/findings/62eed6ad005045e897e6b52d66212345/securityMarks" 61 | }, 62 | "eventTime": "2023-03-03T09:24:09.270839Z", 63 | "createTime": "2023-03-03T09:24:09.740Z", 64 | "severity": "LOW", 65 | "canonicalName": "projects/123412341234/sources/12345678912345678/findings/62eed6ad005045e897e6b52d66212345", 66 | "mute": "UNDEFINED", 67 | "findingClass": "THREAT", 68 | "mitreAttack": { 69 | "primaryTactic": "EXFILTRATION", 70 | "primaryTechniques": ["EXFILTRATION_OVER_WEB_SERVICE"] 71 | }, 72 | "access": { 73 | "callerIpGeo": {}, 74 | "serviceName": "cloudsql.googleapis.com", 75 | "methodName": "cloudsql.instances.query" 76 | }, 77 | "parentDisplayName": "Event Threat Detection", 78 | "database": { 79 | "displayName": "my_db", 80 | "userName": "my_db", 81 | "query": "GRANT ALL ON ALL TABLES IN SCHEMA public TO \"my_admin\"", 82 | "grantees": ["my_admin"] 83 | } 84 | }, 85 | "resource": { 86 | "name": "//cloudsql.googleapis.com/projects/my-project/instances/my-db", 87 | "project": "//cloudresourcemanager.googleapis.com/projects/123412341234", 88 | "projectDisplayName": "my-project", 89 | "parent": "//cloudresourcemanager.googleapis.com/projects/123412341234", 90 | "parentDisplayName": "my-project", 91 | "type": "google.cloud.sql.Instance", 92 | "folders": [ 93 | { 94 | "resourceFolder": "//cloudresourcemanager.googleapis.com/folders/1111111111", 95 | "resourceFolderDisplayName": "my-folder1" 96 | }, 97 | { 98 | "resourceFolder": "//cloudresourcemanager.googleapis.com/folders/2222222222", 99 | "resourceFolderDisplayName": "my-folder2" 100 | }, 101 | { 102 | "resourceFolder": "//cloudresourcemanager.googleapis.com/folders/33333333", 103 | "resourceFolderDisplayName": "my-folder3" 104 | } 105 | ], 106 | "displayName": "my-db" 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /pkg/chain/testdata/control/action.rego: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | run contains res if { 4 | input.alert.attrs[k2].key == "k2" 5 | res := { 6 | "id": "run_mock", 7 | "uses": "mock", 8 | "commit": [ 9 | { 10 | "id": input.alert.attrs[k2].id, 11 | "key": "k2", 12 | "value": "v2a", 13 | }, 14 | { 15 | "key": "k3", 16 | "value": "v3", 17 | }, 18 | ], 19 | } 20 | } 21 | 22 | run contains res if { 23 | input.called[_].id == "run_mock" 24 | res := { 25 | "id": "run2", 26 | "uses": "mock.after", 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pkg/chain/testdata/control/alert.rego: -------------------------------------------------------------------------------- 1 | package alert.my_test 2 | 3 | alert contains msg if { 4 | msg := { 5 | "title": "test alert", 6 | "attrs": [ 7 | { 8 | "key": "k1", 9 | "value": "v1", 10 | }, 11 | { 12 | "key": "k2", 13 | "value": "v2", 14 | }, 15 | ], 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pkg/chain/testdata/countup/action.rego: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | run contains job if { 4 | job := { 5 | "id": "my_job", 6 | "uses": "mock", 7 | "commit": [ 8 | { 9 | "id": attr.id, 10 | "key": "counter", 11 | "value": attr.value + 1, 12 | "persist": true, 13 | }, 14 | ], 15 | } 16 | } 17 | 18 | attr := input.alert.attrs[x] if { 19 | input.alert.attrs[x].key == "counter" 20 | } else := init if { 21 | init := { 22 | "id": null, 23 | "key": "counter", 24 | "value": 0, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pkg/chain/testdata/countup/alert.rego: -------------------------------------------------------------------------------- 1 | package alert.my_alert 2 | 3 | alert contains msg if { 4 | msg := { 5 | "title": "global alert test", 6 | "namespace": "default", 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /pkg/chain/testdata/force_action/action.rego: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | run contains job if { 4 | input.seq == 0 5 | job := { 6 | "id": "force_continue", 7 | "uses": "mock", 8 | "args": {"step": 1}, 9 | "force": true, 10 | } 11 | } 12 | 13 | run contains job if { 14 | input.seq == 1 15 | job := { 16 | "id": "stop_by_error", 17 | "uses": "mock", 18 | "args": {"step": 2}, 19 | "force": false, 20 | } 21 | } 22 | 23 | run contains job if { 24 | input.seq == 1 25 | job := { 26 | "id": "not_run", 27 | "uses": "mock", 28 | "args": {"step": 3}, 29 | "force": false, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pkg/chain/testdata/force_action/alert.rego: -------------------------------------------------------------------------------- 1 | package alert.my_alert 2 | 3 | alert contains msg if { 4 | msg := { 5 | "title": "global alert test", 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /pkg/chain/testdata/global_attr/action.rego: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | run contains { 4 | "id": "my_job", 5 | "uses": "mock", 6 | "commit": [ 7 | { 8 | "key": "status", 9 | "value": "done", 10 | "persist": true, 11 | }, 12 | ], 13 | } if { 14 | not is_done 15 | } 16 | 17 | is_done if { 18 | input.alert.attrs[x].key == "status" 19 | input.alert.attrs[x].value == "done" 20 | } 21 | -------------------------------------------------------------------------------- /pkg/chain/testdata/global_attr/alert.rego: -------------------------------------------------------------------------------- 1 | package alert.my_alert 2 | 3 | alert contains msg if { 4 | msg := { 5 | "title": "global alert test", 6 | "namespace": "default", 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /pkg/chain/testdata/loop/action.rego: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | run contains res if { 4 | p := input.alert.attrs[_] 5 | p.key == "c" 6 | p.value < 10 7 | 8 | res := { 9 | "uses": "mock", 10 | "commit": [ 11 | { 12 | "id": p.id, 13 | "key": "c", 14 | "value": p.value + 1, 15 | }, 16 | ], 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /pkg/chain/testdata/loop/alert.rego: -------------------------------------------------------------------------------- 1 | package alert.my_test 2 | 3 | alert contains msg if { 4 | msg := { 5 | "title": "test alert", 6 | "attrs": [{ 7 | "key": "c", 8 | "value": 1, 9 | }], 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /pkg/chain/testdata/play/action.rego: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | run contains res if { 4 | p := input.alert.attrs[_] 5 | p.key == "c" 6 | p.value < 3 7 | 8 | res := {"uses": "mock"} 9 | } 10 | 11 | # exit[res] { 12 | # p := input.alert.attrs[_] 13 | # p.key == "c" 14 | 15 | # res := {"attrs": [{ 16 | # "id": p.id, 17 | # "key": "c", 18 | # "value": p.value + 1, 19 | # }]} 20 | # } 21 | -------------------------------------------------------------------------------- /pkg/chain/testdata/play/alert.rego: -------------------------------------------------------------------------------- 1 | package alert.my_test 2 | 3 | alert contains msg if { 4 | msg := { 5 | "title": "test alert", 6 | "attrs": [{ 7 | "key": "c", 8 | "value": 1, 9 | }], 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /pkg/chain/testdata/play/playbook.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | scenarios: [ 3 | { 4 | id: 's1', 5 | title: 'Scenario 1', 6 | events: [ 7 | { 8 | input: { 9 | class: 'threat', 10 | }, 11 | schema: 'my_test', 12 | results: { 13 | my_action: [ 14 | { 15 | index: 'first', 16 | }, 17 | { 18 | index: 'second', 19 | }, 20 | ], 21 | }, 22 | }, 23 | ], 24 | }, 25 | ], 26 | } 27 | -------------------------------------------------------------------------------- /pkg/chain/testdata/play_workflow/action.rego: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | run contains job if { 4 | input.seq == 0 5 | job := { 6 | "id": "1st", 7 | "uses": "mock", 8 | "args": {"tick": 1}, 9 | } 10 | } 11 | 12 | run contains job if { 13 | input.seq == 1 14 | job := { 15 | "id": "2nd", 16 | "uses": "mock", 17 | "args": {"tick": 2}, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pkg/chain/testdata/play_workflow/playbook.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | scenarios: [ 3 | { 4 | id: 's1', 5 | title: 'Scenario 1', 6 | events: [ 7 | { 8 | input: { 9 | class: 'threat', 10 | }, 11 | schema: 'my_test', 12 | actions: { 13 | mock: [ 14 | { 15 | index: 'first', 16 | }, 17 | { 18 | index: 'second', 19 | }, 20 | ], 21 | }, 22 | }, 23 | ], 24 | }, 25 | ], 26 | } 27 | -------------------------------------------------------------------------------- /pkg/chain/workflow_test.go: -------------------------------------------------------------------------------- 1 | package chain_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "testing" 8 | 9 | "github.com/m-mizutani/gt" 10 | "github.com/secmon-lab/alertchain/pkg/chain" 11 | "github.com/secmon-lab/alertchain/pkg/domain/model" 12 | "github.com/secmon-lab/alertchain/pkg/domain/types" 13 | "github.com/secmon-lab/alertchain/pkg/infra/memory" 14 | "github.com/secmon-lab/alertchain/pkg/infra/policy" 15 | "github.com/secmon-lab/alertchain/pkg/infra/recorder" 16 | "github.com/secmon-lab/alertchain/pkg/service" 17 | ) 18 | 19 | type buffer struct { 20 | bytes.Buffer 21 | } 22 | 23 | func (x *buffer) Close() error { 24 | return nil 25 | } 26 | 27 | func TestWorkflow(t *testing.T) { 28 | actionPolicy := gt.R1(policy.New( 29 | policy.WithPackage("action"), 30 | policy.WithFile("testdata/play_workflow/action.rego"), 31 | policy.WithReadFile(read), 32 | )).NoError(t) 33 | 34 | var playbook model.Playbook 35 | gt.NoError(t, model.ParsePlaybook("testdata/play_workflow/playbook.jsonnet", read, &playbook)) 36 | gt.A(t, playbook.Scenarios).Length(1) 37 | 38 | var calledMock int 39 | mock := func(ctx context.Context, alert model.Alert, _ model.ActionArgs) (any, error) { 40 | calledMock++ 41 | return nil, nil 42 | } 43 | 44 | buf := &buffer{} 45 | recorder := recorder.NewJsonRecorder(buf, playbook.Scenarios[0]) 46 | c, err := chain.New( 47 | chain.WithPolicyAction(actionPolicy), 48 | chain.WithExtraAction("mock", mock), 49 | chain.WithActionMock(&playbook.Scenarios[0].Events[0]), 50 | chain.WithScenarioRecorder(recorder), 51 | chain.WithEnv(func() types.EnvVars { return types.EnvVars{} }), 52 | chain.WithEnablePrint(), 53 | ) 54 | gt.NoError(t, err) 55 | 56 | ctx := context.Background() 57 | alert := model.NewAlert(model.AlertMetaData{ 58 | Title: "test-alert", 59 | }, "test-alert", "test-data") 60 | 61 | svc := service.New(memory.New()) 62 | gt.NoError(t, c.RunWorkflow(ctx, alert, svc)) 63 | recorder.Flush() 64 | 65 | var log model.ScenarioLog 66 | gt.NoError(t, json.NewDecoder(bytes.NewReader(buf.Bytes())).Decode(&log)) 67 | 68 | gt.V(t, log.ID).Equal("s1") 69 | gt.V(t, log.Title).Equal("Scenario 1") 70 | gt.A(t, log.Results).Length(1).At(0, func(t testing.TB, v *model.PlayLog) { 71 | gt.V(t, v.Alert.Title).Equal("test-alert") 72 | gt.A(t, v.Actions).Length(2).At(0, func(t testing.TB, v *model.ActionLog) { 73 | gt.V(t, v.Seq).Equal(0) 74 | gt.V(t, v.Uses).Equal("mock") 75 | gt.V(t, v.ID).Equal("1st") 76 | gt.M(t, v.Args).EqualAt("tick", float64(1)) 77 | }).At(1, func(t testing.TB, v *model.ActionLog) { 78 | gt.V(t, v.Seq).Equal(1) 79 | gt.V(t, v.Uses).Equal("mock") 80 | gt.V(t, v.ID).Equal("2nd") 81 | gt.M(t, v.Args).EqualAt("tick", float64(2)) 82 | }) 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /pkg/controller/cli/cmd.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/secmon-lab/alertchain/pkg/controller/cli/config" 7 | "github.com/secmon-lab/alertchain/pkg/ctxutil" 8 | "github.com/secmon-lab/alertchain/pkg/domain/types" 9 | "github.com/secmon-lab/alertchain/pkg/logging" 10 | "github.com/urfave/cli/v3" 11 | ) 12 | 13 | type CLI struct { 14 | app *cli.Command 15 | } 16 | 17 | func New() *CLI { 18 | var ( 19 | loggerConfig config.Logger 20 | ) 21 | flags := []cli.Flag{} 22 | flags = append(flags, loggerConfig.Flags()...) 23 | 24 | defers := []func(){} 25 | 26 | app := &cli.Command{ 27 | Name: "alertchain", 28 | Flags: flags, 29 | Before: func(ctx context.Context, _ *cli.Command) (context.Context, error) { 30 | closer, err := loggerConfig.Configure() 31 | if err != nil { 32 | return nil, err 33 | } 34 | defers = append(defers, closer) 35 | 36 | return ctx, nil 37 | }, 38 | After: func(ctx context.Context, _ *cli.Command) error { 39 | for _, f := range defers { 40 | f() 41 | } 42 | return nil 43 | }, 44 | 45 | Commands: []*cli.Command{ 46 | cmdServe(), 47 | cmdRun(), 48 | cmdPlay(), 49 | cmdEnhance(), 50 | cmdNew(), 51 | { 52 | Name: "version", 53 | Aliases: []string{"v"}, 54 | Usage: "Show version", 55 | Action: func(context.Context, *cli.Command) error { 56 | println("alertchain", types.AppVersion) 57 | return nil 58 | }, 59 | }, 60 | }, 61 | } 62 | return &CLI{app: app} 63 | } 64 | 65 | func (x *CLI) Run(ctx context.Context, argv []string) error { 66 | if err := x.app.Run(ctx, argv); err != nil { 67 | ctxutil.Logger(ctx).Error("cli failed", logging.ErrAttr(err)) 68 | return err 69 | } 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /pkg/controller/cli/cmd_test.go: -------------------------------------------------------------------------------- 1 | package cli_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/m-mizutani/gt" 8 | "github.com/secmon-lab/alertchain/pkg/controller/cli" 9 | ) 10 | 11 | func TestCLI(t *testing.T) { 12 | ctx := context.Background() 13 | gt.NoError(t, cli.New().Run(ctx, []string{"alertchain"})) 14 | } 15 | -------------------------------------------------------------------------------- /pkg/controller/cli/common.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/secmon-lab/alertchain/pkg/chain" 7 | "github.com/secmon-lab/alertchain/pkg/controller/cli/config" 8 | "github.com/secmon-lab/alertchain/pkg/ctxutil" 9 | ) 10 | 11 | func buildChain(ctx context.Context, policy *config.Policy, options ...chain.Option) (*chain.Chain, error) { 12 | if policy.Print() { 13 | ctxutil.Logger(ctx).Info("enable print mode") 14 | options = append(options, chain.WithEnablePrint()) 15 | } 16 | 17 | alertPolicy, err := policy.Load(ctx, "alert") 18 | if err != nil { 19 | return nil, err 20 | } 21 | options = append(options, chain.WithPolicyAlert(alertPolicy)) 22 | 23 | actionPolicy, err := policy.Load(ctx, "action") 24 | if err != nil { 25 | return nil, err 26 | } 27 | options = append(options, chain.WithPolicyAction(actionPolicy)) 28 | 29 | return chain.New(options...) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/controller/cli/config/database.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/m-mizutani/goerr/v2" 7 | "github.com/secmon-lab/alertchain/pkg/domain/interfaces" 8 | "github.com/secmon-lab/alertchain/pkg/domain/types" 9 | "github.com/secmon-lab/alertchain/pkg/infra/firestore" 10 | "github.com/secmon-lab/alertchain/pkg/infra/memory" 11 | "github.com/secmon-lab/alertchain/pkg/utils" 12 | "github.com/urfave/cli/v3" 13 | ) 14 | 15 | type Database struct { 16 | dbType string 17 | firestoreProjectID string 18 | firestoreDatabaseID string 19 | } 20 | 21 | func (x *Database) Flags() []cli.Flag { 22 | category := "Database" 23 | 24 | return []cli.Flag{ 25 | &cli.StringFlag{ 26 | Name: "db-type", 27 | Usage: "Database type (memory, firestore)", 28 | Category: category, 29 | Aliases: []string{"t"}, 30 | Sources: cli.EnvVars("ALERTCHAIN_DB_TYPE"), 31 | Value: "memory", 32 | Destination: &x.dbType, 33 | }, 34 | &cli.StringFlag{ 35 | Name: "firestore-project-id", 36 | Usage: "Project ID of Firestore", 37 | Category: category, 38 | Sources: cli.EnvVars("ALERTCHAIN_FIRESTORE_PROJECT_ID"), 39 | Destination: &x.firestoreProjectID, 40 | }, 41 | &cli.StringFlag{ 42 | Name: "firestore-database-id", 43 | Usage: "Prefix of Firestore database ID", 44 | Category: category, 45 | Sources: cli.EnvVars("ALERTCHAIN_FIRESTORE_DATABASE_ID"), 46 | Destination: &x.firestoreDatabaseID, 47 | }, 48 | } 49 | } 50 | 51 | func (x *Database) New(ctx context.Context) (interfaces.Database, func(), error) { 52 | nopCloser := func() {} 53 | 54 | switch x.dbType { 55 | case "memory": 56 | return memory.New(), nopCloser, nil 57 | 58 | case "firestore": 59 | if x.firestoreProjectID == "" { 60 | return nil, nopCloser, goerr.New("firestore-project-id is required for firestore", goerr.T(types.ErrTagConfig)) 61 | } 62 | if x.firestoreDatabaseID == "" { 63 | return nil, nopCloser, goerr.New("firestore-collection-prefix is required for firestore") 64 | } 65 | 66 | client, err := firestore.New(ctx, x.firestoreProjectID, x.firestoreDatabaseID) 67 | if err != nil { 68 | return nil, nopCloser, goerr.Wrap(err, "failed to initialize firestore client", goerr.T(types.ErrTagConfig)) 69 | } 70 | return client, func() { utils.SafeClose(ctx, client) }, nil 71 | 72 | default: 73 | return nil, nopCloser, goerr.New("invalid db-type", goerr.V("db-type", x.dbType), goerr.T(types.ErrTagConfig)) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /pkg/controller/cli/config/logger.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log/slog" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/m-mizutani/goerr/v2" 12 | "github.com/secmon-lab/alertchain/pkg/domain/types" 13 | "github.com/secmon-lab/alertchain/pkg/logging" 14 | "github.com/secmon-lab/alertchain/pkg/utils" 15 | "github.com/urfave/cli/v3" 16 | ) 17 | 18 | type Logger struct { 19 | level string 20 | format string 21 | output string 22 | } 23 | 24 | func (x *Logger) Flags() []cli.Flag { 25 | return []cli.Flag{ 26 | &cli.StringFlag{ 27 | Name: "log-level", 28 | Category: "logging", 29 | Aliases: []string{"l"}, 30 | Sources: cli.EnvVars("ALERTCHAIN_LOG_LEVEL"), 31 | Usage: "Set log level [debug|info|warn|error]", 32 | Value: "info", 33 | Destination: &x.level, 34 | }, 35 | &cli.StringFlag{ 36 | Name: "log-format", 37 | Category: "logging", 38 | Aliases: []string{"f"}, 39 | Sources: cli.EnvVars("ALERTCHAIN_LOG_FORMAT"), 40 | Usage: "Set log format [console|json]", 41 | Destination: &x.format, 42 | }, 43 | &cli.StringFlag{ 44 | Name: "log-output", 45 | Category: "logging", 46 | Aliases: []string{"o"}, 47 | Sources: cli.EnvVars("ALERTCHAIN_LOG_OUTPUT"), 48 | Usage: "Set log output (create file other than '-', 'stdout', 'stderr')", 49 | Value: "-", 50 | Destination: &x.output, 51 | }, 52 | } 53 | } 54 | 55 | // Configure sets up logger and returns closer function and error. You can call closer even if error is not nil. 56 | func (x *Logger) Configure() (func(), error) { 57 | closer := func() {} 58 | formatMap := map[string]logging.Format{ 59 | "console": logging.FormatConsole, 60 | "json": logging.FormatJSON, 61 | } 62 | 63 | var format logging.Format 64 | if x.format == "" { 65 | term := os.Getenv("TERM") 66 | if strings.Contains(term, "color") || strings.Contains(term, "xterm") { 67 | format = logging.FormatConsole 68 | } else { 69 | format = logging.FormatJSON 70 | } 71 | } else { 72 | fmt, ok := formatMap[x.format] 73 | if !ok { 74 | return closer, goerr.New("Invalid log format", goerr.V("format", x.format), goerr.T(types.ErrTagConfig)) 75 | } 76 | format = fmt 77 | } 78 | 79 | levelMap := map[string]slog.Level{ 80 | "debug": slog.LevelDebug, 81 | "info": slog.LevelInfo, 82 | "warn": slog.LevelWarn, 83 | "error": slog.LevelError, 84 | } 85 | level, ok := levelMap[x.level] 86 | if !ok { 87 | return closer, goerr.New("Invalid log level", goerr.V("level", x.level), goerr.T(types.ErrTagConfig)) 88 | } 89 | 90 | var output io.Writer 91 | switch x.output { 92 | case "stdout", "-": 93 | output = os.Stdout 94 | case "stderr": 95 | output = os.Stderr 96 | default: 97 | f, err := os.OpenFile(filepath.Clean(x.output), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600) 98 | if err != nil { 99 | return closer, goerr.Wrap(err, "Failed to open log file", goerr.V("path", x.output)) 100 | } 101 | output = f 102 | closer = func() { 103 | utils.SafeClose(context.Background(), f) 104 | } 105 | } 106 | 107 | logging.ReconfigureLogger(output, level, format) 108 | 109 | return closer, nil 110 | } 111 | -------------------------------------------------------------------------------- /pkg/controller/cli/config/policy.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "github.com/secmon-lab/alertchain/pkg/chain" 8 | "github.com/secmon-lab/alertchain/pkg/ctxutil" 9 | "github.com/secmon-lab/alertchain/pkg/infra/policy" 10 | "github.com/urfave/cli/v3" 11 | ) 12 | 13 | type Policy struct { 14 | path string 15 | print bool 16 | } 17 | 18 | func (x *Policy) Path() string { return x.path } 19 | func (x *Policy) Print() bool { return x.print } 20 | 21 | func (x *Policy) Flags() []cli.Flag { 22 | category := "Policy" 23 | 24 | return []cli.Flag{ 25 | &cli.BoolFlag{ 26 | Name: "enable-print", 27 | Usage: "Enable print feature in Rego. The cli option is priority than config file.", 28 | Category: category, 29 | Aliases: []string{"p"}, 30 | Sources: cli.EnvVars("ALERTCHAIN_ENABLE_PRINT"), 31 | Value: false, 32 | Destination: &x.print, 33 | }, 34 | &cli.StringFlag{ 35 | Name: "policy-dir", 36 | Usage: "directory path of policy files", 37 | Category: category, 38 | Aliases: []string{"d"}, 39 | Sources: cli.EnvVars("ALERTCHAIN_POLICY_DIR"), 40 | Required: true, 41 | Destination: &x.path, 42 | }, 43 | } 44 | } 45 | 46 | func (x *Policy) Load(ctx context.Context, pkgName string) (*policy.Client, error) { 47 | ctxutil.Logger(ctx).Info("loading policy", 48 | slog.String("package", pkgName), 49 | slog.String("path", x.path), 50 | ) 51 | return policy.New(policy.WithDir(x.path), policy.WithPackage(pkgName)) 52 | } 53 | 54 | func (x *Policy) CoreOption(ctx context.Context) ([]chain.Option, error) { 55 | var options []chain.Option 56 | 57 | if x.Print() { 58 | ctxutil.Logger(ctx).Info("enable print mode") 59 | options = append(options, chain.WithEnablePrint()) 60 | } 61 | 62 | alertPolicy, err := x.Load(ctx, "alert") 63 | if err != nil { 64 | return nil, err 65 | } 66 | options = append(options, chain.WithPolicyAlert(alertPolicy)) 67 | 68 | actionPolicy, err := x.Load(ctx, "action") 69 | if err != nil { 70 | return nil, err 71 | } 72 | options = append(options, chain.WithPolicyAction(actionPolicy)) 73 | 74 | return options, nil 75 | } 76 | -------------------------------------------------------------------------------- /pkg/controller/cli/config/sentry.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/getsentry/sentry-go" 9 | "github.com/secmon-lab/alertchain/pkg/ctxutil" 10 | "github.com/secmon-lab/alertchain/pkg/domain/types" 11 | "github.com/secmon-lab/alertchain/pkg/logging" 12 | "github.com/urfave/cli/v3" 13 | ) 14 | 15 | type Sentry struct { 16 | dsn string 17 | env string 18 | } 19 | 20 | func (x *Sentry) Flags() []cli.Flag { 21 | category := "Sentry" 22 | 23 | return []cli.Flag{ 24 | &cli.StringFlag{ 25 | Name: "sentry-dsn", 26 | Usage: "Sentry DSN", 27 | Category: category, 28 | Destination: &x.dsn, 29 | Sources: cli.EnvVars("ALERTCHAIN_SENTRY_DSN"), 30 | }, 31 | &cli.StringFlag{ 32 | Name: "sentry-env", 33 | Usage: "Sentry environment", 34 | Category: category, 35 | Destination: &x.env, 36 | Sources: cli.EnvVars("ALERTCHAIN_SENTRY_ENV"), 37 | }, 38 | } 39 | } 40 | 41 | func (x *Sentry) Configure(ctx context.Context) (func(), error) { 42 | if x.dsn == "" { 43 | ctxutil.Logger(ctx).Warn("Sentry is not configured") 44 | return func() {}, nil 45 | } 46 | 47 | err := sentry.Init(sentry.ClientOptions{ 48 | Dsn: x.dsn, 49 | Environment: x.env, 50 | Release: fmt.Sprintf("alertchain@%s", types.AppVersion), 51 | Debug: false, 52 | }) 53 | if err != nil { 54 | ctxutil.Logger(ctx).Warn("failed to initialize Sentry", logging.ErrAttr(err)) 55 | return nil, err 56 | } 57 | 58 | // Flush buffered events before the program terminates. 59 | // Set the timeout to the maximum duration the program can afford to wait. 60 | return func() { 61 | sentry.Recover() 62 | sentry.Flush(2 * time.Second) 63 | }, nil 64 | } 65 | -------------------------------------------------------------------------------- /pkg/controller/cli/enhance.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | 7 | "github.com/secmon-lab/alertchain/pkg/controller/cli/config" 8 | "github.com/secmon-lab/alertchain/pkg/domain/types" 9 | "github.com/secmon-lab/alertchain/pkg/infra/gemini" 10 | "github.com/secmon-lab/alertchain/pkg/usecase" 11 | "github.com/urfave/cli/v3" 12 | ) 13 | 14 | func cmdEnhance() *cli.Command { 15 | return &cli.Command{ 16 | Name: "enhance", 17 | Aliases: []string{"e"}, 18 | Usage: "Enhance alertchain policy with Generative AI", 19 | Commands: []*cli.Command{ 20 | cmdEnhanceIgnore(), 21 | }, 22 | } 23 | } 24 | 25 | func cmdEnhanceIgnore() *cli.Command { 26 | var ( 27 | input usecase.EnhanceIgnorePolicyInput 28 | alertIDSet []string 29 | geminiProjectID string 30 | geminiLocation string 31 | 32 | dbCfg config.Database 33 | ) 34 | 35 | flags := []cli.Flag{ 36 | &cli.StringSliceFlag{ 37 | Name: "alert-id", 38 | Aliases: []string{"i"}, 39 | Usage: "Alert ID to ignore", 40 | Sources: cli.EnvVars("ALERTCHAIN_ALERT_ID"), 41 | Required: true, 42 | Destination: (*[]string)(&alertIDSet), 43 | }, 44 | &cli.StringFlag{ 45 | Name: "base-policy-file", 46 | Aliases: []string{"b"}, 47 | Usage: "Base policy file. It will be used as a template", 48 | Sources: cli.EnvVars("ALERTCHAIN_BASE_POLICY"), 49 | Required: true, 50 | Destination: &input.BasePolicyFile, 51 | }, 52 | &cli.StringFlag{ 53 | Name: "test-data-dir", 54 | Aliases: []string{"d"}, 55 | Usage: "Directory path to store test data (e.g. alert/testdata/your_rule)", 56 | Sources: cli.EnvVars("ALERTCHAIN_TEST_DATA_DIR"), 57 | Required: true, 58 | Destination: &input.TestDataDir, 59 | }, 60 | &cli.StringFlag{ 61 | Name: "test-data-rego-path", 62 | Aliases: []string{"r"}, 63 | Usage: "Path to store test data in rego format (e.g. data.alert.testdata.your_rule)", 64 | Sources: cli.EnvVars("ALERTCHAIN_TEST_DATA_REGO_PATH"), 65 | Required: true, 66 | Destination: &input.TestDataRegoPath, 67 | }, 68 | &cli.StringFlag{ 69 | Name: "gemini-project-id", 70 | Usage: "Google Cloud Project ID for Gemini", 71 | Sources: cli.EnvVars("ALERTCHAIN_GEMINI_PROJECT_ID"), 72 | Required: true, 73 | Destination: &geminiProjectID, 74 | }, 75 | &cli.StringFlag{ 76 | Name: "gemini-location", 77 | Usage: "Google Cloud Location for Gemini (e.g. us-central1)", 78 | Sources: cli.EnvVars("ALERTCHAIN_GEMINI_LOCATION"), 79 | Required: true, 80 | Destination: &geminiLocation, 81 | }, 82 | &cli.BoolFlag{ 83 | Name: "overwrite", 84 | Aliases: []string{"w"}, 85 | Usage: "Overwrite existing base policy file", 86 | Sources: cli.EnvVars("ALERTCHAIN_OVERWRITE"), 87 | Destination: &input.OverWrite, 88 | }, 89 | } 90 | 91 | flags = append(flags, dbCfg.Flags()...) 92 | 93 | return &cli.Command{ 94 | Name: "ignore", 95 | Usage: "Create new ignore policy based on the alert with Gemini", 96 | Flags: flags, 97 | 98 | Action: func(ctx context.Context, cmd *cli.Command) error { 99 | for _, id := range alertIDSet { 100 | input.AlertIDs = append(input.AlertIDs, types.AlertID(id)) 101 | } 102 | 103 | geminiClient, err := gemini.New(ctx, geminiProjectID, geminiLocation) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | dbClient, dbClose, err := dbCfg.New(ctx) 109 | if err != nil { 110 | return err 111 | } 112 | defer dbClose() 113 | 114 | if err := usecase.EnhanceIgnorePolicy(ctx, dbClient, geminiClient, input); err != nil { 115 | return err 116 | } 117 | 118 | return nil 119 | }, 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /pkg/controller/cli/new.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/secmon-lab/alertchain/pkg/usecase" 7 | "github.com/urfave/cli/v3" 8 | ) 9 | 10 | func cmdNew() *cli.Command { 11 | var ( 12 | dir string 13 | ) 14 | 15 | flags := []cli.Flag{ 16 | &cli.StringFlag{ 17 | Name: "dir", 18 | Aliases: []string{"d"}, 19 | Usage: "Directory path to create new AlertChain policy repository", 20 | Sources: cli.EnvVars("ALERTCHAIN_DIR"), 21 | Destination: &dir, 22 | Value: ".", 23 | }, 24 | } 25 | 26 | return &cli.Command{ 27 | Name: "new", 28 | Usage: "Create new AlertChain policy repository", 29 | Flags: flags, 30 | 31 | Action: func(ctx context.Context, cmd *cli.Command) error { 32 | if err := usecase.NewPolicyDirectory(ctx, dir); err != nil { 33 | return err 34 | } 35 | 36 | return nil 37 | }, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pkg/controller/cli/play.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/secmon-lab/alertchain/pkg/controller/cli/config" 7 | "github.com/secmon-lab/alertchain/pkg/ctxutil" 8 | "github.com/secmon-lab/alertchain/pkg/usecase" 9 | "github.com/urfave/cli/v3" 10 | ) 11 | 12 | func cmdPlay() *cli.Command { 13 | var ( 14 | input usecase.PlayInput 15 | 16 | policyCfg config.Policy 17 | ) 18 | 19 | flags := []cli.Flag{ 20 | &cli.StringFlag{ 21 | Name: "scenario", 22 | Aliases: []string{"s"}, 23 | Usage: "scenario directory", 24 | Sources: cli.EnvVars("ALERTCHAIN_SCENARIO"), 25 | Required: true, 26 | Destination: &input.ScenarioPath, 27 | }, 28 | &cli.StringFlag{ 29 | Name: "output", 30 | Aliases: []string{"o"}, 31 | Usage: "output directory", 32 | Sources: cli.EnvVars("ALERTCHAIN_OUTPUT"), 33 | Destination: &input.OutDir, 34 | Value: "./output", 35 | }, 36 | &cli.StringSliceFlag{ 37 | Name: "target", 38 | Aliases: []string{"t"}, 39 | Usage: "Target scenario ID to play. If not specified, all scenarios are played", 40 | Sources: cli.EnvVars("ALERTCHAIN_TARGET"), 41 | Destination: &input.Targets, 42 | }, 43 | } 44 | flags = append(flags, policyCfg.Flags()...) 45 | 46 | return &cli.Command{ 47 | Name: "play", 48 | Aliases: []string{"p"}, 49 | Usage: "Simulate alertchain policy", 50 | Flags: flags, 51 | 52 | Action: func(ctx context.Context, cmd *cli.Command) error { 53 | ctx = ctxutil.SetCLI(ctx) 54 | 55 | coreOptions, err := policyCfg.CoreOption(ctx) 56 | if err != nil { 57 | return err 58 | } 59 | input.CoreOptions = coreOptions 60 | 61 | if err := usecase.Play(ctx, input); err != nil { 62 | return err 63 | } 64 | 65 | return nil 66 | }, 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /pkg/controller/cli/run.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | 10 | "log/slog" 11 | 12 | "github.com/m-mizutani/goerr/v2" 13 | "github.com/secmon-lab/alertchain/pkg/chain" 14 | "github.com/secmon-lab/alertchain/pkg/controller/cli/config" 15 | "github.com/secmon-lab/alertchain/pkg/ctxutil" 16 | "github.com/secmon-lab/alertchain/pkg/domain/types" 17 | "github.com/urfave/cli/v3" 18 | ) 19 | 20 | func cmdRun() *cli.Command { 21 | var ( 22 | input string 23 | schema types.Schema 24 | policyCfg config.Policy 25 | ) 26 | 27 | flags := []cli.Flag{ 28 | &cli.StringFlag{ 29 | Name: "input", 30 | Aliases: []string{"i"}, 31 | Usage: "input file or '-' for stdin", 32 | Sources: cli.EnvVars("ALERTCHAIN_INPUT"), 33 | Required: true, 34 | Destination: &input, 35 | Category: "run", 36 | }, 37 | &cli.StringFlag{ 38 | Name: "schema", 39 | Aliases: []string{"s"}, 40 | Usage: "schema type", 41 | Sources: cli.EnvVars("ALERTCHAIN_SCHEMA"), 42 | Required: true, 43 | Destination: (*string)(&schema), 44 | }, 45 | } 46 | flags = append(flags, policyCfg.Flags()...) 47 | 48 | return &cli.Command{ 49 | Name: "run", 50 | Aliases: []string{"r"}, 51 | Usage: "Run alertchain policy at once and exit in", 52 | Flags: flags, 53 | Action: func(ctx context.Context, cmd *cli.Command) error { 54 | var chainOptions []chain.Option 55 | 56 | chain, err := buildChain(ctx, &policyCfg, chainOptions...) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | var r io.Reader 62 | if input != "-" { 63 | fd, err := os.Open(filepath.Clean(input)) 64 | if err != nil { 65 | return goerr.Wrap(err, "failed to open input file") 66 | } 67 | r = fd 68 | } else { 69 | r = os.Stdin 70 | } 71 | 72 | var data any 73 | if err := json.NewDecoder(r).Decode(&data); err != nil { 74 | return goerr.Wrap(err, "failed to decode input data") 75 | } 76 | 77 | ctxutil.Logger(ctx).Info("starting alertchain with run mode", slog.Any("data", data)) 78 | 79 | if _, err := chain.HandleAlert(ctx, schema, data); err != nil { 80 | return goerr.Wrap(err, "failed to handle alert") 81 | } 82 | 83 | return nil 84 | }, 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /pkg/controller/cli/serve.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "github.com/secmon-lab/alertchain/pkg/chain" 8 | "github.com/secmon-lab/alertchain/pkg/controller/cli/config" 9 | "github.com/secmon-lab/alertchain/pkg/controller/graphql" 10 | "github.com/secmon-lab/alertchain/pkg/controller/server" 11 | "github.com/secmon-lab/alertchain/pkg/ctxutil" 12 | "github.com/secmon-lab/alertchain/pkg/service" 13 | "github.com/secmon-lab/alertchain/pkg/utils" 14 | "github.com/urfave/cli/v3" 15 | ) 16 | 17 | func cmdServe() *cli.Command { 18 | var ( 19 | addr string 20 | disableAction bool 21 | playground bool 22 | graphQL bool 23 | 24 | dbCfg config.Database 25 | policyCfg config.Policy 26 | sentryCfg config.Sentry 27 | ) 28 | 29 | flags := []cli.Flag{ 30 | &cli.StringFlag{ 31 | Name: "addr", 32 | Usage: "Bind address", 33 | Aliases: []string{"a"}, 34 | Sources: cli.EnvVars("ALERTCHAIN_ADDR"), 35 | Value: "127.0.0.1:8080", 36 | Destination: &addr, 37 | }, 38 | &cli.BoolFlag{ 39 | Name: "graphql", 40 | Usage: "Enable GraphQL", 41 | Sources: cli.EnvVars("ALERTCHAIN_GRAPHQL"), 42 | Value: true, 43 | Destination: &graphQL, 44 | }, 45 | &cli.BoolFlag{ 46 | Name: "playground", 47 | Usage: "Enable GraphQL playground", 48 | Sources: cli.EnvVars("ALERTCHAIN_PLAYGROUND"), 49 | Value: false, 50 | Destination: &playground, 51 | }, 52 | } 53 | flags = append(flags, dbCfg.Flags()...) 54 | flags = append(flags, policyCfg.Flags()...) 55 | flags = append(flags, sentryCfg.Flags()...) 56 | 57 | return &cli.Command{ 58 | Name: "serve", 59 | Aliases: []string{"s"}, 60 | Flags: flags, 61 | 62 | Action: func(ctx context.Context, cmd *cli.Command) error { 63 | ctxutil.Logger(ctx).Info("starting alertchain with serve mode", 64 | slog.String("addr", addr), 65 | slog.Bool("disable-action", disableAction), 66 | slog.Any("database", dbCfg), 67 | slog.Any("sentry", sentryCfg), 68 | ) 69 | 70 | // Build chain 71 | var chainOpt []chain.Option 72 | 73 | dbClient, dbCloser, err := dbCfg.New(ctx) 74 | if err != nil { 75 | return err 76 | } 77 | defer dbCloser() 78 | chainOpt = append(chainOpt, chain.WithDatabase(dbClient)) 79 | 80 | sentryCloser, err := sentryCfg.Configure(ctx) 81 | if err != nil { 82 | return err 83 | } 84 | defer sentryCloser() 85 | 86 | chain, err := buildChain(ctx, &policyCfg, chainOpt...) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | // Build server 92 | var serverOpt []server.Option 93 | 94 | authz, err := policyCfg.Load(ctx, "authz") 95 | if err != nil { 96 | return err 97 | } 98 | serverOpt = append(serverOpt, server.WithAuthzPolicy(authz)) 99 | 100 | if graphQL { 101 | resolver := graphql.NewResolver(service.New(dbClient)) 102 | serverOpt = append(serverOpt, server.WithResolver(resolver)) 103 | } 104 | if playground { 105 | serverOpt = append(serverOpt, server.WithEnableGraphiQL()) 106 | } 107 | 108 | srv := server.New(chain.HandleAlert, serverOpt...) 109 | 110 | // Starting server 111 | ctxutil.Logger(ctx).Info("starting alertchain with serve mode", slog.String("addr", addr)) 112 | if err := srv.Run(addr); err != nil { 113 | utils.HandleError(ctx, err) 114 | return err 115 | } 116 | 117 | return nil 118 | }, 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /pkg/controller/graphql/resolver.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import "github.com/secmon-lab/alertchain/pkg/service" 4 | 5 | // This file will not be regenerated automatically. 6 | // 7 | // It serves as dependency injection for your app, add any dependencies you require here. 8 | 9 | type Resolver struct { 10 | svc *service.Services 11 | } 12 | 13 | func NewResolver(svc *service.Services) *Resolver { 14 | return &Resolver{ 15 | svc: svc, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pkg/controller/graphql/schema.resolvers.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | // Code generated by github.com/99designs/gqlgen version v0.17.61 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | 11 | "github.com/secmon-lab/alertchain/pkg/domain/model" 12 | "github.com/secmon-lab/alertchain/pkg/domain/types" 13 | "github.com/secmon-lab/alertchain/pkg/utils" 14 | ) 15 | 16 | // Workflows is the resolver for the workflows field. 17 | func (r *queryResolver) Workflows(ctx context.Context, offset *int, limit *int) ([]*model.WorkflowRecord, error) { 18 | results, err := r.svc.Workflow.Get(ctx, offset, limit) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | return utils.ToPtrSlice(results), nil 24 | } 25 | 26 | // Workflow is the resolver for the workflow field. 27 | func (r *queryResolver) Workflow(ctx context.Context, id string) (*model.WorkflowRecord, error) { 28 | return r.svc.Workflow.Lookup(ctx, types.WorkflowID(id)) 29 | } 30 | 31 | // Actions is the resolver for the actions field. 32 | func (r *workflowRecordResolver) Actions(ctx context.Context, obj *model.WorkflowRecord) ([]*model.ActionRecord, error) { 33 | panic(fmt.Errorf("not implemented: Actions - actions")) 34 | } 35 | 36 | // Query returns QueryResolver implementation. 37 | func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } 38 | 39 | // WorkflowRecord returns WorkflowRecordResolver implementation. 40 | func (r *Resolver) WorkflowRecord() WorkflowRecordResolver { return &workflowRecordResolver{r} } 41 | 42 | type queryResolver struct{ *Resolver } 43 | type workflowRecordResolver struct{ *Resolver } 44 | -------------------------------------------------------------------------------- /pkg/controller/server/middleware.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | 10 | "log/slog" 11 | 12 | "github.com/secmon-lab/alertchain/pkg/ctxutil" 13 | "github.com/secmon-lab/alertchain/pkg/domain/interfaces" 14 | "github.com/secmon-lab/alertchain/pkg/domain/types" 15 | "github.com/secmon-lab/alertchain/pkg/infra/policy" 16 | "github.com/secmon-lab/alertchain/pkg/logging" 17 | "github.com/secmon-lab/alertchain/pkg/utils" 18 | ) 19 | 20 | type StatusCodeWriter struct { 21 | code int 22 | http.ResponseWriter 23 | } 24 | 25 | func (x *StatusCodeWriter) WriteHeader(code int) { 26 | x.code = code 27 | x.ResponseWriter.WriteHeader(code) 28 | } 29 | 30 | type HTTPAuthzInput struct { 31 | Method string `json:"method"` 32 | Path string `json:"path"` 33 | Query url.Values `json:"query"` 34 | Header map[string][]string `json:"header"` 35 | Remote string `json:"remote"` 36 | Body string `json:"body"` 37 | Env types.EnvVars `json:"env"` 38 | } 39 | 40 | type HTTPAuthzOutput struct { 41 | Deny bool `json:"deny"` 42 | } 43 | 44 | func Authorize(authz *policy.Client, getEnv interfaces.Env) func(next http.Handler) http.Handler { 45 | return func(next http.Handler) http.Handler { 46 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 47 | ctx := r.Context() 48 | if authz != nil { 49 | reader := r.Body 50 | body, err := io.ReadAll(reader) 51 | if err != nil { 52 | utils.HandleError(ctx, err) 53 | w.WriteHeader(http.StatusBadRequest) 54 | utils.SafeWrite(ctx, w, []byte(err.Error())) 55 | return 56 | } 57 | defer utils.SafeClose(ctx, reader) 58 | r.Body = io.NopCloser(bytes.NewReader(body)) 59 | 60 | input := &HTTPAuthzInput{ 61 | Method: r.Method, 62 | Path: r.URL.Path, 63 | Query: r.URL.Query(), 64 | Header: r.Header, 65 | Remote: r.RemoteAddr, 66 | Body: string(body), 67 | Env: getEnv(), 68 | } 69 | 70 | options := []policy.QueryOption{ 71 | policy.WithPackageSuffix("http"), 72 | policy.WithRegoPrint(func(file string, row int, msg string) error { 73 | ctxutil.Logger(ctx).Info("rego print", 74 | slog.String("file", file), 75 | slog.Int("row", row), 76 | slog.String("msg", msg), 77 | slog.String("package", "authz.http"), 78 | ) 79 | return nil 80 | }), 81 | } 82 | 83 | var output HTTPAuthzOutput 84 | if err := authz.Query(r.Context(), input, &output, options...); err != nil { 85 | if !errors.Is(err, types.ErrNoPolicyResult) { 86 | ctxutil.Logger(ctx).Error("Fail to evaluate authz policy", logging.ErrAttr(err)) 87 | w.WriteHeader(http.StatusInternalServerError) 88 | return 89 | } 90 | } 91 | 92 | if output.Deny { 93 | w.WriteHeader(http.StatusForbidden) 94 | utils.SafeWrite(ctx, w, []byte("Access denied")) 95 | return 96 | } 97 | } 98 | 99 | next.ServeHTTP(w, r) 100 | }) 101 | } 102 | } 103 | 104 | func Logging(next http.Handler) http.Handler { 105 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 106 | ctx := r.Context() 107 | logger := ctxutil.Logger(ctx).With("request_id", types.NewRequestID()) 108 | ctx = ctxutil.InjectLogger(ctx, logger) 109 | 110 | sw := &StatusCodeWriter{ResponseWriter: w} 111 | next.ServeHTTP(sw, r.WithContext(ctx)) 112 | logger.Info("request", 113 | slog.Any("method", r.Method), 114 | slog.Any("path", r.URL.Path), 115 | slog.Int("status", sw.code), 116 | slog.Any("remote", r.RemoteAddr), 117 | ) 118 | }) 119 | } 120 | -------------------------------------------------------------------------------- /pkg/controller/server/testdata/action.rego: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | run contains job if { 4 | job := { 5 | "id": "test", 6 | "uses": "http.fetch", 7 | "args": { 8 | "method": "GET", 9 | "url": "https://emhkq5vqrco2fpr6zqlctbjale0eyygt.lambda-url.ap-northeast-1.on.aws", 10 | }, 11 | "commit": [ 12 | { 13 | "key": "added_attr", 14 | "value": "swirls", 15 | }, 16 | ], 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /pkg/controller/server/testdata/alert.rego: -------------------------------------------------------------------------------- 1 | package alert.test_service 2 | 3 | alert contains msg if { 4 | msg := { 5 | "title": "test alert", 6 | "description": "test description", 7 | "attrs": {{ 8 | "key": "test_attr", 9 | "value": "test_value", 10 | }}, 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /pkg/controller/server/testdata/authz.rego: -------------------------------------------------------------------------------- 1 | package authz.http 2 | 3 | default deny = true 4 | 5 | deny := false if { allow } 6 | 7 | allow if { 8 | input.path == "/health" 9 | } 10 | 11 | jwks_request(url) := http.send({ 12 | "url": url, 13 | "method": "GET", 14 | "force_cache": true, 15 | "force_cache_duration_seconds": 3600 # Cache response for an hour 16 | }).raw_body 17 | 18 | # Allow request to /api if the request contains a valid JWT token 19 | allow if { 20 | # Check path 21 | startswith(input.path, "/alert/") 22 | 23 | # Extract token from Authorization header 24 | authHdr := input.header["Authorization"] 25 | count(authHdr) == 1 26 | authHdrValues := split(authHdr[0], " ") 27 | count(authHdrValues) == 2 28 | lower(authHdrValues[0]) == "bearer" 29 | token := authHdrValues[1] 30 | 31 | # Get JWKS of google 32 | jwks := jwks_request("https://www.googleapis.com/oauth2/v3/certs") 33 | 34 | # Verify token 35 | io.jwt.verify_rs256(token, jwks) 36 | claims := io.jwt.decode(token) 37 | claims[1]["iss"] == "https://accounts.google.com" 38 | claims[1]["email"] == "__GOOGLE_CLOUD_ACCOUNT_EMAIL__" 39 | time.now_ns() / (1000 * 1000 * 1000) < claims[1]["exp"] 40 | } 41 | 42 | allow if { 43 | input.path == "/alert/raw/cloudstrike_hawk" 44 | 45 | sigHdr := input.header["X-Cs-Primary-Signature"] 46 | count(sigHdr) == 1 47 | sig := hex.encode(base64.decode(sigHdr[0])) 48 | 49 | tsHdr := input.header["X-Cs-Delivery-Timestamp"] 50 | count(tsHdr) == 1 51 | ts := tsHdr[0] 52 | 53 | d := concat("", [input.body, ts]) 54 | sign := crypto.hmac.sha256(d, input.env.CLOUDSTRIKE_HAWK_KEY) 55 | crypto.hmac.equal(sign, sig) 56 | } 57 | -------------------------------------------------------------------------------- /pkg/ctxutil/ctxutil.go: -------------------------------------------------------------------------------- 1 | package ctxutil 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "time" 7 | 8 | "github.com/secmon-lab/alertchain/pkg/domain/model" 9 | "github.com/secmon-lab/alertchain/pkg/logging" 10 | ) 11 | 12 | type ctxStackKey struct{} 13 | 14 | func InjectStack(ctx context.Context, stack int) context.Context { 15 | return context.WithValue(ctx, ctxStackKey{}, stack) 16 | } 17 | 18 | func GetStack(ctx context.Context) int { 19 | v := ctx.Value(ctxStackKey{}) 20 | if v == nil { 21 | return 0 22 | } 23 | return v.(int) 24 | } 25 | 26 | type ctxAlertKey struct{} 27 | 28 | func InjectAlert(ctx context.Context, alert *model.Alert) context.Context { 29 | return context.WithValue(ctx, ctxAlertKey{}, alert) 30 | } 31 | 32 | func GetAlert(ctx context.Context) *model.Alert { 33 | v := ctx.Value(ctxAlertKey{}) 34 | if v == nil { 35 | return nil 36 | } 37 | return v.(*model.Alert) 38 | } 39 | 40 | type ctxDryRunKey struct{} 41 | 42 | func SetDryRun(ctx context.Context, dryRun bool) context.Context { 43 | return context.WithValue(ctx, ctxDryRunKey{}, dryRun) 44 | } 45 | 46 | func IsDryRun(ctx context.Context) bool { 47 | v := ctx.Value(ctxDryRunKey{}) 48 | if v == nil { 49 | return false 50 | } 51 | return v.(bool) 52 | } 53 | 54 | type ctxClockKey struct{} 55 | 56 | func InjectClock(ctx context.Context, clock model.Clock) context.Context { 57 | return context.WithValue(ctx, ctxClockKey{}, clock) 58 | } 59 | 60 | func Now(ctx context.Context) time.Time { 61 | v := ctx.Value(ctxClockKey{}) 62 | if v == nil { 63 | return time.Now() 64 | } 65 | return v.(model.Clock)() 66 | } 67 | 68 | type ctxCLIKey struct{} 69 | 70 | func SetCLI(ctx context.Context) context.Context { 71 | return context.WithValue(ctx, ctxCLIKey{}, true) 72 | } 73 | 74 | func IsCLI(ctx context.Context) bool { 75 | v := ctx.Value(ctxCLIKey{}) 76 | if v == nil { 77 | return false 78 | } 79 | return v.(bool) 80 | } 81 | 82 | type ctxLoggerKey struct{} 83 | 84 | func InjectLogger(ctx context.Context, logger *slog.Logger) context.Context { 85 | return context.WithValue(ctx, ctxLoggerKey{}, logger) 86 | } 87 | 88 | func Logger(ctx context.Context) *slog.Logger { 89 | v := ctx.Value(ctxLoggerKey{}) 90 | if v == nil { 91 | return logging.Default() 92 | } 93 | return v.(*slog.Logger) 94 | } 95 | -------------------------------------------------------------------------------- /pkg/domain/interfaces/infra.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/secmon-lab/alertchain/pkg/domain/model" 8 | "github.com/secmon-lab/alertchain/pkg/domain/types" 9 | ) 10 | 11 | type GenAI interface { 12 | Generate(ctx context.Context, prompts ...string) ([]string, error) 13 | } 14 | 15 | type Database interface { 16 | GetAttrs(ctx context.Context, ns types.Namespace) (model.Attributes, error) 17 | PutAttrs(ctx context.Context, ns types.Namespace, attrs model.Attributes) error 18 | PutWorkflow(ctx context.Context, workflow model.WorkflowRecord) error 19 | GetWorkflows(ctx context.Context, offset, limit int) ([]model.WorkflowRecord, error) 20 | GetWorkflow(ctx context.Context, id types.WorkflowID) (*model.WorkflowRecord, error) 21 | PutAlert(ctx context.Context, alert model.Alert) error 22 | GetAlert(ctx context.Context, id types.AlertID) (*model.Alert, error) 23 | Lock(ctx context.Context, ns types.Namespace, timeout time.Time) error 24 | Unlock(ctx context.Context, ns types.Namespace) error 25 | Close() error 26 | } 27 | -------------------------------------------------------------------------------- /pkg/domain/interfaces/interfaces.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/secmon-lab/alertchain/pkg/domain/model" 7 | "github.com/secmon-lab/alertchain/pkg/domain/types" 8 | ) 9 | 10 | // ActionMock is an interface for "play" mode. The mock should be registered as an option within the chain.Chain. This mock only returns the prepared result for each action ID. 11 | type ActionMock interface { 12 | GetResult(name types.ActionName) any 13 | } 14 | 15 | // ScenarioRecorder records the "play" result of the alert chain, which is used for debugging and testing purposes. A logger should be created by the LoggerFactory for each scenario. The LoggerFactory is registered as an option within the chain.Chain. 16 | type ScenarioRecorder interface { 17 | NewAlertRecorder(alert *model.Alert) AlertRecorder 18 | LogError(err error) 19 | Flush() error 20 | } 21 | 22 | // AlertRecorder records the "play" Action results of the chain, which is used for debugging and testing purposes. An AlertRecorder should be created by the ScenarioRecorder for each alert. The ScenarioRecorder is registered as an option within the chain.Chain. 23 | type AlertRecorder interface { 24 | NewActionRecorder() ActionRecorder 25 | } 26 | 27 | // ActionRecorder records the "play" result of each action, which is used for debugging and testing purposes. An ActionRecorder should be created by the AlertRecorder for each action. The AlertRecorder is registered as an option within the chain.Chain. 28 | type ActionRecorder interface { 29 | Add(action model.Action) 30 | } 31 | 32 | // AlertHandler is a function to handle the alert from data source. The handler is registered as an option within the chain.Chain. 33 | type AlertHandler func(ctx context.Context, schema types.Schema, data any) ([]*model.Alert, error) 34 | 35 | type Env func() types.EnvVars 36 | -------------------------------------------------------------------------------- /pkg/domain/model/action.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "github.com/m-mizutani/goerr/v2" 8 | ) 9 | 10 | // RunAction is a function to run an action. The function is registered as an option within the chain.Chain. 11 | type RunAction func(ctx context.Context, alert Alert, args ActionArgs) (any, error) 12 | 13 | type ActionArgs map[string]any 14 | 15 | func ArgDef[T any](key string, dst *T, options ...ArgOption) ArgParser { 16 | var opt argParserOption 17 | for _, o := range options { 18 | o(&opt) 19 | } 20 | 21 | return func(args ActionArgs) error { 22 | v, ok := args[key] 23 | if !ok { 24 | if opt.Optional { 25 | return nil 26 | } 27 | return goerr.New("No such Optional key in action args", goerr.V("key", key)) 28 | } 29 | 30 | raw, err := json.Marshal(v) 31 | if err != nil { 32 | return goerr.Wrap(err, "Failed to marshal action args", goerr.V("key", key)) 33 | } 34 | 35 | var src T 36 | if err := json.Unmarshal(raw, &src); err != nil { 37 | return goerr.Wrap(err, "Failed to unmarshal action args", goerr.V("key", key)) 38 | } 39 | 40 | *dst = src 41 | 42 | return nil 43 | } 44 | } 45 | 46 | type argParserOption struct { 47 | Optional bool 48 | } 49 | 50 | type ArgOption func(*argParserOption) 51 | 52 | func ArgOptional() ArgOption { 53 | return func(opt *argParserOption) { 54 | opt.Optional = true 55 | } 56 | } 57 | 58 | type ArgParser func(args ActionArgs) error 59 | 60 | func (x ActionArgs) Parse(psr ...ArgParser) error { 61 | for _, p := range psr { 62 | if err := p(x); err != nil { 63 | return err 64 | } 65 | } 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /pkg/domain/model/action_test.go: -------------------------------------------------------------------------------- 1 | package model_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/m-mizutani/gt" 7 | "github.com/secmon-lab/alertchain/pkg/domain/model" 8 | ) 9 | 10 | func TestActionArgsParser(t *testing.T) { 11 | args := model.ActionArgs{ 12 | "foo": "bar", 13 | "bar": int64(123), 14 | "baz": float64(3.14), 15 | "raw": []byte("raw"), 16 | "ss": []string{"a", "b"}, 17 | } 18 | 19 | t.Run("parse all", func(t *testing.T) { 20 | var foo string 21 | var bar int64 22 | var baz float64 23 | var raw []byte 24 | var ss []string 25 | 26 | gt.NoError(t, args.Parse( 27 | model.ArgDef("foo", &foo), 28 | model.ArgDef("bar", &bar), 29 | model.ArgDef("baz", &baz), 30 | model.ArgDef("raw", &raw), 31 | model.ArgDef("ss", &ss), 32 | )) 33 | gt.Equal(t, "bar", foo) 34 | gt.Equal(t, int64(123), bar) 35 | gt.Equal(t, float64(3.14), baz) 36 | gt.Equal(t, []byte("raw"), raw) 37 | gt.A(t, ss).Length(2).Have("a").Have("b") 38 | }) 39 | 40 | t.Run("parse partial", func(t *testing.T) { 41 | var foo string 42 | var baz float64 43 | gt.NoError(t, args.Parse( 44 | model.ArgDef("foo", &foo), 45 | model.ArgDef("baz", &baz), 46 | )) 47 | gt.Equal(t, "bar", foo) 48 | gt.Equal(t, float64(3.14), baz) 49 | }) 50 | 51 | t.Run("parse error", func(t *testing.T) { 52 | var foo string 53 | 54 | gt.Error(t, args.Parse( 55 | model.ArgDef("xxx", &foo), 56 | )) 57 | }) 58 | 59 | t.Run("type error", func(t *testing.T) { 60 | var foo int64 61 | 62 | gt.Error(t, args.Parse( 63 | model.ArgDef("foo", &foo), 64 | )) 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /pkg/domain/model/alert.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/secmon-lab/alertchain/pkg/domain/types" 10 | ) 11 | 12 | type AlertMetaData struct { 13 | Title string `json:"title"` 14 | Description string `json:"description"` 15 | Source string `json:"source"` 16 | Namespace types.Namespace `json:"namespace"` 17 | Attrs Attributes `json:"attrs"` 18 | Refs References `json:"refs"` 19 | } 20 | 21 | func (x AlertMetaData) Copy() AlertMetaData { 22 | newMeta := AlertMetaData{ 23 | Title: x.Title, 24 | Description: x.Description, 25 | Source: x.Source, 26 | Namespace: x.Namespace, 27 | Attrs: x.Attrs.Copy(), 28 | Refs: x.Refs.Copy(), 29 | } 30 | return newMeta 31 | } 32 | 33 | type Alert struct { 34 | AlertMetaData 35 | ID types.AlertID `json:"id"` 36 | Schema types.Schema `json:"schema"` 37 | Data any `json:"data,omitempty"` 38 | CreatedAt time.Time `json:"created_at"` 39 | 40 | // Raw is a JSON string of Data. The field will be redacted by masq because of verbosity. 41 | Raw string `json:"raw,omitempty" masq:"quiet"` 42 | } 43 | 44 | func (x Alert) Copy() Alert { 45 | newAlert := Alert{ 46 | AlertMetaData: x.AlertMetaData.Copy(), 47 | 48 | ID: x.ID, 49 | Schema: x.Schema, 50 | Data: x.Data, 51 | CreatedAt: x.CreatedAt, 52 | 53 | Raw: x.Raw, 54 | } 55 | 56 | return newAlert 57 | } 58 | 59 | func encodeAlertData(a any) string { 60 | var buf bytes.Buffer 61 | encoder := json.NewEncoder(&buf) 62 | encoder.SetIndent("", " ") 63 | if err := encoder.Encode(a); err != nil { 64 | return fmt.Sprintf("%v", a) 65 | } 66 | 67 | return buf.String() 68 | } 69 | 70 | func NewAlert(meta AlertMetaData, schema types.Schema, data any) Alert { 71 | alert := Alert{ 72 | AlertMetaData: meta, 73 | ID: types.NewAlertID(), 74 | Schema: schema, 75 | Data: data, 76 | CreatedAt: time.Now(), 77 | 78 | Raw: encodeAlertData(data), 79 | } 80 | alert.AlertMetaData.Attrs = alert.AlertMetaData.Attrs.Tidy() 81 | 82 | return alert 83 | } 84 | -------------------------------------------------------------------------------- /pkg/domain/model/attribute.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/secmon-lab/alertchain/pkg/domain/types" 5 | ) 6 | 7 | type Attribute struct { 8 | ID types.AttrID `json:"id" firestore:"id"` 9 | Key types.AttrKey `json:"key" firestore:"key"` 10 | Value types.AttrValue `json:"value" firestore:"value"` 11 | Type types.AttrType `json:"type" firestore:"type"` 12 | Persist bool `json:"persist" firestore:"persist"` 13 | TTL int `json:"ttl" firestore:"ttl"` 14 | } 15 | 16 | func (x Attribute) Copy() Attribute { 17 | copied := x 18 | return copied 19 | } 20 | 21 | type Attributes []Attribute 22 | 23 | func (x Attributes) Copy() Attributes { 24 | newAttrs := make(Attributes, len(x)) 25 | for i, p := range x { 26 | newAttrs[i] = Attribute{ 27 | ID: p.ID, 28 | Key: p.Key, 29 | Value: p.Value, 30 | Type: p.Type, 31 | TTL: p.TTL, 32 | Persist: p.Persist, 33 | } 34 | } 35 | return newAttrs 36 | } 37 | 38 | func (x Attributes) Tidy() Attributes { 39 | var ret Attributes 40 | 41 | idMap := map[types.AttrID]int{} 42 | 43 | for _, p := range x { 44 | if p.ID == "" { 45 | p.ID = types.NewAttrID() 46 | } 47 | 48 | if _, ok := idMap[p.ID]; ok { 49 | ret[idMap[p.ID]] = p 50 | } else { 51 | ret = append(ret, p) 52 | idMap[p.ID] = len(ret) - 1 53 | } 54 | } 55 | 56 | return ret 57 | } 58 | -------------------------------------------------------------------------------- /pkg/domain/model/attribute_test.go: -------------------------------------------------------------------------------- 1 | package model_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/m-mizutani/gt" 7 | "github.com/secmon-lab/alertchain/pkg/domain/model" 8 | ) 9 | 10 | func TestTidyAttributes(t *testing.T) { 11 | attrs := model.Attributes{ 12 | {ID: "1", Key: "attr1", Value: "value1", Type: "type1"}, 13 | {ID: "2", Key: "attr2", Value: "value2", Type: "type2"}, 14 | {ID: "1", Key: "attr1_updated", Value: "value1_updated", Type: "type1_updated"}, 15 | {ID: "3", Key: "attr3", Value: "value3", Type: "type3"}, 16 | } 17 | 18 | expected := model.Attributes{ 19 | {ID: "1", Key: "attr1_updated", Value: "value1_updated", Type: "type1_updated"}, 20 | {ID: "2", Key: "attr2", Value: "value2", Type: "type2"}, 21 | {ID: "3", Key: "attr3", Value: "value3", Type: "type3"}, 22 | } 23 | 24 | result := attrs.Tidy() 25 | gt.A(t, result).Equal(expected) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/domain/model/clock.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | type Clock func() time.Time 6 | -------------------------------------------------------------------------------- /pkg/domain/model/graphql.go: -------------------------------------------------------------------------------- 1 | // Code generated by github.com/99designs/gqlgen, DO NOT EDIT. 2 | 3 | package model 4 | 5 | import ( 6 | "time" 7 | 8 | "github.com/secmon-lab/alertchain/pkg/domain/types" 9 | ) 10 | 11 | type ActionRecord struct { 12 | ID string `json:"id"` 13 | Seq int `json:"seq"` 14 | Uses string `json:"uses"` 15 | Args []*ArgumentRecord `json:"args"` 16 | Result *string `json:"result,omitempty"` 17 | Next []*NextRecord `json:"next"` 18 | Error *string `json:"error,omitempty"` 19 | StartedAt time.Time `json:"startedAt"` 20 | FinishedAt time.Time `json:"finishedAt"` 21 | } 22 | 23 | type AlertRecord struct { 24 | ID types.AlertID `json:"id"` 25 | Schema string `json:"schema"` 26 | Data string `json:"data"` 27 | CreatedAt time.Time `json:"createdAt"` 28 | Title string `json:"title"` 29 | Description string `json:"description"` 30 | Source string `json:"source"` 31 | Namespace *string `json:"namespace,omitempty"` 32 | InitAttrs []*AttributeRecord `json:"initAttrs"` 33 | LastAttrs []*AttributeRecord `json:"lastAttrs"` 34 | Refs []*ReferenceRecord `json:"refs"` 35 | } 36 | 37 | type ArgumentRecord struct { 38 | Key string `json:"key"` 39 | Value string `json:"value"` 40 | } 41 | 42 | type AttributeRecord struct { 43 | ID string `json:"id"` 44 | Key string `json:"key"` 45 | Value string `json:"value"` 46 | Type *string `json:"type,omitempty"` 47 | Persist bool `json:"persist"` 48 | TTL int `json:"ttl"` 49 | } 50 | 51 | type NextRecord struct { 52 | Abort bool `json:"abort"` 53 | Attrs []*AttributeRecord `json:"attrs"` 54 | } 55 | 56 | type Query struct { 57 | } 58 | 59 | type ReferenceRecord struct { 60 | Title *string `json:"title,omitempty"` 61 | URL *string `json:"url,omitempty"` 62 | } 63 | 64 | type WorkflowRecord struct { 65 | ID types.WorkflowID `json:"id"` 66 | CreatedAt time.Time `json:"createdAt"` 67 | Alert *AlertRecord `json:"alert"` 68 | Actions []*ActionRecord `json:"actions"` 69 | } 70 | -------------------------------------------------------------------------------- /pkg/domain/model/log.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "github.com/secmon-lab/alertchain/pkg/domain/types" 4 | 5 | type ScenarioLog struct { 6 | ID types.ScenarioID `json:"id"` 7 | Title types.ScenarioTitle `json:"title"` 8 | 9 | Results []*PlayLog `json:"results,omitempty"` 10 | Error any `json:"error,omitempty"` 11 | } 12 | 13 | type PlayLog struct { 14 | Alert Alert `json:"alert"` 15 | 16 | Actions []*ActionLog `json:"actions"` 17 | } 18 | 19 | type ActionLog struct { 20 | Seq int `json:"seq"` 21 | Action 22 | } 23 | -------------------------------------------------------------------------------- /pkg/domain/model/playbook_test.go: -------------------------------------------------------------------------------- 1 | package model_test 2 | 3 | import ( 4 | "embed" 5 | "testing" 6 | 7 | "github.com/m-mizutani/gt" 8 | "github.com/secmon-lab/alertchain/pkg/domain/model" 9 | ) 10 | 11 | //go:embed testdata/playbook/*.jsonnet 12 | //go:embed testdata/playbook/*.json 13 | var playbooks embed.FS 14 | 15 | func TestParseScenario(t *testing.T) { 16 | s, err := model.ParseScenario("testdata/playbook/base.jsonnet", playbooks.ReadFile) 17 | gt.NoError(t, err) 18 | gt.Equal(t, s.ID, "test1") 19 | gt.V(t, s.Events[0].Actions["chatgpt.query"][0]).NotNil() 20 | } 21 | -------------------------------------------------------------------------------- /pkg/domain/model/policy.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/PaesslerAG/gval" 7 | "github.com/PaesslerAG/jsonpath" 8 | "github.com/m-mizutani/goerr/v2" 9 | "github.com/secmon-lab/alertchain/pkg/domain/types" 10 | ) 11 | 12 | type AlertPolicyResult struct { 13 | Alerts []AlertMetaData `json:"alert"` 14 | } 15 | 16 | type ActionRunRequest struct { 17 | Alert Alert `json:"alert"` 18 | EnvVars types.EnvVars `json:"env" masq:"secret"` 19 | Seq int `json:"seq"` 20 | Called []ActionResult `json:"called"` 21 | } 22 | 23 | type ActionRunResponse struct { 24 | Runs []Action `json:"run"` 25 | } 26 | 27 | type Action struct { 28 | ID types.ActionID `json:"id"` 29 | Name string `json:"name"` 30 | Uses types.ActionName `json:"uses"` 31 | Args ActionArgs `json:"args"` 32 | Force bool `json:"force"` 33 | Abort bool `json:"abort"` 34 | Commit []Commit `json:"commit"` 35 | } 36 | 37 | func (x Action) Copy() Action { 38 | copied := x 39 | return copied 40 | } 41 | 42 | type Commit struct { 43 | Attribute 44 | Path string `json:"path"` 45 | } 46 | 47 | func (x Commit) Copy() Commit { 48 | return Commit{ 49 | Attribute: x.Attribute.Copy(), 50 | Path: x.Path, 51 | } 52 | } 53 | 54 | func (x *Commit) ToAttr(data any) (*Attribute, error) { 55 | attr := x.Attribute 56 | 57 | if x.Path == "" { 58 | if attr.Value == nil { 59 | return nil, goerr.New("Path is empty and Value is nil", goerr.V("attr", attr)) 60 | } 61 | return &attr, nil 62 | } 63 | 64 | if data == nil { 65 | return nil, goerr.New("Data is nil", goerr.V("commit", x)) 66 | } 67 | 68 | builder := gval.Full(jsonpath.PlaceholderExtension()) 69 | dst, err := builder.Evaluate(x.Path, data) 70 | if err != nil { 71 | if unwrapped := errors.Unwrap(err); unwrapped != nil && unwrapped.Error() == "unknown key invalid" { 72 | if attr.Value == nil { 73 | return nil, nil 74 | } 75 | return &attr, nil 76 | } 77 | 78 | return nil, goerr.Wrap(err, "failed to evaluate JSON path", goerr.V("path", x.Path), goerr.V("data", data)) 79 | } 80 | attr.Value = dst 81 | 82 | return &attr, nil 83 | } 84 | 85 | type ActionResult struct { 86 | Action 87 | Result any `json:"result,omitempty"` 88 | } 89 | -------------------------------------------------------------------------------- /pkg/domain/model/policy_test.go: -------------------------------------------------------------------------------- 1 | package model_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/m-mizutani/gt" 7 | "github.com/secmon-lab/alertchain/pkg/domain/model" 8 | ) 9 | 10 | func TestInvalidPath(t *testing.T) { 11 | c := model.Commit{ 12 | Attribute: model.Attribute{ 13 | Key: "hoge", 14 | }, 15 | Path: "$.invalid", 16 | } 17 | 18 | data := map[string]interface{}{ 19 | "hoge": "fuga", 20 | } 21 | 22 | v, err := c.ToAttr(data) 23 | gt.NoError(t, err) 24 | gt.EQ(t, v, nil) 25 | } 26 | 27 | func TestEmptyPathWithValue(t *testing.T) { 28 | c := model.Commit{ 29 | Attribute: model.Attribute{ 30 | Key: "hoge", 31 | Value: "fuga", 32 | }, 33 | Path: "", 34 | } 35 | 36 | data := map[string]interface{}{ 37 | "hoge": "fuga", 38 | } 39 | 40 | v, err := c.ToAttr(data) 41 | gt.NoError(t, err) 42 | gt.EQ(t, v.Key, "hoge") 43 | gt.EQ(t, v.Value, "fuga") 44 | } 45 | 46 | func TestEmptyPathWithoutValue(t *testing.T) { 47 | c := model.Commit{ 48 | Attribute: model.Attribute{ 49 | Key: "hoge", 50 | }, 51 | Path: "", 52 | } 53 | 54 | data := map[string]interface{}{ 55 | "hoge": "fuga", 56 | } 57 | 58 | v, err := c.ToAttr(data) 59 | gt.Error(t, err) 60 | gt.EQ(t, v, nil) 61 | } 62 | 63 | func TestValidPath(t *testing.T) { 64 | c := model.Commit{ 65 | Attribute: model.Attribute{ 66 | Key: "hoge", 67 | }, 68 | Path: "$.hoge", 69 | } 70 | 71 | data := map[string]interface{}{ 72 | "hoge": "fuga", 73 | } 74 | 75 | v, err := c.ToAttr(data) 76 | gt.NoError(t, err) 77 | gt.EQ(t, v.Key, "hoge") 78 | gt.EQ(t, v.Value, "fuga") 79 | } 80 | 81 | func TestGetArrayObject(t *testing.T) { 82 | c := model.Commit{ 83 | Attribute: model.Attribute{ 84 | Key: "hoge", 85 | }, 86 | Path: "$.array[0]", 87 | } 88 | 89 | data := map[string]interface{}{ 90 | "array": []interface{}{ 91 | "fuga", 92 | }, 93 | } 94 | 95 | v, err := c.ToAttr(data) 96 | gt.NoError(t, err) 97 | gt.EQ(t, v.Key, "hoge") 98 | gt.EQ(t, v.Value, "fuga") 99 | } 100 | -------------------------------------------------------------------------------- /pkg/domain/model/pubsub.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type PubSubRequest struct { 4 | DeliveryAttempt int64 `json:"deliveryAttempt"` 5 | Message PubSubMessage `json:"message"` 6 | Subscription string `json:"subscription"` 7 | } 8 | 9 | type PubSubMessage struct { 10 | Attributes map[string]string `json:"attributes"` 11 | Data []byte `json:"data"` 12 | MessageID string `json:"message_id"` 13 | PublishTime string `json:"publish_time"` 14 | } 15 | -------------------------------------------------------------------------------- /pkg/domain/model/references.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type References []Reference 4 | 5 | type Reference struct { 6 | Title string `json:"title"` 7 | URL string `json:"url"` 8 | } 9 | 10 | func (r Reference) Copy() Reference { 11 | newRef := Reference{ 12 | Title: r.Title, 13 | URL: r.URL, 14 | } 15 | return newRef 16 | } 17 | 18 | func (refs References) Copy() References { 19 | newRefs := make(References, len(refs)) 20 | for i, ref := range refs { 21 | newRefs[i] = ref.Copy() 22 | } 23 | return newRefs 24 | } 25 | -------------------------------------------------------------------------------- /pkg/domain/model/testdata/config/config1.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | policy: {}, 3 | } 4 | -------------------------------------------------------------------------------- /pkg/domain/model/testdata/config/config2.jsonnet: -------------------------------------------------------------------------------- 1 | local imported = import 'config2_imported.jsonnet'; 2 | 3 | imported 4 | -------------------------------------------------------------------------------- /pkg/domain/model/testdata/config/config2_imported.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | number: 5, 3 | } 4 | -------------------------------------------------------------------------------- /pkg/domain/model/testdata/playbook/base.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | id: 'test1', 3 | title: 'test1_title', 4 | events: [ 5 | { 6 | input: import 'input.json', 7 | schema: 'scc', 8 | actions: { 9 | 'chatgpt.query': [ 10 | { 11 | name: 'test1', 12 | }, 13 | ], 14 | }, 15 | }, 16 | ], 17 | } 18 | -------------------------------------------------------------------------------- /pkg/domain/model/testdata/playbook/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "color": "blue" 3 | } 4 | -------------------------------------------------------------------------------- /pkg/domain/types/attribute.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | ) 6 | 7 | type ( 8 | AttrID string 9 | AttrKey string 10 | AttrValue any 11 | AttrType string 12 | AttrTTL int64 13 | ) 14 | 15 | func NewAttrID() AttrID { 16 | return AttrID(uuid.NewString()) 17 | } 18 | 19 | const ( 20 | IPAddr AttrType = "ipaddr" 21 | DomainName AttrType = "domain" 22 | FileSha256 AttrType = "file.sha256" 23 | FileSha512 AttrType = "file.sha512" 24 | MarkDown AttrType = "markdown" 25 | ) 26 | 27 | func (x AttrID) String() string { return string(x) } 28 | func (x AttrKey) String() string { return string(x) } 29 | -------------------------------------------------------------------------------- /pkg/domain/types/const.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | const ( 4 | AppVersion = "v0.2.1" 5 | 6 | DefaultMaxSequences = 32 7 | 8 | DefaultAttributeTTL int = 3600 * 24 // 1 day 9 | ) 10 | -------------------------------------------------------------------------------- /pkg/domain/types/error.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "github.com/m-mizutani/goerr/v2" 4 | 5 | var ( 6 | ErrNoPolicyResult = goerr.New("no policy result") 7 | ErrActionInvalidArgument = goerr.New("invalid action argument", goerr.T(ErrTagAction)) 8 | /* 9 | ErrInvalidOption = AsConfigErr(goerr.New("invalid option")) 10 | 11 | ErrActionInvalidArgument = AsPolicyErr(goerr.New("invalid action argument")) 12 | ErrActionNotFound = AsPolicyErr(goerr.New("action not found")) 13 | ErrActionFailed = AsPolicyErr(goerr.New("action failed")) 14 | 15 | ErrInvalidScenario = goerr.New("invalid play scenario") 16 | 17 | ErrInvalidHTTPRequest = AsBadRequestErr(goerr.New("invalid HTTP request")) 18 | ErrInvalidLambdaRequest = AsBadRequestErr(goerr.New("invalid Lambda request")) 19 | */ 20 | ) 21 | 22 | var ( 23 | // ErrTagConfig is a tag for configuration and startup option error. 24 | ErrTagConfig = goerr.NewTag("config") 25 | 26 | // ErrTagPolicy is a tag for policy error. It is used for failure of policy evaluation or invalid policy result. 27 | ErrTagPolicy = goerr.NewTag("policy") 28 | 29 | // ErrTagAction is a tag for action error. It is used for failure of action execution or invalid action argument. 30 | ErrTagAction = goerr.NewTag("action") 31 | 32 | // ErrTagBadRequest is a tag for bad request to AlertChain server or runtime. 33 | ErrTagBadRequest = goerr.NewTag("bad_request") 34 | 35 | // ErrTagSystem is a tag for unexpected system behavior. E.g. I/O error, system call failure, database error, error from integrated system, connection error, etc. 36 | ErrTagSystem = goerr.NewTag("system") 37 | ) 38 | -------------------------------------------------------------------------------- /pkg/domain/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | ) 6 | 7 | type ( 8 | RequestID string 9 | 10 | AlertID string 11 | ActionName string 12 | ActionID string 13 | 14 | Schema string 15 | RawData any 16 | 17 | ScenarioID string 18 | ScenarioTitle string 19 | 20 | EnvVarName string 21 | EnvVarValue string 22 | 23 | ActionSecret any 24 | 25 | Namespace string 26 | 27 | WorkflowID string 28 | ) 29 | 30 | // EnvVars is a set of environment variables 31 | type EnvVars map[EnvVarName]EnvVarValue 32 | 33 | func NewRequestID() RequestID { return RequestID(uuid.New().String()) } 34 | func NewAlertID() AlertID { return AlertID(uuid.New().String()) } 35 | func NewActionID() ActionID { return ActionID(uuid.New().String()) } 36 | func NewWorkflowID() WorkflowID { 37 | return WorkflowID(uuid.NewString()) 38 | } 39 | 40 | func (x RequestID) String() string { return string(x) } 41 | func (x AlertID) String() string { return string(x) } 42 | func (x WorkflowID) String() string { return string(x) } 43 | -------------------------------------------------------------------------------- /pkg/infra/firestore/client_test.go: -------------------------------------------------------------------------------- 1 | package firestore_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/m-mizutani/gots/ptr" 9 | "github.com/m-mizutani/gt" 10 | "github.com/secmon-lab/alertchain/pkg/domain/model" 11 | "github.com/secmon-lab/alertchain/pkg/domain/types" 12 | "github.com/secmon-lab/alertchain/pkg/infra/firestore" 13 | "github.com/secmon-lab/alertchain/pkg/utils" 14 | ) 15 | 16 | func TestWorkflow(t *testing.T) { 17 | var ( 18 | projectID string 19 | databaseID string 20 | ) 21 | 22 | if err := utils.LoadEnv( 23 | utils.EnvDef("TEST_FIRESTORE_PROJECT_ID", &projectID), 24 | utils.EnvDef("TEST_FIRESTORE_DATABASE_ID", &databaseID), 25 | ); err != nil { 26 | t.Skipf("Skip test due to missing env: %v", err) 27 | } 28 | 29 | ctx := context.Background() 30 | now := time.Now() 31 | client := gt.R1(firestore.New(ctx, projectID, databaseID)).NoError(t) 32 | 33 | workflow0 := model.WorkflowRecord{ 34 | ID: types.NewWorkflowID(), 35 | CreatedAt: now.Add(-time.Second), 36 | } 37 | workflow1 := model.WorkflowRecord{ 38 | ID: types.NewWorkflowID(), 39 | CreatedAt: now, 40 | Alert: &model.AlertRecord{ 41 | ID: types.NewAlertID(), 42 | CreatedAt: now, 43 | Source: "test", 44 | Title: "testing", 45 | InitAttrs: []*model.AttributeRecord{ 46 | {Key: "key1", Value: "value1"}, 47 | {Key: "key2", Value: "value2"}, 48 | }, 49 | Refs: []*model.ReferenceRecord{ 50 | { 51 | Title: ptr.To("ref1"), 52 | URL: ptr.To("https://example.com"), 53 | }, 54 | }, 55 | }, 56 | } 57 | workflow2 := model.WorkflowRecord{ 58 | ID: types.NewWorkflowID(), 59 | CreatedAt: now.Add(time.Second), 60 | } 61 | 62 | // Test PutWorkflow method 63 | gt.NoError(t, client.PutWorkflow(ctx, workflow0)) 64 | gt.NoError(t, client.PutWorkflow(ctx, workflow1)) 65 | gt.NoError(t, client.PutWorkflow(ctx, workflow2)) 66 | 67 | // Test GetWorkflows method with offset and limit 68 | workflows := gt.R1(client.GetWorkflows(ctx, 1, 1)).NoError(t) 69 | gt.A(t, workflows).Length(1).At(0, func(t testing.TB, v model.WorkflowRecord) { 70 | gt.Equal(t, v.ID, workflow1.ID) 71 | gt.V(t, v.Alert).Must().NotNil() 72 | gt.Equal(t, v.Alert.Title, "testing") 73 | gt.Equal(t, v.Alert.InitAttrs[0].Key, "key1") 74 | gt.Equal(t, v.Alert.InitAttrs[0].Value, "value1") 75 | gt.Equal(t, *v.Alert.Refs[0].Title, "ref1") 76 | }) 77 | } 78 | -------------------------------------------------------------------------------- /pkg/infra/gemini/client.go: -------------------------------------------------------------------------------- 1 | package gemini 2 | 3 | import ( 4 | "context" 5 | 6 | "cloud.google.com/go/vertexai/genai" 7 | "github.com/m-mizutani/goerr/v2" 8 | ) 9 | 10 | type Client struct { 11 | client *genai.Client 12 | model string 13 | } 14 | 15 | func New(ctx context.Context, projectID, location string) (*Client, error) { 16 | // modelName := "gemini-1.5-flash-002" 17 | modelName := "gemini-2.0-flash-exp" 18 | 19 | client, err := genai.NewClient(ctx, projectID, location) 20 | if err != nil { 21 | return nil, goerr.Wrap(err, "failed to create genai client") 22 | } 23 | return &Client{client: client, model: modelName}, nil 24 | } 25 | 26 | func (x *Client) Generate(ctx context.Context, prompts ...string) ([]string, error) { 27 | gemini := x.client.GenerativeModel(x.model) 28 | 29 | var parts []genai.Part 30 | for _, t := range prompts { 31 | parts = append(parts, genai.Text(t)) 32 | } 33 | 34 | resp, err := gemini.GenerateContent(ctx, parts...) 35 | if err != nil { 36 | return nil, goerr.Wrap(err, "failed to generate content") 37 | } 38 | 39 | var respText []string 40 | for _, c := range resp.Candidates { 41 | for _, p := range c.Content.Parts { 42 | switch d := p.(type) { 43 | case genai.Text: 44 | respText = append(respText, string(d)) 45 | } 46 | } 47 | } 48 | return respText, nil 49 | } 50 | -------------------------------------------------------------------------------- /pkg/infra/gemini/client_test.go: -------------------------------------------------------------------------------- 1 | package gemini_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/m-mizutani/gt" 9 | "github.com/secmon-lab/alertchain/pkg/infra/gemini" 10 | "github.com/secmon-lab/alertchain/pkg/utils" 11 | ) 12 | 13 | func TestGenerateRule(t *testing.T) { 14 | var ( 15 | projectID string 16 | location string 17 | ) 18 | if err := utils.LoadEnv( 19 | utils.EnvDef("TEST_GEMINI_PROJECT_ID", &projectID), 20 | utils.EnvDef("TEST_GEMINI_LOCATION", &location), 21 | ); err != nil { 22 | t.Skipf("Skip test due to missing env: %v", err) 23 | } 24 | 25 | ctx := context.Background() 26 | client, err := gemini.New(ctx, projectID, location) 27 | gt.NoError(t, err) 28 | 29 | policy := gt.R1(os.ReadFile("scc.rego")).NoError(t) 30 | alert := gt.R1(os.ReadFile("alert.json")).NoError(t) 31 | prompt := ` 32 | Instructions: 33 | 34 | The initial JSON data provided contains information about false positive alerts. Based on the code given thereafter, generate a new Rego policy file to ignore these alerts. 35 | 36 | 37 | Constraints: 38 | 39 | 40 | The new Rego policy file must include the content of all existing rules. 41 | Integrate rules if possible. 42 | The output should be in Rego code format only, not Markdown. 43 | Use information such as project name, service account, and target resource for detection to create new rules. 44 | Do not include frequently changing information like Pod or cluster IDs in the rules. 45 | ` 46 | 47 | t.Log("Generate new policy") 48 | resp, err := client.Generate(ctx, prompt, string(alert), string(policy)) 49 | gt.NoError(t, err) 50 | for _, line := range resp { 51 | t.Log(line) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pkg/infra/policy/client_test.go: -------------------------------------------------------------------------------- 1 | package policy_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/m-mizutani/gt" 9 | "github.com/secmon-lab/alertchain/pkg/infra/policy" 10 | ) 11 | 12 | const examplePolicy = ` 13 | package test 14 | 15 | default allow = false 16 | 17 | allow if { 18 | input.role == "admin" 19 | } 20 | ` 21 | 22 | type examplePolicyResult struct { 23 | Allow bool `json:"allow"` 24 | } 25 | 26 | func TestClient_Query(t *testing.T) { 27 | client, err := policy.New(policy.WithPolicyData("test.rego", examplePolicy), policy.WithPackage("test")) 28 | gt.NoError(t, err) 29 | 30 | ctx := context.Background() 31 | 32 | tests := []struct { 33 | name string 34 | input interface{} 35 | output *examplePolicyResult 36 | expect bool 37 | }{ 38 | { 39 | name: "admin role should be allowed", 40 | input: map[string]interface{}{"role": "admin"}, 41 | output: new(examplePolicyResult), 42 | expect: true, 43 | }, 44 | { 45 | name: "non-admin role should not be allowed", 46 | input: map[string]interface{}{"role": "user"}, 47 | output: new(examplePolicyResult), 48 | expect: false, 49 | }, 50 | } 51 | 52 | for _, test := range tests { 53 | t.Run(test.name, func(t *testing.T) { 54 | err := client.Query(ctx, test.input, test.output) 55 | gt.NoError(t, err) 56 | gt.V(t, test.output.Allow).Equal(test.expect) 57 | }) 58 | } 59 | } 60 | 61 | func TestClient_New_WithFile(t *testing.T) { 62 | policyFile := "test.rego" 63 | err := os.WriteFile(policyFile, []byte(examplePolicy), 0644) 64 | gt.NoError(t, err) 65 | defer os.Remove(policyFile) 66 | 67 | client, err := policy.New(policy.WithFile(policyFile), policy.WithPackage("test")) 68 | gt.NoError(t, err) 69 | 70 | ctx := context.Background() 71 | 72 | input := map[string]interface{}{"role": "admin"} 73 | var output examplePolicyResult 74 | err = client.Query(ctx, input, &output) 75 | gt.NoError(t, err) 76 | gt.B(t, output.Allow).True() 77 | } 78 | 79 | func TestClient_New_WithDir(t *testing.T) { 80 | policyDir := "policy_test" 81 | err := os.Mkdir(policyDir, 0755) 82 | gt.NoError(t, err) 83 | defer os.RemoveAll(policyDir) 84 | 85 | policyFile := policyDir + "/test.rego" 86 | err = os.WriteFile(policyFile, []byte(examplePolicy), 0644) 87 | gt.NoError(t, err) 88 | 89 | client, err := policy.New(policy.WithDir(policyDir), policy.WithPackage("test")) 90 | gt.NoError(t, err) 91 | 92 | ctx := context.Background() 93 | 94 | input := map[string]interface{}{"role": "admin"} 95 | var output examplePolicyResult 96 | err = client.Query(ctx, input, &output) 97 | gt.NoError(t, err) 98 | gt.B(t, output.Allow).True() 99 | } 100 | 101 | func TestClient_New_NoPolicy(t *testing.T) { 102 | _, err := policy.New() 103 | gt.Error(t, err) 104 | } 105 | 106 | func TestClient_Query_NoResult(t *testing.T) { 107 | client, err := policy.New(policy.WithPolicyData("test.rego", examplePolicy), policy.WithPackage("test")) 108 | gt.NoError(t, err) 109 | 110 | ctx := context.Background() 111 | 112 | input := map[string]interface{}{"unknown_key": "unknown_value"} 113 | var output bool 114 | err = client.Query(ctx, input, &output) 115 | gt.Error(t, err) 116 | } 117 | -------------------------------------------------------------------------------- /pkg/infra/policy/hook.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import "github.com/open-policy-agent/opa/v1/topdown/print" 4 | 5 | type regoPrintHook struct { 6 | callback RegoPrint 7 | } 8 | 9 | func (x *regoPrintHook) Print(ctx print.Context, msg string) error { 10 | return x.callback(ctx.Location.File, ctx.Location.Row, msg) 11 | } 12 | -------------------------------------------------------------------------------- /pkg/infra/recorder/json.go: -------------------------------------------------------------------------------- 1 | package recorder 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | 7 | "github.com/m-mizutani/goerr/v2" 8 | "github.com/secmon-lab/alertchain/pkg/domain/interfaces" 9 | "github.com/secmon-lab/alertchain/pkg/domain/model" 10 | ) 11 | 12 | type JSONLogger struct { 13 | w io.WriteCloser 14 | log model.ScenarioLog 15 | } 16 | 17 | var _ interfaces.ScenarioRecorder = &JSONLogger{} 18 | 19 | func NewJsonRecorder(w io.WriteCloser, s *model.Scenario) *JSONLogger { 20 | return &JSONLogger{ 21 | w: w, 22 | log: s.ToLog(), 23 | } 24 | } 25 | 26 | func (x *JSONLogger) NewAlertRecorder(alert *model.Alert) interfaces.AlertRecorder { 27 | copied := alert.Copy() 28 | 29 | // Remove redundant data from alert 30 | copied.Data = nil 31 | copied.Raw = "" 32 | 33 | log := &model.PlayLog{ 34 | Alert: copied, 35 | } 36 | x.log.Results = append(x.log.Results, log) 37 | 38 | return &JSONAlertRecorder{ 39 | log: log, 40 | } 41 | } 42 | 43 | func (x *JSONLogger) LogError(err error) { 44 | if gErr := goerr.Unwrap(err); gErr != nil { 45 | x.log.Error = gErr.Printable() 46 | return 47 | } 48 | 49 | x.log.Error = err.Error() 50 | } 51 | 52 | func (x *JSONLogger) Flush() error { 53 | encoder := json.NewEncoder(x.w) 54 | encoder.SetIndent("", " ") 55 | 56 | if err := encoder.Encode(x.log); err != nil { 57 | return goerr.Wrap(err, "Failed to encode JSON scenario log") 58 | } 59 | 60 | return nil 61 | } 62 | 63 | var _ interfaces.AlertRecorder = &JSONAlertRecorder{} 64 | 65 | type JSONAlertRecorder struct { 66 | seq int 67 | log *model.PlayLog 68 | } 69 | 70 | // NewJSONActionRecorder implements interfaces.AlertRecorder. 71 | func (x *JSONAlertRecorder) NewActionRecorder() interfaces.ActionRecorder { 72 | logger := &JSONActionRecorder{ 73 | seq: x.seq, 74 | log: x.log, 75 | } 76 | 77 | x.seq++ 78 | return logger 79 | } 80 | 81 | type JSONActionRecorder struct { 82 | seq int 83 | log *model.PlayLog 84 | } 85 | 86 | // Add implements interfaces.AlertRecorder. 87 | func (x *JSONActionRecorder) Add(log model.Action) { 88 | x.log.Actions = append(x.log.Actions, &model.ActionLog{ 89 | Seq: x.seq, 90 | Action: log, 91 | }) 92 | } 93 | -------------------------------------------------------------------------------- /pkg/infra/recorder/json_test.go: -------------------------------------------------------------------------------- 1 | package recorder_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "testing" 7 | 8 | "github.com/m-mizutani/gt" 9 | "github.com/secmon-lab/alertchain/pkg/domain/model" 10 | "github.com/secmon-lab/alertchain/pkg/infra/recorder" 11 | ) 12 | 13 | // bufferWriteCloser is a wrapper around bytes.Buffer that implements io.WriteCloser. 14 | type bufferWriteCloser struct { 15 | bytes.Buffer 16 | } 17 | 18 | // NewBufferWriteCloser creates a new bufferWriteCloser. 19 | func NewBufferWriteCloser() *bufferWriteCloser { 20 | return &bufferWriteCloser{ 21 | Buffer: bytes.Buffer{}, 22 | } 23 | } 24 | 25 | // Close does nothing as bytes.Buffer does not need any cleanup. 26 | func (bwc *bufferWriteCloser) Close() error { 27 | return nil 28 | } 29 | 30 | func TestJSONLogger(t *testing.T) { 31 | scenario := &model.Scenario{ 32 | ID: "test-scenario", 33 | } 34 | 35 | buf := NewBufferWriteCloser() 36 | jsonLogger := recorder.NewJsonRecorder(buf, scenario) 37 | 38 | alert := model.Alert{ 39 | ID: "test-alert", 40 | } 41 | 42 | AlertRecorder := jsonLogger.NewAlertRecorder(&alert) 43 | 44 | // first process 45 | ActionRecorder := AlertRecorder.NewActionRecorder() 46 | ActionRecorder.Add(model.Action{ 47 | ID: "test-action", 48 | Name: "test-action-name", 49 | }) 50 | 51 | // second process, but not action recorded 52 | _ = AlertRecorder.NewActionRecorder() 53 | 54 | err := jsonLogger.Flush() 55 | gt.NoError(t, err) 56 | 57 | var resultLog model.ScenarioLog 58 | err = json.Unmarshal(buf.Bytes(), &resultLog) 59 | gt.NoError(t, err) 60 | 61 | gt.V(t, scenario.ID).Equal(resultLog.ID) 62 | gt.A(t, resultLog.Results).Length(1) 63 | 64 | r := resultLog.Results[0] 65 | gt.V(t, r.Alert.ID).Equal("test-alert") 66 | gt.A(t, r.Actions).Length(1) 67 | 68 | gt.N(t, r.Actions[0].Seq).Equal(0) 69 | gt.V(t, r.Actions[0].ID).Equal("test-action") 70 | gt.V(t, r.Actions[0].Name).Equal("test-action-name") 71 | } 72 | -------------------------------------------------------------------------------- /pkg/infra/recorder/memory.go: -------------------------------------------------------------------------------- 1 | package recorder 2 | 3 | import ( 4 | "github.com/secmon-lab/alertchain/pkg/domain/interfaces" 5 | "github.com/secmon-lab/alertchain/pkg/domain/model" 6 | ) 7 | 8 | type Memory struct { 9 | Log model.ScenarioLog 10 | } 11 | 12 | // NewAlertRecorder implements interfaces.ScenarioRecorder. 13 | func (x *Memory) NewAlertRecorder(alert *model.Alert) interfaces.AlertRecorder { 14 | log := &model.PlayLog{ 15 | Alert: *alert, 16 | } 17 | x.Log.Results = append(x.Log.Results, log) 18 | 19 | return &MemoryAlertRecorder{ 20 | log: log, 21 | } 22 | } 23 | 24 | var _ interfaces.ScenarioRecorder = &Memory{} 25 | 26 | func NewMemory(s *model.Scenario) *Memory { 27 | return &Memory{ 28 | Log: s.ToLog(), 29 | } 30 | } 31 | 32 | func (x *Memory) LogError(err error) { 33 | x.Log.Error = err.Error() 34 | } 35 | 36 | func (x *Memory) Flush() error { 37 | return nil 38 | } 39 | 40 | type MemoryAlertRecorder struct { 41 | seq int 42 | log *model.PlayLog 43 | } 44 | 45 | func (x *MemoryAlertRecorder) NewActionRecorder() interfaces.ActionRecorder { 46 | logger := &MemoryActionRecorder{ 47 | seq: x.seq, 48 | log: x.log, 49 | } 50 | x.seq++ 51 | 52 | return logger 53 | } 54 | 55 | type MemoryActionRecorder struct { 56 | seq int 57 | log *model.PlayLog 58 | } 59 | 60 | // LogRun implements interfaces.AlertRecorder. 61 | func (x *MemoryActionRecorder) Add(log model.Action) { 62 | x.log.Actions = append(x.log.Actions, &model.ActionLog{ 63 | Seq: x.seq, 64 | Action: log, 65 | }) 66 | } 67 | 68 | var _ interfaces.AlertRecorder = &MemoryAlertRecorder{} 69 | -------------------------------------------------------------------------------- /pkg/logging/logger.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log/slog" 7 | "reflect" 8 | "sync" 9 | "time" 10 | 11 | "github.com/fatih/color" 12 | "github.com/m-mizutani/clog" 13 | "github.com/m-mizutani/masq" 14 | ) 15 | 16 | type Format int 17 | 18 | const ( 19 | FormatConsole Format = iota + 1 20 | FormatJSON 21 | ) 22 | 23 | var ( 24 | logger = slog.Default() 25 | loggerMutex sync.Mutex 26 | ) 27 | 28 | func Default() *slog.Logger { 29 | return logger 30 | } 31 | 32 | func ReconfigureLogger(w io.Writer, level slog.Level, format Format) { 33 | filter := masq.New( 34 | masq.WithTag("secret"), 35 | masq.WithTag("quiet"), 36 | masq.WithFieldPrefix("secret_"), 37 | masq.WithAllowedType(reflect.TypeOf(time.Time{})), 38 | ) 39 | 40 | var handler slog.Handler 41 | switch format { 42 | case FormatConsole: 43 | handler = clog.New( 44 | clog.WithWriter(w), 45 | clog.WithLevel(level), 46 | clog.WithReplaceAttr(filter), 47 | // clog.WithSource(true), 48 | // clog.WithTimeFmt("2006-01-02 15:04:05"), 49 | clog.WithColorMap(&clog.ColorMap{ 50 | Level: map[slog.Level]*color.Color{ 51 | slog.LevelDebug: color.New(color.FgGreen, color.Bold), 52 | slog.LevelInfo: color.New(color.FgCyan, color.Bold), 53 | slog.LevelWarn: color.New(color.FgYellow, color.Bold), 54 | slog.LevelError: color.New(color.FgRed, color.Bold), 55 | }, 56 | LevelDefault: color.New(color.FgBlue, color.Bold), 57 | Time: color.New(color.FgWhite), 58 | Message: color.New(color.FgHiWhite), 59 | AttrKey: color.New(color.FgHiCyan), 60 | AttrValue: color.New(color.FgHiWhite), 61 | }), 62 | ) 63 | 64 | case FormatJSON: 65 | handler = slog.NewJSONHandler(w, &slog.HandlerOptions{ 66 | AddSource: true, 67 | Level: level, 68 | ReplaceAttr: filter, 69 | }) 70 | 71 | default: 72 | panic("Unsupported log format: " + fmt.Sprintf("%d", format)) 73 | } 74 | 75 | loggerMutex.Lock() 76 | logger = slog.New(handler) 77 | loggerMutex.Unlock() 78 | } 79 | 80 | func ErrAttr(err error) slog.Attr { return slog.Any("error", err) } 81 | -------------------------------------------------------------------------------- /pkg/logging/logger_test.go: -------------------------------------------------------------------------------- 1 | package logging_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "log/slog" 8 | 9 | "github.com/m-mizutani/gt" 10 | "github.com/secmon-lab/alertchain/pkg/logging" 11 | ) 12 | 13 | func TestLogger(t *testing.T) { 14 | t.Run("default logger", func(t *testing.T) { 15 | var buf bytes.Buffer 16 | logging.ReconfigureLogger(&buf, slog.LevelInfo, logging.FormatJSON) 17 | logging.Default().Info("hello", 18 | slog.String("secret_key", "xxx"), 19 | slog.String("normal_key", "aaa"), 20 | ) 21 | 22 | gt.S(t, buf.String()).Contains("aaa").NotContains("xxx") 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /pkg/service/action.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/secmon-lab/alertchain/pkg/domain/interfaces" 7 | "github.com/secmon-lab/alertchain/pkg/domain/model" 8 | "github.com/secmon-lab/alertchain/pkg/domain/types" 9 | ) 10 | 11 | type ActionService struct { 12 | db interfaces.Database 13 | } 14 | 15 | func NewActionService(db interfaces.Database) *ActionService { 16 | return &ActionService{db: db} 17 | } 18 | 19 | func (x *ActionService) Fetch(ctx context.Context, wfID types.WorkflowID) ([]model.ActionRecord, error) { 20 | return nil, nil 21 | } 22 | -------------------------------------------------------------------------------- /pkg/service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import "github.com/secmon-lab/alertchain/pkg/domain/interfaces" 4 | 5 | type Services struct { 6 | Workflow *WorkflowService 7 | } 8 | 9 | func New(db interfaces.Database) *Services { 10 | return &Services{ 11 | Workflow: NewWorkflowService(db), 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /pkg/service/workflow.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/m-mizutani/goerr/v2" 9 | "github.com/secmon-lab/alertchain/pkg/ctxutil" 10 | "github.com/secmon-lab/alertchain/pkg/domain/interfaces" 11 | "github.com/secmon-lab/alertchain/pkg/domain/model" 12 | "github.com/secmon-lab/alertchain/pkg/domain/types" 13 | ) 14 | 15 | type WorkflowService struct { 16 | db interfaces.Database 17 | } 18 | 19 | type Workflow struct { 20 | db interfaces.Database 21 | wf *model.WorkflowRecord 22 | } 23 | 24 | func NewWorkflowService(db interfaces.Database) *WorkflowService { 25 | return &WorkflowService{db: db} 26 | } 27 | 28 | func (x *WorkflowService) Get(ctx context.Context, offset, limit *int) ([]model.WorkflowRecord, error) { 29 | if offset == nil { 30 | offset = new(int) 31 | *offset = 0 32 | } 33 | if limit == nil { 34 | limit = new(int) 35 | *limit = 20 36 | } 37 | 38 | return x.db.GetWorkflows(ctx, *offset, *limit) 39 | } 40 | 41 | func (x *WorkflowService) Lookup(ctx context.Context, id types.WorkflowID) (*model.WorkflowRecord, error) { 42 | return nil, nil 43 | } 44 | 45 | func attrsToRecord(attrs model.Attributes) []*model.AttributeRecord { 46 | records := make([]*model.AttributeRecord, len(attrs)) 47 | for i, attr := range attrs { 48 | var typ *string 49 | if attr.Type != "" { 50 | typ = (*string)(&attrs[i].Type) 51 | } 52 | 53 | records[i] = &model.AttributeRecord{ 54 | ID: string(attr.ID), 55 | Key: string(attr.Key), 56 | Value: fmt.Sprintf("%+v", attr.Value), 57 | Type: typ, 58 | Persist: attr.Persist, 59 | TTL: int(attr.TTL), 60 | } 61 | } 62 | 63 | return records 64 | } 65 | 66 | func (x *WorkflowService) Create(ctx context.Context, alert model.Alert) (*Workflow, error) { 67 | rawData, err := json.Marshal(alert.Data) 68 | if err != nil { 69 | return nil, goerr.Wrap(err, "Fail to marshal alert data", goerr.T(types.ErrTagBadRequest)) 70 | // types.AsBadRequestErr(goerr.Wrap(err, "Fail to marshal alert data")) 71 | } 72 | 73 | var namespace *string 74 | if alert.Namespace != "" { 75 | namespace = (*string)(&alert.Namespace) 76 | } 77 | 78 | if err := x.db.PutAlert(ctx, alert); err != nil { 79 | return nil, err 80 | } 81 | 82 | workflow := model.WorkflowRecord{ 83 | ID: types.NewWorkflowID(), 84 | CreatedAt: ctxutil.Now(ctx), 85 | Alert: &model.AlertRecord{ 86 | ID: alert.ID, 87 | Schema: string(alert.Schema), 88 | Data: string(rawData), 89 | CreatedAt: alert.CreatedAt, 90 | Title: alert.Title, 91 | Source: alert.Source, 92 | InitAttrs: attrsToRecord(alert.Attrs), 93 | Description: alert.Description, 94 | Namespace: namespace, 95 | }, 96 | } 97 | 98 | if err := x.db.PutWorkflow(ctx, workflow); err != nil { 99 | return nil, err 100 | } 101 | 102 | return &Workflow{db: x.db, wf: &workflow}, nil 103 | } 104 | 105 | func (x *Workflow) UpdateLastAttrs(ctx context.Context, attrs model.Attributes) error { 106 | x.wf.Alert.LastAttrs = attrsToRecord(attrs) 107 | if err := x.db.PutWorkflow(ctx, *x.wf); err != nil { 108 | return err 109 | } 110 | return nil 111 | } 112 | 113 | func (x *Workflow) AddAction(ctx context.Context, action *model.Action) error { 114 | return nil 115 | } 116 | -------------------------------------------------------------------------------- /pkg/usecase/enhance_ignore_test.go: -------------------------------------------------------------------------------- 1 | package usecase_test 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "encoding/json" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | 11 | "github.com/m-mizutani/gt" 12 | "github.com/secmon-lab/alertchain/pkg/domain/model" 13 | "github.com/secmon-lab/alertchain/pkg/domain/types" 14 | "github.com/secmon-lab/alertchain/pkg/mock" 15 | "github.com/secmon-lab/alertchain/pkg/usecase" 16 | ) 17 | 18 | //go:embed testdata/example_policy.rego 19 | var examplePolicy []byte 20 | 21 | func TestEnhanceIgnorePolicy(t *testing.T) { 22 | ctx := context.Background() 23 | 24 | alertID := types.AlertID("test-alert-id") 25 | alertData := model.Alert{ 26 | Data: map[string]interface{}{ 27 | "key": "value", 28 | }, 29 | } 30 | alert := model.Alert{Data: alertData} 31 | alertDataJSON, _ := json.Marshal(alertData) 32 | slug := "testing_slug" 33 | 34 | dbClient := &mock.DatabaseMock{ 35 | GetAlertFunc: func(ctx context.Context, id types.AlertID) (*model.Alert, error) { 36 | return &alert, nil 37 | }, 38 | } 39 | genSeq := 0 40 | genAI := &mock.GenAIMock{ 41 | GenerateFunc: func(ctx context.Context, prompts ...string) ([]string, error) { 42 | genSeq++ 43 | switch genSeq { 44 | case 1: 45 | return []string{slug}, nil 46 | case 2: 47 | return []string{"updated_policy"}, nil 48 | default: 49 | t.FailNow() 50 | return nil, nil 51 | } 52 | }, 53 | } 54 | 55 | fd, err := os.CreateTemp("", "*_base_policy.rego") 56 | gt.NoError(t, err) 57 | gt.R1(fd.Write(examplePolicy)).NoError(t) 58 | gt.NoError(t, fd.Close()) 59 | // TODO: Enable this line 60 | // defer os.Remove(input.BasePolicyFile) 61 | 62 | dir, err := os.MkdirTemp("", "test_data") 63 | gt.NoError(t, err) 64 | // TODO: Enable this line 65 | // defer os.RemoveAll(input.TestDataDir) 66 | 67 | input := usecase.EnhanceIgnorePolicyInput{ 68 | AlertIDs: []types.AlertID{alertID}, 69 | BasePolicyFile: fd.Name(), 70 | TestDataDir: dir, 71 | TestDataRegoPath: "test_rego_path", 72 | OverWrite: true, 73 | } 74 | 75 | gt.NoError(t, usecase.EnhanceIgnorePolicy(ctx, dbClient, genAI, input)) 76 | 77 | // Check if test data file is created 78 | testDataPath := filepath.Join(input.TestDataDir, slug, "data.json") 79 | testDataContent, err := os.ReadFile(testDataPath) 80 | gt.NoError(t, err) 81 | gt.S(t, string(testDataContent)).Contains(string(alertDataJSON)) 82 | 83 | // Check if test file is updated 84 | testFilePath := usecase.GenTestFilePath(input.BasePolicyFile) 85 | testFileContent, err := os.ReadFile(testFilePath) 86 | gt.NoError(t, err) 87 | 88 | gt.S(t, string(testFileContent)).Contains("package my_alert") 89 | gt.S(t, string(testFileContent)).Contains("test_testing_slug if {") 90 | } 91 | -------------------------------------------------------------------------------- /pkg/usecase/export_test.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | var GenTestFilePath = genTestFilePath 4 | -------------------------------------------------------------------------------- /pkg/usecase/new.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "io" 7 | "io/fs" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/m-mizutani/goerr/v2" 12 | "github.com/secmon-lab/alertchain/pkg/ctxutil" 13 | "github.com/secmon-lab/alertchain/pkg/utils" 14 | ) 15 | 16 | //go:embed templates/** 17 | var embedTemplatesFS embed.FS 18 | 19 | func NewPolicyDirectory(ctx context.Context, dir string) error { 20 | if err := copyEmbeddedFiles(ctx, embedTemplatesFS, "templates", dir); err != nil { 21 | return err 22 | } 23 | return nil 24 | } 25 | 26 | func copyEmbeddedFiles(ctx context.Context, efs embed.FS, srcDir, dstDir string) error { 27 | logger := ctxutil.Logger(ctx) 28 | return fs.WalkDir(efs, srcDir, func(path string, d fs.DirEntry, err error) error { 29 | if err != nil { 30 | return goerr.Wrap(err, "failed to walk through embedded directory") 31 | } 32 | if d.IsDir() { 33 | return nil 34 | } 35 | 36 | srcPath := filepath.Clean(path) 37 | fd, err := efs.Open(srcPath) 38 | if err != nil { 39 | return goerr.Wrap(err, "failed to open embedded file") 40 | } 41 | defer utils.SafeClose(ctx, fd) 42 | 43 | relPath, err := filepath.Rel(srcDir, path) 44 | if err != nil { 45 | return goerr.Wrap(err, "failed to get relative path") 46 | } 47 | dstPath := filepath.Join(dstDir, relPath) 48 | 49 | if err := os.MkdirAll(filepath.Dir(filepath.Clean(dstPath)), os.ModePerm); err != nil { 50 | return err 51 | } 52 | 53 | w, err := os.Create(dstPath) 54 | if err != nil { 55 | return goerr.Wrap(err, "failed to create file") 56 | } 57 | defer utils.SafeClose(ctx, w) 58 | 59 | if _, err := io.Copy(w, fd); err != nil { 60 | return goerr.Wrap(err, "failed to copy file") 61 | } 62 | logger.Info("Copy file", "path", dstPath) 63 | 64 | return nil 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /pkg/usecase/prompt/alert_slug.md: -------------------------------------------------------------------------------- 1 | # Instructions 2 | 3 | The given JSON data represents a security alert. Generate a slug that represents this alert, with a minimum of 10 characters and a maximum of 30 characters. 4 | 5 | # Constraints 6 | 7 | The slug must start with an alphabet letter. 8 | The slug should contain only lowercase letters, numbers, and underscores. 9 | The slug should be expressed in lowercase. 10 | Separate words in the slug with underscores. 11 | The output should be only slug text. DO NOT include any other characters, spaces or quote in the output. 12 | -------------------------------------------------------------------------------- /pkg/usecase/prompt/new_ignore.md: -------------------------------------------------------------------------------- 1 | # Instructions 2 | 3 | The initial Rego rule provided is a policy to determine if the given input is an alert. The following JSON-formatted data represents false positives. Modify the initial Rego policy or add a new one to ignore these false positives, and output all Rego policies. If adding a new policy, ensure it aligns with the existing one. Please adhere to Rego syntax when writing. 4 | 5 | # Rule 6 | 7 | ## Input 8 | 9 | `input` (object): The input object. This is the received JSON data. 10 | 11 | ## Output 12 | 13 | `alert` (set): The set containing the alert data. If the input is an alert (that should be triaged as security issue), the set should contain the alert data. Otherwise, it should be empty. 14 | 15 | # Constraints 16 | 17 | The new Rego policy file must include the content of all existing rules. 18 | Integrate rules if possible. 19 | The output should be in Rego code format only, not Markdown. 20 | Use information such as project name, service account, and target resource for detection to create new rules. 21 | Do not include frequently changing information like Pod or cluster IDs in the rules. 22 | Use tab indentation for the rules instead of spaces. 23 | Output only the Rego code. Do not include any other characters, spaces, or quotes like markdown in the output. 24 | -------------------------------------------------------------------------------- /pkg/usecase/templates/.gitignore: -------------------------------------------------------------------------------- 1 | /policy/play/output 2 | -------------------------------------------------------------------------------- /pkg/usecase/templates/Dockerfile: -------------------------------------------------------------------------------- 1 | # TODO: Add tag or commit hash to the base image 2 | FROM ghcr.io/secmon-lab/alertchain 3 | 4 | COPY policy /policy 5 | 6 | WORKDIR / 7 | EXPOSE 8080 8 | 9 | ENV ALERTCHAIN_LOG_FORMAT=json 10 | ENV ALERTCHAIN_LOG_LEVEL=info 11 | ENV ALERTCHAIN_ADDR=0.0.0.0:8080 12 | ENV ALERTCHAIN_POLICY_DIR=/policy 13 | 14 | ENTRYPOINT ["/alertchain", "serve"] 15 | -------------------------------------------------------------------------------- /pkg/usecase/templates/Makefile: -------------------------------------------------------------------------------- 1 | CMD_ALERTCHAIN=alertchain 2 | CMD_OPA=opa 3 | 4 | BASE_POLICY_FILES=\ 5 | policy/alert/*.rego \ 6 | policy/action/*.rego 7 | 8 | TEST_POLICY_FILES=\ 9 | policy/authz/*.rego \ 10 | policy/play/*.rego 11 | 12 | RESULT_FILE=policy/play/output/result.json 13 | 14 | SCENARIO_FILES=\ 15 | scenario/*.jsonnet \ 16 | scenario/data/*.json 17 | 18 | all: test 19 | 20 | test: $(RESULT_FILE) $(BASE_POLICY_FILES) $(TEST_POLICY_FILES) 21 | $(CMD_OPA) test -v ./policy 22 | 23 | $(RESULT_FILE): $(BASE_POLICY_FILES) $(SCENARIO_FILES) 24 | $(CMD_ALERTCHAIN) play -d ./policy -s ./scenario -o ./policy/play/output 25 | -------------------------------------------------------------------------------- /pkg/usecase/templates/policy/action/main.rego: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | # This is a sample policy for creating an issue on GitHub and adding a comment to it. 4 | 5 | # base_github_args is a common argument set for GitHub actions. 6 | # Please see https://github.com/secmon-lab/alertchain/tree/main/action/github for more details. 7 | base_github_args := { 8 | "app_id": 111111, 9 | "install_id": 222222, 10 | "secret_private_key": input.env.GITHUB_APP_PRIVATE_KEY, 11 | "owner": "your-org", 12 | "repo": "your-repo", 13 | } 14 | 15 | # github_issue_id_key is a key to store the issue ID in the alert. 16 | github_issue_id_key := "github_issue_id" 17 | 18 | # has_github_issue checks if the alert has a GitHub issue ID as a persistent attribute 19 | has_github_issue if { 20 | input.alert.attrs[_].key == github_issue_id_key 21 | } 22 | 23 | # If the alert does not have a GitHub issue ID, create a new issue on GitHub. 24 | run contains { 25 | "id": "create_issue", 26 | "uses": "github.create_issue", 27 | "args": base_github_args, 28 | "commit": [{ 29 | "key": github_issue_id_key, 30 | "persist": true, 31 | "path": "number", 32 | }], 33 | } if { 34 | input.seq == 0 35 | not has_github_issue 36 | } 37 | 38 | # If GitHub issue ID exists, add a comment to the issue. 39 | run contains { 40 | "id": "add_comment", 41 | "uses": "github.add_comment", 42 | "args": object.union(base_github_args, { 43 | "issue_number": input.alert.attrs[_].value, 44 | "body": "This is a comment", 45 | }), 46 | } if { 47 | input.seq == 0 48 | has_github_issue 49 | } 50 | -------------------------------------------------------------------------------- /pkg/usecase/templates/policy/alert/main.rego: -------------------------------------------------------------------------------- 1 | package alert.your_schema 2 | 3 | alert contains { 4 | "title": input.name, 5 | "description": "Your description here", 6 | "source": "your_source", 7 | "namespace": input.key, 8 | } if { 9 | input.severity == ["HIGH", "CRITICAL"][_] 10 | } 11 | -------------------------------------------------------------------------------- /pkg/usecase/templates/policy/alert/main_test.rego: -------------------------------------------------------------------------------- 1 | package alert.your_schema 2 | 3 | test_detect_your_schema if { 4 | resp := alert with input as data.alert.testdata.your_schema 5 | count(resp) > 0 6 | } 7 | 8 | test_not_detect_your_schema if { 9 | resp := alert with input as json.patch(data.alert.testdata.your_schema, [{"op": "replace", "path": "/severity", "value": ["LOW"]}]) 10 | count(resp) == 0 11 | } 12 | -------------------------------------------------------------------------------- /pkg/usecase/templates/policy/alert/testdata/your_schema/event.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my_event", 3 | "key": "value", 4 | "severity": "HIGH" 5 | } 6 | -------------------------------------------------------------------------------- /pkg/usecase/templates/policy/authz/http.rego: -------------------------------------------------------------------------------- 1 | package authz.http 2 | 3 | deny := false 4 | 5 | ## Add allow rules if needed 6 | # 7 | # deny := false if { 8 | # allow 9 | # } 10 | # allow if { 11 | # input.path == "/health" 12 | # } 13 | # allow if { 14 | # input.header.Authorization == "Bearer XXX" 15 | # } 16 | -------------------------------------------------------------------------------- /pkg/usecase/templates/policy/play/test.rego: -------------------------------------------------------------------------------- 1 | package play 2 | 3 | test_sample if { 4 | r := data.play.output.my_first_scenario 5 | } 6 | -------------------------------------------------------------------------------- /pkg/usecase/templates/scenario/data/event.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my_event", 3 | "key": "value", 4 | "severity": "HIGH" 5 | } 6 | -------------------------------------------------------------------------------- /pkg/usecase/templates/scenario/env.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | GITHUB_APP_PRIVATE_KEY: 'github_test_private_key', 3 | } 4 | -------------------------------------------------------------------------------- /pkg/usecase/templates/scenario/my_first_scenario.jsonnet: -------------------------------------------------------------------------------- 1 | local event = import 'data/event.json'; 2 | 3 | { 4 | id: 'my_first_scenario', 5 | title: 'Create an issue on GitHub and add a comment to it', 6 | events: [ 7 | # The first alert should create an issue 8 | { 9 | input: event, 10 | schema: 'your_schema', 11 | actions: { 12 | "github.create_issue": [{number: 666}], 13 | }, 14 | }, 15 | 16 | # The second alert should add a comment to the issue 17 | { 18 | input: event, 19 | schema: 'your_schema', 20 | }, 21 | ], 22 | 23 | env: import 'env.libsonnet', 24 | } 25 | -------------------------------------------------------------------------------- /pkg/usecase/testdata/example_policy.rego: -------------------------------------------------------------------------------- 1 | package my_alert 2 | 3 | ignore if { 4 | input.reason == "testing" 5 | } 6 | -------------------------------------------------------------------------------- /pkg/usecase/usecase.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import "github.com/secmon-lab/alertchain/pkg/domain/interfaces" 4 | 5 | type UseCase struct { 6 | db interfaces.Database 7 | genAI interfaces.GenAI 8 | } 9 | 10 | func New(options ...Option) *UseCase { 11 | uc := &UseCase{} 12 | for _, opt := range options { 13 | opt(uc) 14 | } 15 | return uc 16 | } 17 | 18 | type Option func(*UseCase) 19 | 20 | func WithDatabase(db interfaces.Database) Option { 21 | return func(uc *UseCase) { 22 | uc.db = db 23 | } 24 | } 25 | 26 | func WithGenAI(genAI interfaces.GenAI) Option { 27 | return func(uc *UseCase) { 28 | uc.genAI = genAI 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pkg/utils/convert.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/m-mizutani/goerr/v2" 7 | ) 8 | 9 | func ToAny(input any) (any, error) { 10 | raw, err := json.Marshal(input) 11 | if err != nil { 12 | return nil, goerr.Wrap(err, "Fail to marshal data", goerr.V("input", input)) 13 | } 14 | 15 | var output any 16 | if err := json.Unmarshal(raw, &output); err != nil { 17 | return nil, goerr.Wrap(err, "Fail to unmarshal data", goerr.V("raw", string(raw))) 18 | } 19 | 20 | return output, nil 21 | } 22 | -------------------------------------------------------------------------------- /pkg/utils/env.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | "github.com/secmon-lab/alertchain/pkg/domain/types" 8 | ) 9 | 10 | func Env() types.EnvVars { 11 | vars := types.EnvVars{} 12 | for _, env := range os.Environ() { 13 | pair := strings.SplitN(env, "=", 2) 14 | vars[types.EnvVarName(pair[0])] = types.EnvVarValue(pair[1]) 15 | } 16 | return vars 17 | } 18 | -------------------------------------------------------------------------------- /pkg/utils/error.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/getsentry/sentry-go" 8 | "github.com/m-mizutani/goerr/v2" 9 | "github.com/secmon-lab/alertchain/pkg/ctxutil" 10 | "github.com/secmon-lab/alertchain/pkg/logging" 11 | ) 12 | 13 | func HandleError(ctx context.Context, err error) { 14 | // Logging error 15 | ctxutil.Logger(ctx).Error("runtime error", logging.ErrAttr(err)) 16 | 17 | // Sending error to Sentry 18 | hub := sentry.CurrentHub().Clone() 19 | hub.ConfigureScope(func(scope *sentry.Scope) { 20 | if goErr := goerr.Unwrap(err); goErr != nil { 21 | for k, v := range goErr.Values() { 22 | scope.SetExtra(fmt.Sprintf("%v", k), v) 23 | } 24 | } 25 | }) 26 | hub.CaptureException(err) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/utils/safe_func.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "io" 6 | 7 | "github.com/m-mizutani/goerr/v2" 8 | "github.com/secmon-lab/alertchain/pkg/ctxutil" 9 | "github.com/secmon-lab/alertchain/pkg/logging" 10 | ) 11 | 12 | type Closer interface { 13 | Close() error 14 | } 15 | 16 | func SafeClose(ctx context.Context, c Closer) { 17 | if err := c.Close(); err != nil { 18 | ctxutil.Logger(ctx).Error("Fail to close io.WriteCloser", logging.ErrAttr(goerr.Wrap(err, "Fail to close io.WriteCloser"))) 19 | } 20 | } 21 | 22 | func SafeWrite(ctx context.Context, w io.Writer, b []byte) { 23 | if _, err := w.Write(b); err != nil { 24 | ctxutil.Logger(ctx).Error("Fail to write", logging.ErrAttr(goerr.Wrap(err, "Fail to write"))) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pkg/utils/tests.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/m-mizutani/goerr/v2" 7 | ) 8 | 9 | type EnvLoader func() error 10 | 11 | func EnvDef(key string, dst *string) EnvLoader { 12 | return func() error { 13 | v, ok := os.LookupEnv(key) 14 | if !ok { 15 | return goerr.New("No such env: " + key) 16 | } 17 | *dst = v 18 | return nil 19 | } 20 | } 21 | 22 | func LoadEnv(envs ...EnvLoader) error { 23 | for _, env := range envs { 24 | if err := env(); err != nil { 25 | return err 26 | } 27 | } 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /pkg/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func ToPtrSlice[T any](x []T) []*T { 4 | y := make([]*T, len(x)) 5 | for i := range x { 6 | y[i] = &x[i] 7 | } 8 | return y 9 | } 10 | --------------------------------------------------------------------------------