diff --git a/CHANGELOG.md b/CHANGELOG.md index 68251c4..079373e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ CodeTruss CLI follows semantic versioning. Release artifacts and their SHA-256 checksums are published at . -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 . The npm `latest` tag is still [`@codetruss/cli@0.2.50`](https://www.npmjs.com/package/@codetruss/cli/v/0.2.50): @@ -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. diff --git a/packages/analyzer-engine/src/secrets.ts b/packages/analyzer-engine/src/secrets.ts index b1986ca..679626b 100644 --- a/packages/analyzer-engine/src/secrets.ts +++ b/packages/analyzer-engine/src/secrets.ts @@ -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 @@ -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', @@ -290,7 +374,9 @@ 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 @@ -298,7 +384,7 @@ export const secretsAnalyzer: Analyzer = { : '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 diff --git a/packages/analyzer-engine/src/security/engine.ts b/packages/analyzer-engine/src/security/engine.ts index f616de9..eebae1c 100644 --- a/packages/analyzer-engine/src/security/engine.ts +++ b/packages/analyzer-engine/src/security/engine.ts @@ -5,13 +5,24 @@ import { type SastParser, type SyntaxNode, } from './lang' -import { asCall, asFunction, urlHeadOf, walk, type NCall, type NFunc } from './normalize' +import { + asCall, + asFunction, + identifierName, + isTaggedTemplate, + stringLiteralValue, + urlHeadOf, + walk, + type NCall, + type NFunc, +} from './normalize' import { analyzeFunction, bindsToLocalFn, firstSource, hasRealSource, paramIndexes, + soleParamOrigin, taintBudgetExhausted, taintOf, type FunctionTaint, @@ -430,6 +441,8 @@ async function scanOne( }) const findings: SastFinding[] = [] + /** Sinks whose whole dangerous argument is a parameter — see {@link resolveCallerSupplied}. */ + const callerSupplied: CallerSuppliedCandidate[] = [] const enabled = (id: string) => !options.ruleIds || options.ruleIds.has(id) const applicableSinks = TAINT_SINKS.filter((s) => ruleAppliesTo(s, lang) && enabled(s.id)) const applicablePatterns = PATTERN_RULES.filter((p) => ruleAppliesTo(p, lang) && enabled(p.id)) @@ -501,6 +514,31 @@ async function scanOne( if (hasRealSource(origins)) { const src = firstSource(origins)! findings.push(makeTaintFinding(sink, lang, filePath, call, src.node, src.sourceKind, false, loc, lines)) + } else if (sink.callerSuppliedArg?.appliesTo(call, lang)) { + // The argument IS one of this function's parameters, named: not a + // local built from one. The distinction is the whole precision of + // this report and the corpus found it — firecrawl's + // `services/worker/nuq.ts:1028` passes a local `query` assembled + // from a template two lines above, whose only taint origin happens + // to be a parameter. Nobody supplies that string; the file builds + // it, in view, so "supplied by the caller" would be false about it. + // Requiring the identifier to be the parameter itself keeps this to + // the case where the file performs no query construction at all. + // + // Held back until every call site is known — a same-file caller + // binding a literal or a tagged template answers the question the + // finding would ask. + // + // An anonymous enclosing function is skipped outright. The + // suppression below resolves call sites BY NAME, so a nameless + // function can never be shown to be constrained and would report + // unconditionally — `const run = (sql) => pool.query(sql)` beside + // `run('SELECT 1')` is safe and would fire. Stated recall cost: + // this report never reaches an arrow assigned to a variable. + const param = soleParamOrigin(origins) + if (param && rec.fn.name !== ANONYMOUS_FN && identifierName(arg, lang) === param.name) { + callerSupplied.push({ sink, call, fn: rec.fn, paramIndex: param.index, paramName: param.name }) + } } for (const pi of paramIndexes(origins)) { if (!rec.sinkParams.has(pi)) rec.sinkParams.set(pi, { sink, node: call.node, line: call.line }) @@ -542,6 +580,11 @@ async function scanOne( } } + // ---- caller-supplied SQL text ---- + if (callerSupplied.length > 0 && !expired()) { + resolveCallerSupplied(callerSupplied, parsed.rootNode, lang, filePath, findings, loc, lines, expired) + } + // ---- pattern rules (single pass over the whole tree) ---- runPatternRules(parsed.rootNode, applicablePatterns, lang, filePath, findings, lines, expired) @@ -558,6 +601,110 @@ async function scanOne( return { findings, truncated, timeCapped } } +/** What {@link asFunction} names a function whose node carries no `name` field. */ +const ANONYMOUS_FN = '' + +/** A sink argument that is exactly one of the enclosing function's parameters. */ +interface CallerSuppliedCandidate { + sink: TaintSink + call: NCall + fn: NFunc + paramIndex: number + paramName: string +} + +/** + * Decide which caller-supplied-argument candidates survive, and report them. + * + * The question a candidate asks is "what can a caller put here?", so the answer + * lives at the call sites. One same-file call binding a safe construction — + * a string literal, or a tagged template whose interpolations are bound values — + * shows the file DOES constrain the parameter, and the candidate is dropped. + * A function nothing in this file calls keeps its finding: its callers are + * outside the translation unit, which is precisely why the value is unknown. + * + * Call sites are matched by name under the same rule as the interprocedural hop + * ({@link bindsToLocalFn}): a direct call, or `this`/`self`. A member call on + * any other receiver is a different function that merely shares a name. + */ +function resolveCallerSupplied( + candidates: CallerSuppliedCandidate[], + root: SyntaxNode, + lang: SastLanguage, + filePath: string, + findings: SastFinding[], + loc: (n: SyntaxNode, label: string) => CodeLocation & { label: string }, + lines: string[], + expired: () => boolean, +): void { + const wanted = new Set(candidates.map((c) => c.fn.name)) + /** `${fnName}#${paramIndex}` for every parameter a call site binds safely. */ + const constrained = new Set() + walk(root, (node) => { + if (expired()) return + const call = asCall(node, lang) + if (!call || call.isConstruct || !wanted.has(call.method) || !bindsToLocalFn(call)) return + call.args.forEach((arg, i) => { + if (stringLiteralValue(arg, lang) !== null || isTaggedTemplate(arg, lang)) { + constrained.add(`${call.method}#${i}`) + } + }) + }) + // A drained clock means the call-site sweep is incomplete, so "no call site + // constrains it" is unproven. Report nothing rather than report on a partial + // answer; the file is already marked truncated. + if (expired()) return + const seen = new Set() + for (const candidate of candidates) { + if (findings.length >= MAX_FINDINGS_PER_FILE) return + if (constrained.has(`${candidate.fn.name}#${candidate.paramIndex}`)) continue + if (seen.has(candidate.call.node.id)) continue + seen.add(candidate.call.node.id) + findings.push(makeCallerSuppliedFinding(candidate, lang, filePath, loc, lines)) + } +} + +function makeCallerSuppliedFinding( + candidate: CallerSuppliedCandidate, + lang: SastLanguage, + filePath: string, + loc: (n: SyntaxNode, label: string) => CodeLocation & { label: string }, + lines: string[], +): SastFinding { + const { sink, call, fn, paramName } = candidate + const report = sink.callerSuppliedArg! + const info = CWE[sink.cweKey] + const source = loc(fn.node, `${fn.name}(${paramName}) — supplied by the caller`) + const sinkLoc = loc(call.node, `${call.fullName}()`) + return { + ruleId: sink.id, + kind: 'taint', + cwe: info.cwe, + owasp: info.owasp, + severity: report.severity, + title: sink.title, + message: report.message(fn.name, paramName), + language: lang, + filePath, + line: call.line, + column: call.node.startPosition.column + 1, + flow: { + source, + sink: sinkLoc, + steps: [source, sinkLoc], + summary: `${fn.name}(${paramName}) → ${call.fullName}()`, + interprocedural: false, + }, + remediation: report.remediation, + metadata: sortMeta({ + callerSupplied: true, + parameter: paramName, + sink: call.fullName, + snippet: snippetAt(lines, call.line - 1), + }), + } +} + /** Pattern-rule pass over a tree. Cheap (no taint), so it always runs even when * the taint solve is skipped under memory pressure. */ function runPatternRules( diff --git a/packages/analyzer-engine/src/security/local-profile.ts b/packages/analyzer-engine/src/security/local-profile.ts index 8712233..fe6344a 100644 --- a/packages/analyzer-engine/src/security/local-profile.ts +++ b/packages/analyzer-engine/src/security/local-profile.ts @@ -7,8 +7,19 @@ import type { SastLanguage } from './lang' * deliberate SUBSET, and the subset is a precision decision, not a packaging * one: every rule listed here has been differentially validated — same rule, * same file, zero disagreement against the hosted tree-sitter parser — across - * the real-repository corpus, and adjudicated to zero false positives. A rule - * earns its way into this list; it is not added because it compiles. + * the real-repository corpus. A rule earns its way into this list; it is not + * added because it compiles. + * + * CORRECTION (0.2.53). This comment used to end "and adjudicated to zero false + * positives". That was true of the eight-repository corpus it was written + * against and false as a general claim, and the difference mattered: on a + * ten-repository corpus nobody here chose, `sql-injection` reported drizzle's + * `db.execute(sql`…`)` — bound parameters, the documented safe construction — + * as CRITICAL "untrusted input is concatenated", and missed a genuinely dynamic + * `client.query(query)` in the same run. Both are fixed. The claim this comment + * now makes is the narrower one it can support: differential parser validation, + * plus whatever the published corpus measurements say on the day you read them. + * A zero adjudicated on a corpus we picked is a property of the corpus. * * The five AI-agent defect rules are the classes coding agents actually get * wrong (floating writes, swallowed errors, coercion comparisons, N+1 loops, diff --git a/packages/analyzer-engine/src/security/normalize.ts b/packages/analyzer-engine/src/security/normalize.ts index 191ea75..b389818 100644 --- a/packages/analyzer-engine/src/security/normalize.ts +++ b/packages/analyzer-engine/src/security/normalize.ts @@ -209,6 +209,32 @@ export interface NCall { isConstruct: boolean } +/** + * True when the expression is a TAGGED TEMPLATE LITERAL — ``tag`…${x}…` ``. + * + * The distinction from a plain template literal is structural, not lexical: a + * tagged template hands the static text and each interpolated value to the tag + * as SEPARATE arguments. The values never enter the string, so the tag — not + * the caller — decides how they are combined. For a query builder that is + * exactly parameter binding, which is why this shape is the *safe* + * construction of every SQL library that offers it (drizzle's `sql`, Prisma's + * `$queryRaw`, postgres.js, slonik, `@vercel/postgres`) and why an untagged + * ``db.query(`… ${x}`)`` — where the value IS concatenated into the string — + * is not this shape and keeps its finding. + * + * Expressed as a shape rather than a list of tag names so a library we have + * never heard of is covered without another patch. The residual unsoundness is + * stated: a tag that concatenates rather than binds would be exempted too. No + * SQL library does that, and the escape hatches that exist for it are named + * calls (`sql.raw`, `$queryRawUnsafe`) which remain sinks in their own right. + */ +export function isTaggedTemplate(node: SyntaxNode, lang: SastLanguage): boolean { + if (!isJsFamily(lang)) return false + const n = unwrap(node, lang) + if (n.type !== 'call_expression') return false + return field(n, 'arguments')?.type === 'template_string' +} + const CALL_TYPES: Record> = { javascript: new Set(['call_expression', 'new_expression']), typescript: new Set(['call_expression', 'new_expression']), diff --git a/packages/analyzer-engine/src/security/rules.ts b/packages/analyzer-engine/src/security/rules.ts index ead118f..9bf2c2a 100644 --- a/packages/analyzer-engine/src/security/rules.ts +++ b/packages/analyzer-engine/src/security/rules.ts @@ -12,6 +12,7 @@ import { identifierName, interpolationExprs, isFunctionNode, + isTaggedTemplate, numberLiteralValue, stringLiteralValue, subscriptBase, @@ -59,6 +60,36 @@ export interface TaintSink extends RuleMeta { * later path/query segments of a constant-authority string. */ taintPosition?: 'head' + /** + * Report a dangerous argument that is exactly one of the enclosing function's + * PARAMETERS, with no untrusted source behind it. + * + * Off for every sink but SQL, and deliberately so. A parameter reaching a + * sink is normally not a finding — the analysis cannot see the call sites, so + * `readFile(name)` in a helper says nothing. SQL is the exception because the + * argument is not data but the *program being executed*: a function whose + * whole query text comes from its caller has no constraint on what runs, and + * nothing later in the file can add one. That is what the ten-repository + * corpus caught us missing on `uptime-kuma/server/monitor-types/postgres.js`. + * + * The engine still suppresses it when any same-file call site binds a safe + * construction (a string literal or a tagged template) to that parameter, + * because then the file DOES show what runs. + */ + callerSuppliedArg?: { + severity: Severity + /** + * Positive evidence that this call really is the sink, required because + * there is no taint flow to supply it. {@link match} may be generous about + * a method name when a request source has already been traced into it; with + * only a parameter behind the argument the name is all there is, and + * `this.query(rightIndex)` in a Fenwick tree is not a database. + */ + appliesTo(call: NCall, lang: SastLanguage): boolean + /** Prose for the finding, given the function and parameter names. */ + message(fnName: string, paramName: string): string + remediation: string + } } export interface PatternHit { @@ -207,6 +238,29 @@ function optionIsTrue(call: NCall, lang: SastLanguage, name: string): boolean { // TAINT SINKS // --------------------------------------------------------------------------- +/** Argument indexes of a call that carry SQL TEXT, before shape filtering. */ +function sqlSinkArgs(call: NCall, lang: SastLanguage): number[] | null { + const m = lc(call.method) + if (SQL_ALWAYS.has(m)) 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 ?? '')) { + if (m === 'queryrow') return [0] + if (m === 'querycontext' || m === 'queryrowcontext' || m === 'execcontext') return [1] + } + // Prisma raw escape hatches. Only the Unsafe variants take a plain SQL + // string — tagged $queryRaw/$executeRaw templates are parameterized by + // Prisma and must NOT flag. (asCall strips the leading $, so match both.) + if (/^\$?(query|execute)rawunsafe$/.test(m)) return [0] + // C#/Java: new SqlCommand(sql) / new Statement(sql) + if (call.isConstruct && /sqlcommand|npgsqlcommand|mysqlcommand|oledbcommand/i.test(call.fullName)) return [0] + return null +} + export const TAINT_SINKS: TaintSink[] = [ // ---- SQL injection ---- { @@ -217,26 +271,30 @@ 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.', + callerSuppliedArg: { + severity: 'HIGH', + // `query`/`raw`/`exec` are SQL sinks on any receiver once a request source + // has been traced into them. With only a parameter behind the argument + // the receiver is the entire case that this is a database at all, and the + // ten-repository corpus made the point: `this.query(rightIndex)` inside + // `FenwickTree.queryRange` is a prefix-sum lookup, reported twice before + // this gate and correctly silent after it. + appliesTo: (call) => DB_RECEIVER.test(call.receiverName ?? ''), + message: (fnName, paramName) => + `${fnName}() executes the SQL text it receives in its \`${paramName}\` parameter. The query is not built in this file, so nothing here constrains what a caller can run, and no call site in this file binds a constant or a parameterized template to it. Whether this is injectable is decided entirely by callers the analysis cannot see.`, + remediation: + 'Keep the query text a constant inside this function and accept only bound parameters from callers. If callers genuinely must choose the statement, validate their input against an allow-list of known queries rather than executing it verbatim.', + }, match(call, lang) { - const m = lc(call.method) - if (SQL_ALWAYS.has(m)) 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 ?? '')) { - if (m === 'queryrow') return [0] - if (m === 'querycontext' || m === 'queryrowcontext' || m === 'execcontext') return [1] - } - // Prisma raw escape hatches. Only the Unsafe variants take a plain SQL - // string — tagged $queryRaw/$executeRaw templates are parameterized by - // Prisma and must NOT flag. (asCall strips the leading $, so match both.) - if (/^\$?(query|execute)rawunsafe$/.test(m)) return [0] - // C#/Java: new SqlCommand(sql) / new Statement(sql) - if (call.isConstruct && /sqlcommand|npgsqlcommand|mysqlcommand|oledbcommand/i.test(call.fullName)) return [0] - return null + const idxs = sqlSinkArgs(call, lang) + // A tagged template hands its interpolations to the tag as bound values + // instead of splicing them into the string, so it is the parameterized + // construction, not concatenation — the exemption Prisma's `$queryRaw` + // already had inside `sqlSinkArgs`, expressed as the SHAPE so drizzle's + // `sql`, slonik, postgres.js and the next library to adopt it are too. The + // escape hatches (`sql.raw`, `$queryRawUnsafe`) are ordinary calls, not + // tagged templates, so they still match — including nested inside one. + return idxs && idxs.filter((i) => !call.args[i] || !isTaggedTemplate(call.args[i], lang)) }, }, diff --git a/packages/analyzer-engine/src/security/taint.ts b/packages/analyzer-engine/src/security/taint.ts index fb7fcea..3f4a1a2 100644 --- a/packages/analyzer-engine/src/security/taint.ts +++ b/packages/analyzer-engine/src/security/taint.ts @@ -520,6 +520,20 @@ export function paramIndexes(origins: Origins): number[] { return origins.filter((o): o is Extract => o.kind === 'param').map((o) => o.index) } +/** + * The single parameter an expression came from, when that is ALL it came from. + * + * Returns null the moment anything else is mixed in — a real source, a second + * parameter — because the caller of this (the caller-supplied-argument report) + * is making a claim about provenance, not about danger: "this value is exactly + * what one caller passed". A merged value is not that. + */ +export function soleParamOrigin(origins: Origins): Extract | null { + if (origins.length !== 1) return null + const only = origins[0] + return only.kind === 'param' ? only : null +} + /** First real source origin, for flow reporting. */ export function firstSource(origins: Origins): Extract | null { return origins.find((o): o is Extract => o.kind === 'source') ?? null diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 6e930fa..3d6b227 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -5,6 +5,138 @@ checksums are published at + 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. diff --git a/packages/cli/package.json b/packages/cli/package.json index 3a26b2d..0ac2ccc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@codetruss/cli", - "version": "0.2.52", + "version": "0.2.53", "description": "Local-first scope, quality, and verification receipts for coding agents", "license": "SEE LICENSE IN LICENSE", "type": "module", diff --git a/packages/cli/src/local-sast.ts b/packages/cli/src/local-sast.ts index af12f88..76bce90 100644 --- a/packages/cli/src/local-sast.ts +++ b/packages/cli/src/local-sast.ts @@ -18,8 +18,10 @@ import { loadGrammarParser } from './grammar-parser.js' * * Same engine, same rules, same taint solver as the hosted audit — behind a * zero-dependency parser instead of 6 MB of WASM grammars, and restricted to the - * rule subset that has been differentially validated against the hosted parser - * at zero false positives. + * rule subset that has been differentially validated against the hosted parser: + * same rule, same file, zero disagreement. That is a PARSER-parity claim and + * this comment used to overstate it as "at zero false positives"; see the + * correction in {@link CLI_SAST_RULE_IDS}. * * Since 0.2.40 the pass has a SECOND parser: an opt-in grammar pack the user * installs explicitly (`codetruss grammars install python`). When present and diff --git a/packages/cli/src/receipt.ts b/packages/cli/src/receipt.ts index a2204d0..1735a96 100644 --- a/packages/cli/src/receipt.ts +++ b/packages/cli/src/receipt.ts @@ -105,6 +105,7 @@ function analysisProfileLines(receipt: Receipt): string[] { if (current.id === 'local-registry-v1') return omittedSastProfileLines(receipt, current.id) if (current.id === 'local-registry-v2') return thirteenAnalyzerProfileLines(receipt, current.id) if (current.id === 'local-registry-v3') return jsOnlySastProfileLines(receipt, current.id) + if (current.id === 'local-registry-v4') return requestSourceSqlProfileLines(receipt, current.id) const python = pythonCoverage(receipt) return [ @@ -116,6 +117,52 @@ function analysisProfileLines(receipt: Receipt): string[] { '', '### What the local security pass checked', '', + '- **SQL injection (CWE-89).** Untrusted input tracked from request sources through string building into query execution. Separately, and at HIGH rather than CRITICAL, a query whose entire text is one of the enclosing function\'s parameters: nothing in that file constrains it and no call site in that file binds it to a constant or a parameterized template, so whether it is injectable is decided by callers this pass cannot see.', + '- **Mass assignment (CWE-915).** A raw request body spread into a database write, and write helpers whose payload type accepts arbitrary keys.', + '- **Un-awaited database writes, swallowed errors, coercion-prone `==` comparisons, and N+1 queries in loops** — the defect classes coding agents most often introduce.', + ...(python.analyzed ? [ + `- **The complete rule pack over ${python.scanned} Python file(s).** The installed grammar pack is the same \`web-tree-sitter\` runtime and the same compiled grammar the hosted audit loads, so Python here was checked by the hosted machinery rather than an approximation of it — including the injection, traversal, SSRF and deserialization classes the JavaScript subset below omits.`, + ] : []), + '', + '### What did not run', + '', + `- **The rest of the security rule pack${python.analyzed ? ', for JavaScript, TypeScript and TSX' : ''}.** Command injection, code injection, path traversal, SSRF, open redirect, XSS and insecure deserialization were **not** checked ${python.analyzed ? 'in those languages' : 'here'}. Those rules run in a hosted scan; absence of a finding in those classes means they were not analyzed, not that the code is clean.`, + ...pythonDisclosureLines(python), + '- **Hosted symbol graph.** No cross-file call or data-flow graph was built, so architecture and dead-code conclusions cover only what the local passes can see in isolation.', + '- **Abstraction-shape analysis.** Single-implementation interfaces, options nobody overrides, and parameters never varied at any call site were not checked. They require the cross-file symbol graph, which does not run locally. This receipt says nothing either way about those shapes.', + ...(receipt.llm ? [] : [ + '- **Optional LLM review.** No model read this diff. It is opt-in via `--llm` and is force-disabled under agent hooks, so a hook receipt is always deterministic evidence only.', + ]), + '- **Hosted Health scores.** Not calculated, reported as **N/A**. The scores are defined over the graph and the complete SAST pass; a number derived from this pass set would overstate what ran.', + '', + 'Local security findings are reported for review and do not fail the verdict on their own.', + '', + 'A PASS verdict means the passes listed above never ran and the passes that did run found nothing new. It is not a statement that this change is secure.', + '', + '[Run a hosted full audit](https://codetruss.com/dashboard/repos/new?source=cli-receipt).', + ] +} + +/** + * The frozen `local-registry-v4` block. + * + * Byte-identical to what CLI 0.2.40–0.2.52 signed. v5 supersedes it because the + * SQL bullet became untrue: those releases reported CWE-89 only for taint + * traced from a request source, and 0.2.53 also reports a query whose whole text + * is a caller-supplied parameter. A receipt signed by one of those releases must + * keep rendering the sentence it was signed with. + */ +function requestSourceSqlProfileLines(receipt: Receipt, profileId: string): string[] { + const python = pythonCoverage(receipt) + return [ + '## Analysis profile', + '', + `Profile: \`${profileId}\`.`, + '', + `The 15 deterministic registry analyzers ran locally on this machine, plus a local security pass: the shared SAST engine — the same rules and the same source-to-sink taint tracking as the hosted audit — over the ${python.analyzed ? 'JavaScript, TypeScript, TSX and Python' : 'JavaScript, TypeScript and TSX'} in this repository.`, + '', + '### What the local security pass checked', + '', '- **SQL injection (CWE-89).** Untrusted input tracked from request sources through string building into query execution.', '- **Mass assignment (CWE-915).** A raw request body spread into a database write, and write helpers whose payload type accepts arbitrary keys.', '- **Un-awaited database writes, swallowed errors, coercion-prone `==` comparisons, and N+1 queries in loops** — the defect classes coding agents most often introduce.', diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 1cb4ae0..d6be2de 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -33,7 +33,16 @@ export const MAX_LLM_DIFF_BYTES = 2_000_000 * Honest local-analysis contract: which passes ran on this machine, which did * not, and whether scores may be inferred. * - * `local-registry-v4` supersedes `local-registry-v3`. The pass SET is identical + * `local-registry-v5` supersedes `local-registry-v4`. The pass SET is identical + * again, but v4's SQL bullet says CWE-89 means "untrusted input tracked from + * request sources through string building into query execution", and since + * 0.2.53 that is not the whole of what the rule reports: a query whose entire + * text is a caller-supplied parameter is reported too, at HIGH, with no request + * source behind it. A receipt that under-describes what its own pass can say is + * the failure this block exists to prevent, so the wording changed and the id + * changed with it. + * + * `local-registry-v4` had superseded `local-registry-v3`. The pass SET is identical * — fifteen registry analyzers, the local security pass, no graph — but v3's * block states flatly that the local pass "covers JavaScript, TypeScript and * TSX only" and that Python "received no security rule or taint analysis". With @@ -51,7 +60,7 @@ export const MAX_LLM_DIFF_BYTES = 2_000_000 * with. Every superseded version keeps a frozen renderer in `receipt.ts`. */ export const LOCAL_ANALYSIS_PROFILE = { - id: 'local-registry-v4', + id: 'local-registry-v5', omittedPasses: ['graph'], localPasses: ['local-sast'], scoreStatus: 'not-computed', @@ -78,11 +87,19 @@ export interface LegacyLocalAnalysisProfileV3 { localPasses: readonly ['local-sast'] scoreStatus: 'not-computed' } +/** The v4 shape, retained so request-source-only-SQL receipts still parse. */ +export interface LegacyLocalAnalysisProfileV4 { + id: 'local-registry-v4' + omittedPasses: readonly ['graph'] + localPasses: readonly ['local-sast'] + scoreStatus: 'not-computed' +} export type AnyLocalAnalysisProfile = | LocalAnalysisProfile | LegacyLocalAnalysisProfileV1 | LegacyLocalAnalysisProfileV2 | LegacyLocalAnalysisProfileV3 + | LegacyLocalAnalysisProfileV4 export interface CliConfig { version: 1 diff --git a/packages/cli/test/receipt.test.ts b/packages/cli/test/receipt.test.ts index 26bd1e4..bf4c0c2 100644 --- a/packages/cli/test/receipt.test.ts +++ b/packages/cli/test/receipt.test.ts @@ -125,7 +125,7 @@ describe('signed receipts', () => { await expect(verifyReceipt(dir, receipt.sessionId)).resolves.toMatchObject({ verdict: 'PASS' }) const markdown = await readFile(paths.markdown, 'utf8') expect(markdown).toContain('Policy SHA-256') - expect(markdown).toContain('Profile: `local-registry-v4`') + expect(markdown).toContain('Profile: `local-registry-v5`') expect(markdown).not.toContain('Final scores:') await writeFile(paths.markdown, `${await readFile(paths.markdown, 'utf8')}tampered`) await expect(verifyReceipt(dir, receipt.sessionId)).rejects.toThrow('Markdown receipt does not match') @@ -226,7 +226,7 @@ describe('signed receipts', () => { it('states the new registry count and the abstraction-shape limit on a current receipt', () => { const markdown = renderMarkdown(fixture('/tmp/repo')) - expect(markdown).toContain('Profile: `local-registry-v4`') + expect(markdown).toContain('Profile: `local-registry-v5`') expect(markdown).toContain('The 15 deterministic registry analyzers ran locally on this machine') expect(markdown).toContain('**Abstraction-shape analysis.**') expect(markdown).toContain('says nothing either way about those shapes') diff --git a/packages/cli/test/verify-receipt.test.ts b/packages/cli/test/verify-receipt.test.ts index 60499aa..3ffadf4 100644 --- a/packages/cli/test/verify-receipt.test.ts +++ b/packages/cli/test/verify-receipt.test.ts @@ -24,6 +24,7 @@ const FROZEN_MARKDOWN_SHA256: Record = { 'local-registry-v2': '113d62dee72cce21ca004ce3bf748cc8ff08a8bcceacfdc65100c5b21487dea2', 'local-registry-v3': 'f38a8bde572f2c83ddcd76e167762acc80c0e603d2e45a43d3b4dd86aef1579a', 'local-registry-v4': '288de3e266dd208735854a35ec467caa44307414121020e062eec58e808cb66b', + 'local-registry-v5': '6ae337ce26c127531cb97aa5418f64fa2681898228c3a53ba25eb2777b9e1703', } /** Every analysis profile whose Markdown wording is frozen inside signed receipts. */ @@ -31,7 +32,8 @@ const FROZEN_PROFILES = [ { id: 'local-registry-v1', profile: { id: 'local-registry-v1', omittedPasses: ['graph', 'sast'], scoreStatus: 'not-computed' } }, { id: 'local-registry-v2', profile: { id: 'local-registry-v2', omittedPasses: ['graph'], localPasses: ['local-sast'], scoreStatus: 'not-computed' } }, { id: 'local-registry-v3', profile: { id: 'local-registry-v3', omittedPasses: ['graph'], localPasses: ['local-sast'], scoreStatus: 'not-computed' } }, - { id: 'local-registry-v4', profile: LOCAL_ANALYSIS_PROFILE }, + { id: 'local-registry-v4', profile: { id: 'local-registry-v4', omittedPasses: ['graph'], localPasses: ['local-sast'], scoreStatus: 'not-computed' } }, + { id: 'local-registry-v5', profile: LOCAL_ANALYSIS_PROFILE }, ] as const function fixture(profile: unknown = LOCAL_ANALYSIS_PROFILE, patch = 'diff evidence'): Receipt { diff --git a/public/downloads/codetruss-cli-0.2.53.sbom.cdx.json b/public/downloads/codetruss-cli-0.2.53.sbom.cdx.json new file mode 100644 index 0000000..17b62a2 --- /dev/null +++ b/public/downloads/codetruss-cli-0.2.53.sbom.cdx.json @@ -0,0 +1,170 @@ +{ + "$schema": "https://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "serialNumber": "urn:uuid:6d901377-79c0-50cd-84dd-46fc5c7c62c0", + "specVersion": "1.6", + "version": 1, + "metadata": { + "component": { + "type": "application", + "bom-ref": "pkg:npm/%40codetruss/cli@0.2.53", + "name": "@codetruss/cli", + "version": "0.2.53", + "description": "Local-first scope, quality, and verification receipts for coding agents", + "licenses": [ + { + "license": { + "name": "CodeTruss CLI Proprietary License" + } + } + ], + "purl": "pkg:npm/%40codetruss/cli@0.2.53" + }, + "properties": [ + { + "name": "codetruss:distribution", + "value": "single-file JavaScript bundle" + }, + { + "name": "codetruss:runtimeDependencies", + "value": "0" + } + ] + }, + "components": [ + { + "type": "library", + "bom-ref": "pkg:npm/%40codetruss/analyzer-engine@0.1.0", + "name": "@codetruss/analyzer-engine", + "version": "0.1.0", + "licenses": [ + { + "license": { + "name": "CodeTruss CLI Proprietary License" + } + } + ], + "purl": "pkg:npm/%40codetruss/analyzer-engine@0.1.0", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/balanced-match@4.0.4", + "name": "balanced-match", + "version": "4.0.4", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "purl": "pkg:npm/balanced-match@4.0.4", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/brace-expansion@5.0.9", + "name": "brace-expansion", + "version": "5.0.9", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "purl": "pkg:npm/brace-expansion@5.0.9", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/minimatch@10.2.6", + "name": "minimatch", + "version": "10.2.6", + "licenses": [ + { + "license": { + "id": "BlueOak-1.0.0" + } + } + ], + "purl": "pkg:npm/minimatch@10.2.6", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/yaml@2.9.0", + "name": "yaml", + "version": "2.9.0", + "licenses": [ + { + "license": { + "id": "ISC" + } + } + ], + "purl": "pkg:npm/yaml@2.9.0", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/%40codetruss/analyzer-engine@0.1.0", + "dependsOn": [] + }, + { + "ref": "pkg:npm/%40codetruss/cli@0.2.53", + "dependsOn": [ + "pkg:npm/%40codetruss/analyzer-engine@0.1.0", + "pkg:npm/minimatch@10.2.6", + "pkg:npm/yaml@2.9.0" + ] + }, + { + "ref": "pkg:npm/balanced-match@4.0.4", + "dependsOn": [] + }, + { + "ref": "pkg:npm/brace-expansion@5.0.9", + "dependsOn": [ + "pkg:npm/balanced-match@4.0.4" + ] + }, + { + "ref": "pkg:npm/minimatch@10.2.6", + "dependsOn": [ + "pkg:npm/brace-expansion@5.0.9" + ] + }, + { + "ref": "pkg:npm/yaml@2.9.0", + "dependsOn": [] + } + ] +} diff --git a/public/downloads/codetruss-cli-0.2.53.tgz b/public/downloads/codetruss-cli-0.2.53.tgz new file mode 100644 index 0000000..cc6ebfd Binary files /dev/null and b/public/downloads/codetruss-cli-0.2.53.tgz differ diff --git a/public/downloads/codetruss-cli-0.2.53.tgz.sha256 b/public/downloads/codetruss-cli-0.2.53.tgz.sha256 new file mode 100644 index 0000000..2cc7d3d --- /dev/null +++ b/public/downloads/codetruss-cli-0.2.53.tgz.sha256 @@ -0,0 +1 @@ +5344ab8e32c5ccd0093d17088c520aa685b988423a0890de29cbaac1c96c2aca codetruss-cli-0.2.53.tgz diff --git a/public/downloads/codetruss-cli-latest.json b/public/downloads/codetruss-cli-latest.json index fc61468..f8f72f3 100644 --- a/public/downloads/codetruss-cli-latest.json +++ b/public/downloads/codetruss-cli-latest.json @@ -1,13 +1,13 @@ { "name": "@codetruss/cli", - "version": "0.2.52", - "url": "/downloads/codetruss-cli-0.2.52.tgz", + "version": "0.2.53", + "url": "/downloads/codetruss-cli-0.2.53.tgz", "latestUrl": "/downloads/codetruss-cli-latest.tgz", - "sha256": "5afc2a563358f1f7dd41eaf2484dc7913e326d9e7072f0d8c5bdeb1bb77792dd", - "sbomUrl": "/downloads/codetruss-cli-0.2.52.sbom.cdx.json", - "sbomSha256": "0667253d5059b5aa5373c3dc6266efae87919b28179724ecd26dc6747f167d89", + "sha256": "5344ab8e32c5ccd0093d17088c520aa685b988423a0890de29cbaac1c96c2aca", + "sbomUrl": "/downloads/codetruss-cli-0.2.53.sbom.cdx.json", + "sbomSha256": "de3b934c62aacb467b7ce333faeebf3a79e7f6d6c2476e3da4113e9c61fa5d12", "node": ">=20.9.0", "repository": "https://github.com/CodeTruss/codetruss-cli", - "releaseUrl": "https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.52", - "attestationCommand": "gh attestation verify codetruss-cli-0.2.52.tgz --repo CodeTruss/codetruss-cli" + "releaseUrl": "https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.53", + "attestationCommand": "gh attestation verify codetruss-cli-0.2.53.tgz --repo CodeTruss/codetruss-cli" } diff --git a/public/downloads/codetruss-cli-latest.sbom.cdx.json b/public/downloads/codetruss-cli-latest.sbom.cdx.json index 280aaf0..17b62a2 100644 --- a/public/downloads/codetruss-cli-latest.sbom.cdx.json +++ b/public/downloads/codetruss-cli-latest.sbom.cdx.json @@ -1,15 +1,15 @@ { "$schema": "https://cyclonedx.org/schema/bom-1.6.schema.json", "bomFormat": "CycloneDX", - "serialNumber": "urn:uuid:4c318a44-4d62-55be-95fe-0240ae7f3d90", + "serialNumber": "urn:uuid:6d901377-79c0-50cd-84dd-46fc5c7c62c0", "specVersion": "1.6", "version": 1, "metadata": { "component": { "type": "application", - "bom-ref": "pkg:npm/%40codetruss/cli@0.2.52", + "bom-ref": "pkg:npm/%40codetruss/cli@0.2.53", "name": "@codetruss/cli", - "version": "0.2.52", + "version": "0.2.53", "description": "Local-first scope, quality, and verification receipts for coding agents", "licenses": [ { @@ -18,7 +18,7 @@ } } ], - "purl": "pkg:npm/%40codetruss/cli@0.2.52" + "purl": "pkg:npm/%40codetruss/cli@0.2.53" }, "properties": [ { @@ -139,7 +139,7 @@ "dependsOn": [] }, { - "ref": "pkg:npm/%40codetruss/cli@0.2.52", + "ref": "pkg:npm/%40codetruss/cli@0.2.53", "dependsOn": [ "pkg:npm/%40codetruss/analyzer-engine@0.1.0", "pkg:npm/minimatch@10.2.6", diff --git a/public/downloads/codetruss-cli-latest.tgz b/public/downloads/codetruss-cli-latest.tgz index 2b40831..cc6ebfd 100644 Binary files a/public/downloads/codetruss-cli-latest.tgz and b/public/downloads/codetruss-cli-latest.tgz differ diff --git a/public/downloads/codetruss-cli-latest.tgz.sha256 b/public/downloads/codetruss-cli-latest.tgz.sha256 index 5baae22..28c2d72 100644 --- a/public/downloads/codetruss-cli-latest.tgz.sha256 +++ b/public/downloads/codetruss-cli-latest.tgz.sha256 @@ -1 +1 @@ -5afc2a563358f1f7dd41eaf2484dc7913e326d9e7072f0d8c5bdeb1bb77792dd codetruss-cli-latest.tgz +5344ab8e32c5ccd0093d17088c520aa685b988423a0890de29cbaac1c96c2aca codetruss-cli-latest.tgz diff --git a/release-reference.json b/release-reference.json index 2f7804f..7694598 100644 --- a/release-reference.json +++ b/release-reference.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "version": "0.2.52", - "websiteArchive": "https://codetruss.com/downloads/codetruss-cli-0.2.52.tgz", - "archiveSha256": "5afc2a563358f1f7dd41eaf2484dc7913e326d9e7072f0d8c5bdeb1bb77792dd", - "sbomSha256": "0667253d5059b5aa5373c3dc6266efae87919b28179724ecd26dc6747f167d89", - "bundleSha256": "6db4f9ef9e94a4fb379b785be51f5b8b932cbd4c251032851866e0892e4cc332" + "version": "0.2.53", + "websiteArchive": "https://codetruss.com/downloads/codetruss-cli-0.2.53.tgz", + "archiveSha256": "5344ab8e32c5ccd0093d17088c520aa685b988423a0890de29cbaac1c96c2aca", + "sbomSha256": "de3b934c62aacb467b7ce333faeebf3a79e7f6d6c2476e3da4113e9c61fa5d12", + "bundleSha256": "1052b1a0fc8f0a7b36ff32cf2e916f113af9e949437bebb0fcb19e751fe65b0d" }