diff --git a/scripts/lib/source-id.mjs b/scripts/lib/source-id.mjs index 84c1fdeda..2fed50f56 100644 --- a/scripts/lib/source-id.mjs +++ b/scripts/lib/source-id.mjs @@ -1,14 +1,32 @@ +const compactCredentialLabelPattern = /(^|[^a-z0-9])(clientsecret|sessiontoken|authtoken|accesstoken|refreshtoken|accesskeyid|secretaccesskey|signingkey)([^a-z0-9]|$)/i; +const credentialHeaderAssignmentPattern = /(^|[^a-z0-9])(authorization|bearer|credential|sig|signature|pwd|passphrase)\s*[:=]/i; +const githubCredentialTokenPattern = /(^|[^a-z0-9])(github_pat_|gh[pousr]_)/i; +const npmCredentialTokenPattern = /(^|[^a-z0-9])npm_[a-z0-9]{36}([^a-z0-9]|$)/i; +const unsafeIdentityCodePointPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u; +const nonCanonicalSpacePattern = /[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/u; +const percentEncodedIdentityPattern = /%[0-9A-Fa-f]{2}/; +const nonHierarchicalLocatorSchemePattern = /^(?:about|data|javascript|mailto|sms|tel|vbscript):/i; + export function hasUnsafeSourceId(value) { const rawSourceId = String(value ?? ""); const sourceId = rawSourceId.trim(); const normalized = sourceId.toLowerCase(); + const camelSeparatedSourceId = sourceId.replace(/([a-z0-9])([A-Z])/g, "$1_$2"); return rawSourceId !== sourceId + || sourceId !== sourceId.normalize("NFKC") + || unsafeIdentityCodePointPattern.test(sourceId) + || nonCanonicalSpacePattern.test(sourceId) + || percentEncodedIdentityPattern.test(sourceId) || normalized === "placeholder" || normalized === "todo" || normalized === "tbd" || normalized.startsWith("replace-with-") - || /https?:\/\//i.test(sourceId) + || sourceId.includes("://") + || nonHierarchicalLocatorSchemePattern.test(sourceId) || sourceId.includes("?") - || /(^|[^a-z0-9])(github_pat_|gh[pousr]_)/i.test(sourceId) - || /(^|[^a-z0-9])(token|secret|api[_-]?key|access[_-]?key|private[_-]?key)([^a-z0-9]|$)/i.test(sourceId); + || githubCredentialTokenPattern.test(sourceId) + || npmCredentialTokenPattern.test(sourceId) + || /(^|[^a-z0-9])(token|secret|password|passwd|api[_-]?key|access[_-]?key|private[_-]?key)([^a-z0-9]|$)/i.test(camelSeparatedSourceId) + || compactCredentialLabelPattern.test(sourceId) + || credentialHeaderAssignmentPattern.test(sourceId); } diff --git a/test/source-id-canonical-whitespace.test.ts b/test/source-id-canonical-whitespace.test.ts index 38b1fcb57..162a82ebe 100644 --- a/test/source-id-canonical-whitespace.test.ts +++ b/test/source-id-canonical-whitespace.test.ts @@ -11,10 +11,33 @@ describe("KPI source-id canonical identity", () => { expect(hasUnsafeSourceId(sourceId)).toBe(true); }); + it.each([ + "cloudflare-logpush:\nnoema-production", + "cloudflare-logpush:\u0000noema-production", + "cloudflare-logpush:\u202Enoema-production", + "cloudflare-logpush:\u2028noema-production", + "cloudflare-logpush:\u2029noema-production", + "cloudflare-logpush:\uD800noema-production", + "cloudflare-logpush:\u00A0noema-production", + "cloudflare-logpush:\u202Fnoema-production", + "cloudflare-logpush:\u3000noema-production", + ])("rejects embedded control, separator, format, surrogate, or non-canonical space characters in source label %j", (sourceId) => { + expect(hasUnsafeSourceId(sourceId)).toBe(true); + }); + + it("rejects a canonically equivalent but non-NFC source label", () => { + expect(hasUnsafeSourceId("cloudflare-logpush:noema-e\u0301")).toBe(true); + }); + + it("rejects a compatibility-equivalent but non-NFKC source label", () => { + expect(hasUnsafeSourceId("cloudflare-logpush:noema-production")).toBe(true); + }); + it.each([ "cloudflare-logpush:noema-production", "github-app:noema-reviewer", "github:repository-noema", + "cloudflare-logpush:noema-é", ])("keeps exact non-secret source label %s", (sourceId) => { expect(hasUnsafeSourceId(sourceId)).toBe(false); }); diff --git a/test/source-id-exact-coverage.test.ts b/test/source-id-exact-coverage.test.ts new file mode 100644 index 000000000..725399972 --- /dev/null +++ b/test/source-id-exact-coverage.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { hasUnsafeSourceId } from "../scripts/lib/source-id.mjs"; + +describe("source-id exact production coverage", () => { + it.each([ + " source", + "source ", + "source", + "source\u0000id", + "source\u202Eid", + "source\uD800id", + "source\u2028id", + "source\u2029id", + "source\u00A0id", + "placeholder", + "todo", + "tbd", + "replace-with-source", + "https://logs.noema.internal/export", + "source?token=redacted", + "ghp_EXAMPLEVALUE123456", + "npm_abcdefghijklmnopqrstuvwxyz0123456789", + "token=EXAMPLEVALUE123456", + "apiKey=EXAMPLEVALUE123456", + "clientSecret=EXAMPLEVALUE123456", + "authorization=Bearer EXAMPLEVALUE123456", + ])("executes a fail-closed source identity branch for %j", (sourceId) => { + expect(hasUnsafeSourceId(sourceId)).toBe(true); + }); + + it.each([ + undefined, + null, + "", + "cloudflare-logpush:noema-production", + "github-app:noema-reviewer", + "npm-package-metadata", + "source-é", + ])("executes a non-secret source identity branch for %j", (sourceId) => { + expect(hasUnsafeSourceId(sourceId)).toBe(false); + }); +}); diff --git a/test/source-id-secret-prefixes.test.ts b/test/source-id-secret-prefixes.test.ts index 8c14788e0..a920c3204 100644 --- a/test/source-id-secret-prefixes.test.ts +++ b/test/source-id-secret-prefixes.test.ts @@ -9,7 +9,55 @@ describe("KPI source-id credential prefix safety", () => { "github:ghu_EXAMPLEVALUE123456", "github:ghs_EXAMPLEVALUE123456", "github:ghr_EXAMPLEVALUE123456", - ])("rejects GitHub credential-shaped source label %s", (sourceId) => { + "npm_abcdefghijklmnopqrstuvwxyz0123456789", + ])("rejects strong credential-shaped source label %s", (sourceId) => { + expect(hasUnsafeSourceId(sourceId)).toBe(true); + }); + + it.each([ + "clientSecret=EXAMPLEVALUE123456", + "clientsecret=EXAMPLEVALUE123456", + "sessionToken=EXAMPLEVALUE123456", + "sessiontoken=EXAMPLEVALUE123456", + "accessKeyId=AKIAEXAMPLEVALUE", + "accesskeyid=AKIAEXAMPLEVALUE", + "refreshToken=EXAMPLEVALUE123456", + "refreshtoken=EXAMPLEVALUE123456", + "secretAccessKey=EXAMPLEVALUE123456", + "secretaccesskey=EXAMPLEVALUE123456", + "password=EXAMPLEVALUE123456", + "passwd=EXAMPLEVALUE123456", + "pwd=EXAMPLEVALUE123456", + "passphrase=EXAMPLEVALUE123456", + "authorization=Bearer EXAMPLEVALUE123456", + "bearer=EXAMPLEVALUE123456", + "credential=EXAMPLEVALUE123456", + "signature=EXAMPLEVALUE123456", + "sig=EXAMPLEVALUE123456", + ])("rejects compact or credential-header-shaped source label %s", (sourceId) => { + expect(hasUnsafeSourceId(sourceId)).toBe(true); + }); + + it.each([ + "%67hp_EXAMPLEVALUE123456", + "pwd%3DEXAMPLEVALUE123456", + "passphrase%253DEXAMPLEVALUE123456", + "https%3A%2F%2Flogs.example.invalid%2Fnoema", + ])("rejects percent-encoded ambiguous source authority %s", (sourceId) => { + expect(hasUnsafeSourceId(sourceId)).toBe(true); + }); + + it.each([ + "s3://noema-production-kpi", + "ftp://logs.example.invalid/noema", + "file:///var/log/noema.ndjson", + "mailto:operator@example.com", + "data:text/plain,production-log", + "tel:+12025550123", + "javascript:alert(1)", + "vbscript:msgbox(1)", + "about:blank", + ])("rejects locator-shaped source label %s", (sourceId) => { expect(hasUnsafeSourceId(sourceId)).toBe(true); }); @@ -18,6 +66,12 @@ describe("KPI source-id credential prefix safety", () => { "github-app:noema-reviewer", "github:repository-noema", "ghp-metrics", + "npm-package-metadata", + "credential-registry-production", + "signature-verifier-production", + "passwordless-auth-production", + "passphrase-policy-production", + "success-rate-95%", ])("keeps descriptive non-secret source label %s", (sourceId) => { expect(hasUnsafeSourceId(sourceId)).toBe(false); }); diff --git a/vitest.config.ts b/vitest.config.ts index cd7b1a4fa..80468ee8f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ "scripts/prepare-agent-pr-message.mjs", "scripts/verify-orchestrator-gateway.mjs", "scripts/lib/orchestrator-gateway.mjs", + "scripts/lib/source-id.mjs", "scripts/workflow-registry-audit.mjs", "scripts/workflow-registry-disable-plan.mjs", "scripts/workflow-registry-live-disable.mjs",