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 --testin 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.
| 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 |
- Run the rule tests and confirm each
ruleid:line is reported and eachok:line is not. - Confirm the SARIF upload appears in code scanning for the pull request's commit.
- Before trusting zero DAST findings, find a 200 response from a protected page under the scanner user in the scan log.
- Re-run the original probe and a normal request against the fixed deployment and record both with the artifact digest.
- Review suppressions on a schedule; each needs a rule id, a location, a reason and an owner.
- Applies to
Combining static and dynamic findings. Scan completion does not demonstrate coverage of authenticated or asynchronous routes.
- Part of
- Workflow: Test an application
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
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.
Before you start
Developers and AppSec engineers who run code and application scanners in CI, and reviewers who triage and close their findings.
- Distinguish source findings, observed requests and independently reproduced behavior.
Refresh first: Review a code change through its trust boundaries
Go to the guided practiceStatic 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/.
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.
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: $SQLRun 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.
# .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: semgrepBefore 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.
# 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-reportThe 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.
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 scenario material
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.
Expected result and reasoning
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.
Expected result and reasoning
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.
Expected result and reasoning
alicematches id 1, while the complete probe andO'Connormatch 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.
Expected result and reasoning
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.
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.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
Reference information
- SDLC phase
- TestingMaintenance