> ## Documentation Index
> Fetch the complete documentation index at: https://docs.endorlabs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Scanning with Buildkite

> Learn how to implement Endor Labs in Buildkite pipelines.

export const YamlTable = ({children, data: propData, content}) => {
  const KV_RE = /^([A-Za-z][A-Za-z0-9_()/#\s-]+?):\s*(.+)$/;
  const INLINE_MD_RE = /(\[([^\]]+)\]\(([^)]+)\))|(`([^`]+)`)|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)/g;
  const YES_RE = /^-yes-$/i;
  const NO_RE = /^-no-$/i;
  const LIMITED_RE = /^-(limited|partial)-$/i;
  const NA_RE = /^-(na|none)-$/i;
  const NA2_RE = /^-na2-$/i;
  const SIMPLE_TAG_RE = /(<br\s*\/?>)|(<p\s*\/?>)|(-note-)|(-warning-)/gi;
  const tryParseKV = trimmed => {
    const m = KV_RE.exec(trimmed);
    return m ? {
      key: m[1],
      value: m[2].trim()
    } : null;
  };
  const registerKey = (key, seenKeys, orderedKeys) => {
    if (!seenKeys.has(key)) {
      orderedKeys.push(key);
      seenKeys.add(key);
    }
  };
  const flushEntry = (currentEntry, entries) => {
    if (Object.keys(currentEntry).length > 0) entries.push(currentEntry);
  };
  const parseDashPrefixed = (lines, entries, orderedKeys, seenKeys) => {
    let currentEntry = {};
    let inEntry = false;
    for (const line of lines) {
      const trimmed = line.trim();
      if (trimmed.startsWith('- ')) {
        if (inEntry) entries.push(currentEntry);
        currentEntry = {};
        inEntry = true;
        const kv = tryParseKV(trimmed.substring(2).trim());
        if (kv) {
          registerKey(kv.key, seenKeys, orderedKeys);
          currentEntry[kv.key] = kv.value;
        }
      } else if (inEntry && trimmed !== '') {
        const kv = tryParseKV(trimmed);
        if (kv) {
          registerKey(kv.key, seenKeys, orderedKeys);
          currentEntry[kv.key] = kv.value;
        }
      }
    }
    flushEntry(currentEntry, entries);
  };
  const parseBlankSeparated = (lines, entries, orderedKeys, seenKeys) => {
    let currentEntry = {};
    let inEntry = false;
    for (const line of lines) {
      const trimmed = line.trim();
      if (trimmed === '') {
        if (inEntry) {
          flushEntry(currentEntry, entries);
          currentEntry = {};
          inEntry = false;
        }
        continue;
      }
      const kv = tryParseKV(trimmed);
      if (!kv) continue;
      const isNewEntry = !line.startsWith(' ') && !line.startsWith('\t');
      if (isNewEntry && inEntry && Object.keys(currentEntry).length > 0) {
        entries.push(currentEntry);
        currentEntry = {};
      }
      registerKey(kv.key, seenKeys, orderedKeys);
      currentEntry[kv.key] = kv.value;
      inEntry = true;
    }
    flushEntry(currentEntry, entries);
  };
  const normalizeEntries = (entries, orderedKeys) => entries.map(entry => {
    const filled = {};
    for (const key of orderedKeys) filled[key] = entry[key] || '';
    return filled;
  });
  const parseYamlTableContent = contentStr => {
    if (!contentStr) return [];
    const entries = [];
    const orderedKeys = [];
    const seenKeys = new Set();
    const lines = contentStr.split('\n');
    if (lines.some(line => line.trim().startsWith('- '))) {
      parseDashPrefixed(lines, entries, orderedKeys, seenKeys);
    } else {
      parseBlankSeparated(lines, entries, orderedKeys, seenKeys);
    }
    return normalizeEntries(entries, orderedKeys);
  };
  const processText = text => {
    if (!text) return text;
    const parts = [];
    let keyIndex = 0;
    let lastIndex = 0;
    let match;
    while ((match = INLINE_MD_RE.exec(text)) !== null) {
      if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
      if (match[1]) {
        parts.push(<a key={keyIndex++} href={match[3]}>{match[2]}</a>);
      } else if (match[4]) {
        parts.push(<code key={keyIndex++}>{match[5]}</code>);
      } else if (match[6]) {
        parts.push(<strong key={keyIndex++}>{match[7]}</strong>);
      } else if (match[8]) {
        parts.push(<em key={keyIndex++}>{match[9]}</em>);
      }
      lastIndex = match.index + match[0].length;
    }
    if (lastIndex < text.length) parts.push(text.slice(lastIndex));
    if (parts.length === 0) return text;
    const keyRef = {
      current: keyIndex
    };
    return expandHtmlTags(parts, keyRef);
  };
  const processBadges = text => {
    if (!text || typeof text !== 'string') return text;
    if (YES_RE.test(text)) return <span className="yt-badge-yes" role="img" aria-label="Supported" title="Supported">✓</span>;
    if (NO_RE.test(text)) return <span className="yt-badge-no" role="img" aria-label="Not supported" title="Not supported">✗</span>;
    if (LIMITED_RE.test(text)) return <span className="yt-badge-limited" role="img" aria-label="Partially supported" title="Partially supported">◐</span>;
    if (NA_RE.test(text) || NA2_RE.test(text)) return <span className="yt-sr-only" title="Not applicable">Not applicable</span>;
    return processText(text);
  };
  const cellClassName = text => {
    if (!text || typeof text !== 'string') return undefined;
    if (NA_RE.test(text)) return 'yt-cell-na';
    if (NA2_RE.test(text)) return 'yt-cell-na2';
    return undefined;
  };
  const expandSimpleTags = (str, keyRef) => {
    const result = [];
    let last = 0;
    SIMPLE_TAG_RE.lastIndex = 0;
    let m;
    while ((m = SIMPLE_TAG_RE.exec(str)) !== null) {
      if (m.index > last) result.push(str.slice(last, m.index));
      if (m[1]) {
        result.push(<br key={keyRef.current++} />);
      } else if (m[2]) {
        result.push(<br key={keyRef.current++} />, <br key={keyRef.current++} />);
      } else if (m[3]) {
        result.push(<span key={keyRef.current++} className="yt-badge-note" style={{
          fontWeight: 600
        }}>Note: </span>);
      } else if (m[4]) {
        result.push(<span key={keyRef.current++} className="yt-badge-warning" style={{
          fontWeight: 600
        }}>Warning: </span>);
      }
      last = m.index + m[0].length;
    }
    if (last < str.length) result.push(str.slice(last));
    return result;
  };
  const expandHtmlTags = (chunks, keyRef) => {
    const out = [];
    for (const chunk of chunks) {
      if (typeof chunk === 'string') {
        out.push(...expandSimpleTags(chunk, keyRef));
      } else {
        out.push(chunk);
      }
    }
    return out;
  };
  const extractText = node => {
    if (node === null || node === undefined) return '';
    if (typeof node === 'string') return node;
    if (typeof node === 'number') return String(node);
    if (typeof node === 'boolean') return '';
    if (Array.isArray(node)) return node.map(extractText).join('');
    if (node && typeof node === 'object' && node.type) {
      const props = node.props || ({});
      if (typeof props.children === 'string') return props.children;
      if (props.children) return extractText(props.children);
      return '';
    }
    return String(node || '');
  };
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const data = useMemo(() => {
    if (propData) return propData;
    if (content && typeof content === 'string') return parseYamlTableContent(content);
    if (!children) return [];
    if (typeof children === 'string') return parseYamlTableContent(children);
    const childrenArray = Array.isArray(children) ? children : [children];
    return parseYamlTableContent(childrenArray.map(extractText).join('').trim());
  }, [children, propData, content]);
  const columns = useMemo(() => {
    if (!data || data.length === 0) return [];
    const firstRow = data[0];
    if (!firstRow || typeof firstRow !== 'object') return [];
    return Object.keys(firstRow);
  }, [data]);
  if (!mounted) return null;
  if (!data || data.length === 0) return null;
  const rowKey = row => columns.map(c => row[c] || '').join('|');
  return <table>
      <thead>
        <tr>
          {columns.map(col => <th key={col}>{col.replaceAll('_', ' ')}</th>)}
        </tr>
      </thead>
      <tbody>
        {data.map(row => <tr key={rowKey(row)}>
            {columns.map(col => <td key={col} className={cellClassName(row[col])}>{processBadges(row[col])}</td>)}
          </tr>)}
      </tbody>
    </table>;
};

export const BuildkitePluginOptions = ({group, showVersion = false, heading}) => {
  const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
  const OWNER_REPO = 'endorlabs/endorlabs-buildkite-plugin';
  const FALLBACK_REF = 'main';
  const REF_OVERRIDE_PARAM = 'plugin-ref';
  const OTHER_GROUP = 'other';
  const RELEASE_CACHE_KEY = 'bk-plugin-release-' + OWNER_REPO;
  const OPTIONS_CACHE_KEY = 'bk-plugin-options-' + OWNER_REPO;
  const pluginYmlUrl = 'https://github.com/' + OWNER_REPO + '/blob/main/plugin.yml';
  const GROUP_KEYS = {
    common: ['mode', 'namespace', 'api', 'endorctl_version', 'endorctl_checksum', 'endorctl_skip_install', 'log_level', 'log_verbose', 'additional_args'],
    authentication: ['api_key_env', 'api_secret_env', 'aws_role_arn', 'enable_azure_managed_identity', 'gcp_service_account'],
    'scan-types': ['scan_dependencies', 'scan_secrets', 'scan_sast', 'scan_git_logs', 'scan_github_actions', 'scan_ai_models', 'scan_tools', 'scan_package', 'scan_container'],
    'scan-configuration': ['scan_path', 'project_name', 'project_tags', 'tags', 'phantom_dependencies', 'disable_code_snippet_storage', 'use_bazel', 'bazel_include_targets', 'bazel_exclude_targets', 'bazel_targets_query', 'as_ref'],
    'container-scans': ['image', 'image_tar', 'container_scan_path', 'os_reachability', 'profiling_data_dir'],
    'pr-scans': ['pr', 'pr_baseline', 'pr_incremental'],
    'annotations-output': ['annotate', 'annotate_context', 'annotate_scope', 'annotate_findings_limit', 'output_type', 'sarif_file', 'output_file', 'upload_artifacts', 'artifact_paths'],
    'failure-control': ['fail_on_policy', 'soft_fail', 'exit_on_policy_warning'],
    'artifact-signing': ['artifact_name', 'certificate_oidc_issuer', 'certificate_identity', 'source_repository_ref', 'source_repository', 'source_repository_owner', 'source_repository_digest', 'build_config_name', 'build_config_digest', 'runner_environment']
  };
  const [isDark, setIsDark] = useState(false);
  const [data, setData] = useState(null);
  const [error, setError] = useState(false);
  useEffect(() => {
    const checkTheme = () => {
      const root = document.documentElement;
      setIsDark(root.dataset.theme === 'dark' || root.classList.contains('dark'));
    };
    checkTheme();
    const observer = new MutationObserver(checkTheme);
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ['data-theme', 'class']
    });
    return () => observer.disconnect();
  }, []);
  useEffect(() => {
    let cancelled = false;
    if (!globalThis.__bkPluginRegistry) {
      globalThis.__bkPluginRegistry = {};
    }
    const registry = globalThis.__bkPluginRegistry;
    const readCache = cacheKey => {
      try {
        const raw = localStorage.getItem(cacheKey);
        if (!raw) return null;
        const cached = JSON.parse(raw);
        if (Date.now() - cached.ts > CACHE_TTL_MS) {
          localStorage.removeItem(cacheKey);
          return null;
        }
        return cached.data;
      } catch (storageError) {
        console.debug('bk-plugin: cache read failed', storageError);
        return null;
      }
    };
    const writeCache = (cacheKey, value) => {
      try {
        localStorage.setItem(cacheKey, JSON.stringify({
          ts: Date.now(),
          data: value
        }));
      } catch (storageError) {
        console.debug('bk-plugin: cache write failed', storageError);
      }
    };
    const stripQuotes = value => value.replace(/^["']|["']$/g, '');
    const parseEnumList = rawValue => rawValue.replace(/[[\]]/g, '').split(',').map(item => stripQuotes(item.trim())).filter(Boolean);
    const applyField = (option, field, rawValue) => {
      if (field === 'type') {
        option.type = rawValue;
        return false;
      }
      if (field === 'default') {
        option.defaultValue = stripQuotes(rawValue);
        return false;
      }
      if (field === 'enum') {
        option.enumValues = parseEnumList(rawValue);
        return false;
      }
      if (field === 'description') {
        const startsFoldedBlock = rawValue === '' || ['>-', '>', '|', '|-'].includes(rawValue);
        if (startsFoldedBlock) {
          option.description = '';
          return true;
        }
        option.description = rawValue;
      }
      return false;
    };
    const appendFoldedLine = (option, text) => {
      option.description += (option.description ? ' ' : '') + text;
    };
    const propertyKeyRe = /^ {4}(\w+):\s*$/;
    const fieldRe = /^ {6}([a-z_]+):\s*(.*)$/;
    const foldedTextRe = /^ {8}(\S.*)$/;
    const propertiesBlockEndRe = /^ {0,3}\S/;
    const consumePropertyLine = (state, line) => {
      const propertyMatch = propertyKeyRe.exec(line);
      if (propertyMatch) {
        if (state.current) state.options.push(state.current);
        state.current = {
          key: propertyMatch[1],
          type: '',
          defaultValue: null,
          enumValues: null,
          description: ''
        };
        state.inFoldedDescription = false;
        return;
      }
      if (!state.current) return;
      if (state.inFoldedDescription) {
        const foldedMatch = foldedTextRe.exec(line);
        if (foldedMatch) {
          appendFoldedLine(state.current, foldedMatch[1].trim());
          return;
        }
        state.inFoldedDescription = false;
      }
      const fieldMatch = fieldRe.exec(line);
      if (fieldMatch) {
        state.inFoldedDescription = applyField(state.current, fieldMatch[1], fieldMatch[2].trim());
      }
    };
    const parsePluginYaml = yamlText => {
      const state = {
        options: [],
        current: null,
        inFoldedDescription: false
      };
      let inProperties = false;
      for (const line of yamlText.split('\n')) {
        if (!inProperties) {
          inProperties = (/^ {2}properties:\s*$/).test(line);
          continue;
        }
        if (line.trim() === '') continue;
        if (propertiesBlockEndRe.test(line)) break;
        consumePropertyLine(state, line);
      }
      if (state.current) state.options.push(state.current);
      return state.options;
    };
    const searchParams = new URLSearchParams(globalThis.location.search);
    const refOverride = searchParams.get(REF_OVERRIDE_PARAM);
    const fetchLatestVersion = async () => {
      const cached = readCache(RELEASE_CACHE_KEY);
      if (cached) return cached.version;
      if (registry[RELEASE_CACHE_KEY]) return (await registry[RELEASE_CACHE_KEY]).version;
      const promise = (async () => {
        const response = await fetch('https://api.github.com/repos/' + OWNER_REPO + '/releases/latest');
        if (!response.ok) throw new Error('HTTP ' + response.status);
        const release = await response.json();
        const result = {
          version: release.tag_name
        };
        writeCache(RELEASE_CACHE_KEY, result);
        return result;
      })();
      registry[RELEASE_CACHE_KEY] = promise;
      const clearInFlight = () => {
        delete registry[RELEASE_CACHE_KEY];
      };
      promise.then(clearInFlight, clearInFlight);
      return (await promise).version;
    };
    const fetchPluginOptions = async () => {
      if (!refOverride) {
        const cached = readCache(OPTIONS_CACHE_KEY);
        if (cached) return cached;
      }
      const registryKey = refOverride ? OPTIONS_CACHE_KEY + '@' + refOverride : OPTIONS_CACHE_KEY;
      if (registry[registryKey]) return registry[registryKey];
      const promise = (async () => {
        let version = refOverride;
        if (!version) {
          try {
            version = await fetchLatestVersion();
          } catch (releaseError) {
            console.debug('bk-plugin: release lookup failed, using main', releaseError);
            version = FALLBACK_REF;
          }
        }
        const yamlResp = await fetch('https://raw.githubusercontent.com/' + OWNER_REPO + '/' + version + '/plugin.yml');
        if (!yamlResp.ok) throw new Error('HTTP ' + yamlResp.status);
        const options = parsePluginYaml(await yamlResp.text());
        if (options.length === 0) throw new Error('no options parsed from plugin.yml');
        const result = {
          version,
          options,
          isPreview: Boolean(refOverride)
        };
        if (!refOverride) writeCache(OPTIONS_CACHE_KEY, result);
        return result;
      })();
      registry[registryKey] = promise;
      const clearInFlight = () => {
        delete registry[registryKey];
      };
      promise.then(clearInFlight, clearInFlight);
      return promise;
    };
    fetchPluginOptions().then(result => {
      if (!cancelled) setData(result);
    }).catch(() => {
      if (!cancelled) setError(true);
    });
    return () => {
      cancelled = true;
    };
  }, []);
  const groupRows = useMemo(() => {
    if (!data) return [];
    const byKey = new Map(data.options.map(option => [option.key, option]));
    if (group === OTHER_GROUP) {
      const assigned = new Set(Object.values(GROUP_KEYS).flat());
      return data.options.filter(option => !assigned.has(option.key));
    }
    const keys = GROUP_KEYS[group] || [];
    return keys.map(key => byKey.get(key)).filter(Boolean);
  }, [data, group]);
  const textColor = isDark ? '#e6edf3' : '#1f2937';
  const errorTextColor = isDark ? '#fecaca' : '#7f1d1d';
  const errorBorderColor = isDark ? '#7f1d1d' : '#fecaca';
  const styles = {
    statusBox: {
      color: textColor,
      opacity: 0.6,
      fontStyle: 'italic',
      fontSize: '0.85rem',
      margin: '0.75rem 0'
    },
    errorBox: {
      margin: '0.75rem 0',
      padding: '0.6rem 0.75rem',
      background: isDark ? '#3b1111' : '#fef2f2',
      border: '1px solid ' + errorBorderColor,
      borderRadius: '8px',
      color: errorTextColor,
      fontSize: '0.8rem',
      lineHeight: 1.5
    },
    errorLink: {
      color: errorTextColor,
      textDecoration: 'underline'
    },
    versionNote: {
      color: textColor,
      opacity: 0.6,
      fontSize: '0.75rem',
      margin: '0.25rem 0 0.75rem 0'
    },
    previewNote: {
      margin: '0.75rem 0 0.25rem 0',
      padding: '0.35rem 0.6rem',
      background: isDark ? '#422006' : '#fffbeb',
      border: '1px solid ' + (isDark ? '#a16207' : '#fde68a'),
      borderRadius: '6px',
      color: isDark ? '#fde68a' : '#92400e',
      fontSize: '0.75rem',
      lineHeight: 1.5
    },
    monoText: {
      fontFamily: "'Monaco','Menlo','Ubuntu Mono',monospace"
    }
  };
  const splitTrailingPunctuation = rawUrl => {
    let url = rawUrl;
    let trailing = '';
    while ((/[.,;]$/).test(url)) {
      trailing = url.slice(-1) + trailing;
      url = url.slice(0, -1);
    }
    return {
      url,
      trailing
    };
  };
  const renderLinkMatch = (match, anchorKey) => {
    if (match[1] && match[2]) {
      return [<a key={anchorKey} href={match[2]} target="_blank" rel="noopener">
          {match[1]}
        </a>];
    }
    const {url, trailing} = splitTrailingPunctuation(match[0]);
    const anchor = <a key={anchorKey} href={url} target="_blank" rel="noopener">
        {url.replace(/^https?:\/\//, '')}
      </a>;
    return trailing ? [anchor, trailing] : [anchor];
  };
  const renderLinkedText = (text, keyPrefix) => {
    const linkRe = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|https?:\/\/[^\s)]+/g;
    const segments = [];
    let lastIndex = 0;
    let linkIndex = 0;
    let match;
    while ((match = linkRe.exec(text)) !== null) {
      if (match.index > lastIndex) segments.push(text.slice(lastIndex, match.index));
      segments.push(...renderLinkMatch(match, 'link-' + keyPrefix + '-' + linkIndex++));
      lastIndex = match.index + match[0].length;
    }
    if (lastIndex < text.length) segments.push(text.slice(lastIndex));
    return segments;
  };
  const renderDescriptionText = text => {
    const codespanRe = /`([^`]+)`/g;
    const segments = [];
    let lastIndex = 0;
    let chunkIndex = 0;
    let match;
    while ((match = codespanRe.exec(text)) !== null) {
      if (match.index > lastIndex) segments.push(...renderLinkedText(text.slice(lastIndex, match.index), chunkIndex));
      segments.push(<code key={'code-' + chunkIndex++}>{match[1]}</code>);
      lastIndex = match.index + match[0].length;
    }
    if (lastIndex < text.length) segments.push(...renderLinkedText(text.slice(lastIndex), chunkIndex));
    return segments;
  };
  const renderAllowedValues = enumValues => <span>
      {' Allowed values: '}
      {enumValues.map((value, index) => <span key={value}>
          {index > 0 ? ', ' : ''}
          <code>{value}</code>
        </span>)}
      {'.'}
    </span>;
  const renderOptionRow = option => <tr key={option.key}>
      <td><code>{option.key}</code></td>
      <td>{option.defaultValue === null ? '-' : <code>{option.defaultValue}</code>}</td>
      <td>
        {renderDescriptionText(option.description)}
        {option.enumValues?.length ? renderAllowedValues(option.enumValues) : null}
      </td>
    </tr>;
  const validGroups = [...Object.keys(GROUP_KEYS), OTHER_GROUP];
  if (!validGroups.includes(group)) {
    return <div className="not-prose" style={styles.errorBox}>
        Unknown options group "{String(group)}". Valid groups: {validGroups.join(', ')}.
      </div>;
  }
  if (error) {
    return <div className="not-prose" style={styles.errorBox}>
        <span>{'⚠️'} Could not load the plugin options from GitHub. </span>
        <a href={pluginYmlUrl} target="_blank" rel="noopener" style={styles.errorLink}>
          View plugin.yml for the full reference
        </a>
      </div>;
  }
  if (!data) {
    return <div className="not-prose" style={styles.statusBox}>Loading plugin options...</div>;
  }
  if (groupRows.length === 0) {
    return null;
  }
  return <div>
      {heading ? <p style={{
    fontWeight: 600,
    margin: '1rem 0 0.25rem 0'
  }}>{heading}</p> : null}
      {data.isPreview ? <p className="not-prose" style={styles.previewNote}>
          {'⚠️'} Previewing plugin.yml at <span style={styles.monoText}>{data.version}</span>. Published tables use the latest release.
        </p> : null}
      <table>
        <thead>
          <tr>
            <th>Option</th>
            <th>Default</th>
            <th>Description</th>
          </tr>
        </thead>
        <tbody>
          {groupRows.map(renderOptionRow)}
        </tbody>
      </table>
      {showVersion && <p className="not-prose" style={styles.versionNote}>
          Loaded live from{' '}
          <a href={pluginYmlUrl} target="_blank" rel="noopener" style={{
    color: 'inherit',
    textDecoration: 'underline'
  }}>
            plugin.yml
          </a>
          {' at '}{data.version}.
        </p>}
    </div>;
};

