> ## 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.

# AI SAST PR scans

> Run incremental AI SAST scans on pull requests to surface only the new findings introduced by the change.

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 LicenseBadge = ({sku, skus, relation = 'any'}) => {
  const DATA_URL = '/snippets/license-sku-data.json';
  const CACHE_KEY = 'license-sku-data';
  const CACHE_TTL_MS = 60 * 60 * 1000;
  const REGISTRY_KEY = '__licenseSkuRegistry';
  const FALLBACK_LICENSES_URL = '/introduction/licenses';
  const ACCENT = '#26D07C';
  const CONJUNCTION = {
    any: 'or',
    all: 'and'
  };
  const FONT_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif';
  const [isDark, setIsDark] = useState(false);
  const [data, setData] = useState(null);
  const [hasFetchError, setHasFetchError] = useState(false);
  const skuList = useMemo(() => {
    if (Array.isArray(skus) && skus.length > 0) {
      return skus.filter(code => typeof code === 'string' && code.trim()).map(c => c.trim());
    }
    if (typeof sku === 'string' && sku.trim()) {
      return [sku.trim()];
    }
    return [];
  }, [sku, skus]);
  useEffect(() => {
    const check = () => {
      const root = document.documentElement;
      setIsDark(root.dataset.theme === 'dark' || root.classList.contains('dark'));
    };
    check();
    const observer = new MutationObserver(check);
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ['data-theme', 'class']
    });
    return () => observer.disconnect();
  }, []);
  useEffect(() => {
    let cancelled = false;
    const readCache = () => {
      try {
        const raw = sessionStorage.getItem(CACHE_KEY);
        if (!raw) return null;
        const parsed = JSON.parse(raw);
        if (Date.now() - parsed.ts > CACHE_TTL_MS) {
          sessionStorage.removeItem(CACHE_KEY);
          return null;
        }
        return parsed.data;
      } catch (e) {
        return null;
      }
    };
    const writeCache = value => {
      try {
        sessionStorage.setItem(CACHE_KEY, JSON.stringify({
          ts: Date.now(),
          data: value
        }));
      } catch (e) {}
    };
    const fetchSkuData = async () => {
      const cached = readCache();
      if (cached) return cached;
      if (!globalThis[REGISTRY_KEY]) {
        globalThis[REGISTRY_KEY] = {};
      }
      const registry = globalThis[REGISTRY_KEY];
      if (registry[CACHE_KEY]) return registry[CACHE_KEY];
      const promise = (async () => {
        const resp = await fetch(DATA_URL);
        if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
        const json = await resp.json();
        writeCache(json);
        return json;
      })();
      registry[CACHE_KEY] = promise;
      promise.finally(() => {
        delete registry[CACHE_KEY];
      });
      return promise;
    };
    fetchSkuData().then(d => {
      if (!cancelled) setData(d);
    }).catch(() => {
      if (!cancelled) setHasFetchError(true);
    });
    return () => {
      cancelled = true;
    };
  }, []);
  const textColor = isDark ? '#e6edf3' : '#1f2937';
  const textMuted = isDark ? 'rgba(230,237,243,0.65)' : 'rgba(31,41,55,0.65)';
  const bannerBackground = isDark ? '#161b22' : '#f6f8fa';
  const borderColor = isDark ? 'rgba(38,208,124,0.35)' : 'rgba(38,208,124,0.45)';
  const linkColor = isDark ? '#4ade80' : '#047857';
  const errorBackground = isDark ? '#3b1111' : '#fef2f2';
  const errorBorder = isDark ? '#7f1d1d' : '#fecaca';
  const errorText = isDark ? '#fecaca' : '#7f1d1d';
  const licensesUrl = data?.licensesPageUrl || FALLBACK_LICENSES_URL;
  const resolveSkuName = code => {
    const entry = data?.skus?.[code];
    if (entry?.name) return entry.name;
    return null;
  };
  const formatSkuLabel = code => {
    const name = resolveSkuName(code);
    if (name) return name;
    return code;
  };
  const joinWithConjunction = (codes, conjunction) => {
    const labels = codes.map(formatSkuLabel);
    if (labels.length === 0) return '';
    if (labels.length === 1) return labels[0];
    if (labels.length === 2) return `${labels[0]} ${conjunction} ${labels[1]}`;
    const head = labels.slice(0, -1).join(', ');
    const tail = labels[labels.length - 1];
    return `${head}, ${conjunction} ${tail}`;
  };
  const buildSkuSentence = codes => {
    const conj = CONJUNCTION[relation] || CONJUNCTION.any;
    const names = joinWithConjunction(codes, conj);
    const noun = codes.length > 1 ? 'licenses' : 'license';
    return `${names} ${noun}`;
  };
  const renderLink = text => <a href={licensesUrl} className="lic-link" style={{
    color: linkColor,
    fontSize: '0.75rem',
    fontWeight: 500,
    textDecoration: 'none',
    whiteSpace: 'nowrap'
  }}>
      {text} →
    </a>;
  const renderBanner = codes => <div className="lic-banner not-prose" style={{
    margin: '1rem 0',
    padding: '0.5rem 0.85rem',
    background: bannerBackground,
    border: `1px solid ${borderColor}`,
    borderLeft: `3px solid ${ACCENT}`,
    borderRadius: '6px',
    color: textColor,
    fontSize: '0.75rem',
    display: 'flex',
    alignItems: 'center',
    gap: '0.5rem',
    flexWrap: 'wrap',
    fontFamily: FONT_STACK,
    lineHeight: 1.5
  }}>
      <span style={{
    flex: 1,
    minWidth: 0
  }}>
        <span style={{
    color: textMuted,
    marginRight: '0.3rem'
  }}>Requires</span>
        <span style={{
    fontWeight: 600
  }}>{buildSkuSentence(codes)}</span>
      </span>
      {renderLink('Licenses')}
    </div>;
  const renderLoading = () => <div className="not-prose" style={{
    margin: '1rem 0',
    padding: '0.6rem 0.9rem',
    background: bannerBackground,
    border: `1px dashed ${borderColor}`,
    borderRadius: '6px',
    color: textMuted,
    fontSize: '0.85rem',
    fontStyle: 'italic',
    fontFamily: FONT_STACK
  }} role="status" aria-live="polite">
      Loading license info…
    </div>;
  const renderInputError = message => <div className="not-prose" style={{
    margin: '1rem 0',
    padding: '0.6rem 0.9rem',
    background: errorBackground,
    border: `1px solid ${errorBorder}`,
    borderRadius: '6px',
    color: errorText,
    fontSize: '0.85rem',
    fontFamily: FONT_STACK
  }} role="alert">
      {message}
    </div>;
  if (typeof sku === 'string' && Array.isArray(skus)) {
    return renderInputError('LicenseBadge: pass either `sku` or `skus`, not both.');
  }
  if (skuList.length === 0) {
    return renderInputError('LicenseBadge: `sku` or `skus` is required.');
  }
  if (hasFetchError) return renderBanner(skuList);
  if (!data) return renderLoading();
  return renderBanner(skuList);
};

