Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/spellcheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ on:
branches:
- main

permissions:
contents: read

jobs:
spellcheck:
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ We release patches for security vulnerabilities for the following versions:
If you discover a security vulnerability, please do the following:

1. **Do NOT** open a public issue
2. Email security details to: [your-email@example.com]
2. Use [GitHub Security Advisories](https://github.com/jongio/azd-extensions/security/advisories/new) to privately report a vulnerability
3. Include:
- Description of the vulnerability
- Steps to reproduce
Expand Down
556 changes: 556 additions & 0 deletions docs/archive/security-audit-2025-07-14.md

Large diffs are not rendered by default.

12 changes: 7 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
"update-readme-versions": "node scripts/update-readme-versions.js"
},
"dependencies": {
"@jongio/azd-web-core": "^2.4.0",
"astro": "^5.17.1"
"@jongio/azd-web-core": "^2.4.1",
"astro": "^5.17.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^25.2.1",
"tailwindcss": "^4.1.18",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^25.3.3",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3"
},
"repository": {
Expand Down
762 changes: 720 additions & 42 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion scripts/install-all.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ $registryUrl = "https://jongio.github.io/azd-extensions/registry.json"
$extensions = @(
@{ Name = "azd-app"; Id = "jongio.azd.app"; Path = Join-Path $parentDir "azd-app\cli" },
@{ Name = "azd-copilot"; Id = "jongio.azd.copilot"; Path = Join-Path $parentDir "azd-copilot\cli" },
@{ Name = "azd-exec"; Id = "jongio.azd.exec"; Path = Join-Path $parentDir "azd-exec\cli" }
@{ Name = "azd-exec"; Id = "jongio.azd.exec"; Path = Join-Path $parentDir "azd-exec\cli" },
@{ Name = "azd-rest"; Id = "jongio.azd.rest"; Path = Join-Path $parentDir "azd-rest\cli" }
)

# Ensure the jongio extension source is registered
Expand Down
49 changes: 49 additions & 0 deletions scripts/lib/semver.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Shared semver comparison utility for registry scripts.
*
* Handles strict major.minor.patch versions. Rejects pre-release suffixes
* and non-numeric components rather than silently degrading.
*/

/**
* Parse a semver string into its numeric components.
* Throws if any component is not a non-negative integer.
*
* @param {string} version - Semver string (e.g., "1.2.3")
* @returns {number[]} Array of [major, minor, patch]
*/
export function parseSemver(version) {
const parts = version.split('.');
const nums = parts.map((part, i) => {

Check warning on line 17 in scripts/lib/semver.js

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (nums)
const n = Number(part);
if (!Number.isInteger(n) || n < 0) {
throw new Error(
`Invalid semver component "${part}" at position ${i} in version "${version}"`
);
}
return n;
});
return nums;

Check warning on line 26 in scripts/lib/semver.js

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (nums)
}

/**
* Compare two semver strings (major.minor.patch) for sorting ascending.
* Returns negative if a < b, positive if a > b, 0 if equal.
*
* Throws on invalid version strings (pre-release suffixes, non-numeric parts)
* rather than silently producing incorrect sort order.
*
* @param {string} a - First version string
* @param {string} b - Second version string
* @returns {number} Comparison result for Array.sort()
*/
export function compareSemver(a, b) {
const pa = parseSemver(a);
const pb = parseSemver(b);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}
3 changes: 2 additions & 1 deletion scripts/uninstall-all.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ $registrySource = "jongio"
$extensions = @(
@{ Name = "azd-app"; Id = "jongio.azd.app" },
@{ Name = "azd-copilot"; Id = "jongio.azd.copilot" },
@{ Name = "azd-exec"; Id = "jongio.azd.exec" }
@{ Name = "azd-exec"; Id = "jongio.azd.exec" },
@{ Name = "azd-rest"; Id = "jongio.azd.rest" }
)

Write-Host "`n🗑️ Uninstalling all azd extensions...`n" -ForegroundColor Cyan
Expand Down
18 changes: 7 additions & 11 deletions scripts/update-readme-versions.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { readFileSync, writeFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { compareSemver } from './lib/semver.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
Expand All @@ -19,18 +20,10 @@ for (const ext of registry.extensions) {
const parts = ext.id.split('.');
const repoName = parts.slice(1).join('-');

// Find highest version using semver-style comparison
// Find highest version using shared semver comparison
const latest = ext.versions
.map((v) => v.version)
.sort((a, b) => {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0) return diff;
}
return 0;
})
.sort(compareSemver)
.pop();

if (latest) {
Expand All @@ -43,9 +36,12 @@ let readme = readFileSync(readmePath, 'utf8');
let updated = readme;

for (const [repoName, version] of latestVersions) {
// Escape regex special characters in repoName to prevent injection,
// then allow dash to also match dots (azd-app matches azd.app in table)
const escapedName = repoName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/-/g, '[-.]');
// Match table rows containing the repo name and update the version at the end
const pattern = new RegExp(
`(\\|[^|]*${repoName.replace(/-/g, '[-\\\\.]')}[^|]*\\|[^|]*\\|)\\s*v?[0-9]+\\.[0-9]+\\.[0-9]+\\s*(\\|)`,
`(\\|[^|]*${escapedName}[^|]*\\|[^|]*\\|)\\s*v?[0-9]+\\.[0-9]+\\.[0-9]+\\s*(\\|)`,
'g',
);
updated = updated.replace(pattern, `$1 v${version} $2`);
Expand Down
79 changes: 59 additions & 20 deletions scripts/update-registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,36 +8,36 @@

import { writeFileSync } from 'fs';
import https from 'https';
import { compareSemver } from './lib/semver.js';

const REGISTRY_FILE = 'public/registry.json';

/**
* Compare two semver strings (major.minor.patch) for sorting ascending.
* Returns negative if a < b, positive if a > b, 0 if equal.
*/
function compareSemver(a, b) {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < 3; i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}

/**
* HEAD request with redirect following. Returns HTTP status code.
* Only follows redirects to HTTPS URLs to prevent downgrade attacks.
*/
function headRequest(url, redirectCount = 0) {
return new Promise((resolve, reject) => {
if (redirectCount > 5) return resolve(0);
if (!url.startsWith('https://')) {
console.warn(` ⚠ Refusing non-HTTPS URL: ${url}`);
return resolve(0);
}
const req = https.request(url, { method: 'HEAD', timeout: 10_000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return headRequest(res.headers.location, redirectCount + 1).then(resolve).catch(reject);
const redirectTarget = new URL(res.headers.location, url).href;
if (!redirectTarget.startsWith('https://')) {
console.warn(` ⚠ Refusing redirect to non-HTTPS URL: ${redirectTarget}`);
return resolve(0);
}
return headRequest(redirectTarget, redirectCount + 1).then(resolve).catch(reject);
}
resolve(res.statusCode);
});
req.on('error', () => resolve(0));
req.on('error', (err) => {
console.warn(` ⚠ HEAD request failed for ${url}: ${err.message}`);
resolve(0);
});
req.on('timeout', () => {
req.destroy();
resolve(0);
Expand All @@ -54,8 +54,26 @@
'https://raw.githubusercontent.com/jongio/azd-rest/refs/heads/main/registry.json',
];

// Allowed hostname for artifact download URLs (GitHub releases only)
const ALLOWED_ARTIFACT_HOST = 'github.com';

/**
* Validate that an artifact URL points to an allowed domain.
* Prevents a compromised upstream registry from redirecting downloads
* to attacker-controlled infrastructure.
*/
function isAllowedArtifactUrl(url) {
try {
const parsed = new URL(url);
return parsed.protocol === 'https:' && parsed.hostname.endsWith(ALLOWED_ARTIFACT_HOST);

Check failure

Code scanning / CodeQL

Incomplete URL substring sanitization High

'
github.com
' may be preceded by an arbitrary host name.

Copilot Autofix

AI 3 months ago

In general, to fix this kind of problem you must avoid substring/suffix checks for host validation and instead compare the parsed hostname against a whitelist of exact allowed hostnames or well-defined patterns that cannot be spoofed by prefixing or embedding the allowed host. For GitHub artifacts, that usually means allowing only github.com and possibly objects.githubusercontent.com or similar, as explicit, complete hostnames.

For this code, the safest change that preserves functionality and intent is to replace the endsWith(ALLOWED_ARTIFACT_HOST) check with an equality check against a whitelist of allowed hostnames. Since the surrounding comment says “Allowed hostname for artifact download URLs (GitHub releases only)” and the constant is singular, we can update it to an array of explicit hostnames and check includes(parsed.hostname). This avoids accidental acceptance of evilgithub.com while still allowing additional GitHub-related artifact hosts if desired in the future.

Concretely, in scripts/update-registry.js:

  • Change ALLOWED_ARTIFACT_HOST from a string to an array like ['github.com'] (or include any additional strictly-known hosts).
  • Update isAllowedArtifactUrl so that it returns true only if:
    • parsed.protocol === 'https:', and
    • ALLOWED_ARTIFACT_HOSTS.includes(parsed.hostname) (exact match, case-sensitive, which is fine given Node’s URL normalization).
  • Optionally, rename the constant to ALLOWED_ARTIFACT_HOSTS to reflect that it is now a list, but keep usage limited to the provided snippet.

No new imports are needed; we continue using the built-in URL class and existing imports only.

Suggested changeset 1
scripts/update-registry.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/scripts/update-registry.js b/scripts/update-registry.js
--- a/scripts/update-registry.js
+++ b/scripts/update-registry.js
@@ -54,8 +54,8 @@
   'https://raw.githubusercontent.com/jongio/azd-rest/refs/heads/main/registry.json',
 ];
 
-// Allowed hostname for artifact download URLs (GitHub releases only)
-const ALLOWED_ARTIFACT_HOST = 'github.com';
+// Allowed hostnames for artifact download URLs (GitHub releases only)
+const ALLOWED_ARTIFACT_HOSTS = ['github.com'];
 
 /**
  * Validate that an artifact URL points to an allowed domain.
@@ -65,7 +65,7 @@
 function isAllowedArtifactUrl(url) {
   try {
     const parsed = new URL(url);
-    return parsed.protocol === 'https:' && parsed.hostname.endsWith(ALLOWED_ARTIFACT_HOST);
+    return parsed.protocol === 'https:' && ALLOWED_ARTIFACT_HOSTS.includes(parsed.hostname);
   } catch {
     return false;
   }
EOF
@@ -54,8 +54,8 @@
'https://raw.githubusercontent.com/jongio/azd-rest/refs/heads/main/registry.json',
];

// Allowed hostname for artifact download URLs (GitHub releases only)
const ALLOWED_ARTIFACT_HOST = 'github.com';
// Allowed hostnames for artifact download URLs (GitHub releases only)
const ALLOWED_ARTIFACT_HOSTS = ['github.com'];

/**
* Validate that an artifact URL points to an allowed domain.
@@ -65,7 +65,7 @@
function isAllowedArtifactUrl(url) {
try {
const parsed = new URL(url);
return parsed.protocol === 'https:' && parsed.hostname.endsWith(ALLOWED_ARTIFACT_HOST);
return parsed.protocol === 'https:' && ALLOWED_ARTIFACT_HOSTS.includes(parsed.hostname);
} catch {
return false;
}
Copilot is powered by AI and may make mistakes. Always verify output.
} catch {
return false;
}
}

/**
* Fetch registry JSON from a URL
* Fetch registry JSON from a URL.
* Enforces a 5 MB size limit to prevent DoS from oversized responses.
*/
async function fetchRegistry(url) {
console.log(`Fetching ${url}...`);
Expand All @@ -65,7 +83,18 @@
throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
}

return await response.json();
const MAX_REGISTRY_SIZE = 5 * 1024 * 1024; // 5 MB
const contentLength = response.headers.get('content-length');
if (contentLength && parseInt(contentLength, 10) > MAX_REGISTRY_SIZE) {
throw new Error(`Registry at ${url} exceeds maximum size of ${MAX_REGISTRY_SIZE} bytes`);
}

const text = await response.text();
if (text.length > MAX_REGISTRY_SIZE) {
throw new Error(`Registry at ${url} exceeds maximum size of ${MAX_REGISTRY_SIZE} bytes`);
}

return JSON.parse(text);
}

/**
Expand Down Expand Up @@ -120,20 +149,30 @@
console.log(` ⚠ Dropping ${ext.id}@${ver.version}: missing required platforms`);
return false;
}
// All artifact URLs must be valid HTTPS URLs
// All artifact URLs must be valid HTTPS URLs from allowed domains
for (const [, artifact] of Object.entries(artifacts)) {
if (!artifact.url || !artifact.url.startsWith('https://')) {
console.log(` ⚠ Dropping ${ext.id}@${ver.version}: non-HTTPS or missing artifact URL`);
return false;
}
if (!isAllowedArtifactUrl(artifact.url)) {
console.log(` ⚠ Dropping ${ext.id}@${ver.version}: artifact URL from disallowed domain — ${artifact.url}`);
return false;
}
}
// Must not have zero/placeholder checksums
// Must not have zero/placeholder checksums or weak hash algorithms
const ALLOWED_HASH_ALGORITHMS = ['sha256', 'sha384', 'sha512'];
for (const [, artifact] of Object.entries(artifacts)) {
const value = artifact.checksum?.value || '';
if (/^0+$/.test(value)) {
console.log(` ⚠ Dropping ${ext.id}@${ver.version}: placeholder checksum`);
return false;
}
const algorithm = (artifact.checksum?.algorithm || '').toLowerCase();
if (algorithm && !ALLOWED_HASH_ALGORITHMS.includes(algorithm)) {
console.log(` ⚠ Dropping ${ext.id}@${ver.version}: weak checksum algorithm "${algorithm}"`);
return false;
}
}
return true;
});
Expand Down
41 changes: 26 additions & 15 deletions scripts/validate-registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
import https from 'https';
import http from 'http';
import { compareSemver } from './lib/semver.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand All @@ -31,34 +31,32 @@
'linux/arm64',
];

const OPTIONAL_PLATFORMS = ['windows/arm64'];
// Allowed hostname for artifact download URLs
const ALLOWED_ARTIFACT_HOST = 'github.com';

// Acceptable checksum algorithms (reject weak hashes like MD5, SHA1)
const ALLOWED_HASH_ALGORITHMS = ['sha256', 'sha384', 'sha512'];

const URL_TIMEOUT_MS = 10_000;
const MAX_REDIRECTS = 5;

// ── Helpers ──────────────────────────────────────────────────────────────────

function compareSemver(a, b) {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < 3; i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}

/**
* Perform an HTTP(S) HEAD request, following redirects up to `maxRedirects`.
* Perform an HTTPS HEAD request, following redirects up to `maxRedirects`.
* Rejects redirects to non-HTTPS URLs to prevent downgrade attacks.
* Resolves with the final status code or rejects on error / timeout.
*/
function headRequest(url, maxRedirects = MAX_REDIRECTS) {
return new Promise((resolvePromise, reject) => {
const doRequest = (targetUrl, redirectsLeft) => {
const parsedUrl = new URL(targetUrl);
const lib = parsedUrl.protocol === 'https:' ? https : http;
if (parsedUrl.protocol !== 'https:') {
reject(new Error(`Refusing non-HTTPS URL: ${targetUrl}`));
return;
}

const req = lib.request(
const req = https.request(
targetUrl,
{ method: 'HEAD', timeout: URL_TIMEOUT_MS },
(res) => {
Expand Down Expand Up @@ -139,6 +137,8 @@
fail(`[${extId}@${latestVersion.version}] ${platform}: missing checksum`);
} else if (!checksum.algorithm) {
fail(`[${extId}@${latestVersion.version}] ${platform}: checksum missing algorithm`);
} else if (!ALLOWED_HASH_ALGORITHMS.includes(checksum.algorithm.toLowerCase())) {
fail(`[${extId}@${latestVersion.version}] ${platform}: weak checksum algorithm "${checksum.algorithm}" (allowed: ${ALLOWED_HASH_ALGORITHMS.join(', ')})`);
} else if (!checksum.value) {
fail(`[${extId}@${latestVersion.version}] ${platform}: checksum missing value`);
} else if (/^0+$/.test(checksum.value)) {
Expand All @@ -165,6 +165,17 @@
fail(
`[${extId}@${ver.version}] ${platform}: non-HTTPS or missing URL — ${artifact.url || '(none)'}`
);
} else {
try {
const parsed = new URL(artifact.url);
if (!parsed.hostname.endsWith(ALLOWED_ARTIFACT_HOST)) {

Check failure

Code scanning / CodeQL

Incomplete URL substring sanitization High

'
github.com
' may be preceded by an arbitrary host name.

Copilot Autofix

AI 3 months ago

In general, instead of checking hostname.endsWith(ALLOWED_ARTIFACT_HOST), compare the parsed hostname against a whitelist of exactly allowed hosts (and, if needed, explicitly listed subdomains). This avoids accepting arbitrary domains that merely contain the allowed domain as a suffix.

For this script, the best fix with minimal functional change is:

  • Replace the endsWith check with an exact‑match check against a small array of allowed hostnames.
  • Keep using new URL() to parse the URL; do not rely on string operations on the full URL.
  • Define an ALLOWED_ARTIFACT_HOSTS array near the existing ALLOWED_ARTIFACT_HOST (or in place of it, if that constant is defined in this file), and use includes(parsed.hostname) instead of endsWith.

Concretely, in validateAllVersions (around lines 169–176), change:

if (!parsed.hostname.endsWith(ALLOWED_ARTIFACT_HOST)) {
  fail(...);
}

to:

if (!ALLOWED_ARTIFACT_HOSTS.includes(parsed.hostname)) {
  fail(...);
}

and add a definition of ALLOWED_ARTIFACT_HOSTS in this file (for example, near the top, after the other constants). Since we are told to only modify code shown here and cannot see where ALLOWED_ARTIFACT_HOST is defined, we introduce ALLOWED_ARTIFACT_HOSTS locally and keep using the existing constant in the error message to avoid changing behavior elsewhere.

Suggested changeset 1
scripts/validate-registry.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/scripts/validate-registry.js b/scripts/validate-registry.js
--- a/scripts/validate-registry.js
+++ b/scripts/validate-registry.js
@@ -31,6 +31,11 @@
   'linux/arm64',
 ];
 
+// Only these exact hosts are allowed for artifact URLs.
+const ALLOWED_ARTIFACT_HOSTS = [
+  ALLOWED_ARTIFACT_HOST,
+];
+
 // Allowed hostname for artifact download URLs
 const ALLOWED_ARTIFACT_HOST = 'github.com';
 
@@ -168,7 +173,7 @@
       } else {
         try {
           const parsed = new URL(artifact.url);
-          if (!parsed.hostname.endsWith(ALLOWED_ARTIFACT_HOST)) {
+          if (!ALLOWED_ARTIFACT_HOSTS.includes(parsed.hostname)) {
             fail(
               `[${extId}@${ver.version}] ${platform}: URL from disallowed domain — ${artifact.url}`
             );
EOF
@@ -31,6 +31,11 @@
'linux/arm64',
];

// Only these exact hosts are allowed for artifact URLs.
const ALLOWED_ARTIFACT_HOSTS = [
ALLOWED_ARTIFACT_HOST,
];

// Allowed hostname for artifact download URLs
const ALLOWED_ARTIFACT_HOST = 'github.com';

@@ -168,7 +173,7 @@
} else {
try {
const parsed = new URL(artifact.url);
if (!parsed.hostname.endsWith(ALLOWED_ARTIFACT_HOST)) {
if (!ALLOWED_ARTIFACT_HOSTS.includes(parsed.hostname)) {
fail(
`[${extId}@${ver.version}] ${platform}: URL from disallowed domain — ${artifact.url}`
);
Copilot is powered by AI and may make mistakes. Always verify output.
fail(
`[${extId}@${ver.version}] ${platform}: URL from disallowed domain — ${artifact.url}`
);
}
} catch {
fail(`[${extId}@${ver.version}] ${platform}: malformed URL — ${artifact.url}`);
}
}
const value = artifact.checksum?.value || '';
if (/^0+$/.test(value)) {
Expand Down
3 changes: 2 additions & 1 deletion scripts/watch-all.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ $parentDir = Split-Path -Parent $repoDir
$extensions = @(
@{ Name = "app"; Color = "Cyan"; Path = Join-Path $parentDir "azd-app\cli" },
@{ Name = "copilot"; Color = "Magenta"; Path = Join-Path $parentDir "azd-copilot\cli" },
@{ Name = "exec"; Color = "Green"; Path = Join-Path $parentDir "azd-exec\cli" }
@{ Name = "exec"; Color = "Green"; Path = Join-Path $parentDir "azd-exec\cli" },
@{ Name = "rest"; Color = "Yellow"; Path = Join-Path $parentDir "azd-rest\cli" }
)

# Validate all repos exist before starting
Expand Down
4 changes: 2 additions & 2 deletions src/components/ExtensionShowcase.astro
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,9 @@ const { id, name, tagline, description, icon, website, repository, features, sce
const command = (btn as HTMLElement).dataset.command || '';
try {
await navigator.clipboard.writeText(command);
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
btn.innerHTML = '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
} catch {
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
btn.innerHTML = '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
}
setTimeout(() => { btn.innerHTML = originalHTML; copying = false; }, 1500);
});
Expand Down
1 change: 1 addition & 0 deletions src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ const installAzdTabs = [
publishedDate="2025-11-09T20:22:39Z"
>
<Fragment slot="head">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'none'; frame-ancestors 'none';" />
<meta name="keywords" content="Azure, Azure Developer CLI, azd, azd extensions, azd app, azd exec, azd copilot, azd rest, Azure CLI, developer tools, Jon Gallant, jongio, Azure authentication, local development, AI assistant, REST API" />
<meta name="robots" content="index, follow" />
<link rel="icon" type="image/svg+xml" href="/azd-extensions/favicon.svg" />
Expand Down
Loading