A composite GitHub Action that runs an authenticated HawkScan DAST scan against a staging host, and reports the results.
- Installs a pinned
hawkCLI version, verified against a SHA-256 kept inaction.yml - Signs in as a scan user (Devise form login, optional tenant
client_slug), or scans logged out for apps without a password login - Sends a WAF bypass header so payloads reach the app. The key is passed in directly or read from SSM
- Writes the findings as a table on the run page, with the offending paths behind
<details> - Uploads the raw alert JSON as the
dast-findingsartifact (kept 90 days) - Posts a DCF-18 (Vulnerability Scans) evidence record to the Drata "HawkScan DAST" custom connection
- Pings Slack when the scan finds issues at or above the configured severity threshold (default: High)
- Fails the job when findings cross the
stackhawk.ymlthreshold (hawk exit 42) or the scan itself fails - Skips the summary, artifact, Drata record and Slack ping when the scan itself fails, so a broken scan never reports as clean
Each app keeps its own stackhawk.yml (seed paths, cookie name, excludes), because those are app-specific.
Copy examples/caller.yml to .github/workflows/dast.yml in the app repo:
name: dast
on:
schedule:
- cron: '0 18 * * 0' # 03:00 JST Monday
workflow_dispatch:
inputs:
host:
description: 'Tenant host to scan (bare hostname, no scheme)'
default: 'stg.example.jp'
waf_bypass:
description: 'Send the WAF bypass header so payloads reach the app. Off = scan as an outsider sees it.'
type: boolean
default: true
permissions:
contents: read
id-token: write # only for the SSM key source
concurrency:
group: dast-${{ github.ref }}
cancel-in-progress: false
jobs:
scan:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: DAST scan
uses: scoville/dast-action@v1 # pin to the full commit SHA of the release if your repo requires hash-pinned actions
with:
app-name: noman
host: ${{ inputs.host || 'stg.example.jp' }}
waf-bypass: ${{ github.event_name != 'workflow_dispatch' || inputs.waf_bypass }}
waf-key-ssm-parameter: /noman-stg/dast/scan-key
aws-role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME_STG }}
stackhawk-api-key: ${{ secrets.HAWK_API_KEY }}
stackhawk-app-id: ${{ secrets.STACKHAWK_APP_ID }}
scan-username: ${{ secrets.DAST_USERNAME }}
scan-password: ${{ secrets.DAST_PASSWORD }}
client-slug: ${{ secrets.DAST_CLIENT_SLUG }}
drata-api-key: ${{ secrets.DRATA_CCT_API_KEY }}
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
slack-channel-id: ${{ vars.DAST_SLACK_CHANNEL_ID }}The action checks out the caller repo itself, so it can read stackhawk.yml; the caller doesn't need an actions/checkout step. The trigger, permissions, concurrency and job timeout stay in the caller.
Secrets are passed as ordinary inputs. They stay explicit, since the action only receives what you hand it, and GitHub masks them in logs. The suggested repo secret names are only a convention, so map whatever your repo already has. For example, the nomanbase web repos pass their existing E2E_* user.
| Input | Required | Default | Suggested repo secret | Description |
|---|---|---|---|---|
app-name |
Yes | - | Short app name. The Drata record id is <app-name>-dast, so keep it stable or a new record will be created |
|
host |
Yes | - | Host to scan, as a bare hostname | |
stackhawk-api-key |
Yes | - | HAWK_API_KEY (shared value, provided by us) |
StackHawk API key (scan + alert fetch) |
stackhawk-app-id |
Yes | - | STACKHAWK_APP_ID |
StackHawk application id |
scan-username |
No | '' |
DAST_USERNAME |
Scan user login. Required when stackhawk.yml has an authentication block; leave empty for an unauthenticated scan |
scan-password |
No | '' |
DAST_PASSWORD |
Scan user password. Same rule as scan-username |
client-slug |
No | '' |
DAST_CLIENT_SLUG |
Tenant slug. The sign-in page becomes <login-path>?client_slug=<slug> |
app-env |
No | Staging |
StackHawk environment name | |
config-file |
No | stackhawk.yml |
HawkScan config in the caller repo | |
login-path |
No | /users/sign_in |
Sign-in path | |
waf-bypass |
No | true |
Send the WAF bypass header | |
waf-scan-key |
No | '' |
DAST_SCAN_KEY |
WAF bypass key. Takes precedence over SSM |
waf-key-ssm-parameter |
No | '' |
SSM SecureString holding the bypass key | |
aws-role-to-assume |
No | '' |
AWS_ROLE_TO_ASSUME_STG |
IAM role that reads the SSM key over OIDC |
aws-region |
No | ap-northeast-1 |
Region of the SSM parameter | |
drata-api-key |
No | '' |
DRATA_CCT_API_KEY (shared value, provided by us) |
Drata custom-connection key. The Drata step is skipped when it's empty |
drata-connection-id |
No | 20 |
Drata custom connection id | |
drata-resource-id |
No | 1 |
Drata custom connection resource id | |
slack-bot-token |
No | '' |
SLACK_BOT_TOKEN (shared bot, or your own) |
Slack bot token. Slack is skipped when it's empty |
slack-channel-id |
No | '' |
Slack channel for alerts. Slack is skipped when it's empty | |
slack-severity-threshold |
No | High |
Minimum severity that triggers a Slack notification (High, Medium, or Low) |
|
artifact-retention-days |
No | 90 |
Retention of the dast-findings artifact |
GitHub doesn't enforce required for actions, so the first step fails with a clear error when a required input is empty. That usually means the secret is missing from the repo. The same goes for the scan credentials when stackhawk.yml has an authentication block, or when only one of the two is set.
For an app with no password login (public sites, LINE/LIFF or SSO-only logins), scan it as an outsider:
- Remove the
authenticationblock fromstackhawk.yml, and exclude the paths that need a session. - Leave
scan-usernameandscan-passwordout of the caller, or pass empty values.
The scan then only covers what a logged-out visitor can reach.
| Output | Description |
|---|---|
high |
Number of High-risk findings. Empty if the scan or the alert fetch failed |
medium |
Number of Medium-risk findings |
low |
Number of Low-risk findings |
The staging WAF lets a request skip its managed rule groups when it carries x-dast-scan-key: <key>. The key is a Terraform random_password published to SSM (nomanbase_infra/modules/core_infrastructure/waf.tf). The action picks its source in this order:
waf-scan-key. No AWS involved, and the caller doesn't needid-token: write. If Terraform ever regenerates the key, the secret goes stale and scans quietly run with the WAF active (lots of 403s, few findings). Update it whenever the SSM value changes.- SSM (
waf-key-ssm-parameter+aws-role-to-assume). The action assumes the role over OIDC and reads the current value, so it can't go stale. - Neither. The action logs a warning and scans with the WAF active.
When waf-bypass is off, the header is still sent, but with a placeholder value that can't match the rule.
The header goes out on every scan request, so stackhawk.yml must list it under app.redact (see examples/stackhawk.yml). Without that, the key is stored in the alert evidence on StackHawk and readable by anyone with StackHawk access.
- Create a StackHawk application for the app, and store its id as the
STACKHAWK_APP_IDrepo secret. - Make sure a scan user exists on staging, and store
DAST_USERNAME,DAST_PASSWORDandDAST_CLIENT_SLUG. Skip this if the app has no password login (see Unauthenticated scans). - Enable
enable_dast_scan_bypassfor the stage in the infra repo. Then either copy the SSM value into aDAST_SCAN_KEYsecret, or give the repo an OIDC role (AWS_ROLE_TO_ASSUME_STG) that can read/<app>-stg/dast/scan-key. - Add the shared
HAWK_API_KEYandDRATA_CCT_API_KEYvalues we provide (plusSLACK_BOT_TOKENfor alerts) as repo secrets. - Copy
examples/stackhawk.ymlto the repo root. Set the cookie name and host, and take the seed paths fromconfig/routes.rb. - Copy
examples/caller.ymlto.github/workflows/dast.ymland fill in the inputs, then run it once from the Actions tab.
hawk is pinned in the Install hawk step so that a compromised StackHawk manifest can't swap the binary. To upgrade, copy the linux-x64 version and sha256 from the manifest into HAWK_VERSION and HAWK_SHA256, then cut a release.
Callers pin @v1. Every release gets a vX.Y.Z tag, and the v1 tag is moved to the latest non-breaking release:
git tag v1.0.1 && git tag -f v1 && git push origin v1.0.1 && git push -f origin v1This repo is public, so any repository can use the action without an Actions access setting.
This action is maintained by Scoville for its own apps. We don't accept pull requests from outside the scoville organization, and workflows on outside pull requests only run after a maintainer approves them.