export const BuildkitePluginRef = ({owner = 'endorlabs', repo = 'endorlabs-buildkite-plugin'}) => {
  const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
  const COPY_FEEDBACK_TIMEOUT_MS = 2000;
  const releaseCacheKey = 'bk-plugin-release-' + owner + '/' + repo;
  const releasesUrl = 'https://github.com/' + owner + '/' + repo + '/releases/latest';
  const [isDark, setIsDark] = useState(false);
  const [data, setData] = useState(null);
  const [error, setError] = useState(false);
  const [copied, setCopied] = useState(false);
  useEffect(() => {
    const checkTheme = () => {
      const root = document.documentElement;
      setIsDark(root.dataset.theme === 'dark' || root.classList.contains('dark'));
    };
    checkTheme();
    const observer = new MutationObserver(checkTheme);
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ['data-theme', 'class']
    });
    return () => observer.disconnect();
  }, []);
  useEffect(() => {
    let cancelled = false;
    if (!globalThis.__bkPluginRegistry) {
      globalThis.__bkPluginRegistry = {};
    }
    const registry = globalThis.__bkPluginRegistry;
    const readCache = () => {
      try {
        const raw = localStorage.getItem(releaseCacheKey);
        if (!raw) return null;
        const cached = JSON.parse(raw);
        if (Date.now() - cached.ts > CACHE_TTL_MS) {
          localStorage.removeItem(releaseCacheKey);
          return null;
        }
        return cached.data;
      } catch (storageError) {
        console.debug('bk-plugin: cache read failed', storageError);
        return null;
      }
    };
    const writeCache = value => {
      try {
        localStorage.setItem(releaseCacheKey, JSON.stringify({
          ts: Date.now(),
          data: value
        }));
      } catch (storageError) {
        console.debug('bk-plugin: cache write failed', storageError);
      }
    };
    const fetchLatestRelease = async () => {
      const cached = readCache();
      if (cached) return cached;
      if (registry[releaseCacheKey]) return registry[releaseCacheKey];
      const promise = (async () => {
        const response = await fetch('https://api.github.com/repos/' + owner + '/' + repo + '/releases/latest');
        if (!response.ok) throw new Error('HTTP ' + response.status);
        const release = await response.json();
        const result = {
          version: release.tag_name
        };
        writeCache(result);
        return result;
      })();
      registry[releaseCacheKey] = promise;
      const clearInFlight = () => {
        delete registry[releaseCacheKey];
      };
      promise.then(clearInFlight, clearInFlight);
      return promise;
    };
    fetchLatestRelease().then(result => {
      if (!cancelled) setData(result);
    }).catch(() => {
      if (!cancelled) setError(true);
    });
    return () => {
      cancelled = true;
    };
  }, [owner, repo]);
  const pluginRef = data ? 'https://github.com/' + owner + '/' + repo + '.git#' + data.version : null;
  const handleCopyRef = () => {
    if (!pluginRef) return;
    const flashSuccess = () => {
      setCopied(true);
      setTimeout(() => setCopied(false), COPY_FEEDBACK_TIMEOUT_MS);
    };
    const fallbackCopy = () => {
      try {
        const textarea = document.createElement('textarea');
        textarea.value = pluginRef;
        textarea.setAttribute('readonly', '');
        textarea.style.cssText = 'position:fixed;left:-9999px;opacity:0';
        document.body.appendChild(textarea);
        textarea.select();
        const succeeded = document.execCommand('copy');
        textarea.remove();
        if (succeeded) flashSuccess();
      } catch (copyError) {
        console.debug('bk-plugin: fallback copy failed', copyError);
      }
    };
    if (navigator.clipboard?.writeText) {
      navigator.clipboard.writeText(pluginRef).then(flashSuccess).catch(fallbackCopy);
    } else {
      fallbackCopy();
    }
  };
  const backgroundColor = isDark ? '#0d1117' : '#ffffff';
  const backgroundLight = isDark ? '#161b22' : '#f6f8fa';
  const textColor = isDark ? '#e6edf3' : '#1f2937';
  const styles = {
    usageBlock: {
      background: backgroundLight,
      border: '1px solid rgba(38,208,124,0.3)',
      borderRadius: '8px',
      padding: '0.75rem'
    },
    header: {
      display: 'flex',
      alignItems: 'center',
      color: textColor,
      fontWeight: 600,
      margin: '0 0 0.5rem 0',
      fontSize: '0.85rem'
    },
    copyButton: {
      marginLeft: 'auto',
      padding: '0.25rem 0.5rem',
      background: copied ? '#28a745' : 'linear-gradient(to bottom, rgba(50,225,140,0.92), rgba(30,190,110,0.92))',
      color: isDark ? '#000' : '#fff',
      border: '1px solid rgba(38,208,124,0.5)',
      borderRadius: '6px',
      fontSize: '0.75rem',
      cursor: 'pointer',
      fontWeight: 500,
      whiteSpace: 'nowrap',
      flexShrink: 0
    },
    codeBlock: {
      background: backgroundColor,
      color: textColor,
      padding: '0.6rem 0.75rem',
      borderRadius: '6px',
      fontFamily: "'Monaco','Menlo','Ubuntu Mono',monospace",
      fontSize: '0.8rem',
      lineHeight: 1.4,
      overflowX: 'auto',
      margin: 0,
      whiteSpace: 'pre-wrap',
      wordBreak: 'break-all',
      border: '1px solid rgba(38,208,124,0.1)'
    },
    fallbackNote: {
      color: textColor,
      opacity: 0.7,
      fontSize: '0.75rem',
      marginTop: '0.5rem',
      lineHeight: 1.5
    },
    fallbackLink: {
      color: textColor,
      textDecoration: 'underline'
    },
    loading: {
      color: textColor,
      opacity: 0.5,
      fontStyle: 'italic'
    }
  };
  const renderUsageBlock = () => <div style={styles.usageBlock}>
      <div style={styles.header}>
        <span>Latest plugin release</span>
        <button type="button" onClick={handleCopyRef} disabled={!data} aria-label="Copy the pinned Buildkite plugin reference" style={{
    ...styles.copyButton,
    ...data ? {} : {
      opacity: 0.5,
      cursor: 'not-allowed'
    }
  }}>
          {copied ? '✓ Copied!' : '📋 Copy ref'}
        </button>
      </div>
      <pre style={styles.codeBlock}>
        {pluginRef || <span style={styles.loading}>Loading...</span>}
      </pre>
    </div>;
  const renderFallback = () => <div style={styles.usageBlock}>
      <div style={styles.header}>
        <span>Latest plugin release</span>
      </div>
      <pre style={styles.codeBlock}>
        {'https://github.com/' + owner + '/' + repo + '.git#<release-tag>'}
      </pre>
      <div style={styles.fallbackNote}>
        Automatic release lookup is temporarily unavailable. Replace the placeholder with the
        current tag from the{' '}
        <a href={releasesUrl} target="_blank" rel="noopener" style={styles.fallbackLink}>releases page</a>.
      </div>
    </div>;
  return <div className="not-prose" style={{
    margin: '1rem 0',
    fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif'
  }}>
      {error ? renderFallback() : renderUsageBlock()}
    </div>;
};

