├── .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 |
--------------------------------------------------------------------------------