diff --git a/CHANGELOG.md b/CHANGELOG.md index ca06144..2ed30ab 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.44 on GitHub](https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.44), +The current public release is [v0.2.45 on GitHub](https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.45), distributed from . The npm `latest` tag is still [`@codetruss/cli@0.2.41`](https://www.npmjs.com/package/@codetruss/cli/v/0.2.41): @@ -16,6 +16,55 @@ were superseded before distribution. No unreleased changes. +## 0.2.45 — 2026-08-07 + +- **A PASS was reachable by typing.** `codetruss-ignore: ` exists so a + developer can dismiss a finding beside the code it is about, and the one + promise it makes is that "nothing was found" can never be reached by editing + text. It could be. A dismissed finding stops gating the verdict — that is what + dismissing is for — and the marker was honored wherever those characters + appeared on the finding's own line, including inside a string literal. A + minified bundle is one physical line, so a single planted string dismissed + every credential finding in the file, and the verdict followed. The marker is + now read only where a person could have written it. It must sit in a COMMENT, + decided by the same classifier the comment analyzers ship, which separates + comments from code and from strings; in a language that classifier does not + cover, the marker is honored only in the placement that needs no classifier — + a line whose every preceding character is whitespace or comment punctuation. + Markers are no longer read out of generated, vendored or minified content at + all: that text had no author who could have meant it. And the reason itself is + now redacted against the credential patterns before it is quoted. A reason runs + to the end of its line, so a marker written just before a connection string + harvested the password verbatim onto a signed receipt and synced it to the + hosted database — the secret scanner's promise that values never leave it now + holds for the text other passes copy out of the repository too. + +- **Oversized-file findings counted comments as code.** The size analyzer + measured non-blank lines and then printed the number as a fact: "parser.ts has + 2202 lines of code", in a document a customer can disprove with `wc`. Two of + this repository's own HIGH findings existed only because of it — the same two + files measure 1995 and 1996 lines of code, both below the threshold that made + them HIGH — and the overcount inflated every oversized finding, because the + 800-line gate read the same number. Both the gate and the printed number now + come from the classifier. Nothing stops being reported that a refactor would + have helped: a file with 800 lines of code has 800 lines of code however they + are counted. What stops is documentation manufacturing severity. + +- **Redirects that are not redirect calls are now findings.** The open-redirect + rule matched two method names, `redirect` and `sendRedirect`. Most navigation + in a React or Next.js codebase is neither: it is ``, + `
`, `location.href = next`, `location.assign(...)` or + `router.push(...)`, and none of those is a call to anything the rule was + looking for. It missed a live open redirect in our own repository on that + basis. Those shapes are sinks now. The call forms are gated on their receiver, + because `push` and `replace` unqualified are `Array.prototype.push` and + `String.prototype.replace`; the binding forms fire only where the untrusted + value IS the navigation target — a value, or a field of the request itself — + because reading taint off a record that a route segment merely looked up turns + every call-to-action on a `[slug]` page into an open redirect. Measured against + this repository, the narrow rule adds the real defect plus two links a reviewer + should confirm; the wide one added five more that no reviewer should have to. + ## 0.2.44 — 2026-08-07 - **The person you hand a receipt to can now check it.** Until this release a diff --git a/packages/analyzer-engine/src/secrets.ts b/packages/analyzer-engine/src/secrets.ts index cc17e38..d70ae0f 100644 --- a/packages/analyzer-engine/src/secrets.ts +++ b/packages/analyzer-engine/src/secrets.ts @@ -22,6 +22,29 @@ const SECRET_PATTERNS: Array<{ name: string; re: RegExp }> = [ { name: 'Database URL with credentials', re: /(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?):\/\/([^\s'":@/]+):([^\s'"@]+)@([^\s'"/]+)/ }, ] +/** {@link SECRET_PATTERNS} as global matchers, for replacing every occurrence. */ +const REDACTION_PATTERNS = SECRET_PATTERNS.map(({ name, re }) => ({ + name, + re: new RegExp(re.source, `${re.flags}g`), +})) + +/** + * Replace anything credential-shaped in free text with its credential TYPE. + * + * This module's contract is that values never leave it. Text harvested from the + * repository and quoted onto a signed receipt is bound by the same contract even + * when another pass harvests it — a `codetruss-ignore` reason runs to the end of + * its physical line, so on a one-line file the marker swallows whatever follows + * it. Redacting is preferred to dropping the text: the reason is the entire + * evidentiary output of the marker, and a receipt that says only "dismissed" has + * lost the thing that made the dismissal auditable. + */ +export function redactSecrets(text: string): string { + let out = text + for (const { name, re } of REDACTION_PATTERNS) out = out.replace(re, `[redacted ${name}]`) + return out +} + const SKIP_FILES = /(\.env\.example|\.md|\.lock|package-lock\.json|pnpm-lock\.yaml)$/i const PLACEHOLDER = /(example|placeholder|your[-_]|xxx|changeme|dummy|<[^>]+>|\$\{)/i /** diff --git a/packages/analyzer-engine/src/security/engine.ts b/packages/analyzer-engine/src/security/engine.ts index 3f7cac8..4cef718 100644 --- a/packages/analyzer-engine/src/security/engine.ts +++ b/packages/analyzer-engine/src/security/engine.ts @@ -414,7 +414,11 @@ async function scanOne( for (const sink of applicableAssignSinks) { const nv = asNamedValue(node, lang) if (!nv || !sink.matchName(nv.name)) continue + if (sink.sites && !sink.sites.has(node.type)) continue if (sink.safeValue?.(nv.value, lang)) continue + // Head-position sinks (open redirect): taint confined to a path segment + // of an origin-relative target cannot steer the victim off-origin. + if (sink.taintPosition === 'head' && headTaintSuppressed(nv.value, rec.ft, lang)) continue const origins = taintOf(nv.value, rec.ft) if (!hasRealSource(origins)) continue const src = firstSource(origins)! @@ -654,7 +658,7 @@ function makeAssignFinding( source, sink: sinkLoc, steps: [source, sinkLoc], - summary: `${sourceKind} -> raw HTML`, + summary: `${sourceKind} -> ${sink.surface}`, interprocedural: false, } const sameLine = source.filePath === sinkLoc.filePath && source.line === sinkLoc.line @@ -666,15 +670,15 @@ function makeAssignFinding( severity: sink.severity, title: sink.title, message: sameLine - ? `${sink.message} Untrusted data from ${sourceKind} is assigned to a raw-HTML binding in the same expression (line ${sinkLoc.line}).` - : `${sink.message} Untrusted data from ${sourceKind} (line ${source.line}) is assigned to a raw-HTML binding at line ${sinkLoc.line}.`, + ? `${sink.message} Untrusted data from ${sourceKind} is assigned to ${sink.surface} in the same expression (line ${sinkLoc.line}).` + : `${sink.message} Untrusted data from ${sourceKind} (line ${source.line}) is assigned to ${sink.surface} at line ${sinkLoc.line}.`, language: lang, filePath, line: sinkLoc.line, column: sinkNode.startPosition.column + 1, flow, remediation: sink.remediation, - metadata: sortMeta({ sourceKind, sink: 'raw HTML', snippet: snippetAt(lines, sinkLoc.line - 1) }), + metadata: sortMeta({ sourceKind, sink: sink.surface, snippet: snippetAt(lines, sinkLoc.line - 1) }), } } diff --git a/packages/analyzer-engine/src/security/rules.ts b/packages/analyzer-engine/src/security/rules.ts index e0447c6..ead118f 100644 --- a/packages/analyzer-engine/src/security/rules.ts +++ b/packages/analyzer-engine/src/security/rules.ts @@ -14,11 +14,13 @@ import { isFunctionNode, numberLiteralValue, stringLiteralValue, + subscriptBase, unwrap, walk, type NCall, } from './normalize' import { readModifyWriteHits } from './rmw' +import { sourceKindOf } from './taint' /** * The curated, high-precision rule pack. @@ -166,6 +168,15 @@ const HTTP_CLIENTS = new Set([ 'httpclient', 'urllib', 'urllib.request', 'node-fetch', 'undici', 'webclient', ]) +/** + * Receivers that navigate the BROWSER: `location`, `window.location`, + * `document.location`. Gating on the receiver is what keeps `assign` and + * `replace` — `Object.assign`, `String.prototype.replace` — from firing. + */ +const NAVIGATION_RECEIVER = /(^|\.)location$/ +/** Receivers of a client-side router: Next.js `router`, History-API `history`. */ +const ROUTER_RECEIVER = /(^|\.)(router|history)$/ + /** A keyword/option argument `name=value` present among a call's args (py/js). */ function findOption(call: NCall, lang: SastLanguage, name: string): SyntaxNode | null { const want = name.toLowerCase() @@ -363,7 +374,14 @@ export const TAINT_SINKS: TaintSink[] = [ taintPosition: 'head', match(call) { const m = lc(call.method) + const recv = lc(call.receiverName) if (m === 'redirect' || m === 'sendredirect') return [0] + // Browser navigation — `location.assign(url)`, `window.location.replace(url)`. + // Receiver-gated: a bare `replace` is String.prototype.replace. + if ((m === 'assign' || m === 'replace') && NAVIGATION_RECEIVER.test(recv)) return [0] + // Client router — `router.push(url)`, `history.replace(url)`. Same gate: + // `push` unqualified is Array.prototype.push on nearly every line it appears. + if ((m === 'push' || m === 'replace') && ROUTER_RECEIVER.test(recv)) return [0] return null }, }, @@ -473,6 +491,18 @@ export function asNamedValue( const value = f('value') if (nameNode && value) return { name: clean(nameNode.text), value, node } } + if (t === 'jsx_attribute') { + // ``. Neither grammar names the value with a field, and the + // `=` between them is anonymous, so the name is the first named child and + // the value the last; a valueless attribute (`disabled`) has only one. + const nameNode = node.namedChildren[0] + const valueNode = node.namedChildren[node.namedChildren.length - 1] + if (nameNode && valueNode && nameNode !== valueNode) { + // `{to}` is an expression container; the expression inside it is the value. + const value = valueNode.type === 'jsx_expression' ? valueNode.namedChildren[0] : valueNode + if (value) return { name: clean(nameNode.text), value, node } + } + } if (t === 'assignment_expression' || t === 'assignment') { const left = f('left') const right = f('right') @@ -707,6 +737,62 @@ export interface TaintAssignSink extends RuleMeta { matchName(name: string): boolean /** Sink-local safety: the assigned expression is already safe by construction. */ safeValue?(value: SyntaxNode, lang: SastLanguage): boolean + /** + * Binding SHAPES this sink may be written as, by AST node type. Omitted means + * every shape `asNamedValue` understands — which for a name as ordinary as + * `href` would include `const href = …`, a local variable that navigates + * nothing. A sink whose name is common English restricts itself here. + */ + sites?: ReadonlySet + /** See {@link TaintSink.taintPosition}. */ + taintPosition?: 'head' + /** What the value reaches, named in the finding's prose and metadata. */ + surface: string +} + +/** + * Binding shapes that navigate: a JSX attribute (``, ``) and an assignment (`location.href = to`). Deliberately not + * `variable_declarator` or `pair`: naming a local `href` is not navigating, and + * every route table in a React codebase is objects with `href` keys. + */ +const NAVIGATION_BINDING_SITES: ReadonlySet = new Set([ + 'jsx_attribute', + 'assignment_expression', + 'assignment', +]) + +/** + * True unless the untrusted value IS the navigation target. + * + * The shapes this sink exists for are `href={returnTo}` and + * `href={searchParams.next}` — the target is the untrusted value, or a field of + * the request itself. Anything else is a target the untrusted value merely + * helped BUILD, and in a React codebase that is dominated by one pattern: + * `href={post.cta?.href ?? '/register'}`, where `post` came back from + * `getPost(slug)` and the only taint is the route segment used as the lookup + * key. Reading the key's taint as the record's taint turns every call-to-action + * in a `[slug]` page into an open redirect — measured, that shape plus a JSX + * `action` prop holding a component was five false positives in this repository + * alone, against the one real defect the sink was added to catch. + * + * So this sink ships at the precision it can prove. The cost is real and + * bounded: a target assembled by concatenation or chosen by a ternary is not + * reported here. Widening is a later change made against measurements. + */ +function navigationTargetIsIndirect(value: SyntaxNode, lang: SastLanguage): boolean { + const n = unwrap(value, lang) + if (sourceKindOf(n, lang)) return false + if (identifierName(n, lang)) return false + // Walk a member/subscript chain to its root: `searchParams.next`, `req.query.to`. + let root: SyntaxNode = n + for (let depth = 0; depth < 12; depth++) { + const inner = asMember(root, lang)?.object ?? subscriptBase(root) + if (!inner) break + root = unwrap(inner, lang) + if (sourceKindOf(root, lang)) return false + } + return true } /** @@ -737,6 +823,29 @@ export const TAINT_ASSIGN_SINKS: TaintAssignSink[] = [ remediation: 'Render the value as text instead of HTML, or sanitize it (DOMPurify) before assigning. For JSON embedded in a script tag, serialize with JSON.stringify and escape "<".', matchName: (name) => name === '__html' || name === 'innerhtml' || name === 'outerhtml', safeValue: isEscapedJsonLd, + surface: 'a raw-HTML binding', + }, + { + // Same rule class as the call-shaped `open-redirect` sink above, and the + // same id, because it is the same defect: a user-controlled navigation + // target. Most redirects in a React codebase are never a redirect CALL — + // they are ``, `` or + // `location.href = next`, none of which a `match(call)` rule can see. + id: 'open-redirect', + cweKey: 'OPENREDIR', + severity: 'MEDIUM', + languages: new Set(['javascript', 'typescript', 'tsx']), + title: 'Open redirect', + message: 'A user-controlled value is used as a navigation target, enabling phishing by sending victims to attacker-chosen sites.', + remediation: 'Navigate only to a fixed set of allowed paths, or validate the target against an allow-list of hosts.', + matchName: (name) => name === 'href' || name === 'action', + sites: NAVIGATION_BINDING_SITES, + safeValue: navigationTargetIsIndirect, + // Taint confined to a path segment of an origin-relative target + // (`/repo/${id}`) cannot send the victim off-origin — which is what keeps + // this off the interpolated hrefs that make up most of a React app. + taintPosition: 'head', + surface: 'a navigation target', }, ] diff --git a/packages/analyzer-engine/src/size.ts b/packages/analyzer-engine/src/size.ts index 350cd78..04a83d6 100644 --- a/packages/analyzer-engine/src/size.ts +++ b/packages/analyzer-engine/src/size.ts @@ -1,9 +1,32 @@ -import type { Analyzer, AnalyzerFinding } from './types' +import { classifyLines } from './comments' +import type { Analyzer, AnalyzerFinding, IndexedFile } from './types' import { looksGenerated } from './support' const HUGE_LOC = 800 const LARGE_ASSET_BYTES = 5 * 1024 * 1024 +/** + * Lines of CODE — comments and blanks excluded. + * + * `IndexedFile.loc` counts non-blank lines, so a heavily documented file + * measures larger than it is, and this analyzer QUOTES its number in a paid + * deliverable: "parser.ts has 2202 lines of code" is disproved by the reader's + * own `wc`, and the same overcount pushes files across the HIGH threshold that + * are only oversized in prose. Measuring with the classifier this engine already + * ships costs nothing in recall — a file with 800 lines of code has 800 lines of + * code however it is counted — and it is what makes the sentence true. + * + * Falls back to `loc` when the file has no classifier or no readable content: + * an approximate number is better than dropping the finding, and `loc` bounds + * the code count from above, so the fallback can only under-report. + */ +function codeLoc(file: IndexedFile): number { + if (!file.content) return file.loc + const classified = classifyLines(file.path, file.content) + if (classified.length === 0) return file.loc + return classified.filter((line) => line.kind === 'code').length +} + /** Flags unmaintainably large source files and oversized committed assets. */ export const sizeAnalyzer: Analyzer = { id: 'size', @@ -13,33 +36,37 @@ export const sizeAnalyzer: Analyzer = { const findings: AnalyzerFinding[] = [] for (const f of index.files) { + // `loc` bounds the code count from above, so it decides cheaply which + // files are worth classifying; the classified count then decides. if (f.loc > HUGE_LOC && (f.kind === 'source' || f.kind === 'component' || f.kind === 'route')) { + const loc = codeLoc(f) + if (loc <= HUGE_LOC) continue if (f.content && looksGenerated(f.content)) { // Machine-written files don't get hand-refactoring advice — the // actionable observation is that a build artifact is committed. findings.push({ category: 'STRUCTURE', severity: 'LOW', - title: `Generated artifact committed: ${f.path.split('/').pop()} (${f.loc} LOC)`, + title: `Generated artifact committed: ${f.path.split('/').pop()} (${loc} LOC)`, description: `${f.path} declares itself autogenerated. Large generated files bloat diffs and invite accidental hand-edits.`, filePath: f.path, suggestion: 'Generate it at build time instead of committing it, or mark it linguist-generated in .gitattributes.', impactScore: 25, effort: 'low', - metadata: { loc: f.loc, generated: true }, + metadata: { loc, generated: true }, }) continue } findings.push({ category: 'TECH_DEBT', - severity: f.loc > 2000 ? 'HIGH' : 'MEDIUM', - title: `Oversized file: ${f.path.split('/').pop()} (${f.loc} LOC)`, - description: `${f.path} has ${f.loc} lines of code. Files this large are hard to review, test, and safely change.`, + severity: loc > 2000 ? 'HIGH' : 'MEDIUM', + title: `Oversized file: ${f.path.split('/').pop()} (${loc} LOC)`, + description: `${f.path} has ${loc} lines of code. Files this large are hard to review, test, and safely change.`, filePath: f.path, suggestion: 'Split into focused modules along responsibility boundaries.', - impactScore: Math.min(90, 40 + Math.floor(f.loc / 100)), + impactScore: Math.min(90, 40 + Math.floor(loc / 100)), effort: 'medium', - metadata: { loc: f.loc }, + metadata: { loc }, }) } if (f.kind === 'asset' && f.sizeBytes > LARGE_ASSET_BYTES) { diff --git a/packages/analyzer-engine/src/suppression.ts b/packages/analyzer-engine/src/suppression.ts index ebf00f6..0257faf 100644 --- a/packages/analyzer-engine/src/suppression.ts +++ b/packages/analyzer-engine/src/suppression.ts @@ -1,3 +1,5 @@ +import { classifyLines } from './comments' +import { redactSecrets } from './secrets' import type { AnalyzerFinding, FindingSuppression, RepoIndex } from './types' /** @@ -19,6 +21,11 @@ import type { AnalyzerFinding, FindingSuppression, RepoIndex } from './types' * 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. + * + * That last sentence is a load-bearing claim, so this pass reads a marker only + * where a HUMAN could have written one: in a comment ({@link markerIsComment}), + * in a file this repository actually authors ({@link readFile}), and it quotes + * back only text that carries no credential ({@link parseMarker}). */ const MARKER_RE = /\bcodetruss-ignore\b[ \t]*(:[ \t]*(.*))?/ @@ -53,16 +60,60 @@ 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 reason runs to the end of the physical line, so on a one-line file a + // marker written BEFORE a credential harvests the credential — and the reason + // is quoted onto a signed receipt and synced to the hosted database. The + // secret scanner's promise that values never leave it has to hold for text + // this module copies out of the repository too, so it is redacted here rather + // than trusted to be prose. Redaction runs before the cap: truncating first + // could cut a credential short of the pattern that recognizes it. + const raw = match[2] === undefined ? '' : match[2].replace(COMMENT_CLOSE_RE, '').trim() + return { + reason: redactSecrets(raw).slice(0, MAX_REASON_LENGTH), + commentOnly: !/[A-Za-z0-9_$]/.test(line.slice(0, match.index)), + } +} + +/** + * A file's lines, plus what each line contributes as CODE where that is knowable. + * + * `code` is null for a language {@link classifyLines} has no classifier for; it + * is never an empty array, so "no classifier" and "empty file" stay distinct. + */ +interface FileLines { + raw: string[] + code: string[] | null +} + +/** + * Is the marker on this line written in a COMMENT? + * + * It has to be. `codetruss-ignore:` is otherwise honored wherever the characters + * appear — including inside a string literal — and a minified bundle is ONE + * physical line, so a single planted string would dismiss every finding in the + * file. Dismissed findings stop gating the verdict, which puts a PASS one + * planted string away: exactly what this module promises is unreachable. + * + * The classifier separates comments from code and from strings, so a marker is + * in a comment precisely when it does not appear in the line's code. For a + * language with no classifier the question cannot be answered, and the marker is + * honored only in the placement that needs no answer — a line whose every + * preceding character is whitespace or comment punctuation. + */ +function markerIsComment(file: FileLines, row: number, marker: ParsedMarker): boolean { + const code = file.code?.[row] + if (code === undefined) return marker.commentOnly + return !MARKER_RE.test(code) } /** 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 } +function markerFor(file: FileLines, line: number): { reason: string; markerLine: number } | null { + const own = parseMarker(file.raw[line - 1]) + if (own && markerIsComment(file, line - 1, own)) return { reason: own.reason, markerLine: line } + const above = parseMarker(file.raw[line - 2]) + if (above?.commentOnly && markerIsComment(file, line - 2, above)) { + return { reason: above.reason, markerLine: line - 1 } + } return null } @@ -76,24 +127,29 @@ function markerFor(lines: string[], line: number): { reason: string; markerLine: */ export function annotateSuppressions(findings: AnalyzerFinding[], index: RepoIndex): AnalyzerFinding[] { if (findings.length === 0) return findings - const lineCache = new Map() - const readLines = (path: string): string[] | null => { - const cached = lineCache.get(path) + const fileCache = new Map() + const readFile = (path: string): FileLines | null => { + const cached = fileCache.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) + // `content` only, never `excludedContent`. A generated, minified or vendored + // file has no author who could have written an intentional marker, so a + // marker found there was written by a generator or shipped by a dependency — + // neither of which is a judgement this repository's developers made. + const content = file?.content ?? null + const lines = + content === null + ? null + : { raw: content.split('\n'), code: codeLines(path, content) } + fileCache.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) + const file = readFile(finding.filePath) + if (!file) return finding + const marker = markerFor(file, finding.line) if (!marker) return finding const suppression: FindingSuppression = { reason: marker.reason, @@ -103,3 +159,9 @@ export function annotateSuppressions(findings: AnalyzerFinding[], index: RepoInd return { ...finding, suppression } }) } + +/** Per-line code text, or null when this file's language has no classifier. */ +function codeLines(path: string, content: string): string[] | null { + const classified = classifyLines(path, content) + return classified.length === 0 ? null : classified.map((line) => line.code) +} diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 0d4bc5d..af89bc0 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -5,6 +5,55 @@ checksums are published at ` exists so a + developer can dismiss a finding beside the code it is about, and the one + promise it makes is that "nothing was found" can never be reached by editing + text. It could be. A dismissed finding stops gating the verdict — that is what + dismissing is for — and the marker was honored wherever those characters + appeared on the finding's own line, including inside a string literal. A + minified bundle is one physical line, so a single planted string dismissed + every credential finding in the file, and the verdict followed. The marker is + now read only where a person could have written it. It must sit in a COMMENT, + decided by the same classifier the comment analyzers ship, which separates + comments from code and from strings; in a language that classifier does not + cover, the marker is honored only in the placement that needs no classifier — + a line whose every preceding character is whitespace or comment punctuation. + Markers are no longer read out of generated, vendored or minified content at + all: that text had no author who could have meant it. And the reason itself is + now redacted against the credential patterns before it is quoted. A reason runs + to the end of its line, so a marker written just before a connection string + harvested the password verbatim onto a signed receipt and synced it to the + hosted database — the secret scanner's promise that values never leave it now + holds for the text other passes copy out of the repository too. + +- **Oversized-file findings counted comments as code.** The size analyzer + measured non-blank lines and then printed the number as a fact: "parser.ts has + 2202 lines of code", in a document a customer can disprove with `wc`. Two of + this repository's own HIGH findings existed only because of it — the same two + files measure 1995 and 1996 lines of code, both below the threshold that made + them HIGH — and the overcount inflated every oversized finding, because the + 800-line gate read the same number. Both the gate and the printed number now + come from the classifier. Nothing stops being reported that a refactor would + have helped: a file with 800 lines of code has 800 lines of code however they + are counted. What stops is documentation manufacturing severity. + +- **Redirects that are not redirect calls are now findings.** The open-redirect + rule matched two method names, `redirect` and `sendRedirect`. Most navigation + in a React or Next.js codebase is neither: it is ``, + ``, `location.href = next`, `location.assign(...)` or + `router.push(...)`, and none of those is a call to anything the rule was + looking for. It missed a live open redirect in our own repository on that + basis. Those shapes are sinks now. The call forms are gated on their receiver, + because `push` and `replace` unqualified are `Array.prototype.push` and + `String.prototype.replace`; the binding forms fire only where the untrusted + value IS the navigation target — a value, or a field of the request itself — + because reading taint off a record that a route segment merely looked up turns + every call-to-action on a `[slug]` page into an open redirect. Measured against + this repository, the narrow rule adds the real defect plus two links a reviewer + should confirm; the wide one added five more that no reviewer should have to. + ## 0.2.44 — 2026-08-07 - **The person you hand a receipt to can now check it.** Until this release a diff --git a/packages/cli/package.json b/packages/cli/package.json index eab054d..a53620a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@codetruss/cli", - "version": "0.2.44", + "version": "0.2.45", "description": "Local-first scope, quality, and verification receipts for coding agents", "license": "SEE LICENSE IN LICENSE", "type": "module", diff --git a/public/downloads/codetruss-cli-0.2.45.sbom.cdx.json b/public/downloads/codetruss-cli-0.2.45.sbom.cdx.json new file mode 100644 index 0000000..b76287b --- /dev/null +++ b/public/downloads/codetruss-cli-0.2.45.sbom.cdx.json @@ -0,0 +1,170 @@ +{ + "$schema": "https://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "serialNumber": "urn:uuid:80004520-98c3-510e-ad1f-a9db80eaf310", + "specVersion": "1.6", + "version": 1, + "metadata": { + "component": { + "type": "application", + "bom-ref": "pkg:npm/%40codetruss/cli@0.2.45", + "name": "@codetruss/cli", + "version": "0.2.45", + "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.45" + }, + "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.7", + "name": "brace-expansion", + "version": "5.0.7", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "purl": "pkg:npm/brace-expansion@5.0.7", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/minimatch@10.2.5", + "name": "minimatch", + "version": "10.2.5", + "licenses": [ + { + "license": { + "id": "BlueOak-1.0.0" + } + } + ], + "purl": "pkg:npm/minimatch@10.2.5", + "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.45", + "dependsOn": [ + "pkg:npm/%40codetruss/analyzer-engine@0.1.0", + "pkg:npm/minimatch@10.2.5", + "pkg:npm/yaml@2.9.0" + ] + }, + { + "ref": "pkg:npm/balanced-match@4.0.4", + "dependsOn": [] + }, + { + "ref": "pkg:npm/brace-expansion@5.0.7", + "dependsOn": [ + "pkg:npm/balanced-match@4.0.4" + ] + }, + { + "ref": "pkg:npm/minimatch@10.2.5", + "dependsOn": [ + "pkg:npm/brace-expansion@5.0.7" + ] + }, + { + "ref": "pkg:npm/yaml@2.9.0", + "dependsOn": [] + } + ] +} diff --git a/public/downloads/codetruss-cli-0.2.45.tgz b/public/downloads/codetruss-cli-0.2.45.tgz new file mode 100644 index 0000000..99615fa Binary files /dev/null and b/public/downloads/codetruss-cli-0.2.45.tgz differ diff --git a/public/downloads/codetruss-cli-0.2.45.tgz.sha256 b/public/downloads/codetruss-cli-0.2.45.tgz.sha256 new file mode 100644 index 0000000..eadcfdc --- /dev/null +++ b/public/downloads/codetruss-cli-0.2.45.tgz.sha256 @@ -0,0 +1 @@ +0ced9ca92b28a96faf997a1c45911fd2dd77bb4107fdd3153d88692139c714ce codetruss-cli-0.2.45.tgz diff --git a/public/downloads/codetruss-cli-latest.json b/public/downloads/codetruss-cli-latest.json index 3b92d3c..f710689 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.44", - "url": "/downloads/codetruss-cli-0.2.44.tgz", + "version": "0.2.45", + "url": "/downloads/codetruss-cli-0.2.45.tgz", "latestUrl": "/downloads/codetruss-cli-latest.tgz", - "sha256": "8a405b77b2042c8daca6f2def782fa8e38a2ffcec4ebe9643631d3491af50884", - "sbomUrl": "/downloads/codetruss-cli-0.2.44.sbom.cdx.json", - "sbomSha256": "0a0f28ffcc31affe5a1557ecd172aefde43df72184ab4806da6dd7249a7a8268", + "sha256": "0ced9ca92b28a96faf997a1c45911fd2dd77bb4107fdd3153d88692139c714ce", + "sbomUrl": "/downloads/codetruss-cli-0.2.45.sbom.cdx.json", + "sbomSha256": "8c6dc2b384d5f401daa2791d25366b8417a1e6a9cad598281bdbddbc2c9ad3b4", "node": ">=20.9.0", "repository": "https://github.com/CodeTruss/codetruss-cli", - "releaseUrl": "https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.44", - "attestationCommand": "gh attestation verify codetruss-cli-0.2.44.tgz --repo CodeTruss/codetruss-cli" + "releaseUrl": "https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.45", + "attestationCommand": "gh attestation verify codetruss-cli-0.2.45.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 6d293c5..b76287b 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:07c0de0b-d5eb-51d9-94cd-389e5ce448ff", + "serialNumber": "urn:uuid:80004520-98c3-510e-ad1f-a9db80eaf310", "specVersion": "1.6", "version": 1, "metadata": { "component": { "type": "application", - "bom-ref": "pkg:npm/%40codetruss/cli@0.2.44", + "bom-ref": "pkg:npm/%40codetruss/cli@0.2.45", "name": "@codetruss/cli", - "version": "0.2.44", + "version": "0.2.45", "description": "Local-first scope, quality, and verification receipts for coding agents", "licenses": [ { @@ -18,7 +18,7 @@ } } ], - "purl": "pkg:npm/%40codetruss/cli@0.2.44" + "purl": "pkg:npm/%40codetruss/cli@0.2.45" }, "properties": [ { @@ -139,7 +139,7 @@ "dependsOn": [] }, { - "ref": "pkg:npm/%40codetruss/cli@0.2.44", + "ref": "pkg:npm/%40codetruss/cli@0.2.45", "dependsOn": [ "pkg:npm/%40codetruss/analyzer-engine@0.1.0", "pkg:npm/minimatch@10.2.5", diff --git a/public/downloads/codetruss-cli-latest.tgz b/public/downloads/codetruss-cli-latest.tgz index 9bfb2a8..99615fa 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 84b4cee..7816a3f 100644 --- a/public/downloads/codetruss-cli-latest.tgz.sha256 +++ b/public/downloads/codetruss-cli-latest.tgz.sha256 @@ -1 +1 @@ -8a405b77b2042c8daca6f2def782fa8e38a2ffcec4ebe9643631d3491af50884 codetruss-cli-latest.tgz +0ced9ca92b28a96faf997a1c45911fd2dd77bb4107fdd3153d88692139c714ce codetruss-cli-latest.tgz diff --git a/release-reference.json b/release-reference.json index 7adad50..c8b03de 100644 --- a/release-reference.json +++ b/release-reference.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "version": "0.2.44", - "websiteArchive": "https://codetruss.com/downloads/codetruss-cli-0.2.44.tgz", - "archiveSha256": "8a405b77b2042c8daca6f2def782fa8e38a2ffcec4ebe9643631d3491af50884", - "sbomSha256": "0a0f28ffcc31affe5a1557ecd172aefde43df72184ab4806da6dd7249a7a8268", - "bundleSha256": "33baae597f5f307ddb24e222c00ccef2d2a34090a3aa50b34279545e65ab73b9" + "version": "0.2.45", + "websiteArchive": "https://codetruss.com/downloads/codetruss-cli-0.2.45.tgz", + "archiveSha256": "0ced9ca92b28a96faf997a1c45911fd2dd77bb4107fdd3153d88692139c714ce", + "sbomSha256": "8c6dc2b384d5f401daa2791d25366b8417a1e6a9cad598281bdbddbc2c9ad3b4", + "bundleSha256": "85d58cdff704e356d60043dc54e4cf2ef2523f2b88218988b9f0100544c1af6f" }