<LicenseBadge sku="EL-CODE-PRO" />

AI SAST PR scans run incremental security analysis on pull requests against an established baseline scan of the target branch. They analyze only the code changed by the pull request, deduplicate findings against the baseline, and surface only the net new findings that the pull request introduces. When the pull request is merged, the new findings are added to the baseline so that future PR scans compare against the latest mainline state. An average AI SAST PR scan completes within about five minutes.

AI SAST PR scans run the [detection agent](/scan/ai-sast/detection-agent) on the pull request.

AI SAST PR scans help you to:

* Scan only the code changed by the pull request, with results in a few minutes.
* Surface only net new, auto-triaged findings, and filter out false positives.
* Review findings inline on the pull request through PR comments and in PR Runs.
* Block risky merges or fail CI through action policies on the resulting findings.

You can run AI SAST PR scans in the following ways:

* [From the CLI using endorctl](#run-ai-sast-pr-scans-from-the-cli) to run a PR scan against a baseline branch from your terminal.
* [From a CI pipeline](#run-ai-sast-pr-scans-from-ci) to invoke `endorctl scan` as part of a pull request job.
* [Through SCM apps](#run-ai-sast-pr-scans-through-scm-apps) to trigger PR scans automatically when a pull request is opened or updated.

## How AI SAST PR scans work

An AI SAST PR scan runs in three stages.

<Steps>
  <Step title="Build the baseline">
    AI SAST processes the full repository on the target branch and stores all detection-agent findings as the baseline. See [Establish a baseline scan](#establish-a-baseline-scan).
  </Step>

  <Step title="Scan the PR diff">
    A pull request triggers a PR scan that analyzes only the code changed by the pull request, using the full-repository context that the baseline established.
  </Step>

  <Step title="Surface net new findings">
    Findings are fingerprinted and matched against the baseline. Issues that already exist in the baseline are filtered out, and only net new findings appear in the PR result, so reviewers see exactly what the pull request introduces.
  </Step>
</Steps>

## Establish a baseline scan

The baseline is the reference point every AI SAST PR scan compares against. It defines the known security state of the target branch. Therefore, the PR scan can isolate net new findings from issues that already exist. The baseline gives the AI SAST agents the full-repository context they need to reason about the changed code. Without a baseline, every AI SAST finding on the pull request would surface as new, including pre-existing issues, and the PR result would not be reviewable.

We recommend that you run the first baseline scan from the command line so the baseline is in place before the first PR scan is requested. Replace `/path/to/code` with the path to your repository and `<namespace>` with your Endor Labs namespace.

```bash theme={null}
endorctl scan --ai-sast --path=/path/to/code -n <namespace>
```

When a pull request is merged, the net new findings from the PR scan are automatically added to the baseline. This keeps PR scans and baseline scans consistent for the next round of pull requests.

## Run AI SAST PR scans from the CLI

You can run AI SAST PR scans using endorctl for GitHub and GitLab. The `--ai-sast --pr --pr-baseline=<target-branch>` combination runs the AI SAST detection agent on the changed code, deduplicates findings against the baseline, and records the results as a PR Run that does not affect main-branch monitoring scans and reports. Endor Labs stores PR and MR scan findings in PR Runs for three weeks, after which they are removed to accommodate new PR scans.

Run the following command after you commit to a pull request or merge request. Ensure that you install and configure endorctl before running scans from the command line.

```bash theme={null}
endorctl scan --ai-sast --pr --pr-baseline=<target-branch> --path=/path/to/code -n <namespace>
```

Replace `<target-branch>` with the pull request's target branch, such as `main`. For the full list of pull request flags, see [Pull request (CI) flags](/developers-api/cli/commands/scan#pull-request-ci-flags) to learn more.

AI SAST PR scans are always incremental and analyze only the code changed by the pull request. Do not set `--diff-scope` in PR scan commands. That flag is for [local diff scans](/scan/ai-sast#ai-sast-diff-scans).

## Run AI SAST PR scans from CI

AI SAST PR scans can be run from a continuous integration pipeline by invoking `endorctl scan` with the `--ai-sast`, `--pr`, and `--pr-baseline` flags in jobs triggered by pull request or merge request events. This approach provides control over when scans run, supports posting findings as PR or MR comments, and allows policies to be enforced, such as failing builds or blocking merges. The scan profile assigned to the project determines the toolchains and environment used for the scan.

For platform-specific YAML and command syntax, see:

* [AI SAST PR scan in GitHub Actions](/setup-deployment/ci-cd/scan-with-github-actions#scanning-parameters)
* [AI SAST MR scan in GitLab](/setup-deployment/ci-cd/scan-with-gitlab#ai-sast-mr-scan)
* [AI SAST PR scan in Bitbucket Pipelines](/setup-deployment/ci-cd/scan-with-bitbucket#ai-sast-pr-scan)

A baseline AI SAST scan must exist on the target branch before any PR scan can produce findings. See [Establish a baseline scan](#establish-a-baseline-scan).

## Run AI SAST PR scans through SCM apps

The Endor Labs GitHub and GitLab apps can run AI SAST PR scans automatically on every pull request, without CI pipeline configuration. PR comments post the net new findings introduced by a pull request as a PR comment in GitHub and as an MR comment in GitLab, so developers see results in the SCM UI without leaving their review flow. Configure [Action policies](/platform-administration/policies/action-policies) to post PR comments. See [Pull request comments](/scan/pr-scans/pr-comments) to learn more.

To configure AI SAST PR scans:

1. Install the [Endor Labs GitHub app](/setup-deployment/scm-integrations/github-app), [Endor Labs GitLab app](/setup-deployment/scm-integrations/gitlab-app/gitlab-mr-scan), [Bitbucket Cloud app](/setup-deployment/scm-integrations/bitbucket-cloud/bitbucket-cloud-pr-scans), or [Bitbucket Data Center app](/setup-deployment/scm-integrations/bitbucket-datacenter-app/bitbucket-datacenter-pr-scans).
2. Enable PR scans and PR comments during installation: [GitHub App Pro](/setup-deployment/scm-integrations/github-app#configure-pr-scans-during-github-app-pro-installation), [GitLab App](/setup-deployment/scm-integrations/gitlab-app/gitlab-mr-scan), [Bitbucket Cloud app](/setup-deployment/scm-integrations/bitbucket-cloud/bitbucket-cloud-pr-scans), or [Bitbucket Data Center app](/setup-deployment/scm-integrations/bitbucket-datacenter-app/bitbucket-datacenter-pr-scans).
3. [Create a scan profile](/scan/scan-profiles/configure-scanprofile-ui) and add `ENDOR_SCAN_AI_SAST=true` to the **Environment Variables**.
4. [Associate the scan profile with the project](/scan/scan-profiles/configure-scanprofile-ui#associate-projects-with-a-scan-profile).
5. Open or update a pull request on the configured repository to trigger an AI SAST PR scan.

If the project does not yet have a baseline AI SAST scan, the platform automatically triggers one when the first PR scan is requested. The PR scan does not produce results or post PR comments until the baseline completes. The first baseline scan takes longer than subsequent scans because it must process the full repository.
