SAST: Finding Vulnerabilities in Source Code with CodeQL, Semgrep and .NET Security Analyzers
SAST looks for paths where user input (a source) reaches a dangerous call (a sink) without a sanitizer; it only sees the sinks it models and cannot find business rule or authorization gaps. At Bookstore we run CodeQL, Semgrep and the .NET security analyzers against the same five problems and show with real output who found what, a tested Bookstore-specific Semgrep rule, false positive triage and a pipeline that fails only on new findings.
Version note: The code in this post was built and run with .NET 10; the scans were run with CodeQL 2.27.4 (csharp-queries 1.11.0), Semgrep Community Edition 1.168 and the security analyzers in the .NET 10 SDK.
Guess First
Five changes went into the Bookstore API in one sprint: SQL string concatenation in the book search, reading a file with a user-supplied file name in the cover image endpoint, MD5 in the password reset token, an order details endpoint without an ownership check, and the first version of the same cover endpoint written with PhysicalFile. We run three tools: CodeQL, Semgrep and .NET's own security analyzers. Guess: how many of the five problems does at least one tool find, and how many does none of them find?
The answer is three and two. In this post we see with real output how SAST thinks, what these three tools find and miss in Bookstore's code and why, how to wire them into the pipeline and how to triage the findings.
How SAST Thinks: Source, Sink and Taint
Most security-focused SAST tools do taint analysis. A source is where data an attacker can control enters the program: a controller action parameter, a header, a request body. A sink is where that data becomes dangerous: a SQL command, a file path, starting a process, HTML (HyperText Markup Language) output. The tool looks for data flow paths from source to sink; if it sees no sanitizer on the path that makes the data safe (parameterization, encoding, validation), it produces a finding. SQL injection, path traversal, command injection and XSS (Cross-Site Scripting) fit this model very well. Findings such as a weak cryptography call need no taint; calling a particular API is enough.
This model has two natural limits. First, the tool only knows the sources and sinks it models; data flowing into a sink that is not in the model stays invisible. Second, SAST does not know what the code is supposed to do. "A customer may only see their own orders" is a business rule; its absence from the code is not a data flow error, and no taint analysis can find it. The API Security series' authorization tests and code review catch these gaps.
Bookstore's Code
The search endpoint builds SQL in the controller by concatenating strings and hands it to a shared SqlExecutor; the executor passes the text to SqlCommand:
[HttpGet("search")]
public Task<List<string>> Search([FromQuery] string title) =>
executor.QueryTitlesAsync("SELECT Title FROM Books WHERE Title LIKE '%" + title + "%'");
public async Task<List<string>> QueryTitlesAsync(string sql, params SqlParameter[] parameters)
{
await using SqlConnection connection = new(connectionString);
await connection.OpenAsync();
await using SqlCommand command = new(sql, connection);
command.Parameters.AddRange(parameters);
// ...
}
In its first version the cover endpoint appends the file name directly to the path and returns PhysicalFile; in its second version it reads the same path with System.IO.File.ReadAllBytes. The password reset token is derived from the email and the time with MD5. The order endpoint is under [Authorize] but does not check whom the order belongs to:
public IActionResult Get(string fileName)
{
string path = Path.Combine(environment.ContentRootPath, "covers", fileName);
return PhysicalFile(path, "image/jpeg");
}
public string CreateToken(string email, DateTimeOffset now)
{
byte[] hash = MD5.HashData(Encoding.UTF8.GetBytes($"{email}:{now.ToUnixTimeSeconds()}"));
return Convert.ToHexString(hash);
}
// Any signed-in customer can read any order: no ownership check
[HttpGet("{id:int}")]
public Task<List<string>> Get(int id) =>
executor.QueryTitlesAsync(
"SELECT b.Title FROM OrderItems oi JOIN Books b ON b.Id = oi.BookId WHERE oi.OrderId = @id",
new SqlParameter("@id", id));
.NET Security Analyzers
The cheapest layer is already inside the SDK. Most rules in the Security category of the .NET analyzers are off by default; when I built the project as is, I got zero warnings. Turning the whole category on with AnalysisModeSecurity changes the picture (paths and documentation links shortened):
dotnet build Bookstore.slnx -p:AnalysisModeSecurity=All --no-incremental
src/Bookstore.Api/Data/SqlExecutor.cs(11,42): warning CA2100: Review if the query string passed to 'SqlCommand.SqlCommand(string cmdText, SqlConnection connection)' in 'QueryTitlesAsync', accepts any user input
src/Bookstore.Api/Data/SqlExecutor.cs(11,42): warning CA3001: Potential SQL injection vulnerability was found where 'SqlCommand.SqlCommand(string cmdText, SqlConnection connection)' in method 'Task<List<string>> SqlExecutor.QueryTitlesAsync(string sql, params SqlParameter[] parameters)' may be tainted by user-controlled data from 'string title' in method 'Task<List<string>> BooksController.Search(string title)'.
src/Bookstore.Api/Services/ResetTokenService.cs(10,23): warning CA5351: CreateToken uses a broken cryptographic algorithm MD5
CA3001 is a real taint analysis: it found that title in BooksController.Search reaches the SqlCommand in SqlExecutor, crossing the method boundary. CA2100, on the other hand, does not look for taint; it says "review" for any command text that is not constant. CA5351 caught MD5. There is no warning at all for the cover endpoint with PhysicalFile. Turning these rules on permanently in Directory.Build.props, tuning their severities with .editorconfig and managing them together with quality rules is the topic of the Static Analysis series; here we only look at the security side.
CodeQL: Querying Code Like a Database
CodeQL first extracts the code into a database, then runs queries against that database. For C# there is a none build mode that needs no build; GitHub's default setup uses this mode for C# as well. Locally with the CLI:
codeql database create db --language=csharp --build-mode=none --source-root=bookstore
codeql database analyze db codeql/csharp-queries:codeql-suites/csharp-security-extended.qls --format=csv --output=results.csv
The security-extended suite ran 70 queries for C# and gave a single finding for the PhysicalFile version: SQL injection. When I scanned the cover endpoint in its ReadAllBytes version, a second finding came (CSV lines split for readability):
"SQL query built from user-controlled sources","Building a SQL query from user-controlled sources is vulnerable to insertion of malicious SQL code by the user.","error",
"This query depends on [[""this ASP.NET Core MVC action method parameter""|""relative:///src/Bookstore.Api/Controllers/BooksController.cs:11:57:11:61""]].",
"/src/Bookstore.Api/Data/SqlExecutor.cs","11","46","11","48"
"Uncontrolled data used in path expression","Accessing paths influenced by users can allow an attacker to access unexpected resources.","error",
"This path depends on a [[""user-provided value""|""relative:///src/Bookstore.Api/Controllers/CoversController.cs:10:37:10:44""]].",
"/src/Bookstore.Api/Controllers/CoversController.cs","13","54","13","57"
The lesson here is sink modeling. The same user input goes to the same path; File.ReadAllBytes is a sink in the models of CodeQL and CA3003, while ControllerBase.PhysicalFile is not, in these versions of the two tools. You cannot assume the code is safe because "the scanner found nothing". MD5 did not produce a finding in this CodeQL suite either; different tools see the same weaknesses from different angles.
CodeQL has three built-in suites: default runs a small number of high-precision queries; security-extended adds queries with slightly lower precision on top; security-and-quality also adds maintainability and reliability queries and requires advanced setup. On GitHub there are two ways to set it up. Default setup is turned on with a few clicks, picks the languages itself and needs no workflow file; advanced setup comes with a workflow file and leaves the suite, path filters and triggers under your control. At Bookstore we use advanced setup, because we want to wire Semgrep into the same pipeline and have the configuration go through code review.
Mind the license: the CodeQL CLI license allows analyzing open source code and academic research; using it on a closed source codebase, such as a private repo, requires a GitHub Advanced Security license. On GitHub, code scanning in private repos needs the Code Security product. Copilot Autofix generates fix suggestions for CodeQL alerts and does not need a separate Copilot subscription; but its documentation states plainly that suggestions are non-deterministic, can be wrong or incomplete, and each one must be reviewed. Read an autofix like any other PR, and check that the tests pass and the alert is closed.
Semgrep: Writing Your Own Rule
I ran Semgrep's ready-made C# rule set on the same code:
semgrep scan --config p/csharp --metrics=off
Ran 27 rules on 6 files: 0 findings.
Zero findings. In the first post the same rule set caught the new SqlCommand("..." + title) line; here the concatenation is in the controller and SqlCommand is in another file. Semgrep Community Edition's analysis stays within a function; taint tracking across files and methods is in the paid edition. But this is where Semgrep's real strength starts: you can write a rule that knows Bookstore's own API in minutes. The rule: the first argument to the Query...Async methods of SqlExecutor may not be a concatenation, an interpolated string or string.Format.
rules:
- id: bookstore-sqlexecutor-raw-sql
languages: [csharp]
severity: ERROR
message: >-
SQL passed to SqlExecutor.$METHOD is built from strings. Keep the SQL text
constant and pass values as SqlParameter.
metadata:
cwe: "CWE-89: SQL Injection"
owner: bookstore-appsec
patterns:
- pattern-either:
- pattern: $EXEC.$METHOD($LEFT + $RIGHT, ...)
- pattern: $EXEC.$METHOD($"...", ...)
- pattern: $EXEC.$METHOD(string.Format(...), ...)
- metavariable-regex:
metavariable: $METHOD
regex: ^Query\w*Async$
A rule is tested like code. A C# file with the same name in the same folder marks which lines should be caught (ruleid) and which should not (ok):
public class Samples
{
public async Task Run(SqlExecutor executor, string title, int id)
{
// ruleid: bookstore-sqlexecutor-raw-sql
await executor.QueryTitlesAsync("SELECT Title FROM Books WHERE Title LIKE '%" + title + "%'");
// ruleid: bookstore-sqlexecutor-raw-sql
await executor.QueryTitlesAsync($"SELECT Title FROM Books WHERE Id = {id}");
// ruleid: bookstore-sqlexecutor-raw-sql
await executor.QueryTitlesAsync(string.Format("SELECT Title FROM Books WHERE Id = {0}", id));
// ok: bookstore-sqlexecutor-raw-sql
await executor.QueryTitlesAsync("SELECT Title FROM Books WHERE Id = @id", new SqlParameter("@id", id));
}
}
semgrep --test semgrep-rules --metrics=off
1/1: ✓ All tests passed
To see that the test really measures something, I turned the ok line into ruleid; the output dropped to 0/1 with "missed lines: [15]". In the first version of the rule I wrote the receiver with its type, as (SqlExecutor $EXEC). It passed in the test file and found nothing in the real code: executor in the controller is a primary constructor parameter and Semgrep could not resolve its type. After removing the type constraint and narrowing the method name with a regex, the real finding came:
src/Bookstore.Api/Controllers/BooksController.cs
❯❯❱ semgrep-rules.bookstore-sqlexecutor-raw-sql
❰❰ Blocking ❱❱
SQL passed to SqlExecutor.QueryTitlesAsync is built from strings. Keep the SQL text constant and
pass values as SqlParameter.
12┆ executor.QueryTitlesAsync("SELECT Title FROM Books WHERE Title LIKE '%" + title + "%'");
A note on licensing: Semgrep's engine (Community Edition) is open source under the LGPL 2.1; in December 2024 Semgrep's own rule repository moved to the Semgrep Rules License v1.0, which limits the rules to internal use and prohibits using them in competing products and SaaS offerings. Scanning your own code in your own CI falls within that scope. In response to this change, a fork named Opengrep was created from version 1.100.0 of the engine in January 2025. Rules you write yourself are yours; the licensing question applies only to the ready-made rule sets.
Who Found What?
| Problem | CodeQL security-extended | Semgrep p/csharp | Semgrep + Bookstore rule | .NET analyzers |
|---|---|---|---|---|
SQL concatenation, through SqlExecutor | Found (cs/sql-injection) | Missed | Found | Found (CA3001, CA2100) |
Cover, PhysicalFile | Missed | Missed | Out of scope | Missed |
Cover, ReadAllBytes | Found (cs/path-injection) | Missed | Out of scope | Found (CA3003) |
| Token with MD5 | Missed | Missed | Out of scope | Found (CA5351) |
| No order ownership check | Missed | Missed | Missed | Missed |
Two conclusions follow from the table. The tools complement each other: CodeQL or CA3001 for interprocedural taint, a Semgrep rule for team-specific APIs, the analyzers for specific API calls. And no tool knows the business rules: the hole in the order endpoint may be the most serious problem in this post, and it is empty in all four columns.
The fixes, in order: the search went back to a parameterized query (LIKE @pattern, the value in a SqlParameter), the token is generated with RandomNumberGenerator.GetHexString(64, lowercase: true) (the details of token design are in the Cryptography for Developers series), and the cover endpoint takes a book id instead of a file name. User-supplied text never reaches the file system:
[HttpGet("{bookId:int}")]
[SuppressMessage("Security", "CA3003",
Justification = "The path is built from an int route value; no user-supplied text reaches the file system.")]
public IActionResult Get(int bookId)
{
string path = Path.Combine(environment.ContentRootPath, "covers", $"{bookId}.jpg");
if (!System.IO.File.Exists(path))
{
return NotFound();
}
byte[] content = System.IO.File.ReadAllBytes(path);
return File(content, "image/jpeg");
}
The SuppressMessage on this code is the topic of the next section.
Triage: False Positives, Justifications and a New-Code Gate
To fix the cover endpoint I first tried two approaches. I wrote a correct guard that takes the full path with Path.GetFullPath and checks with StartsWith that it starts with the covers folder; both CodeQL and CA3003 kept the finding. Cleaning the file name with Path.GetFileName was not enough for either tool. With the final int-based design CodeQL gave zero findings, while CA3003 kept warning for the File.Exists and ReadAllBytes lines: in its eyes even an int route value is user input. This is a false positive, and there are two correct responses: switch to a design the tool recognizes, or suppress the finding with a justification. We did both; the design satisfied CodeQL, and we wrote a justified SuppressMessage for CA3003. CA2100 in SqlExecutor was suppressed the same way, with a justification saying that the Semgrep rule enforces the contract. The suppression itself must go through code review; instead of an unexplained #pragma warning disable, an attribute with a Justification makes visible in review what you are accepting.
On GitHub, CodeQL alerts are dismissed with the reasons "false positive", "won't fix" and "used in tests", with a comment; that decision stays in the repo's history. Semgrep has a line-level // nosemgrep: rule-id comment; always write the rule id, because a nosemgrep without an id silences every rule on that line.
For a new-code gate we apply the baseline principle from the first post to SAST. On a PR, CodeQL shows only new alerts in the code the PR changed as annotations; a ruleset can define a rule that makes code scanning results required for merging. In Semgrep we use --baseline-commit. Bookstore's SAST workflow:
name: sast
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "17 3 * * 1"
permissions:
contents: read
jobs:
codeql:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [csharp, javascript-typescript]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: github/codeql-action/init@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
with:
languages: ${{ matrix.language }}
build-mode: none
config-file: ./.github/codeql/codeql-config.yml
- uses: github/codeql-action/analyze@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
with:
category: "/language:${{ matrix.language }}"
semgrep:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- run: python -m pip install semgrep==1.168.0
# rule tests first: a broken rule must not pass silently
- run: semgrep --test semgrep-rules --metrics=off
- name: Scan
env:
BASE_REF: ${{ github.base_ref }}
run: |
baseline=()
if [ -n "$BASE_REF" ]; then baseline=(--baseline-commit "origin/$BASE_REF"); fi
semgrep scan --config p/csharp --config semgrep-rules/bookstore-sql.yaml \
--exclude semgrep-rules --metrics=off --error \
--sarif --output semgrep.sarif "${baseline[@]}"
- if: always()
uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
with:
sarif_file: semgrep.sarif
category: semgrep
name: bookstore-codeql
queries:
- uses: security-extended
paths-ignore:
- semgrep-rules
- "**/*.Tests"
A few details. security-events: write is granted only to these two jobs; the workflow level is contents: read. Because Semgrep's test file deliberately contains vulnerable lines, it is excluded from both CodeQL (paths-ignore) and the Semgrep scan (--exclude). The SARIF upload runs with if: always(), because when Semgrep finds something the step fails due to --error, but the findings still need to reach code scanning. I validated the workflow with actionlint; I ran the scan step's script locally by marking main as origin/main and adding SQL with an interpolated string to an ISBN lookup on a new branch:
Creating git worktree from 'origin/main' to scan baseline.
• Findings: 1 (1 blocking)
• Scan was limited to files changed since baseline commit.
Ran 28 rules on 1 file: 1 finding.
The exit code was 1 and the generated SARIF had a single result: BooksController.cs line 19, the newly added line. I did not run the CodeQL job on GitHub; I ran CodeQL itself locally with the same version.
In Production
Measure the time. In Bookstore's small repository, creating the database with build-mode none took 32 seconds and the security-extended analysis 10 seconds; in a real repository it takes minutes. When running CodeQL on PRs use parallel jobs and a language matrix; a weekly schedule catches what new query versions find in code that no PR touches.
Grow the rule set gradually. Start with the default suite, watch the false positive rate for a few sprints, then move to security-extended. In Semgrep, instead of making the whole ready-made rule set blocking, run it in report mode first and move the rules that make sense for the team to --error. Give your own rules an owner (metadata.owner) and give every rule a test.
Pin tool versions and take updates as a PR. When a CodeQL query pack or a Semgrep rule is updated, findings that were not there yesterday may appear today; knowing they come from a tool change and not a code change speeds up triage.
Common Pitfalls
First, putting SAST in place of code review. None of the four tools saw the authorization hole in the order endpoint. SAST catches known patterns at scale; business rules, permissions and design are caught by review and tests.
Second, reading "no findings" as "secure". The PhysicalFile example showed that an unmodeled sink does not exist for the scanner.
Third, suppression without justification. #pragma warning disable CA3001 or a nosemgrep without an id also silences the next real finding.
Fourth, making a rule blocking without testing it. In the typed metavariable example the rule passed its test and found nothing in the real code. You need both a rule test and at least one check against real code.
Fifth, merging a Copilot Autofix suggestion without review. The suggestion is a starting point; you verify whether it preserves the behaviour.
A Small Experiment
★☆☆ Run dotnet build -p:AnalysisModeSecurity=All on your own project, count the CA3xxx and CA5xxx warnings and label each as "real", "false positive" or "not sure".
★★☆ Write a Semgrep rule for one of your team's data access helpers, add a test file with at least three ruleid lines and one ok line, and see semgrep --test pass first and then fail with a line you break on purpose.
★★★ Create a database for your project with the CodeQL CLI, run the default and security-extended suites separately and examine the difference between the two result sets. How many of the extra findings are real for you?
We scanned our source code; but Bookstore goes to production as a container image, and inside that image there is an operating system we did not write. In the next post we look at image and IaC scanning, base image choice, and the March 2026 Trivy incident, where the scanner itself was a supply chain risk.