---
key: "sast-dast-workflow"
title: "Combine static and dynamic tests around a verified finding"
url: "https://softwaresecurity.com/domains/appsec/testing/sast-dast-workflow/"
api: "https://softwaresecurity.com/api/v1/articles/sast-dast-workflow.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["A05:2025"]
tags: ["Security testing","Vulnerability management"]
stages: ["test","respond"]
roles: ["developer","appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Combine static and dynamic tests around a verified finding

Use analysis tools to find evidence, then retain an application regression that demonstrates the failure and the fix.

Canonical URL: https://softwaresecurity.com/domains/appsec/testing/sast-dast-workflow/

## In short

Treat every tool result as scoped evidence. Run static rules such as Semgrep or CodeQL on each pull request and keep their findings as SARIF in code scanning. Run dynamic scans such as ZAP against a test deployment with a dedicated scanner account and a logged-in check, because a scan that never authenticated reports nothing. Close a finding only when the source change, a reproducer against the deployed revision and a normal-case test agree.

- SAST finds source-to-sink paths without running the code; DAST observes a running system; IAST instruments the running application to connect the two.
- A clean dynamic scan means nothing until a logged-in baseline shows the scanner reached the protected pages.
- Write project rules with annotated vulnerable and safe test cases, and run `semgrep --test` in CI.
- Suppress one finding at one location with a recorded reason, never a whole rule or directory.
- Tie every result to the analyzed commit and the deployed artifact digest.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Semgrep in CI | A pinned image such as `semgrep/semgrep:1.180.0`, `semgrep --test` on the rules, then `semgrep scan --sarif --error` | — | Semgrep documentation: testing rules |
| SARIF upload job permissions | `contents: read` and `security-events: write`; actions pinned to commit SHAs | — | GitHub: uploading a SARIF file |
| ZAP authentication | A context with `authentication`, `verification` (`loggedInRegex`, `pollUrl`) and a dedicated user | Exclude logout and destructive routes from the context. | ZAP Automation Framework: authentication |
| ZAP plan failure handling | `failOnError: true`, with the scan credentials read from environment variables | — | ZAP Automation Framework |
| Suppressions | An inline `nosemgrep: <rule-id>` or a code scanning dismissal, each with a written reason | — | Semgrep documentation: ignoring findings |

## Verify it

1. Run the rule tests and confirm each `ruleid:` line is reported and each `ok:` line is not.
2. Confirm the SARIF upload appears in code scanning for the pull request's commit.
3. Before trusting zero DAST findings, find a 200 response from a protected page under the scanner user in the scan log.
4. Re-run the original probe and a normal request against the fixed deployment and record both with the artifact digest.
5. Review suppressions on a schedule; each needs a rule id, a location, a reason and an owner.

## What you will learn

- Interpret scanner results as scoped evidence.
- Establish authenticated reachability before attack conclusions.
- Close on repaired behavior and a traceable source change.

Audience: Developers and AppSec engineers who run code and application scanners in CI, and reviewers who triage and close their findings.

Before you start:

- Distinguish source findings, observed requests and independently reproduced behavior.

Time: About 29 minutes: 7 to read, 4 on the code and 18 for the exercise

## Understand the evidence each technique can supply

Static analysis examines code or build artifacts without exercising a particular live request. It can identify a flow from request input to a dangerous sink even when a route is hard to reach. Dynamic testing exercises a running system and observes behavior under a particular identity, configuration and data state. Interactive testing (IAST) places an agent inside the running application during functional or dynamic tests and reports the code path each request took; it adds execution context but still sees only the behavior those tests exercised. A clean static report may reflect a missing rule or unsupported framework; a clean dynamic scan may mean the scanner never authenticated or reached the feature.

Use an invoice lookup that interpolates an owner parameter into SQL. A static finding names source `request.query.owner`, an intermediate helper and sink `database.execute`. A dynamic probe can show whether the query actually returns foreign rows. The strongest result connects the two: an identified code path, a controlled request that reaches it, observed impact and a regression after the repair. Neither tool alone knows every business authorization rule or deployment assumption.

This guide works one finding through both techniques. To choose tools for a category in the first place, including IAST, SCA, secrets, IaC, container and API scanners, see /domains/appsec/testing/appsec-tool-categories-and-evaluation/.

## Make the static claim reproducible and inspectable

Record source revision, analyzer version, rule id, relevant configuration and the source-to-sink path. Review transformations along that path. An apparent sanitizer may remove quotes but leave another grammar problem; an actual parameter-binding API may break the injection flow. Do not suppress a finding solely because a test payload failed once. State the reason the sink is safe or the path is unreachable, with supporting code and conditions.

For the fixture, the vulnerable query places `owner` inside a quoted SQL literal. The supplied probe closes the literal and introduces a true predicate. Replacing interpolation with a bound parameter fixes grammar separation; /fix/sql-injection/ shows that repair for each common stack. Adding an authentication check alone does not: a legitimate member may still supply the probe. Similarly, suppressing the rule or renaming the helper changes the report without changing the program's behavior. A useful static regression verifies the dangerous path is gone, while a behavioral test verifies the required lookup still works.

Project rules make the static claim precise. The Semgrep rule below uses taint mode: request values are sources, the SQL text argument of `execute` is the sink, and a value passed in the separate parameter tuple never reaches it. Keep each rule beside a test file whose lines are annotated `# ruleid:` where it must fire and `# ok:` where it must not; with Semgrep 1.180.0, `semgrep --test` passed for string concatenation and an f-string, stayed silent for the bound query, and a scan of the exercise handler produced one SARIF result at the concatenating line. CodeQL offers the same source-to-sink model through its query packs and GitHub default setup.

```yaml
rules:
  - id: python-sql-built-from-request
    mode: taint
    languages: [python]
    severity: ERROR
    message: >-
      Request data reaches SQL text. Pass it as a bound parameter instead,
      for example execute("... WHERE owner = ?", (owner,)).
    metadata:
      cwe: "CWE-89: Improper Neutralization of Special Elements used in an SQL Command"
    pattern-sources:
      - pattern: request.args.get(...)
      - pattern: request.args[...]
    pattern-sinks:
      - patterns:
          - pattern-inside: $DB.execute($SQL, ...)
          - focus-metavariable: $SQL

```

## Run the rules in CI and keep the results as SARIF

Run the rules on every pull request so a finding reaches the author while the change is fresh, and publish results in SARIF, the format GitHub code scanning and most security platforms import. The workflow below tests the rules first, so a broken rule cannot silently report nothing, then scans and uploads the SARIF even when the scan fails. It grants only the permissions the upload needs, pins actions to commit SHAs and keeps the checkout token out of the workspace; actionlint 1.7.12 and zizmor 1.30.1 reported no problems with it. For an existing codebase, start from a baseline so only new findings block the merge, and record each dismissal with its reason rather than excluding files.

```yaml
# .github/workflows/sast.yml: project Semgrep rules on every pull request, results as SARIF.
name: sast
on:
  pull_request:
  push:
    branches: [main]
permissions: {}
jobs:
  semgrep:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write # upload-sarif creates code scanning alerts
    container:
      image: semgrep/semgrep:1.180.0
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - name: Check each rule against its annotated test file
        run: semgrep --test semgrep-rules/
      - name: Scan the repository
        run: semgrep scan --config semgrep-rules/ --exclude semgrep-rules --sarif --output semgrep.sarif --error
      - name: Upload results to code scanning
        if: always()
        uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
        with:
          sarif_file: semgrep.sarif
          category: semgrep

```

## Prove the dynamic test reached the intended operation

Before interpreting an attack result, establish a normal request that succeeds as the intended synthetic actor. Record status, route marker or correlated server trace, and expected data. Then change only the test value. A scanner receiving a login page with status 200 has not tested the invoice handler. A reverse proxy blocking all unusual strings may prevent the probe without fixing the vulnerable backend, leaving alternative encodings or internal callers exposed.

Use a controlled test environment and synthetic records, and keep the payload's effect bounded to observation. Compare the authorized baseline with an injection value and a quote-containing ordinary name. Inspect returned row ids, not only status. Dynamic evidence is scoped to the tested deployment digest, route, credentials, input and time. Expired sessions, missing feature flags and stale deployments can all make a post-fix run appear clean for the wrong reason.

The ZAP Automation Framework plan below scans a test deployment as a dedicated user. The context's `authentication` block logs in with a form, `verification` polls a protected URL and checks for text only a signed-in response contains, and a `requestor` job confirms the protected handler returns 200 before the spider and active scan start. Against a local training copy of the exercise's vulnerable handler, ZAP 2.17.0 reported SQL injection (rule 40018) on `/accounts?owner=`; the same plan without the user finished with "Automation plan succeeded!" and no alerts, because every request was redirected to the login page.

```yaml
# zap.yaml: ZAP Automation Framework plan for an authenticated scan of a test environment.
env:
  contexts:
    - name: accounts
      urls: ["http://app:8090/"]
      includePaths: ["http://app:8090/.*"]
      excludePaths: ["http://app:8090/logout.*"]
      authentication:
        method: form
        parameters:
          loginPageUrl: "http://app:8090/login"
          loginRequestUrl: "http://app:8090/login"
          loginRequestBody: "user={%username%}&password={%password%}"
        verification:
          method: poll
          loggedInRegex: "\\Q\"owner\": \"alice\"\\E"
          loggedOutRegex: "\\QLocation: /login\\E"
          pollFrequency: 20
          pollUnits: requests
          pollUrl: "http://app:8090/accounts?owner=alice"
      sessionManagement:
        method: cookie
      users:
        - name: alice
          credentials:
            username: alice
            password: ${ZAP_ALICE_PASSWORD}
  parameters:
    failOnError: true
    progressToStdout: true
jobs:
  - type: requestor  # a known authenticated request, so the scan starts from the real handler
    parameters:
      user: alice
    requests:
      - url: "http://app:8090/accounts?owner=alice"
        responseCode: 200
  - type: spider
    parameters:
      context: accounts
      user: alice
      maxDuration: 1
  - type: activeScan
    parameters:
      context: accounts
      user: alice
      maxScanDurationInMins: 5
    policyDefinition:
      defaultStrength: low
      defaultThreshold: "off"
      rules:
        - id: 40018  # SQL injection
          strength: medium
          threshold: low
  - type: report
    parameters:
      template: traditional-json
      reportDir: /zap/wrk
      reportFile: zap-report

```

## Close a finding by connecting behavior to the repair

The closure record should say which source change removes the unsafe interpretation, which candidate artifact contains it and what the paired tests observed. Re-run the original reproducer against the candidate and retain a normal case. If the analyzer still reports a safe binding API, document that specific false positive with narrow suppression and a regression; do not disable an entire rule category across the repository.

Use disagreement as a diagnostic clue. Static positive/dynamic negative asks whether the path is reachable and whether the test got past authentication. Static negative/dynamic positive asks whether the analyzer modeled the framework and sink correctly. Both negative is only scoped evidence, not proof of absence. Keep scanner credentials restricted, reset test data and avoid putting session tokens or captured private responses into broadly accessible build logs.

## Keep scan configuration and application revision linked

Suppose the source analysis runs on commit C2 but the dynamic environment still serves C1. A clean static report and a successful dynamic exploit are not contradictory; they concern different programs. Record the deployed artifact digest or equivalent revision alongside test traces. A page footer is a useful hint but can be cached or changed independently; prefer evidence from the deployment system and a correlated application request.

Coverage also depends on identity and state. A scanner with an ordinary member account cannot exercise administrator-only routes, and an expired session may redirect every request halfway through a run. Include a periodic authenticated baseline and stop interpreting later negative results when it fails. Reset synthetic data so a previous request does not remove the resource needed by the next test. A scanner cannot assess an operation it never successfully reaches.

For static analysis, a new framework wrapper can hide a known sink from the analyzer. Compare the modeled call path with the actual wrapper behavior rather than assuming a tool upgrade understands every project helper. Narrow custom modeling may be appropriate, but it should be reviewed and exercised by a representative known-positive and known-safe case. The aim is reliable evidence about specific paths, not maximizing alert volume or minimizing it through broad exclusions. This also makes trend metrics meaningful: fewer alerts after excluding half the repository is a scope change, not necessarily a security improvement.

## Guided practice

Combine supplied code-flow and scanner traces without installing a scanner.

### Supplied material

```text
Synthetic rows: id 1 owner=alice; id 2 owner=bob. Expected Alice lookup returns id 1 only.
Static S1: request.query.owner -> buildQuery -> execute("SELECT id FROM accounts WHERE owner='"+owner+"'").
Probe P = ' OR 1=1 --
Dynamic D1: baseline /accounts?owner=alice -> 302 /login; P -> 302 /login.
D2 with valid training session: baseline -> 200 [1], handler trace=yes; P -> 200 [1,2], handler trace=yes.
Candidate fix: execute('SELECT id FROM accounts WHERE owner=?',(owner,)).
D3 candidate: baseline -> 200 [1]; P -> 200 []; O'Connor -> 200 []; trace=yes for all.
Static S2 after candidate: old rule still flags execute by name without modeling parameters.
```

### 1. Interpret missing coverage

Decide whether D1 disproves S1.

Worked answer: No. Both requests stopped at login and did not reach the sink. The scan result says nothing about injection behavior in the authenticated handler.

### 2. Confirm the causal finding

Connect S1 and D2 to the violated property.

Worked answer: The supplied owner changes SQL grammar and the live handler returns both ids. D2 provides concrete impact for the source flow. Authentication is present and does not prevent the injection.

### 3. Assess the repair

Explain D3's three outputs and whether a 200 response to P is itself a failure.

Worked answer: `alice` matches id 1, while the complete probe and `O'Connor` match no stored owner. A 200 with empty results is valid here; the security property is preserved query meaning, not mandatory rejection of punctuation.

### 4. Resolve the remaining static alert

State the evidence for a narrow S2 disposition and what would invalidate it.

Worked answer: The sink uses separate bound parameters and the candidate trace confirms behavior. Document the analyzer's modeling limitation for this call. A later change back to interpolated SQL or a different unreviewed sink invalidates the justification.

Deliverable: A finding record linking source, authenticated reachability, observed rows, repair and scoped analyzer disposition.

Scope: Paper exercise: code flows and scan traces are supplied, so no scanner is needed. The Semgrep rule, CI workflow and ZAP plan above produce the same S1 and D2 evidence against real tools.

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [Semgrep: test rules](https://docs.semgrep.dev/writing-rules/testing-rules)
- [Semgrep: taint analysis overview](https://docs.semgrep.dev/writing-rules/data-flow/taint-mode/overview)
- [GitHub: uploading a SARIF file to code scanning](https://docs.github.com/en/code-security/how-tos/find-and-fix-code-vulnerabilities/integrate-with-existing-tools/upload-sarif-file)
- [ZAP Automation Framework: authentication](https://www.zaproxy.org/docs/desktop/addons/automation-framework/authentication/)
- [OWASP WSTG-INPV-05: testing for SQL injection](https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/07-Input_Validation_Testing/05-Testing_for_SQL_Injection/). WSTG v4.2.
- [CodeQL: about data flow analysis](https://codeql.github.com/docs/writing-codeql-queries/about-data-flow-analysis/)
- [Semgrep: ignore files, folders and code](https://docs.semgrep.dev/ignoring-files-folders-code)