diff --git a/CHANGELOG.md b/CHANGELOG.md index 49547e5..b882ccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,12 @@ CodeTruss CLI follows semantic versioning. Release artifacts and their SHA-256 checksums are published at . -The current public release is [v0.2.24 on GitHub](https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v0.2.24) -and [`@codetruss/cli@0.2.24` on npm](https://www.npmjs.com/package/@codetruss/cli/v/0.2.24). +The current public release is [v0.2.30 on GitHub](https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v0.2.30), +distributed from . +The npm `latest` tag is still +[`@codetruss/cli@0.2.24`](https://www.npmjs.com/package/@codetruss/cli/v/0.2.24): +npm publication is a separate, manually dispatched step, so npm can trail the +website and the GitHub release. Entries explicitly marked `(unpublished)` are retained release candidates that were superseded before distribution. @@ -12,6 +16,102 @@ were superseded before distribution. No unreleased changes. +## 0.2.30 — 2026-08-06 + +- Publish the 0.2.25 through 0.2.29 release history, which shipped without + changelog entries. No CLI behaviour changed. `CHANGELOG.md` is one of the eight + published files and is byte-compared against the immutable release archive, so + documenting those releases requires a new version rather than an edit in place. + +## 0.2.29 — 2026-08-06 + +- Say plainly that a local run never checks for injection. The coverage + analyzer had stayed silent for TypeScript, JavaScript, and Python because the + SAST engine supports those languages — a fact about the engine, not about a + run that never executes it. Analyzers now receive the non-registry passes in + effect, defaulting to none, and a repository holding at least 300 lines that + pass would have covered gets one INFO finding naming the classes nobody + looked for: SQL injection, command injection, code injection, path traversal, + SSRF, open redirect, XSS, and insecure deserialization. +- Replace the receipt's "Hosted Health scores: N/A" footnote with a "What did + not run" section naming SAST, the hosted symbol graph, the scores, and — when + no model read the diff — the optional LLM review as detection gaps, and + stating that a PASS verdict is not a claim the change is secure. +- Keep receipts written by 0.2.28 and earlier verifiable. No signed field + changed, only the rendering, so each superseded wording stays byte-reproducible + for verification. + +## 0.2.28 — 2026-08-06 + +- Let `codetruss setup --yes` finish protecting an ordinary repository instead + of exiting 3 with a policy file and no guardrail. Conventional source roots + that exist on disk are now adopted unattended, never a repository-wide glob, + and the adopted list is printed so the decision stays auditable. With nothing + detectable the run still stops and asks. +- Leave auto-detected verification commands out of the policy on an unattended + run instead of aborting, and print how to enable them. A recorded but + untrusted command list makes every later review exit 3 with no receipt, so the + previous behaviour produced a repository that was configured, hooked, and + permanently blocked. Withholding applies only when hooks are being installed; + `--hooks none` still records the commands for inspect-then-trust. + +## 0.2.27 — 2026-08-06 + +- Deduplicate the PostToolUse fast scope check per turn. The first notice for a + path arrives exactly as before, repeats are silent, and a new path still + speaks immediately; a turn that touched one out-of-scope file a dozen times + previously emitted a dozen identical warnings. +- Keep that deduplication fail-soft: when session or turn state cannot be read + or written, every warning is emitted as it was before. + +## 0.2.26 — 2026-08-06 + +- Keep the receipt's Task line readable when an agent harness delivers a machine + event — a background-task notification or a tool result — on the same prompt + channel as human instructions. A human prompt is preserved exactly as written; + a structured event is reduced to its event tag and summary. Derivation stays + deterministic, so prompt-time turn binding is unaffected. +- Report credential-shaped placeholders at INFO instead of skipping them in + silence, so a deliberate skip is not mistaken for a scanner that detects + nothing. Only values that announce themselves as fake qualify; runtime + credential references such as `process.env`, `{{...}}`, and `ENV[]` stay + silent, because that is how a credential is supposed to be written. + +## 0.2.25 — 2026-08-06 + +- Stop an immaterial index-coverage gap from failing a receipt. When the index + measurably covered at least 95% of its analyzable input, the remaining + limitations — an oversized lockfile, an unreadable file — are reported as + review-level context instead of incomplete evidence. Larger losses, and a + truncated file walk, still fail closed. +- Accept a set of pinned signers. `signing.publicKeys` holds one key per + developer, `codetruss verify-policy trust-key` appends the local key to it, + and receipt verification accepts any trusted signer, so a teammate signs as + themselves instead of sharing a private key and destroying attribution. +- Report the verification commands `codetruss init` detected and state that they + are not trusted until approved. Without that notice the next `review` exits 3 + with no receipt at all. +- Stop reporting test and seed credentials as HIGH: Go, Python, and Ruby test + file conventions are recognized, and a database seed script gets its own + MEDIUM finding about the seed path reaching a production database rather than + a rotate-immediately instruction. +- Stop reporting prose as a credential. Values carrying internal whitespace + under a credential-shaped key (validation messages, translations, UI labels), + SCREAMING_SNAKE enum members, `{{...}}` templates, and environment reads are + no longer leaks. +- Exclude minified and bundled assets by mean line width rather than filename, + and report the excluded volume in KB as well as LOC, since packed output + understates its size in lines. Excluding a file no longer manufactures dead + code for the files it imports. +- Recognize more convention-loaded entry points as reachable: Pages Router + routes, Storybook stories, tooling dotfiles, and `package.json` script targets + are no longer reported as orphaned. +- Suppress the duplicate "no `.env.example`" nudge when a committed runtime + `.env` already produces the structure analyzer's version of that finding, and + reword the per-language security caveat from "surface-only" to "coverage is + partial", since pattern and dataflow rules do apply beyond TypeScript, + JavaScript, and Python. + ## 0.2.24 — 2026-07-15 - Ship the guided setup, local evidence privacy, invocation provenance, diff --git a/README.md b/README.md index 843382e..92ebbcb 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,32 @@ # CodeTruss CLI -Review tools ask whether code looks wrong. CodeTruss proves whether the agent -stayed inside its task contract and whether the exact final Git state passed -your checks. +The deterministic first-pass verification gate for AI-written code. -CodeTruss is a local-first guardrail for coding agents. It captures an exact -before/after Git evidence pair, checks task scope and sensitive surfaces, runs -the shared CodeTruss analyzers and repository verification commands, then writes -a signed `PASS`, `REVIEW_REQUIRED`, or `FAILED` receipt before a pull request. +An agent finishes a change. Something has to look at it before a human does. +CodeTruss Boundary is that first pass: it captures an exact before/after Git +evidence pair, checks the change against the task contract you declared, runs 13 +deterministic analyzers and your own project checks, then signs a `PASS`, +`REVIEW_REQUIRED`, or `FAILED` receipt you can re-verify later. -## Quickstart +Routine changes clear the checks and pass. Material changes escalate for human +sign-off. Every verdict leaves a receipt that records the reasons, and the +receipt also names what never ran. -Install from npm with Node.js 20.9 or newer: +It is local-first. Nothing is uploaded unless you explicitly run `codetruss sync`. -```bash -npm install --global @codetruss/cli -codetruss setup -``` +## Install -Or run one review without a global install: +Node.js 20.9 or newer. macOS or Linux: ```bash -npx --yes @codetruss/cli review --task "Review my current agent changes" +curl -fsSL https://codetruss.com/install.sh | sh +codetruss setup ``` -macOS or Linux shell installer: +Homebrew on macOS: ```bash -curl -fsSL https://codetruss.com/install.sh | sh -codetruss setup +brew install DeliriumPulse/codetruss/codetruss ``` Windows PowerShell: @@ -38,79 +36,106 @@ irm https://codetruss.com/install.ps1 | iex codetruss setup ``` -Homebrew on macOS: +The shell installers resolve the versioned artifact named in +[`codetruss-cli-latest.json`](https://codetruss.com/downloads/codetruss-cli-latest.json) +and verify its published SHA-256 digest before installing. + +To pin an exact version, install the immutable archive directly: ```bash -brew install DeliriumPulse/codetruss/codetruss +npm install --global --ignore-scripts --no-audit --no-fund \ + https://codetruss.com/downloads/codetruss-cli-0.2.30.tgz ``` -## Claude Code, Codex, and Agent Skills +The `@codetruss/cli` package on the npm registry is published as a separate, +manually dispatched step and currently trails the website at `0.2.24`. Use the +installers or the versioned archive above for the current release. -The official open integration wrappers teach coding agents to configure and -operate the separately installed local CLI. They do not contain another -analyzer, add an MCP server, or create a new upload path. +## First run -Claude Code: +```bash +codetruss review --task "Review my current agent changes" +codetruss verify latest +``` + +That first receipt needs no account and no configuration. Without an allow +policy every changed file is deliberately unexpected, so the review exits `1` +with `REVIEW_REQUIRED` and still writes valid signed evidence. + +Run `codetruss setup` once at the Git root to make it automatic. It proposes +conventional source roots rather than repository-wide access, shows any detected +verification commands with their exact trust fingerprint before you trust them, +installs the hooks you pick, and runs diagnostics. It uploads nothing. Codex asks +for one final project-hook approval in `/hooks`. + +To wrap an agent so the task and both Git states are captured together: + +```bash +codetruss run --task "Fix auth" --allow "src/auth/**" --verify "pnpm test" -- codex exec "Fix auth" +``` + +## Claude Code, Codex, and Agent Skills + +The open integration wrappers teach coding agents to configure and operate the +separately installed CLI. They contain no second analyzer, add no MCP server, and +create no new upload path. ```bash claude plugin marketplace add DeliriumPulse/codetruss-plugins claude plugin install codetruss@codetruss ``` -Codex: - ```bash codex plugin marketplace add DeliriumPulse/codetruss-plugins codex plugin add codetruss@codetruss ``` -Agent Skills clients can install the same canonical skill for both Claude Code -and Codex from the public wrapper repository: +Agent Skills clients can install the same canonical skill for both agents: ```bash npx --yes skills add DeliriumPulse/codetruss-plugins \ --skill codetruss --agent claude-code codex -y ``` -See the [live skills.sh listing](https://skills.sh/deliriumpulse/codetruss-plugins/codetruss) +See the [skills.sh listing](https://skills.sh/deliriumpulse/codetruss-plugins/codetruss) or [DeliriumPulse/codetruss-plugins](https://github.com/DeliriumPulse/codetruss-plugins) -for the MIT-licensed manifests, skill instructions, privacy guardrails, and -marketplace source. - -Run `codetruss setup` once at the Git root. It proposes conventional source -roots without defaulting to repository-wide access, shows detected verification -commands and their exact trust fingerprint, installs the hooks you select, and -runs diagnostics. It uploads nothing. Codex asks for one final project-hook -approval in `/hooks`. - -To review an existing change before configuring automation: - -```bash -codetruss review --task "Review my current agent changes" -codetruss verify latest +for the MIT-licensed manifests, skill instructions, and marketplace source. + +## Commands + +```text +codetruss run --task "..." [--allow GLOB] [--deny GLOB] [--verify CMD] [--no-verify] + [--llm] [--provider anthropic|openai|claude] -- +codetruss review [--staged] --task "..." [--allow GLOB] [--deny GLOB] [--verify CMD] + [--no-verify] [--llm] [--provider anthropic|openai|claude] +codetruss report [id|latest] [--json] +codetruss list [--json] +codetruss metrics [--json] +codetruss setup [--allow GLOB] [--deny GLOB] [--hooks all|pre-commit|claude|codex|none] + [--trust-verify] [--yes] +codetruss init [--allow GLOB] [--deny GLOB] [--force] +codetruss verify [id|latest] +codetruss sync [id|latest] [--dry-run] +codetruss auth login|status|logout +codetruss verify-policy [status|trust|trust-key|revoke] +codetruss hooks install|status|doctor|uninstall [pre-commit|claude|codex|all] ``` -That first receipt needs no account or configuration. Without an allow policy, -changed files are deliberately unexpected, so the review exits `1` with -`REVIEW_REQUIRED` and still writes valid signed evidence. - -Wrap an agent command when you want the task and exact before/after Git states -captured together: - -```bash -codetruss run --task "Fix auth" --allow "src/auth/**" --verify "pnpm test" -- codex exec "Fix auth" -``` - -Manual hook controls remain available through `codetruss hooks install`, -`status`, `doctor`, and `uninstall`. +`verify-policy status`, `trust`, and `revoke` govern whether the repository's +detected verification commands are trusted to run. `verify-policy trust-key` +is separate: it appends your local signing key to `signing.publicKeys` so a +teammate can sign receipts as themselves instead of sharing a private key. +Commit `.codetruss.yml` afterward so the rest of the team inherits the change. +It is accepted by the CLI but omitted from the built-in `--help` banner in +0.2.30. ## Fail-closed policy -CodeTruss cannot return `PASS` until the approved scope is explicit. Guided -setup requires at least one useful allow glob; lower-level `codetruss init` -intentionally starts empty unless `--allow` is supplied. Deny rules win over -allow rules, and sensitive surfaces such as CI, infrastructure, migrations, -secrets, dependencies, and lockfiles are flagged independently of scope. +CodeTruss cannot return `PASS` until the approved scope is explicit. Guided setup +requires at least one useful allow glob. Lower-level `codetruss init` +intentionally starts empty unless `--allow` is supplied. Deny rules beat allow +rules, and sensitive surfaces such as CI, infrastructure, migrations, secrets, +dependencies, and lockfiles are flagged independently of scope. ```yaml # .codetruss.yml @@ -130,8 +155,8 @@ llm: maxDiffBytes: 200000 ``` -Command-line `--allow`, `--deny`, and `--verify` values can supply the policy for -one run. Repository configuration cannot redirect authenticated sync traffic; +Command-line `--allow`, `--deny`, and `--verify` supply the policy for a single +run. Repository configuration cannot redirect authenticated sync traffic; production sync is fixed to `https://codetruss.com`. ## Verdicts and exit codes @@ -139,76 +164,107 @@ production sync is fixed to `https://codetruss.com`. | Verdict | Exit | Meaning | |---|---:|---| | `PASS` | 0 | No blocking or review signal was found; any configured verification commands passed. | -| `REVIEW_REQUIRED` | 1 | Scope drift, a denied or sensitive surface, dependency changes, uncertain attribution, a medium-or-higher finding, or optional local LLM review needs human judgment. | +| `REVIEW_REQUIRED` | 1 | Scope drift, a denied or sensitive surface, dependency changes, uncertain attribution, a medium-or-higher finding, or optional LLM review needs human judgment. | | `FAILED` | 2 | The agent or a verification command failed, evidence is incomplete, or a high/critical security or dependency finding blocks the result. | -Usage and environment errors exit `3`. A receipt records every explicit reason; -the verdict is not a confidence score. +Usage and environment errors exit `3`. A receipt records every explicit reason. +The verdict is not a confidence score. -## Illustrative receipt +## What a receipt says, including what it did not check -The shortened IDs and hashes below are illustrative sample data, not a customer -result or validation claim. +Receipts are written as Markdown and JSON next to hashed patch evidence, and can +be rechecked later with `codetruss verify latest`. Every receipt states the +detection gaps in its own body, so a `PASS` is never mistaken for a security +clearance. Abridged from a real 0.2.30 run: ```markdown # CodeTruss receipt — REVIEW_REQUIRED -- Task: Fix auth callback validation -- Evidence trees: `a1b2c3…` → `d4e5f6…` -- Policy SHA-256: `91f0ab…` +- **Task:** Fix auth callback validation +- **Evidence trees:** `f8c28a26…` → `52fdebbe…` +- **Policy SHA-256:** `368f88df…` ## Verdict: REVIEW_REQUIRED -- 1 file changed outside approved scope: infra/main.tf +- 1 file(s) changed outside approved scope: infra/main.tf - sensitive surfaces changed: infra/main.tf (iac) -## Verification +## Analysis profile + +Profile: `local-registry-v1`. + +The 13 deterministic registry analyzers ran locally on this machine. -- `pnpm test` — exit 0 +### What did not run -Diff evidence: complete, SHA-256 `7bc21e…`. +- **Security static analysis (SAST).** No injection or taint analysis was + performed. SQL injection, command injection, code injection, path traversal, + SSRF, open redirect, XSS and insecure deserialization were never checked. +- **Hosted symbol graph.** No cross-file call or data-flow graph was built. +- **Hosted Health scores.** Not calculated, reported as **N/A**. + +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. ``` -Receipts are written as Markdown and JSON alongside the hashed patch evidence -and can be checked later with `codetruss verify latest`. +SAST and the symbol graph are hosted-only. A local run never performs injection +or taint analysis. + +## Measured accuracy + +On a nine-case adversarial corpus of AI-agent bug classes, the analyzers caught +five at the exact file and line, with zero false positives across eight +repositories. The four misses are named, each with the reason it needs dataflow +analysis the local passes do not perform. + +Method, per-case reasoning, and the misses are published at +[codetruss.com/benchmark](https://codetruss.com/benchmark). ## Privacy -Deterministic `run`, `review`, `report`, `list`, `metrics`, `init`, `setup`, -`verify`, `verify-policy`, and hook checks run locally without contacting -CodeTruss. Installing through npm fetches the package from the npm registry; the -shell installers fetch release metadata and package bytes from CodeTruss. -Optional review with `--llm --provider anthropic|openai|claude` sends the -bounded task and diff directly to the -selected provider using the developer's API key or authenticated local Claude -Code. CodeTruss receives no receipt unless the developer explicitly runs -`codetruss sync`; there is no background telemetry or synchronization. - -`codetruss auth login` contacts CodeTruss device/session endpoints but uploads -no source, patch, or receipt. `auth status` contacts the session endpoint to -verify the saved credential. `auth logout` revokes the server-side credential -before deleting the local copy; if revocation fails, the local copy is retained -for retry. Neither command sends source, patches, or receipts. Local receipts -identify the 13-pass `local-registry-v1` analyzer profile and report hosted -Health scores as N/A; graph and SAST analysis remain part of the hosted full -audit. +`run`, `review`, `report`, `list`, `metrics`, `init`, `setup`, `verify`, +`verify-policy`, and the hook checks are deterministic and run locally without +contacting CodeTruss. Installing fetches release metadata and package bytes from +CodeTruss or npm. + +Optional `--llm --provider anthropic|openai|claude` sends the bounded task and +diff straight to that provider using your own API key or authenticated local +Claude Code. It never crosses CodeTruss servers, and it is force-disabled under +agent hooks so hook receipts stay deterministic. The receipt discloses reviewed +versus total diff bytes, and truncation prevents `PASS`. + +`codetruss sync` is the only command that uploads a receipt, and it strips the +patch, absolute local path, agent command, raw verification commands and output, +and signing secrets. There is no background telemetry. `auth login`, `status`, +and `logout` contact CodeTruss session endpoints only and send no source, patch, +or receipt. Agent-turn evidence is held in a private per-turn Git object store under Git -metadata, is unavailable to ordinary repository Git commands, and is removed -after the receipt is complete. Synced receipts omit the patch, absolute local -path, agent command, raw verification commands/output, and signing secrets. +metadata, is invisible to ordinary repository Git commands, and is removed once +the receipt is complete. `.codetruss.yml` is reviewable repository policy and may be committed. -`.codetruss/` contains local receipts, patches, signatures, snapshots, and -generated hook runners. CodeTruss adds that evidence root to the -repository-local Git exclude and refuses future operations if evidence becomes -tracked or is routed through an unsafe path. +`.codetruss/` holds local receipts, patches, signatures, snapshots, and generated +hook runners. CodeTruss adds that evidence root to the repository-local Git +exclude and refuses to operate if evidence becomes tracked or is routed through +an unsafe path. + +## Pricing + +The CLI is free forever. Installing it, running every command above, and +producing signed receipts cost nothing and always will. There is no seat count, +no trial clock, and no feature that stops working once you depend on it. + +The paid line sits at the network boundary: local stays free, hosted is billed. +The hosted side starts free, then receipt History at $9/mo, Pro at $19 per seat, +Team at $15 per seat with a 5-seat minimum, and Agency at $249/mo including 15 +client workspaces. See [codetruss.com/pricing](https://codetruss.com/pricing). ## Source and development -This repository contains the CLI and its DB-free analyzer engine. The published -npm package and GitHub release archive contain the same bundled executable with -no runtime npm dependencies. +This repository mirrors the released CLI and its DB-free analyzer engine. The +published archive contains one bundled executable with no runtime npm +dependencies. ```bash corepack enable @@ -217,22 +273,21 @@ pnpm install --frozen-lockfile pnpm validate ``` -`pnpm validate` typechecks, builds the deterministic release, runs the source -and adversarial release tests, verifies the exact website reference, and -exercises a clean global install. +`pnpm validate` typechecks, builds the deterministic release, runs the source and +adversarial release tests, verifies the result byte-for-byte against the exact +published website artifact recorded in `release-reference.json`, and exercises a +clean global install. -Release output is written to `release/`. Verify a GitHub release attestation -with: +Verify a downloaded release yourself: ```bash -gh attestation verify codetruss-cli-VERSION.tgz --repo DeliriumPulse/codetruss-cli +gh attestation verify codetruss-cli-0.2.30.tgz --repo DeliriumPulse/codetruss-cli +shasum -a 256 -c codetruss-cli-0.2.30.tgz.sha256 ``` -Tag-driven GitHub releases and attestations do not depend on npm credentials. -The public `@codetruss/cli` package is published by an explicitly confirmed -workflow that sends the already-attested release bytes to npm. Maintainers -should follow [docs/RELEASE.md](docs/RELEASE.md) for the trusted-publishing -controls. +Maintainers should follow [docs/RELEASE.md](docs/RELEASE.md). Tag-driven GitHub +releases and attestations do not depend on npm credentials; npm publication is a +separate, explicitly confirmed workflow that sends the already-attested bytes. ## License and support diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 5786a1a..9e98ff8 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -10,8 +10,8 @@ account, npm token, package access, or npm environment approval. Update `release-reference.json` from the immutable website candidate. The verifier rejects any archive, SBOM, or executable digest that differs from that checked-in reference. -2. Create and push the matching annotated tag, for example `v0.2.24` for package - version `0.2.24`. +2. Create and push the matching annotated tag, for example `v0.2.30` for package + version `0.2.30`. 3. `.github/workflows/release.yml` installs the locked dependency graph, runs typechecking and tests, builds the package, verifies a clean global install, creates GitHub build-provenance and SBOM attestations, and finally creates the @@ -19,8 +19,8 @@ account, npm token, package access, or npm environment approval. 4. Verify the downloaded archive independently: ```bash - gh attestation verify codetruss-cli-0.2.24.tgz --repo DeliriumPulse/codetruss-cli - shasum -a 256 -c codetruss-cli-0.2.24.tgz.sha256 + gh attestation verify codetruss-cli-0.2.30.tgz --repo DeliriumPulse/codetruss-cli + shasum -a 256 -c codetruss-cli-0.2.30.tgz.sha256 ``` The release workflow intentionally has no npm environment, npm credentials, or diff --git a/packages/analyzer-engine/package.json b/packages/analyzer-engine/package.json index 3e5faf5..66b32a7 100644 --- a/packages/analyzer-engine/package.json +++ b/packages/analyzer-engine/package.json @@ -2,7 +2,6 @@ "name": "@codetruss/analyzer-engine", "version": "0.1.0", "private": true, - "license": "SEE LICENSE IN ../../LICENSE", "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", diff --git a/packages/analyzer-engine/src/coverage.ts b/packages/analyzer-engine/src/coverage.ts index 8896d52..4dcb99f 100644 --- a/packages/analyzer-engine/src/coverage.ts +++ b/packages/analyzer-engine/src/coverage.ts @@ -1,5 +1,6 @@ -import type { Analyzer, AnalyzerFinding } from './types' +import type { Analyzer, AnalyzerContext, AnalyzerFinding } from './types' import type { RepoIndex } from './types' +import { REGISTRY_ONLY_ANALYSIS } from './types' import { SUPPORTED_TREESITTER_LANGS } from './support' import { SAST_COVERED_LANGUAGES } from './support' @@ -18,10 +19,18 @@ import { SAST_COVERED_LANGUAGES } from './support' * * - SECURITY (injection, untrusted-input, deserialization): a real SAST engine * (rules + taint tracking) runs for the languages it covers - * (SAST_COVERED_LANGUAGES). For those languages the security axis is EARNED — - * real vulns lower it, genuinely clean code scores high. For every other - * language we run only regex secret scanning — that is not a security review, - * so a clean security axis there means "we didn't look", not "it's safe". + * (SAST_COVERED_LANGUAGES) IN THE PROFILES THAT INCLUDE THAT PASS. For those + * languages the security axis is EARNED — real vulns lower it, genuinely + * clean code scores high. For every other language we run only regex secret + * scanning — that is not a security review, so a clean security axis there + * means "we didn't look", not "it's safe". + * + * Language support is only half of that premise. The SAST pass lives outside + * this registry, so whether it runs at all is a property of the caller + * (AnalyzerContext.sast), not of the languages present: a hosted QUICK scan + * and every local CLI run execute the registry without it. When the pass did + * not run, "TypeScript is SAST-covered" says nothing about this analysis, and + * staying silent would be the blind spot the module exists to prevent. * * Everything outside the structure set is *surface-scanned* — structure regex, * secrets, file size, docs — with no architecture, flow, or complexity @@ -160,6 +169,8 @@ export function analysisCoverage(index: RepoIndex): AnalysisCoverage { * * - Genuinely unsupported language (no extractor, no tree-sitter): the strong * "limited analysis" caveat — architecture/flow/complexity were NOT analyzed. + * - SAST pass absent from this profile while the repo has code it would have + * covered: the pass-level caveat — the injection classes were never examined. * - Tree-sitter language (structure fully analyzed, security surface-only): the * nuanced caveat — structure is real, security is secret-scanning only. */ @@ -168,7 +179,7 @@ export const coverageAnalyzer: Analyzer = { name: 'Analysis coverage', description: 'Discloses the depth of analysis per language so a clean report is not misread as a full architecture + security review.', - async run(index: RepoIndex): Promise { + async run(index: RepoIndex, context: AnalyzerContext = REGISTRY_ONLY_ANALYSIS): Promise { const coverage = analysisCoverage(index) if (coverage.structureLimited) { @@ -204,6 +215,48 @@ export const coverageAnalyzer: Analyzer = { ] } + // The SAST pass is not in this registry, so a profile can omit it entirely. + // When it did and the repo holds a meaningful amount of code that pass + // covers, silence would read as "nothing found" for classes nobody looked + // for — the exact misreading the language caveats below exist to prevent. + if (!context.sast && coverage.securityLoc >= MIN_ANALYZABLE_LOC) { + const sastLanguages = Object.entries(index.languages) + .filter(([lang]) => isSecurityDeep(lang)) + .sort((a, b) => b[1] - a[1]) + .map(([lang]) => lang) + return [ + { + category: 'SECURITY_HYGIENE', + severity: 'INFO', + title: 'Security static analysis did not run in this analysis profile', + description: + `This analysis ran the deterministic registry analyzers only. The SAST pass — the ` + + `security rule pack plus taint tracking from untrusted source to dangerous sink — is ` + + `not part of this profile, so it never examined the ${sastLanguages.join(', ')} code ` + + `here (${coverage.securityLoc} lines). The classes that pass owns were not checked at ` + + `all: SQL injection, command injection, code injection, path traversal, SSRF, open ` + + `redirect, XSS and insecure deserialization, plus the pattern rules for hardcoded ` + + `credentials, disabled TLS validation, weak hashes and ciphers, and insecure ` + + `randomness. Secret scanning, dependency advisories and every other registry pass did ` + + `run and their results stand. Read the absence of injection findings as "that analysis ` + + `did not run", not "this code is clean" — this says nothing either way about whether ` + + `the code is vulnerable.`, + suggestion: + `Do not treat this run as an injection-class security review. A hosted SECURITY or ` + + `FULL_AUDIT scan runs the SAST pass over the same code and reports what it finds.`, + impactScore: 20, + effort: 'low', + metadata: { + sastPassRan: false, + securityRatio: coverage.securityRatio, + securityLoc: coverage.securityLoc, + totalLoc: coverage.totalLoc, + sastLanguages, + }, + }, + ] + } + if (coverage.securityLimited) { const lang = coverage.primaryLanguage ?? coverage.structureOnlyLanguages[0] ?? 'this language' @@ -211,18 +264,18 @@ export const coverageAnalyzer: Analyzer = { { category: 'SECURITY_HYGIENE', severity: 'INFO', - title: `Security is surface-only for ${lang}`, + title: `Security coverage is partial for ${lang}`, description: `Structure and complexity for ${lang} are fully analyzed via AST parsing — the ` + - `architecture, call-graph and health scores reflect the real code. Security, however, ` + - `is surface-only: CodeTruss ran secret scanning (regex) over ${lang}, NOT a full ` + - `security review. CodeTruss's SAST engine (security rules + taint tracking for ` + - `injection, untrusted-input and deserialization) runs only for TypeScript, JavaScript ` + - `and Python. Read the security score as "no leaked secrets found", not "this code is ` + - `secure".`, + `architecture, call-graph and health scores reflect the real code. Security coverage ` + + `is partial, not absent: CodeTruss ran secret scanning plus the pattern and dataflow ` + + `rules that apply to ${lang}, but its untrusted-input catalog is tuned for TypeScript, ` + + `JavaScript and Python, so injection and deserialization coverage for ${lang} is ` + + `narrower than for those languages. Read the security score as "nothing found by the ` + + `checks that ran", not "this code is secure".`, suggestion: - `Treat the security score as secret-scanning coverage only for ${lang}. A manual ` + - `security review is still warranted for injection, untrusted input and deserialization.`, + `Treat the security score for ${lang} as partial coverage. A manual security review ` + + `is still warranted for injection, untrusted input and deserialization.`, impactScore: 20, effort: 'low', metadata: { diff --git a/packages/analyzer-engine/src/dead-code.ts b/packages/analyzer-engine/src/dead-code.ts index 35a8560..46e7985 100644 --- a/packages/analyzer-engine/src/dead-code.ts +++ b/packages/analyzer-engine/src/dead-code.ts @@ -29,23 +29,55 @@ export const deadCodeAnalyzer: Analyzer = { // Entry-point-ish files that are loaded by convention, not by import // (proxy.ts is Next 16's middleware — deleting it would drop the auth gate) - const CONVENTION = /(page|layout|route|loading|error|not-found|template|default|middleware|proxy|instrumentation|opengraph-image|twitter-image|icon|apple-icon|sitemap|robots|manifest|index|main|app|server|config|next-env|globals)\.[jt]sx?$|\.(d|config|test|spec)\.[cm]?[jt]s$/ + // `instrumentation-client` / `instrumentation.edge` are the suffixed + // variants Next.js and Sentry install; `*.stories.tsx` is collected by + // Storybook's glob. Neither is ever imported. + const CONVENTION = /(page|layout|route|loading|error|not-found|template|default|middleware|proxy|instrumentation|opengraph-image|twitter-image|icon|apple-icon|sitemap|robots|manifest|index|main|app|server|config|next-env|globals)\.[jt]sx?$|^instrumentation[-.][a-z]+\.[jt]sx?$|\.(stories|story)\.[jt]sx?$|\.(d|config|test|spec)\.[cm]?[jt]s$/ - const allContent = haystackFiles.map((f) => f.content!).join('\n') + // package.json script values reference runner entrypoints ("npx tsx + // lib/db/seed.ts") — raw manifest JSON satisfies the quoted-ref needle. + const manifests = index.files.filter( + (f) => f.content && /(^|\/)package\.json$/.test(f.path), + ) + const allContent = haystackFiles + .concat(manifests) + .map((f) => f.content!) + .join('\n') + + // Generated files are indexed with null content, so their imports are + // invisible to the needle below. A generated OpenAPI client importing + // `core/request.ts` would make that file look orphaned — excluding a file + // must never manufacture dead code for the files it uses. Treat everything + // under a directory that holds generated output as reachable. + const generatedDirs = Object.keys(index.generatedFiles ?? {}).map((p) => + p.includes('/') ? p.slice(0, p.lastIndexOf('/') + 1) : '', + ) + const nearGeneratedOutput = (path: string) => + generatedDirs.some((dir) => (dir === '' ? !path.includes('/') : path.startsWith(dir))) const candidateLimit = 1500 for (const file of jsFiles.slice(0, candidateLimit)) { + if (nearGeneratedOutput(file.path)) continue // CLI/tooling entry points are invoked by runners, not imports if (/(^|\/)(scripts|bin|tools)\//.test(file.path)) continue + // Next.js Pages Router: the PATH is the route, so nothing imports these. + // Anchored at the project root so a `components/pages/` folder is unaffected. + if (/^(src\/)?pages\//.test(file.path)) continue const base = file.path.split('/').pop()! if (CONVENTION.test(base)) continue + // Tooling dotfiles (.prettierrc.js, .eslintrc.js) are loaded by name. + if (base.startsWith('.')) continue const stem = base.replace(/\.[cm]?[jt]sx?$/, '') if (stem.length < 3) continue // too ambiguous to match safely // Imported anywhere? look for `/stem'`, `/stem"`, or `from './stem`-style // refs — or a bare quoted `stem.ext` (a spawn-by-string worker path built // from segments, e.g. join(cwd, 'src', 'lib', 'batch-process.ts')). + // The final alternative catches a path embedded in a longer command + // string — `"sync-stripe": "node --env-file .env sync-stripe.js"` — where + // the quote does not directly abut the path. const needle = new RegExp( - `['"\`](?:[^'"\`]*/${escapeRegExp(stem)}(\\.[cm]?[jt]sx?)?|${escapeRegExp(stem)}\\.[cm]?[jt]sx?)['"\`]`, + `['"\`](?:[^'"\`]*/${escapeRegExp(stem)}(\\.[cm]?[jt]sx?)?|${escapeRegExp(stem)}\\.[cm]?[jt]sx?)['"\`]` + + `|[\\s/]${escapeRegExp(stem)}\\.[cm]?[jt]sx?(?=[\\s'"\`])`, ) if (!needle.test(allContent)) { findings.push({ diff --git a/packages/analyzer-engine/src/detect.ts b/packages/analyzer-engine/src/detect.ts index f4c5b9d..8387a3c 100644 --- a/packages/analyzer-engine/src/detect.ts +++ b/packages/analyzer-engine/src/detect.ts @@ -49,7 +49,16 @@ export function classifyFile(path: string): string { const p = path.toLowerCase() const base = p.split('/').pop() ?? p - if (/\.(test|spec)\.[jt]sx?$/.test(base) || p.includes('__tests__/') || p.startsWith('test/') || p.startsWith('tests/')) + if ( + /\.(test|spec)\.[jt]sx?$/.test(base) || + /_(test|spec)\.(go|py|rb|exs?)$/.test(base) || + /^test_[^/]*\.py$/.test(base) || + p.includes('__tests__/') || + p.includes('__mocks__/') || + /(^|\/)tests?\//.test(p) || + // spec/ dirs are RSpec only for Ruby files — OpenAPI specs live there too + (/(^|\/)specs?\//.test(p) && base.endsWith('.rb')) + ) return 'test' if (p.includes('/migrations/') || p.includes('/migrate/')) return 'migration' if (base === 'dockerfile' || base === 'package.json' || /\.(ya?ml|toml|ini|env\.example)$/.test(base) || base.startsWith('.env')) @@ -237,6 +246,36 @@ export function looksGenerated(content: string): boolean { return /auto-?generated|@generated|generated by|do not edit/i.test(head) } +/** Below this many bytes a file produces too few findings to be worth excluding. */ +const MIN_MINIFIED_BYTES = 2_000 +/** + * AVERAGE line width separating packed build output from authored code. + * Measured across real repos: authored files top out around 62 chars/line + * (a 639-line React/Tailwind route) and 27 (a 50KB app.js), while every + * minified bundle observed started at 2,556 (jQuery 16,849; Ace 22,069). + * + * Deliberately NOT max line width: real React/Tailwind source carries the odd + * 1,000+ char className string or inline SVG path, and excluding a whole file + * for one long line silently hides authored code from every analyzer. + */ +const MINIFIED_MEAN_LINE_WIDTH = 200 + +/** + * Minified/bundled output: a build artifact or vendored library, not authored + * source. Analyzing it reports "deep nesting" and "oversized file" against code + * the team never wrote and cannot fix — on one real audit these were 41% of all + * findings. Detected by shape rather than filename so bundles that are not + * named `.min.js` are caught too. + */ +export function looksMinified(content: string): boolean { + if (content.length < MIN_MINIFIED_BYTES) return false + let newlines = 0 + for (let i = 0; i < content.length; i++) { + if (content.charCodeAt(i) === 10 /* \n */) newlines++ + } + return content.length / (newlines + 1) >= MINIFIED_MEAN_LINE_WIDTH +} + export function detectDatabases(deps: Set, paths: string[]): string[] { const dbs: string[] = [] if (deps.has('pg') || deps.has('postgres')) dbs.push('PostgreSQL') diff --git a/packages/analyzer-engine/src/env-vars.ts b/packages/analyzer-engine/src/env-vars.ts index 372e072..07e523a 100644 --- a/packages/analyzer-engine/src/env-vars.ts +++ b/packages/analyzer-engine/src/env-vars.ts @@ -1,5 +1,19 @@ import type { Analyzer, AnalyzerFinding } from './types' +/** + * A committed runtime .env file (test fixtures excluded, mirroring the + * structure analyzer's own gate). Its presence means the structure analyzer + * already owns the "missing .env.example" finding for this repository. + */ +function hasRuntimeEnvFile(index: { files: Array<{ path: string }> }): boolean { + return index.files.some( + (f) => + /\.env(\.|$)/.test(f.path.split('/').pop() ?? '') && + !/^\.env\.example$/.test(f.path) && + !/(^|\/)(tests?|__tests__|__mocks__|fixtures)\//i.test(f.path), + ) +} + /** Environment variable usage vs. documentation drift. */ export const envVarsAnalyzer: Analyzer = { id: 'env-vars', @@ -78,9 +92,13 @@ export const envVarsAnalyzer: Analyzer = { effort: 'low', metadata: { variables: undocumented.slice(0, 50) }, }) - } else if (index.repoType !== 'library') { + } else if (index.repoType !== 'library' && !hasRuntimeEnvFile(index)) { // No template at all: one LOW nudge, not a per-var wall. Libraries have // no deploy environment, so skip them entirely. + // + // Suppressed when a runtime .env file exists, because the structure + // analyzer already reports the missing template for that case — the + // reader was otherwise shown two rows demanding the same single action. findings.push({ category: 'DOCUMENTATION', severity: 'LOW', diff --git a/packages/analyzer-engine/src/indexer.ts b/packages/analyzer-engine/src/indexer.ts index 01dba44..23f8756 100644 --- a/packages/analyzer-engine/src/indexer.ts +++ b/packages/analyzer-engine/src/indexer.ts @@ -10,6 +10,7 @@ import { detectPackageManagers, detectRepoType, looksGenerated, + looksMinified, parsePyprojectDeps, parseRequirementsTxt, } from './detect' @@ -66,6 +67,7 @@ export function vendoredRoot(path: string): string | null { */ export function generatedLabel(content: string): string | null { if (looksGenerated(content)) return 'self-declared generated file' + if (looksMinified(content)) return 'minified or bundled asset' const head = content.slice(0, 4000) // Supabase `supabase gen types typescript` — large, header-less, generated. if (/\b__InternalSupabase\b/.test(head) && /\bexport type Database\b/.test(head)) { diff --git a/packages/analyzer-engine/src/runner.ts b/packages/analyzer-engine/src/runner.ts index 4f3b4fc..c169a18 100644 --- a/packages/analyzer-engine/src/runner.ts +++ b/packages/analyzer-engine/src/runner.ts @@ -1,5 +1,12 @@ import { getAnalyzers } from './registry' -import { analyzerResult, type AnalyzerFinding, type AnalyzerRunResult, type RepoIndex } from './types' +import { + analyzerResult, + REGISTRY_ONLY_ANALYSIS, + type AnalyzerContext, + type AnalyzerFinding, + type AnalyzerRunResult, + type RepoIndex, +} from './types' export interface AnalyzerPass { id: string @@ -10,12 +17,13 @@ export interface AnalyzerPass { /** Run every deterministic analyzer without allowing one failed pass to abort the suite. */ export async function runAnalyzers( index: RepoIndex, + context: AnalyzerContext = REGISTRY_ONLY_ANALYSIS, ): Promise<{ findings: AnalyzerFinding[]; passes: AnalyzerPass[] }> { const findings: AnalyzerFinding[] = [] const passes: AnalyzerPass[] = [] for (const analyzer of getAnalyzers()) { try { - const raw = analyzerResult(await analyzer.run(index)) + const raw = analyzerResult(await analyzer.run(index, context)) const result = { ...raw, findings: raw.findings.map((finding) => ({ ...finding, analyzerId: analyzer.id })), diff --git a/packages/analyzer-engine/src/secrets.ts b/packages/analyzer-engine/src/secrets.ts index ba07887..9dd47fd 100644 --- a/packages/analyzer-engine/src/secrets.ts +++ b/packages/analyzer-engine/src/secrets.ts @@ -23,14 +23,34 @@ const SECRET_PATTERNS: Array<{ name: string; re: RegExp }> = [ const SKIP_FILES = /(\.env\.example|\.md|\.lock|package-lock\.json|pnpm-lock\.yaml)$/i const PLACEHOLDER = /(example|placeholder|your[-_]|xxx|changeme|dummy|<[^>]+>|\$\{)/i -/** Placeholder shapes inside the matched value itself. */ -const PLACEHOLDER_VALUE = /(example|sample|dummy|fake|placeholder|changeme|your[-_]?(key|token|secret)|abc123|xxx+)/i +/** + * Runtime credential REFERENCES — `process.env.X`, `{{...}}`, `${VAR}`, `ENV[]`. + * This is how a credential is *supposed* to be written, so it is skipped in + * silence; annotating correct code would be noise. + */ +const RUNTIME_CREDENTIAL_REFERENCE = /(\{\{|\$\{|process\.env|os\.environ|os\.getenv|\bgetenv\b|ENV\[)/i + +/** + * Literals that announce themselves as fake. Skipping these silently is + * correct, but a silent skip looks exactly like a scanner that detects nothing + * — which is what a developer concludes when they test it with a dummy key. + * Reported at INFO so the skip is visible and explained. + */ +const FAKE_LITERAL_VALUE = + /(example|sample|dummy|fake|placeholder|changeme|your[-_]?(key|token|secret)|abc123|xxx+)/i + +/** SCREAMING_SNAKE values are constant identifiers, not credentials — + * `UPDATE_PASSWORD = 'UPDATE_PASSWORD'` is an enum member. */ +const CONSTANT_IDENTIFIER_VALUE = /^[A-Z0-9]+(_[A-Z0-9]+)+$/ /** Dev/CI dummy hosts — credentials pointing here are not real secrets. */ const DUMMY_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', 'host.docker.internal']) -/** Test/fixture locations: findings here are downgraded, not silenced. */ -const TEST_PATH_RE = /(^|\/)(tests?|__tests__|__mocks__|fixtures)\/|\.(test|spec)\./ +/** Test/fixture locations: findings here are downgraded, not silenced. + * Covers JS (.test./.spec., __tests__/), Go (_test.go), Python (test_*.py, + * *_test.py), and Ruby (spec/, *_spec.rb) conventions. */ +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 @@ -39,6 +59,13 @@ const TEST_PATH_RE = /(^|\/)(tests?|__tests__|__mocks__|fixtures)\/|\.(test|spec */ const TEST_DOWNGRADEABLE = new Set(['Generic password assignment']) +/** + * Database seed / fixture scripts. Their credentials are deliberate, documented + * dev defaults, so "treat as compromised, rotate immediately" is the wrong + * instruction — the real risk is the seed path reaching a production database. + */ +const SEED_PATH_RE = /(^|\/)(seed|seeds|seeders?)(\/|\.|$)|(^|\/)seed[-_.][^/]*$/i + export const secretsAnalyzer: Analyzer = { id: 'secrets', name: 'Exposed Secrets', @@ -50,6 +77,7 @@ export const secretsAnalyzer: Analyzer = { for (const file of index.files) { if (!file.content || SKIP_FILES.test(file.path)) continue const isTestContext = TEST_PATH_RE.test(file.path) + const isSeedScript = SEED_PATH_RE.test(file.path) const lines = file.content.split('\n') for (let i = 0; i < lines.length && findings.length < findingLimit; i++) { const line = lines[i] @@ -57,7 +85,33 @@ export const secretsAnalyzer: Analyzer = { for (const { name, re } of SECRET_PATTERNS) { const match = line.match(re) if (!match) continue - if (PLACEHOLDER_VALUE.test(match[0])) continue + if (RUNTIME_CREDENTIAL_REFERENCE.test(match[0])) continue + if (FAKE_LITERAL_VALUE.test(match[0])) { + findings.push({ + category: 'SECURITY_HYGIENE', + severity: 'INFO', + title: `Credential-shaped placeholder ignored in ${file.path.split('/').pop()}`, + description: `Line ${i + 1} of ${file.path} matches a ${name} pattern, but the value announces itself as a placeholder, so it is NOT reported as a leak. Shown only to confirm the scanner read this line — a real credential in the same position would be reported.`, + filePath: file.path, + line: i + 1, + suggestion: 'No action needed. This entry exists so a deliberate skip is never mistaken for a missed detection.', + impactScore: 5, + effort: 'low', + metadata: { credentialType: name, placeholder: true }, + }) + break + } + // A credential committed to source is a single token. Internal + // whitespace means the value is display text — a validation message, + // an i18n string, a UI label — which nearly every repo has under a + // `password:` key. Reported HIGH it would say "rotate immediately" + // about a translation, and drag the security score with it. + let messageString = false + if (name === 'Generic password assignment') { + const value = /['"]([^'"]+)['"]/.exec(match[0])?.[1] + if (value && CONSTANT_IDENTIFIER_VALUE.test(value)) continue + if (value && /\s/.test(value)) messageString = true + } if (name === 'Database URL with credentials') { const hostPort = match[3] const host = hostPort.replace(/:\d+$/, '').replace(/^\[|\]$/g, '') @@ -65,20 +119,42 @@ export const secretsAnalyzer: Analyzer = { // (postgres:postgres, admin:admin) on a real host still are. if (DUMMY_HOSTS.has(host)) continue } + // Committed .env files always escalate, even under tests/ or a seed path. const isEnvFile = /(^|\/)\.env/.test(file.path) - // Committed .env files always escalate, even under tests/. - if (isTestContext && TEST_DOWNGRADEABLE.has(name) && !isEnvFile) { + const baseName = file.path.split('/').pop() + if (!isEnvFile && !messageString && isSeedScript && TEST_DOWNGRADEABLE.has(name)) { + findings.push({ + category: 'SECURITY_HYGIENE', + severity: 'MEDIUM', + title: `Seed-script credential in ${baseName}`, + description: `Line ${i + 1} of ${file.path} sets a ${name} inside a database seed script. Seed defaults are usually intentional, but anyone who can read the repository knows this login, so it becomes a real account the moment seeding runs anywhere reachable.`, + filePath: file.path, + line: i + 1, + suggestion: 'Confirm this seed cannot execute against a production or shared database (guard it on NODE_ENV, keep it out of deploy and migration steps), and generate the value at runtime instead of committing it.', + impactScore: 45, + effort: 'low', + metadata: { credentialType: name, seedScript: true }, + }) + break + } + if (!isEnvFile && (messageString || (isTestContext && TEST_DOWNGRADEABLE.has(name)))) { findings.push({ category: 'SECURITY_HYGIENE', severity: 'LOW', - title: `Test fixture resembling a secret: ${name} in ${file.path.split('/').pop()}`, - description: `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.`, + title: messageString + ? `Message text under a credential-shaped key in ${baseName}` + : `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.`, filePath: file.path, line: i + 1, - suggestion: 'Use an obviously fake placeholder (e.g. "test-not-a-real-key") so scanners and reviewers can dismiss it at a glance.', - impactScore: 25, + suggestion: messageString + ? 'No action needed if this is user-facing copy. If a real passphrase was pasted here, rotate it and move it to environment configuration.' + : 'Use an obviously fake placeholder (e.g. "test-not-a-real-key") so scanners and reviewers can dismiss it at a glance.', + impactScore: messageString ? 10 : 25, effort: 'low', - metadata: { credentialType: name, testContext: true }, + metadata: { credentialType: name, testContext: isTestContext, messageString }, }) } else { findings.push({ diff --git a/packages/analyzer-engine/src/structure.ts b/packages/analyzer-engine/src/structure.ts index d0b8845..4e84591 100644 --- a/packages/analyzer-engine/src/structure.ts +++ b/packages/analyzer-engine/src/structure.ts @@ -107,18 +107,26 @@ export const structureAnalyzer: Analyzer = { const generatedFiles = index.generatedFiles ?? {} const generatedPaths = Object.keys(generatedFiles) const generatedLoc = Object.values(generatedFiles).reduce((a, b) => a + b, 0) - if (generatedPaths.length > 0 && generatedLoc >= 500) { + // Minified bundles pack thousands of statements onto a handful of lines, so + // their LOC understates the excluded volume by orders of magnitude. Gate and + // report on BYTES too, or a 570KB vendored payload disappears silently. + const generatedBytes = index.files + .filter((f) => f.kind === 'generated') + .reduce((total, f) => total + (f.sizeBytes ?? 0), 0) + const generatedKb = Math.round(generatedBytes / 1024) + if (generatedPaths.length > 0 && (generatedLoc >= 500 || generatedBytes >= 50_000)) { const plural = generatedPaths.length > 1 + const volume = `~${generatedLoc.toLocaleString()} LOC, ${generatedKb.toLocaleString()} KB` findings.push({ category: 'STRUCTURE', severity: 'LOW', - title: `Generated code excluded from analysis (${generatedPaths.length} file${plural ? 's' : ''}, ~${generatedLoc.toLocaleString()} LOC)`, - description: `CodeTruss detected ${generatedPaths.length} machine-generated file${plural ? 's' : ''} (~${generatedLoc.toLocaleString()} LOC, e.g. \`${generatedPaths[0]}\`) and excluded ${plural ? 'them' : 'it'} from LOC totals, scores, and the architecture graph so ${plural ? 'they' : 'it'} don't inflate metrics or produce spurious "oversized file" / "duplicated logic" findings.`, + title: `Generated code excluded from analysis (${generatedPaths.length} file${plural ? 's' : ''}, ${generatedKb.toLocaleString()} KB)`, + description: `CodeTruss detected ${generatedPaths.length} machine-generated or minified file${plural ? 's' : ''} (${volume}, e.g. \`${generatedPaths[0]}\`) and excluded ${plural ? 'them' : 'it'} from LOC totals, scores, and the architecture graph so ${plural ? 'they' : 'it'} don't inflate metrics or produce spurious "oversized file" / "duplicated logic" findings. Minified bundles report few lines for their size, so the KB figure is the honest measure of what was skipped.`, filePath: generatedPaths[0], - suggestion: 'Keep generated files out of review scope — regenerate them at build time, or mark them linguist-generated in .gitattributes.', + suggestion: 'Keep generated and vendored bundles out of review scope — regenerate them at build time, or mark them linguist-generated in .gitattributes.', impactScore: 20, effort: 'low', - metadata: { files: generatedPaths.length, loc: generatedLoc }, + metadata: { files: generatedPaths.length, loc: generatedLoc, bytes: generatedBytes }, }) } diff --git a/packages/analyzer-engine/src/types.ts b/packages/analyzer-engine/src/types.ts index 30937d2..ace0118 100644 --- a/packages/analyzer-engine/src/types.ts +++ b/packages/analyzer-engine/src/types.ts @@ -107,9 +107,30 @@ export function analyzerResult(output: AnalyzerFinding[]): AnalyzerRunResult { return { findings: output, complete: status?.complete ?? true, ...status } } +/** + * Which passes OUTSIDE the registry run alongside the analyzers in this + * execution. The registry is shared, but the passes around it are not: the + * hosted pipeline runs SAST for the scan types whose policy includes it, and + * the local CLI never runs it at all. An analyzer that discloses coverage has + * to be told, because "the SAST engine covers TypeScript" is a fact about the + * engine, not about this run. + */ +export interface AnalyzerContext { + /** The SAST pass (security rules + taint tracking) runs for this analysis. */ + sast: boolean +} + +/** + * Fail-honest default: a caller that does not declare its non-registry passes + * is treated as running none of them. A front-end that omits the declaration + * therefore over-discloses rather than inheriting a caveat written for a pass + * it never executes. + */ +export const REGISTRY_ONLY_ANALYSIS: AnalyzerContext = { sast: false } + export interface Analyzer { id: string name: string description: string - run(index: RepoIndex): Promise + run(index: RepoIndex, context?: AnalyzerContext): Promise } diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 091a752..ead7df3 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -5,6 +5,102 @@ checksums are published at createHash('sha256').update(value).digest('hex') async function writeRelease(archivePackageDir = packageDir) { - await rm(releaseDir, { recursive: true, force: true }) - await mkdir(releaseDir, { recursive: true }) - const pkg = JSON.parse(await readFile(join(archivePackageDir, 'package.json'), 'utf8')) - const archiveName = `codetruss-cli-${pkg.version}.tgz` - const sbomName = `codetruss-cli-${pkg.version}.sbom.cdx.json` - const archive = await buildDeterministicPackageArchive(archivePackageDir, join(releaseDir, archiveName)) - const entries = verifyDeterministicPackageArchive(archive) - const bundle = entries.get('package/dist/cli.cjs') - if (!bundle) throw new Error('fixture archive does not contain the CLI executable') + const pkg = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf8')) + const versionedName = `codetruss-cli-${pkg.version}.tgz` + const latestName = 'codetruss-cli-latest.tgz' + const versionedSbomName = `codetruss-cli-${pkg.version}.sbom.cdx.json` + const archive = await buildDeterministicPackageArchive(archivePackageDir, join(downloadDir, versionedName)) + const sha256 = digest(archive) const sbom = await readFile(join(archivePackageDir, 'SBOM.cdx.json')) - const archiveSha256 = digest(archive) const sbomSha256 = digest(sbom) - await writeFile(join(releaseDir, sbomName), sbom) - await writeFile(join(releaseDir, `${archiveName}.sha256`), `${archiveSha256} ${archiveName}\n`) - await writeFile(join(releaseDir, 'release-manifest.json'), `${JSON.stringify({ - schemaVersion: 1, - package: pkg.name, - version: pkg.version, - tag: `v${pkg.version}`, - repository: 'https://github.com/DeliriumPulse/codetruss-cli', - node: pkg.engines.node, - artifacts: [ - { name: archiveName, mediaType: 'application/gzip', sha256: archiveSha256 }, - { name: sbomName, mediaType: 'application/vnd.cyclonedx+json', sha256: sbomSha256 }, - ], - }, null, 2)}\n`) - await writeFile(referencePath, `${JSON.stringify({ - schemaVersion: 1, + await writeFile(join(downloadDir, latestName), archive) + await writeFile(join(downloadDir, versionedSbomName), sbom) + await writeFile(join(downloadDir, 'codetruss-cli-latest.sbom.cdx.json'), sbom) + await writeFile(join(downloadDir, `${versionedName}.sha256`), `${sha256} ${versionedName}\n`) + await writeFile(join(downloadDir, `${latestName}.sha256`), `${sha256} ${latestName}\n`) + await writeFile(join(downloadDir, 'codetruss-cli-latest.json'), `${JSON.stringify({ + name: pkg.name, version: pkg.version, - websiteArchive: `https://codetruss.com/downloads/${archiveName}`, - archiveSha256, + url: `/downloads/${versionedName}`, + latestUrl: `/downloads/${latestName}`, + sha256, + sbomUrl: `/downloads/${versionedSbomName}`, sbomSha256, - bundleSha256: digest(bundle), + node: pkg.engines.node, + repository: 'https://github.com/DeliriumPulse/codetruss-cli', + releaseUrl: `https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v${pkg.version}`, + attestationCommand: `gh attestation verify ${versionedName} --repo DeliriumPulse/codetruss-cli`, }, null, 2)}\n`) -} - -async function verify(packagePath = packageDir) { - return verifyRelease({ packageDir: packagePath, releaseDir, referencePath }) + return { versionedName, latestName } } try { - await writeRelease() - await verify() + await mkdir(downloadDir, { recursive: true }) + const names = await writeRelease() + await verifyRelease({ packageDir, downloadDir }) for (const file of PACKAGE_ARCHIVE_FILES) { const target = join(tamperedPackageDir, file.source) @@ -69,14 +57,20 @@ try { maliciousManifest.scripts = { ...maliciousManifest.scripts, postinstall: 'node -e "process.exit(99)"' } await writeFile(join(tamperedPackageDir, 'package.json'), `${JSON.stringify(maliciousManifest, null, 2)}\n`) await writeRelease(tamperedPackageDir) - await assert.rejects(() => verify(), /does not contain the current package\.json/) - await assert.rejects(() => verify(tamperedPackageDir), /postinstall install lifecycle script/) + await assert.rejects(() => verifyRelease({ packageDir, downloadDir }), /current package\.json/) + await assert.rejects( + () => verifyRelease({ packageDir: tamperedPackageDir, downloadDir }), + /postinstall install lifecycle script/, + ) delete maliciousManifest.scripts.postinstall maliciousManifest.dependencies = { 'runtime-surprise': '1.0.0' } await writeFile(join(tamperedPackageDir, 'package.json'), `${JSON.stringify(maliciousManifest, null, 2)}\n`) await writeRelease(tamperedPackageDir) - await assert.rejects(() => verify(tamperedPackageDir), /must not declare dependencies/) + await assert.rejects( + () => verifyRelease({ packageDir: tamperedPackageDir, downloadDir }), + /must not declare dependencies/, + ) delete maliciousManifest.dependencies for (const [field, value] of [ @@ -86,25 +80,22 @@ try { maliciousManifest.publishConfig = { access: 'public', [field]: value } await writeFile(join(tamperedPackageDir, 'package.json'), `${JSON.stringify(maliciousManifest, null, 2)}\n`) await writeRelease(tamperedPackageDir) - await assert.rejects(() => verify(tamperedPackageDir), /package access or supported Node policy is invalid/) + await assert.rejects( + () => verifyRelease({ packageDir: tamperedPackageDir, downloadDir }), + /package access or supported Node policy is invalid/, + ) } await writeRelease() - const reference = JSON.parse(await readFile(referencePath, 'utf8')) - await writeFile(referencePath, `${JSON.stringify({ ...reference, unexpected: true }, null, 2)}\n`) - await assert.rejects(() => verify(), /reference is not the canonical website metadata/) - - await writeRelease() - const manifestPath = join(releaseDir, 'release-manifest.json') - const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) - await writeFile(manifestPath, `${JSON.stringify({ ...manifest, unexpected: true }, null, 2)}\n`) - await assert.rejects(() => verify(), /release manifest is not canonical/) + const metadataPath = join(downloadDir, 'codetruss-cli-latest.json') + const metadata = JSON.parse(await readFile(metadataPath, 'utf8')) + await writeFile(metadataPath, `${JSON.stringify({ ...metadata, unexpected: true }, null, 2)}\n`) + await assert.rejects(() => verifyRelease({ packageDir, downloadDir }), /metadata is not the canonical manifest/) await writeRelease() - const pkg = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf8')) - const sidecarPath = join(releaseDir, `codetruss-cli-${pkg.version}.tgz.sha256`) - await writeFile(sidecarPath, `${await readFile(sidecarPath, 'utf8')}untrusted trailing bytes\n`) - await assert.rejects(() => verify(), /not the canonical checksum/) + const latestSidecar = join(downloadDir, `${names.latestName}.sha256`) + await writeFile(latestSidecar, `${await readFile(latestSidecar, 'utf8')}untrusted trailing bytes\n`) + await assert.rejects(() => verifyRelease({ packageDir, downloadDir }), /not the canonical checksum/) for (const file of PACKAGE_ARCHIVE_FILES) { await copyFile(join(packageDir, file.source), join(tamperedPackageDir, file.source)) @@ -114,11 +105,11 @@ try { await writeFile(join(tamperedPackageDir, 'SBOM.cdx.json'), `${JSON.stringify(noncanonicalSbom, null, 2)}\n`) await writeRelease(tamperedPackageDir) await assert.rejects( - () => verify(tamperedPackageDir), + () => verifyRelease({ packageDir: tamperedPackageDir, downloadDir }), /does not have the canonical CycloneDX identity/, ) } finally { await rm(scratch, { recursive: true, force: true }) } -process.stdout.write('Public release integrity verifier tests passed.\n') +process.stdout.write('Release integrity verifier tests passed.\n') diff --git a/packages/cli/scripts/verify-release.mjs b/packages/cli/scripts/verify-release.mjs index 31d99fb..534e571 100644 --- a/packages/cli/scripts/verify-release.mjs +++ b/packages/cli/scripts/verify-release.mjs @@ -8,70 +8,57 @@ import { verifyDeterministicPackageArchive } from './verify-deterministic-packag const scriptPath = fileURLToPath(import.meta.url) const defaultPackageDir = resolve(dirname(scriptPath), '..') -const defaultRepoRoot = resolve(defaultPackageDir, '../..') -const defaultReleaseDir = join(defaultRepoRoot, 'release') -const defaultReferencePath = join(defaultRepoRoot, 'release-reference.json') +const defaultDownloadDir = join(resolve(defaultPackageDir, '../..'), 'public', 'downloads') const digest = (value) => createHash('sha256').update(value).digest('hex') -export async function verifyRelease({ - packageDir = defaultPackageDir, - releaseDir = defaultReleaseDir, - referencePath = defaultReferencePath, -} = {}) { +export async function verifyRelease({ packageDir = defaultPackageDir, downloadDir = defaultDownloadDir } = {}) { const pkgBytes = await readFile(join(packageDir, 'package.json')) const pkg = JSON.parse(pkgBytes.toString('utf8')) assertReleasePackagePolicy(pkg) - const archiveName = `codetruss-cli-${pkg.version}.tgz` - const sbomName = `codetruss-cli-${pkg.version}.sbom.cdx.json` - const archive = await readFile(join(releaseDir, archiveName)) - const sbom = await readFile(join(releaseDir, sbomName)) - const archiveSha256 = digest(archive) - const sbomSha256 = digest(sbom) - const entries = verifyDeterministicPackageArchive(archive) - const bundle = entries.get('package/dist/cli.cjs') - if (!bundle) throw new Error('release archive does not contain the CLI executable') - const bundleSha256 = digest(bundle) - - const expectedReference = { - schemaVersion: 1, + const versionedName = `codetruss-cli-${pkg.version}.tgz` + const latestName = 'codetruss-cli-latest.tgz' + const versionedSbomName = `codetruss-cli-${pkg.version}.sbom.cdx.json` + const versioned = await readFile(join(downloadDir, versionedName)) + const latest = await readFile(join(downloadDir, latestName)) + const versionedSbom = await readFile(join(downloadDir, versionedSbomName)) + const latestSbom = await readFile(join(downloadDir, 'codetruss-cli-latest.sbom.cdx.json')) + const versionedSha = digest(versioned) + const sbomSha = digest(versionedSbom) + const expectedMetadata = { + name: pkg.name, version: pkg.version, - websiteArchive: `https://codetruss.com/downloads/${archiveName}`, - archiveSha256, - sbomSha256, - bundleSha256, - } - const referenceBytes = await readFile(referencePath) - const expectedReferenceBytes = Buffer.from(`${JSON.stringify(expectedReference, null, 2)}\n`, 'utf8') - if (!referenceBytes.equals(expectedReferenceBytes)) { - throw new Error(`release reference is not the canonical website metadata for ${pkg.version}`) - } - - const expectedManifest = { - schemaVersion: 1, - package: pkg.name, - version: pkg.version, - tag: `v${pkg.version}`, - repository: 'https://github.com/DeliriumPulse/codetruss-cli', + url: `/downloads/${versionedName}`, + latestUrl: `/downloads/${latestName}`, + sha256: versionedSha, + sbomUrl: `/downloads/${versionedSbomName}`, + sbomSha256: sbomSha, node: pkg.engines.node, - artifacts: [ - { name: archiveName, mediaType: 'application/gzip', sha256: archiveSha256 }, - { name: sbomName, mediaType: 'application/vnd.cyclonedx+json', sha256: sbomSha256 }, - ], + repository: 'https://github.com/DeliriumPulse/codetruss-cli', + releaseUrl: `https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v${pkg.version}`, + attestationCommand: `gh attestation verify ${versionedName} --repo DeliriumPulse/codetruss-cli`, } - const manifestBytes = await readFile(join(releaseDir, 'release-manifest.json')) - const expectedManifestBytes = Buffer.from(`${JSON.stringify(expectedManifest, null, 2)}\n`, 'utf8') - if (!manifestBytes.equals(expectedManifestBytes)) { - throw new Error(`release manifest is not canonical for ${archiveName}`) + const metadataBytes = await readFile(join(downloadDir, 'codetruss-cli-latest.json')) + const expectedMetadataBytes = Buffer.from(`${JSON.stringify(expectedMetadata, null, 2)}\n`, 'utf8') + if (!metadataBytes.equals(expectedMetadataBytes)) { + throw new Error(`release metadata is not the canonical manifest for CLI ${pkg.version}; run pnpm cli:release`) } + if (!latest.equals(versioned)) throw new Error(`${latestName} does not match immutable ${versionedName}`) + if (!latestSbom.equals(versionedSbom)) throw new Error(`latest release SBOM does not match ${versionedSbomName}`) - const sidecar = await readFile(join(releaseDir, `${archiveName}.sha256`), 'utf8') - if (sidecar !== `${archiveSha256} ${archiveName}\n`) { - throw new Error(`${archiveName}.sha256 is not the canonical checksum for ${archiveName}`) + const expectedSidecars = new Map([ + [`${versionedName}.sha256`, `${versionedSha} ${versionedName}\n`], + [`${latestName}.sha256`, `${versionedSha} ${latestName}\n`], + ]) + for (const [name, expected] of expectedSidecars) { + const sidecar = await readFile(join(downloadDir, name), 'utf8') + if (sidecar !== expected) throw new Error(`${name} is not the canonical checksum for ${versionedName}`) } // A checksum can prove that a hosted tarball is immutable without proving // that it contains the source being released. Compare every package byte, - // including package.json lifecycle scripts and dependency metadata. + // including package.json lifecycle scripts and dependency metadata, before a + // site build may advertise this artifact. + const entries = verifyDeterministicPackageArchive(versioned) for (const name of [ 'CHANGELOG.md', 'LICENSE', @@ -85,28 +72,28 @@ export async function verifyRelease({ const local = name === 'package.json' ? pkgBytes : await readFile(join(packageDir, name)) const packed = entries.get(`package/${name}`) if (!packed || !local.equals(packed)) { - throw new Error(`${archiveName} does not contain the current ${name}`) + throw new Error(`${versionedName} does not contain the current ${name}; bump the CLI version and run pnpm cli:release`) } } - if (!entries.get('package/SBOM.cdx.json').equals(sbom)) { - throw new Error(`${sbomName} does not describe the SBOM included in ${archiveName}`) + if (!entries.get('package/SBOM.cdx.json').equals(versionedSbom)) { + throw new Error(`${versionedSbomName} does not describe the SBOM included in ${versionedName}`) } - const packagedSbom = JSON.parse(sbom.toString('utf8')) + const packagedSbom = JSON.parse(versionedSbom.toString('utf8')) if (packagedSbom.metadata?.component?.name !== pkg.name || packagedSbom.metadata?.component?.version !== pkg.version) { - throw new Error(`${sbomName} does not identify ${pkg.name}@${pkg.version}`) + throw new Error(`${versionedSbomName} does not identify ${pkg.name}@${pkg.version}`) } const expectedSerialNumber = cycloneDxSerialNumber(pkg.name, pkg.version) if (packagedSbom.bomFormat !== 'CycloneDX' || packagedSbom.specVersion !== '1.6' || packagedSbom.serialNumber !== expectedSerialNumber) { - throw new Error(`${sbomName} does not have the canonical CycloneDX identity ${expectedSerialNumber}`) + throw new Error(`${versionedSbomName} does not have the canonical CycloneDX identity ${expectedSerialNumber}`) } - return { version: pkg.version, sha256: archiveSha256 } + return { version: pkg.version, sha256: versionedSha } } const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : undefined if (invokedPath === import.meta.url) { const result = await verifyRelease() - process.stdout.write(`Verified ${result.version} release (${result.sha256})\n`) + process.stdout.write(`Verified immutable CodeTruss CLI ${result.version} release (${result.sha256})\n`) } diff --git a/packages/cli/src/analysis.ts b/packages/cli/src/analysis.ts index 0bcb853..ab4597e 100644 --- a/packages/cli/src/analysis.ts +++ b/packages/cli/src/analysis.ts @@ -8,7 +8,10 @@ export async function analyzeRepository(root: string) { const priorOffline = process.env.CODETRUSS_OFFLINE process.env.CODETRUSS_OFFLINE = '1' try { - const result = await runAnalyzers(index) + // LOCAL_ANALYSIS_PROFILE omits the SAST pass. Declaring it here is what + // makes the coverage analyzer disclose the gap instead of inheriting the + // hosted premise that TypeScript/JavaScript/Python were security-reviewed. + const result = await runAnalyzers(index, { sast: false }) return { ...result, index } } finally { if (priorOffline === undefined) delete process.env.CODETRUSS_OFFLINE @@ -80,7 +83,53 @@ export function diffFindings( return { introduced, worsened, recurring, resolved } } -/** Offline vulnerability lookup is advisory; every deterministic pass and index byte is required. */ +/** + * A partially-read index still counts as authoritative when it measurably + * covered at least this fraction of its analyzable input. Mirrors + * AUTHORITATIVE_COVERAGE_RATIO in the hosted pipeline (src/lib/scans/analysis.ts): + * an immaterial cap (one oversized lockfile) must not void the receipt, while a + * real coverage loss still withholds one. + */ +export const AUTHORITATIVE_COVERAGE_RATIO = 0.95 + +/** + * Text files loaded over analyzable text candidates. Binary files masquerading + * as text are not analyzable and never count against coverage. The file-walk + * cap has no measurable denominator — unwalked files are unknown — so a + * truncated walk claims no ratio. + */ +export function indexCoverageRatio(coverage: IndexCoverage | undefined): number | undefined { + if (!coverage || coverage.truncated) return undefined + const analyzable = coverage.textCandidates - coverage.binaryTextFiles + if (analyzable <= 0) return 1 + return coverage.contentLoaded / analyzable +} + +function coverageIsAuthoritative(coverage: IndexCoverage | undefined): boolean { + const ratio = indexCoverageRatio(coverage) + return ratio !== undefined && ratio >= AUTHORITATIVE_COVERAGE_RATIO +} + +/** Content-loading limitations, worded so the reader learns the fix, not just the symptom. */ +function coverageLimitations(coverage: IndexCoverage): string[] { + const limitations: string[] = [] + if (coverage.oversizedTextFiles > 0) { + limitations.push( + `${coverage.oversizedTextFiles} oversized analyzable file(s) were not read — raise CODETRUSS_MAX_INDEX_FILE_BYTES to include them`, + ) + } + if (coverage.unreadableTextFiles > 0) limitations.push(`${coverage.unreadableTextFiles} analyzable file(s) could not be read`) + if (coverage.binaryTextFiles > 0) limitations.push(`${coverage.binaryTextFiles} apparent text file(s) contained binary data`) + return limitations +} + +/** + * Offline vulnerability lookup is advisory; every deterministic pass is + * required. Index limitations are required too, EXCEPT when the index still + * measurably covered >= AUTHORITATIVE_COVERAGE_RATIO of its analyzable input — + * those are returned by analysisCoverageNotes() as review-level instead, so a + * single large lockfile cannot fail an otherwise clean receipt. + */ export function analysisEvidenceIssues( passes: AnalyzerPass[], coverage: IndexCoverage | undefined, @@ -96,13 +145,21 @@ export function analysisEvidenceIssues( if (!coverage) issues.push('repository index did not report coverage') else { if (coverage.truncated) issues.push(`repository index reached its ${coverage.maxFiles}-file bound`) - if (coverage.oversizedTextFiles > 0) issues.push(`${coverage.oversizedTextFiles} oversized analyzable file(s) were not read`) - if (coverage.unreadableTextFiles > 0) issues.push(`${coverage.unreadableTextFiles} analyzable file(s) could not be read`) - if (coverage.binaryTextFiles > 0) issues.push(`${coverage.binaryTextFiles} apparent text file(s) contained binary data`) + if (!coverageIsAuthoritative(coverage)) issues.push(...coverageLimitations(coverage)) } return issues } +/** + * Index limitations small enough to leave the evidence authoritative. Surfaced + * on the receipt as review-level context rather than suppressed — the reader + * still learns exactly what was not read. + */ +export function analysisCoverageNotes(coverage: IndexCoverage | undefined): string[] { + if (!coverage || !coverageIsAuthoritative(coverage)) return [] + return coverageLimitations(coverage) +} + export function changedFindings(findings: AnalyzerFinding[], files: ChangedFile[]): AnalyzerFinding[] { const changed = new Set(files.flatMap((file) => [file.path, file.oldPath].filter(Boolean) as string[])) return findings.filter((finding) => { @@ -120,6 +177,7 @@ export function computeVerdict(input: { llm?: LlmReview evidenceIssues?: string[] baselineEvidenceIssues?: string[] + advisoryEvidenceIssues?: string[] }): { verdict: Verdict; reasons: string[] } { const failed: string[] = [] const review: string[] = [] @@ -127,6 +185,7 @@ export function computeVerdict(input: { if (input.agentExitCode !== undefined && input.agentExitCode !== 0) failed.push(`agent command exited with code ${input.agentExitCode}`) for (const issue of input.evidenceIssues ?? []) failed.push(`evidence incomplete: ${issue}`) for (const issue of input.baselineEvidenceIssues ?? []) review.push(`baseline evidence limitation resolved in the final tree: ${issue}`) + for (const issue of input.advisoryEvidenceIssues ?? []) review.push(`index coverage was partial but authoritative: ${issue}`) for (const verification of input.verifications.filter((item) => item.exitCode !== 0)) failed.push(`verification command failed: ${verification.command}`) const blocking = input.findings.filter((finding) => severityRank[finding.severity] >= severityRank.HIGH && (finding.category === 'SECURITY_HYGIENE' || finding.category === 'DEPENDENCY')) if (blocking.length) failed.push(`${blocking.length} high/critical security or dependency finding(s) affect changed files`) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f95b11f..22461e5 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2,9 +2,9 @@ import { createHash, randomUUID } from 'node:crypto' import { realpath, rmdir } from 'node:fs/promises' import { dirname, isAbsolute, join, resolve } from 'node:path' -import { analyzeRepository, analysisEvidenceIssues, analyzerReceipt, computeVerdict, diffFindings } from './analysis.js' +import { analyzeRepository, analysisCoverageNotes, analysisEvidenceIssues, analyzerReceipt, computeVerdict, diffFindings } from './analysis.js' import { loadSyncAuthentication } from './auth-storage.js' -import { initialize, loadConfig, receiptDir } from './config.js' +import { CONFIG_FILE, initialize, loadConfig, receiptDir, trustLocalSigningKey } from './config.js' import { allocatedVerificationTimeout, captureDiffEvidence, @@ -356,7 +356,7 @@ async function executeReview(parsed: Parsed, root: string, liveConfig: CliConfig if (requestedProvider && config.llm.model && config.llm.provider && config.llm.provider !== requestedProvider) { throw new Error(`--provider ${requestedProvider} conflicts with model ${config.llm.model} configured for ${config.llm.provider}`) } - await requireTrustedSigningKey(config.signing.publicKey) + await requireTrustedSigningKey(config.signing.publicKeys) const options: ReviewOptions = { mode, task, allow: many(parsed, 'allow', config.allow), @@ -461,6 +461,8 @@ async function executeReview(parsed: Parsed, root: string, liveConfig: CliConfig ...finalEvidenceIssues.map((issue) => `final ${issue}`), ...(diff.truncated ? [`diff capture retained ${diff.capturedBytes} of ${diff.totalBytes} bytes`] : []), ] + // Coverage gaps small enough to leave the index authoritative: reported, not fatal. + const advisoryEvidenceIssues = analysisCoverageNotes(analysis.index.coverage).map((issue) => `final ${issue}`) const verifications = [] for (const [commandIndex, command] of options.verify.entries()) { @@ -536,6 +538,7 @@ async function executeReview(parsed: Parsed, root: string, liveConfig: CliConfig llm, evidenceIssues, baselineEvidenceIssues: finalEvidenceIssues.length === 0 ? baselineEvidenceIssues : [], + advisoryEvidenceIssues, }) if (llmFailure) { outcome.verdict = 'FAILED'; outcome.reasons.unshift(`requested LLM review failed: ${llmFailure}`) } const finishedAt = new Date() @@ -711,6 +714,16 @@ async function main(argv = process.argv.slice(2)): Promise { const deny = many(parsed, 'deny', []) const path = await initialize(root, parsed.booleans.has('force'), { allow, deny }) process.stdout.write(`${path}\n`) + // init auto-detects verification commands, but running them requires an + // explicit trust decision. Without this notice the next `review` exits 3 + // with no receipt at all — a dead end on the very first run. + const detectedVerify = (await loadConfig(root)).verify + if (detectedVerify.length > 0) { + process.stdout.write( + `Detected verification commands (${detectedVerify.join(', ')}). They are not trusted until you approve them.\n` + + 'Run: codetruss verify-policy trust\n', + ) + } if (allow.length === 0) { process.stdout.write( 'No allow globs configured: changed paths remain unexpected, and agent hooks cannot be installed until .codetruss.yml defines at least one allow glob.\n' @@ -723,7 +736,7 @@ async function main(argv = process.argv.slice(2)): Promise { const dir = receiptDir(root, config) if (parsed.command === 'run' || parsed.command === 'review') return executeReview(parsed, root, config) if (parsed.command === 'report') { - const receipt = await verifyReceipt(dir, parsed.positionals[0] ?? 'latest', config.signing.publicKey) + const receipt = await verifyReceipt(dir, parsed.positionals[0] ?? 'latest', config.signing.publicKeys) process.stdout.write(parsed.booleans.has('json') ? `${JSON.stringify(receipt, null, 2)}\n` : renderMarkdown(receipt)) return 0 } @@ -736,12 +749,26 @@ async function main(argv = process.argv.slice(2)): Promise { } if (parsed.command === 'metrics') { const metrics = await collectLocalMetrics(root, dir, config.signing.publicKey) - process.stdout.write(parsed.booleans.has('json') ? `${JSON.stringify(metrics, null, 2)}\n` : renderLocalMetrics(metrics)) + process.stdout.write(parsed.booleans.has('json') ? `${JSON.stringify(metrics, null, 2)} +` : renderLocalMetrics(metrics)) return 0 } - if (parsed.command === 'verify') { const receipt = await verifyReceipt(dir, parsed.positionals[0] ?? 'latest', config.signing.publicKey); process.stdout.write(`verified ${receipt.sessionId} (${receipt.verdict})\n`); return 0 } + if (parsed.command === 'verify') { const receipt = await verifyReceipt(dir, parsed.positionals[0] ?? 'latest', config.signing.publicKeys); process.stdout.write(`verified ${receipt.sessionId} (${receipt.verdict}) +`); return 0 } if (parsed.command === 'verify-policy') { const action = parsed.positionals[0] ?? 'status' + // Key trust is independent of verification commands: a repository with no + // verify commands still pins signers, and a blocked teammate must be able + // to add their key here. + if (action === 'trust-key') { + const result = await trustLocalSigningKey(root) + process.stdout.write( + result.status === 'added' + ? `added signing key ${result.fingerprint}; this repository now trusts ${result.total} signer(s)\nCommit ${CONFIG_FILE} so your teammates inherit the change.\n` + : `signing key ${result.fingerprint} is already trusted by this repository\n`, + ) + return 0 + } if (!config.verify.length) { process.stdout.write('No repository verification commands are configured.\n'); return 0 } if (action === 'status') { const trust = await verifyCommandTrustStatus(root, config.verify) @@ -758,10 +785,10 @@ async function main(argv = process.argv.slice(2)): Promise { process.stdout.write(`revoked ${trust.hash}\n`) return 0 } - throw new Error(`unknown verify-policy action ${action}; expected status, trust, or revoke`) + throw new Error(`unknown verify-policy action ${action}; expected status, trust, trust-key, or revoke`) } if (parsed.command === 'sync') { - const receipt = await verifyReceipt(dir, parsed.positionals[0] ?? 'latest', config.signing.publicKey) + const receipt = await verifyReceipt(dir, parsed.positionals[0] ?? 'latest', config.signing.publicKeys) const envelope = await createSyncEnvelope(receipt) if (parsed.booleans.has('dry-run')) { process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); return 0 } const authentication = await loadSyncAuthentication() diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 3dc21d2..d25bd1e 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -9,6 +9,26 @@ export const CONFIG_FILE = '.codetruss.yml' export const PRODUCTION_SYNC_ORIGIN = 'https://codetruss.com' export const DEV_SYNC_ORIGIN_ENV = 'CODETRUSS_DEV_SYNC_ORIGIN' export const APPROVED_RECEIPT_DIR = '.codetruss/receipts' +/** + * Normalize the signing pin into a set. Accepts a single `publicKey` (what + * `init` writes) and/or a `publicKeys` list, so a team can add each developer's + * key without any one of them being blocked. Duplicates collapse; order is + * preserved so `publicKey` stays stable for existing single-signer configs. + */ +function signingPins(signing: Record): { publicKey?: string; publicKeys: string[] } { + const raw: unknown[] = [ + signing.publicKey, + ...(Array.isArray(signing.publicKeys) ? signing.publicKeys : []), + ] + const publicKeys: string[] = [] + for (const entry of raw) { + if (typeof entry !== 'string' || !entry.trim()) continue + const normalized = normalizePublicKey(entry) + if (!publicKeys.includes(normalized)) publicKeys.push(normalized) + } + return { publicKey: publicKeys[0], publicKeys } +} + export const DEFAULT_CONFIG: CliConfig = { version: 1, allow: [], @@ -16,13 +36,20 @@ export const DEFAULT_CONFIG: CliConfig = { verify: [], receipts: { dir: APPROVED_RECEIPT_DIR }, llm: { maxDiffBytes: 200_000 }, - signing: {}, + signing: { publicKeys: [] }, sync: { url: PRODUCTION_SYNC_ORIGIN }, } export interface InitializeOptions { allow?: string[] deny?: string[] + /** + * Override the auto-detected verification commands. Unattended setup passes + * an empty list so a repository is never configured with commands it has not + * been given permission to run — a review would otherwise refuse to produce + * any receipt at all. + */ + verify?: string[] } function objectValue(value: unknown, key: string): Record { @@ -86,11 +113,7 @@ export async function loadConfig(root: string): Promise { // only when --llm is actually requested. maxDiffBytes: typeof maxDiffBytes === 'number' && maxDiffBytes > 0 ? maxDiffBytes : DEFAULT_CONFIG.llm.maxDiffBytes, }, - signing: { - publicKey: typeof signing.publicKey === 'string' && signing.publicKey.trim() - ? normalizePublicKey(signing.publicKey) - : undefined, - }, + signing: signingPins(signing), // This value is an application invariant, never repository configuration. sync: { url: PRODUCTION_SYNC_ORIGIN }, } @@ -160,7 +183,7 @@ export async function initialize(root: string, force = false, options: Initializ if (!force) { try { await access(path); throw new Error(`${CONFIG_FILE} already exists; pass --force to replace it`) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } } - const detected = await detectVerify(root) + const detected = options.verify ?? (await detectVerify(root)) const key = await loadSigningKey(true) const value = { version: DEFAULT_CONFIG.version, @@ -228,3 +251,38 @@ async function detectVerify(root: string): Promise { if (((await exists('pyproject.toml')) || (await exists('requirements.txt'))) && await available('pytest')) return ['pytest'] return [] } + +/** + * Append the local signing key to the repository's trusted set so a teammate + * can produce receipts under their OWN identity. The alternative the old error + * implied — sharing a private key — would destroy per-signer attribution, which + * is the entire point of signing the receipt. + */ +export async function trustLocalSigningKey( + root: string, +): Promise<{ status: 'added' | 'already-trusted'; fingerprint: string; total: number }> { + const key = await loadSigningKey(true) + const path = join(root, CONFIG_FILE) + let text: string + try { + text = await readFile(path, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + throw new Error(`no ${CONFIG_FILE} in this repository; run codetruss init first`) + } + const parsed = parse(text) + const document = parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + const existing = signingPins(objectValue(document.signing, 'signing')) + const mine = normalizePublicKey(key.publicKey) + if (existing.publicKeys.includes(mine)) { + return { status: 'already-trusted', fingerprint: key.fingerprint, total: existing.publicKeys.length } + } + const publicKeys = [...existing.publicKeys, mine] + document.signing = { publicKeys } + // Preserve the leading header comment; `stringify` would otherwise drop it. + const header = text.startsWith('#') ? `${text.slice(0, text.indexOf('\n') + 1)}` : '' + await writeFile(path, `${header}${stringify(document)}`, 'utf8') + return { status: 'added', fingerprint: key.fingerprint, total: publicKeys.length } +} diff --git a/packages/cli/src/hook-runtime.ts b/packages/cli/src/hook-runtime.ts index 89418e5..eddb61b 100644 --- a/packages/cli/src/hook-runtime.ts +++ b/packages/cli/src/hook-runtime.ts @@ -365,7 +365,11 @@ function frozenConfig(config: CliConfig): CliConfig { ...(config.llm.model ? { model: config.llm.model } : {}), maxDiffBytes: config.llm.maxDiffBytes, }, - signing: { ...(config.signing.publicKey ? { publicKey: config.signing.publicKey } : {}) }, + signing: { + ...(config.signing.publicKey ? { publicKey: config.signing.publicKey } : {}), + // Freeze the whole trusted set: a repository may pin one key per developer. + publicKeys: [...config.signing.publicKeys], + }, sync: { url: config.sync.url }, } } @@ -390,6 +394,7 @@ function validateHookTurnContext(value: unknown): HookTurnContext { || (config.llm.provider !== undefined && !['anthropic', 'openai', 'claude', 'codex'].includes(config.llm.provider)) || (config.llm.model !== undefined && typeof config.llm.model !== 'string') || !config.signing || (config.signing.publicKey !== undefined && typeof config.signing.publicKey !== 'string') + || !isStringArray(config.signing.publicKeys) || !config.sync || typeof config.sync.url !== 'string' || !isStringArray(context.baselineDirtyFiles)) { throw new Error('hook turn context is invalid') @@ -1214,9 +1219,30 @@ function inputTurnId(input: HookInput): string | undefined { return turnId } +/** + * The receipt's Task line is its primary human-readable field, and the first + * thing a reviewer (or a client receiving the receipt) reads. Agent harnesses + * deliver machine events — background-task notifications, tool results — on the + * same prompt channel as human instructions, and reproducing that XML verbatim + * leaves the headline field unreadable. Keep human prompts exactly as written; + * reduce a structured system event to its summary, labelled by its event tag. + * + * Deterministic: capture and prompt-time verification derive the task the same + * way, so turn binding is unaffected. + */ +function readableTask(prompt: string): string { + const trimmed = prompt.trim() + if (!trimmed.startsWith('<')) return trimmed + const tag = /^<([A-Za-z][\w-]*)/.exec(trimmed)?.[1] + if (!tag) return trimmed + const summary = /([\s\S]*?)<\/summary>/i.exec(trimmed)?.[1]?.replace(/\s+/g, ' ').trim() + return summary ? `[${tag}] ${summary}` : `[${tag}]` +} + function inputTask(input: HookInput): string | undefined { const prompt = asNonEmptyString(input.prompt) - return prompt?.slice(0, MAX_TASK_CHARS) + if (!prompt) return undefined + return asNonEmptyString(readableTask(prompt).slice(0, MAX_TASK_CHARS)) } type NamedLockName = 'capture.lock' | 'stop.lock' | 'migration.lock' @@ -1507,11 +1533,48 @@ function normalizeHookPath(root: string, cwd: string | undefined, inputPath: str return { path: candidate } } -function fastPathFeedback( +/** + * Warnings already surfaced during this turn. An always-on guardrail earns its + * place by being felt, not heard: repeating an identical notice on every tool + * call is what makes a developer uninstall it. Fail-soft by design — if turn + * state cannot be read or written, every warning is emitted as before, because + * a missed warning is worse than a duplicated one. + */ +async function alreadyNotified( root: string, + surface: AgentHookSurface, + input: HookInput, + warnings: string[], +): Promise<{ fresh: string[]; persist?: () => Promise }> { + const sessionId = asNonEmptyString(input.session_id) + if (!sessionId) return { fresh: warnings } + try { + const prepared = await gitStateRoot(root, surface, sessionId) + if (prepared.legacyBlockReason) return { fresh: warnings } + const sessionDir = sessionStateDir(prepared.base, surface, sessionId) + const { turnDir } = await readStopSelector(sessionDir) + const file = join(turnDir, 'fast-path-notified.json') + const seen = (await readBoundedRegularJson(file, MAX_SELECTOR_BYTES)) as unknown + const previous = isStringArray(seen) ? seen : [] + const fresh = warnings.filter((warning) => !previous.includes(warning)) + if (fresh.length === 0) return { fresh } + return { + fresh, + persist: async () => { + await writePrivateJson(file, [...previous, ...fresh].slice(-200)) + }, + } + } catch { + return { fresh: warnings } + } +} + +async function fastPathFeedback( + root: string, + surface: AgentHookSurface, input: HookInput, config: CliConfig, -): HookOutput | undefined { +): Promise { const cwd = asNonEmptyString(input.cwd) const rawPaths = [ ...stringsAtKnownPathKeys(input.tool_input), @@ -1533,7 +1596,10 @@ function fastPathFeedback( } for (const path of outside) warnings.push(`${path}: resolves outside the repository`) if (warnings.length === 0) return undefined - return postToolFeedback(`CodeTruss fast scope check:\n${warnings.map((warning) => `- ${warning}`).join('\n')}\nA full analyzer receipt will run once when this turn stops.`) + const { fresh, persist } = await alreadyNotified(root, surface, input, warnings) + if (fresh.length === 0) return undefined + await persist?.().catch(() => undefined) + return postToolFeedback(`CodeTruss fast scope check:\n${fresh.map((warning) => `- ${warning}`).join('\n')}\nA full analyzer receipt will run once when this turn stops.`) } export function hookReviewEnvironment( @@ -2046,7 +2112,7 @@ export async function handleAgentHook( ): Promise { const event = hookEvent(input) if (event === 'UserPromptSubmit') return capturePromptBaseline(root, surface, input, config, dependencies) - if (event === 'PostToolUse') return fastPathFeedback(root, input, config) + if (event === 'PostToolUse') return fastPathFeedback(root, surface, input, config) if (event === 'Stop') { try { return await reviewAtStop(root, surface, input, dependencies) diff --git a/packages/cli/src/receipt.ts b/packages/cli/src/receipt.ts index e5003d8..ecc9a0f 100644 --- a/packages/cli/src/receipt.ts +++ b/packages/cli/src/receipt.ts @@ -81,7 +81,48 @@ function legacyScoreLines(receipt: Receipt): string[] { ] } +/** + * The omitted passes named as detection gaps, not as a scoring footnote. + * + * Earlier wording reported the omission only through "Hosted Health scores: + * N/A", which a developer reads as "no score" — not as "injection was never + * checked". A receipt is evidence of what ran, so the absent passes get their + * own section, named as the detection they cost rather than the number. + */ function analysisProfileLines(receipt: Receipt): string[] { + const current = 'analysisProfile' in receipt.analyzers && receipt.analyzers.analysisProfile + return [ + '## Analysis profile', + '', + ...(current ? [ + `Profile: \`${current.id}\`.`, + '', + 'The 13 deterministic registry analyzers ran locally on this machine.', + ] : [ + 'Legacy local receipt. Earlier CLI versions emitted numeric scores without hosted graph and SAST; those values are suppressed.', + ]), + '', + '### What did not run', + '', + '- **Security static analysis (SAST).** No injection or taint analysis was performed. SQL injection, command injection, code injection, path traversal, SSRF, open redirect, XSS and insecure deserialization were never checked, so this receipt says nothing either way about those classes.', + '- **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.', + ...(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 SAST passes; a number derived from this pass set would overstate what ran.', + '', + '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 profile block exactly as CLI 0.2.28 and earlier wrote it. Kept verbatim + * so `codetruss verify` still reproduces Markdown that was signed before the + * disclosure was reworded; the signed JSON is unchanged, only its rendering. + */ +function priorProfileLines(receipt: Receipt): string[] { const current = 'analysisProfile' in receipt.analyzers && receipt.analyzers.analysisProfile return [ '## Analysis profile', @@ -100,7 +141,16 @@ function analysisProfileLines(receipt: Receipt): string[] { ] } -function renderMarkdownInternal(receipt: Receipt, preserveLegacyScores: boolean): string { +/** Which historical rendering of the analysis block to reproduce. */ +type ReceiptMarkdownVariant = 'current' | 'legacy-scores' | 'prior-profile' + +function analysisLines(receipt: Receipt, variant: ReceiptMarkdownVariant): string[] { + if (variant === 'legacy-scores') return legacyScoreLines(receipt) + if (variant === 'prior-profile') return priorProfileLines(receipt) + return analysisProfileLines(receipt) +} + +function renderMarkdownInternal(receipt: Receipt, variant: ReceiptMarkdownVariant): string { const lines = [ `# CodeTruss receipt — ${receipt.verdict}`, '', @@ -130,7 +180,7 @@ function renderMarkdownInternal(receipt: Receipt, preserveLegacyScores: boolean) '|---|---|---|---|', ...receipt.analyzers.findings.slice(0, 100).map((finding) => `| ${finding.severity} | ${finding.analyzerId ?? 'unknown'} | ${finding.filePath ? `\`${finding.filePath}${finding.line ? `:${finding.line}` : ''}\`` : 'repository'} | ${finding.title.replaceAll('|', '\\|')} |`), '', - ...(preserveLegacyScores ? legacyScoreLines(receipt) : analysisProfileLines(receipt)), + ...analysisLines(receipt, variant), ...(receipt.analyzers.delta ? [ `Finding delta: ${receipt.analyzers.delta.introduced} introduced, ${receipt.analyzers.delta.worsened} worsened, ${receipt.analyzers.delta.recurring} recurring, ${receipt.analyzers.delta.resolved} resolved.`, ] : []), @@ -159,12 +209,17 @@ function renderMarkdownInternal(receipt: Receipt, preserveLegacyScores: boolean) /** Render the current honest local profile, including when displaying a legacy receipt. */ export function renderMarkdown(receipt: Receipt): string { - return renderMarkdownInternal(receipt, false) + return renderMarkdownInternal(receipt, 'current') } /** Byte-compatible renderer used only to verify Markdown written by older receipt-v1 clients. */ export function renderLegacyMarkdown(receipt: Receipt): string { - return renderMarkdownInternal(receipt, true) + return renderMarkdownInternal(receipt, 'legacy-scores') +} + +/** Byte-compatible renderer for profile receipts signed before the disclosure was reworded. */ +export function renderPriorProfileMarkdown(receipt: Receipt): string { + return renderMarkdownInternal(receipt, 'prior-profile') } async function writePrivateAtomic(path: string, value: string | Buffer): Promise { @@ -216,7 +271,7 @@ export async function resolveReceipt(dir: string, id = 'latest'): Promise<{ rece return { receipt: JSON.parse(await readFile(jsonPath, 'utf8')) as Receipt, jsonPath } } -export async function verifyReceipt(dir: string, id = 'latest', pinnedPublicKey?: string): Promise { +export async function verifyReceipt(dir: string, id = 'latest', pinnedPublicKey?: string | string[]): Promise { const { receipt, jsonPath } = await resolveReceipt(dir, id) if (receipt.git && ![receipt.git.baselineTree, receipt.git.finalTree].every((oid) => /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(oid))) { throw new Error('receipt evidence tree object id is invalid') @@ -225,21 +280,34 @@ export async function verifyReceipt(dir: string, id = 'latest', pinnedPublicKey? throw new Error('receipt policy SHA-256 is invalid') } if (!receipt.evidence.publicKey || !receipt.evidence.signatureFile) throw new Error('receipt is unsigned') - const trustedPublicKey = pinnedPublicKey ? normalizePublicKey(pinnedPublicKey) : (await loadSigningKey()).publicKey + // A repository may trust several signers (one per developer). The receipt is + // valid when its embedded key is one of them, and the signature is then + // checked against that exact key — so a teammate's receipt verifies without + // anyone sharing a private key. + const pins = Array.isArray(pinnedPublicKey) ? pinnedPublicKey : pinnedPublicKey ? [pinnedPublicKey] : [] + const trustedPublicKeys = pins.length > 0 + ? pins.map((pin) => normalizePublicKey(pin)) + : [(await loadSigningKey()).publicKey] const embeddedPublicKey = normalizePublicKey(receipt.evidence.publicKey) const embeddedFingerprint = publicKeyFingerprint(embeddedPublicKey) - const trustedFingerprint = publicKeyFingerprint(trustedPublicKey) + const trustedFingerprints = trustedPublicKeys.map((key) => publicKeyFingerprint(key)) if (receipt.evidence.keyFingerprint !== embeddedFingerprint) throw new Error('receipt signing fingerprint does not match its public key') - if (embeddedFingerprint !== trustedFingerprint) { - throw new Error(`receipt signer ${embeddedFingerprint} does not match trusted key ${trustedFingerprint}`) + if (!trustedFingerprints.includes(embeddedFingerprint)) { + throw new Error(`receipt signer ${embeddedFingerprint} does not match trusted key ${trustedFingerprints.join(', ')}`) } + const trustedPublicKey = trustedPublicKeys[trustedFingerprints.indexOf(embeddedFingerprint)] const jsonBytes = await readFile(jsonPath) const signature = (await readFile(join(dir, receipt.evidence.signatureFile), 'utf8')).trim() if (!verifyBytes(jsonBytes, trustedPublicKey, signature)) throw new Error('receipt signature does not match') const markdown = await readFile(join(dir, `${receipt.sessionId}.md`), 'utf8') - const currentMarkdown = renderMarkdown(receipt) - const legacyMarkdown = !('analysisProfile' in receipt.analyzers) ? renderLegacyMarkdown(receipt) : null - if (markdown !== currentMarkdown && markdown !== legacyMarkdown) throw new Error('Markdown receipt does not match the signed JSON') + // Every rendering this exact signed JSON could legitimately have produced. + // Rewording a disclosure must not invalidate receipts already on disk, so + // each superseded wording stays reproducible for verification only. + const accepted = [ + renderMarkdown(receipt), + 'analysisProfile' in receipt.analyzers ? renderPriorProfileMarkdown(receipt) : renderLegacyMarkdown(receipt), + ] + if (!accepted.includes(markdown)) throw new Error('Markdown receipt does not match the signed JSON') if (sha256(markdown) !== receipt.evidence.markdownSha256) throw new Error('Markdown receipt hash does not match') if (receipt.evidence.patchFile) { const patch = await readFile(join(dir, receipt.evidence.patchFile)) diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index 7e133e6..3ebbbca 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -88,7 +88,18 @@ async function resolveAllowGlobs( if (explicit?.length) return explicit const suggestions = await suggestedAllowGlobs(root) if (yes) { - throw new Error('non-interactive setup requires at least one explicit --allow "src/**" value') + // Plug and play: an unattended run on an ordinary repository should end up + // protected rather than stopped to look up glob syntax. Only conventional + // roots that actually exist on disk are adopted, never a repository-wide + // glob, and the choice is printed so an unattended decision stays + // auditable. Verification-command trust is the genuinely dangerous + // decision and is still withheld unless --trust-verify is passed. + const detected = suggestions.filter((glob) => !repositoryWide(glob)) + if (!detected.length) { + throw new Error('non-interactive setup requires at least one explicit --allow "src/**" value') + } + write(`Adopted detected allowed change roots: ${detected.join(', ')}\n`) + return detected } if (suggestions.length) { @@ -164,9 +175,17 @@ export async function guidedSetup(root: string, options: GuidedSetupOptions = {} } else { const selectedAllow = await resolveAllowGlobs(root, allow, yes, ask, write) const selectedDeny = deny ?? [] + // Unattended setup must never record commands it has no permission to + // run: an untrusted verify list makes every later review exit 3 with no + // receipt, which is worse than having no verification configured. + // Only when hooks will actually be installed: with --hooks none there is + // nothing to block, and recording untrusted commands for later inspection + // is the point of that flow. + const withheldVerify = yes && !options.trustVerify && requestedHooks !== 'none' const path = await initialize(root, false, { allow: selectedAllow, deny: selectedDeny, + ...(withheldVerify ? { verify: [] } : {}), }) write(`Saved policy: ${path}\n`) } @@ -199,7 +218,7 @@ export async function guidedSetup(root: string, options: GuidedSetupOptions = {} await trustVerifyCommands(root, config.verify) write('Trusted the exact verification command list shown above.\n') } else if (yes) { - throw new Error('automatic setup will not trust repository commands via --yes; inspect the commands above and rerun with --trust-verify') + write('Verification commands are NOT trusted and were left out of the policy; inspect them, then run codetruss verify-policy trust (or rerun setup with --trust-verify) to enable them.\n') } else { const approval = await ask('Type "trust" to approve this exact command list for automatic checks: ') if (approval.trim().toLowerCase() !== 'trust') { diff --git a/packages/cli/src/signing.ts b/packages/cli/src/signing.ts index c4a4046..892bc35 100644 --- a/packages/cli/src/signing.ts +++ b/packages/cli/src/signing.ts @@ -55,13 +55,23 @@ export async function loadSigningKey(create = false): Promise { return { privateKey, publicKey, fingerprint: publicKeyFingerprint(publicKey) } } -export async function requireTrustedSigningKey(pinnedPublicKey?: string): Promise { +/** + * The repository pin is a SET of trusted signers, so each developer signs with + * their own key and keeps per-signer attribution. The failure message must + * never suggest obtaining someone else's private key — that would destroy the + * attribution the receipt exists to provide. + */ +export async function requireTrustedSigningKey(pinnedPublicKey?: string | string[]): Promise { const key = await loadSigningKey(true) - if (pinnedPublicKey) { - const pinnedFingerprint = publicKeyFingerprint(pinnedPublicKey) - if (pinnedFingerprint !== key.fingerprint) { + const pins = (Array.isArray(pinnedPublicKey) ? pinnedPublicKey : [pinnedPublicKey]).filter( + (pin): pin is string => typeof pin === 'string' && pin.trim().length > 0, + ) + if (pins.length > 0) { + const fingerprints = pins.map((pin) => publicKeyFingerprint(pin)) + if (!fingerprints.includes(key.fingerprint)) { throw new Error( - `local signing key ${key.fingerprint} does not match repository pin ${pinnedFingerprint}; set CODETRUSS_SIGNING_KEY to the trusted private key`, + `this repository trusts ${fingerprints.join(', ')} but your local signing key is ${key.fingerprint}; ` + + 'add yours with codetruss verify-policy trust-key (or have a maintainer append it to signing.publicKeys)', ) } } diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index b04d8a0..0cee623 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -32,7 +32,13 @@ export interface CliConfig { model?: string maxDiffBytes: number } - signing: { publicKey?: string } + /** + * Pinned signer identities. A repository is worked on by more than one + * developer, so the pin is a SET: `publicKeys` lists every trusted signer and + * `publicKey` is its first entry, kept for the single-signer config shape and + * for the hook turn context. + */ + signing: { publicKey?: string; publicKeys: string[] } sync: { url: string } } diff --git a/packages/cli/test/analysis-profile.test.ts b/packages/cli/test/analysis-profile.test.ts index 552201a..1733652 100644 --- a/packages/cli/test/analysis-profile.test.ts +++ b/packages/cli/test/analysis-profile.test.ts @@ -41,4 +41,34 @@ describe('honest local analysis profile', () => { expect(evidence).not.toHaveProperty('baselineScores') expect(JSON.stringify(evidence)).not.toContain('"security"') }) + + it('discloses the absent SAST pass on the TypeScript repos where it used to stay silent', async () => { + const root = await mkdtemp(join(tmpdir(), 'codetruss-local-sast-gap-')) + cleanup.push(root) + await mkdir(join(root, 'src')) + // A repo large enough to draw coverage conclusions, entirely in a language + // the SAST engine covers — the exact shape that produced zero coverage + // findings while the injection rules never ran. + for (let unit = 0; unit < 8; unit += 1) { + const body = Array.from({ length: 50 }, (_, line) => ` const value${line} = ${line} * ${unit + 1}`) + await writeFile( + join(root, 'src', `unit-${unit}.ts`), + [`export function unit${unit}(): number {`, ...body, ' return value0', '}', ''].join('\n'), + ) + } + + const analysis = await analyzeRepository(root) + const coverage = analysis.passes.find((pass) => pass.id === 'coverage') + expect(coverage?.result.findings).toHaveLength(1) + const finding = coverage!.result.findings[0] + expect(finding.category).toBe('SECURITY_HYGIENE') + expect(finding.severity).toBe('INFO') + expect(finding.title).toMatch(/did not run/i) + expect(finding.description).toMatch(/SQL injection/) + expect(finding.metadata).toMatchObject({ sastPassRan: false, sastLanguages: ['TypeScript'] }) + // A disclosure, never a blocking accusation: the local verdict must not + // fail a change because a pass was absent. + expect(finding.severity).not.toBe('HIGH') + expect(finding.severity).not.toBe('CRITICAL') + }) }) diff --git a/packages/cli/test/command-e2e.test.ts b/packages/cli/test/command-e2e.test.ts index 2ffc5e9..f478bff 100644 --- a/packages/cli/test/command-e2e.test.ts +++ b/packages/cli/test/command-e2e.test.ts @@ -83,6 +83,38 @@ describe('CLI snapshot and delta enforcement', () => { expect(config).toMatch(/allow:\n\s+- src\/\*\*\n\s+- tests\/\*\*/) }, 30_000) + it('adopts detected source roots for unattended setup instead of demanding a flag', async () => { + // Plug and play: a developer who runs `codetruss setup --yes` on an ordinary + // repo should end up protected, not stopped to look up glob syntax. The + // chosen scope is printed so an unattended decision stays auditable. + const root = await repository() + await mkdir(join(root, 'src')) + await mkdir(join(root, 'tests')) + await installPersistentCliFixture(root) + await writeFile(join(root, '.gitignore'), '/node_modules\n') + + const setup = runCli(root, ['setup', '--yes'], {}) + + expect(setup.status, `${setup.stderr}\n${setup.stdout}`).toBe(0) + expect(setup.stdout).toContain('src/**') + const config = await readFile(join(root, '.codetruss.yml'), 'utf8') + expect(config).toMatch(/allow:\n\s+- src\/\*\*\n\s+- tests\/\*\*/) + // Command trust is the genuinely dangerous decision and must NOT be implied. + expect(config).not.toMatch(/^verify:\n\s+-/m) + }, 30_000) + + it('still refuses unattended setup when no conventional source root exists', async () => { + // With nothing to detect, guessing would be worse than asking. + const root = await repository() + await installPersistentCliFixture(root) + await writeFile(join(root, '.gitignore'), '/node_modules\n') + + const setup = runCli(root, ['setup', '--yes'], {}) + + expect(setup.status).toBe(3) + expect(`${setup.stderr}${setup.stdout}`).toContain('--allow') + }, 30_000) + it('completes idempotent local-only setup and keeps generated evidence out of normal staging', async () => { const root = await repository() await mkdir(join(root, 'src')) diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index 8b48d16..cb3559d 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -1,7 +1,9 @@ import { mkdir, mkdtemp, readFile, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { generateKeyPairSync } from 'node:crypto' import { afterEach, describe, expect, it } from 'vitest' +import { loadSigningKey, requireTrustedSigningKey } from '../src/signing.js' import { APPROVED_RECEIPT_DIR, PRODUCTION_SYNC_ORIGIN, @@ -120,3 +122,53 @@ describe('initialization', () => { } }) }) + +/** + * A committed .codetruss.yml pins the signing key. With a single-key pin, every + * teammate who clones gets exit 3 and no receipt, and the old error told them to + * "set CODETRUSS_SIGNING_KEY to the trusted private key" — i.e. to obtain a + * colleague's private key, which destroys the per-signer attribution the receipt + * exists to provide. Bad advice from a security product. + */ +describe('multi-developer signing pins', () => { + it('accepts a publicKeys list and keeps publicKey as the first entry', async () => { + const root = await mkdtemp(join(tmpdir(), 'codetruss-multikey-')) + process.env.CODETRUSS_SIGNING_KEY = join(root, 'signing.pem') + const a = generateKeyPairSync('ed25519').publicKey.export({ type: 'spki', format: 'pem' }).toString() + const b = generateKeyPairSync('ed25519').publicKey.export({ type: 'spki', format: 'pem' }).toString() + await writeFile( + join(root, '.codetruss.yml'), + `allow: ["src/**"]\nsigning:\n publicKeys:\n - |\n${a.trimEnd().split('\n').map((l) => ` ${l}`).join('\n')}\n - |\n${b.trimEnd().split('\n').map((l) => ` ${l}`).join('\n')}\n`, + 'utf8', + ) + const config = await loadConfig(root) + expect(config.signing.publicKeys).toHaveLength(2) + expect(config.signing.publicKey).toBe(config.signing.publicKeys[0]) + }) + + it('still accepts a single legacy publicKey', async () => { + const root = await mkdtemp(join(tmpdir(), 'codetruss-singlekey-')) + process.env.CODETRUSS_SIGNING_KEY = join(root, 'signing.pem') + await initialize(root) + const config = await loadConfig(root) + expect(config.signing.publicKeys).toHaveLength(1) + expect(config.signing.publicKeys[0]).toContain('BEGIN PUBLIC KEY') + expect(config.signing.publicKey).toBe(config.signing.publicKeys[0]) + }) + + it('trusts any pinned key and never tells the reader to obtain a private key', async () => { + const root = await mkdtemp(join(tmpdir(), 'codetruss-trust-')) + process.env.CODETRUSS_SIGNING_KEY = join(root, 'signing.pem') + const mine = await loadSigningKey(true) + const teammate = generateKeyPairSync('ed25519').publicKey.export({ type: 'spki', format: 'pem' }).toString() + + // My key is in the pinned set alongside a teammate's: allowed. + await expect(requireTrustedSigningKey([teammate, mine.publicKey])).resolves.toMatchObject({ + fingerprint: mine.fingerprint, + }) + + // My key is absent: rejected, but the guidance must not be "get their key". + await expect(requireTrustedSigningKey([teammate])).rejects.toThrow(/verify-policy trust-key/) + await expect(requireTrustedSigningKey([teammate])).rejects.not.toThrow(/private key/) + }) +}) diff --git a/packages/cli/test/hooks.test.ts b/packages/cli/test/hooks.test.ts index 9e55b06..8fd9f3b 100644 --- a/packages/cli/test/hooks.test.ts +++ b/packages/cli/test/hooks.test.ts @@ -824,7 +824,8 @@ describe('exact immutable hook snapshots', () => { [CODETRUSS_HOOK_SURFACE_ENV]: 'codex', CODETRUSS_HOOK_START_COMMIT: git(root, 'rev-parse', 'HEAD'), CODETRUSS_HOOK_END_COMMIT: git(root, 'rev-parse', 'HEAD'), - CODETRUSS_HOOK_STARTED_AT: '2026-07-14T10:00:00.000Z', + // the spawned CLI checks this against the real clock, so it must stay recent + CODETRUSS_HOOK_STARTED_AT: new Date(Date.now() - 5 * 60_000).toISOString(), [CODETRUSS_EVIDENCE_OBJECT_DIRECTORY_ENV]: store.objectDirectory, [CODETRUSS_HOOK_CONTEXT_PATH_ENV]: contextPath, [CODETRUSS_HOOK_CONTEXT_SHA256_ENV]: contextSha256, @@ -1000,6 +1001,69 @@ describe('agent hook runtime', () => { }, config())).resolves.toBeUndefined() }) + it('reports each scope warning once per turn instead of on every tool call', async () => { + // The guardrail should be felt, not heard. Repeating the identical warning + // on every edit is what makes an always-on hook annoying enough to remove — + // but the first notice must still arrive, and a NEW path must still speak. + const root = await repo() + await writeConfig(root) + const prompt = { session_id: 'session-quiet', turn_id: 'turn-q', hook_event_name: 'UserPromptSubmit', prompt: 'Edit things', cwd: root } + await handleAgentHook(root, 'claude', prompt, config(['src/**'])) + + const edit = (file: string) => handleAgentHook(root, 'claude', { + session_id: 'session-quiet', turn_id: 'turn-q', hook_event_name: 'PostToolUse', cwd: root, + tool_input: { file_path: file }, + }, config(['src/**'])) + + const first = await edit(join(root, 'infra', 'deploy.tf')) + expect(JSON.stringify(first)).toContain('outside the allowed task scope') + + // Same path again in the same turn: already said, stay quiet. + const repeat = await edit(join(root, 'infra', 'deploy.tf')) + expect(repeat).toBeUndefined() + + // A different out-of-scope path is new information and must be surfaced. + const other = await edit(join(root, 'infra', 'secrets.tf')) + expect(JSON.stringify(other)).toContain('secrets.tf') + }) + + it('records a readable task when the harness delivers a machine event, not a human prompt', async () => { + // Agent harnesses push background-task notifications and tool results down + // the same prompt channel. Dumping that XML verbatim makes the receipt's + // headline Task field unreadable — the exact thing a reviewer reads first. + const root = await repo() + await writeConfig(root) + const receipt = join(root, '.codetruss', 'receipts', 'hook.md') + await mkdir(join(root, '.codetruss', 'receipts'), { recursive: true }) + await writeFile(receipt, '# receipt\n') + const requests: HookReviewRequest[] = [] + const runReview = vi.fn(async (request: HookReviewRequest) => { + requests.push(request) + return hookReviewResponse(request, 'PASS', 0, receipt) + }) + const dependencies = { runReview, now: () => new Date() } + await mkdir(join(root, 'src'), { recursive: true }) + await writeFile(join(root, 'src', 'value.ts'), 'export const value = "before"\n') + const notification = [ + '', + 'b2eypo7jv', + 'toolu_01Pqm3pu4ubbnSdKf4jJtCjR', + 'completed', + 'Background command "Wait for production to serve CLI 0.2.25" completed (exit code 0)', + '', + ].join('\n') + const prompt = { session_id: 'session-evt', turn_id: 'turn-evt', hook_event_name: 'UserPromptSubmit', prompt: notification, cwd: root } + await expect(handleAgentHook(root, 'codex', prompt, config(['src/**']), dependencies)).resolves.toBeUndefined() + await writeFile(join(root, 'src', 'value.ts'), 'export const value = "after"\n') + await handleAgentHook(root, 'codex', { ...prompt, hook_event_name: 'Stop', background_tasks: [] }, config(['src/**']), dependencies) + + expect(runReview).toHaveBeenCalledTimes(1) + const task = requests[0]!.context.task + expect(task).not.toContain('') + expect(task).not.toContain('toolu_') + expect(task).toContain('Wait for production to serve CLI 0.2.25') + }) + it('captures once, waits for background tasks, reviews immutable final evidence once, and lets PASS stop quietly', async () => { const root = await repo() await writeConfig(root) diff --git a/packages/cli/test/policy-fingerprint.test.ts b/packages/cli/test/policy-fingerprint.test.ts index 3df5edc..eeb1353 100644 --- a/packages/cli/test/policy-fingerprint.test.ts +++ b/packages/cli/test/policy-fingerprint.test.ts @@ -9,7 +9,7 @@ const config: CliConfig = { verify: [], receipts: { dir: '.codetruss/receipts' }, llm: { provider: 'claude', model: 'default', maxDiffBytes: 100_000 }, - signing: {}, + signing: { publicKeys: [] }, sync: { url: 'https://codetruss.com' }, } diff --git a/packages/cli/test/policy-verdict.test.ts b/packages/cli/test/policy-verdict.test.ts index 3fe90e1..85970e7 100644 --- a/packages/cli/test/policy-verdict.test.ts +++ b/packages/cli/test/policy-verdict.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { classifyPath, isDependencyFile, sensitiveCategory } from '../src/policy.js' -import { analysisEvidenceIssues, computeVerdict, diffFindings } from '../src/analysis.js' -import type { AnalyzerFinding, AnalyzerPass } from '@codetruss/analyzer-engine' +import { analysisCoverageNotes, analysisEvidenceIssues, computeVerdict, diffFindings } from '../src/analysis.js' +import type { AnalyzerFinding, AnalyzerPass, IndexCoverage } from '@codetruss/analyzer-engine' describe('scope and sensitive policy', () => { it('is fail closed, gives deny precedence, and protects rename origins', () => { @@ -113,3 +113,58 @@ describe('analyzer finding deltas', () => { }) }) }) + +/** + * A >1MB lockfile is ordinary in real repos. Failing a developer's FIRST + * receipt over one file the agent never touched — with a reason naming no file + * and no fix — reads as "this tool is broken". The hosted pipeline already + * solved this (AUTHORITATIVE_COVERAGE_RATIO in src/lib/scans/analysis.ts); the + * CLI applied zero tolerance to the identical IndexCoverage signal. + */ +describe('immaterial index coverage loss', () => { + const coverage = (over: Partial): IndexCoverage => ({ + discoveredFiles: 100, + maxFiles: 5000, + truncated: false, + textCandidates: 100, + contentLoaded: 100, + oversizedTextFiles: 0, + unreadableTextFiles: 0, + binaryTextFiles: 0, + ...over, + }) + const passes = [{ id: 'secrets', result: { findings: [], complete: true } }] satisfies AnalyzerPass[] + const inScope = { path: 'src/a.ts', change: 'modified', classification: 'allowed', dependency: false, additions: 1, deletions: 0 } as const + + it('does not block on one oversized file in an otherwise fully read repo', () => { + const c = coverage({ contentLoaded: 99, oversizedTextFiles: 1 }) + expect(analysisEvidenceIssues(passes, c)).toEqual([]) + const notes = analysisCoverageNotes(c) + expect(notes).toHaveLength(1) + // The reason must name the escape hatch, not just the symptom. + expect(notes[0]).toContain('CODETRUSS_MAX_INDEX_FILE_BYTES') + }) + + it('still blocks when a real share of the repo went unread', () => { + const c = coverage({ contentLoaded: 50, oversizedTextFiles: 50 }) + expect(analysisEvidenceIssues(passes, c).length).toBeGreaterThan(0) + expect(analysisCoverageNotes(c)).toEqual([]) + }) + + it('still blocks when the file walk hit its bound (no measurable denominator)', () => { + const c = coverage({ truncated: true }) + expect(analysisEvidenceIssues(passes, c).length).toBeGreaterThan(0) + }) + + it('routes an immaterial coverage note to REVIEW_REQUIRED, not FAILED', () => { + const result = computeVerdict({ + agentExitCode: 0, + verifications: [], + files: [inScope], + startDirty: false, + findings: [], + advisoryEvidenceIssues: analysisCoverageNotes(coverage({ contentLoaded: 99, oversizedTextFiles: 1 })), + }) + expect(result.verdict).toBe('REVIEW_REQUIRED') + }) +}) diff --git a/packages/cli/test/receipt.test.ts b/packages/cli/test/receipt.test.ts index 8076491..4a00974 100644 --- a/packages/cli/test/receipt.test.ts +++ b/packages/cli/test/receipt.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, readFile, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { createSyncEnvelope, hookSessionId, newSessionId, renderLegacyMarkdown, renderMarkdown, verifyReceipt, writeReceipt } from '../src/receipt.js' +import { createSyncEnvelope, hookSessionId, newSessionId, renderLegacyMarkdown, renderMarkdown, renderPriorProfileMarkdown, verifyReceipt, writeReceipt } from '../src/receipt.js' import { loadSigningKey, sha256, signBytes, verifyBytes } from '../src/signing.js' import { LOCAL_ANALYSIS_PROFILE, type Receipt } from '../src/types.js' @@ -59,8 +59,6 @@ describe('signed receipts', () => { const markdown = await readFile(paths.markdown, 'utf8') expect(markdown).toContain('Policy SHA-256') expect(markdown).toContain('Profile: `local-registry-v1`') - expect(markdown).toContain('Hosted Health scores: **N/A**') - expect(markdown).toContain('Hosted graph and SAST passes were omitted') 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') @@ -83,10 +81,59 @@ describe('signed receipts', () => { const verified = await verifyReceipt(dir, receipt.sessionId) expect(oldMarkdown).toContain('Final scores: health 100') expect(renderMarkdown(verified)).toContain('Legacy local receipt') - expect(renderMarkdown(verified)).toContain('Hosted Health scores: **N/A**') + expect(renderMarkdown(verified)).toContain('**Hosted Health scores.** Not calculated, reported as **N/A**') expect(renderMarkdown(verified)).not.toContain('security 100') }) + it('names the omitted passes as detection gaps rather than a missing score', () => { + const markdown = renderMarkdown(fixture('/tmp/repo')) + const disclosure = markdown.slice(markdown.indexOf('### What did not run')) + + expect(markdown).toContain('### What did not run') + // The gap a developer must not misread: injection was never examined. + expect(disclosure).toContain('**Security static analysis (SAST).**') + expect(disclosure).toMatch(/SQL injection.*command injection.*path traversal/) + expect(disclosure).toContain('says nothing either way about those classes') + expect(disclosure).toContain('**Hosted symbol graph.**') + expect(disclosure).toContain('**Optional LLM review.**') + expect(disclosure).toContain('force-disabled under agent hooks') + // Never an accusation: the receipt reports what ran, not a verdict on the code. + expect(disclosure).not.toMatch(/vulnerab|insecure code|unsafe/i) + expect(markdown).toContain('It is not a statement that this change is secure.') + }) + + it('does not list the LLM review as omitted when a model actually reviewed the diff', () => { + const receipt = fixture('/tmp/repo') + receipt.llm = { + provider: 'anthropic', transmittedBytes: 10, + diffCoverage: { reviewedBytes: 13, totalBytes: 13, truncated: false }, + verdict: 'clean', summary: 'Nothing notable.', findings: [], + } + const markdown = renderMarkdown(receipt) + expect(markdown).toContain('### What did not run') + expect(markdown).not.toContain('**Optional LLM review.** No model read this diff') + expect(markdown).toContain('## Optional LLM review') + }) + + it('still verifies a profile receipt whose Markdown carries the superseded wording', async () => { + const root = await mkdtemp(join(tmpdir(), 'codetruss-prior-profile-receipt-')) + const dir = join(root, 'receipts') + process.env.CODETRUSS_SIGNING_KEY = join(root, 'signing.pem') + const receipt = fixture(root) + const paths = await writeReceipt(dir, receipt, 'diff evidence') + const priorMarkdown = renderPriorProfileMarkdown(receipt) + receipt.evidence.markdownSha256 = sha256(priorMarkdown) + const jsonText = `${JSON.stringify(receipt, null, 2)}\n` + const key = await loadSigningKey() + await writeFile(paths.json, jsonText) + await writeFile(paths.markdown, priorMarkdown) + await writeFile(paths.signature, `${signBytes(jsonText, key.privateKey)}\n`) + + expect(priorMarkdown).toContain('Hosted Health scores: **N/A**.') + expect(priorMarkdown).not.toContain('### What did not run') + await expect(verifyReceipt(dir, receipt.sessionId)).resolves.toMatchObject({ verdict: 'PASS' }) + }) + it('renders explicit optional LLM diff coverage', () => { const receipt = fixture('/tmp/repo') receipt.llm = { diff --git a/public/downloads/codetruss-cli-0.2.30.sbom.cdx.json b/public/downloads/codetruss-cli-0.2.30.sbom.cdx.json new file mode 100644 index 0000000..4725dea --- /dev/null +++ b/public/downloads/codetruss-cli-0.2.30.sbom.cdx.json @@ -0,0 +1,170 @@ +{ + "$schema": "https://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "serialNumber": "urn:uuid:b2765fa3-4817-59b8-8f08-7350aedbf1ca", + "specVersion": "1.6", + "version": 1, + "metadata": { + "component": { + "type": "application", + "bom-ref": "pkg:npm/%40codetruss/cli@0.2.30", + "name": "@codetruss/cli", + "version": "0.2.30", + "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.30" + }, + "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.30", + "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.30.tgz b/public/downloads/codetruss-cli-0.2.30.tgz new file mode 100644 index 0000000..9ca20d8 Binary files /dev/null and b/public/downloads/codetruss-cli-0.2.30.tgz differ diff --git a/public/downloads/codetruss-cli-0.2.30.tgz.sha256 b/public/downloads/codetruss-cli-0.2.30.tgz.sha256 new file mode 100644 index 0000000..fa9c0c6 --- /dev/null +++ b/public/downloads/codetruss-cli-0.2.30.tgz.sha256 @@ -0,0 +1 @@ +9c97f573aa7e7a052fe8d4c578efda6a8d43f2bcfec9a74ab7d2fdf6b53eccdc codetruss-cli-0.2.30.tgz diff --git a/public/downloads/codetruss-cli-latest.json b/public/downloads/codetruss-cli-latest.json index 2186497..818e0a0 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.24", - "url": "/downloads/codetruss-cli-0.2.24.tgz", + "version": "0.2.30", + "url": "/downloads/codetruss-cli-0.2.30.tgz", "latestUrl": "/downloads/codetruss-cli-latest.tgz", - "sha256": "793895784552e5d59540ffcc9dc2acf6bff6f778b59f9010bd901e4d4c3fa712", - "sbomUrl": "/downloads/codetruss-cli-0.2.24.sbom.cdx.json", - "sbomSha256": "29d5d38b5cca75a393a95465487aaaa7e74eee5c656fa8f843e4d5f37d7eeb42", + "sha256": "9c97f573aa7e7a052fe8d4c578efda6a8d43f2bcfec9a74ab7d2fdf6b53eccdc", + "sbomUrl": "/downloads/codetruss-cli-0.2.30.sbom.cdx.json", + "sbomSha256": "3a01c0977bfe3dc4407d39fd38af8e47521d06fe952570838bd4f011308a6b6d", "node": ">=20.9.0", "repository": "https://github.com/DeliriumPulse/codetruss-cli", - "releaseUrl": "https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v0.2.24", - "attestationCommand": "gh attestation verify codetruss-cli-0.2.24.tgz --repo DeliriumPulse/codetruss-cli" + "releaseUrl": "https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v0.2.30", + "attestationCommand": "gh attestation verify codetruss-cli-0.2.30.tgz --repo DeliriumPulse/codetruss-cli" } diff --git a/public/downloads/codetruss-cli-latest.sbom.cdx.json b/public/downloads/codetruss-cli-latest.sbom.cdx.json index dd8c1d6..4725dea 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:749dc75b-10d5-5c8c-9c04-9410333a0c4a", + "serialNumber": "urn:uuid:b2765fa3-4817-59b8-8f08-7350aedbf1ca", "specVersion": "1.6", "version": 1, "metadata": { "component": { "type": "application", - "bom-ref": "pkg:npm/%40codetruss/cli@0.2.24", + "bom-ref": "pkg:npm/%40codetruss/cli@0.2.30", "name": "@codetruss/cli", - "version": "0.2.24", + "version": "0.2.30", "description": "Local-first scope, quality, and verification receipts for coding agents", "licenses": [ { @@ -18,7 +18,7 @@ } } ], - "purl": "pkg:npm/%40codetruss/cli@0.2.24" + "purl": "pkg:npm/%40codetruss/cli@0.2.30" }, "properties": [ { @@ -139,7 +139,7 @@ "dependsOn": [] }, { - "ref": "pkg:npm/%40codetruss/cli@0.2.24", + "ref": "pkg:npm/%40codetruss/cli@0.2.30", "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 c2e9f98..9ca20d8 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 e0f232c..ffe8aa0 100644 --- a/public/downloads/codetruss-cli-latest.tgz.sha256 +++ b/public/downloads/codetruss-cli-latest.tgz.sha256 @@ -1 +1 @@ -793895784552e5d59540ffcc9dc2acf6bff6f778b59f9010bd901e4d4c3fa712 codetruss-cli-latest.tgz +9c97f573aa7e7a052fe8d4c578efda6a8d43f2bcfec9a74ab7d2fdf6b53eccdc codetruss-cli-latest.tgz diff --git a/release-reference.json b/release-reference.json index 00ce822..52f88b1 100644 --- a/release-reference.json +++ b/release-reference.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "version": "0.2.24", - "websiteArchive": "https://codetruss.com/downloads/codetruss-cli-0.2.24.tgz", - "archiveSha256": "793895784552e5d59540ffcc9dc2acf6bff6f778b59f9010bd901e4d4c3fa712", - "sbomSha256": "29d5d38b5cca75a393a95465487aaaa7e74eee5c656fa8f843e4d5f37d7eeb42", - "bundleSha256": "96be1bec20d5478dacc785e8aebebe71640726516702a792bd9cf1ecc2d35a1b" + "version": "0.2.30", + "websiteArchive": "https://codetruss.com/downloads/codetruss-cli-0.2.30.tgz", + "archiveSha256": "9c97f573aa7e7a052fe8d4c578efda6a8d43f2bcfec9a74ab7d2fdf6b53eccdc", + "sbomSha256": "3a01c0977bfe3dc4407d39fd38af8e47521d06fe952570838bd4f011308a6b6d", + "bundleSha256": "d3be1602d416b8c6b3e92107be8e2897bff46f43982698736f68a3e064088a09" }