diff --git a/content/admin/administering-your-instance/administering-your-instance-from-the-web-ui/managing-search-indices-for-your-instance.md b/content/admin/administering-your-instance/administering-your-instance-from-the-web-ui/managing-search-indices-for-your-instance.md index 21644d74fbae..cf64c0f6be11 100644 --- a/content/admin/administering-your-instance/administering-your-instance-from-the-web-ui/managing-search-indices-for-your-instance.md +++ b/content/admin/administering-your-instance/administering-your-instance-from-the-web-ui/managing-search-indices-for-your-instance.md @@ -21,7 +21,7 @@ For more information about search for {% data variables.product.prodname_ghe_ser {% data variables.product.prodname_ghe_server %} reconciles the state of the search index with data on the instance automatically and regularly, including: -* Issues, pull requests, repositories, and users in the database +* Issues,{% ifversion ghes > 3.17 %} projects,{% endif %} pull requests, repositories, and users in the database * Git repositories (source code) on disk In normal use, enterprise owners do not need to create new indices or schedule repair jobs. For troubleshooting or other support purposes, {% data variables.contact.github_support %} may instruct you to run a repair job. diff --git a/content/code-security/secret-scanning/introduction/supported-secret-scanning-patterns.md b/content/code-security/secret-scanning/introduction/supported-secret-scanning-patterns.md index 3e5a7e216abf..1aa6912aa75f 100644 --- a/content/code-security/secret-scanning/introduction/supported-secret-scanning-patterns.md +++ b/content/code-security/secret-scanning/introduction/supported-secret-scanning-patterns.md @@ -101,6 +101,18 @@ In addition to these generic non-provider patterns, {% data variables.product.pr Service providers update the patterns used to generate tokens periodically and may support more than one version of a token. Push protection only supports the most recent token versions that {% data variables.product.prodname_secret_scanning %} can identify with confidence. This avoids push protection blocking commits unnecessarily when a result may be a false positive, which is more likely to happen with legacy tokens. +#### Multi-part secrets + + + +By default, {% data variables.product.prodname_secret_scanning %} supports validation for pair-matched access keys and key IDs. + +{% data variables.product.prodname_secret_scanning_caps %} also supports validation for individual key IDs for Amazon AWS Access Key IDs, in addition to existing pair matching. + +A key ID will show as active if {% data variables.product.prodname_secret_scanning %} confirms the key ID exists, regardless of whether or not a corresponding access key is found. The key ID will show as `inactive` if it's invalid (for example, if it is not a real key ID). + +Where a valid pair is found, the {% data variables.product.prodname_secret_scanning %} alerts will be linked. + ## Further reading * [AUTOTITLE](/code-security/secret-scanning/managing-alerts-from-secret-scanning/about-alerts) diff --git a/data/code-languages.yml b/data/code-languages.yml index e6b3e17cf852..f40c63a8cee5 100644 --- a/data/code-languages.yml +++ b/data/code-languages.yml @@ -7,6 +7,9 @@ bash: bicep: name: Bicep comment: slash +copilot: + name: Copilot Chat prompt + comment: none csharp: name: C# comment: slash diff --git a/package-lock.json b/package-lock.json index d9e40018bdcc..c7d05b29c4ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,6 +96,7 @@ "tcp-port-used": "1.0.2", "tsx": "^4.19.4", "unified": "^11.0.5", + "unist-util-find": "^3.0.0", "unist-util-visit": "^5.0.0", "url-template": "^3.1.1", "walk-sync": "^4.0.1" @@ -10563,6 +10564,11 @@ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" }, + "node_modules/lodash.iteratee": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.iteratee/-/lodash.iteratee-4.7.0.tgz", + "integrity": "sha512-yv3cSQZmfpbIKo4Yo45B1taEvxjNvcpF1CEOc0Y6dEyvhPIfEJE3twDwPgWTPQubcSgXyBwBKG6wpQvWMDOf6Q==" + }, "node_modules/lodash.kebabcase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", @@ -15653,6 +15659,20 @@ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.0.tgz", "integrity": "sha512-MFETx3tbTjE7Uk6vvnWINA/1iJ7LuMdO4fcq8UfF0pRbj01aGLduVvQcRyswuACJdpnHgg8E3rQLhaRdNEJS0w==" }, + "node_modules/unist-util-find": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find/-/unist-util-find-3.0.0.tgz", + "integrity": "sha512-T7ZqS7immLjYyC4FCp2hDo3ksZ1v+qcbb+e5+iWxc2jONgHOLXPCpms1L8VV4hVxCXgWTxmBHDztuEZFVwC+Gg==", + "dependencies": { + "@types/unist": "^3.0.0", + "lodash.iteratee": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-find-after": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", @@ -15683,6 +15703,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-find/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/unist-util-is": { "version": "5.1.1", "license": "MIT", diff --git a/package.json b/package.json index b83ea5ae07c4..b5c18f05d11c 100644 --- a/package.json +++ b/package.json @@ -243,6 +243,7 @@ "tcp-port-used": "1.0.2", "tsx": "^4.19.4", "unified": "^11.0.5", + "unist-util-find": "^3.0.0", "unist-util-visit": "^5.0.0", "url-template": "^3.1.1", "walk-sync": "^4.0.1" diff --git a/src/content-render/liquid/engine.js b/src/content-render/liquid/engine.js index 50a03f701ae6..e2d5d60c7c9e 100644 --- a/src/content-render/liquid/engine.js +++ b/src/content-render/liquid/engine.js @@ -6,6 +6,7 @@ import Octicon from './octicon' import Ifversion from './ifversion' import { Tool, tags as toolTags } from './tool' import { Spotlight, tags as spotlightTags } from './spotlight' +import { Prompt } from './prompt' export const engine = new Liquid({ extname: '.html', @@ -25,6 +26,8 @@ for (const tag in spotlightTags) { engine.registerTag(tag, Spotlight) } +engine.registerTag('prompt', Prompt) + /** * Like the `size` filter, but specifically for * getting the number of keys in an object diff --git a/src/content-render/liquid/prompt.js b/src/content-render/liquid/prompt.js new file mode 100644 index 000000000000..cc81688f93be --- /dev/null +++ b/src/content-render/liquid/prompt.js @@ -0,0 +1,31 @@ +// src/content-render/liquid/prompt.js +// Defines {% prompt %}…{% endprompt %} to wrap its content in and append the Copilot icon. + +import octicons from '@primer/octicons' + +export const Prompt = { + type: 'block', + + // Collect everything until {% endprompt %} + parse(tagToken, remainTokens) { + this.templates = [] + const stream = this.liquid.parser.parseStream(remainTokens) + stream + .on('template', (tpl) => this.templates.push(tpl)) + .on('tag:endprompt', () => stream.stop()) + .on('end', () => { + throw new Error(`{% prompt %} tag not closed`) + }) + stream.start() + }, + + // Render the inner Markdown, wrap in , then append the SVG + render: function* (scope) { + const content = yield this.liquid.renderer.renderTemplates(this.templates, scope) + + // build a URL with the prompt text encoded as query parameter + const promptParam = encodeURIComponent(content) + const href = `https://github.com/copilot?prompt=${promptParam}` + return `${content}${octicons.copilot.toSVG()}` + }, +} diff --git a/src/content-render/tests/copilot-code-blocks.js b/src/content-render/tests/copilot-code-blocks.js new file mode 100644 index 000000000000..45dbbbf77905 --- /dev/null +++ b/src/content-render/tests/copilot-code-blocks.js @@ -0,0 +1,203 @@ +import { describe, it, expect, vi } from 'vitest' +import { renderContent } from '@/content-render/index' + +describe('code-header plugin', () => { + describe('copilot language code blocks', () => { + it('should render basic copilot code block without header (no copy meta)', async () => { + const markdown = '```copilot\nImprove the variable names in this function\n```' + + const html = await renderContent(markdown) + + // Should keep copilot as the language (not convert to text without copy meta) + expect(html).toContain('language-copilot') + // Should NOT wrap in code-example div since no copy meta + expect(html).not.toContain('code-example') + // Should NOT have header since no copy meta + expect(html).not.toContain(' { + const markdown = '```copilot copy\nImprove the variable names in this function\n```' + + const html = await renderContent(markdown) + + // Should be wrapped in code-example div + expect(html).toContain('code-example') + // Should have header with copy button + expect(html).toContain(' { + const markdown = '```copilot prompt\nImprove the variable names in this function\n```' + + const html = await renderContent(markdown) + + // Should be wrapped in code-example div + expect(html).toContain('code-example') + // Should have header + expect(html).toContain(' { + const markdown = '```copilot copy prompt\nImprove the variable names in this function\n```' + + const html = await renderContent(markdown) + + // Should be wrapped in code-example div + expect(html).toContain('code-example') + // Should have header with copy button + expect(html).toContain(' { + const markdown = ` +\`\`\`javascript id=js-age +function logPersonsAge(a, b, c) { + if (c) { + console.log(a + " is " + b + " years old."); + } else { + console.log(a + " does not want to reveal their age."); + } +} +\`\`\` + +\`\`\`copilot copy prompt ref=js-age +Improve the variable names in this function +\`\`\` + ` + + const html = await renderContent(markdown) + + // Should have prompt button with both code blocks in URL + expect(html).toContain('https://github.com/copilot?prompt=') + // Should contain encoded content from both the referenced code and the prompt + expect(html).toContain('function%20logPersonsAge') + expect(html).toContain('Improve%20the%20variable%20names') + // Should have different aria-label indicating context + expect(html).toContain('aria-label="Run this prompt with context in Copilot Chat"') + }) + + it('should render copilot code block with prompt and ref only (no copy meta)', async () => { + const markdown = ` +\`\`\`javascript id=js-age +function logPersonsAge(a, b, c) { + if (c) { + console.log(a + " is " + b + " years old."); + } else { + console.log(a + " does not want to reveal their age."); + } +} +\`\`\` + +\`\`\`copilot prompt ref=js-age +Improve the variable names in this function +\`\`\` + ` + + const html = await renderContent(markdown) + + // Should have prompt button with both code blocks in URL + expect(html).toContain('https://github.com/copilot?prompt=') + // Should contain encoded content from both the referenced code and the prompt + expect(html).toContain('function%20logPersonsAge') + expect(html).toContain('Improve%20the%20variable%20names') + // Should have different aria-label indicating context + expect(html).toContain('aria-label="Run this prompt with context in Copilot Chat"') + // Should NOT have copy button + expect(html).not.toContain('js-btn-copy') + }) + }) + + describe('edge cases', () => { + it('should handle missing reference gracefully and fall back to current code only', async () => { + // Mock console.warn to capture warning + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const markdown = + '```copilot copy prompt ref=nonexistent-id\nImprove the variable names in this function\n```' + + const html = await renderContent(markdown) + + // Should warn about missing reference + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Can't find referenced code block with id=nonexistent-id"), + ) + + // Should still render with prompt button using current code only + expect(html).toContain('https://github.com/copilot?prompt=') + expect(html).toContain('Improve%20the%20variable%20names%20in%20this%20function') + // Should NOT contain any referenced code since none was found + expect(html).not.toContain('function%20logPersonsAge') + // Should have standard aria-label (not context version) + expect(html).toContain('aria-label="Run this prompt in Copilot Chat"') + // Should not crash or fail + expect(html).toContain('code-example') + + // Restore console.warn + consoleWarnSpy.mockRestore() + }) + + it('should not process annotated code blocks', async () => { + const markdown = `\`\`\`javascript copy annotate +// This is an annotation +function test() {} +\`\`\`` + + const html = await renderContent(markdown) + + // Should NOT wrap in code-example div (annotated blocks are excluded) + expect(html).not.toContain('code-example') + }) + + it('should handle regular code blocks with copy', async () => { + const markdown = '```javascript copy\nfunction test() {}\n```' + + const html = await renderContent(markdown) + + // Should render with copy button + expect(html).toContain('code-example') + expect(html).toContain('js-btn-copy') + expect(html).toContain('language-javascript') + }) + }) + + describe('URL encoding', () => { + it('should properly encode special characters in prompt URLs', async () => { + const markdown = '```copilot copy prompt\nHow do I handle "quotes" and & symbols?\n```' + + const html = await renderContent(markdown) + + // Should encode quotes and ampersands properly + expect(html).toContain('%22quotes%22') + expect(html).toContain('%26%20symbols') + }) + + it('should handle multiline prompts correctly', async () => { + const markdown = `\`\`\`copilot copy prompt +This is line 1 +This is line 2 +\`\`\`` + + const html = await renderContent(markdown) + + // Should encode newlines properly + expect(html).toContain('This%20is%20line%201%0AThis%20is%20line%202') + }) + }) +}) diff --git a/src/content-render/tests/prompt.js b/src/content-render/tests/prompt.js new file mode 100644 index 000000000000..029a0ef9d486 --- /dev/null +++ b/src/content-render/tests/prompt.js @@ -0,0 +1,11 @@ +import { describe, expect, test } from 'vitest' +import { renderContent } from '@/content-render/index' + +describe('prompt tag', () => { + test('wraps content in and appends svg', async () => { + const input = 'Here is your prompt: {% prompt %}example prompt text{% endprompt %}.' + const output = await renderContent(input) + expect(output).toContain('example prompt text node.type === 'element' && node.tagName === 'pre' && - // For now, limit to ones with the copy meta, + // For now, limit to ones with the copy or prompt meta, // but we may enable for all examples later. - getPreMeta(node).copy && + (getPreMeta(node).copy || getPreMeta(node).prompt) && // Don't add this header for annotated examples. !getPreMeta(node).annotate export default function codeHeader() { return (tree) => { visit(tree, matcher, (node, index, parent) => { - parent.children[index] = wrapCodeExample(node) + parent.children[index] = wrapCodeExample(node, tree) }) } } -function wrapCodeExample(node) { +function wrapCodeExample(node, tree) { const lang = node.children[0].properties.className?.[0].replace('language-', '') const code = node.children[0].children[0].value - return h('div', { className: 'code-example' }, [header(lang, code), node]) + + const subnav = null // getSubnav() lives in annotate.js, not needed for normal code blocks + const prompt = getPrompt(node, tree, code) // returns null if there's no prompt + const hasCopy = Boolean(getPreMeta(node).copy) // defaults to true + + const headerHast = header(lang, code, subnav, prompt, hasCopy) + + return h('div', { className: 'code-example' }, [headerHast, node]) } -export function header(lang, code, subnav) { +export function header(lang, code, subnav = null, prompt = null, hasCopy = true) { const codeId = murmur('js-btn-copy').hash(code).result() + return h( 'header', { @@ -56,15 +66,18 @@ export function header(lang, code, subnav) { [ h('span', { className: 'flex-1' }, languages[lang]?.name), subnav, - h( - 'button', - { - class: ['js-btn-copy', 'btn', 'btn-sm', 'tooltipped', 'tooltipped-nw'], - 'aria-label': `Copy ${languages[lang]?.name} code to clipboard`, - 'data-clipboard': codeId, - }, - btnIcon(), - ), + prompt, + hasCopy + ? h( + 'button', + { + class: ['js-btn-copy', 'btn', 'btn-sm', 'tooltipped', 'tooltipped-nw'], + 'aria-label': `Copy ${languages[lang]?.name} code to clipboard`, + 'data-clipboard': codeId, + }, + btnIcon(), + ) + : null, h('pre', { hidden: true, 'data-clipboard': codeId }, code), ], ) @@ -77,7 +90,7 @@ function btnIcon() { return btnIcon } -function getPreMeta(node) { +export function getPreMeta(node) { // Here's why this monstrosity works: // https://github.com/syntax-tree/mdast-util-to-hast/blob/c87cd606731c88a27dbce4bfeaab913a9589bf83/lib/handlers/code.js#L40-L42 return node.children[0]?.data?.meta || {} diff --git a/src/content-render/unified/copilot-prompt.js b/src/content-render/unified/copilot-prompt.js new file mode 100644 index 000000000000..1d2ca48d67c1 --- /dev/null +++ b/src/content-render/unified/copilot-prompt.js @@ -0,0 +1,75 @@ +/** + * Adds a runnable prompt button in the header of Copilot Chat blocks. + */ + +import { find } from 'unist-util-find' +import { h } from 'hastscript' +import octicons from '@primer/octicons' +import { parse } from 'parse5' +import { fromParse5 } from 'hast-util-from-parse5' +import { getPreMeta } from './code-header' + +export function getPrompt(node, tree, code) { + const hasPrompt = Boolean(getPreMeta(node).prompt) + if (!hasPrompt) return null + + const { promptContent, ariaLabel } = buildPromptData(node, tree, code) + const promptLink = `https://github.com/copilot?prompt=${encodeURIComponent(promptContent.trim())}` + + return h( + 'a', + { + href: promptLink, + target: '_blank', + class: ['btn', 'btn-sm', 'mr-1', 'tooltipped', 'tooltipped-nw', 'no-underline'], + 'aria-label': ariaLabel, + }, + copilotIcon(), + ) +} + +function buildPromptData(node, tree, code) { + // Find a ref meta in the format 'ref=' + const ref = getPreMeta(node).ref + + if (!ref) { + // If no 'ref=' meta is found, use just the current code for the prompt link. + return promptOnly(code) + } + + // If the 'ref=' meta is found, find a matching code block to include as context in the prompt link. + const matchingCodeEl = findMatchingCode(ref, tree) + if (!matchingCodeEl) { + console.warn(`Can't find referenced code block with id=${ref}`) + return promptOnly(code) + } + const matchingCode = matchingCodeEl?.children[0].children[0].value || null + return promptAndContext(code, matchingCode) +} + +function promptOnly(code) { + return { + promptContent: code, + ariaLabel: 'Run this prompt in Copilot Chat', + } +} + +function promptAndContext(code, matchingCode) { + return { + promptContent: `${matchingCode}\n${code}`, + ariaLabel: 'Run this prompt with context in Copilot Chat', + } +} + +function findMatchingCode(ref, tree) { + return find(tree, (node) => { + return node.type === 'element' && node.tagName === 'pre' && getPreMeta(node).id === ref + }) +} + +function copilotIcon() { + const copilotIconHtml = octicons.copilot.toSVG() + const copilotIconAst = parse(String(copilotIconHtml), { sourceCodeLocationInfo: true }) + const copilotIcon = fromParse5(copilotIconAst, { file: copilotIconHtml }) + return copilotIcon +} diff --git a/src/content-render/unified/parse-info-string.js b/src/content-render/unified/parse-info-string.js index 6512ab363532..ec27c455d6c2 100644 --- a/src/content-render/unified/parse-info-string.js +++ b/src/content-render/unified/parse-info-string.js @@ -4,6 +4,7 @@ // becomes... // node.lang = javascript // node.meta = { lineNumbers: 'left', copy: 'all', annotate: true } +// Also parse equals signs, where id=some-id becomes { id: 'some-id' } import { visit } from 'unist-util-visit' @@ -25,7 +26,7 @@ function strToObj(str) { return Object.fromEntries( str .split(/\s+/g) - .map((k) => k.split(':')) + .map((k) => k.split(/[:=]/)) // split by colon or equals sign .map(([k, ...v]) => [k, v.length ? v.join(':') : true]), ) } diff --git a/src/content-render/unified/processor.ts b/src/content-render/unified/processor.ts index a717a4b709f0..8b51bba4e119 100644 --- a/src/content-render/unified/processor.ts +++ b/src/content-render/unified/processor.ts @@ -57,7 +57,7 @@ export function createProcessor(context: Context): UnifiedProcessor { subset: false, aliases: { // As of Jan 2024, 'jsonc' is not supported by highlight.js. It - // just because plain text. + // just becomes plain text. // But 'jsonc' works great in github.com. For example, when // previewing and edited .md content in the browser. Or viewing // PR diffs in web view. @@ -66,6 +66,9 @@ export function createProcessor(context: Context): UnifiedProcessor { // but with this alias you get the nice syntax highlighting when // viewed on our site. json: 'jsonc', + // Docs supports a custom 'copilot' language, which is useful for contributors, + // but is not a supported highlight.js language, so alias to 'text'. + text: 'copilot', }, }) .use(raw) diff --git a/src/ghes-releases/lib/enterprise-dates.json b/src/ghes-releases/lib/enterprise-dates.json index d45bc146f360..6b4b00246060 100644 --- a/src/ghes-releases/lib/enterprise-dates.json +++ b/src/ghes-releases/lib/enterprise-dates.json @@ -255,7 +255,7 @@ "releaseDate": "2025-08-05", "deprecationDate": "2026-08-25", "releaseCandidateDate": "2025-08-05", - "generalAvailabilityDate": "2025-08-26" + "generalAvailabilityDate": "2025-10-06" }, "3.19": { "releaseDate": "2025-11-11", @@ -287,4 +287,4 @@ "releaseCandidateDate": "2026-11-10", "generalAvailabilityDate": "2026-12-08" } -} +} \ No newline at end of file diff --git a/src/secret-scanning/data/public-docs.yml b/src/secret-scanning/data/public-docs.yml index e167d5ed29cd..252a5d612e11 100644 --- a/src/secret-scanning/data/public-docs.yml +++ b/src/secret-scanning/data/public-docs.yml @@ -199,6 +199,7 @@ isPrivateWithGhas: true hasPushProtection: true hasValidityCheck: '{% ifversion fpt or ghes %}false{% else %}true{% endif %}' + ismultipart: true base64Supported: false isduplicate: false - provider: Amazon AWS diff --git a/src/secret-scanning/middleware/secret-scanning.ts b/src/secret-scanning/middleware/secret-scanning.ts index 5d68d398a322..8feb9cabbd3e 100644 --- a/src/secret-scanning/middleware/secret-scanning.ts +++ b/src/secret-scanning/middleware/secret-scanning.ts @@ -46,6 +46,9 @@ export default async function secretScanning( if (entry.isduplicate) { entry.secretType += '
Token versions' } + if (entry.ismultipart) { + entry.secretType += '
Multi-part secrets' + } }) return next() diff --git a/src/types.ts b/src/types.ts index f7b32ce7682d..db399ccb59dc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -311,6 +311,7 @@ export type SecretScanningData = { isPrivateWithGhas: boolean hasPushProtection: boolean hasValidityCheck: boolean | string + ismultipart?: boolean base64Supported: boolean isduplicate: boolean }