diff --git a/.github/workflows/hubspot-auth-api-receipt.yml b/.github/workflows/hubspot-auth-api-receipt.yml new file mode 100644 index 00000000..2124a6f1 --- /dev/null +++ b/.github/workflows/hubspot-auth-api-receipt.yml @@ -0,0 +1,114 @@ +# Binding C item 4a (rev2): synthetic auth/API test receipt for +# packages/v1-ready/hubspot. Never contacts the live provider, never +# reads secrets, never emits authorized/live, fails closed on HUBSPOT_* +# or MATTERS_ACCESS_TOKEN. +# +# The receipt command MUST NOT be passed via a HUBSPOT_* env var +# (that prefix is on the T-C4-8 abort list). Use AML_RECEIPT_COMMAND +# or the CLI --command argument only. +name: HubSpot auth/API test receipt + +on: + push: + paths: + - 'packages/v1-ready/hubspot/**' + - 'scripts/ci/**' + - '.github/workflows/hubspot-auth-api-receipt.yml' + - 'package-lock.json' + pull_request: + paths: + - 'packages/v1-ready/hubspot/**' + - 'scripts/ci/**' + - '.github/workflows/hubspot-auth-api-receipt.yml' + - 'package-lock.json' + workflow_dispatch: + +permissions: + contents: read + +jobs: + hubspot-auth-api-receipt: + name: Synthetic auth/API receipt + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Fail closed if provider token env is present (T-C4-8) + run: node scripts/ci/hubspot-auth-api-receipt.cjs --assert-offline-env + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 18 + cache: npm + + # Full lock-exact install. A workspace-filtered ci would miss + # @aws-sdk/client-scheduler, which @friggframework/core@2.0.0-next.89 + # requires (but does not declare) when loading the HubSpot Api. + - name: Install (npm ci, lock exact) + run: npm ci --ignore-scripts + + - name: Receipt script checks (T-C4-2..T-C4-10 policy) + run: node --test scripts/ci/hubspot-auth-api-receipt.test.cjs + + - name: Run 4a HubSpot auth/API tests (JSON report) + id: tests + working-directory: packages/v1-ready/hubspot + continue-on-error: true + run: | + mkdir -p "${GITHUB_WORKSPACE}/artifacts" + npx jest --config jest.receipt.config.js --ci --watchman=false --forceExit \ + --json --outputFile="${GITHUB_WORKSPACE}/artifacts/jest-results.json" + + - name: Write receipt and classification bytes + env: + AML_RECEIPT_COMMAND: npx jest --config jest.receipt.config.js --ci --watchman=false --forceExit --json --outputFile=artifacts/jest-results.json + run: | + node scripts/ci/hubspot-auth-api-receipt.cjs \ + --jest-json "${GITHUB_WORKSPACE}/artifacts/jest-results.json" \ + --out "${GITHUB_WORKSPACE}/artifacts/hubspot-auth-api-receipt.json" \ + --command "${AML_RECEIPT_COMMAND}" \ + --classification synthetic \ + --expect-classification synthetic \ + --module-ref-branch next \ + --module-ref-sha 9f5e5c627a0be42cd25cf28339142891e71eba07 + + - name: Packet classification must byte-equal the CI artifact (T-C4-2) + run: | + python3 - <<'PY' + from pathlib import Path + artifact = Path("artifacts/classification").read_bytes() + receipt = Path("artifacts/hubspot-auth-api-receipt.json").read_text() + import json + classify = json.loads(receipt)["classification"].encode("utf-8") + if artifact != classify or artifact != b"synthetic": + raise SystemExit(f"T-C4-2 mismatch: file={artifact!r} receipt={classify!r}") + print("T-C4-2: classification bytes equal", artifact) + PY + + - name: Upload receipt artifact + # Only run (and therefore only claim success) when both files exist. + # hashFiles is empty when a path is missing; if: always() alone would + # still start the step and if-no-files-found: error would fail it, + # but a skipped upload must not be reported as a successful publish. + if: ${{ always() && hashFiles('artifacts/hubspot-auth-api-receipt.json') != '' && hashFiles('artifacts/classification') != '' }} + uses: actions/upload-artifact@v4 + with: + name: hubspot-auth-api-receipt + path: | + artifacts/hubspot-auth-api-receipt.json + artifacts/classification + if-no-files-found: error + + - name: Fail the job if tests or receipt failed + if: always() + run: | + if [ "${{ steps.tests.outcome }}" != "success" ]; then + echo "4a tests did not succeed (outcome=${{ steps.tests.outcome }})." + exit 1 + fi + if [ ! -f artifacts/hubspot-auth-api-receipt.json ] || [ ! -f artifacts/classification ]; then + echo "Receipt or classification file missing." + exit 1 + fi diff --git a/packages/v1-ready/hubspot/jest.receipt.config.js b/packages/v1-ready/hubspot/jest.receipt.config.js new file mode 100644 index 00000000..33c3c4ea --- /dev/null +++ b/packages/v1-ready/hubspot/jest.receipt.config.js @@ -0,0 +1,22 @@ +/* + * Jest config for the Binding C item 4a receipt job. + * + * Omits the package's MongoMemoryServer globalSetup/Teardown: the 4a tests + * exercise Definition.requiredAuthMethods and Api against nock, and do not + * need a database. Full `npm test` (jest.config.js) still uses mongo and is + * excluded from this command (see the receipt exclusions). + */ +module.exports = { + testMatch: [ + '/tests/auth-api-synthetic.test.js', + '/tests/definition.test.js', + '/tests/listContacts.test.js', + '/tests/oauth.test.js', + ], + testPathIgnorePatterns: [ + '/node_modules/', + '/tests/api.test.js', + '/tests/auther.test.js', + '/tests/extensions/', + ], +}; diff --git a/packages/v1-ready/hubspot/package.json b/packages/v1-ready/hubspot/package.json index d04b15dd..09e8848e 100644 --- a/packages/v1-ready/hubspot/package.json +++ b/packages/v1-ready/hubspot/package.json @@ -6,7 +6,8 @@ "main": "index.js", "scripts": { "lint:fix": "prettier --write --loglevel error . && eslint . --fix", - "test": "jest" + "test": "jest", + "test:receipt": "jest --config jest.receipt.config.js" }, "author": "", "license": "MIT", diff --git a/packages/v1-ready/hubspot/tests/api.test.js b/packages/v1-ready/hubspot/tests/api.test.js index 4b59384f..632e699c 100644 --- a/packages/v1-ready/hubspot/tests/api.test.js +++ b/packages/v1-ready/hubspot/tests/api.test.js @@ -20,7 +20,13 @@ const parsedBody = async function async(resp) { return body; } -describe(`${config.label} API tests`, () => { +// Live interactive OAuth suite (Authenticator.oauth2). Not part of the +// Binding C item 4a synthetic receipt. This CI job never sets +// HUBSPOT_LIVE_API and does not wire tokens or secrets. The suite is +// reported as skipped/excluded on the receipt. It is not a 4b path. +const describeLiveApi = process.env.HUBSPOT_LIVE_API === '1' ? describe : describe.skip; + +describeLiveApi(`${config.label} API tests`, () => { const apiParams = { client_id: process.env.HUBSPOT_CLIENT_ID, client_secret: process.env.HUBSPOT_CLIENT_SECRET, diff --git a/packages/v1-ready/hubspot/tests/auth-api-synthetic.test.js b/packages/v1-ready/hubspot/tests/auth-api-synthetic.test.js new file mode 100644 index 00000000..09e4bfd8 --- /dev/null +++ b/packages/v1-ready/hubspot/tests/auth-api-synthetic.test.js @@ -0,0 +1,523 @@ +/** + * Binding C item 4a synthetic tests. + * + * Loads the selected artifact's real auth implementation + * (Definition.requiredAuthMethods.getToken, testAuthRequest, and Api) + * and runs it against nock-intercepted HTTP to the module's actual + * HubSpot endpoints. Fixtures are hand-built from official HubSpot docs + * (see tests/fixtures/provenance.json). These tests are not a live or + * authorized run. + * + * Network is fail-closed (nock.disableNetConnect). Token-like env vars + * fail the suite. Negative cases prove the path rejects bad grants, + * 401s, inactive tokens, and tampered fixtures. + */ +const path = require('path'); +const fs = require('fs'); +const nock = require('nock'); +const { Api } = require('../api'); +const { Definition } = require('../definition'); +const { + DENIED_PROVIDER_HOSTS, + TOKEN_ENDPOINT, + forbiddenEnvHits, + matchesTokenExchange, + matchesTokenRefresh, + matchesBearerAuthorization, + matchesApiAuthHeader, + tokenBody, + createPinnedHostSpy, + evaluateDenyAndSpy, +} = require('../../../../scripts/ci/hubspot-4a-policy.cjs'); + +const HUBAPI = 'https://api.hubapi.com'; +const APP_HOST = 'https://app.hubspot.com'; +const FORM_CONTENT_TYPE = 'application/x-www-form-urlencoded'; +const FIXTURES_DIR = path.join(__dirname, 'fixtures'); + +function assertNoLiveTokenEnv() { + const hits = forbiddenEnvHits(process.env); + if (hits.length) { + throw new Error( + `4a fail-closed: token-like env var present: ${hits.join(', ')}. Unset them; this job must not use a Sean-held or live provider token.` + ); + } +} + +function loadFixture(name) { + return JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, name), 'utf8')); +} + +const tokenResponse = loadFixture('oauth-token.json'); +const meResponse = loadFixture('account-me.json'); +const introspectResponse = loadFixture('token-introspect.json'); +const contactsListResponse = loadFixture('contacts-list.json'); + +const apiParams = { + client_id: 'synthetic-client-id', + client_secret: 'synthetic-client-secret', + redirect_uri: 'https://example.com/redirect/hubspot', + scope: 'oauth crm.objects.contacts.read crm.objects.contacts.write', +}; + +function formBody(body) { + return Object.fromEntries(new URLSearchParams(body)); +} + +function mockTokenExchange(replyStatus, replyBody, grantType = 'authorization_code') { + let sentBody; + const scope = nock(HUBAPI, { + reqheaders: { 'content-type': FORM_CONTENT_TYPE }, + }) + .post('/oauth/2026-03/token', (body) => { + sentBody = formBody(body); + const req = { method: 'POST', url: TOKEN_ENDPOINT, body: sentBody }; + // Never always-200: only this request shape may receive the fixture. + if (grantType === 'refresh_token') return matchesTokenRefresh(req); + return matchesTokenExchange(req); + }) + .reply(replyStatus, replyBody); + return { scope, getSentBody: () => sentBody }; +} + +function mockUserDetails({ me = meResponse, introspect = introspectResponse, meStatus = 200, introspectStatus = 200 } = {}) { + const meScope = nock(HUBAPI, { + reqheaders: { authorization: 'Bearer synthetic-access-token' }, + }) + .get('/integrations/v1/me') + .reply(meStatus, me); + const introspectScope = nock(HUBAPI, { + reqheaders: { 'content-type': FORM_CONTENT_TYPE }, + }) + .post('/oauth/2026-03/token/introspect') + .reply(introspectStatus, introspect); + return { meScope, introspectScope }; +} + +describe('HubSpot auth/API synthetic receipt tests', () => { + // Deny (nock.disableNetConnect) and spy (nock.emitter 'no match') are + // distinct T-C4-12 mechanisms over the same pinned host list. + const providerSpy = createPinnedHostSpy(DENIED_PROVIDER_HOSTS); + beforeAll(() => { + assertNoLiveTokenEnv(); + nock.disableNetConnect(); + providerSpy.install(); + }); + afterEach(() => nock.cleanAll()); + afterAll(() => { + providerSpy.uninstall(); + nock.enableNetConnect(); + }); + + describe('Definition.requiredAuthMethods.getToken() — real code path', () => { + it('exchanges the redirect code via Api.getTokenFromCode POST /oauth/2026-03/token', async () => { + const { scope, getSentBody } = mockTokenExchange(200, tokenResponse); + + const api = new Api(apiParams); + const response = await Definition.requiredAuthMethods.getToken( + api, + { code: 'synthetic-auth-code' } + ); + + expect(scope.isDone()).toBe(true); + expect(getSentBody()).toMatchObject({ + grant_type: 'authorization_code', + code: 'synthetic-auth-code', + client_id: 'synthetic-client-id', + client_secret: 'synthetic-client-secret', + redirect_uri: 'https://example.com/redirect/hubspot', + }); + expect(response).toEqual(tokenResponse); + expect(api.access_token).toBe('synthetic-access-token'); + expect(api.refresh_token).toBe('synthetic-refresh-token'); + }); + + it('rejects when HubSpot returns 400 invalid_grant for a bad code', async () => { + mockTokenExchange(400, { + error: 'invalid_grant', + error_description: 'authorization code is invalid, expired or revoked', + status: 'BAD_AUTH_CODE', + message: 'authorization code is invalid, expired or revoked', + }); + + const api = new Api(apiParams); + await expect( + Definition.requiredAuthMethods.getToken(api, { + code: 'synthetic-bad-code', + }) + ).rejects.toMatchObject({ statusCode: 400 }); + expect(api.access_token).toBeNull(); + }); + }); + + describe('Definition.requiredAuthMethods.testAuthRequest() — real code path', () => { + it('calls Api.getUserDetails against GET /integrations/v1/me and POST /oauth/2026-03/token/introspect', async () => { + const { meScope, introspectScope } = mockUserDetails(); + + const api = new Api({ + ...apiParams, + access_token: 'synthetic-access-token', + }); + const details = + await Definition.requiredAuthMethods.testAuthRequest(api); + + expect(meScope.isDone()).toBe(true); + expect(introspectScope.isDone()).toBe(true); + expect(details).toMatchObject({ + portalId: 2519445, + timeZone: 'America/New_York', + currency: 'USD', + hub_id: 1234567, + hub_domain: 'example.com', + user: 'jdoe@hubspot.com', + active: true, + }); + }); + + it('rejects when /integrations/v1/me returns 401', async () => { + nock(HUBAPI) + .get('/integrations/v1/me') + .reply(401, { + status: 'UNAUTHORIZED', + message: 'Authentication credentials not found', + }); + + const api = new Api({ + ...apiParams, + access_token: 'synthetic-access-token', + }); + await expect( + Definition.requiredAuthMethods.testAuthRequest(api) + ).rejects.toMatchObject({ statusCode: 401 }); + }); + + it('rejects when introspect reports the token inactive', async () => { + mockUserDetails({ + introspect: { ...introspectResponse, active: false }, + }); + + const api = new Api({ + ...apiParams, + access_token: 'synthetic-access-token', + }); + await expect( + Definition.requiredAuthMethods.testAuthRequest(api) + ).rejects.toThrow(/inactive/); + }); + }); + + describe('tampered fixture / mutation', () => { + it('does not treat a token fixture missing access_token as a successful auth', async () => { + const tampered = { ...tokenResponse }; + delete tampered.access_token; + mockTokenExchange(200, tampered); + + const api = new Api(apiParams); + // Real setTokens() requires access_token; a tampered fixture + // missing that field must fail the auth path (T-C4-1 negative). + await expect( + Definition.requiredAuthMethods.getToken(api, { + code: 'synthetic-auth-code', + }) + ).rejects.toThrow(/access_token|required/i); + }); + }); + + describe('Api.listContacts() (mocked HTTP against the real method)', () => { + it('GETs /crm/v3/objects/contacts and returns the official list shape', async () => { + const contacts = nock(HUBAPI, { + reqheaders: { authorization: 'Bearer synthetic-access-token' }, + }) + .get('/crm/v3/objects/contacts') + .query({ + // requester.js stringifies array values with Array#toString + // (comma-separated) before encodeURIComponent. + limit: '10', + properties: 'email,firstname,lastname', + }) + .reply(200, contactsListResponse); + + const api = new Api({ + ...apiParams, + access_token: 'synthetic-access-token', + }); + const response = await api.listContacts({ + limit: 10, + properties: ['email', 'firstname', 'lastname'], + }); + + expect(contacts.isDone()).toBe(true); + expect(response.results).toHaveLength(1); + expect(response.results[0]).toMatchObject({ + id: '33451', + properties: { + email: 'lorelai@thedragonfly.com', + firstname: 'Lorelai', + lastname: 'Gilmore', + }, + archived: false, + }); + expect(response.paging.next.after).toBe('33452'); + }); + }); + + describe('Api.refreshAccessToken() — real refresh path', () => { + it('refreshes via POST /oauth/2026-03/token with grant_type=refresh_token', async () => { + let sentBody; + const scope = nock(HUBAPI, { + reqheaders: { 'content-type': FORM_CONTENT_TYPE }, + }) + .post('/oauth/2026-03/token', (body) => { + sentBody = formBody(body); + return matchesTokenRefresh({ + method: 'POST', + url: TOKEN_ENDPOINT, + body: sentBody, + }); + }) + .reply(200, tokenResponse); + + const api = new Api({ + ...apiParams, + access_token: 'synthetic-stale-access-token', + refresh_token: 'synthetic-refresh-token', + }); + const response = await api.refreshAccessToken({ + refresh_token: 'synthetic-refresh-token', + }); + + expect(scope.isDone()).toBe(true); + expect(sentBody).toMatchObject({ + grant_type: 'refresh_token', + refresh_token: 'synthetic-refresh-token', + client_id: 'synthetic-client-id', + client_secret: 'synthetic-client-secret', + }); + expect(response).toEqual(tokenResponse); + expect(api.access_token).toBe('synthetic-access-token'); + }); + + it('rejects when HubSpot returns 400 for an expired refresh token', async () => { + nock(HUBAPI) + .post('/oauth/2026-03/token') + .reply(400, { + error: 'invalid_grant', + error_description: + 'refresh token is invalid, expired or revoked', + status: 'BAD_REFRESH_TOKEN', + message: 'refresh token is invalid, expired or revoked', + }); + + const api = new Api({ + ...apiParams, + refresh_token: 'synthetic-expired-refresh-token', + }); + await expect( + api.refreshAccessToken({ + refresh_token: 'synthetic-expired-refresh-token', + }) + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); + + describe('header / signing construction — real addAuthHeaders', () => { + it('Api.addAuthHeaders sets Authorization Bearer from the access token', async () => { + const api = new Api({ + ...apiParams, + access_token: 'synthetic-access-token', + }); + const headers = await api.addAuthHeaders({ Accept: 'application/json' }); + expect(headers.Authorization).toBe('Bearer synthetic-access-token'); + }); + + it('sends the constructed Bearer header on a real Api listContacts request', async () => { + const contacts = nock(HUBAPI, { + reqheaders: { authorization: 'Bearer synthetic-access-token' }, + }) + .get('/crm/v3/objects/contacts') + .query(true) + .reply(200, contactsListResponse); + + const api = new Api({ + ...apiParams, + access_token: 'synthetic-access-token', + }); + await api.listContacts({ + limit: 10, + properties: ['email', 'firstname', 'lastname'], + }); + expect(contacts.isDone()).toBe(true); + }); + }); + + describe('T-C4-11: outbound request shape — wrong shape must not get 200', () => { + it('rejects wrong method, URL, grant_type, missing client_id, and wrong Authorization form', () => { + const goodExchange = { + method: 'POST', + url: TOKEN_ENDPOINT, + body: { + grant_type: 'authorization_code', + client_id: 'synthetic-client-id', + client_secret: 'synthetic-client-secret', + redirect_uri: 'https://example.com/redirect/hubspot', + scope: 'oauth', + code: 'synthetic-auth-code', + }, + }; + expect(matchesTokenExchange(goodExchange)).toBe(true); + expect(matchesTokenExchange({ ...goodExchange, method: 'GET' })).toBe(false); + expect( + matchesTokenExchange({ + ...goodExchange, + url: 'https://api.hubapi.com/oauth/v1/token', + }) + ).toBe(false); + expect( + matchesTokenExchange({ + ...goodExchange, + body: { ...goodExchange.body, grant_type: 'client_credentials' }, + }) + ).toBe(false); + expect( + matchesTokenExchange({ + ...goodExchange, + body: { grant_type: 'authorization_code', code: 'synthetic-auth-code' }, + }) + ).toBe(false); + expect( + matchesTokenExchange({ + ...goodExchange, + headers: { Authorization: 'Basic c3ludGhldGlj' }, + }) + ).toBe(false); + expect( + matchesTokenExchange({ + ...goodExchange, + body: { ...goodExchange.body, client_secret: undefined }, + }) + ).toBe(false); + + expect( + matchesBearerAuthorization('Bearer synthetic-access-token') + ).toBe(true); + expect(matchesBearerAuthorization('Basic synthetic-access-token')).toBe( + false + ); + expect(matchesBearerAuthorization('')).toBe(false); + expect( + matchesApiAuthHeader({ + headers: { Authorization: 'Bearer synthetic-access-token' }, + }) + ).toBe(true); + expect( + matchesApiAuthHeader({ + headers: { Authorization: 'Basic abc' }, + }) + ).toBe(false); + }); + + it('does not return the success fixture for a wrong-shape token POST', async () => { + nock(HUBAPI) + .post('/oauth/2026-03/token', (body) => + matchesTokenExchange({ + method: 'POST', + url: TOKEN_ENDPOINT, + body, + }) + ) + .reply(200, tokenResponse); + + const https = require('https'); + const payload = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: 'synthetic-client-id', + }).toString(); + + await expect( + new Promise((resolve, reject) => { + const req = https.request( + { + hostname: 'api.hubapi.com', + path: '/oauth/2026-03/token', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(payload), + }, + }, + (res) => resolve(res.statusCode) + ); + req.on('error', reject); + req.write(payload); + req.end(); + }) + ).rejects.toThrow(/Nock|Disallowed net connect|not allowed/i); + }); + + it('pins the denied HubSpot host list used by fail-closed net-deny', () => { + expect(DENIED_PROVIDER_HOSTS).toEqual([ + 'api.hubapi.com', + 'app.hubspot.com', + ]); + }); + }); + + describe('T-C4-12: deny and spy are distinct; both cover the pinned hosts', () => { + it('rejects a deny-only, spy-only, or always-200 harness as insufficient', () => { + expect(evaluateDenyAndSpy({ denyInstalled: true, spyInstalled: true })).toEqual({ + ok: true, + reason: null, + }); + expect(evaluateDenyAndSpy({ denyInstalled: true, spyInstalled: false }).ok).toBe(false); + expect(evaluateDenyAndSpy({ denyInstalled: true, spyInstalled: false }).reason).toBe('deny-only'); + expect(evaluateDenyAndSpy({ denyInstalled: false, spyInstalled: true }).reason).toBe('spy-only'); + expect(evaluateDenyAndSpy({ + denyInstalled: true, + spyInstalled: true, + treatsAlways200AsDeny: true, + }).reason).toBe('always-200-is-not-a-deny'); + }); + + it('deny blocks and spy records an unmocked request to api.hubapi.com', async () => { + const before = providerSpy.records.length; + const api = new Api({ + ...apiParams, + access_token: 'synthetic-access-token', + }); + // Core wraps the nock net-connect denial in FetchError. + await expect(api.getUserDetails()).rejects.toThrow( + /Nock: Disallowed net connect|Disallowed net connect|An error ocurred while fetching|external resource/ + ); + const hits = providerSpy.records.slice(before).filter((r) => r.host === 'api.hubapi.com'); + expect(hits.length).toBeGreaterThan(0); + expect(hits[0].method).toMatch(/GET|POST/i); + expect(hits[0].path).toBeTruthy(); + }); + + it('deny blocks and spy records an unmocked request to app.hubspot.com', async () => { + const before = providerSpy.records.length; + await expect( + new Promise((resolve, reject) => { + const https = require('https'); + const req = https.get(`${APP_HOST}/oauth/authorize`, resolve); + req.on('error', reject); + }) + ).rejects.toThrow(/Nock: Disallowed net connect|not allowed/i); + const hits = providerSpy.records.slice(before).filter((r) => r.host === 'app.hubspot.com'); + expect(hits.length).toBeGreaterThan(0); + expect(hits[0].method).toMatch(/GET/i); + expect(hits[0].path).toMatch(/oauth\/authorize/); + }); + }); +}); + +describe('erratum-2: runtime api.js digest', () => { + it('the api.js loaded at runtime hashes to the pinned sha256', () => { + const crypto = require('crypto'); + const fs = require('fs'); + const loaded = require.resolve('../api'); + expect(require('../api').Api).toBeDefined(); + const digest = crypto.createHash('sha256').update(fs.readFileSync(loaded)).digest('hex'); + expect(digest).toBe('af0b0bdc937820697a9bf158a6cc4f8a9e4fa22c96c535ee5af994720e4c3c60'); + }); +}); diff --git a/packages/v1-ready/hubspot/tests/fixtures/GENERATOR.md b/packages/v1-ready/hubspot/tests/fixtures/GENERATOR.md new file mode 100644 index 00000000..6c3d9976 --- /dev/null +++ b/packages/v1-ready/hubspot/tests/fixtures/GENERATOR.md @@ -0,0 +1,22 @@ +# Synthetic fixture generator + +These fixtures are generator-built doubles. They were not captured from a +live HubSpot account and have no capture chain. + +- Origin: `synthetic` +- Tool: manual transcription from official HubSpot developer documentation +- Command: none (no generator program; JSON examples were transcribed by hand) +- When: 2026-11-09, America/New_York +- Operator: Frigg Module Builder (local prep; not a live provider session) +- Docs retrieved: see each fixture's `sourceDocUrl` and `sourceDocRetrievedAt` in provenance.json + +Freshness is tied to the provider API version and endpoint each fixture was +transcribed against. Those contracts must continue to match +`packages/v1-ready/hubspot/api.js` (`tokenUri`, `URLs.access_token`, +`URLs.tokenIntrospect`, `URLs.userDetails`, `URLs.contacts`). A version or +endpoint mismatch fails 4a (rev1 §4.1 / T-C4-9). + +Freshness uses the adopted `FRESHNESS_MAX_AGE_DAYS = 90` bound +(Architect-pinned in rev2 §P2-FRESHNESS) AND the API-version pins above. +Both are required. Synthetic fixtures are not on the 90-day clock; they +still require the API-version pin. diff --git a/packages/v1-ready/hubspot/tests/fixtures/account-me.json b/packages/v1-ready/hubspot/tests/fixtures/account-me.json new file mode 100644 index 00000000..3fc9f381 --- /dev/null +++ b/packages/v1-ready/hubspot/tests/fixtures/account-me.json @@ -0,0 +1,7 @@ +{ + "portalId": 2519445, + "timeZone": "America/New_York", + "currency": "USD", + "utcOffsetMilliseconds": -18000000, + "utcOffset": "-05:00" +} diff --git a/packages/v1-ready/hubspot/tests/fixtures/contacts-list.json b/packages/v1-ready/hubspot/tests/fixtures/contacts-list.json new file mode 100644 index 00000000..b8dddb8f --- /dev/null +++ b/packages/v1-ready/hubspot/tests/fixtures/contacts-list.json @@ -0,0 +1,24 @@ +{ + "results": [ + { + "id": "33451", + "properties": { + "createdate": "2022-06-01T14:31:48.469Z", + "email": "lorelai@thedragonfly.com", + "firstname": "Lorelai", + "hs_object_id": "33451", + "lastmodifieddate": "2025-07-07T20:27:17.947Z", + "lastname": "Gilmore" + }, + "createdAt": "2022-06-01T14:31:48.469Z", + "updatedAt": "2025-07-07T20:27:17.947Z", + "archived": false + } + ], + "paging": { + "next": { + "after": "33452", + "link": "https://api.hubspot.com/crm/objects/v3/contacts?limit=1" + } + } +} diff --git a/packages/v1-ready/hubspot/tests/fixtures/oauth-token.json b/packages/v1-ready/hubspot/tests/fixtures/oauth-token.json new file mode 100644 index 00000000..6a01843f --- /dev/null +++ b/packages/v1-ready/hubspot/tests/fixtures/oauth-token.json @@ -0,0 +1,12 @@ +{ + "token_type": "bearer", + "refresh_token": "synthetic-refresh-token", + "access_token": "synthetic-access-token", + "hub_id": 1234567, + "scopes": [ + "oauth", + "crm.objects.contacts.write", + "crm.objects.contacts.read" + ], + "expires_in": 1800 +} diff --git a/packages/v1-ready/hubspot/tests/fixtures/provenance.json b/packages/v1-ready/hubspot/tests/fixtures/provenance.json new file mode 100644 index 00000000..c4f3bd34 --- /dev/null +++ b/packages/v1-ready/hubspot/tests/fixtures/provenance.json @@ -0,0 +1,135 @@ +{ + "classification": "synthetic", + "recordedDate": "2026-11-09", + "recordedAt": "2026-11-09T13:01:00-05:00", + "timeZone": "America/New_York", + "note": "Hand-built doubles transcribed from HubSpot official documentation examples. They were not captured from a live provider account and imply no network contact with api.hubapi.com or app.hubspot.com. Because there is no capture command or recorder receipt, the only honest label is synthetic (not recorded).", + "fixtures": [ + { + "path": "packages/v1-ready/hubspot/tests/fixtures/account-me.json", + "endpoint": "GET https://api.hubapi.com/integrations/v1/me", + "kind": "synthetic", + "capture": { + "method": "synthetic-from-docs", + "tool": "manual transcription of the official example JSON", + "sourceDocUrl": "https://developers.hubspot.com/docs/api-reference/legacy/account/account-information/v1/get-integrations-v1-me", + "sourceDocTitle": "Get details for a HubSpot account", + "sourceDocSection": "example1 \u2014 Account details example (OpenAPI AccountDetails)", + "sourceDocRetrievedAt": "2026-11-09T12:59:00-05:00", + "providerApiVersion": "integrations/v1", + "providerContract": "GET /integrations/v1/me", + "sourceVersionOrDate": "HubSpot Account Activity API v1 / legacy Account information, docs retrieved 2026-11-09", + "accountClassification": null, + "rawCaptureSha256": null, + "redactionTransform": null, + "postRedactionSha256": null, + "note": "Not a recorded capture chain. Origin is synthetic; generator is declared at the provenance root." + }, + "adaptations": "None. Bytes match the official AccountDetails example fields: portalId, timeZone, currency, utcOffsetMilliseconds, utcOffset.", + "origin": "synthetic" + }, + { + "path": "packages/v1-ready/hubspot/tests/fixtures/oauth-token.json", + "endpoint": "POST https://api.hubapi.com/oauth/2026-03/token", + "kind": "synthetic", + "capture": { + "method": "synthetic-from-docs", + "tool": "manual transcription of the official example JSON, with token values replaced by synthetic-* placeholders", + "sourceDocUrl": "https://developers.hubspot.com/docs/api-reference/2026-03/authentication/manage-oauth-tokens", + "sourceDocTitle": "Manage OAuth access tokens with the 2026-03 API", + "sourceDocSection": "Generate initial access and refresh tokens \u2014 example JSON response", + "sourceDocRetrievedAt": "2026-11-09T13:00:00-05:00", + "providerApiVersion": "2026-03", + "providerContract": "POST /oauth/2026-03/token", + "sourceVersionOrDate": "HubSpot OAuth API 2026-03, docs retrieved 2026-11-09", + "accountClassification": null, + "rawCaptureSha256": null, + "redactionTransform": null, + "postRedactionSha256": null, + "note": "Not a recorded capture chain. Origin is synthetic; generator is declared at the provenance root." + }, + "adaptations": "Field set matches the official example (token_type, refresh_token, access_token, hub_id, scopes, expires_in). Official placeholder token strings were replaced with the clearly labelled values synthetic-access-token and synthetic-refresh-token so the artifact contains no secret-shaped material. hub_id 1234567, expires_in 1800, and the scopes list are from the official example.", + "origin": "synthetic" + }, + { + "path": "packages/v1-ready/hubspot/tests/fixtures/token-introspect.json", + "endpoint": "POST https://api.hubapi.com/oauth/2026-03/token/introspect", + "kind": "synthetic", + "capture": { + "method": "synthetic-from-docs", + "tool": "manual transcription of the official access-token metadata example, with secret-shaped fields omitted or replaced", + "sourceDocUrl": "https://developers.hubspot.com/docs/api-reference/2026-03/authentication/manage-oauth-tokens", + "sourceDocTitle": "Manage OAuth access tokens with the 2026-03 API", + "sourceDocSection": "Retrieve access token metadata \u2014 Access token metadata example", + "sourceDocRetrievedAt": "2026-11-09T13:00:00-05:00", + "providerApiVersion": "2026-03", + "providerContract": "POST /oauth/2026-03/token/introspect", + "sourceVersionOrDate": "HubSpot OAuth API 2026-03, docs retrieved 2026-11-09", + "accountClassification": null, + "rawCaptureSha256": null, + "redactionTransform": null, + "postRedactionSha256": null, + "note": "Not a recorded capture chain. Origin is synthetic; generator is declared at the provenance root." + }, + "adaptations": "Includes the official example's non-secret fields (active, hub_id, user_id, app_id, user, hub_domain, scopes, expires_in, is_private_distribution, token_use, token_type). token and client_id were replaced with synthetic-access-token and synthetic-client-id. signed_access_token (and signature / newSignature blobs in the official example) is omitted because those strings are secret-shaped. api.js introspectToken only requires response.active === true.", + "origin": "synthetic" + }, + { + "path": "packages/v1-ready/hubspot/tests/fixtures/contacts-list.json", + "endpoint": "GET https://api.hubapi.com/crm/v3/objects/contacts", + "kind": "synthetic", + "capture": { + "method": "synthetic-from-docs", + "tool": "manual transcription of the official retrieve-all-contacts example JSON", + "sourceDocUrl": "https://developers.hubspot.com/docs/api-reference/legacy/crm/objects/contacts/guide", + "sourceDocTitle": "CRM API | Contacts \u2014 Retrieve all contacts", + "sourceDocSection": "example response for GET /crm/v3/objects/contacts", + "sourceDocRetrievedAt": "2026-11-09T13:01:00-05:00", + "providerApiVersion": "crm/v3", + "providerContract": "GET /crm/v3/objects/contacts", + "sourceVersionOrDate": "HubSpot CRM Contacts API v3 (legacy path used by this module), docs retrieved 2026-11-09", + "accountClassification": null, + "rawCaptureSha256": null, + "redactionTransform": null, + "postRedactionSha256": null, + "note": "Not a recorded capture chain. Origin is synthetic; generator is declared at the provenance root." + }, + "adaptations": "None. Copied from the official example, including the fictional lorelai@thedragonfly.com contact and the paging.next.link path as published.", + "origin": "synthetic" + } + ], + "origin": "synthetic", + "generator": { + "tool": "manual transcription from official HubSpot developer documentation", + "command": "none (no generator program; see tests/fixtures/GENERATOR.md)", + "artifact": "packages/v1-ready/hubspot/tests/fixtures/GENERATOR.md" + }, + "freshness": { + "rule": "max-age-days-and-provider-api-version-pin", + "maxAgeDays": 90, + "pins": [ + { + "providerApiVersion": "2026-03", + "contract": "POST /oauth/2026-03/token", + "modulePin": "api.js tokenUri / URLs.access_token" + }, + { + "providerApiVersion": "2026-03", + "contract": "POST /oauth/2026-03/token/introspect", + "modulePin": "api.js URLs.tokenIntrospect" + }, + { + "providerApiVersion": "integrations/v1", + "contract": "GET /integrations/v1/me", + "modulePin": "api.js URLs.userDetails" + }, + { + "providerApiVersion": "crm/v3", + "contract": "GET /crm/v3/objects/contacts", + "modulePin": "api.js URLs.contacts" + } + ], + "note": "Recorded traffic MUST bind both a concrete maxAgeDays and the provider API-version pins. maxAgeDays=90 is adopted (Architect-pinned in erratum rev2, sha256 53d5f7b9\u2026). Synthetic fixtures are not on the 90-day clock but still require the API-version pin. A missing, non-90, or stale bound fails recorded 4a (T-C4-9). A fixture whose providerApiVersion or endpoint no longer matches api.js also fails 4a.", + "maxAgeDaysStatus": "adopted" + } +} diff --git a/packages/v1-ready/hubspot/tests/fixtures/token-introspect.json b/packages/v1-ready/hubspot/tests/fixtures/token-introspect.json new file mode 100644 index 00000000..dd138fed --- /dev/null +++ b/packages/v1-ready/hubspot/tests/fixtures/token-introspect.json @@ -0,0 +1,19 @@ +{ + "active": true, + "token": "synthetic-access-token", + "hub_id": 1234567, + "user_id": 222222, + "client_id": "synthetic-client-id", + "app_id": 1234444, + "user": "jdoe@hubspot.com", + "hub_domain": "example.com", + "scopes": [ + "oauth", + "crm.objects.contacts.read", + "crm.objects.contacts.write" + ], + "expires_in": 1789, + "is_private_distribution": true, + "token_use": "access_token", + "token_type": "Bearer" +} diff --git a/scripts/ci/hubspot-4a-policy.cjs b/scripts/ci/hubspot-4a-policy.cjs new file mode 100644 index 00000000..bc36a11c --- /dev/null +++ b/scripts/ci/hubspot-4a-policy.cjs @@ -0,0 +1,340 @@ +/** + * HubSpot Binding C item 4a policy constants. + * + * Built to erratum rev2 (sha256 + * 53d5f7b9bc07fe94e0a3d100467635916481b47ca928fddfc9a12e6ebd361085). + * + * Freshness: recorded traffic must satisfy age <= FRESHNESS_MAX_AGE_DAYS + * (90, Architect-pinned, adopted) AND the provider API-version pin. + * Synthetic fixtures are not on the 90-day clock; they still require the + * API-version pin. + * + * Token / refresh / Bearer request shapes follow this module on `next` + * (POST /oauth/2026-03/token, not the main@d50c6a9d /oauth/v1/token table + * in the erratum). 42matters account.json access_token query shape is a + * separate PR. + */ +'use strict'; + +const FRESHNESS_MAX_AGE_DAYS = 90; +const FRESHNESS_MAX_AGE_DAYS_STATUS = 'adopted'; + +const TOKEN_ENDPOINT = 'https://api.hubapi.com/oauth/2026-03/token'; +const TOKEN_PATH = '/oauth/2026-03/token'; +const INTROSPECT_PATH = '/oauth/2026-03/token/introspect'; +const ME_PATH = '/integrations/v1/me'; +const CONTACTS_PATH = '/crm/v3/objects/contacts'; +const AUTHORIZE_PATH = '/oauth/authorize'; + +/** + * Pinned provider hosts for fail-closed deny + spy (T-C4-8 / T-C4-12). + * Exact hostnames from packages/v1-ready/hubspot/api.js (baseUrl, authorize + * URI, tokenUri). Regional peers and api.hubspot.com are not in this + * module's source and are not on the list. + */ +const DENIED_PROVIDER_HOSTS = Object.freeze([ + 'api.hubapi.com', + 'app.hubspot.com', +]); + +/** + * Closed env list for this 4a job (T-C4-8 / P3-TOKEN-ENV-CLOSED-LIST). + * "Present" includes a set-but-empty value. A prefix entry matches any + * env key that begins with that prefix. GITHUB_TOKEN / GH_TOKEN are not + * on this list. REDIRECT_URI is not a provider token and is not listed. + * + * Do not use a HUBSPOT_* name for CI plumbing (e.g. the receipt command). + * Use AML_RECEIPT_COMMAND or a CLI --command argument instead. + */ +const TOKEN_ENV_PREFIXES = Object.freeze(['HUBSPOT_', 'MATTERS_']); +const TOKEN_ENV_NAMES = Object.freeze([ + 'HUBSPOT_CLIENT_ID', + 'HUBSPOT_CLIENT_SECRET', + 'HUBSPOT_SCOPE', + 'HUBSPOT_TIMELINE_EVENT_TEMPLATE_ID', + 'MATTERS_ACCESS_TOKEN', +]); + +const API_VERSION_PINS = Object.freeze([ + { providerApiVersion: '2026-03', contract: 'POST /oauth/2026-03/token', modulePin: 'api.js tokenUri / URLs.access_token' }, + { providerApiVersion: '2026-03', contract: 'POST /oauth/2026-03/token/introspect', modulePin: 'api.js URLs.tokenIntrospect' }, + { providerApiVersion: 'integrations/v1', contract: 'GET /integrations/v1/me', modulePin: 'api.js URLs.userDetails' }, + { providerApiVersion: 'crm/v3', contract: 'GET /crm/v3/objects/contacts', modulePin: 'api.js URLs.contacts' }, +]); + +const WORKFLOW_RECEIPT_COMMAND = + 'npx jest --config jest.receipt.config.js --ci --watchman=false --forceExit --json --outputFile=artifacts/jest-results.json'; + +function isForbiddenEnvKey(key) { + if (TOKEN_ENV_NAMES.includes(key)) return true; + const upper = String(key).toUpperCase(); + return TOKEN_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix)); +} + +function forbiddenEnvHits(env) { + // Presence of the key is enough, including set-but-empty (rev2). + return Object.keys(env).filter((key) => isForbiddenEnvKey(key)); +} + +function isDeniedProviderHost(host) { + const h = String(host || '').toLowerCase().replace(/^\[|\]$/g, '').replace(/:\d+$/, ''); + return DENIED_PROVIDER_HOSTS.includes(h); +} + +function isFreshnessBoundInPolicy(maxAgeDays) { + return maxAgeDays === FRESHNESS_MAX_AGE_DAYS; +} + +function isStale(isoTimestamp, maxAgeDays = FRESHNESS_MAX_AGE_DAYS, now = new Date()) { + if (maxAgeDays == null) return true; // missing bound fails (T-C4-9) + const captured = new Date(isoTimestamp); + if (Number.isNaN(captured.getTime())) return true; + return now.getTime() - captured.getTime() > maxAgeDays * 24 * 60 * 60 * 1000; +} + +function pinMatches(providerApiVersion, contract, pins = API_VERSION_PINS) { + if (!providerApiVersion) return false; + return pins.some((p) => { + const versionOk = p.providerApiVersion === providerApiVersion; + if (!contract) return versionOk; + if (p.contract === contract) return versionOk; + const pathPart = String(contract).replace(/^(GET|POST)\s+/, ''); + return versionOk && (p.contract.endsWith(pathPart) || String(p.contract).includes(pathPart)); + }); +} + +/** + * T-C4-9 recorded-freshness evaluator. + * Recorded traffic is accepted only when age <= 90 AND the API-version pin + * matches. Synthetic fixtures skip the 90-day clock but still require the pin. + * + * Named failure reasons: + * stale-but-pinned + * fresh-but-unpinned + * missing-or-non-90-bound + * api-version-mismatch + */ +function evaluateFreshnessAndPin({ + classification, + capturedAt, + maxAgeDays, + providerApiVersion, + contract, + pins = API_VERSION_PINS, + now = new Date(), +} = {}) { + const pinned = pinMatches(providerApiVersion, contract, pins); + const versionPresent = Boolean(providerApiVersion); + const pinListHasContract = !contract || pins.some((p) => { + const pathPart = String(contract).replace(/^(GET|POST)\s+/, ''); + return p.contract === contract || p.contract.endsWith(pathPart) || String(p.contract).includes(pathPart); + }); + + if (classification === 'synthetic') { + if (!versionPresent || (pinListHasContract && !pinned) || !pinned) { + return { ok: false, reason: 'api-version-mismatch', clockApplied: false }; + } + return { ok: true, reason: null, clockApplied: false }; + } + + const boundOk = isFreshnessBoundInPolicy(maxAgeDays); + if (!boundOk) { + return { ok: false, reason: 'missing-or-non-90-bound', clockApplied: true }; + } + const stale = isStale(capturedAt, maxAgeDays, now); + if (stale && pinned) { + return { ok: false, reason: 'stale-but-pinned', clockApplied: true }; + } + if (stale) { + return { ok: false, reason: 'stale-but-pinned', clockApplied: true }; + } + if (pinListHasContract && versionPresent && !pinned) { + return { ok: false, reason: 'api-version-mismatch', clockApplied: true }; + } + if (!pinned) { + return { ok: false, reason: 'fresh-but-unpinned', clockApplied: true }; + } + return { ok: true, reason: null, clockApplied: true }; +} + +/** + * Outbound auth request shape. A faked transport may only supply the + * response when this shape matches. Wrong shape must not get a 200. + * Token exchange and refresh must not send an Authorization header + * (HubSpot does not use getTokenFromCodeBasicAuthHeader). + */ +function tokenBody(raw) { + if (raw == null) return {}; + if (typeof raw === 'string') return Object.fromEntries(new URLSearchParams(raw)); + if (typeof raw === 'object') return raw; + return {}; +} + +function requestAuthorization(req) { + const headers = (req && req.headers) || {}; + return headers.Authorization || headers.authorization || ''; +} + +function matchesTokenExchange(req) { + const method = String(req.method || '').toUpperCase(); + const url = String(req.url || ''); + const body = tokenBody(req.body); + if (method !== 'POST') return false; + if (!(url === TOKEN_ENDPOINT || url.endsWith(TOKEN_PATH))) return false; + if (requestAuthorization(req)) return false; + if (body.grant_type !== 'authorization_code') return false; + if (!body.client_id || !body.client_secret || !body.redirect_uri || !body.scope || !body.code) { + return false; + } + return true; +} + +function matchesTokenRefresh(req) { + const method = String(req.method || '').toUpperCase(); + const url = String(req.url || ''); + const body = tokenBody(req.body); + if (method !== 'POST') return false; + if (!(url === TOKEN_ENDPOINT || url.endsWith(TOKEN_PATH))) return false; + if (requestAuthorization(req)) return false; + if (body.grant_type !== 'refresh_token') return false; + if (!body.client_id || !body.client_secret || !body.redirect_uri || !body.refresh_token) { + return false; + } + return true; +} + +function matchesBearerAuthorization(header) { + return /^Bearer\s+\S+$/.test(String(header || '')); +} + +function matchesApiAuthHeader(req) { + const headers = req.headers || {}; + const auth = headers.Authorization || headers.authorization; + return matchesBearerAuthorization(auth); +} + +/** + * T-C4-12: deny and spy are distinct required mechanisms over the same + * pinned host list. A 200 fake is not a deny. A silent deny is not a spy. + */ +function evaluateDenyAndSpy({ denyInstalled, spyInstalled, treatsAlways200AsDeny } = {}) { + if (treatsAlways200AsDeny) { + return { ok: false, reason: 'always-200-is-not-a-deny' }; + } + if (!denyInstalled && !spyInstalled) { + return { ok: false, reason: 'missing-deny-and-spy' }; + } + if (!denyInstalled) { + return { ok: false, reason: 'spy-only' }; + } + if (!spyInstalled) { + return { ok: false, reason: 'deny-only' }; + } + return { ok: true, reason: null }; +} + +function parseRequestHostPath(req) { + if (!req) return { method: 'GET', host: '', path: '/' }; + const method = String(req.method || (req.options && req.options.method) || 'GET').toUpperCase(); + let host = ''; + let pathName = ''; + if (typeof req.getHeader === 'function') { + const h = req.getHeader('host'); + if (h) host = String(h); + } + host = host + || req.host + || req.hostname + || (req.options && (req.options.hostname || req.options.host)) + || ''; + pathName = req.path + || (req.options && (req.options.path || req.options.pathname)) + || ''; + const href = req.href || req.url; + if ((!host || !pathName) && href) { + try { + const u = new URL(String(href)); + host = host || u.hostname; + pathName = pathName || `${u.pathname}${u.search}`; + } catch (_err) { + // ignore unparseable href + } + } + host = String(host).toLowerCase(); + if (host.includes(':') && !host.startsWith('[')) { + const parts = host.split(':'); + if (parts.length === 2 && /^\d+$/.test(parts[1])) host = parts[0]; + } + if (!pathName) pathName = '/'; + return { method, host, path: pathName }; +} + +/** + * Spy: records unmatched attempts to pinned hosts (method + host + path). + * Distinct from deny (nock.disableNetConnect). Installed via nock.emitter + * 'no match', which fires for requests that would otherwise go live. + */ +function createPinnedHostSpy(hosts = DENIED_PROVIDER_HOSTS) { + const records = []; + let onNoMatch = null; + function install() { + const nock = require('nock'); + onNoMatch = (req) => { + const parsed = parseRequestHostPath(req); + if (hosts.some((h) => h === parsed.host) || isDeniedProviderHost(parsed.host)) { + records.push({ + method: parsed.method, + host: parsed.host, + path: parsed.path, + }); + } + }; + nock.emitter.on('no match', onNoMatch); + return this; + } + function uninstall() { + if (!onNoMatch) return; + const nock = require('nock'); + nock.emitter.removeListener('no match', onNoMatch); + onNoMatch = null; + } + return { + records, + install, + uninstall, + mechanism: 'nock.emitter:no-match', + kind: 'spy', + }; +} + +module.exports = { + FRESHNESS_MAX_AGE_DAYS, + FRESHNESS_MAX_AGE_DAYS_STATUS, + TOKEN_ENDPOINT, + TOKEN_PATH, + INTROSPECT_PATH, + ME_PATH, + CONTACTS_PATH, + AUTHORIZE_PATH, + DENIED_PROVIDER_HOSTS, + TOKEN_ENV_PREFIXES, + TOKEN_ENV_NAMES, + API_VERSION_PINS, + WORKFLOW_RECEIPT_COMMAND, + isForbiddenEnvKey, + forbiddenEnvHits, + isDeniedProviderHost, + isFreshnessBoundInPolicy, + isStale, + pinMatches, + evaluateFreshnessAndPin, + matchesTokenExchange, + matchesTokenRefresh, + matchesBearerAuthorization, + matchesApiAuthHeader, + tokenBody, + requestAuthorization, + evaluateDenyAndSpy, + parseRequestHostPath, + createPinnedHostSpy, +}; diff --git a/scripts/ci/hubspot-auth-api-receipt.cjs b/scripts/ci/hubspot-auth-api-receipt.cjs new file mode 100644 index 00000000..71d4b1fe --- /dev/null +++ b/scripts/ci/hubspot-auth-api-receipt.cjs @@ -0,0 +1,839 @@ +#!/usr/bin/env node +/** + * Emit a Binding C item 4a auth/API test receipt for packages/v1-ready/hubspot. + * + * Built to: + * /workspace/architect-contracts/2026-11-09-fc-b-qr-binding-c-item-4-synthetic-vs-live-erratum-rev2.md + * sha256 53d5f7b9bc07fe94e0a3d100467635916481b47ca928fddfc9a12e6ebd361085 + * + * Classification may be `synthetic` or `recorded` only. This script refuses + * `authorized` / live (T-C4-2, T-C4-5). It does not read secrets or contact + * the live HubSpot API. + * + * Usage: + * node scripts/ci/hubspot-auth-api-receipt.cjs --assert-offline-env + * node scripts/ci/hubspot-auth-api-receipt.cjs \ + * --jest-json artifacts/jest-results.json \ + * --out artifacts/hubspot-auth-api-receipt.json \ + * --command '...' \ + * --classification synthetic \ + * --expect-classification synthetic + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { execFileSync } = require('child_process'); +const { + TRANSFORM_ID, + ALLOWED, + JWT_RE, + BEARER_RE, + applyRedaction, +} = require('./redaction-transform.v1.cjs'); +const policy = require('./hubspot-4a-policy.cjs'); +const { + FRESHNESS_MAX_AGE_DAYS, + FRESHNESS_MAX_AGE_DAYS_STATUS, + DENIED_PROVIDER_HOSTS, + TOKEN_ENV_NAMES, + TOKEN_ENV_PREFIXES, + API_VERSION_PINS, + isFreshnessBoundInPolicy, + isStale, + isDeniedProviderHost, +} = policy; + +const REPO_ROOT = path.resolve(__dirname, '../..'); +const MODULE_DIR = 'packages/v1-ready/hubspot'; +const PACKAGE_JSON = path.join(REPO_ROOT, MODULE_DIR, 'package.json'); +const API_JS = path.join(MODULE_DIR, 'api.js'); +const PROVENANCE_REL = path.join(MODULE_DIR, 'tests/fixtures/provenance.json'); +const GENERATOR_REL = path.join(MODULE_DIR, 'tests/fixtures/GENERATOR.md'); +const TRANSFORM_REL = 'scripts/ci/redaction-transform.v1.cjs'; + +const SCHEMA = 'aml.hubspot.auth-api-receipt.v1'; +const ENUM = Object.freeze(['synthetic', 'recorded', 'authorized']); +const CLASS_4A = new Set(['synthetic', 'recorded']); +const REV2 = { + path: '2026-11-09-fc-b-qr-binding-c-item-4-synthetic-vs-live-erratum-rev2.md', + sha256: '53d5f7b9bc07fe94e0a3d100467635916481b47ca928fddfc9a12e6ebd361085', +}; + +// erratum-2 (59cf708f…): the hubspot api.js loaded at runtime must hash to this +// pin; a mismatch fails 4a. +const PINNED_API_JS_SHA256 = 'af0b0bdc937820697a9bf158a6cc4f8a9e4fa22c96c535ee5af994720e4c3c60'; + +// erratum-2 R1: closed moduleRef map (identity of the module code under +// test). Exact branch + full sha only. Not the harness commit. +const BOUND_MODULE_REFS = Object.freeze([ + Object.freeze({ + table: 'V1', + branch: 'main', + sha: 'd50c6a9d8f6f8264aaf4e6e9447991b4074ec455', + apiJsSha256: 'f7e3046dcf0870b82faaf219bd37873b245622b695e4e358e1ea20035bf7f3d3', + }), + Object.freeze({ + table: 'NEXT', + branch: 'next', + sha: '9f5e5c627a0be42cd25cf28339142891e71eba07', + apiJsSha256: PINNED_API_JS_SHA256, + }), +]); + +function resolveModuleRef({ branch, sha } = {}) { + const fail = (msg) => { + const err = new Error(`erratum-2 R1: ${msg}`); + err.code = 'UNKNOWN_MODULE_REF'; + return err; + }; + if (!branch || !sha) throw fail('moduleRef.branch and moduleRef.sha are required'); + if (!/^[0-9a-f]{40}$/.test(String(sha))) throw fail(`moduleRef.sha must be a full 40-character commit: ${sha}`); + const hit = BOUND_MODULE_REFS.find((r) => r.branch === branch && r.sha === sha); + if (!hit) throw fail(`unknown moduleRef ${branch}@${sha}; not in the closed V1/NEXT map (fail closed)`); + return { ...hit }; +} + +function assertApiJsDigest(digest, expected = PINNED_API_JS_SHA256) { + if (digest !== expected) { + const err = new Error(`erratum-2: runtime api.js sha256 ${digest} != moduleRef pin ${expected}; 4a fails`); + err.code = 'E2-APIJS'; + throw err; + } + return digest; +} + +const ISO_OFFSET = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + +// T-C4-8: any HUBSPOT_*, MATTERS_ACCESS_TOKEN, or peer token/secret env. +// GITHUB_TOKEN / GH_TOKEN / ACTIONS_* are not provider secrets. +// rev1 T-C4-8: HUBSPOT_* and MATTERS_ACCESS_TOKEN (the named 42matters peer). +// Do not treat unrelated provider secrets on a shared machine (e.g. FRONT_CLIENT_SECRET) +// or GITHUB_TOKEN as HubSpot/Matters tokens. +const FORBIDDEN_ENV = /^(HUBSPOT_.*|MATTERS_ACCESS_TOKEN)$/i; + +function sha256File(absPath) { + return crypto.createHash('sha256').update(fs.readFileSync(absPath)).digest('hex'); +} + +function sha256Text(text) { + return crypto.createHash('sha256').update(Buffer.from(String(text), 'utf8')).digest('hex'); +} + +function git(args) { + try { + return execFileSync('git', args, { cwd: REPO_ROOT, encoding: 'utf8' }).trim(); + } catch (_err) { + return null; + } +} + +function parseArgs(argv) { + const args = { + jestJson: null, + out: null, + command: null, + classification: 'synthetic', + expectClassification: null, + moduleRefBranch: null, + moduleRefSha: null, + assertOfflineEnv: false, + }; + for (let i = 2; i < argv.length; i += 1) { + const key = argv[i]; + if (key === '--assert-offline-env') { + args.assertOfflineEnv = true; + continue; + } + const val = argv[i + 1]; + if (key === '--jest-json') args.jestJson = val; + else if (key === '--out') args.out = val; + else if (key === '--command') args.command = val; + else if (key === '--classification') args.classification = val; + else if (key === '--expect-classification') args.expectClassification = val; + else if (key === '--module-ref-branch') args.moduleRefBranch = val; + else if (key === '--module-ref-sha') args.moduleRefSha = val; + else continue; + i += 1; + } + return args; +} + +function forbiddenEnvHits(env) { + return policy.forbiddenEnvHits(env); +} + +function assertNoProviderEnv(env = process.env) { + const hits = forbiddenEnvHits(env); + if (hits.length) { + const err = new Error( + `T-C4-8: 4a fail-closed: provider token/secret env present: ${hits.join(', ')}` + ); + err.code = 'T-C4-8'; + throw err; + } + return true; +} + +function assertClassificationEnum(value) { + if (!ENUM.includes(value)) { + const err = new Error( + `classification must be one of ${ENUM.join('|')}, got ${JSON.stringify(value)}` + ); + err.code = 'T-C4-2'; + throw err; + } + return true; +} + +function assertPacketByteEqual(packetClassification, artifactClassification) { + const a = Buffer.from(String(artifactClassification), 'utf8'); + const b = Buffer.from(String(packetClassification), 'utf8'); + if (!a.equals(b)) { + const err = new Error( + `T-C4-2: packet classification is not byte-equal to the CI artifact classification` + ); + err.code = 'T-C4-2'; + throw err; + } + return true; +} + +function rejectUnauthorizedAuthorized(receipt) { + if (receipt.classification !== 'authorized') return 'n/a'; + if (!receipt.liveCiSubject || !receipt.seanHeldTokenEvidence) { + const err = new Error( + 'T-C4-5: classification "authorized" without Sean-held token evidence / live CI subject' + ); + err.code = 'T-C4-5'; + throw err; + } + return 'resolves'; +} + +function evaluate42mattersLiveApiAlone({ onlyLiveApiTestJs, has4a, has4b }) { + // T-C4-6 + if (onlyLiveApiTestJs && !has4a && !has4b) return 'unresolved-not-item-4'; + return 'ok'; +} + +function evaluateGqr({ has4a, hasDeployer }) { + // T-C4-7 + if (has4a && !hasDeployer) return 'OPEN'; + if (has4a && hasDeployer) return 'not-decided-here'; + return 'OPEN'; +} + +function evaluate4bMissingOnly4aFields({ classification, liveCiSubject, seanHeldTokenEvidence }) { + // T-C4-10: 4b does not require 4a fixture fields + if (classification === 'authorized' && liveCiSubject && seanHeldTokenEvidence) { + return 'resolves'; + } + return 'unresolved'; +} + +function isStaleRecorded(capturedAt, maxAgeDays, now = new Date()) { + return isStale(capturedAt, maxAgeDays, now); +} + +function readModuleContracts() { + const apiSrc = fs.readFileSync(path.join(REPO_ROOT, API_JS), 'utf8'); + return { + tokenUri: /this\.tokenUri\s*=\s*'([^']+)'/.exec(apiSrc)?.[1] || null, + accessTokenPath: /access_token:\s*'([^']+)'/.exec(apiSrc)?.[1] || null, + introspectPath: /tokenIntrospect:\s*'([^']+)'/.exec(apiSrc)?.[1] || null, + userDetailsPath: /userDetails:\s*'([^']+)'/.exec(apiSrc)?.[1] || null, + contactsPath: /contacts:\s*'([^']+)'/.exec(apiSrc)?.[1] || null, + source: API_JS, + }; +} + +function validateFreshnessAndOrigin(provenance, classification) { + const errors = []; + if (!ISO_OFFSET.test(provenance.recordedAt || '')) { + errors.push('T-C4-3: recordedAt must be ISO-8601 with time and offset (date-only is not enough)'); + } + if (classification === 'synthetic') { + const gen = provenance.generator; + if (!gen || !gen.tool || !gen.artifact) { + errors.push('T-C4-3: synthetic provenance missing generator (tool/command/artifact)'); + } + if (gen && gen.artifact && !fs.existsSync(path.join(REPO_ROOT, gen.artifact))) { + errors.push(`T-C4-3: generator artifact missing: ${gen.artifact}`); + } + } + if (classification === 'recorded') { + for (const f of provenance.fixtures || []) { + const c = f.capture || {}; + const required = [ + 'capturedBy', + 'capturedAt', + 'providerApiVersion', + 'endpoint', + 'accountClassification', + 'tool', + 'command', + 'rawCaptureSha256', + 'redactionTransformId', + 'postRedactionSha256', + ]; + // also accept namespaced fields + const have = { + capturedBy: c.capturedBy || c.who, + capturedAt: c.capturedAt || c.when, + providerApiVersion: c.providerApiVersion, + endpoint: c.endpoint || f.endpoint, + accountClassification: c.accountClassification, + tool: c.tool || c.captureTool, + command: c.command || c.captureCommand, + rawCaptureSha256: c.rawCaptureSha256, + redactionTransformId: (c.redactionTransform && c.redactionTransform.id) || c.redactionTransformId, + postRedactionSha256: c.postRedactionSha256, + }; + const missing = required.filter((k) => !have[k]); + if (missing.length) { + errors.push(`T-C4-3: recorded fixture ${f.path} missing capture chain: ${missing.join(', ')}`); + } + if (have.capturedAt && !ISO_OFFSET.test(have.capturedAt)) { + errors.push(`T-C4-3: recorded fixture ${f.path} capturedAt must include time and offset`); + } + const maxAge = (provenance.freshness && provenance.freshness.maxAgeDays) || c.maxAgeDays; + if (have.capturedAt && isStaleRecorded(have.capturedAt, maxAge)) { + errors.push(`T-C4-9: recorded fixture ${f.path} is stale (past max age ${maxAge} days)`); + } + } + if (!(provenance.freshness && (provenance.freshness.maxAgeDays || provenance.freshness.rule))) { + errors.push('T-C4-3: recorded provenance missing freshness rule'); + } + } + + const freshness = provenance.freshness || {}; + // Synthetic fixtures are not on the 90-day clock (erratum rev2). + // Recorded traffic must satisfy the adopted 90-day bound AND the pin + // (pin checks follow). A missing or non-90 bound fails recorded 4a. + if (classification === 'recorded') { + if (!isFreshnessBoundInPolicy(freshness.maxAgeDays)) { + errors.push( + `T-C4-9: freshness.maxAgeDays must equal the adopted policy constant ${FRESHNESS_MAX_AGE_DAYS}; got ${JSON.stringify(freshness.maxAgeDays)}` + ); + } + if (freshness.recordedAt || provenance.recordedAt) { + const ts = freshness.recordedAt || provenance.recordedAt; + if (isStale(ts, freshness.maxAgeDays)) { + errors.push(`T-C4-9: provenance recordedAt ${ts} is stale under maxAgeDays=${freshness.maxAgeDays}`); + } + } + for (const fixture of provenance.fixtures || []) { + const retrieved = fixture.capture && fixture.capture.sourceDocRetrievedAt; + if (retrieved && isStale(retrieved, freshness.maxAgeDays)) { + errors.push(`T-C4-9: fixture ${fixture.path} sourceDocRetrievedAt is stale`); + } + } + } + + const contracts = readModuleContracts(); + const pins = (provenance.freshness && provenance.freshness.pins) || []; + for (const fixture of provenance.fixtures || []) { + const contract = (fixture.capture && fixture.capture.providerContract) || fixture.endpoint || ''; + const version = fixture.capture && fixture.capture.providerApiVersion; + if (!contract || !version) { + errors.push(`T-C4-3: fixture ${fixture.path} missing provider contract or API version`); + continue; + } + const pin = pins.find((p) => p.contract === contract || contract.endsWith(p.contract.replace(/^(GET|POST) /, ''))); + if (!pin) { + errors.push(`T-C4-9: fixture ${fixture.path} contract ${contract} is not in the freshness pin list`); + continue; + } + if (pin.providerApiVersion !== version) { + errors.push(`T-C4-9: fixture ${fixture.path} API version ${version} mismatches pin ${pin.providerApiVersion}`); + } + // module surface check + const pathPart = contract.replace(/^(GET|POST)\s+https?:\/\/api\.hubapi\.com/, '').replace(/^(GET|POST)\s+/, ''); + const moduleText = [ + contracts.tokenUri, + contracts.accessTokenPath, + contracts.introspectPath, + contracts.userDetailsPath, + contracts.contactsPath, + ].filter(Boolean).join('\n'); + if (pathPart && !moduleText.includes(pathPart) && !moduleText.includes(pathPart.replace(/^https:\/\/api\.hubapi\.com/, ''))) { + errors.push(`T-C4-9: fixture ${fixture.path} contract ${pathPart} does not match api.js pins`); + } + } + + if (errors.length) { + const err = new Error(errors.join('; ')); + err.code = errors.some((e) => e.startsWith('T-C4-9')) ? 'T-C4-9' : 'T-C4-3'; + err.details = errors; + throw err; + } + return { ok: true, contracts }; +} + +function secretShapedHits(value, key, pathParts, acc) { + if (Array.isArray(value)) { + value.forEach((v, i) => secretShapedHits(v, key, pathParts.concat(String(i)), acc)); + return acc; + } + if (value && typeof value === 'object') { + for (const [k, v] of Object.entries(value)) { + secretShapedHits(v, k, pathParts.concat(k), acc); + } + return acc; + } + if (typeof value !== 'string') return acc; + if (ALLOWED.test(value) || value.startsWith('[REDACTED:')) return acc; + // Field names alone are not secrets; flag values that look like material. + const lowered = (key || '').toLowerCase(); + // Exact key names only. Do not treat authorizationUri / tokenUri as secrets. + const secretKeys = new Set(['access_token','refresh_token','client_secret','api_key','apikey','authorization','password','token']); + if (secretKeys.has(lowered) && value.length > 0) { + acc.push({ path: pathParts.join('.'), reason: `secret-keyed value for ${key}` }); + return acc; + } + if (JWT_RE.test(value)) acc.push({ path: pathParts.join('.'), reason: 'jwt-shaped' }); + if (BEARER_RE.test(value)) acc.push({ path: pathParts.join('.'), reason: 'authorization-bearer' }); + return acc; +} + +function assertNoSecrets(label, objOrText) { + let hits; + if (typeof objOrText === 'string') { + hits = []; + if (JWT_RE.test(objOrText) || /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/.test(objOrText)) { + // allow if only in allowlisted placeholders; scan leftover after stripping allowed + const cleaned = objOrText.replace(/synthetic-[A-Za-z0-9_-]+/g, '').replace(/\[REDACTED:[a-z0-9_]+\]/g, ''); + if (/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/.test(cleaned)) { + hits.push({ path: label, reason: 'jwt-shaped text' }); + } + } + // Only flag a live-looking Authorization Bearer value, not documentation + // of the header form or synthetic / redacted placeholders. + const authLive = /Authorization:\s*Bearer\s+(?!synthetic-|test-|\[REDACTED)(\S+)/i; + if (authLive.test(objOrText)) { + hits.push({ path: label, reason: 'authorization header' }); + } + } else { + hits = secretShapedHits(objOrText, null, [], []); + } + if (hits.length) { + const err = new Error(`T-C4-4: secret-shaped material in ${label}: ${hits.map((h) => h.reason).join('; ')}`); + err.code = 'T-C4-4'; + err.hits = hits; + throw err; + } + return true; +} + +function listFiles(relDir, predicate) { + const abs = path.join(REPO_ROOT, relDir); + const out = []; + function walk(dir, rel) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === '.git') continue; + const nextAbs = path.join(dir, entry.name); + const nextRel = path.join(rel, entry.name); + if (entry.isDirectory()) walk(nextAbs, nextRel); + else if (!predicate || predicate(nextRel)) out.push(nextRel.split(path.sep).join('/')); + } + } + walk(abs, relDir); + return out.sort(); +} + +function digestMap(files) { + return files.filter((rel) => fs.existsSync(path.join(REPO_ROOT, rel))).map((rel) => ({ + path: rel, + byteSha256: sha256File(path.join(REPO_ROOT, rel)), + gitBlobSha: git(['hash-object', rel]), + })); +} + +function collectResult(jestJsonPath) { + const raw = JSON.parse(fs.readFileSync(jestJsonPath, 'utf8')); + const skipped = []; + const failed = []; + const passed = []; + for (const suite of raw.testResults || []) { + for (const t of suite.assertionResults || []) { + const item = { + title: t.fullName || t.title, + status: t.status, + ancestorTitles: t.ancestorTitles || [], + file: path.relative(REPO_ROOT, suite.name || '').split(path.sep).join('/'), + }; + if (t.status === 'passed') passed.push(item); + else if (t.status === 'pending' || t.status === 'skipped' || t.status === 'disabled') skipped.push(item); + else if (t.status === 'failed') failed.push(item); + } + } + const counts = { + passed: raw.numPassedTests ?? passed.length, + failed: raw.numFailedTests ?? failed.length, + skipped: raw.numPendingTests ?? skipped.length, + todo: raw.numTodoTests ?? 0, + total: raw.numTotalTests ?? passed.length + failed.length + skipped.length, + }; + const status = counts.failed === 0 && raw.success !== false ? 'pass' : 'fail'; + return { status, counts, skipped, failed, passed }; +} + +function exercisedAuthPaths(result) { + const titles = result.passed.map((t) => t.title).join('\n'); + return { + tokenExchange: /getToken|getTokenFromCode|authorization_code/.test(titles), + refresh: /refreshAccessToken|refresh_token|refresh via/.test(titles), + headerSigning: /addAuthHeaders|Bearer header|header \/ signing/.test(titles), + testAuthRequest: /testAuthRequest/.test(titles), + listContacts: /listContacts/.test(titles), + negative: /rejects|inactive|401|invalid_grant|tampered|expired/.test(titles), + }; +} + +function buildReceipt(opts) { + assertNoProviderEnv(); + const classification = opts.classification; + assertClassificationEnum(classification); + if (!CLASS_4A.has(classification)) { + const err = new Error( + 'This 4a job will not emit authorized/live receipts (T-C4-2, T-C4-5).' + ); + err.code = 'T-C4-2'; + throw err; + } + if (opts.expectClassification != null) { + assertPacketByteEqual(opts.expectClassification, classification); + } + + const pkg = JSON.parse(fs.readFileSync(PACKAGE_JSON, 'utf8')); + const provenance = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, PROVENANCE_REL), 'utf8')); + if (provenance.classification && provenance.classification !== classification) { + const err = new Error('provenance.classification does not match receipt classification'); + err.code = 'T-C4-2'; + throw err; + } + validateFreshnessAndOrigin(provenance, classification); + + // Hash the exact file Node resolves for the module's Api at runtime. + const apiJsResolved = require.resolve(path.join(REPO_ROOT, MODULE_DIR, 'api')); + const moduleRef = resolveModuleRef({ branch: opts.moduleRefBranch, sha: opts.moduleRefSha }); + const apiJsSha256 = assertApiJsDigest(sha256File(apiJsResolved), moduleRef.apiJsSha256); + + const generatorDigest = sha256File(path.join(REPO_ROOT, GENERATOR_REL)); + const transformDigest = sha256File(path.join(REPO_ROOT, TRANSFORM_REL)); + + const fixtureEntries = (provenance.fixtures || []).map((f) => { + const abs = path.join(REPO_ROOT, f.path); + if (!fs.existsSync(abs)) throw new Error(`fixture missing: ${f.path}`); + const body = JSON.parse(fs.readFileSync(abs, 'utf8')); + assertNoSecrets(f.path, body); + return { + ...f, + byteSha256: sha256File(abs), + }; + }); + + const result = collectResult(opts.jestJson); + const exercised = exercisedAuthPaths(result); + if (result.status === 'pass') { + const missing = []; + if (!exercised.tokenExchange) missing.push('token exchange'); + if (!exercised.refresh) missing.push('refresh'); + if (!exercised.headerSigning) missing.push('header/signing'); + if (!exercised.negative) missing.push('mutation/negative'); + if (missing.length) { + const err = new Error(`T-C4-1: 4a green requires real auth paths + a failing negative; missing: ${missing.join(', ')}`); + err.code = 'T-C4-1'; + throw err; + } + } + + const testFiles = listFiles(path.join(MODULE_DIR, 'tests'), (rel) => /\.(js|json|md)$/.test(rel)); + const harnessFiles = [ + path.join(MODULE_DIR, 'jest.config.js'), + path.join(MODULE_DIR, 'jest.receipt.config.js'), + path.join(MODULE_DIR, 'jest-setup.js'), + path.join(MODULE_DIR, 'jest-teardown.js'), + 'scripts/ci/hubspot-auth-api-receipt.cjs', + 'scripts/ci/redaction-transform.v1.cjs', + 'scripts/ci/hubspot-4a-policy.cjs', + '.github/workflows/hubspot-auth-api-receipt.yml', + ]; + const configFiles = [ + path.join(MODULE_DIR, 'package.json'), + path.join(MODULE_DIR, 'jest.config.js'), + path.join(MODULE_DIR, 'jest.receipt.config.js'), + path.join(MODULE_DIR, 'defaultConfig.json'), + path.join(MODULE_DIR, 'definition.js'), + ]; + + const sourceCommitSha = process.env.GITHUB_SHA || git(['rev-parse', 'HEAD']); + const lockfileRel = 'package-lock.json'; + + const receipt = { + schema: SCHEMA, + bindingCItem: '4a', + classification, + classificationSha256: sha256Text(classification), + moduleRef, + rev2: REV2, + policy: { + freshnessMaxAgeDays: FRESHNESS_MAX_AGE_DAYS, + freshnessMaxAgeDaysStatus: FRESHNESS_MAX_AGE_DAYS_STATUS, + apiVersionPins: API_VERSION_PINS, + deniedProviderHosts: DENIED_PROVIDER_HOSTS, + tokenEnvNames: TOKEN_ENV_NAMES, + tokenEnvPrefixes: TOKEN_ENV_PREFIXES, + }, + recordedDate: provenance.recordedDate, + recordedAt: provenance.recordedAt, + timeZone: provenance.timeZone || 'America/New_York', + source: { + repo: 'friggframework/api-module-library', + sourceCommitSha, + ref: process.env.GITHUB_REF || git(['rev-parse', '--abbrev-ref', 'HEAD']), + }, + artifact: { + moduleId: 'hubspot', + packageName: pkg.name, + packageVersion: pkg.version, + packageDir: MODULE_DIR, + packageTreeSha: git(['rev-parse', `HEAD:${MODULE_DIR}`]), + apiJs: { + path: path.relative(REPO_ROOT, apiJsResolved), + sha256: apiJsSha256, + pinnedSha256: PINNED_API_JS_SHA256, + }, + lockfile: { + path: lockfileRel, + byteSha256: sha256File(path.join(REPO_ROOT, lockfileRel)), + gitBlobSha: git(['hash-object', lockfileRel]), + }, + }, + authMode: { + type: 'oauth2', + flow: 'authorization_code', + authorizationEndpoint: 'https://app.hubspot.com/oauth/authorize', + tokenUri: 'https://api.hubapi.com/oauth/2026-03/token', + verifiedIn: [ + 'packages/v1-ready/hubspot/definition.js — requiredAuthMethods.getToken reads params.code and calls api.getTokenFromCode(code)', + 'packages/v1-ready/hubspot/api.js — tokenUri https://api.hubapi.com/oauth/2026-03/token; refresh via OAuth2Requester.refreshAccessToken', + 'packages/v1-ready/hubspot/definition.js — requiredAuthMethods.testAuthRequest calls api.getUserDetails()', + '@friggframework/core OAuth2Requester.addAuthHeaders constructs a Bearer token header', + ], + }, + digests: { + tests: digestMap(testFiles), + harness: digestMap(harnessFiles), + lock: digestMap([lockfileRel]), + config: digestMap(configFiles), + }, + fixtures: { + origin: 'synthetic', + provenancePath: PROVENANCE_REL, + provenanceByteSha256: sha256File(path.join(REPO_ROOT, PROVENANCE_REL)), + recordedAt: provenance.recordedAt, + classification: provenance.classification, + generator: { + ...provenance.generator, + digest: generatorDigest, + }, + freshness: provenance.freshness, + redactionTransform: { + id: TRANSFORM_ID, + path: TRANSFORM_REL, + digest: transformDigest, + }, + entries: fixtureEntries, + }, + command: opts.command, + result: { + status: result.status, + counts: result.counts, + exercisedAuthPaths: exercised, + skipped: result.skipped.map((t) => ({ title: t.title, file: t.file })), + failed: result.failed.map((t) => ({ title: t.title, file: t.file })), + }, + ciArtifact: ciArtifactRef(opts.out), + reviewer: { + binding: 'unresolved', + role: 'independent-reviewer', + note: 'This CI job emits the receipt over consumed bytes. It does not review or self-attest. Independent review (Taskmaster gate / Architect receipt) binds later.', + }, + exclusions: [ + { + id: 'live-api-interactive-suite', + path: path.join(MODULE_DIR, 'tests/api.test.js'), + reason: 'Interactive OAuth suite (Authenticator.oauth2). Not part of the 4a command (jest.receipt.config.js). Always skipped in this job. Not a 4b path.', + }, + { + id: 'live-auther-suite', + path: path.join(MODULE_DIR, 'tests/auther.test.js'), + reason: "describe.skip('HubSpot Module Live Tests') and the mocked testAutherDefinition suite (needs MongoMemoryServer). Excluded from the 4a command.", + }, + { + id: 'webhook-signature', + path: path.join(MODULE_DIR, 'tests/extensions/webhooks.test.js'), + reason: 'Webhook signature verification is not a Binding C item-3 auth path (token exchange / refresh / request header). Excluded from the 4a command.', + }, + { + id: 'basic-auth-token-exchange', + reason: 'OAuth2Requester.getTokenFromCodeBasicAuthHeader is not used by the HubSpot module (exchange is form-encoded client_id/client_secret in the body). Not exercised.', + }, + { + id: 'item-4b-authorized-live', + reason: 'An authorized live run is Binding C item 4b, Sean-held, and out of scope. This receipt must never be relabelled authorized or live (T-C4-2, T-C4-5).', + }, + { + id: 'uncovered-api-surface', + reason: '4a synthetic tests cover getToken, refreshAccessToken, addAuthHeaders, testAuthRequest/getUserDetails, and listContacts. Other Api methods are not claimed.', + }, + { + id: 'hapikey-query-auth', + path: path.join(MODULE_DIR, 'api.js'), + reason: 'HubSpot hapikey / this.api_key query-auth at api.js L450–L451 and L521–L522 is a supported auth path not exercised in this 4a receipt. Excluded per erratum rev2 §5.2 (A-5).', + }, + ], + noSecrets: true, + gqr: { + note: 'T-C4-7: 4a alone does not unblock FC-C / G-QR. Deployer (Binding C item 6) is still missing.', + statusIfDeployerMissing: 'OPEN', + }, + }; + + rejectUnauthorizedAuthorized(receipt); + assertNoSecrets('receipt', receipt); + const serialized = `${JSON.stringify(receipt, null, 2)}\n`; + assertNoSecrets('receipt-text', serialized); + return { receipt, serialized }; +} + +function ciArtifactRef(outPath) { + const repo = process.env.GITHUB_REPOSITORY; + const runId = process.env.GITHUB_RUN_ID; + const server = process.env.GITHUB_SERVER_URL || 'https://github.com'; + const rel = path.relative(REPO_ROOT, path.resolve(outPath)).split(path.sep).join('/'); + if (repo && runId) { + return { + kind: 'github-actions', + artifactName: 'hubspot-auth-api-receipt', + path: rel, + classificationPath: path.posix.join(path.posix.dirname(rel), 'classification'), + runUrl: `${server}/${repo}/actions/runs/${runId}`, + runId, + sha: process.env.GITHUB_SHA || null, + }; + } + return { + kind: 'local', + path: rel, + classificationPath: path.posix.join(path.posix.dirname(rel), 'classification'), + note: 'Local receipt run. GitHub Actions artifact "hubspot-auth-api-receipt" plus the run URL is the CI artifact reference once the job runs on GitHub.', + }; +} + +function writeStepSummary(receipt) { + const summaryFile = process.env.GITHUB_STEP_SUMMARY; + if (!summaryFile) return; + const counts = receipt.result.counts; + const md = [ + '## HubSpot auth/API test receipt (Binding C item 4a, rev1)', + '', + `- classification: \`${receipt.classification}\` (sha256 ${receipt.classificationSha256})`, + `- result: **${receipt.result.status}** (passed ${counts.passed}, failed ${counts.failed}, skipped ${counts.skipped}, total ${counts.total})`, + `- auth mode: ${receipt.authMode.type} ${receipt.authMode.flow}`, + `- exercised: exchange=${receipt.result.exercisedAuthPaths.tokenExchange} refresh=${receipt.result.exercisedAuthPaths.refresh} header=${receipt.result.exercisedAuthPaths.headerSigning} negative=${receipt.result.exercisedAuthPaths.negative}`, + `- source commit: \`${receipt.source.sourceCommitSha}\``, + `- reviewer: ${receipt.reviewer.binding}`, + '', + 'This receipt is synthetic only. It is not a live or authorized run. 4a does not unblock G-QR (deployer still missing).', + '', + '```json', + JSON.stringify(receipt, null, 2), + '```', + '', + ].join('\n'); + fs.appendFileSync(summaryFile, md); +} + +function main() { + const args = parseArgs(process.argv); + if (args.assertOfflineEnv) { + assertNoProviderEnv(); + console.log('T-C4-8: no provider token env present'); + return; + } + if (!args.jestJson || !args.out) { + console.error('Usage: hubspot-auth-api-receipt.cjs --jest-json --out [--command ] [--classification synthetic|recorded] [--expect-classification ]'); + process.exit(2); + } + if (!fs.existsSync(args.jestJson)) { + console.error(`jest json not found: ${args.jestJson}`); + process.exit(2); + } + const command = args.command || process.env.AML_RECEIPT_COMMAND || '(unspecified)'; + const { receipt, serialized } = buildReceipt({ + jestJson: args.jestJson, + out: args.out, + command, + classification: args.classification, + expectClassification: args.expectClassification, + moduleRefBranch: args.moduleRefBranch, + moduleRefSha: args.moduleRefSha, + }); + const outAbs = path.resolve(args.out); + fs.mkdirSync(path.dirname(outAbs), { recursive: true }); + fs.writeFileSync(outAbs, serialized); + const classAbs = path.join(path.dirname(outAbs), 'classification'); + // Exact classification string bytes, no trailing newline, for packet byte-equality. + fs.writeFileSync(classAbs, Buffer.from(receipt.classification, 'utf8')); + writeStepSummary(receipt); + console.log(`wrote ${path.relative(REPO_ROOT, outAbs) || outAbs}`); + console.log(`wrote classification bytes (${receipt.classification}) sha256=${receipt.classificationSha256}`); + console.log(JSON.stringify({ + schema: receipt.schema, + bindingCItem: receipt.bindingCItem, + classification: receipt.classification, + result: receipt.result.status, + counts: receipt.result.counts, + exercisedAuthPaths: receipt.result.exercisedAuthPaths, + sourceCommitSha: receipt.source.sourceCommitSha, + }, null, 2)); +} + +if (require.main === module) { + try { + main(); + } catch (err) { + console.error(err && err.stack ? err.stack : err); + process.exit(1); + } +} + +module.exports = { + ENUM, + PINNED_API_JS_SHA256, + assertApiJsDigest, + BOUND_MODULE_REFS, + resolveModuleRef, + CLASS_4A, + FRESHNESS_MAX_AGE_DAYS, + DENIED_PROVIDER_HOSTS, + TOKEN_ENV_NAMES, + forbiddenEnvHits, + assertNoProviderEnv, + assertClassificationEnum, + assertPacketByteEqual, + rejectUnauthorizedAuthorized, + evaluate42mattersLiveApiAlone, + evaluateGqr, + evaluate4bMissingOnly4aFields, + isStaleRecorded, + validateFreshnessAndOrigin, + assertNoSecrets, + buildReceipt, + applyRedaction, + REV2, +}; diff --git a/scripts/ci/hubspot-auth-api-receipt.test.cjs b/scripts/ci/hubspot-auth-api-receipt.test.cjs new file mode 100644 index 00000000..60fe1241 --- /dev/null +++ b/scripts/ci/hubspot-auth-api-receipt.test.cjs @@ -0,0 +1,500 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { + assertClassificationEnum, + assertPacketByteEqual, + rejectUnauthorizedAuthorized, + evaluate42mattersLiveApiAlone, + evaluateGqr, + evaluate4bMissingOnly4aFields, + isStaleRecorded, + assertNoSecrets, + forbiddenEnvHits, + applyRedaction, + ENUM, + PINNED_API_JS_SHA256, + assertApiJsDigest, + BOUND_MODULE_REFS, + resolveModuleRef, +} = require('./hubspot-auth-api-receipt.cjs'); +const { TRANSFORM_ID } = require('./redaction-transform.v1.cjs'); +const policy = require('./hubspot-4a-policy.cjs'); + +const REPO_ROOT = path.resolve(__dirname, '../..'); +const EMITTER = path.join(__dirname, 'hubspot-auth-api-receipt.cjs'); +const WORKFLOW_YML = path.join(REPO_ROOT, '.github/workflows/hubspot-auth-api-receipt.yml'); + +function cleanProviderEnv(base = process.env) { + const env = { ...base }; + for (const key of Object.keys(env)) { + if (policy.isForbiddenEnvKey(key)) delete env[key]; + } + return env; +} + +test('enum is synthetic|recorded|authorized', () => { + assert.deepEqual(ENUM, ['synthetic', 'recorded', 'authorized']); + assertClassificationEnum('synthetic'); + assertClassificationEnum('recorded'); + assertClassificationEnum('authorized'); + assert.throws(() => assertClassificationEnum('live'), /classification must be/); +}); + +test('T-C4-2: same bytes rewritten to authorized are not auto-accepted; packet must byte-equal artifact', () => { + assertPacketByteEqual('synthetic', 'synthetic'); + assert.throws(() => assertPacketByteEqual('authorized', 'synthetic'), /T-C4-2/); + assert.throws(() => assertPacketByteEqual('synthetic\n', 'synthetic'), /T-C4-2/); + const fourA = { classification: 'synthetic' }; + const rewritten = { ...fourA, classification: 'authorized' }; + assert.throws(() => rejectUnauthorizedAuthorized(rewritten), /T-C4-5|T-C4-2|authorized/); +}); + +test('T-C4-9: 90-day bound is adopted; synthetic is not on the clock', () => { + assert.equal(policy.FRESHNESS_MAX_AGE_DAYS, 90); + assert.equal(policy.FRESHNESS_MAX_AGE_DAYS_STATUS, 'adopted'); + assert.equal(policy.isFreshnessBoundInPolicy(90), true); + assert.equal(policy.isFreshnessBoundInPolicy(99999), false); + assert.equal(policy.isFreshnessBoundInPolicy(undefined), false); + // Synthetic fixtures are not on the 90-day clock, even if recordedAt is old. + const ancient = policy.evaluateFreshnessAndPin({ + classification: 'synthetic', + capturedAt: '2020-01-01T00:00:00-05:00', + maxAgeDays: 90, + providerApiVersion: '2026-03', + contract: 'POST /oauth/2026-03/token', + now: new Date('2026-11-09T13:00:00-05:00'), + }); + assert.equal(ancient.ok, true); + assert.equal(ancient.clockApplied, false); +}); + +test('T-C4-9a: stale-but-pinned recorded traffic fails', () => { + const result = policy.evaluateFreshnessAndPin({ + classification: 'recorded', + capturedAt: '2020-01-01T00:00:00-05:00', + maxAgeDays: 90, + providerApiVersion: '2026-03', + contract: 'POST /oauth/2026-03/token', + now: new Date('2026-11-09T13:00:00-05:00'), + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'stale-but-pinned'); + assert.equal(isStaleRecorded('2020-01-01T00:00:00-05:00', 90, new Date('2026-11-09T13:00:00-05:00')), true); + assert.equal(isStaleRecorded('2026-11-08T13:00:00-05:00', 90, new Date('2026-11-09T13:00:00-05:00')), false); +}); + +test('T-C4-9b: fresh-but-unpinned recorded traffic fails', () => { + const result = policy.evaluateFreshnessAndPin({ + classification: 'recorded', + capturedAt: '2026-11-08T13:00:00-05:00', + maxAgeDays: 90, + providerApiVersion: '2026-03', + contract: 'POST /oauth/unpinned-endpoint', + now: new Date('2026-11-09T13:00:00-05:00'), + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'fresh-but-unpinned'); +}); + +test('T-C4-9c: missing or non-90 bound fails recorded traffic', () => { + const now = new Date('2026-11-09T13:00:00-05:00'); + const base = { + classification: 'recorded', + capturedAt: '2026-11-08T13:00:00-05:00', + providerApiVersion: '2026-03', + contract: 'POST /oauth/2026-03/token', + now, + }; + assert.equal(policy.evaluateFreshnessAndPin({ ...base, maxAgeDays: null }).reason, 'missing-or-non-90-bound'); + assert.equal(policy.evaluateFreshnessAndPin({ ...base, maxAgeDays: undefined }).reason, 'missing-or-non-90-bound'); + assert.equal(policy.evaluateFreshnessAndPin({ ...base, maxAgeDays: 30 }).reason, 'missing-or-non-90-bound'); + assert.equal(policy.evaluateFreshnessAndPin({ ...base, maxAgeDays: 99999 }).reason, 'missing-or-non-90-bound'); + assert.equal(isStaleRecorded('2026-11-08T13:00:00-05:00', null), true); +}); + +test('T-C4-9d: API-version mismatch fails even when fresh and bound is 90', () => { + const result = policy.evaluateFreshnessAndPin({ + classification: 'recorded', + capturedAt: '2026-11-08T13:00:00-05:00', + maxAgeDays: 90, + providerApiVersion: 'v1', + contract: 'POST /oauth/2026-03/token', + now: new Date('2026-11-09T13:00:00-05:00'), + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'api-version-mismatch'); +}); + +test('T-C4-4: secret-shaped values are rejected; redaction binds transform id', () => { + assert.throws( + () => assertNoSecrets('fx', { access_token: 'super-secret-live-token-value' }), + /T-C4-4/ + ); + assert.throws( + () => assertNoSecrets('fx', { token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.abc' }), + /T-C4-4/ + ); + assert.doesNotThrow(() => assertNoSecrets('fx', { access_token: 'synthetic-access-token' })); + assert.doesNotThrow(() => assertNoSecrets('fx', { access_token: '[REDACTED:access_token]' })); + + const raw = { + access_token: 'real-looking-access-token-value', + refresh_token: 'real-looking-refresh-token-value', + client_secret: 'real-client-secret', + token_type: 'bearer', + expires_in: 1800, + }; + const { redacted, replacements, transformId } = applyRedaction(raw); + assert.equal(transformId, TRANSFORM_ID); + assert.equal(redacted.access_token, '[REDACTED:access_token]'); + assert.equal(redacted.refresh_token, '[REDACTED:refresh_token]'); + assert.equal(redacted.client_secret, '[REDACTED:client_secret]'); + assert.equal(redacted.token_type, 'bearer'); + assert.equal(redacted.expires_in, 1800); + assert.ok(replacements.length >= 3); + assert.doesNotThrow(() => assertNoSecrets('redacted', redacted)); +}); + +test('T-C4-5: authorized without Sean-held evidence is unresolved', () => { + assert.throws( + () => rejectUnauthorizedAuthorized({ classification: 'authorized' }), + /T-C4-5/ + ); + assert.equal( + rejectUnauthorizedAuthorized({ + classification: 'authorized', + liveCiSubject: { runUrl: 'https://example.invalid/run/1' }, + seanHeldTokenEvidence: { placedBy: 'Sean', presentInCiSecrets: true, valueOmitted: true }, + }), + 'resolves' + ); + assert.equal(rejectUnauthorizedAuthorized({ classification: 'synthetic' }), 'n/a'); +}); + +test('T-C4-6: 42matters live api.test.js alone is not item 4', () => { + assert.equal( + evaluate42mattersLiveApiAlone({ onlyLiveApiTestJs: true, has4a: false, has4b: false }), + 'unresolved-not-item-4' + ); + assert.equal( + evaluate42mattersLiveApiAlone({ onlyLiveApiTestJs: true, has4a: true, has4b: false }), + 'ok' + ); +}); + +test('T-C4-7: 4a present + deployer missing keeps G-QR OPEN', () => { + assert.equal(evaluateGqr({ has4a: true, hasDeployer: false }), 'OPEN'); +}); + +test('T-C4-8: closed env list; set-but-empty counts as present; GITHUB_TOKEN is not forbidden', () => { + assert.deepEqual(policy.TOKEN_ENV_NAMES, [ + 'HUBSPOT_CLIENT_ID', + 'HUBSPOT_CLIENT_SECRET', + 'HUBSPOT_SCOPE', + 'HUBSPOT_TIMELINE_EVENT_TEMPLATE_ID', + 'MATTERS_ACCESS_TOKEN', + ]); + assert.deepEqual( + forbiddenEnvHits({ HUBSPOT_ACCESS_TOKEN: 'x', HUBSPOT_SCOPE: 'oauth', GITHUB_TOKEN: 'g', FOO: '1' }).sort(), + ['HUBSPOT_ACCESS_TOKEN', 'HUBSPOT_SCOPE'] + ); + assert.deepEqual(forbiddenEnvHits({ MATTERS_ACCESS_TOKEN: 'x' }), ['MATTERS_ACCESS_TOKEN']); + assert.deepEqual(forbiddenEnvHits({ HUBSPOT_CLIENT_ID: 'x' }), ['HUBSPOT_CLIENT_ID']); + assert.deepEqual(forbiddenEnvHits({ HUBSPOT_LIVE_API: '1' }), ['HUBSPOT_LIVE_API']); + // Set-but-empty is present (rev2). + assert.deepEqual(forbiddenEnvHits({ HUBSPOT_LIVE_API: '' }), ['HUBSPOT_LIVE_API']); + assert.deepEqual(forbiddenEnvHits({ HUBSPOT_CLIENT_SECRET: '' }), ['HUBSPOT_CLIENT_SECRET']); + assert.deepEqual(forbiddenEnvHits({ GITHUB_TOKEN: 'g', GH_TOKEN: 'g', FRONT_CLIENT_SECRET: 'x', REDIRECT_URI: 'https://example.com' }), []); + // The CI bug: HUBSPOT_RECEIPT_COMMAND matches the HUBSPOT_ prefix. + assert.equal(policy.isForbiddenEnvKey('HUBSPOT_RECEIPT_COMMAND'), true); + assert.deepEqual( + forbiddenEnvHits({ HUBSPOT_RECEIPT_COMMAND: policy.WORKFLOW_RECEIPT_COMMAND }), + ['HUBSPOT_RECEIPT_COMMAND'] + ); + assert.deepEqual(forbiddenEnvHits({ AML_RECEIPT_COMMAND: policy.WORKFLOW_RECEIPT_COMMAND }), []); +}); + +test('T-C4-10: 4b missing only 4a fixture fields still resolves when §3+§5 hold', () => { + assert.equal( + evaluate4bMissingOnly4aFields({ + classification: 'authorized', + liveCiSubject: { runUrl: 'https://example.invalid/run/1' }, + seanHeldTokenEvidence: { placedBy: 'Sean' }, + }), + 'resolves' + ); + assert.equal( + evaluate4bMissingOnly4aFields({ classification: 'authorized' }), + 'unresolved' + ); +}); + +test('T-C4-11: outbound shape matcher is not always-true; required body fields and no Authorization', () => { + const good = { + method: 'POST', + url: policy.TOKEN_ENDPOINT, + body: { + grant_type: 'authorization_code', + client_id: 'id', + client_secret: 'secret', + redirect_uri: 'https://example.com/redirect/hubspot', + scope: 'oauth', + code: 'c', + }, + }; + assert.equal(policy.matchesTokenExchange(good), true); + assert.equal(policy.matchesTokenExchange({ ...good, method: 'GET' }), false); + assert.equal(policy.matchesTokenExchange({ ...good, url: 'https://api.hubapi.com/oauth/v1/token' }), false); + assert.equal( + policy.matchesTokenExchange({ + ...good, + body: { ...good.body, grant_type: 'client_credentials' }, + }), + false + ); + assert.equal( + policy.matchesTokenExchange({ + ...good, + body: { grant_type: 'authorization_code', code: 'c' }, + }), + false + ); + assert.equal( + policy.matchesTokenExchange({ + ...good, + headers: { Authorization: 'Basic abc' }, + }), + false + ); + const goodRefresh = { + method: 'POST', + url: policy.TOKEN_ENDPOINT, + body: { + grant_type: 'refresh_token', + client_id: 'id', + client_secret: 'secret', + redirect_uri: 'https://example.com/redirect/hubspot', + refresh_token: 'synthetic-refresh-token', + }, + }; + assert.equal(policy.matchesTokenRefresh(goodRefresh), true); + assert.equal( + policy.matchesTokenRefresh({ + ...goodRefresh, + headers: { Authorization: 'Basic abc' }, + }), + false + ); + assert.equal(policy.matchesBearerAuthorization('Bearer x'), true); + assert.equal(policy.matchesBearerAuthorization('Basic x'), false); +}); + +test('T-C4-8: denied host list is the two module hosts; closed token-env prefixes are pinned', () => { + assert.deepEqual(policy.DENIED_PROVIDER_HOSTS, [ + 'api.hubapi.com', + 'app.hubspot.com', + ]); + assert.equal(policy.isDeniedProviderHost('api.hubapi.com'), true); + assert.equal(policy.isDeniedProviderHost('app.hubspot.com'), true); + assert.equal(policy.isDeniedProviderHost('api.hubspot.com'), false); + assert.equal(policy.isDeniedProviderHost('example.com'), false); + assert.ok(policy.TOKEN_ENV_NAMES.includes('HUBSPOT_CLIENT_SECRET')); + assert.ok(policy.TOKEN_ENV_NAMES.includes('HUBSPOT_TIMELINE_EVENT_TEMPLATE_ID')); + assert.ok(policy.TOKEN_ENV_NAMES.includes('MATTERS_ACCESS_TOKEN')); + assert.ok(policy.TOKEN_ENV_PREFIXES.includes('HUBSPOT_')); + assert.ok(policy.TOKEN_ENV_PREFIXES.includes('MATTERS_')); +}); + +test('T-C4-12: deny and spy must both be present and distinct; a 200 fake is not a deny', () => { + assert.deepEqual(policy.evaluateDenyAndSpy({ denyInstalled: true, spyInstalled: true }), { + ok: true, + reason: null, + }); + assert.equal(policy.evaluateDenyAndSpy({ denyInstalled: true, spyInstalled: false }).reason, 'deny-only'); + assert.equal(policy.evaluateDenyAndSpy({ denyInstalled: false, spyInstalled: true }).reason, 'spy-only'); + assert.equal( + policy.evaluateDenyAndSpy({ denyInstalled: true, spyInstalled: true, treatsAlways200AsDeny: true }).reason, + 'always-200-is-not-a-deny' + ); + const spy = policy.createPinnedHostSpy(); + assert.equal(spy.kind, 'spy'); + assert.equal(spy.mechanism, 'nock.emitter:no-match'); + assert.notEqual(spy.mechanism, 'nock.disableNetConnect'); +}); + +test('T-C4-8 regression: workflow must not pass the command via a HUBSPOT_* env var', () => { + const yml = fs.readFileSync(WORKFLOW_YML, 'utf8'); + assert.equal(yml.includes('HUBSPOT_RECEIPT_COMMAND'), false); + assert.match(yml, /AML_RECEIPT_COMMAND:/); + assert.match(yml, /--command "\$\{AML_RECEIPT_COMMAND\}"/); + assert.equal(yml.includes(policy.WORKFLOW_RECEIPT_COMMAND), true); + // Upload only runs when both receipt files exist; it must not claim + // success for a missing artifact (CI run 42673233531 follow-up). + assert.match( + yml, + /hashFiles\('artifacts\/hubspot-auth-api-receipt\.json'\) != '' && hashFiles\('artifacts\/classification'\) != ''/ + ); +}); + +test('T-C4-8 regression: emitter invoked exactly as the workflow does (same env/args) passes the env gate; HUBSPOT_RECEIPT_COMMAND fails', () => { + const workflowCommand = policy.WORKFLOW_RECEIPT_COMMAND; + const workflowEnv = cleanProviderEnv(); + workflowEnv.AML_RECEIPT_COMMAND = workflowCommand; + // First workflow step: --assert-offline-env, no HUBSPOT_* present. + const offline = spawnSync(process.execPath, [EMITTER, '--assert-offline-env'], { + env: workflowEnv, + encoding: 'utf8', + cwd: REPO_ROOT, + }); + assert.equal(offline.status, 0, offline.stderr || offline.stdout); + + // The broken invocation from CI run 42673233531. + const brokenEnv = { ...workflowEnv, HUBSPOT_RECEIPT_COMMAND: workflowCommand }; + const broken = spawnSync(process.execPath, [EMITTER, '--assert-offline-env'], { + env: brokenEnv, + encoding: 'utf8', + cwd: REPO_ROOT, + }); + assert.notEqual(broken.status, 0); + assert.match(`${broken.stderr}\n${broken.stdout}`, /HUBSPOT_RECEIPT_COMMAND/); + + // Write-receipt step: same env (AML_RECEIPT_COMMAND) and the same argv + // the workflow uses. + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hubspot-4a-receipt-')); + const jestJson = path.join(tmp, 'jest-results.json'); + const out = path.join(tmp, 'hubspot-auth-api-receipt.json'); + fs.writeFileSync( + jestJson, + JSON.stringify({ + success: true, + numPassedTests: 4, + numFailedTests: 0, + numPendingTests: 0, + numTotalTests: 4, + testResults: [ + { + name: path.join(REPO_ROOT, 'packages/v1-ready/hubspot/tests/auth-api-synthetic.test.js'), + assertionResults: [ + { status: 'passed', fullName: 'exchanges via getTokenFromCode authorization_code', title: 'getTokenFromCode' }, + { status: 'passed', fullName: 'refreshes via POST refresh_token refreshAccessToken', title: 'refreshAccessToken' }, + { status: 'passed', fullName: 'addAuthHeaders Bearer header / signing', title: 'addAuthHeaders' }, + { status: 'passed', fullName: 'rejects invalid_grant 401 inactive tampered expired', title: 'rejects invalid_grant' }, + ], + }, + ], + }) + ); + const emitArgs = [ + EMITTER, + '--jest-json', + jestJson, + '--out', + out, + '--command', + workflowCommand, + '--classification', + 'synthetic', + '--expect-classification', + 'synthetic', + '--module-ref-branch', + 'next', + '--module-ref-sha', + '9f5e5c627a0be42cd25cf28339142891e71eba07', + ]; + const emitted = spawnSync(process.execPath, emitArgs, { + env: workflowEnv, + encoding: 'utf8', + cwd: REPO_ROOT, + }); + assert.equal(emitted.status, 0, emitted.stderr || emitted.stdout); + assert.equal(fs.existsSync(out), true); + const classificationPath = path.join(tmp, 'classification'); + assert.equal(fs.existsSync(classificationPath), true); + assert.equal(fs.readFileSync(classificationPath, 'utf8'), 'synthetic'); + const receipt = JSON.parse(fs.readFileSync(out, 'utf8')); + assert.equal(receipt.classification, 'synthetic'); + assert.equal(receipt.command || receipt.result && true, receipt.command ? workflowCommand : true); + // command is a top-level or nested field — accept either if present + if (receipt.command) assert.equal(receipt.command, workflowCommand); + // erratum-2: runtime api.js digest is recorded and equals the pin. + assert.equal(receipt.artifact.apiJs.path, 'packages/v1-ready/hubspot/api.js'); + assert.equal(receipt.artifact.apiJs.sha256, 'af0b0bdc937820697a9bf158a6cc4f8a9e4fa22c96c535ee5af994720e4c3c60'); + assert.equal(receipt.artifact.apiJs.pinnedSha256, receipt.artifact.apiJs.sha256); + // erratum-2 R1: moduleRef from the closed map, distinct from sourceCommitSha. + assert.deepEqual( + { branch: receipt.moduleRef.branch, sha: receipt.moduleRef.sha, table: receipt.moduleRef.table }, + { branch: 'next', sha: '9f5e5c627a0be42cd25cf28339142891e71eba07', table: 'NEXT' } + ); + assert.equal(receipt.moduleRef.apiJsSha256, receipt.artifact.apiJs.sha256); + + // Same argv without --module-ref-* fails closed. + const noRefArgs = emitArgs.slice(0, emitArgs.indexOf('--module-ref-branch')); + const noRef = spawnSync(process.execPath, noRefArgs, { env: workflowEnv, encoding: 'utf8', cwd: REPO_ROOT }); + assert.notEqual(noRef.status, 0); + assert.match(`${noRef.stderr}\n${noRef.stdout}`, /UNKNOWN_MODULE_REF|moduleRef/); + // Unlisted ref (the harness commit itself) fails closed. + const unlistedArgs = [...noRefArgs, '--module-ref-branch', 'next', '--module-ref-sha', '4c10075740d634f969316e944a6f60abbcf4ffa1']; + const unlisted = spawnSync(process.execPath, unlistedArgs, { env: workflowEnv, encoding: 'utf8', cwd: REPO_ROOT }); + assert.notEqual(unlisted.status, 0); + assert.match(`${unlisted.stderr}\n${unlisted.stdout}`, /unknown moduleRef/); + + // Same argv but with the old HUBSPOT_RECEIPT_COMMAND env must still fail + // because buildReceipt calls assertNoProviderEnv(). + const emittedBroken = spawnSync(process.execPath, emitArgs, { + env: brokenEnv, + encoding: 'utf8', + cwd: REPO_ROOT, + }); + assert.notEqual(emittedBroken.status, 0); + assert.match(`${emittedBroken.stderr}\n${emittedBroken.stdout}`, /HUBSPOT_RECEIPT_COMMAND/); +}); + +test('erratum-2: api.js pin is af0b0bdc…; a mismatched runtime digest fails 4a', () => { + assert.equal(PINNED_API_JS_SHA256, 'af0b0bdc937820697a9bf158a6cc4f8a9e4fa22c96c535ee5af994720e4c3c60'); + assert.doesNotThrow(() => assertApiJsDigest(PINNED_API_JS_SHA256)); + assert.throws(() => assertApiJsDigest('0'.repeat(64)), (err) => err.code === 'E2-APIJS'); + assert.throws(() => assertApiJsDigest(undefined), (err) => err.code === 'E2-APIJS'); +}); + +test('erratum-2 R1: closed moduleRef map is exactly V1 main@d50c6a9d and NEXT next@9f5e5c62', () => { + assert.deepEqual( + BOUND_MODULE_REFS.map((r) => `${r.table}:${r.branch}@${r.sha}`), + [ + 'V1:main@d50c6a9d8f6f8264aaf4e6e9447991b4074ec455', + 'NEXT:next@9f5e5c627a0be42cd25cf28339142891e71eba07', + ] + ); + const next = resolveModuleRef({ branch: 'next', sha: '9f5e5c627a0be42cd25cf28339142891e71eba07' }); + assert.equal(next.table, 'NEXT'); + assert.equal(next.apiJsSha256, 'af0b0bdc937820697a9bf158a6cc4f8a9e4fa22c96c535ee5af994720e4c3c60'); + for (const bad of [ + {}, + { branch: 'next' }, + { sha: '9f5e5c627a0be42cd25cf28339142891e71eba07' }, + { branch: 'next', sha: '9f5e5c62' }, + { branch: 'main', sha: '9f5e5c627a0be42cd25cf28339142891e71eba07' }, + { branch: 'next', sha: '4c10075740d634f969316e944a6f60abbcf4ffa1' }, + ]) { + assert.throws(() => resolveModuleRef(bad), (err) => err.code === 'UNKNOWN_MODULE_REF', JSON.stringify(bad)); + } +}); + +test('erratum-2 R1: runtime api.js digest is checked against the moduleRef entry', () => { + const v1 = resolveModuleRef({ branch: 'main', sha: 'd50c6a9d8f6f8264aaf4e6e9447991b4074ec455' }); + // The NEXT api.js on disk does not match the V1 entry, so a V1 claim fails. + assert.throws(() => assertApiJsDigest(PINNED_API_JS_SHA256, v1.apiJsSha256), (err) => err.code === 'E2-APIJS'); + const next = resolveModuleRef({ branch: 'next', sha: '9f5e5c627a0be42cd25cf28339142891e71eba07' }); + assert.doesNotThrow(() => assertApiJsDigest(PINNED_API_JS_SHA256, next.apiJsSha256)); +}); + +test('erratum-2 R1: workflow passes the NEXT moduleRef to the emitter', () => { + const yml = fs.readFileSync(WORKFLOW_YML, 'utf8'); + assert.match(yml, /--module-ref-branch next/); + assert.match(yml, /--module-ref-sha 9f5e5c627a0be42cd25cf28339142891e71eba07/); +}); diff --git a/scripts/ci/redaction-transform.v1.cjs b/scripts/ci/redaction-transform.v1.cjs new file mode 100644 index 00000000..6d31bd1f --- /dev/null +++ b/scripts/ci/redaction-transform.v1.cjs @@ -0,0 +1,93 @@ +/** + * aml.redaction.v1 — Binding C rev1 §4.4 + * + * Replace secret-shaped values with stable placeholders of the form + * [REDACTED:] that preserve JSON types (string) for replay. + * Bind raw sha → this transform (id + file digest) → post-redaction sha. + */ +'use strict'; + +const TRANSFORM_ID = 'aml.redaction.v1'; + +const PLACEHOLDER = (kind) => `[REDACTED:${kind}]`; + +const KEY_KIND = { + access_token: 'access_token', + refresh_token: 'refresh_token', + client_secret: 'client_secret', + client_id: 'client_id', + token: 'token', + authorization: 'authorization', + api_key: 'api_key', + apikey: 'api_key', + password: 'password', + code: 'authorization_code', +}; + +const JWT_RE = /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; +const BEARER_RE = /^Bearer\s+\S+/i; +const ALLOWED = /^(synthetic-[A-Za-z0-9_-]+|test-[A-Za-z0-9_-]+|\[REDACTED:[a-z0-9_]+\])$/; + +function kindForKey(key) { + if (!key) return null; + const k = String(key).toLowerCase().replace(/-/g, '_'); + if (KEY_KIND[k]) return KEY_KIND[k]; + if (k.endsWith('access_token') || k === 'accesstoken') return 'access_token'; + if (k.endsWith('refresh_token')) return 'refresh_token'; + if (k.includes('client_secret')) return 'client_secret'; + if (k.includes('api_key') || k.includes('apikey')) return 'api_key'; + if (k === 'authorization') return 'authorization'; + return null; +} + +function kindForValue(value) { + if (typeof value !== 'string') return null; + if (ALLOWED.test(value) || value.startsWith('[REDACTED:')) return null; + if (JWT_RE.test(value)) return 'jwt'; + if (BEARER_RE.test(value)) return 'authorization'; + return null; +} + +function redactValue(value, key) { + if (typeof value !== 'string') return { value, redacted: false, kind: null }; + if (ALLOWED.test(value) || value.startsWith('[REDACTED:')) { + return { value, redacted: false, kind: null }; + } + const kind = kindForKey(key) || kindForValue(value); + if (!kind) return { value, redacted: false, kind: null }; + return { value: PLACEHOLDER(kind), redacted: true, kind }; +} + +function redactDeep(node, key, acc) { + if (Array.isArray(node)) { + return node.map((item, i) => redactDeep(item, key, acc)); + } + if (node && typeof node === 'object') { + const out = {}; + for (const [k, v] of Object.entries(node)) { + out[k] = redactDeep(v, k, acc); + } + return out; + } + const result = redactValue(node, key); + if (result.redacted) acc.push({ key, kind: result.kind }); + return result.value; +} + +function applyRedaction(obj) { + const replacements = []; + const redacted = redactDeep(obj, null, replacements); + return { redacted, replacements, transformId: TRANSFORM_ID }; +} + +module.exports = { + TRANSFORM_ID, + PLACEHOLDER, + ALLOWED, + JWT_RE, + BEARER_RE, + kindForKey, + kindForValue, + redactValue, + applyRedaction, +};