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
79 changes: 78 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.40 on GitHub](https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.40),
The current public release is [v0.2.41 on GitHub](https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.41),
distributed from <https://codetruss.com/downloads/codetruss-cli-latest.json>.
The npm `latest` tag is still
[`@codetruss/cli@0.2.24`](https://www.npmjs.com/package/@codetruss/cli/v/0.2.24):
Expand All @@ -16,6 +16,83 @@ were superseded before distribution.

No unreleased changes.

## 0.2.41 — 2026-08-07

- **You can dismiss a finding you have judged wrong, in the place the judgement
belongs.** A `codetruss-ignore: <reason>` comment on a finding's own line, or
on a comment-only line directly above it, marks that finding as dismissed. A
marker trailing a line of code governs only that line, so it can never reach a
neighbouring finding its author never looked at. **A dismissal never deletes
anything.** The finding, its location, and the exact reason its author gave
all survive into the signed receipt under a "Suppressed findings" heading, and
the reader decides whether the reason is good — a receipt whose evidence could
be erased by editing a comment would not be evidence, and "nothing was found"
must never be reachable that way. The reason is mandatory for the same reason:
the reason *is* the output, and "someone decided this was fine" is not
evidence. A bare `codetruss-ignore` therefore dismisses nothing and is
reported by location, so a developer who wrote one finds out why it did
nothing. Receipts that dismissed nothing are unchanged, byte for byte.
- **Python SQL injection is now caught through a cursor held in a local.**
`cur = conn.cursor()` then `cur.execute(f"... {user_input}")` — the canonical
psycopg/sqlite3/MySQLdb two-step — was invisible, because the sink test was
lexical and `cur` does not read as a database receiver. The receiver is now
resolved to its binding, so a name bound to a `.cursor()` call counts however
it is spelled, including `with conn.cursor() as cur:`. `executemany` joins
`execute`; `exec` stays name-gated, since a bare `.exec()` is far more often
`RegExp.prototype.exec` than SQL. Generic receiver names were not loosened, so
nothing else lost precision.
- **A stalled grammar-pack download now fails with a sentence instead of
hanging.** `codetruss grammars install` is a foreground command someone is
watching, and `fetch` will wait out a server that writes one byte and holds
the socket open forever. Two clocks bound it — a whole-transfer budget and an
idle budget — and the reason it was abandoned survives into the error, rather
than the bare "This operation was aborted" an abort produces on its own. Both
are generous enough that a slow connection is never mistaken for a hostile
origin.
- **A grammar pack's artifacts are bound by role, not by name prefix.** The
loader picked the first file whose name started with `tree-sitter-`, so a pack
carrying an extra artifact ordered ahead of the real grammar would have had
the extra one loaded — and the pin verifier, which only proved each digest
appeared somewhere, would not have caught it. Each role must now be filled by
exactly one pinned artifact; an ambiguous pack does not resolve at all. A pack
that fails this reports a runtime failure rather than a digest failure, so a
defect in the CLI's own pin never publishes a receipt accusing the user's
install of tampering.
- **`SECURITY.md` now states what the grammar-pack digest pin does not cover.**
The pin protects against a compromised download origin, which is what it was
built for. It cannot protect against a compromised build: the pin, the
published artifact, and the offline check that compares them all derive from
the same `node_modules` on the release machine. `pnpm grammars:attest` narrows
that window — it checks the lockfile digest against the npm registry, verifies
the registry's signature, and compares the downloaded tarball against the
files the pack is cut from — and the document says plainly that it does not
close it.
- **`hooks doctor` names which fields drifted and what to run.** It reported
only that an installed handler "differs", which reads identically for a config
installed several versions ago and a deliberate hand-edit, and named no
remedy. It now lists the drifted field names — enough to diagnose, without
putting handler command text in the message — and names the reinstall command.
This repository's own committed `.codex/hooks.json` was the config that
exposed it: several versions stale, missing `core.longpaths=true` and pinned
to the old Stop timeout. It has been refreshed, and a test now compares the
committed hook configuration against what the installer actually writes, so it
cannot drift again unnoticed.
- **Build attestation is verified against the CodeTruss organisation.** The CLI
repository moved from the `DeliriumPulse` account, and every release still in
circulation has been re-attested under the organisation, so one command
verifies all of them: `gh attestation verify <artifact> --repo
CodeTruss/codetruss-cli`. The transferred `--repo DeliriumPulse/…` slug
returns HTTP 404 and is no longer advertised anywhere. The published manifest,
the verifier, and the verifier's own tests now derive that command from a
single module rather than each restating it; the Homebrew tap, plugin
marketplace and support links follow the organisation too.
- **Internal: `hooks.ts` is now seven modules behind an unchanged public
surface.** Installation, uninstallation, the doctor, the pre-commit block, the
agent handler shapes, the agent runner and executable resolution each have
their own file. No behaviour changed, and the hook tests are unmodified by
design — an unchanged test suite passing over a moved implementation is the
evidence that the move was only a move.

## 0.2.40 — 2026-08-07

- **Python can now be analyzed locally, if you ask for it.** `codetruss
Expand Down
43 changes: 43 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,49 @@ fix and disclosure timeline with the reporter. Good-faith research that avoids
privacy violations, service disruption, data destruction, and access beyond
what is needed to demonstrate the issue is welcome.

## Grammar packs: what the digest pin covers, and what it does not

`codetruss grammars install python` downloads code that this CLI later executes
in your process. Every artifact is pinned to an exact SHA-256 compiled into the
binary, checked as the bytes arrive and re-checked on every load, and the buffer
that was hashed is the buffer that is executed — nothing is re-opened by path.

That pin protects you against a **compromised download origin**. If codetruss.com
or anything between it and your machine serves different bytes, the install
fails and no Python is analyzed. It is a strong guarantee and it is the one the
pin was built for.

It does **not** protect you against a compromised **build**. The pin, the
published artifact, and the offline check that compares them are all derived
from the same `node_modules` on the machine that cut the release. Anything able
to write there between `pnpm install` and the release command would poison all
three in one move, and every check would still pass. A digest pin can only ever
say "these are the bytes we published"; it cannot say "these are the bytes we
meant to publish".

Two things narrow that window, and neither closes it:

- **`pnpm grammars:attest` (run by the release).** The tarball digest recorded
in `pnpm-lock.yaml` is checked against what the npm registry serves for that
exact version, the registry's ECDSA signature over the version/digest pair is
verified against npm's published keys, the tarball is downloaded and hashed,
and its files are compared against the `node_modules` copies the pack is cut
from. A file dropped into `node_modules` fails the release. This runs on the
release machine, so a compromise deep enough to patch the script is not caught
by the script.
- **`pnpm grammars:verify` (runs in every build).** Re-derives the whole
generated pin from the published artifacts and compares it byte for byte, and
checks that the versions a pack claims are the versions the lockfile pins. It
is an internal-consistency proof, not an independent one.

There is no reproducible build and no third-party rebuild of these artifacts.
Anyone can check the published bytes for themselves: each artifact is served
with a `.sha256` sidecar under `/downloads/grammars/`, the same digests appear
in the CLI's `src/grammar-pack-manifest.ts`, and the upstream packages
(`web-tree-sitter`, `tree-sitter-wasms`) are copied byte for byte with nothing
recompiled, so a published artifact can be diffed directly against the version
you install yourself.

## Scope

Security-sensitive surfaces include artifact/install integrity, receipt
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
"test:install": "pnpm --filter @codetruss/cli test:install",
"validate": "pnpm typecheck && pnpm release:artifact && pnpm test && pnpm release:verify && pnpm test:install"
},
"dependencies": {
"tree-sitter-wasms": "0.1.11",
"web-tree-sitter": "0.22.6"
},
"devDependencies": {
"@codetruss/analyzer-engine": "workspace:*",
"@codetruss/cli": "workspace:*"
Expand Down
1 change: 1 addition & 0 deletions packages/analyzer-engine/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './types'
export * from './registry'
export * from './runner'
export * from './suppression'
export * from './scoring'
export * from './coverage'
export * from './support'
Expand Down
75 changes: 72 additions & 3 deletions packages/analyzer-engine/src/security/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,73 @@ const CHILD_PROCESS_RECEIVER = /(^|[._])(child_?process|cp|proc|shell)$/i

const SQL_ALWAYS = new Set(['query', 'raw', 'unsafe', 'executequery', 'executeupdate', 'executesql', 'rawquery'])
/** Method names that execute SQL only on a DB-ish receiver. */
const SQL_GATED = new Set(['execute', 'exec'])
const SQL_GATED = new Set(['execute', 'exec', 'executemany'])
/** The DB-API cursor methods among {@link SQL_GATED}: for these the receiver may
* also be proven a cursor by its binding, not just by its name. `exec` stays
* name-gated — a bare `.exec()` is RegExp.prototype.exec far more often than
* it is SQL, and resolving its binding would buy nothing but scan time. */
const CURSOR_METHODS = new Set(['execute', 'executemany'])

/** A `<conn>.cursor()` call — the DB-API idiom that hands back a cursor. */
function isCursorFactoryCall(node: SyntaxNode, lang: SastLanguage): boolean {
const c = asCall(unwrap(node, lang), lang)
return !!c && !!c.receiver && lc(c.method) === 'cursor'
}

/**
* True when this call's receiver is a local BOUND to a DB cursor —
* `cur = conn.cursor()` or `with conn.cursor() as cur:` — rather than a name
* that merely reads DB-ish.
*
* {@link DB_RECEIVER} is a purely lexical test. It catches `cursor.execute(...)`
* and the direct chain `conn.cursor().execute(...)` (whose dotted receiver name
* is `conn.cursor`), but goes blind the moment the cursor is parked in a short
* local — `cur`, `c`, `crsr` — which is how most psycopg/sqlite3/MySQLdb code is
* actually written. Resolving the binding restores that canonical two-step form
* without loosening the lexical gate to generic names, which would cost
* precision everywhere else.
*/
function receiverBindsToCursor(call: NCall, lang: SastLanguage): boolean {
if (!call.receiver) return false
const name = identifierName(call.receiver, lang)
if (!name) return false
// Innermost enclosing scope that binds the name wins (program root last).
let scope: SyntaxNode | null = call.node.parent
for (let i = 0; i < 60 && scope; i++) {
const isRoot = scope.parent === null
if (isFunctionNode(scope, lang) || isRoot) {
const fn = isFunctionNode(scope, lang) ? asFunction(scope, lang) : null
const body = isRoot && !fn ? scope : fn?.body
if (body) {
const defs = [
...collectAssignments(body, lang).filter((a) => a.target === name).map((a) => a.value),
...withAliasDefs(body, name, lang),
]
if (defs.length > 0) return defs.some((d) => isCursorFactoryCall(d, lang))
}
}
scope = scope.parent
}
return false
}

/** `with <expr> as name:` bindings in a scope — python models them as an
* `as_pattern`, which {@link collectAssignments} does not treat as a def. */
function withAliasDefs(body: SyntaxNode, name: string, lang: SastLanguage): SyntaxNode[] {
const out: SyntaxNode[] = []
const visit = (node: SyntaxNode) => {
if (node !== body && isFunctionNode(node, lang)) return // separate scope
if (node.type === 'as_pattern') {
const alias = node.childForFieldName('alias')
const value = node.namedChildren[0]
const bound = alias ? identifierName(alias.namedChildren[0] ?? alias, lang) : null
if (value && bound === name) out.push(value)
}
for (const c of node.namedChildren) visit(c)
}
visit(body)
return out
}

// NOTE: generic receiver names like `client`/`session` are deliberately NOT here.
// They match Redis/DB/gRPC/GraphQL clients (`client.get(id)`), which are not HTTP
Expand Down Expand Up @@ -140,10 +206,13 @@ export const TAINT_SINKS: TaintSink[] = [
title: 'SQL injection',
message: 'Untrusted input is concatenated into a SQL query and executed. An attacker can alter the query to read or modify arbitrary data.',
remediation: 'Use parameterized queries / prepared statements and pass user input as bound parameters, never string concatenation.',
match(call) {
match(call, lang) {
const m = lc(call.method)
if (SQL_ALWAYS.has(m)) return [0]
if (SQL_GATED.has(m) && DB_RECEIVER.test(call.receiverName ?? '')) return [0]
if (SQL_GATED.has(m)) {
if (DB_RECEIVER.test(call.receiverName ?? '')) return [0]
if (CURSOR_METHODS.has(m) && receiverBindsToCursor(call, lang)) return [0]
}
// Go database/sql, gated on a DB-ish receiver. Context variants take
// (ctx, query, ...args) so the SQL string is argument 1, not 0.
if (DB_RECEIVER.test(call.receiverName ?? '')) {
Expand Down
105 changes: 105 additions & 0 deletions packages/analyzer-engine/src/suppression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { AnalyzerFinding, FindingSuppression, RepoIndex } from './types'

/**
* Inline finding suppression: `codetruss-ignore: <reason>`.
*
* A developer who has judged a finding wrong needs a way to say so in the one
* place the judgement belongs — beside the code it is about. The marker mirrors
* `gitleaks:allow` in placement and differs from it in two deliberate ways.
*
* FIRST, a reason is mandatory. The marker's entire output is a line of evidence
* on a signed receipt, and "someone decided this was fine" is not evidence. A
* bare `codetruss-ignore` therefore suppresses nothing and is recorded as
* rejected, so the developer learns why their comment did nothing: a marker that
* silently fails is worse than no marker at all.
*
* SECOND, nothing here deletes a finding. This pass ANNOTATES: the finding keeps
* flowing through the delta, the passes and the receipt, carrying the reason it
* was dismissed. A receipt that quietly dropped a finding because a comment in
* the repository told it to would be a hole in the evidence chain — the whole
* claim of the artifact is that it states what was and was not flagged, and
* "nothing was found" must never be reachable by editing a comment.
*/
const MARKER_RE = /\bcodetruss-ignore\b[ \t]*(:[ \t]*(.*))?/

/**
* Comment terminators that would otherwise be read as part of the reason.
* `/* codetruss-ignore: deliberate *\/` is how this gets written in JS, CSS,
* Java and C; in HTML and Markdown it is `<!-- ... -->`.
*/
const COMMENT_CLOSE_RE = /\s*(\*\/|-->)\s*$/

/**
* Reasons are quoted verbatim into a signed receipt that is also validated
* against a bounded schema on sync. A reason longer than this is not a reason,
* and the cap stops one pathological comment from making a receipt unsyncable.
*/
const MAX_REASON_LENGTH = 500

interface ParsedMarker {
/** Text after the colon. Empty when the marker gave no reason. */
reason: string
/**
* Nothing but whitespace and comment punctuation precedes the marker.
*
* A marker trailing a line of CODE was written about that code, so it governs
* only its own line. Letting it reach the line below would silently dismiss a
* neighbouring finding its author never looked at.
*/
commentOnly: boolean
}

function parseMarker(line: string | undefined): ParsedMarker | null {
if (line === undefined) return null
const match = MARKER_RE.exec(line)
if (!match) return null
const reason = match[2] === undefined ? '' : match[2].replace(COMMENT_CLOSE_RE, '').trim().slice(0, MAX_REASON_LENGTH)
return { reason, commentOnly: !/[A-Za-z0-9_$]/.test(line.slice(0, match.index)) }
}

/** The marker governing `line`: its own line, else a comment-only line above it. */
function markerFor(lines: string[], line: number): { reason: string; markerLine: number } | null {
const own = parseMarker(lines[line - 1])
if (own) return { reason: own.reason, markerLine: line }
const above = parseMarker(lines[line - 2])
if (above?.commentOnly) return { reason: above.reason, markerLine: line - 1 }
return null
}

/**
* Record on every finding whether an inline marker dismissed it.
*
* Returns new objects; the input is not mutated. Findings without BOTH a file
* and a line are returned untouched: a repository-level or whole-file finding
* has no line for a comment to sit beside, and picking one — the top of the
* file, the first match — would suppress by guesswork.
*/
export function annotateSuppressions(findings: AnalyzerFinding[], index: RepoIndex): AnalyzerFinding[] {
if (findings.length === 0) return findings
const lineCache = new Map<string, string[] | null>()
const readLines = (path: string): string[] | null => {
const cached = lineCache.get(path)
if (cached !== undefined) return cached
const file = index.files.find((candidate) => candidate.path === path)
// Generated files are read here for the reason the secret scanner reads
// them: what the line says governs, whichever tool wrote it.
const content = file ? file.content ?? file.excludedContent ?? null : null
const lines = content === null ? null : content.split('\n')
lineCache.set(path, lines)
return lines
}

return findings.map((finding) => {
if (!finding.filePath || !finding.line) return finding
const lines = readLines(finding.filePath)
if (!lines) return finding
const marker = markerFor(lines, finding.line)
if (!marker) return finding
const suppression: FindingSuppression = {
reason: marker.reason,
markerLine: marker.markerLine,
applied: marker.reason.length > 0,
}
return { ...finding, suppression }
})
}
Loading