Apps Script automation

동기

처음에는 Snyk의 공식 API만으로도 충분히 자동화가 가능할 거라 생각했습니다. 하지만 실제로는 “How to Fix”, “Overview”, 그리고 안전한 버전 정보 등 많은 유용한 데이터가 웹 UI에만 노출되어 있었고, API로는 모두 수집할 수 없었습니다.

그래서 저는 Gmail과 Google Apps Script를 활용해 직접 자동 수집기를 만들었습니다. 이 스크립트는 “no remediation available yet"라는 문구가 포함된 이메일을 읽고, 취약점 페이지에 접근하여 관련 정보를 추출합니다.


주요 기능

  1. Gmail에서 “no remediation available yet” 문구가 포함된 Snyk 알림 이메일 검색

  2. 취약점 상세 페이지로 리디렉션된 링크 추적

  3. 아래 정보 자동 파싱:

    • 취약점 이름 및 링크
    • 영향 받는 패키지 및 버전
    • 해결 방법 (FAQ JSON-LD 기반)
    • Overview 텍스트 및 참고 링크
    • 최신 버전 정보 (latest, non-vulnerable, 배포일)
  4. 모든 정보를 Google Sheets에 저장


스크린샷 예시

Gmail 필터링 결과

Gmail filtered search

Apps Script 로그 확인

Apps Script code and logs

출력된 구글 시트

Output sheet


전체 코드

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
function extractSnykNoFixToSheet() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  sheet.clearContents();
  sheet.appendRow([
    "Date", "Subject", "Project", "Vulnerability", "Vuln Link",
    "Package", "Version", "Snyk Package Link",
    "How to Fix", "Overview Text", "Overview Links", "References",
    "Latest Ver", "Non-Vuln Ver", "First Published", "Latest Published"
  ]);

  const threads = GmailApp.search('"no remediation available yet"');

  threads.forEach(thread => {
    thread.getMessages().forEach(msg => {
      const date = msg.getDate();
      const subject = msg.getSubject();
      const body = msg.getBody();

      const iconBlockMatch = body.match(/<img[^>]+icon-cli\.webp[^>]*>[\s\S]*?<strong[^>]*>(.*?)<\/strong>/i);
      const project = iconBlockMatch ? iconBlockMatch[1].trim() : "";

      const vulnMatch = body.match(/<img[^>]+icon-vuln\.webp[^>]*>[\s\S]{0,300}?<a[^>]+href="([^"]+)"[^>]*>(.*?)<\/a>/i);
      let vulnUrl = vulnMatch ? vulnMatch[1].trim() : "";
      const vulnName = vulnMatch ? vulnMatch[2].trim() : "";

      const packageMatch = body.match(/Vulnerability in (@?[a-zA-Z0-9_.:\/\-]+)\s+([0-9][a-zA-Z0-9.\-_]*)/);
      const pkgName = packageMatch ? packageMatch[1].trim() : "";
      const pkgVer = packageMatch ? packageMatch[2].trim() : "";

      let howToFix = "", overviewText = "", overviewLinks = "", references = "", subtitleMatch, snykPkgLink;
      let latestVer = "", nonVulnVer = "", firstPublished = "", latestPublished = "";

      try {
        Logger.log(`🔗 Trying redirect fetch: ${vulnUrl}`);
        const resp = UrlFetchApp.fetch(vulnUrl, {
          followRedirects: false,
          muteHttpExceptions: true
        });

        const status = resp.getResponseCode();
        const headers = resp.getAllHeaders();
        const redirected = headers["Location"] || headers["location"] || vulnUrl;

        Logger.log(`📥 Response Code: ${status}`);
        Logger.log(`📎 Location Header: ${redirected}`);

        vulnUrl = redirected;
      } catch (e) {
        Logger.log(`🔥 Exception during redirect check for ${vulnUrl}: ${e}`);
      }

      try {
        const html = UrlFetchApp.fetch(vulnUrl).getContentText();
        Logger.log(`📄 HTML content preview (first 1000 chars):\n${html.slice(0, 1000)}`);

        subtitleMatch = html.match(/<span[^>]*subheading[^>]*>.*?<a[^>]+href="([^"]+)"[^>]*>(.*?)<\/a>/i);
        snykPkgLink = subtitleMatch ? "https://security.snyk.io" + subtitleMatch[1] : "";
        Logger.log(`🔎 subtitleMatch: ${subtitleMatch}`);
        Logger.log(`🔗 snykPkgLink: ${snykPkgLink}`);

        howToFix = extractFixFromScriptJson(html);
        Logger.log(`✅ How to Fix: ${howToFix}`);

        const overviewResult = extractSectionLinks(html, "Overview");
        overviewText = overviewResult.text;
        overviewLinks = overviewResult.links.join(", ");
        Logger.log(`✅ Overview Text: ${overviewText}`);
        Logger.log(`✅ Overview Links: ${overviewLinks}`);

        const refsResult = extractSectionLinks(html, "References");
        references = refsResult.links.join(", ");
        Logger.log(`✅ References: ${references}`);

        if (snykPkgLink) {
          const pkgHtml = UrlFetchApp.fetch(snykPkgLink).getContentText();
          const valueFromLabel = (label) => {
            const allMatches = [...pkgHtml.matchAll(/<li[^>]*data-snyk-test="DetailsBoxItem: ([^"]+)"[^>]*>[\s\S]*?<h3[^>]*>(.*?)<\/h3>[\s\S]*?<[^>]+>(.*?)<\//g)];
            for (const m of allMatches) {
              if (m[2]?.toLowerCase().includes(label)) return m[3].replace(/<[^>]+>/g, "").trim();
            }
            return "";
          };
          latestVer = valueFromLabel("latest version");
          nonVulnVer = valueFromLabel("latest non vulnerable version");
          firstPublished = valueFromLabel("first published");
          latestPublished = valueFromLabel("latest version published");
          Logger.log(`📦 Snyk Versions - Latest: ${latestVer}, Non-Vuln: ${nonVulnVer}, First: ${firstPublished}, Latest Pub: ${latestPublished}`);
        }
      } catch (e) {
        Logger.log(`🔥 Exception fetching redirected content for ${vulnUrl}: ${e}`);
      }

      const row = [date, subject, project, vulnName, vulnUrl, pkgName, pkgVer, snykPkgLink, howToFix, overviewText, overviewLinks, references, latestVer, nonVulnVer, firstPublished, latestPublished];
      sheet.appendRow(row);
    });
  });
}

