From 60c7e37f2d62da18a03e97fdbb907916a516c9fc Mon Sep 17 00:00:00 2001 From: Manuel Rodriguez Sabella Date: Sat, 15 Aug 2026 15:31:26 -0300 Subject: [PATCH] feat: check detected skills against npm release dates --- CHANGELOG.md | 12 + docs/login-submit.md | 96 ++++++- docs/privacy-tests.md | 3 + docs/signatures.md | 26 ++ signatures/package-map.json | 8 +- src/http-client.ts | 9 +- src/npm-anachronism.ts | 216 ++++++++++++++++ src/program.ts | 6 +- src/submit-command.ts | 51 +++- src/submit.ts | 9 + test/npm-anachronism.test.ts | 358 ++++++++++++++++++++++++++ test/package-map.test.ts | 29 +++ test/privacy/submit-guardrail.test.ts | 94 ++++++- test/privacy/zero-network.test.ts | 4 +- test/program.test.ts | 13 +- test/submit.test.ts | 239 +++++++++++++++++ 16 files changed, 1149 insertions(+), 24 deletions(-) create mode 100644 src/npm-anachronism.ts create mode 100644 test/npm-anachronism.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b96c776..552683c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,18 @@ always bump at least minor; breaking schema changes bump major. - Honor `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` on login and submit via undici's `EnvHttpProxyAgent` (#83). - Network errors name a closed failure class (`connection refused`, TLS / corporate CA, `proxy required`) without echoing `error.message`, headers, or body (#83). - Document corporate proxy / CA setup and the submit visibility-probe captive-proxy edge (`docs/corporate-networks.md`, #83). +- Add a submit-only, post-consent npm release-date check for the small audited + Better Auth, Lemon Squeezy, and Paddle package subset (#81). It runs only + after the remote-visibility gate, completes or aborts before upload, and + warns without blocking when a skill's first-seen date predates the earliest + complete mapped npm reference. npm receives only selected public package + names and normal connection data; failures are silent and no result is + cached. Ambiguous cross-ecosystem and Tier 2 slugs are excluded. The bundle, + schema, Redential upload body, and closed taxonomy are unchanged. + +### Changed +- `--confirm-upload` help now states that it authorizes both the reviewed + upload and subsequent network steps, including eligible npm lookups (#81). ### Fixed - Tier 1 import extraction no longer credits commented-out imports inside diff --git a/docs/login-submit.md b/docs/login-submit.md index 55857f5..fe757b9 100644 --- a/docs/login-submit.md +++ b/docs/login-submit.md @@ -218,7 +218,14 @@ entry points share every invariant below identically. It then: ``` — the label is never part of the bundle JSON below; this is the one place its exact value is shown before consent (principle 4). - 5. A header line — + 5. If the bundle contains an audited npm release-check candidate, a + disclosure explains that confirmation will allow selected public + package names to be queried from npm, which receives those names and + normal connection data such as the user's IP address. It also states + that npm never receives source, the repository URL, the bundle, or the + Redential token. This disclosure sits outside `WHAT GETS UPLOADED` + because that box describes data sent to Redential. + 6. A header line — ``` Exact payload (byte-for-byte what gets sent): ``` @@ -266,7 +273,11 @@ entry points share every invariant below identically. It then: or an identity lookup ever leaves the machine — this is `submit`-only behavior; `scan` still only ever warns, never blocks, since `scan` has no network access to make the real determination. -6. Fetches identity corroboration (below) and, if it succeeds, prints one +6. For the small audited subset of npm-backed skills described below, + checks the public npm package creation date. Every lookup completes or + aborts before the upload starts. Findings are warnings only; lookup + failures and incomplete data are silent and never block the upload. +7. Fetches identity corroboration (below) and, if it succeeds, prints one informational line with the result — see that section for exactly what is and isn't sent. Moved here, after the visibility gate (owner directive, 2026-08): corroboration is informational, not part of the @@ -274,9 +285,9 @@ entry points share every invariant below identically. It then: upload question — it fires only once the user has already said yes. Never blocks or delays the next step: any failure here simply skips the line and sends no header. -7. `POST {SITE_URL}/api/cli/bundles` with `Authorization: Bearer +8. `POST {SITE_URL}/api/cli/bundles` with `Authorization: Bearer ` and the printed bundle JSON as the body — plus, if step - 6's corroboration check succeeded, an + 7's corroboration check succeeded, an `X-Redential-Identity-Corroboration` header (below). On success: `{id}`. Only the `id` is ever printed back — never the full response body, so a change on the server side can't accidentally start echoing @@ -286,16 +297,16 @@ entry points share every invariant below identically. It then: relocated here from `scan`'s own summary, which used to show it unconditionally before any upload happened; see [scan.md](scan.md#the-summary-default-tty-output)). -8. `POST {SITE_URL}/api/cli/private-label` with `{bundle_id: , private_label: }` — only after - step 7 has already succeeded. See +9. `POST {SITE_URL}/api/cli/private-label` with `{bundle_id: , private_label: }` — only after + step 8 has already succeeded. See [docs/private-label.md](private-label.md) for the full contract and failure semantics: this request is never retried, and a failure here never triggers a second bundle upload — it only prints a warning (naming the label, so it can be set again from the web) and `submit` still exits 0, since the bundle itself is already safely uploaded (see [exit-codes.md](exit-codes.md)). -9. Records the upload locally (`last-submission.json`, above) — not part +10. Records the upload locally (`last-submission.json`, above) — not part of what's sent, just local bookkeeping for a later `scan`'s next-step hint. Unlike the version-check notice below, this is not best-effort: a failure here (e.g. an unwritable config dir) surfaces as a real @@ -338,6 +349,73 @@ this gate is its real, definitive answer: this probe look public and block submit. That is a false block, not a leak — see [corporate-networks.md](corporate-networks.md). +## npm release-date check (submit-only) + +After upload confirmation and after the remote-visibility gate has allowed +submission, `submit` checks a deliberately small, audited subset of detected +skills against the public npm registry. It requests the official full +packument with: + +```text +GET https://registry.npmjs.org/{encoded-package-name} +Accept: application/json +``` + +A scoped package is encoded as one path segment (for example, +`%40scope%2Fpackage`). There is no query string, request body, +`Authorization` header, CLI-created `Cookie`, Redential header, or Redential +token. npm sees the selected public package name and ordinary connection +metadata, including the source IP. It never receives the bundle, repository +URL or remote, path, source, label, identity, or token. + +The initial audited set is intentionally conservative: + +- `better-auth` for `auth/better-auth`; +- `@lemonsqueezy/lemonsqueezy.js` for `payments/lemonsqueezy`; +- `@paddle/paddle-js` and `@paddle/paddle-node-sdk` for `payments/paddle`. + +`signatures/package-map.json` stores only those eligible package keys. Their +slugs are still derived from the existing detection map; the list neither +detects skills nor records which package caused a detection. CI requires all +map keys for an eligible slug to be audited and excludes any slug reachable +through Tier 2. Consequently, ambiguous or cross-ecosystem slugs such as +`ai/openai-api` and `auth/firebase-auth` never trigger this check. + +For each eligible detected slug, the CLI reads only `time.created`. If a slug +has several mapped npm references, all must return valid canonical UTC dates; +the earliest date is used. A warning appears only when the bundle's +`first_seen` is strictly earlier. Equality or a later date is not a finding. +Vendored code and private forks can legitimately predate a public npm release, +so the warning is non-accusatory, never adds a prompt, and explicitly says the +upload will continue. A known, low-risk limitation: if a package was ever +unpublished and republished on npm, `time.created` resets to the republish +date, so a legitimately old package can show a misleadingly recent date — an +acceptable gap given the check is already warn-only and never blocks the +upload. + +The checker is bounded to four concurrent requests, 1.5 seconds per request, +and 3 seconds for the entire operation. Every started request is awaited until +it resolves or its abort timeout fires; npm work never continues in the +background or overlaps the Redential upload. A 404, 429, 5xx, network failure, +timeout, malformed response, missing/invalid date, incomplete multi-package +result, exhausted deadline, or unexpected checker error silently omits the +finding and allows upload. No result is cached: persisting it would create a +local technology history and require separate TTL, permissions, corruption, +and migration decisions. + +TTY users see the npm disclosure after the private-label line and before the +payload header, still leaving the exact JSON immediately adjacent to the +upload question. Non-TTY stdout remains JSON-only with the bundle as its first +output; after `--confirm-upload` and a successful visibility gate, the same +disclosure is written to stderr immediately before npm is contacted. A decline, +a visibility refusal, or a bundle with no eligible skill produces no npm +request. `--confirm-upload` therefore authorizes both the reviewed upload and +these subsequent network steps. + +The bundle schema and the byte-for-byte Redential upload body are unchanged. +This new network destination was discussed in issue #81; no bundle field, +Redential upload header, or schema change is involved. + ## Identity corroboration (submit-only) After the single upload confirmation (step 4 above) has been answered @@ -385,7 +463,7 @@ identity is exactly as valid as before, just without an extra corroboration marker server-side. On upload, the two counts travel as a single optional HTTP header on the -`POST /api/cli/bundles` request (step 7 above): +`POST /api/cli/bundles` request (step 8 above): `X-Redential-Identity-Corroboration: {"corroborated_count": N, "total_claimed": M}` (compact JSON). This is the only place they go — they are never added to the bundle body, so the bundle stays byte-for-byte diff --git a/docs/privacy-tests.md b/docs/privacy-tests.md index 02e40c1..fc5f31a 100644 --- a/docs/privacy-tests.md +++ b/docs/privacy-tests.md @@ -17,6 +17,8 @@ wrong, not the test. |---|---| | `test/scan.test.ts` → "requires explicit confirmation before producing a bundle" | `runScan` throws unless `confirmed` is explicitly `true` — `--author` alone (non-interactive identity selection) never implies authorization. | | `test/prompt.test.ts` (both cases) | If the interactive attestation/author prompt hits EOF (closed stdin) before an answer, the CLI fails loudly (throws) instead of silently proceeding — no confirmation can be assumed by default. | +| `test/privacy/zero-network.test.ts` → Better Auth hand-off declined at the upload question | Even when the reviewed bundle contains an audited npm release-check candidate, the complete `scan` → `submit` hand-off makes zero `fetch`/HTTP/HTTPS calls when upload consent is declined. | +| `test/privacy/submit-guardrail.test.ts` → npm release lookup privacy boundary | The npm GET happens only after confirmation and the visibility gate, finishes before identity/upload, carries only the audited public package path, and contains no token, bundle, remote, repository URL, label, body, query, authorization, or CLI cookie. | ## 3. Bounded output @@ -35,6 +37,7 @@ wrong, not the test. |---|---| | `test/scan.test.ts` → single-commit / multiple-author cases (implicit) | `runScan` is a pure function of its inputs (repo state + explicit `now`): given the same repository and the same `now`, it returns byte-identical JSON on every call — there is no hidden enrichment step between what a caller inspects and what `submit` would later send, since both would come from calling the same function on the same reviewed bundle. | | `test/privacy/submit-guardrail.test.ts` → "the request body equals the exact string logged before the upload confirmation" | Closes the gap noted below: `submit` prints the bundle via the same `buildBundleInteractively` path `scan` uses, then uploads that **exact printed string** (`postRawJson`, never a re-serialization of the parsed object) — proven by asserting the mock server's received request body is `===` the printed line, not just deep-equal after re-parsing. | +| `test/privacy/submit-guardrail.test.ts` → npm release lookup privacy boundary | Adding the npm check does not enrich or reserialize the Redential payload: the exact JSON printed for review is still byte-for-byte the bundle POST body, while any timeline warning stays stderr-only and non-blocking. | _Gap closed: `submit` now exists (see [login-submit.md](login-submit.md))_ _and sends the exact bytes `scan`'s bundle-building path printed, verified_ diff --git a/docs/signatures.md b/docs/signatures.md index ceda966..3c749fd 100644 --- a/docs/signatures.md +++ b/docs/signatures.md @@ -59,6 +59,32 @@ run anything, or name a slug outside `taxonomy.json` (enforced at load time — see "Closed vocabulary" below). This is the whole reason Tier 1 can scale to hundreds of entries without hundreds of regexes to audit. +### Audited npm release-check metadata + +`signatures/package-map.json` also contains `npmReleaseCheckPackages`, a +small list of public npm package keys eligible for `submit`'s post-consent +release-date check. This list is **not a detection tier** and never creates a +skill match. Detection still uses only `map`; the checker looks up each listed +key in that map to derive its slug, so package-to-slug knowledge is not +duplicated. + +The initial list contains only `better-auth`, +`@lemonsqueezy/lemonsqueezy.js`, `@paddle/paddle-js`, and +`@paddle/paddle-node-sdk`. Tests require every key to exist in `map`, every +map key pointing to an eligible slug to appear in the list, and no eligible +slug to be produced by a Tier 2 signature. Adding another map key for one of +those slugs therefore fails CI until the entire slug is reaudited. Slugs that +are shared with another ecosystem or reachable through Tier 2 are excluded; +for example, `ai/openai-api` and `auth/firebase-auth` never trigger an npm +lookup merely because their slugs appear in a bundle. + +This conservative metadata deliberately does not claim which package caused a +historical detection: the bundle records only a slug. It allows `submit` to +compare a small unambiguous subset while leaving `scan`, skill detection, the +bundle, and the closed vocabulary unchanged. See +[login-submit.md](login-submit.md#npm-release-date-check-submit-only) for the +network and warning contract. + ### PHP scope, honestly `composer.json`'s `require` block is parsed as structured JSON — exact, diff --git a/signatures/package-map.json b/signatures/package-map.json index 7de3684..29087aa 100644 --- a/signatures/package-map.json +++ b/signatures/package-map.json @@ -686,5 +686,11 @@ "hvac": "security/hashicorp-vault", "node-vault": "security/hashicorp-vault", "pgvector": "ai/vector-search" - } + }, + "npmReleaseCheckPackages": [ + "better-auth", + "@lemonsqueezy/lemonsqueezy.js", + "@paddle/paddle-js", + "@paddle/paddle-node-sdk" + ] } diff --git a/src/http-client.ts b/src/http-client.ts index 1835693..53d2c54 100644 --- a/src/http-client.ts +++ b/src/http-client.ts @@ -234,10 +234,11 @@ export async function headRequest(url: string, timeoutMs: number): Promise<{ sta * Anonymous-or-authenticated GET with a timeout. Returns null (never * throws) on any failure — network error, timeout, non-2xx status, or a * body that isn't valid JSON — so a broken or offline endpoint can never - * delay or fail the command it's attached to. Two best-effort callers: - * version-check.ts's npm-registry freshness check (no headers), and - * submit.ts's fetchVerifiedEmails (a bearer `Authorization` header, for - * the identity-corroboration lookup) — both share this fail-open contract. + * delay or fail the command it's attached to. Best-effort callers are + * version-check.ts's npm-registry freshness check, submit.ts's npm + * packument transport (an `Accept` header only), and submit.ts's + * fetchVerifiedEmails (a bearer `Authorization` header for identity + * corroboration). All share this fail-open contract. */ export async function getJson( url: string, diff --git a/src/npm-anachronism.ts b/src/npm-anachronism.ts new file mode 100644 index 0000000..dbd9230 --- /dev/null +++ b/src/npm-anachronism.ts @@ -0,0 +1,216 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { ScanError } from "./errors.js"; +import type { Bundle } from "./types.js"; + +export const NPM_REGISTRY_BASE_URL = "https://registry.npmjs.org"; +export const NPM_REQUEST_TIMEOUT_MS = 1_500; +export const NPM_CHECK_DEADLINE_MS = 3_000; +export const NPM_CHECK_MAX_CONCURRENCY = 4; + +export const NPM_RELEASE_CHECK_DISCLOSURE = + "After confirmation, Redential will query the npm registry for selected public package names associated with the detected skills. " + + "npm receives those package names and standard connection data (such as your IP address), but never your source, repository URL, bundle, or Redential token."; + +const DEFAULT_PACKAGE_MAP_PATH = fileURLToPath(new URL("../signatures/package-map.json", import.meta.url)); +const CANONICAL_UTC_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?Z$/; + +interface PackageMapFile { + map?: Record; + npmReleaseCheckPackages?: unknown; +} + +export interface NpmReleaseCheckMetadata { + packageToSlug: Map; + packagesBySlug: Map; +} + +export interface NpmAnachronism { + slug: string; + firstSeen: string; + earliestMappedNpmRelease: string; +} + +export type NpmPackumentTransport = (url: string, timeoutMs: number) => Promise; + +export interface NpmAnachronismOptions { + packageMapPath?: string; + now?: () => number; + registryBaseUrl?: string; +} + +function metadataError(path: string): ScanError { + return new ScanError(`Invalid npm release-check metadata in package map file: ${path}`); +} + +export function loadNpmReleaseCheckMetadata( + path: string = DEFAULT_PACKAGE_MAP_PATH +): NpmReleaseCheckMetadata { + let parsed: PackageMapFile; + try { + parsed = JSON.parse(readFileSync(path, "utf8")) as PackageMapFile; + } catch { + throw metadataError(path); + } + + if (!parsed.map || typeof parsed.map !== "object" || !Array.isArray(parsed.npmReleaseCheckPackages)) { + throw metadataError(path); + } + + const packageToSlug = new Map(Object.entries(parsed.map)); + const eligiblePackages = parsed.npmReleaseCheckPackages; + if ( + !eligiblePackages.every((packageName): packageName is string => + typeof packageName === "string" && packageName.length > 0 && packageToSlug.has(packageName) + ) || + new Set(eligiblePackages).size !== eligiblePackages.length + ) { + throw metadataError(path); + } + + const packagesBySlug = new Map(); + for (const packageName of eligiblePackages) { + const slug = packageToSlug.get(packageName)!; + const packages = packagesBySlug.get(slug) ?? []; + packages.push(packageName); + packagesBySlug.set(slug, packages); + } + for (const packages of packagesBySlug.values()) packages.sort(); + + return { packageToSlug, packagesBySlug }; +} + +export function npmReleaseCheckCandidates( + bundle: Bundle, + packageMapPath?: string +): Map { + const { packagesBySlug } = loadNpmReleaseCheckMetadata(packageMapPath); + const detectedSlugs = new Set(bundle.detected_skills.map((skill) => skill.slug)); + return new Map( + [...packagesBySlug] + .filter(([slug]) => detectedSlugs.has(slug)) + .sort(([a], [b]) => a.localeCompare(b)) + ); +} + +export function hasNpmReleaseCheckCandidates(bundle: Bundle, packageMapPath?: string): boolean { + try { + return npmReleaseCheckCandidates(bundle, packageMapPath).size > 0; + } catch { + return false; + } +} + +/** Parses only npm/CLI canonical UTC timestamps, with explicit calendar validation. */ +export function parseCanonicalUtcTimestamp(value: string): number | null { + const match = CANONICAL_UTC_TIMESTAMP.exec(value); + if (!match) return null; + const [, yearText, monthText, dayText, hourText, minuteText, secondText, millisText = "000"] = match; + const parts = [yearText, monthText, dayText, hourText, minuteText, secondText, millisText].map(Number); + const [year, month, day, hour, minute, second, millis] = parts; + if (month < 1 || month > 12 || day < 1 || hour > 23 || minute > 59 || second > 59) return null; + + const timestamp = Date.UTC(year, month - 1, day, hour, minute, second, millis); + const date = new Date(timestamp); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day || + date.getUTCHours() !== hour || + date.getUTCMinutes() !== minute || + date.getUTCSeconds() !== second || + date.getUTCMilliseconds() !== millis + ) { + return null; + } + return timestamp; +} + +function createdTimestamp(packument: unknown): { value: string; timestamp: number } | null { + if (!packument || typeof packument !== "object") return null; + const time = (packument as { time?: unknown }).time; + if (!time || typeof time !== "object") return null; + const created = (time as { created?: unknown }).created; + if (typeof created !== "string") return null; + const timestamp = parseCanonicalUtcTimestamp(created); + return timestamp === null ? null : { value: created, timestamp }; +} + +/** + * Checks the conservative package subset selected by package-map metadata. + * Every started request is awaited; the transport owns timeout cancellation. + */ +export async function checkNpmAnachronisms( + bundle: Bundle, + transport: NpmPackumentTransport, + options: NpmAnachronismOptions = {} +): Promise { + const candidates = npmReleaseCheckCandidates(bundle, options.packageMapPath); + if (candidates.size === 0) return []; + + const now = options.now ?? Date.now; + const deadline = now() + NPM_CHECK_DEADLINE_MS; + const registryBaseUrl = options.registryBaseUrl ?? NPM_REGISTRY_BASE_URL; + const packageNames = [...new Set([...candidates.values()].flat())].sort(); + const results = new Map(); + let nextIndex = 0; + + async function worker(): Promise { + while (nextIndex < packageNames.length) { + const remainingMs = Math.floor(deadline - now()); + if (remainingMs < 1) return; + const packageName = packageNames[nextIndex++]; + const timeoutMs = Math.min(NPM_REQUEST_TIMEOUT_MS, remainingMs); + const url = `${registryBaseUrl}/${encodeURIComponent(packageName)}`; + try { + results.set(packageName, await transport(url, timeoutMs)); + } catch { + results.set(packageName, null); + } + } + } + + const workers = Array.from( + { length: Math.min(NPM_CHECK_MAX_CONCURRENCY, packageNames.length) }, + () => worker() + ); + await Promise.all(workers); + + const skillsBySlug = new Map(bundle.detected_skills.map((skill) => [skill.slug, skill])); + const findings: NpmAnachronism[] = []; + for (const [slug, packages] of candidates) { + const firstSeen = skillsBySlug.get(slug)?.first_seen; + if (!firstSeen) continue; + const firstSeenTimestamp = parseCanonicalUtcTimestamp(firstSeen); + if (firstSeenTimestamp === null) continue; + + const releases = packages.map((packageName) => createdTimestamp(results.get(packageName))); + if (releases.some((release) => release === null)) continue; + const earliest = (releases as { value: string; timestamp: number }[]).reduce((a, b) => + a.timestamp <= b.timestamp ? a : b + ); + if (firstSeenTimestamp < earliest.timestamp) { + findings.push({ + slug, + firstSeen, + earliestMappedNpmRelease: earliest.value, + }); + } + } + return findings; +} + +export function formatNpmAnachronismWarning(findings: NpmAnachronism[]): string | null { + if (findings.length === 0) return null; + const lines = [ + "Warning: release-date checks found possible timeline conflicts; upload will continue.", + ...[...findings] + .sort((a, b) => a.slug.localeCompare(b.slug)) + .map( + (finding) => + `- \`${finding.slug}\` was first seen on ${finding.firstSeen.slice(0, 10)}, before the earliest mapped npm release reference audited for this skill (${finding.earliestMappedNpmRelease.slice(0, 10)}).` + ), + "Vendored or private forked copies can legitimately predate a public npm release; independent verification may still flag these dates.", + ]; + return lines.join("\n"); +} diff --git a/src/program.ts b/src/program.ts index 6ef3bba..bc4ad67 100644 --- a/src/program.ts +++ b/src/program.ts @@ -167,7 +167,11 @@ program "answer the scan-authorization confirmation (y) non-interactively", false ) - .option("--confirm-upload", "confirm the upload itself non-interactively (separate from --yes)", false) + .option( + "--confirm-upload", + "confirm the upload and post-review network steps non-interactively, including eligible npm release-date lookups (separate from --yes)", + false + ) .option( "--label ", "a private nickname for this repo, visible only to you (see docs/private-label.md); " + diff --git a/src/submit-command.ts b/src/submit-command.ts index e5fb352..f638772 100644 --- a/src/submit-command.ts +++ b/src/submit-command.ts @@ -4,7 +4,13 @@ import { formatConsentSummary } from "./summary.js"; import { getSiteUrl } from "./config.js"; import { readCredentials } from "./credentials.js"; import { getRemoteUrl } from "./git.js"; -import { checkVisibilityGate, fetchVerifiedEmails, postBundle, postPrivateLabel } from "./submit.js"; +import { + checkVisibilityGate, + fetchNpmPackument, + fetchVerifiedEmails, + postBundle, + postPrivateLabel, +} from "./submit.js"; import { promptConfirmUpload, promptPrivateLabel } from "./prompt.js"; import { checkForUpdate } from "./version-check.js"; import { bundleContentHash, saveLastSubmission } from "./submission-record.js"; @@ -13,6 +19,13 @@ import { getOrCreateSalt } from "./salt.js"; import { validatePrivateLabel } from "./private-label.js"; import { writeStderrLine } from "./stderr.js"; import type { Bundle } from "./types.js"; +import { + checkNpmAnachronisms, + formatNpmAnachronismWarning, + hasNpmReleaseCheckCandidates, + NPM_RELEASE_CHECK_DISCLOSURE, + type NpmAnachronism, +} from "./npm-anachronism.js"; export type SubmitCommandOptions = BuildBundleOptions & { /** Separate from `yes` (authorization-to-scan) on purpose — this is @@ -40,8 +53,12 @@ export type SubmitCommandOptions = BuildBundleOptions & { // Injectable for tests, so the visibility gate doesn't need a real // network call to github.com to exercise the blocked/unblocked paths. probeFn?: Parameters[1]; - // Injectable so tests don't make a real request to the npm registry; - // defaults to the real checkForUpdate (src/version-check.ts). + // Injectable so tests can exercise release-date findings and failures + // without contacting the npm registry. The full checker is injected, + // keeping its fail-open boundary at this command layer. + checkNpmAnachronismsFn?: (bundle: Bundle) => Promise; + // Injectable so tests don't make the post-upload CLI version request to + // the npm registry; defaults to checkForUpdate (src/version-check.ts). checkForUpdateFn?: () => Promise; // True when stdout is an interactive terminal — cli.ts passes // `process.stdout.isTTY`. Determines whether the human-readable @@ -178,6 +195,7 @@ export async function executeSubmitCommand(opts: SubmitCommandOptions): Promise< const bundle = await buildBundleInteractively(opts); const bundleJson = JSON.stringify(bundle, null, 2); + const hasNpmCandidates = hasNpmReleaseCheckCandidates(bundle); // Thin-history / shallow-clone advisory — see thinHistoryNotice's own // comment. Printed here, before any of the consent-surface output below @@ -223,12 +241,14 @@ export async function executeSubmitCommand(opts: SubmitCommandOptions): Promise< // that travels is shown before the y/n, and the label travels just // as much as the bundle does, even though it's a separate request // (see postPrivateLabel below). - // 4. the payload header + the exact JSON (byte-for-byte what gets sent + // 4. the npm registry disclosure, only when an audited detected skill + // is eligible for the post-confirmation release-date check. + // 5. the payload header + the exact JSON (byte-for-byte what gets sent // — this print IS the guarantee). INVARIANT (owner directive, // 2026-08): the exact JSON is ALWAYS the last thing printed before // the single upload confirmation below, on every path — nothing may // come between them. - // 5. the single upload prompt — "Upload this to your Redential + // 6. the single upload prompt — "Upload this to your Redential // profile? (Y/n)" — immediately after the JSON. // ZERO-NETWORK INVARIANT (owner directive, 2026-08): EVERYTHING above // this prompt is purely local — no identity-corroboration lookup, no @@ -254,6 +274,7 @@ export async function executeSubmitCommand(opts: SubmitCommandOptions): Promise< privateLabel = await (opts.promptPrivateLabelFn ?? promptPrivateLabel)(); } log(formatPrivateLabelLine(privateLabel, opts.plain)); + if (hasNpmCandidates) log(NPM_RELEASE_CHECK_DISCLOSURE); log("Exact payload (byte-for-byte what gets sent):"); log(bundleJson); } @@ -285,6 +306,26 @@ export async function executeSubmitCommand(opts: SubmitCommandOptions): Promise< throw new SubmitError("Submit refused: see the message above."); } + // Conservative npm release-date check. It starts only after consent and + // the visibility gate, and every request it starts has completed or + // aborted before identity lookup/upload begins. Non-TTY users receive + // the same disclosure on stderr immediately before the first lookup; + // TTY users already saw it on the pre-confirmation consent surface. + if (hasNpmCandidates) { + if (!opts.isTTY) warn(NPM_RELEASE_CHECK_DISCLOSURE); + try { + const findings = await ( + opts.checkNpmAnachronismsFn ?? + ((checkedBundle: Bundle) => checkNpmAnachronisms(checkedBundle, fetchNpmPackument)) + )(bundle); + const npmWarning = formatNpmAnachronismWarning(findings); + if (npmWarning) warn(npmWarning); + } catch { + // Fail open: malformed metadata, a fake transport that throws, or any + // unexpected checker failure must never block the reviewed upload. + } + } + // Identity corroboration (optional X-Redential-Identity-Corroboration // header on postBundle below) — moved here, after the visibility gate // and after the upload confirmation (owner directive, 2026-08: this used diff --git a/src/submit.ts b/src/submit.ts index 22f71c6..dacf9c1 100644 --- a/src/submit.ts +++ b/src/submit.ts @@ -6,6 +6,15 @@ import { IDENTITY_CORROBORATION_HEADER, type IdentityCorroboration } from "./ide const HEAD_TIMEOUT_MS = 5000; const EMAILS_TIMEOUT_MS = 5000; +/** + * Transport for submit's npm release-date check. The caller constructs the + * fixed registry URL; this wrapper adds only npm's documented JSON accept + * header and delegates timeout cancellation to getJson. + */ +export async function fetchNpmPackument(url: string, timeoutMs: number): Promise { + return getJson(url, timeoutMs, { accept: "application/json" }); +} + /** * Converts a git remote URL (https, scp-like `git@host:org/repo.git`, or * `ssh://`) into an https URL worth HEAD-requesting. Only ever called after diff --git a/test/npm-anachronism.test.ts b/test/npm-anachronism.test.ts new file mode 100644 index 0000000..a2581d8 --- /dev/null +++ b/test/npm-anachronism.test.ts @@ -0,0 +1,358 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + checkNpmAnachronisms, + formatNpmAnachronismWarning, + loadNpmReleaseCheckMetadata, + NPM_RELEASE_CHECK_DISCLOSURE, + npmReleaseCheckCandidates, + parseCanonicalUtcTimestamp, + type NpmPackumentTransport, +} from "../src/npm-anachronism.js"; +import { loadPackageMap, loadSignatures } from "../src/skill-detect.js"; +import { fetchNpmPackument } from "../src/submit.js"; +import type { Bundle, DetectedSkill } from "../src/types.js"; + +const PACKAGE_MAP_PATH = fileURLToPath(new URL("../signatures/package-map.json", import.meta.url)); +const SIGNATURES_DIR = fileURLToPath(new URL("../signatures", import.meta.url)); +const dirs: string[] = []; + +function bundleWithSkills(...detectedSkills: DetectedSkill[]): Bundle { + return { + schema_version: "1.4.0", + runner: "local", + tool_version: "0.13.0", + created_at: "2026-08-14T00:00:00.000Z", + repo: { host_type: "none", age_days: 1, repo_fingerprint: "abc" }, + identity: { author_identity_hashes: ["hash"], other_contributors_count: 0 }, + commits: { + user_total: 1, + first_at: "2022-01-01T00:00:00.000Z", + last_at: "2022-01-01T00:00:00.000Z", + span_days: 0, + hour_histogram: new Array(24).fill(0), + weekday_histogram: new Array(7).fill(0), + }, + signed: { count: 0, ratio: 0 }, + languages: [], + categories: [], + detected_skills: detectedSkills, + ownership: { user_commit_ratio: 1 }, + integrity: { + merkle_root: "0".repeat(64), + algorithm: "rfc6962-sha256", + date_forensics: { + author_span_days: 0, + committer_span_days: 0, + mismatch_ratio: 0, + committer_burst_ratio: 0, + }, + }, + attestation: { authorized_confirmation: true, confirmed_at: "2026-08-14T00:00:00.000Z" }, + }; +} + +function skill(slug: string, firstSeen = "2022-06-01T00:00:00.000Z"): DetectedSkill { + return { slug, commit_count: 1, first_seen: firstSeen, last_seen: firstSeen }; +} + +function packument(created: string): unknown { + return { time: { created } }; +} + +function customMap(packages: string[]): string { + const dir = mkdtempSync(join(tmpdir(), "redential-npm-map-")); + dirs.push(dir); + const path = join(dir, "package-map.json"); + writeFileSync( + path, + JSON.stringify({ map: Object.fromEntries(packages.map((name) => [name, "auth/better-auth"])), npmReleaseCheckPackages: packages }) + ); + return path; +} + +afterEach(() => { + while (dirs.length > 0) rmSync(dirs.pop()!, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe("npm release-check metadata", () => { + it("contains exactly the four audited package keys and preserves loadPackageMap behavior", () => { + const raw = JSON.parse(readFileSync(PACKAGE_MAP_PATH, "utf8")) as { + map: Record; + npmReleaseCheckPackages: string[]; + }; + expect(raw.npmReleaseCheckPackages).toEqual([ + "better-auth", + "@lemonsqueezy/lemonsqueezy.js", + "@paddle/paddle-js", + "@paddle/paddle-node-sdk", + ]); + + const loaded = loadNpmReleaseCheckMetadata(PACKAGE_MAP_PATH); + expect(loadPackageMap(PACKAGE_MAP_PATH)).toEqual(new Map(Object.entries(raw.map))); + expect(loaded.packageToSlug).toEqual(loadPackageMap(PACKAGE_MAP_PATH)); + }); + + it("includes every package-map key for each eligible slug and excludes Tier 2 slugs", () => { + const { packageToSlug, packagesBySlug } = loadNpmReleaseCheckMetadata(PACKAGE_MAP_PATH); + for (const [slug, eligiblePackages] of packagesBySlug) { + const allMappedPackages = [...packageToSlug] + .filter(([, mappedSlug]) => mappedSlug === slug) + .map(([packageName]) => packageName) + .sort(); + expect(eligiblePackages).toEqual(allMappedPackages); + } + + const tier2Slugs = new Set(loadSignatures(SIGNATURES_DIR).map((signature) => signature.slug)); + for (const slug of packagesBySlug.keys()) expect(tier2Slugs.has(slug)).toBe(false); + }); +}); + +describe("checkNpmAnachronisms", () => { + it("works only for detected eligible slugs; ambiguous ecosystem slugs schedule no request", async () => { + const urls: string[] = []; + const transport: NpmPackumentTransport = async (url) => { + urls.push(url); + return packument("2023-09-18T12:00:00.000Z"); + }; + const findings = await checkNpmAnachronisms( + bundleWithSkills(skill("ai/openai-api"), skill("auth/firebase-auth"), skill("auth/better-auth")), + transport + ); + + expect(urls).toEqual(["https://registry.npmjs.org/better-auth"]); + expect(findings.map((finding) => finding.slug)).toEqual(["auth/better-auth"]); + }); + + it("encodes scoped package names as one path segment and schedules deduplicated packages deterministically", async () => { + const urls: string[] = []; + await checkNpmAnachronisms( + bundleWithSkills(skill("payments/paddle"), skill("payments/lemonsqueezy")), + async (url) => { + urls.push(url); + return packument("2021-01-01T00:00:00Z"); + } + ); + expect(urls).toEqual([ + "https://registry.npmjs.org/%40lemonsqueezy%2Flemonsqueezy.js", + "https://registry.npmjs.org/%40paddle%2Fpaddle-js", + "https://registry.npmjs.org/%40paddle%2Fpaddle-node-sdk", + ]); + }); + + it("warns only when first_seen is strictly earlier and uses the earliest complete multi-package release", async () => { + const dates = new Map([ + ["%40paddle%2Fpaddle-js", "2022-04-01T00:00:00.000Z"], + ["%40paddle%2Fpaddle-node-sdk", "2023-04-01T00:00:00Z"], + ]); + const run = (firstSeen: string) => + checkNpmAnachronisms(bundleWithSkills(skill("payments/paddle", firstSeen)), async (url) => { + const key = url.slice(url.lastIndexOf("/") + 1); + return packument(dates.get(key)!); + }); + + expect(await run("2022-03-31T23:59:59.000Z")).toEqual([ + { + slug: "payments/paddle", + firstSeen: "2022-03-31T23:59:59.000Z", + earliestMappedNpmRelease: "2022-04-01T00:00:00.000Z", + }, + ]); + expect(await run("2022-04-01T00:00:00.000Z")).toEqual([]); + expect(await run("2024-01-01T00:00:00.000Z")).toEqual([]); + }); + + it("omits a multi-package slug when any response is missing, malformed, invalid, null, or throws", async () => { + const invalidResults: Array = [ + null, + {}, + { time: {} }, + packument("not-a-date"), + packument("2023-02-29T00:00:00Z"), + new Error("network failure"), + ]; + for (const invalid of invalidResults) { + const findings = await checkNpmAnachronisms(bundleWithSkills(skill("payments/paddle")), async (url) => { + if (url.includes("node-sdk")) { + if (invalid instanceof Error) throw invalid; + return invalid; + } + return packument("2023-01-01T00:00:00Z"); + }); + expect(findings).toEqual([]); + } + }); + + it("limits concurrency to four without sleeps", async () => { + const path = customMap(["p6", "p2", "p4", "p1", "p5", "p3"]); + let active = 0; + let maxActive = 0; + const resolvers: Array<() => void> = []; + const transport: NpmPackumentTransport = () => { + active++; + maxActive = Math.max(maxActive, active); + return new Promise((resolve) => { + resolvers.push(() => { + active--; + resolve(packument("2023-01-01T00:00:00Z")); + }); + }); + }; + + const checking = checkNpmAnachronisms(bundleWithSkills(skill("auth/better-auth")), transport, { + packageMapPath: path, + }); + await Promise.resolve(); + expect(maxActive).toBe(4); + while (resolvers.length > 0) { + resolvers.shift()!(); + await Promise.resolve(); + } + await checking; + expect(maxActive).toBe(4); + expect(active).toBe(0); + }); + + it("uses the global remaining deadline for queued work and resolves only after every started request settles", async () => { + const path = customMap(["p1", "p2", "p3", "p4", "p5"]); + let now = 10_000; + let active = 0; + const calls: Array<{ timeoutMs: number; resolve: () => void }> = []; + const checking = checkNpmAnachronisms( + bundleWithSkills(skill("auth/better-auth")), + (_url, timeoutMs) => { + active++; + return new Promise((resolve) => { + calls.push({ + timeoutMs, + resolve: () => { + active--; + resolve(packument("2023-01-01T00:00:00Z")); + }, + }); + }); + }, + { packageMapPath: path, now: () => now } + ); + await Promise.resolve(); + expect(calls).toHaveLength(4); + expect(calls.map((call) => call.timeoutMs)).toEqual([1500, 1500, 1500, 1500]); + + now = 12_999; + calls.slice(0, 4).forEach((call) => call.resolve()); + await Promise.resolve(); + await Promise.resolve(); + expect(calls).toHaveLength(5); + expect(calls[4].timeoutMs).toBe(1); + expect(active).toBe(1); + calls[4].resolve(); + await checking; + expect(active).toBe(0); + }); + + it("does not start queued work once the global deadline is exhausted", async () => { + const path = customMap(["p1", "p2", "p3", "p4", "p5"]); + let now = 0; + const resolvers: Array<() => void> = []; + const checking = checkNpmAnachronisms( + bundleWithSkills(skill("auth/better-auth")), + () => new Promise((resolve) => resolvers.push(() => resolve(null))), + { packageMapPath: path, now: () => now } + ); + await Promise.resolve(); + expect(resolvers).toHaveLength(4); + now = 3_000; + resolvers.forEach((resolve) => resolve()); + await checking; + expect(resolvers).toHaveLength(4); + }); +}); + +describe("production npm transport fail-open behavior", () => { + it.each([404, 429, 500])("returns null for HTTP %s", async (status) => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ time: { created: "2023-01-01T00:00:00Z" } }), { + status, + headers: { "content-type": "application/json" }, + }) + ); + + await expect(fetchNpmPackument("https://registry.npmjs.org/better-auth", 1500)).resolves.toBeNull(); + }); + + it("returns null for malformed JSON", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("{not-json", { status: 200, headers: { "content-type": "application/json" } }) + ); + await expect(fetchNpmPackument("https://registry.npmjs.org/better-auth", 1500)).resolves.toBeNull(); + }); + + it("aborts an in-flight production request at its timeout and resolves fail-open", async () => { + let receivedSignal: AbortSignal | undefined; + vi.spyOn(globalThis, "fetch").mockImplementation((_input, init) => { + receivedSignal = init?.signal ?? undefined; + return new Promise((_resolve, reject) => { + receivedSignal?.addEventListener("abort", () => reject(new Error("aborted by test signal")), { once: true }); + }); + }); + + await expect(fetchNpmPackument("https://registry.npmjs.org/better-auth", 5)).resolves.toBeNull(); + expect(receivedSignal).toBeDefined(); + expect(receivedSignal!.aborted).toBe(true); + }); +}); + +describe("date and message formatting", () => { + it("accepts only canonical UTC timestamps with real calendar components", () => { + expect(parseCanonicalUtcTimestamp("2024-02-29T23:59:59Z")).not.toBeNull(); + expect(parseCanonicalUtcTimestamp("2024-02-29T23:59:59.123Z")).not.toBeNull(); + for (const invalid of [ + "2023-02-29T00:00:00Z", + "2024-13-01T00:00:00Z", + "2024-01-01T24:00:00Z", + "2024-01-01", + "2024-01-01T00:00:00+00:00", + "2024-01-01T00:00:00.12Z", + ]) { + expect(parseCanonicalUtcTimestamp(invalid), invalid).toBeNull(); + } + }); + + it("formats deterministic, non-accusatory disclosure and warning copy", () => { + expect(NPM_RELEASE_CHECK_DISCLOSURE).toContain("package names"); + expect(NPM_RELEASE_CHECK_DISCLOSURE).toContain("IP address"); + expect(NPM_RELEASE_CHECK_DISCLOSURE).not.toContain("anonymous"); + + const warning = formatNpmAnachronismWarning([ + { + slug: "payments/paddle", + firstSeen: "2021-01-01T00:00:00.000Z", + earliestMappedNpmRelease: "2022-01-01T00:00:00Z", + }, + { + slug: "auth/better-auth", + firstSeen: "2022-06-01T00:00:00.000Z", + earliestMappedNpmRelease: "2023-09-18T00:00:00Z", + }, + ]); + expect(warning).toBe( + "Warning: release-date checks found possible timeline conflicts; upload will continue.\n" + + "- `auth/better-auth` was first seen on 2022-06-01, before the earliest mapped npm release reference audited for this skill (2023-09-18).\n" + + "- `payments/paddle` was first seen on 2021-01-01, before the earliest mapped npm release reference audited for this skill (2022-01-01).\n" + + "Vendored or private forked copies can legitimately predate a public npm release; independent verification may still flag these dates." + ); + expect(warning).not.toMatch(/fraud|fake/i); + expect(formatNpmAnachronismWarning([])).toBeNull(); + }); + + it("returns candidates sorted by slug", () => { + expect([...npmReleaseCheckCandidates(bundleWithSkills(skill("payments/paddle"), skill("auth/better-auth"))).keys()]).toEqual([ + "auth/better-auth", + "payments/paddle", + ]); + }); +}); diff --git a/test/package-map.test.ts b/test/package-map.test.ts index 1ccbedb..8435791 100644 --- a/test/package-map.test.ts +++ b/test/package-map.test.ts @@ -8,6 +8,12 @@ const PACKAGE_MAP_PATH = fileURLToPath(new URL("../signatures/package-map.json", const TAXONOMY_PATH = fileURLToPath(new URL("../taxonomy.json", import.meta.url)); const SLUG_SHAPE = /^[a-z0-9]+(-[a-z0-9]+)*\/[a-z0-9]+(-[a-z0-9]+)*$/; +const AUDITED_NPM_RELEASE_PACKAGES = [ + "better-auth", + "@lemonsqueezy/lemonsqueezy.js", + "@paddle/paddle-js", + "@paddle/paddle-node-sdk", +]; describe("signatures/package-map.json", () => { it("has at least 400 entries", () => { @@ -81,6 +87,29 @@ describe("signatures/package-map.json", () => { expect([...dupes]).toEqual([]); }); + it("keeps the npm release-check allowlist small, map-backed, and detection-neutral", () => { + const raw = JSON.parse(readFileSync(PACKAGE_MAP_PATH, "utf8")) as { + map: Record; + npmReleaseCheckPackages: string[]; + }; + expect(raw.npmReleaseCheckPackages).toEqual(AUDITED_NPM_RELEASE_PACKAGES); + for (const packageName of raw.npmReleaseCheckPackages) { + expect(raw.map[packageName], `${packageName} must remain a detection-map key`).toBeDefined(); + } + expect(loadPackageMap(PACKAGE_MAP_PATH)).toEqual(new Map(Object.entries(raw.map))); + }); + + it("requires every map key for an eligible npm-release slug to be audited", () => { + const raw = JSON.parse(readFileSync(PACKAGE_MAP_PATH, "utf8")) as { + map: Record; + npmReleaseCheckPackages: string[]; + }; + const eligibleSlugs = new Set(raw.npmReleaseCheckPackages.map((packageName) => raw.map[packageName])); + for (const [packageName, slug] of Object.entries(raw.map)) { + if (eligibleSlugs.has(slug)) expect(raw.npmReleaseCheckPackages).toContain(packageName); + } + }); + it("db/bigquery wins over infra/gcp-sdk for a bigquery-only fixture (spec precedence)", () => { const map = loadPackageMap(PACKAGE_MAP_PATH); const packageJsonDiff = diff --git a/test/privacy/submit-guardrail.test.ts b/test/privacy/submit-guardrail.test.ts index 50fe736..e251748 100644 --- a/test/privacy/submit-guardrail.test.ts +++ b/test/privacy/submit-guardrail.test.ts @@ -6,7 +6,8 @@ import { cleanup, commit, createRepo, setRemote } from "../support/fixtures.js"; import { startMockServer, type MockServer } from "../support/mock-server.js"; import { saveCredentials } from "../../src/credentials.js"; import { executeSubmitCommand } from "../../src/submit-command.js"; -import { checkVisibilityGate } from "../../src/submit.js"; +import { checkVisibilityGate, fetchNpmPackument } from "../../src/submit.js"; +import { checkNpmAnachronisms } from "../../src/npm-anachronism.js"; import { NetworkError } from "../../src/errors.js"; const dirs: string[] = []; @@ -25,6 +26,20 @@ function tempConfigDir(): string { return dir; } +function repoWithBetterAuth(remote?: string): string { + const dir = createRepo(); + dirs.push(dir); + if (remote) setRemote(dir, remote); + commit(dir, { + message: "use better auth", + authorName: "You", + authorEmail: "you@example.com", + authorDate: "2022-06-01T00:00:00Z", + files: { "auth.ts": 'import { betterAuth } from "better-auth";\n' }, + }); + return dir; +} + function repoWithOneCommit(remote?: string): string { const dir = createRepo(); dirs.push(dir); @@ -103,6 +118,83 @@ describe("the visibility probe never fires against a credentialed remote URL", ( }); }); +describe("npm release lookup privacy boundary", { timeout: 30_000 }, () => { + it("runs a bounded public-package GET after visibility and before upload, without Redential or repository data", async () => { + const events: string[] = []; + const registry = await startMockServer((req) => { + events.push("npm"); + return { status: 200, body: { time: { created: "2023-09-18T00:00:00.000Z" } } }; + }); + servers.push(registry); + const site = await startMockServer((req) => { + if (req.url === "/api/cli/identity/emails") { + events.push("identity"); + return { status: 404, body: {} }; + } + if (req.url === "/api/cli/bundles") { + events.push("upload"); + return { status: 200, body: { id: "ok" } }; + } + if (req.url === "/api/cli/private-label") return { status: 204, body: {} }; + return { status: 404, body: {} }; + }); + servers.push(site); + process.env.REDENTIAL_SITE_URL = site.url; + + const remote = "https://github.com/acme/private-employer-repo.git"; + const label = "Private employer work"; + const token = "xxx-EXAMPLE-token"; + const dir = repoWithBetterAuth(remote); + const configDir = tempConfigDir(); + saveCredentials({ access_token: token, site_url: site.url, obtained_at: "now" }, configDir); + const logs: string[] = []; + const warnings: string[] = []; + + await executeSubmitCommand({ + repoPath: dir, + author: ["you@example.com"], + yes: true, + confirmUpload: true, + label, + toolVersion: "0.13.0", + configDir, + log: (message) => logs.push(message), + warn: (message) => { + warnings.push(message); + if (message.includes("timeline conflicts")) events.push("warning"); + }, + probeFn: async () => { + events.push("visibility"); + return { status: 404 }; + }, + checkNpmAnachronismsFn: (bundle) => + checkNpmAnachronisms(bundle, fetchNpmPackument, { registryBaseUrl: registry.url }), + checkForUpdateFn: async () => {}, + }); + + expect(events).toEqual(["visibility", "npm", "warning", "identity", "upload"]); + expect(registry.requests).toHaveLength(1); + const npmRequest = registry.requests[0]; + expect(npmRequest.method).toBe("GET"); + expect(npmRequest.url).toBe("/better-auth"); + expect(npmRequest.url).not.toContain("?"); + expect(npmRequest.body).toBe(""); + expect(npmRequest.headers.accept).toBe("application/json"); + expect(npmRequest.headers.authorization).toBeUndefined(); + expect(npmRequest.headers.cookie).toBeUndefined(); + + const serializedRequest = JSON.stringify(npmRequest); + for (const forbidden of [token, remote, label, site.url, "schema_version", "repo_fingerprint"]) { + expect(serializedRequest).not.toContain(forbidden); + } + const printedBundle = logs.find((line) => line.trim().startsWith("{")); + const uploaded = site.requests.find((request) => request.url === "/api/cli/bundles"); + expect(uploaded?.body).toBe(printedBundle); + expect(warnings.some((message) => message.includes("upload will continue"))).toBe(true); + expect(logs.join("\n")).not.toContain("timeline conflicts"); + }); +}); + describe("what submit prints is byte-for-byte what it uploads", { timeout: 30_000 }, () => { it("the request body equals the exact string logged before the upload confirmation", async () => { const server = await startMockServer((req) => { diff --git a/test/privacy/zero-network.test.ts b/test/privacy/zero-network.test.ts index c0faae1..f156009 100644 --- a/test/privacy/zero-network.test.ts +++ b/test/privacy/zero-network.test.ts @@ -194,10 +194,10 @@ describe("zero network calls during scan", () => { const dir = createRepo(); dirs.push(dir); commit(dir, { - message: "x", + message: "use better auth", authorName: "You", authorEmail: "you@example.com", - files: { "a.ts": "1\n" }, + files: { "auth.ts": 'import { betterAuth } from "better-auth";\n' }, }); const configDir = tempConfigDir(); saveCredentials({ access_token: "t", site_url: getSiteUrl(), obtained_at: "now" }, configDir); diff --git a/test/program.test.ts b/test/program.test.ts index 625443b..90172f3 100644 --- a/test/program.test.ts +++ b/test/program.test.ts @@ -46,4 +46,15 @@ describe("--version", ()=>{ expect((caught as CommanderError).code).toBe("commander.version"); expect(output.trim()).toBe(expectedVersion); }) -}) \ No newline at end of file +}) + +describe("submit help", () => { + it("states that --confirm-upload authorizes upload and the eligible npm lookup", () => { + const submit = createProgram().commands.find((command) => command.name() === "submit"); + expect(submit).toBeDefined(); + const help = submit!.helpInformation().replace(/\s+/g, " "); + expect(help).toContain("--confirm-upload"); + expect(help).toContain("confirm the upload and post-review network steps"); + expect(help).toContain("npm release-date lookups"); + }); +}); diff --git a/test/submit.test.ts b/test/submit.test.ts index 0005ba4..fe1cb78 100644 --- a/test/submit.test.ts +++ b/test/submit.test.ts @@ -161,6 +161,20 @@ function repoWithOneCommit(remote?: string): string { return dir; } +function repoWithBetterAuth(remote?: string): string { + const dir = createRepo(); + dirs.push(dir); + if (remote) setRemote(dir, remote); + commit(dir, { + message: "use better auth", + authorName: "You", + authorEmail: "you@example.com", + authorDate: "2022-06-01T00:00:00Z", + files: { "auth.ts": 'import { betterAuth } from "better-auth";\n' }, + }); + return dir; +} + // Every test below builds a real git repo (multiple `git` process spawns) // and, in most cases, also starts a mock HTTP server — comfortably under // vitest's default 5s budget locally, but not on loaded CI runners (seen @@ -1158,3 +1172,228 @@ describe("submit's thin-history / shallow-clone advisory (non-blocking)", { time expect(bundleRequests(server)).toHaveLength(1); }); }); + +describe("submit npm release-date integration", { timeout: 30_000 }, () => { + it("shows the TTY disclosure before the payload and keeps JSON immediately adjacent to the prompt", async () => { + process.env.REDENTIAL_SITE_URL = "https://site.example"; + const dir = repoWithBetterAuth(); + const configDir = tempConfigDir(); + saveCredentials({ access_token: "t", site_url: process.env.REDENTIAL_SITE_URL, obtained_at: "now" }, configDir); + const logs: string[] = []; + let lastLineAtPrompt = ""; + let npmCalled = false; + + await executeSubmitCommand({ + repoPath: dir, + author: ["you@example.com"], + yes: true, + confirmUpload: false, + label: "acme-backend", + toolVersion: "0.13.0", + configDir, + log: (message) => logs.push(message), + warn: () => {}, + isTTY: true, + promptConfirmUploadFn: async () => { + lastLineAtPrompt = logs.at(-1) ?? ""; + return false; + }, + checkNpmAnachronismsFn: async () => { + npmCalled = true; + return []; + }, + }); + + const disclosureIndex = logs.findIndex((line) => line.includes("npm receives those package names")); + const payloadHeaderIndex = logs.indexOf("Exact payload (byte-for-byte what gets sent):"); + expect(disclosureIndex).toBeGreaterThanOrEqual(0); + expect(disclosureIndex).toBeLessThan(payloadHeaderIndex); + expect(lastLineAtPrompt.trim().startsWith("{")).toBe(true); + expect(npmCalled).toBe(false); + }); + + it("orders visibility, the completed npm check, warning, identity lookup, and upload", async () => { + const events: string[] = []; + const server = await startMockServer((req) => { + if (req.url === "/api/cli/identity/emails") { + events.push("identity"); + return { status: 404, body: {} }; + } + if (req.url === "/api/cli/bundles") { + events.push("upload"); + return { status: 200, body: { id: "ok" } }; + } + return { status: 404, body: {} }; + }); + servers.push(server); + process.env.REDENTIAL_SITE_URL = server.url; + const dir = repoWithBetterAuth("https://github.com/acme/private.git"); + const configDir = tempConfigDir(); + saveCredentials({ access_token: "t", site_url: server.url, obtained_at: "now" }, configDir); + + await executeSubmitCommand({ + repoPath: dir, + author: ["you@example.com"], + yes: true, + confirmUpload: true, + label: "acme-backend", + toolVersion: "0.13.0", + configDir, + log: () => {}, + warn: (message) => { + if (message.includes("timeline conflicts")) events.push("warning"); + }, + probeFn: async () => { + events.push("visibility"); + return { status: 404 }; + }, + checkNpmAnachronismsFn: async () => { + events.push("npm:start"); + await Promise.resolve(); + events.push("npm:end"); + return [ + { + slug: "auth/better-auth", + firstSeen: "2022-06-01T00:00:00.000Z", + earliestMappedNpmRelease: "2023-09-18T00:00:00Z", + }, + ]; + }, + checkForUpdateFn: noCheckForUpdate, + }); + + expect(events).toEqual(["visibility", "npm:start", "npm:end", "warning", "identity", "upload"]); + }); + + it("fails open when the complete npm checker throws and still uploads", async () => { + const server = await startMockServer((req) => { + if (req.url === "/api/cli/bundles") return { status: 200, body: { id: "ok" } }; + return { status: 404, body: {} }; + }); + servers.push(server); + process.env.REDENTIAL_SITE_URL = server.url; + const dir = repoWithBetterAuth(); + const configDir = tempConfigDir(); + saveCredentials({ access_token: "t", site_url: server.url, obtained_at: "now" }, configDir); + + await executeSubmitCommand({ + repoPath: dir, + author: ["you@example.com"], + yes: true, + confirmUpload: true, + label: "acme-backend", + toolVersion: "0.13.0", + configDir, + log: () => {}, + warn: () => {}, + checkNpmAnachronismsFn: async () => { + throw new Error("fake checker failure"); + }, + checkForUpdateFn: noCheckForUpdate, + }); + + expect(bundleRequests(server)).toHaveLength(1); + }); + + it("does not run the npm checker when no audited detected skill is present", async () => { + const server = await startMockServer((req) => { + if (req.url === "/api/cli/bundles") return { status: 200, body: { id: "ok" } }; + return { status: 404, body: {} }; + }); + servers.push(server); + process.env.REDENTIAL_SITE_URL = server.url; + const dir = repoWithOneCommit(); + const configDir = tempConfigDir(); + saveCredentials({ access_token: "t", site_url: server.url, obtained_at: "now" }, configDir); + let npmCalled = false; + + await executeSubmitCommand({ + repoPath: dir, + author: ["you@example.com"], + yes: true, + confirmUpload: true, + label: "acme-backend", + toolVersion: "0.13.0", + configDir, + log: () => {}, + warn: () => {}, + checkNpmAnachronismsFn: async () => { + npmCalled = true; + return []; + }, + checkForUpdateFn: noCheckForUpdate, + }); + + expect(npmCalled).toBe(false); + expect(bundleRequests(server)).toHaveLength(1); + }); + + it("does not run the npm checker when visibility blocks", async () => { + const dir = repoWithBetterAuth("https://github.com/acme/public.git"); + const configDir = tempConfigDir(); + process.env.REDENTIAL_SITE_URL = "https://site.example"; + saveCredentials({ access_token: "t", site_url: process.env.REDENTIAL_SITE_URL, obtained_at: "now" }, configDir); + let npmCalled = false; + + await expect( + executeSubmitCommand({ + repoPath: dir, + author: ["you@example.com"], + yes: true, + confirmUpload: true, + label: "acme-backend", + toolVersion: "0.13.0", + configDir, + log: () => {}, + warn: () => {}, + probeFn: async () => ({ status: 200 }), + checkNpmAnachronismsFn: async () => { + npmCalled = true; + return []; + }, + }) + ).rejects.toBeInstanceOf(SubmitError); + expect(npmCalled).toBe(false); + }); + + it("keeps non-TTY stdout JSON-first and sends disclosure and warning only to stderr", async () => { + const server = await startMockServer((req) => { + if (req.url === "/api/cli/bundles") return { status: 200, body: { id: "ok" } }; + return { status: 404, body: {} }; + }); + servers.push(server); + process.env.REDENTIAL_SITE_URL = server.url; + const dir = repoWithBetterAuth(); + const configDir = tempConfigDir(); + saveCredentials({ access_token: "t", site_url: server.url, obtained_at: "now" }, configDir); + const logs: string[] = []; + const warnings: string[] = []; + + await executeSubmitCommand({ + repoPath: dir, + author: ["you@example.com"], + yes: true, + confirmUpload: true, + label: "acme-backend", + toolVersion: "0.13.0", + configDir, + log: (message) => logs.push(message), + warn: (message) => warnings.push(message), + isTTY: false, + checkNpmAnachronismsFn: async () => [ + { + slug: "auth/better-auth", + firstSeen: "2022-06-01T00:00:00.000Z", + earliestMappedNpmRelease: "2023-09-18T00:00:00Z", + }, + ], + checkForUpdateFn: noCheckForUpdate, + }); + + expect(logs[0].trim().startsWith("{")).toBe(true); + expect(logs.join("\n")).not.toContain("npm receives those package names"); + expect(warnings.some((message) => message.includes("npm receives those package names"))).toBe(true); + expect(warnings.some((message) => message.includes("timeline conflicts"))).toBe(true); + expect(bundleRequests(server)[0].body).toBe(logs[0]); + }); +});