Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 133 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
CodeTruss CLI follows semantic versioning. Release artifacts and their SHA-256
checksums are published at <https://codetruss.com/downloads/codetruss-cli-latest.json>.

The current public release is [v0.2.52 on GitHub](https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.52),
The current public release is [v0.2.53 on GitHub](https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.53),
distributed from <https://codetruss.com/downloads/codetruss-cli-latest.json>.
The npm `latest` tag is still
[`@codetruss/cli@0.2.50`](https://www.npmjs.com/package/@codetruss/cli/v/0.2.50):
Expand All @@ -16,6 +16,138 @@ were superseded before distribution.

No unreleased changes.

## 0.2.53 — 2026-08-08

We published what we missed. This release publishes what we got wrong.

A cross-tool benchmark ran CodeTruss 0.2.51 and Semgrep CE over **ten
repositories selected by a stated rule rather than by us** (top-starred, active,
size-bounded — `docs/benchmarks/cross-tool-2026-08`). It found false positives
that our own eight-repository sweep did not, including a CRITICAL. All three
fixes below were validated on that corpus, before and after, per repository and
per rule; the numbers are measured, not projected.

**Corpus result, published 0.2.51 → this release, same machine, same day.**
Total findings 898 → 904. Eight of the ten repositories are byte-identical;
`firecrawl/firecrawl` 155 → 154 and `louislam/uptime-kuma` 92 → 99 are the only
movements. Rule `sql-injection` 1 → 7. CRITICAL findings 3 → 2. HIGH-or-CRITICAL
security findings 77 → 58, of which the share sitting in test/fixture paths
falls from 62 (80.5%) to 37 (63.8%). Re-checked against the benchmark's 30
hand-adjudicated findings: **zero findings judged correct disappeared.**

- **A parameterized drizzle query was reported as CRITICAL SQL injection.**
`firecrawl/firecrawl` `apps/api/src/db/rpc.ts:98` is
``db.execute(sql`select … ${params.team_id} …`)``. A tagged template hands its
interpolations to the tag as separate bound values; they never enter the
string. We reported it CWE-89 CRITICAL with the words "untrusted input is
concatenated into a SQL query", which is the opposite of what the code does.

The rule already carried this exemption for Prisma — a source comment reading
"tagged `$queryRaw`/`$executeRaw` templates are parameterized by Prisma and
must NOT flag" — but expressed it as a method-name list, so drizzle got
nothing. The fix is the SHAPE, not the library: any tagged template literal in
a SQL sink's query position is the parameterized construction and is not
reported. drizzle's `sql`, postgres.js, slonik, `@vercel/postgres` and the
next library with the same shape are covered without another patch. An
**untagged** template literal in the same sink still fires, because that value
really is spliced into the string, and drizzle's documented escape hatch
`sql.raw()` is an ordinary call, so it remains a sink in its own right —
including nested inside a tagged template.

- **The same rule was silent on a genuinely dynamic query.**
`louislam/uptime-kuma` `server/monitor-types/postgres.js:63` is
`client.query(query, …)` where `query` is the enclosing method's parameter,
carrying the user-configured `monitor.databaseQuery`. Semgrep flagged it; our
pass reported nothing in that file. The cause was neither the sink list nor
the `.js` extension: a bare function parameter has never been a "real source",
so it could only ever be reported one hop later, at a call site in the same
file passing tainted data into it — and there is no such call site here.

SQL now reports it directly, at **HIGH** rather than CRITICAL, with a message
that says what is and is not known: this function executes the SQL text its
caller supplies, nothing in the file constrains it, and whether it is
injectable is decided by callers the analysis cannot see. Deliberately narrow,
and every narrowing below was forced by the corpus rather than guessed:

- The argument must BE the parameter, not a local built from one.
`firecrawl` `apps/api/src/services/worker/nuq.ts:1028` assembles its query
from a template two lines above; the file builds it in view, so
"supplied by the caller" would be false about it.
- The receiver must be database-shaped. `query` is a SQL sink on any receiver
once a request source has been traced into it; with only a parameter behind
the argument, the receiver is the whole case that this is a database —
`this.query(rightIndex)` in `trekhleb/javascript-algorithms`'
`FenwickTree.queryRange` is a prefix-sum lookup and fired twice before this
gate.
- Any same-file call site binding a constant or a tagged template to that
parameter suppresses it, which is what keeps firecrawl's
`execRows(db, sql`…`)` helper quiet.
- An anonymous enclosing function is skipped: the suppression resolves call
sites by name, so a nameless function could never be shown to be
constrained. Stated recall cost — this never reaches `const run = (sql) =>
pool.query(sql)`.

On the corpus this reports seven findings, all in uptime-kuma: the same
monitor shape repeated across its Postgres, MySQL, MSSQL and OracleDB drivers.
Semgrep found one of the four.

- **Synthetic credentials in test files were reported HIGH as committed leaks.**
43% of our security findings on this corpus sat in test/fixture paths against
Semgrep's 2%, and nine of the eleven hand-judged-incorrect findings were one
shape: `AKIA1234567890ABCDEF`, `ghp_abcdefghijklmnopqrstuvwxyz…`,
`sk-proj-Ab12_Cd34-Ef56…` flagged as credentials that "should be treated as
compromised". Three of them were inside a repository's unit tests **for its
own secret scanner**.

The test-context downgrade existed but reached only the fuzzy generic-password
pattern. It now reaches typed credentials — AWS, GitHub, Stripe, Anthropic,
OpenAI, Google, Slack — as a **conjunction**, never on the path alone: the file
must be a test AND the credential body must be a typed sequence rather than a
random draw. The predicate projects out the letters and the digits separately,
case-folded, and looks for a run of eight consecutive characters; that is what
catches `sk-proj-Ab12_Cd34-Ef56Gh78…`, whose letters alone spell the alphabet
while its digits alone count. It is measured the way the existing placeholder
matcher is: 20,000 random 40-character base62 bodies, zero hits, asserted in
the suite. A real key pasted into an `.e2e.ts` still reports HIGH, which a
path-only exemption wide enough to clear these fixtures would not.

Two patterns stay out on purpose. **Database URLs**, because a production DSN
pasted into a fixture is a common way a live credential reaches a public repo
— an earlier adjudication settled that and this release does not reopen it.
**Private key blocks**, because a committed PEM is a real key wherever it
sits; the corpus judged both of its PEM findings correct and both still fire.

Measured: 25 typed-credential HIGH findings in test paths became LOW "test
fixture resembling a secret". Two did not, and both are honest residuals
rather than fixes we withheld — `sk-admin-AAAA_BBBB-CCCC_DDDD-…` is a
repeated-block placeholder, not a monotone run, and
`sk-learning-rate-schedule-was-tuned-carefully` is the OpenAI pattern
over-matching hyphenated prose.

- **What this release does NOT fix, from the same corpus.** The two remaining
CRITICALs are `excalidraw`'s `.env.development:17` and `.env.production:17`,
both `VITE_APP_FIREBASE_CONFIG` web `apiKey` values that Google documents as
public client identifiers compiled into the browser bundle. The benchmark
judged them incorrect. They are unchanged here, and the honest reason is that
the fix is a different one — recognising publishable client identifiers, not a
test-path or value-shape rule — and it has not been designed or measured yet.

- **The analysis profile is `local-registry-v5`.** v4's block states that CWE-89
means "untrusted input tracked from request sources through string building
into query execution". That is no longer all the rule reports, so the wording
changed and the id changed with it. Every v4 receipt keeps rendering the
sentence it was signed with, byte for byte, from a frozen renderer.

- **Two source comments overstated what had been measured.** The CLI rule subset
was documented as "differentially validated … and adjudicated to zero false
positives", and the local pass as validated "at zero false positives". The
differential-parser half is true and stays. The zero-false-positive half was
true of a corpus we chose and is now falsified, so it is gone from both
comments and replaced with what happened. The published benchmark page, the
homepage, the comparison page and the benchmark blog post carry the same
correction: the eight-repository result stands as a result on those eight, and
no longer stands unqualified.

## 0.2.52 — 2026-08-08

Three corrections to published artifacts. No behaviour changes.
Expand Down
96 changes: 91 additions & 5 deletions packages/analyzer-engine/src/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,95 @@ const TEST_PATH_RE =
/(^|\/)(tests?|__tests__|__mocks__|fixtures|spec)\/|\.(test|spec)\.|_(test|spec)\.(go|py|rb|exs?)$|(^|\/)(test|spec)_[^/]+\.(py|rb)$/

/**
* Only the fuzzy generic-password pattern is eligible for the test-fixture
* Only the fuzzy generic-password pattern is eligible for the SEED-script
* downgrade: every other pattern matches an unambiguous production credential
* format, which is a real leak even when pasted into a test file.
* format, which is a real leak even when pasted into a seed file.
*/
const TEST_DOWNGRADEABLE = new Set(['Generic password assignment'])

/**
* Typed credential patterns that a test path may downgrade — but ONLY together
* with {@link hasSyntheticSequence}, never on the path alone.
*
* The path is not evidence. A real key pasted into `checkout.e2e.ts` is exactly
* the leak this analyzer exists to catch, and a path-only exemption wide enough
* to clear a repository's secret-scanner fixtures would clear that too. So the
* downgrade is a conjunction: the file has to be a test AND the value's body has
* to be a hand-typed sequence rather than a random draw.
*
* Two patterns are deliberately absent:
* - **Database URL with credentials.** A production DSN pasted into a fixture
* is one of the commonest ways a live credential reaches a public repo, and
* the host in it is the thing at risk. Adjudicated and kept out on purpose.
* - **Private key block.** A committed PEM is a real key wherever it sits; the
* cross-tool corpus judged both of these CORRECT (firecrawl's TLS-skip
* fixture, axios's `key.pem`) and they must keep firing.
*/
const TYPED_TEST_DOWNGRADEABLE = new Set([
'AWS access key',
'GitHub token',
'Stripe live secret key',
'Anthropic API key',
'OpenAI API key',
'Google API key',
'Slack token',
])

/**
* Consecutive characters, ascending or descending by one, before a value stops
* looking drawn at random. Nine is what `AKIA1234567890ABCDEF` gives (the digit
* run breaks at `9`→`0`), so the floor sits just below it.
*/
const SYNTHETIC_RUN = 8

/** Longest run of characters each exactly one step from the previous. */
function longestStepRun(chars: string): number {
let best = chars.length > 0 ? 1 : 0
let run = 1
let direction = 0
for (let i = 1; i < chars.length; i++) {
const step = chars.charCodeAt(i) - chars.charCodeAt(i - 1)
if (step === direction && (step === 1 || step === -1)) run++
else if (step === 1 || step === -1) {
direction = step
run = 2
} else {
direction = 0
run = 1
}
if (run > best) best = run
}
return best
}

/**
* Whether a credential body was TYPED rather than generated.
*
* Real credentials are random over their alphabet; the fixtures that flooded
* the cross-tool corpus were people walking the keyboard —
* `AKIA1234567890ABCDEF`, `ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ`,
* `sk-proj-Ab12_Cd34-Ef56Gh78…`. The letters and the digits are projected out
* separately and case-folded, because the third example interleaves them: its
* letters alone spell the alphabet while its digits alone count.
*
* Anchored the same way {@link FAKE_LITERAL_VALUE} is, and for the same reason
* — a predicate that silently downgrades a leak has to be measured, not
* assumed. A run of eight in a random base62 body needs seven consecutive
* successes at 1/26 (letters) or 1/10 (digits); the test suite draws 20,000
* bodies and requires zero hits.
*/
export function hasSyntheticSequence(value: string): boolean {
const letters = value.replace(/[^A-Za-z]/g, '').toLowerCase()
if (longestStepRun(letters) >= SYNTHETIC_RUN) return true
return longestStepRun(value.replace(/\D/g, '')) >= SYNTHETIC_RUN
}

/** Whether a match in a test path may be downgraded to LOW. */
function downgradeableInTests(credentialType: string, matched: string): boolean {
if (TEST_DOWNGRADEABLE.has(credentialType)) return true
return TYPED_TEST_DOWNGRADEABLE.has(credentialType) && hasSyntheticSequence(matched)
}

/**
* Database seed / fixture scripts. Their credentials are deliberate, documented
* dev defaults, so "treat as compromised, rotate immediately" is the wrong
Expand Down Expand Up @@ -281,7 +364,8 @@ export const secretsAnalyzer: Analyzer = {
}))
break
}
if (!isEnvFile && (messageString || (isTestContext && TEST_DOWNGRADEABLE.has(name)))) {
const typedFixture = isTestContext && TYPED_TEST_DOWNGRADEABLE.has(name)
if (!isEnvFile && (messageString || (isTestContext && downgradeableInTests(name, match[0])))) {
report(() => ({
category: 'SECURITY_HYGIENE',
severity: 'LOW',
Expand All @@ -290,15 +374,17 @@ export const secretsAnalyzer: Analyzer = {
: `Test fixture resembling a secret: ${name} in ${baseName}`,
description: messageString
? `Line ${i + 1} of ${file.path} assigns a credential-shaped key a value containing spaces, which reads as display text (a validation message, translation, or label) rather than a credential. Reported for awareness only — confirm no real passphrase was pasted here.`
: `Line ${i + 1} of ${file.path} contains a value shaped like a ${name}. It sits in test/fixture code and does not match a production key format, so it is most likely a fixture — but confirm no real credential was pasted.`,
: typedFixture
? `Line ${i + 1} of ${file.path} contains a value shaped like a ${name}. It sits in test/fixture code AND its body is a typed sequence (runs of consecutive letters or digits) rather than a random credential, so it is most likely a fixture — but confirm no real credential was pasted. A ${name} with a random body in this same file is still reported as a leak.`
: `Line ${i + 1} of ${file.path} contains a value shaped like a ${name}. It sits in test/fixture code and does not match a production key format, so it is most likely a fixture — but confirm no real credential was pasted.`,
filePath: file.path,
line: i + 1,
suggestion: messageString
? 'No action needed if this is user-facing copy. If a real passphrase was pasted here, rotate it and move it to environment configuration.'
: 'Use an obviously fake placeholder (e.g. "test-not-a-real-key") so scanners and reviewers can dismiss it at a glance.',
impactScore: messageString ? 10 : 25,
effort: 'low',
metadata: { credentialType: name, testContext: isTestContext, messageString },
metadata: { credentialType: name, testContext: isTestContext, messageString, ...(typedFixture ? { syntheticSequence: true } : {}) },
}))
} else {
// A concrete fix only where the evidence determines one: a tracked
Expand Down
Loading