function extractFixFromScriptJson(html) {
  const matches = [...html.matchAll(/<script[^>]+type="application\/ld\+json"[^>]*>(.*?)<\/script>/g)];
  for (const match of matches) {
    try {
      const json = JSON.parse(match[1]);
      const graph = json["@graph"] || [];
      for (const node of graph) {
        if (node["@type"] === "FAQPage" && node.mainEntity?.length) {
          for (const q of node.mainEntity) {
            if (q.name?.toLowerCase().includes("how to fix") && q.acceptedAnswer?.text) {
              return q.acceptedAnswer.text.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
            }
          }
        }
      }
    } catch (e) {
      Logger.log("❌ Failed to parse How to Fix from JSON-LD block: " + e);
    }
  }
  Logger.log("❌ No matching How to Fix found in JSON-LD blocks");
  return "";
}

function extractSectionLinks(html, sectionTitle) {
  const pattern = new RegExp(`<h2[^>]*>\\s*.{0,10}${sectionTitle}.{0,10}\\s*<\\/h2>[\\s\\S]{0,2000}?<div[^>]*class=\"markdown-to-html[^"]*\"[^>]*>([\\s\\S]*?)<\\/div>`, "gi");
  const matches = [...html.matchAll(pattern)];
  if (matches.length === 0) {
    Logger.log(`❌ Section "${sectionTitle}" not found.`);
    return { text: "", links: [] };
  }

  const content = matches[0][1];
  Logger.log(`🔍 Matched HTML block for ${sectionTitle}:\n${content.slice(0, 500)}`);

  const fragment = HtmlService.createHtmlOutput(content).getContent();
  const linkMatches = [...fragment.matchAll(/<a[^>]+href="([^"]+)"[^>]*>/g)];
  const links = linkMatches.map(m => m[1]);

  const text = content.replace(/<[^>]+>/g, "").replace(/\s+/g, ' ').trim();
  return { text, links };
}

작동 방식 요약

1. Gmail에서 알림 검색

const threads = GmailApp.search('"no remediation available yet"');

2. 취약점 메타데이터 추출

const vulnMatch = body.match(...);

3. 리디렉션 링크 따라가기

const resp = UrlFetchApp.fetch(vulnUrl, { followRedirects: false });
vulnUrl = resp.getAllHeaders()["Location"];

4. JSON-LD에서 “How to Fix” 추출

const json = JSON.parse(match[1]);

5. Overview 및 참고 링크 파싱

extractSectionLinks(html, "Overview");

6. 패키지 메타데이터 수집

const pkgHtml = UrlFetchApp.fetch(snykPkgLink).getContentText();

실제 활용 사례

이 툴을 이용하여 패치가 존재하지 않는 유지보수 중단 오픈소스 라이브러리에 대한 조치 가이드를 작성해 공개했습니다:


직접 사용해 보기

  • Snyk 알림을 받을 수 있는 Gmail 계정 사용
  • Google Apps Script에 스크립트 작성
  • extractSnykNoFixToSheet() 함수 붙여넣기
  • 실행 후 Google Sheet 결과 확인

API 키도, Playwright나 Puppeteer도 필요 없습니다. 이메일과 스크립트만으로 충분합니다.

✋ 비슷한 경험 있으신가요? 여러분의 방식도 공유해 주세요!