Buildkite runs CI/CD pipelines on agents that you host. Use the [Endor Labs Buildkite plugin](https://github.com/endorlabs/endorlabs-buildkite-plugin) to scan your code from any Buildkite step.

The plugin runs as a single Buildkite `post-command` hook. Your step's `command` runs first, then the plugin installs endorctl, authenticates to Endor Labs, and runs the scan. The plugin supports the same scan options and outputs as the [Endor Labs GitHub Action](/setup-deployment/ci-cd/scan-with-github-actions).

To integrate Endor Labs into your Buildkite pipelines:

1. [Install the plugin](#install-the-plugin)
2. [Authenticate to Endor Labs](#authenticate-to-endor-labs)
3. [Configure your pipeline](#configure-your-pipeline)

## Before you begin

Ensure that you complete the following prerequisites before you proceed.

* Have the value of your Endor Labs tenant namespace handy.
* Generate an Endor Labs API key and secret, or use a cloud identity for keyless authentication. See [Managing API keys](/platform-administration/api-keys) to create credentials.
* Install `jq` on your agents if you turn on build annotations.
* On Windows agents, install Git Bash. The plugin hooks delegate to Bash.

<Note>
  The plugin installs endorctl only. Install your build toolchain, such as Java, Node.js, or Bazel, on the agent image or in your step's `command`.
</Note>

## Install the plugin

You can vendor the plugin into your repository or reference the public Git repository in your pipeline. Vendoring gives you a reviewed, pinned copy that also works in air-gapped environments.

<BuildkitePluginRef />

### Vendor the plugin

1. Clone the plugin repository at the latest release tag.

   ```bash theme={null}
   git clone --depth 1 --branch <release-tag> \
     https://github.com/endorlabs/endorlabs-buildkite-plugin.git /tmp/endorlabs-buildkite-plugin
   ```

2. Copy the sync script into your repository.

   ```bash theme={null}
   cp /tmp/endorlabs-buildkite-plugin/scripts/sync-vendor-endorlabs-plugin.sh scripts/
   ```

3. Run the sync script from your repository root.

   ```bash theme={null}
   ENDORLABS_PLUGIN_SRC=/tmp/endorlabs-buildkite-plugin ./scripts/sync-vendor-endorlabs-plugin.sh
   ```

   The script copies the plugin runtime files into `.buildkite/vendor/endorlabs-buildkite-plugin` and writes a `VENDOR_SOURCE.json` record with the source commit.

4. Commit the vendored plugin and the sync script.

   ```bash theme={null}
   git add scripts/sync-vendor-endorlabs-plugin.sh .buildkite/vendor/endorlabs-buildkite-plugin
   ```

5. Reference the vendored plugin in your pipeline steps.

   ```yaml theme={null}
   plugins:
     - ./.buildkite/vendor/endorlabs-buildkite-plugin:
         namespace: "${ENDOR_NAMESPACE}"
   ```

To update the plugin, run the sync script again from a newer checkout and commit the changes.

### Reference the public Git repository

Reference the plugin directly from GitHub, pinned to a release tag. Copy the current pinned reference from the card above.

```yaml theme={null}
plugins:
  - https://github.com/endorlabs/endorlabs-buildkite-plugin.git#<release-tag>:
      namespace: "${ENDOR_NAMESPACE}"
```

## Authenticate to Endor Labs

The plugin reads credentials from environment variables on the agent. You pass the variable names, not the values, so secrets never appear in your pipeline YAML or on the endorctl command line.

### Use API credentials

Store your credentials as Buildkite cluster secrets:

1. In Buildkite, go to **Agents** > your cluster > **Secrets**.
2. Create a secret named `ENDOR_API_CREDENTIALS_KEY` with your API key as the value.
3. Create a secret named `ENDOR_API_CREDENTIALS_SECRET` with your API key secret as the value.
4. Optional: Create a secret named `ENDOR_NAMESPACE` with your tenant namespace to keep it out of your pipeline YAML.

Expose the secrets to your build and pass the variable names to the plugin:

```yaml theme={null}
secrets:
  - ENDOR_NAMESPACE
  - ENDOR_API_CREDENTIALS_KEY
  - ENDOR_API_CREDENTIALS_SECRET

steps:
  - label: "Build and scan"
    command: "make build"
    plugins:
      - ./.buildkite/vendor/endorlabs-buildkite-plugin:
          namespace: "${ENDOR_NAMESPACE}"
          api_key_env: ENDOR_API_CREDENTIALS_KEY
          api_secret_env: ENDOR_API_CREDENTIALS_SECRET
```

For more information, refer to [Buildkite cluster secrets](https://buildkite.com/docs/agent/v3/clusters/secrets).

### Use keyless authentication

If your agents run in AWS, GCP, or Azure, you can authenticate without storing Endor Labs API credentials:

* Set `aws_role_arn` on agents with ambient AWS credentials, such as an EC2 instance profile or EKS IRSA.
* Set `gcp_service_account` to your Endor Labs federation service account on GCP agents.
* Set `enable_azure_managed_identity: true` on Azure agents with a managed identity.

Each cloud option is mutually exclusive with API credentials. See [Keyless authentication](/setup-deployment/ci-cd/keyless-authentication) to set up an authorization policy for your cloud identity.

<Note>
  endorctl doesn't accept Buildkite OIDC tokens for authentication, and GitHub keyless authentication works only in GitHub Actions. On Buildkite, use API credentials or cloud keyless authentication on the agent.
</Note>

Buildkite agents can issue OIDC tokens with `buildkite-agent oidc` to federate with cloud providers. On AWS, you can exchange that token for AWS credentials and then set `aws_role_arn`, so the AWS identity authenticates to Endor Labs. For more information, refer to [OIDC with AWS in Buildkite](https://buildkite.com/docs/pipelines/security/oidc/aws).

## Configure your pipeline

The following example builds a project and scans it for dependency and secrets findings, with a build annotation for the results.

```yaml expandable theme={null}
secrets:
  - ENDOR_NAMESPACE
  - ENDOR_API_CREDENTIALS_KEY
  - ENDOR_API_CREDENTIALS_SECRET

steps:
  - label: ":hammer: Build and scan"
    command: "mvn clean install"
    plugins:
      - ./.buildkite/vendor/endorlabs-buildkite-plugin:
          namespace: "${ENDOR_NAMESPACE}"
          api_key_env: ENDOR_API_CREDENTIALS_KEY
          api_secret_env: ENDOR_API_CREDENTIALS_SECRET
          scan_dependencies: true
          scan_secrets: true
          annotate: true
```

Customize the example for your project:

1. Replace `command` with your project's build steps. The scan starts after the command completes.
2. Replace the plugin reference with the pinned public Git reference if you don't vendor the plugin.
3. Turn on the scan types you need, such as `scan_sast` or `scan_secrets`.

To verify the integration, run the pipeline and confirm that the build log shows `Running endorctl scan`.

## Set up PR scans and branch tracking

The plugin reads the Buildkite build environment and passes branch and pull request context to endorctl automatically:

<YamlTable>
  {`


    - Buildkite context: \`BUILDKITE_BRANCH\`
    endorctl flags: \`--detached-ref-name\`

    - Buildkite context: \`BUILDKITE_PULL_REQUEST\` (numeric)
    endorctl flags: \`--pr=true\`

    - Buildkite context: \`BUILDKITE_PULL_REQUEST_BASE_BRANCH\`
    endorctl flags: \`--pr-baseline\`. Not passed when \`pr_baseline\` is set or \`enable_pr_comments\` is \`true\`.

    - Buildkite context: \`enable_pr_comments: true\` with a numeric \`BUILDKITE_PULL_REQUEST\`
    endorctl flags: \`--scm-pr-id\` and \`--scm-token\` (from \`scm_token_env\`)


    `}
</YamlTable>

Buildkite agents often check out commits in a detached HEAD state. You don't need to configure branch tracking manually because the plugin passes the branch name from `BUILDKITE_BRANCH`.

Builds for pull requests run as PR scans. On other builds, Buildkite sets `BUILDKITE_PULL_REQUEST` to `false` and the scan records a monitored version. Set `pr: false` to record a monitored scan even on a pull request build, or set `pr_baseline` to force a PR scan without one. See [PR scans](/scan/pr-scans) to learn how Endor Labs tracks point-in-time scans.

To post new findings as pull request comments, set `enable_pr_comments: true` and set `scm_token_env` to the name of an environment variable that holds your SCM token. The token must be a personal access token or bot token from your SCM provider. See [PR comments](/scan/pr-scans/pr-comments) to learn how comments appear on pull requests.

## Annotate builds with scan results

Set `annotate: true` to post a build annotation when the scan completes. The annotation shows severity counts, admission policy status, and a findings table scoped to the scan types enabled on that step. Annotations require `jq` on the agent and the default JSON output.

Use `annotate_findings_limit` to control the table size. The value `-1` lists all critical and high findings, `0` shows severity counts only, and a positive number adds up to that many medium and low rows.

<img src="https://mintcdn.com/endorlabs-b4795f4f/OG8id87v-LJxY7hK/images/setup-deployment/ci-cd/buildkite-plugin-annotation.webp?fit=max&auto=format&n=OG8id87v-LJxY7hK&q=85&s=2520253f74e6e055e6014319952500af" alt="Buildkite build with parallel Endor Labs scan steps and a job-scoped dependencies scan annotation showing severity counts, admission policy status, and a findings table" width="2623" height="1711" data-path="images/setup-deployment/ci-cd/buildkite-plugin-annotation.webp" />

In pipelines that run scan types in parallel steps, set `annotate_scope: job` to attach each annotation to its own step. Job-scoped annotations require Buildkite agent v3.112 or later.

## Control build failures

When a blocking admission policy matches, endorctl exits with code `128` and the step fails. This is the default behavior, controlled by `fail_on_policy: true`.

* Set `fail_on_policy: false` to treat a blocking admission policy as success.
* Set `soft_fail: true` to soften other nonzero exits. It doesn't bypass exit `128` while `fail_on_policy` is `true`.
* Set `exit_on_policy_warning: true` to also fail the step on warning policies.

See [endorctl exit codes](/best-practices/troubleshooting/endorctl-exitcodes) for the full list of exit codes.

## Run SAST scans

### SAST scan

Set `scan_sast: true` to scan your source code for security weaknesses.

```yaml theme={null}
plugins:
  - ./.buildkite/vendor/endorlabs-buildkite-plugin:
      namespace: "${ENDOR_NAMESPACE}"
      api_key_env: ENDOR_API_CREDENTIALS_KEY
      api_secret_env: ENDOR_API_CREDENTIALS_SECRET
      scan_sast: true
```

### AI SAST triage agent scan

To run an AI SAST triage agent scan, set `scan_sast: true` and set `additional_args` to `--ai-sast-analysis=agent-fallback`. The AI SAST triage agent automatically classifies findings as true positives or false positives, reducing the need for manual triage.

```yaml theme={null}
plugins:
  - ./.buildkite/vendor/endorlabs-buildkite-plugin:
      namespace: "${ENDOR_NAMESPACE}"
      api_key_env: ENDOR_API_CREDENTIALS_KEY
      api_secret_env: ENDOR_API_CREDENTIALS_SECRET
      scan_sast: true
      additional_args: "--ai-sast-analysis=agent-fallback"
```

### AI SAST detection agent scan

To run an AI SAST detection agent scan, set `additional_args` to `--ai-sast`. The AI SAST detection agent identifies security vulnerabilities beyond traditional rule-based SAST detection.

```yaml theme={null}
plugins:
  - ./.buildkite/vendor/endorlabs-buildkite-plugin:
      namespace: "${ENDOR_NAMESPACE}"
      api_key_env: ENDOR_API_CREDENTIALS_KEY
      api_secret_env: ENDOR_API_CREDENTIALS_SECRET
      additional_args: "--ai-sast"
```

See [SAST scans](/scan/sast) to learn about rules and findings.

## Scan container images

Container scans run in a separate step from repository scans. Set `scan_container: true` with an `image` or `image_tar`, and turn off `scan_dependencies`.

```yaml theme={null}
  - label: ":docker: Scan container image"
    command: "docker build -t my-app:latest ."
    plugins:
      - ./.buildkite/vendor/endorlabs-buildkite-plugin:
          namespace: "${ENDOR_NAMESPACE}"
          api_key_env: ENDOR_API_CREDENTIALS_KEY
          api_secret_env: ENDOR_API_CREDENTIALS_SECRET
          scan_container: true
          scan_dependencies: false
          image: "my-app:latest"
          project_name: "my-app"
```

Set `os_reachability: true` to identify which packages in the image are used at runtime. See [Scan container images](/scan/containers/scan-containers-using-endorctl) and [Container reachability](/scan/containers/container-reachability) for details.

## Sign and verify artifacts

Set `mode: sign` or `mode: verify` with an `artifact_name` to run artifact signing workflows instead of a scan. Scan options aren't valid in these modes. See [Artifact signing](/scan/containers/artifact-signing) to learn about signing and verification.

## Plugin configuration reference

The following tables load directly from the plugin's [plugin.yml](https://github.com/endorlabs/endorlabs-buildkite-plugin/blob/main/plugin.yml) at the latest release, so they always reflect the current plugin version.

### Common options

These options apply to every plugin invocation. Only `namespace` is required.

<BuildkitePluginOptions group="common" showVersion={true} />

### Authentication options

Use one authentication method per step: API credentials or one cloud keyless option.

<BuildkitePluginOptions group="authentication" />

### Scan types

Turn on at least one scan type when `mode` is `scan`.

<BuildkitePluginOptions group="scan-types" />

### Scan configuration options

These options adjust what a scan covers and how projects are named and tagged.

<BuildkitePluginOptions group="scan-configuration" />

### Container scan options

These options apply when `scan_container` is `true`.

<BuildkitePluginOptions group="container-scans" />

### PR scan options

These options control PR scan detection.

<BuildkitePluginOptions group="pr-scans" />

### Annotation and output options

These options control build annotations, output formats, and uploaded artifacts.

<BuildkitePluginOptions group="annotations-output" />

### Build failure options

These options decide when a scan fails the step.

<BuildkitePluginOptions group="failure-control" />

### Artifact signing options

These options apply when `mode` is `sign` or `verify`.

<BuildkitePluginOptions group="artifact-signing" />

<BuildkitePluginOptions group="other" heading="Other options" />
