Status: accepted Date: 2026-08-15 Decision owners: technical owner
SEC-07 of the vendored security standard makes Semgrep an AUTO-GATE: zero unwaived
HIGH/CRITICAL findings at merge. The gate is .github/workflows/security.yml, job
semgrep. It has never once been green. All fourteen main runs of that workflow
failed, from 29565667759 on 2026-07-17 through the scheduled run 31301838938 on
2026-08-09. There is no first green run to regress from.
Two separate defects were behind that, and they point in opposite directions.
Reproduced locally against the same registry config (1074 rules, 72 targets, matching
the CI summary): four blocking findings, two rules firing on each of two adjacent
lines in src/contextsafe/evidence_store.py.
python.lang.security.audit.formatted-sql-query.formatted-sql-querypython.sqlalchemy.security.sqlalchemy-execute-raw-query.sqlalchemy-execute-raw-query
Both lines set the evidence index's format identity as the store is created:
PRAGMA application_id and PRAGMA user_version, each interpolating a module-level
integer constant into an f-string passed straight to execute.
Assessing the four on their merits:
- Neither interpolated value is reachable from caller input.
_INDEX_APPLICATION_IDand_INDEX_USER_VERSIONare module constants, never reassigned, never derived from an argument, environment variable, or file. There is no injection here. - The
formatted-sql-queryfindings are nonetheless true positives on shape. A string built at the point of execution is the construct the rule exists to find, and the rule cannot know the operand's provenance. - The two
sqlalchemy-execute-raw-queryfindings are additionally misattributed. SQLAlchemy is not a dependency of this project — it appears nowhere inpyproject.tomloruv.lock. The rule matched on the method nameexecutealone, and its remediation advice (TextualSQL, the ORM) is inapplicable.
The obvious remediation is unavailable: SQLite does not accept bound parameters in a
PRAGMA statement. PRAGMA user_version = ? is a syntax error, which
test_pragma_header_sql_is_a_fixed_integer_assignment now asserts directly so the
constraint is recorded in the suite rather than in a comment.
An inline coercion does not help either. execute(f"... {int(value)}") was measured
against both rules and still fires: these are shape matchers, not taint analysis.
While confirming defect 1, the semgrep job was found to be green on all three open
pull requests. It was not finding the code clean. It was not reading it.
semgrep ci resolves a diff baseline on a pull_request event and shells out to
git fetch origin --force --depth=1 <head-sha>. This repository is private and the
checkout sets persist-credentials: false, so that fetch failed with
fatal: could not read Username for 'https://github.com'. Semgrep aborted before
scanning, and its default --suppress-errors converted the aborted run into exit 0:
"There were errors during analysis but Semgrep will succeed because there were no
blocking findings."
The job log for run 31872289010 contains a scan environment banner and then the
fetch failure. It has no scan summary at all — no rule count, no target count, no
findings line. Nothing was inspected.
So the control failed in both directions at once. On main it was permanently red for
a reason that was not a vulnerability, which trains a reader to ignore it. On every
pull request — the only place the gate can actually block a merge — it reported a
green SAST check over zero files. A genuine HIGH finding introduced by a pull request
would have passed.
Defect 1. Render both header statements once, at module scope, next to the
constants they encode and alongside the other _*_SQL statements this module already
executes:
_SET_APPLICATION_ID_SQLand_SET_USER_VERSION_SQLare built with the:dconversion, which accepts only an integer and can emit only digits and an optional sign. No string can reach the statement even if a future edit made the constants configurable._publish_new_databaseexecutes those constants. No string is constructed at the call site.test_pragma_header_sql_is_a_fixed_integer_assignmentpins the exact rendered text of both statements, asserts each matchesPRAGMA [a-z_]+ = -?\d+, and asserts that SQLite rejects the parameterized form. Drift in either direction fails the suite.
No waiver was filed, no .semgrepignore was added, and no # nosemgrep was written.
There is nothing left to suppress: the registry auto config now reports 0 findings.
Be precise about why the rules stop firing. The code was already safe; the interpolated
values were never caller-controlled. What changed is that the dynamic construction no
longer sits on the execute call, and that the integer-only rendering and the exact
statement text are now enforced by a test instead of by inspection. The rules stop
matching because the shape they match is gone.
Defect 2. Replace semgrep ci --config auto with
semgrep scan --config auto --error --strict.
semgrep scanneeds no diff baseline and no git credential, sopush,pull_request, and the weekly schedule all run the identical full scan.--errorexits non-zero on findings.--strictexits non-zero on an analysis error. A scan that cannot run can no longer report success — the specific failure mode that produced defect 2.
Verified locally against the pre-fix tree: the replacement invocation exits 1 and reports "Findings: 4 (4 blocking)", so it is at least as strict as the invocation it replaces. Against the fixed tree it exits 0 with 0 findings over 72 targets.
- SEC-07 has a gate that inspects the whole tree on every event and blocks a merge. For the first time in this repository, a green SAST check means something was read.
- Full-scan-on-pull-request is slower than a diff scan and will surface a pre-existing
finding on an unrelated pull request. That is the intended trade for this repository:
it is small (72 targets, 28 seconds), and a gate that only ever examines the diff
cannot notice that
mainis already carrying a finding. --strictwill fail the job on an upstream registry or parse error, not only on a vulnerability. This is deliberate. The alternative is the behaviour being replaced.- Semgrep's registry classification of blocking versus non-blocking findings no longer
applies;
--errortreats any finding as a failure. The tree is at 0 findings today, so this costs nothing now and can be revisited with--severityif it becomes noisy. - A genuine Lob-style detector false positive in future should be handled the way
SEC-10 requires — a dated
.semgrep-waivers.ymlentry with a reason and an expiry — rather than by loosening these flags.
- Parameterize the PRAGMA. Not available. SQLite rejects bound parameters there, and the suite now asserts it.
- Wrap the value in
int()at the call site. Measured; both rules still fire. It would also have left the misleading impression that a cast satisfied the linter. - Waive the four findings. Available under SEC-10, and legitimate for the two
SQLAlchemy findings, which are pattern noise from a framework this project does not
use. Rejected because the
formatted-sql-querypair is a fair reading of the code as written, and a fix that removes the construct is better than an expiring exception that a reader has to look up. # nosemgrepat the two lines. Same objection, with less visibility: an inline comment carries no expiry and no reviewer.- Keep
semgrep ciand addfetch-depth: 0. Might restore the baseline, might not — semgrep may run the fetch unconditionally, and the outcome cannot be verified outside GitHub Actions.semgrep scanremoves the baseline machinery instead of betting on it. - Keep
semgrep ciand add--no-suppress-errorsalone. Turns the silent pass into a loud failure, which is better, but leaves pull requests permanently red for a credential problem rather than scanning them.