diff --git a/README.md b/README.md index d1336e6..df6fe4c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ flowchart LR Deploy --> Client ``` -Provider rendering and delivery, Action inputs/outputs, summaries, lifecycle state machines and endpoint-specific validation remain in consumers. See [architecture](docs/architecture.md) and [extraction audit](docs/extraction-audit.md). +Provider rendering and delivery, Action inputs/outputs, summaries, lifecycle state machines and endpoint-specific validation remain in consumers. See [architecture](docs/architecture.md), the original [extraction audit](docs/extraction-audit.md), and the current [remaining extraction audit](docs/remaining-extraction-audit.md). Installation requires GitHub Packages access, including for public packages. Configure the scope registry and credentials using [the authentication guide](docs/github-packages.md). Consumers must pin exact supported versions. The source manifests contain intended first preview versions; this is not evidence of publication. diff --git a/compatibility/fixtures/remaining-primitives.json b/compatibility/fixtures/remaining-primitives.json new file mode 100644 index 0000000..64c2c60 --- /dev/null +++ b/compatibility/fixtures/remaining-primitives.json @@ -0,0 +1,466 @@ +{ + "text": [ + { + "value": "", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 5, + "build": "", + "deploy": "" + }, + { + "value": "ordinary text", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 1000, + "build": "ordinary text", + "deploy": "ordinary text" + }, + { + "value": "ac", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 1000, + "build": "ac", + "deploy": "ac" + }, + { + "value": "bold", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 5, + "build": "bold", + "deploy": "bold" + }, + { + "value": "ac>d", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 1000, + "build": "acd", + "deploy": "acd" + }, + { + "value": "a>b", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 5, + "build": "ab", + "deploy": "ab" + }, + { + "value": "", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 1000, + "build": "alert(1)", + "deploy": "alert(1)" + }, + { + "value": "<b>", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 1000, + "build": "<b>", + "deploy": "<b>" + }, + { + "value": "eyJheader.payload.signature", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 5, + "build": "[REDA", + "deploy": "[REDA" + }, + { + "value": "eyJfirst.a.b eyJsecond.c.d", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 1000, + "build": "[REDACTED] [REDACTED]", + "deploy": "[REDACTED] [REDACTED]" + }, + { + "value": "eyJ.a.b", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 1000, + "build": "eyJ.a.b", + "deploy": "eyJ.a.b" + }, + { + "value": "prefixeyJabc.def.ghiSuffix", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 5, + "build": "prefi", + "deploy": "prefi" + }, + { + "value": "Bearer eyJheader.payload.signature", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 1000, + "build": "[REDACTED]", + "deploy": "[REDACTED]" + }, + { + "value": "https://example.invalid/suffix", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 1000, + "build": "[URL]", + "deploy": "[URL]" + }, + { + "value": "Authorization: value", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 5, + "build": "[REDA", + "deploy": "[REDA" + }, + { + "value": "token=synthetic-secret&tail", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 1000, + "build": "[REDACTED]&tail", + "deploy": "[REDACTED]&tail" + }, + { + "value": "synthetic secret synthetic%20secret synthetic+secret", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 1000, + "build": "[REDACTED] [REDACTED] [REDACTED]", + "deploy": "[REDACTED] [REDACTED] [REDACTED]" + }, + { + "value": "synthetic-secret synthetic", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 5, + "build": "[REDA", + "deploy": "[REDA" + }, + { + "value": "a\u0000b…c\n d", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 1000, + "build": "a b c d", + "deploy": "a b c d" + }, + { + "value": "message at function(file:1)", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 1000, + "build": "message", + "deploy": "message" + }, + { + "value": " ", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 5, + "build": "", + "deploy": "" + }, + { + "value": "🙂text", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 1000, + "build": "🙂text", + "deploy": "🙂text" + }, + { + "value": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 1000, + "build": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "deploy": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "value": "visible", + "secrets": ["synthetic-secret", "synthetic", "synthetic secret"], + "max": 5, + "build": "visib", + "deploy": "" + } + ], + "paths": [ + { + "value": "build.123", + "build": { + "accepted": true, + "encoded": "build.123" + }, + "deploy": { + "accepted": true, + "encoded": "build.123" + } + }, + { + "value": " leading space ", + "build": { + "accepted": true, + "encoded": "%20leading%20space%20" + }, + "deploy": { + "accepted": true, + "encoded": "%20leading%20space%20" + } + }, + { + "value": "a b", + "build": { + "accepted": true, + "encoded": "a%20b" + }, + "deploy": { + "accepted": true, + "encoded": "a%20b" + } + }, + { + "value": "café", + "build": { + "accepted": true, + "encoded": "caf%C3%A9" + }, + "deploy": { + "accepted": true, + "encoded": "caf%C3%A9" + } + }, + { + "value": "🙂", + "build": { + "accepted": true, + "encoded": "%F0%9F%99%82" + }, + "deploy": { + "accepted": true, + "encoded": "%F0%9F%99%82" + } + }, + { + "value": "%2F", + "build": { + "accepted": true, + "encoded": "%252F" + }, + "deploy": { + "accepted": true, + "encoded": "%252F" + } + }, + { + "value": "a%2fb", + "build": { + "accepted": true, + "encoded": "a%252fb" + }, + "deploy": { + "accepted": true, + "encoded": "a%252fb" + } + }, + { + "value": "a:b", + "build": { + "accepted": true, + "encoded": "a%3Ab" + }, + "deploy": { + "accepted": true, + "encoded": "a%3Ab" + } + }, + { + "value": "a!b", + "build": { + "accepted": true, + "encoded": "a!b" + }, + "deploy": { + "accepted": true, + "encoded": "a!b" + } + }, + { + "value": "a'b", + "build": { + "accepted": true, + "encoded": "a'b" + }, + "deploy": { + "accepted": true, + "encoded": "a'b" + } + }, + { + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "build": { + "accepted": true, + "encoded": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "deploy": { + "accepted": true, + "encoded": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "build": { + "accepted": false + }, + "deploy": { + "accepted": false + } + }, + { + "value": "", + "build": { + "accepted": false + }, + "deploy": { + "accepted": false + } + }, + { + "value": " ", + "build": { + "accepted": false + }, + "deploy": { + "accepted": false + } + }, + { + "value": "/", + "build": { + "accepted": false + }, + "deploy": { + "accepted": false + } + }, + { + "value": "a/b", + "build": { + "accepted": false + }, + "deploy": { + "accepted": false + } + }, + { + "value": "a?b", + "build": { + "accepted": false + }, + "deploy": { + "accepted": false + } + }, + { + "value": "a#b", + "build": { + "accepted": false + }, + "deploy": { + "accepted": false + } + }, + { + "value": "a\\b", + "build": { + "accepted": false + }, + "deploy": { + "accepted": false + } + }, + { + "value": ".", + "build": { + "accepted": false + }, + "deploy": { + "accepted": false + } + }, + { + "value": "..", + "build": { + "accepted": false + }, + "deploy": { + "accepted": false + } + }, + { + "value": "...", + "build": { + "accepted": true, + "encoded": "..." + }, + "deploy": { + "accepted": true, + "encoded": "..." + } + }, + { + "value": "x\u0000y", + "build": { + "accepted": false + }, + "deploy": { + "accepted": false + } + }, + { + "value": "x\u001fy", + "build": { + "accepted": false + }, + "deploy": { + "accepted": false + } + }, + { + "value": "xy", + "build": { + "accepted": false + }, + "deploy": { + "accepted": false + } + }, + { + "value": "x…y", + "build": { + "accepted": false + }, + "deploy": { + "accepted": false + } + }, + { + "value": "xŸy", + "build": { + "accepted": false + }, + "deploy": { + "accepted": false + } + }, + { + "value": "\ud800", + "build": { + "accepted": false + }, + "deploy": { + "accepted": false + } + }, + { + "value": "\udc00", + "build": { + "accepted": false + }, + "deploy": { + "accepted": false + } + } + ] +} diff --git a/compatibility/tests/remaining-primitives.test.ts b/compatibility/tests/remaining-primitives.test.ts new file mode 100644 index 0000000..dc3b5df --- /dev/null +++ b/compatibility/tests/remaining-primitives.test.ts @@ -0,0 +1,98 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { + stripAngleBracketText, + redactJwtLikeTokens, + redactKnownSecrets, + sanitizeBoundedText, + sanitizeErrorMessage, +} from '../../packages/notification-contract/src/index.js'; +import { + encodeCcv2PathSegment, + Ccv2TransportError, + redactTransportText, +} from '../../packages/ccv2-client/src/index.js'; + +// Frozen outputs from the current Actions, not expectations generated by the new APIs. +// Source SHAs and capture method are recorded in docs/remaining-extraction-audit.md. +const fixtures: { + text: { value: string; secrets: string[]; max: number; build: string; deploy: string }[]; + paths: { + value: string; + build: { accepted: boolean; encoded?: string }; + deploy: { accepted: boolean; encoded?: string }; + }[]; +} = JSON.parse(readFileSync('compatibility/fixtures/remaining-primitives.json', 'utf8')); + +describe('Build/Deploy sanitization composition compatibility', () => { + fixtures.text.forEach((fixture, index) => { + it(`preserves both consumer outputs for captured text case ${index}`, () => { + const { value, secrets, max } = fixture; + const buildText = redactJwtLikeTokens(stripAngleBracketText(redactKnownSecrets(value, secrets))); + const build = buildText.trim() + ? sanitizeBoundedText( + sanitizeErrorMessage(redactTransportText(buildText, secrets), secrets), + max, + ).trim() + : ''; + const deployText = redactJwtLikeTokens(stripAngleBracketText(redactTransportText(value, secrets))); + const deploy = deployText.trim() + ? sanitizeBoundedText(sanitizeErrorMessage(deployText, secrets), max).trim() + : ''; + expect(build).toBe(fixture.build); + expect(deploy).toBe(fixture.deploy); + }); + }); + it('keeps provider escaping, bounds and other sanitization opt-in', () => { + expect(stripAngleBracketText('ac>d')).toBe('acd'); + expect(stripAngleBracketText('')).toBe('alert(1)'); + expect(stripAngleBracketText('<b> @here')).toBe('<b> @here'); + expect(stripAngleBracketText('')).toBe(''); + expect(stripAngleBracketText('x'.repeat(1001))).toHaveLength(1001); + expect(redactJwtLikeTokens('eyJheader.payload.signature eyJother.second.third')).toBe( + '[REDACTED] [REDACTED]', + ); + expect(redactJwtLikeTokens('eyJ.a.b not.a.token')).toBe('eyJ.a.b not.a.token'); + expect(redactJwtLikeTokens('')).toBe(''); + }); +}); + +describe('CCv2 path segment compatibility', () => { + fixtures.paths.forEach((fixture, index) => { + it(`preserves Build/Deploy acceptance and encoding for captured path case ${index}`, () => { + expect(fixture.build).toEqual(fixture.deploy); + if (fixture.build.accepted) { + expect(encodeCcv2PathSegment(fixture.value)).toBe(fixture.build.encoded); + } else { + expect(() => encodeCcv2PathSegment(fixture.value)).toThrow(Ccv2TransportError); + try { + encodeCcv2PathSegment(fixture.value); + } catch (error) { + const code = ['\ud800', '\udc00'].includes(fixture.value) ? 'INVALID_PATH_ENCODING' : 'INVALID_URL'; + expect((error as Ccv2TransportError).toJSON()).toEqual({ code }); + expect((error as Error).message).toBe(`CCv2 transport: ${code}.`); + expect((error as Error).stack).toBeUndefined(); + } + } + }); + }); +}); + +// The original expression is used only on bounded synthetic samples as a +// compatibility oracle. Never run it on the adversarial long input below. +it('preserves JWT-like match boundaries across bounded combinations', () => { + const fragments = ['', 'eyJ', 'eyJa', 'xeyJb', 'word', '.', '..', '/', ' ', '-', '_', 'é']; + for (const a of fragments) + for (const b of fragments) + for (const c of fragments) { + const value = `${a}${b}.${c}.eyJmore.payload.signature;eyJlast.a.b`; + expect(redactJwtLikeTokens(value)).toBe(value.replace(/eyJ[\w-]+\.[\w-]+\.[\w-]+/g, '[REDACTED]')); + } +}); +it('handles long overlapping JWT prefixes without backtracking', () => { + const prefix = 'eyJ'.repeat(50_000); + expect(redactJwtLikeTokens(prefix)).toBe(prefix); + expect(redactJwtLikeTokens(`${prefix}.payload`)).toBe(`${prefix}.payload`); + expect(redactJwtLikeTokens(`${prefix}.payload.signature`)).toBe('[REDACTED]'); +}); diff --git a/compatibility/tests/types.ts b/compatibility/tests/types.ts index abec0e4..1ab6fee 100644 --- a/compatibility/tests/types.ts +++ b/compatibility/tests/types.ts @@ -1,12 +1,14 @@ import { createBuildEvent, + stripAngleBracketText, + redactJwtLikeTokens, createDeploymentEvent, createCheckEvent, type BuildEvent, type DeploymentEvent, type NotificationEvent, } from '../../packages/notification-contract/src/index.js'; -import { createCcv2Client } from '../../packages/ccv2-client/src/index.js'; +import { createCcv2Client, encodeCcv2PathSegment } from '../../packages/ccv2-client/src/index.js'; const build: BuildEvent = createBuildEvent({ status: 'success', data: {} }); const deploy: DeploymentEvent = createDeploymentEvent({ status: 'action-required', data: {} }); const event: NotificationEvent = createCheckEvent({ @@ -24,3 +26,14 @@ const client = createCcv2Client({ }); // @ts-expect-error policy is required even for a GET void client.request('path', { method: 'GET' }); + +const plain: string = stripAngleBracketText('synthetic'); +const redacted: string = redactJwtLikeTokens('synthetic'); +const segment: string = encodeCcv2PathSegment('synthetic identifier'); +void [plain, redacted, segment]; +// @ts-expect-error path segments accept raw strings only +encodeCcv2PathSegment(123); +// @ts-expect-error text filters accept strings only +stripAngleBracketText(null); +// @ts-expect-error JWT-like redaction accepts strings only +redactJwtLikeTokens({}); diff --git a/docs/remaining-extraction-audit.md b/docs/remaining-extraction-audit.md new file mode 100644 index 0000000..c5fba55 --- /dev/null +++ b/docs/remaining-extraction-audit.md @@ -0,0 +1,58 @@ +# Remaining consumer extraction audit + +Compared current remote main and clean local consumer branches on 2026-11-11. The shared schema, event factories, generic context, token lifecycle, bounded HTTP reads and policy-driven retries are already consumed from published packages at exactly `0.0.2`. + +| Repository | Inspected revision | State | +| ---------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | +| Notify | `c49bd1363c51d4b77873b9dab501660c98f6005b` | main; shared-contract migration merged | +| Build | `28af0c6ee9cb72fddcacd5d84281e1a950a64a61` | main; both package migrations merged | +| Deploy | `b2420a40c2cb25850c982720d74353e2dd1fb570` | PR #7; main is `17e8f86ce5b7ea6bd6ef2be421c16e74c1d86ad3`; PR changes tooling and bundle verification only | +| Action Kit | `a5018a0` | main; both packages published at 0.0.2 | + +## Candidate decisions + +| Concern and source | Recommendation | Boundary and compatibility requirements | +| ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Build/Deploy `src/inputs.ts`: identical `segment` validation/encoding | **Implement now:** `ccv2-client.encodeCcv2PathSegment` | Raw identifiers only; preserve 128 UTF-16-unit limit, control/delimiter/dot rejection and exact encoding. Action-specific diagnostics stay local. | +| Build/Deploy `src/security.ts`: identical `plainText` filter and JWT-shaped token replacement | **Implement now:** `notification-contract.stripAngleBracketText` and `redactJwtLikeTokens` | Small opt-in text primitives. Preserve the consumers' different ordering, registered secrets, redaction labels and fallback policy. | +| Build/Deploy `src/security.ts`: identical HTML escaping | Defer | Useful only to their summaries today. Keep rendering local; consider an independent presentation utility boundary if more consumers appear. Do not substitute it for Slack/Teams escaping. | +| Build/Deploy `Sanitizer.add`/`contains`, runtime secret registration | Defer | Similar wrappers are small; masking must remain injected and the sanitization compositions differ. No new shared class or Actions SDK dependency is justified yet. | +| Build/Deploy text/integer input checks | Defer | Numeric parsing overlaps, but bounds, defaults and diagnostic wording belong to each Action. Avoid relocating the entire input reader. | +| Build/Deploy `src/notification.ts`: secret-aware context filtering | Defer a focused extraction | Build currently enumerates environment entries; Deploy filters package reads through a proxy. A callback-based filtering API could centralize this later with allowlist-only reads and explicit compatibility tests. | +| Build `src/transport.ts`, Deploy `src/client.ts`: epoch-clock adapters | Defer | Same timing concept, different clock sampling/counter wiring. A shared monotonic-to-epoch adapter needs deterministic clock tests before changing HTTP-date retry behavior. | +| Notify `src/presentation/sanitize.ts` vs package sanitizers | Keep existing policy; revisit only with an explicit compatibility API | Notify's labels, webhook/signed-URL removal, control handling and truncation differ. Current primitives are not interchangeable. | +| Notify `src/delivery/retry-after.ts` vs `ccv2-client.parseRetryAfter` | Keep local | Notify accepts number/date formats differently, returns `undefined` for invalid/past values and caps at 5 seconds. CCv2 uses integer seconds, different invalid/past fallback, and caller bounds. A direct replacement changes waits and adds an inappropriate CCv2 dependency to Notify. | +| Provider renderers/themes/markup, webhook hosts/restrictions, delivery, payload limits | Keep in Notify | Only Notify consumes these today; provider policy is outside both packages. | +| Build/Deploy endpoint paths/DTOs, polling, lifecycle, decisions, cancellation, outputs and errors | Keep in consumers | Similar structure does not mean interchangeable contracts. Build may disclose bounded problem details; Deploy suppresses them. Preserve mutation ambiguity and output semantics. | +| Bundle checks, public scans, workflow setup across all three | Future tooling/reusable-workflow work | CI/build-time duplication is separate from runtime packages. Keep repository-specific scan policies and permissions explicit; do not add runtime dependencies for these scripts. | + +## First implementation + +The new exports are additive and **unreleased**. Existing sanitizer entry points, URL validators, schema, package dependencies and package versions are unchanged. Neither package gains an Actions SDK dependency or a dependency on the other. The two text filters do not automatically run inside existing sanitizers or factories. + +`stripAngleBracketText` matches the existing conservative character filter, including nested/unclosed markup behavior. It is neither an HTML parser nor a complete HTML sanitizer: script contents, entity text and mentions still need the owning renderer's policy. `redactJwtLikeTokens` recognizes the existing `eyJ...` three-segment shape without verifying tokens or promising exhaustive secret detection. CodeQL identified quadratic behavior in the original regex on repeated `eyJ` prefixes. The new primitive uses a linear word-run scan, verified against the original expression on bounded synthetic combinations and against long adversarial inputs. + +`encodeCcv2PathSegment` rejects invalid raw segments with fixed `Ccv2TransportError('INVALID_URL')` diagnostics and malformed Unicode with `INVALID_PATH_ENCODING`. It preserves percent signs as literal input (`%2F` becomes `%252F`), does not trim accepted values and does not assemble endpoint paths. Existing consumers reject malformed Unicode too, although they currently expose an internal URIError before Action-level handling. + +## Compatibility evidence + +`compatibility/fixtures/remaining-primitives.json` was captured by importing the actual Build and Deploy source modules above under Vitest, invoking both `Sanitizer.text` implementations and both `segment` functions with synthetic input, and recording outputs before consumer edits. No consumer implementation is vendored into the fixture. There are 25 text cases and 29 path cases. A long unclosed-at-truncation tag captures the observable difference between Build filtering before truncation and Deploy truncating before filtering. Text fixtures exercise the consumers' existing compositions and bounds, not only isolated primitive behavior; path fixtures compare acceptance and exact encoded output. Error text stays an Action adapter responsibility. + +| Captured source | SHA-256 | +| ----------------------------- | ------------------------------------------------------------------ | +| `ccv2-build/src/security.ts` | `48a43b7f6ce1bc1a0de38074a4f665106d8653059c2cc88f8254a3a22ec3d09b` | +| `ccv2-build/src/inputs.ts` | `f2610837e3f8712fa161ef2251f99f65b24988dbb49968961a118d58a6be012b` | +| `ccv2-deploy/src/security.ts` | `c75765353ed586e39b168656a2c872e63d0da32de28bc8cd287bb36bc750df8b` | +| `ccv2-deploy/src/inputs.ts` | `38c1c7f43bcfceee90bbbc9ccc5a4709014ba0ff353c88fb7324f48edc18c0d1` | + +The new compatibility test recreates only the small proposed compositions from public exports and compares them with frozen outputs. Additional assertions cover unmatched/nested brackets, entity text, multiple JWT-like tokens and fixed non-disclosing errors. Installed-tarball checks exercise both JavaScript and declaration exports, keeping internal paths inaccessible. + +## Consumer rollout after package publication + +1. Review this additive API change and run Action Kit quality/CI/CodeQL. Select new package versions in a separate release change; never overwrite published 0.0.2. +2. Publish the reviewed packages through the existing owner-controlled preview workflow. Do not use local links, git dependencies, tarballs or copied source as Action dependencies. +3. Create focused Build and Deploy migrations pinning the published versions. Keep `textInput(value, 'path identifier', 128)` for established input diagnostics, delegate encoding to `encodeCcv2PathSegment`, and translate shared validation failures to the existing local error. Preserve malformed-Unicode failure classification during that adapter change. +4. Replace each private angle-bracket loop and JWT regex with the new functions at the same positions in the existing compositions. Do not combine or reorder Build and Deploy sanitizers. Keep masking, empty-text behavior and summary escaping local. +5. Run full consumer tests, cross-repository event/render checks, bundle reproducibility, offline standalone-bundle tests and CI/CodeQL before merging consumer changes. Notify has no required migration for this first extraction. + +This PR publishes nothing, creates no tags/releases, changes no consumer dependency and calls no live providers or Sandbox services. Duplication is removed from consumer source only in the subsequent migrations after publication. diff --git a/packages/ccv2-client/CHANGELOG.md b/packages/ccv2-client/CHANGELOG.md index c6feb02..e42d1a3 100644 --- a/packages/ccv2-client/CHANGELOG.md +++ b/packages/ccv2-client/CHANGELOG.md @@ -1,5 +1,9 @@ # ccv2-client changelog +## Unreleased + +Add `encodeCcv2PathSegment` for the existing Build/Deploy raw identifier policy, with fixed non-disclosing errors and a distinct `INVALID_PATH_ENCODING` code for malformed Unicode so Action adapters can preserve existing diagnostics. Existing URL validators and request policies are unchanged. These exports are not available in published 0.0.2; a new package release and separate consumer migrations are required. + ## 0.0.2 Maintenance release following Dependabot updates to globals 17.14.0 and @types/node 26.6.6 (including undici-types 8.9.0). Refresh dependency notices and release metadata. Runtime JavaScript, declarations and the NotificationEvent v1 schema are unchanged; validation continues on Node 24.21.0. Includes the verified package Release pagination fix in repository tooling. diff --git a/packages/ccv2-client/README.md b/packages/ccv2-client/README.md index de0e3fd..2c19684 100644 --- a/packages/ccv2-client/README.md +++ b/packages/ccv2-client/README.md @@ -39,3 +39,9 @@ Install from GitHub Packages after a supported version is published. Configure ` Current release: source version `0.0.2`, an initial pre-1.0 integration release under the `preview` dist-tag. Verify registry availability before installation and pin `0.0.2` exactly. Stable API and coordinated consumer/Sandbox validation remain future gates. Reservation versions are unsupported. Independent package SemVer applies; stable breaking changes require a major increment. NotificationEvent schema version is separate from package version. Preserve current consumer contracts during migration. No telemetry or logging is performed. Use synthetic tests and register exact runtime secrets; pattern redaction cannot identify all secrets. + +## Unreleased path primitive + +The next reviewed release adds `encodeCcv2PathSegment(value: string): string` at the public root. It is **not available in published 0.0.2**. Pass a raw CCv2 identifier, not an already encoded segment. The helper rejects blank values, more than 128 UTF-16 code units, C0/C1 controls, slash/backslash/query/fragment delimiters, exact `.`/`..`, and malformed Unicode. It otherwise returns `encodeURIComponent(value)` without trimming; literal percent escapes are encoded again rather than interpreted. + +Failures throw `Ccv2TransportError` with code `INVALID_URL` for rejected segments or `INVALID_PATH_ENCODING` for malformed Unicode, with no raw identifier or stack. The separate encoding code lets consumer adapters preserve their existing error classification. Consumers retain Action input diagnostics, endpoint assembly and DTO validation. Existing URL validators and transport behavior are unchanged. See the [remaining extraction audit](../../docs/remaining-extraction-audit.md). diff --git a/packages/ccv2-client/src/errors.ts b/packages/ccv2-client/src/errors.ts index 9ba66f7..69c50cb 100644 --- a/packages/ccv2-client/src/errors.ts +++ b/packages/ccv2-client/src/errors.ts @@ -2,6 +2,7 @@ import { redactTransportText } from './security.js'; import type { Ccv2Problem } from './types.js'; export type TransportErrorCode = | 'INVALID_URL' + | 'INVALID_PATH_ENCODING' | 'INVALID_OPTIONS' | 'INVALID_TOKEN' | 'REQUEST_FAILED' diff --git a/packages/ccv2-client/src/url.ts b/packages/ccv2-client/src/url.ts index fa1a8a6..aa4875d 100644 --- a/packages/ccv2-client/src/url.ts +++ b/packages/ccv2-client/src/url.ts @@ -1,4 +1,23 @@ import { Ccv2TransportError } from './errors.js'; + +/** Encode a raw CCv2 identifier once, preserving Build/Deploy's segment policy. */ +export function encodeCcv2PathSegment(value: string): string { + try { + if ( + !value.trim() || + value.length > 128 || + /[/?#\\\u0000-\u001f\u007f-\u009f]/.test(value) || + value === '.' || + value === '..' + ) + throw Error(); + return encodeURIComponent(value); + } catch (error) { + // Includes malformed Unicode; never expose the raw identifier in diagnostics. + throw new Ccv2TransportError(error instanceof URIError ? 'INVALID_PATH_ENCODING' : 'INVALID_URL'); + } +} + function https(value: string): URL { try { if (value.length > 2048 || /[\s\\?#\u0000-\u001f\u007f-\u009f]/.test(value)) throw Error(); diff --git a/packages/notification-contract/CHANGELOG.md b/packages/notification-contract/CHANGELOG.md index e1a4f1e..3bc2847 100644 --- a/packages/notification-contract/CHANGELOG.md +++ b/packages/notification-contract/CHANGELOG.md @@ -1,5 +1,9 @@ # notification-contract changelog +## Unreleased + +Add opt-in `stripAngleBracketText` and `redactJwtLikeTokens` primitives extracted from Build/Deploy. JWT-shaped redaction uses a linear word-run scan to avoid the copied regex's quadratic behavior on repeated prefixes. Existing sanitizers, redaction order, event factories and schema are unchanged. These exports are not available in published 0.0.2; a new package release and separate consumer migrations are required. + ## 0.0.2 Maintenance release following Dependabot updates to globals 17.14.0 and @types/node 26.6.6 (including undici-types 8.9.0). Refresh dependency notices and release metadata. Runtime JavaScript, declarations and the NotificationEvent v1 schema are unchanged; validation continues on Node 24.21.0. Includes the verified package Release pagination fix in repository tooling. diff --git a/packages/notification-contract/README.md b/packages/notification-contract/README.md index 947ec99..a3199a7 100644 --- a/packages/notification-contract/README.md +++ b/packages/notification-contract/README.md @@ -25,3 +25,11 @@ Install from GitHub Packages after a supported version is published. Configure ` Current release: source version `0.0.2`, an initial pre-1.0 integration release under the `preview` dist-tag. Verify registry availability before installation and pin `0.0.2` exactly. Stable API and coordinated consumer/Sandbox validation remain future gates. Reservation versions are unsupported. Independent package SemVer applies; stable breaking changes require a major increment. NotificationEvent schema version is separate from package version. Preserve current consumer contracts during migration. No telemetry or logging is performed. Use synthetic tests and register exact runtime secrets; pattern redaction cannot identify all secrets. + +## Unreleased text primitives + +The next reviewed release adds `stripAngleBracketText(value: string): string` and `redactJwtLikeTokens(value: string): string` at the public root. They are **not available in published 0.0.2**. + +The first removes angle brackets and all text between each opening bracket and the next closing bracket, discarding an unclosed suffix. It neither parses HTML nor removes script text or decodes entities. The second replaces the existing Build/Deploy `eyJ`-prefixed three-segment token shape with `[REDACTED]`; it does not verify tokens or detect every credential. Both preserve unrelated characters and impose no length bound. + +These are opt-in primitives: existing sanitizers/factories do not call them automatically. Consumers retain secret registration, ordering, text bounds, fallback labels and provider escaping. See the [remaining extraction audit](../../docs/remaining-extraction-audit.md) for compatibility evidence and rollout. diff --git a/packages/notification-contract/src/sanitize.ts b/packages/notification-contract/src/sanitize.ts index 8e26f33..5eef7c6 100644 --- a/packages/notification-contract/src/sanitize.ts +++ b/packages/notification-contract/src/sanitize.ts @@ -1,3 +1,51 @@ +/** + * Discard angle brackets and text between them, including an unclosed suffix. + * This preserves the Actions' conservative text filter; it is not an HTML parser + * or an HTML sanitizer. Callers still own output escaping and disclosure policy. + */ +export function stripAngleBracketText(value: string): string { + let inside = false; + let output = ''; + for (const character of value) { + if (character === '<') inside = true; + else if (character === '>') inside = false; + else if (!inside) output += character; + } + return output; +} + +/** Redact the JWT-like shape recognized by Build/Deploy; no token verification. */ +export function redactJwtLikeTokens(value: string): string { + // Scan maximal word runs once. A regex that searches for eyJ followed by an + // unbounded word suffix can retry overlapping prefixes quadratically. + const pending: { text: string; start: number; end: number }[] = []; + let output = ''; + let consumed = 0; + for (const match of value.matchAll(/[\w-]+/g)) { + pending.push({ text: match[0], start: match.index, end: match.index + match[0].length }); + if (pending.length < 3) continue; + const first = pending[0]!, + second = pending[1]!, + third = pending[2]!; + const prefix = first.text.indexOf('eyJ'); + if ( + prefix >= 0 && + prefix + 3 < first.text.length && + first.end + 1 === second.start && + value[first.end] === '.' && + second.end + 1 === third.start && + value[second.end] === '.' + ) { + output += value.slice(consumed, first.start + prefix) + '[REDACTED]'; + consumed = third.end; + pending.length = 0; + } else { + pending.shift(); + } + } + return output + value.slice(consumed); +} + export function redactKnownSecrets(value: string, secrets: readonly string[] = []): string { for (const secret of [...secrets].filter(Boolean).sort((a, b) => b.length - a.length)) { for (const form of new Set([ diff --git a/scripts/pack-and-test.mjs b/scripts/pack-and-test.mjs index da8e9e1..c7438cd 100644 --- a/scripts/pack-and-test.mjs +++ b/scripts/pack-and-test.mjs @@ -57,18 +57,18 @@ try { const types = []; if (!selected || selected === 'notification-contract') { smoke.push( - `import {createCheckEvent,validateNotificationEvent} from '@commercekit-tools/notification-contract'; import schema from '@commercekit-tools/notification-contract/schema' with {type:'json'}; if(schema.properties.schemaVersion.const!==1)throw Error('Schema export'); validateNotificationEvent(createCheckEvent({status:'success',data:{filesChecked:1,errors:0,warnings:0}}));`, + `import {createCheckEvent,validateNotificationEvent,stripAngleBracketText,redactJwtLikeTokens} from '@commercekit-tools/notification-contract'; import schema from '@commercekit-tools/notification-contract/schema' with {type:'json'}; if(stripAngleBracketText('ac')!=='ac'||redactJwtLikeTokens('eyJheader.payload.signature')!=='[REDACTED]')throw Error('Packed text primitives'); if(schema.properties.schemaVersion.const!==1)throw Error('Schema export'); validateNotificationEvent(createCheckEvent({status:'success',data:{filesChecked:1,errors:0,warnings:0}}));`, ); types.push( - `import {createCheckEvent,type NotificationEvent} from '@commercekit-tools/notification-contract'; const event:NotificationEvent=createCheckEvent({status:'success',data:{filesChecked:1,errors:0,warnings:0}}); void event;`, + `import {createCheckEvent,stripAngleBracketText,redactJwtLikeTokens,type NotificationEvent} from '@commercekit-tools/notification-contract'; const event:NotificationEvent=createCheckEvent({status:'success',data:{filesChecked:1,errors:0,warnings:0}}); void event; const text:string=stripAngleBracketText(redactJwtLikeTokens('synthetic'));void text;`, ); } if (!selected || selected === 'ccv2-client') { smoke.push( - `import {createCcv2Client} from '@commercekit-tools/ccv2-client'; const client=createCcv2Client({baseUrl:'https://synthetic.example/',tokens:{get:async()=> 'synthetic',invalidate(){}},runtime:{fetch:async()=>new Response('[1]',{headers:{'content-type':'application/json'}})}}); const result=await client.request('test',{policy:'safe-read'}); if(result.body.kind!=='json'||!Array.isArray(result.body.value))throw Error('Packed transport');`, + `import {createCcv2Client,encodeCcv2PathSegment} from '@commercekit-tools/ccv2-client'; if(encodeCcv2PathSegment('synthetic id')!=='synthetic%20id')throw Error('Packed path primitive'); const client=createCcv2Client({baseUrl:'https://synthetic.example/',tokens:{get:async()=> 'synthetic',invalidate(){}},runtime:{fetch:async()=>new Response('[1]',{headers:{'content-type':'application/json'}})}}); const result=await client.request('test',{policy:'safe-read'}); if(result.body.kind!=='json'||!Array.isArray(result.body.value))throw Error('Packed transport');`, ); types.push( - `import {createCcv2Client,type Ccv2Response} from '@commercekit-tools/ccv2-client'; const client=createCcv2Client({baseUrl:'https://synthetic.example/',tokens:{get:async()=> 'synthetic',invalidate(){}}}); const response:Promise=client.request('path',{policy:'unsafe-mutation'});void response; // compile only`, + `import {createCcv2Client,encodeCcv2PathSegment,type Ccv2Response} from '@commercekit-tools/ccv2-client'; const client=createCcv2Client({baseUrl:'https://synthetic.example/',tokens:{get:async()=> 'synthetic',invalidate(){}}}); const response:Promise=client.request('path',{policy:'unsafe-mutation'});void response;const segment:string=encodeCcv2PathSegment('synthetic');void segment; // compile only`, ); } writeFileSync(join(temp, 'smoke.mjs'), smoke.join('\n'));