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

Filter by extension

Filter by extension


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

The current public release is [v0.2.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 <https://codetruss.com/downloads/codetruss-cli-latest.json>.
The npm `latest` tag is still
[`@codetruss/cli@0.2.41`](https://www.npmjs.com/package/@codetruss/cli/v/0.2.41):
Expand All @@ -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: <reason>` 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 `<Link href={returnTo}>`,
`<form action={next}>`, `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
Expand Down
23 changes: 23 additions & 0 deletions packages/analyzer-engine/src/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
/**
Expand Down
12 changes: 8 additions & 4 deletions packages/analyzer-engine/src/security/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)!
Expand Down Expand Up @@ -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
Expand All @@ -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) }),
}
}

Expand Down
109 changes: 109 additions & 0 deletions packages/analyzer-engine/src/security/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
},
},
Expand Down Expand Up @@ -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') {
// `<Link href={to}>`. 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')
Expand Down Expand Up @@ -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<string>
/** 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 (`<Link href={to}>`, `<form
* action={to}>`) 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<string> = 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
}

/**
Expand Down Expand Up @@ -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 `<Link href={returnTo}>`, `<form action={next}>` or
// `location.href = next`, none of which a `match(call)` rule can see.
id: 'open-redirect',
cweKey: 'OPENREDIR',
severity: 'MEDIUM',
languages: new Set<SastLanguage>(['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',
},
]

Expand Down
43 changes: 35 additions & 8 deletions packages/analyzer-engine/src/size.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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) {
Expand Down
Loading