DEV Community

zk0x /// ℹ️
zk0x /// ℹ️

Posted on

I Abandoned a Security Tool for 8 Months. Then I Finished It in 72 Hours with GitHub Copilot.

GitHub “Finish-Up-A-Thon” Challenge Submission

This is a submission for the GitHub Finish-Up-A-Thon Challenge

The Project I Abandoned

Eight months ago, I started building RepoRadar — a CLI tool that monitors GitHub repositories and sends intelligent alerts about dependency vulnerabilities, license changes, and abandoned dependencies.

The idea was simple: every week, I'd discover that some critical npm package in my project had a new CVE, changed its license from MIT to some restrictive thing, or quietly stopped getting commits. I was tired of finding out about these problems from Twitter threads or Hacker News, weeks after the damage was done.

So I built a prototype. It worked — kind of. It could scan a package.json, fetch vulnerability data from the GitHub Advisory Database, and print a report. Then I got busy with other things, and RepoRadar sat untouched in a private repo for 8 months.

Last week, I decided to finish it. Not just "make it work" — but turn it into something I'd actually use every day. With GitHub Copilot as my pair programmer, I took RepoRadar from a broken prototype to a published npm package in 72 hours.

Here's exactly what happened, what broke, and what I learned.

Before: The Graveyard

Let me be honest about what the codebase looked like when I came back to it.

repo-radar/
├── src/
│   ├── index.js          # 347 lines of spaghetti
│   ├── scanner.js         # Half-finished, TODO comments everywhere
│   ├── reporter.js        # Only worked for npm, not pip or cargo
│   └── config.js          # Hardcoded GitHub token (yes, really)
├── package.json           # Dependencies from 8 months ago
├── README.md              # "WIP" literally in the title
└── .env                   # With my actual GitHub token committed
Enter fullscreen mode Exit fullscreen mode

The problems were everywhere:

Security disaster. My GitHub Personal Access Token was hardcoded in config.js and committed to the repo. Not in .env — in the actual source code. Past me was in a hurry. Past me made bad decisions.

Single-ecosystem support. The scanner only understood package.json. I had TODO comments for requirements.txt, Cargo.toml, and go.mod, but never got to them.

No tests. Zero. Not a single test file. The kind of codebase where you change one line and pray.

Broken dependencies. Eight months in npm-land is an eternity. Three of my dependencies had major version bumps with breaking changes. The lockfile was from another dimension.

Output was ugly. The reporter dumped raw JSON to stdout. No colors, no tables, no summary. You had to squint at a wall of JSON to figure out if your project was on fire.

Here's what the scanner code actually looked like:

// src/scanner.js - the "before"
const fetch = require('node-fetch');
const fs = require('fs');

async function scanPackageJson(filePath) {
  // TODO: handle errors lol
  const content = fs.readFileSync(filePath, 'utf8');
  const pkg = JSON.parse(content);
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };

  const results = [];
  for (const [name, version] of Object.entries(deps)) {
    // TODO: rate limiting? pagination?
    const resp = await fetch(
      `https://api.github.com/advisories?package=npm/${name}`,
      { headers: { 'Authorization': `token ${process.env.GITHUB_TOKEN}` } }
    );
    // TODO: handle non-200 responses
    const advisories = await resp.json();
    if (advisories.length > 0) {
      results.push({ package: name, version, advisories });
    }
  }
  return results;
}

module.exports = { scanPackageJson };
Enter fullscreen mode Exit fullscreen mode

Every comment is a TODO. Every line is a prayer. No error handling, no rate limiting, no pagination. It would blow up on any project with more than 20 dependencies because of GitHub API rate limits.

Day 1: Cleaning Up the Mess (Hours 0–8)

The first thing I did was the obvious thing: delete the hardcoded token and add proper .env support. GitHub Copilot actually flagged this before I even asked — it suggested adding a .env.example and using dotenv with validation.

// src/config.js - the "after"
import 'dotenv/config';
import { z } from 'zod';

const configSchema = z.object({
  GITHUB_TOKEN: z.string().min(1, 'GITHUB_TOKEN is required'),
  SCAN_INTERVAL_HOURS: z.coerce.number().default(24),
  ECOSYSTEMS: z.string().default('npm,pip,cargo,go'),
  OUTPUT_FORMAT: z.enum(['table', 'json', 'markdown']).default('table'),
  ALERT_WEBHOOK: z.string().url().optional(),
});

export const config = configSchema.parse(process.env);
Enter fullscreen mode Exit fullscreen mode

Zod validation means the tool crashes immediately with a clear error message if the config is wrong, instead of failing mysteriously 10 minutes into a scan.

Next: dependency upgrades. This is where Copilot earned its keep. Instead of manually figuring out what changed in each major version, I asked Copilot to review my package.json and suggest the migration path. It caught three breaking API changes I would have missed:

  1. node-fetch v2 → v3 switched to ESM-only. Copilot suggested switching to the built-in fetch in Node 18+.
  2. chalk v4 → v5 also went ESM-only. Copilot suggested picocolors as a lighter alternative.
  3. My testing framework (jest) was two majors behind. Copilot suggested vitest for native ESM support.

The entire dependency refresh took 2 hours instead of the full day I expected.

Day 2: Multi-Ecosystem Support (Hours 8–20)

This was the biggest missing feature. The original scanner only understood npm's package.json. I wanted to support:

  • npmpackage.json / package-lock.json
  • Pythonrequirements.txt / pyproject.toml / Pipfile.lock
  • RustCargo.toml / Cargo.lock
  • Gogo.mod / go.sum

Each ecosystem has its own package naming convention, version format, and advisory API. Copilot helped me design a clean plugin architecture:

// src/ecosystems/base.ts
export interface EcosystemScanner {
  name: string;
  manifestFiles: string[];
  detect(projectPath: string): Promise<boolean>;
  parseDependencies(projectPath: string): Promise<Dependency[]>;
  checkAdvisories(deps: Dependency[]): Promise<Advisory[]>;
}

export interface Dependency {
  name: string;
  version: string;
  ecosystem: string;
  isDev: boolean;
}

export interface Advisory {
  dependency: Dependency;
  severity: 'critical' | 'high' | 'medium' | 'low';
  title: string;
  url: string;
  patchedVersion?: string;
}
Enter fullscreen mode Exit fullscreen mode

Then each ecosystem implements this interface. Here's the Python scanner:

// src/ecosystems/python.ts
import { parseRequirements } from '../parsers/requirements.js';
import { EcosystemScanner, Dependency, Advisory } from './base.js';
import { queryOSV } from '../advisory/osv.js';

export class PythonScanner implements EcosystemScanner {
  name = 'python';
  manifestFiles = ['requirements.txt', 'pyproject.toml', 'Pipfile'];

  async detect(projectPath: string): Promise<boolean> {
    return this.manifestFiles.some(f => 
      existsSync(join(projectPath, f))
    );
  }

  async parseDependencies(projectPath: string): Promise<Dependency[]> {
    const reqFile = join(projectPath, 'requirements.txt');
    if (existsSync(reqFile)) {
      const content = await readFile(reqFile, 'utf8');
      return parseRequirements(content).map(dep => ({
        ...dep,
        ecosystem: 'python',
        isDev: dep.name.includes('test') || dep.name.includes('dev'),
      }));
    }
    // ... pyproject.toml parser, Pipfile parser
    return [];
  }

  async checkAdvisories(deps: Dependency[]): Promise<Advisory[]> {
    // Use OSV (Open Source Vulnerabilities) database - free, no auth needed
    const results = await Promise.allSettled(
      deps.map(dep => queryOSV(dep))
    );
    return results
      .filter((r): r is PromiseFulfilledResult<Advisory[]> => 
        r.status === 'fulfilled'
      )
      .flatMap(r => r.value);
  }
}
Enter fullscreen mode Exit fullscreen mode

The key design decision: instead of hitting GitHub's Advisory API for everything (which has strict rate limits), I used OSV.dev for Python and Go ecosystems. It's free, has no authentication requirements, and covers all major ecosystems. I kept GitHub's API for npm because it has the richest data for JavaScript-specific vulnerabilities.

Copilot was particularly helpful here for generating the requirements.txt parser. Python requirements files have surprisingly complex syntax — version ranges, extras, environment markers, comments, blank lines. Copilot generated a working parser that handles all of these:

// src/parsers/requirements.ts
export function parseRequirements(content: string): Dependency[] {
  return content
    .split('
')
    .map(line => line.trim())
    .filter(line => line && !line.startsWith('#') && !line.startsWith('-'))
    .map(line => {
      // Handle: package==1.0.0, package>=1.0.0, package[extra]==1.0.0
      const match = line.match(
        /^([a-zA-Z0-9_-]+)(?:\[.*?\])?\s*([><=!~]+)\s*([\d.*]+)/
      );
      if (match) {
        return { name: match[1].toLowerCase(), version: match[3] };
      }
      // Package with no version pin
      return { name: line.toLowerCase(), version: '*' };
    });
}
Enter fullscreen mode Exit fullscreen mode

Day 2 Night: The Alert System (Hours 20–28)

The original tool just printed to stdout. Useful for a one-time check, but I wanted ongoing monitoring. I added a webhook system that sends alerts to Discord, Slack, or any URL when new vulnerabilities are found.

// src/alerts/webhook.ts
import { config } from '../config.js';
import { Advisory } from '../ecosystems/base.js';

export async function sendAlert(advisories: Advisory[]): Promise<void> {
  if (!config.ALERT_WEBHOOK || advisories.length === 0) return;

  const severityEmoji = {
    critical: '🔴',
    high: '🟠',
    medium: '🟡',
    low: '🟢',
  };

  const message = {
    content: `## 🚨 RepoRadar Alert\n\nFound **${advisories.length}** new vulnerabilities:\n\n${
      advisories.map(a => 
        `${severityEmoji[a.severity]} **${a.dependency.name}** ${a.dependency.version}\n` +
        `  ${a.title}\n` +
        `  ${a.url}${a.patchedVersion ? `\n  ✅ Fix: upgrade to ${a.patchedVersion}` : ''}`
      ).join('\n\n')
    }`,
  };

  await fetch(config.ALERT_WEBHOOK, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(message),
  });
}
Enter fullscreen mode Exit fullscreen mode

This was the feature that made the tool actually useful for me. I configured it to post to a Discord channel, and now I get a notification every morning if any of my projects have new vulnerabilities. No more finding out from Twitter.

Day 3: The Report That Doesn't Suck (Hours 28–40)

The original JSON dump was unreadable. I built three output formats:

Table format (default, for terminal):

┌─────────────────────────────────────────────────────────────────┐
│                    RepoRadar Scan Report                         │
│                    2026-06-01 09:30 UTC                          │
├─────────────────────────────────────────────────────────────────┤
│ Project: ~/code/my-saas                                          │
│ Ecosystems: npm, python                                          │
│ Dependencies scanned: 147                                        │
├──────────┬──────────────────┬────────┬──────────────────────────┤
│ Severity │ Package          │ Version│ Advisory                 │
├──────────┼──────────────────┼────────┼──────────────────────────┤
│ 🔴 crit  │ express          │ 4.17.1 │ CVE-2024-29041: Open     │
│          │                  │        │ redirect vulnerability   │
├──────────┼──────────────────┼────────┼──────────────────────────┤
│ 🟠 high  │ axios            │ 0.21.4 │ CVE-2024-39338: SSRF     │
│          │                  │        │ via server-side requests │
├──────────┼──────────────────┼────────┼──────────────────────────┤
│ 🟡 med   │ Pillow           │ 9.0.0  │ GHSA-3Q69-PCG2-4PC5:     │
│          │                  │        │ Buffer overflow           │
└──────────┴──────────────────┴────────┴──────────────────────────┘
Summary: 1 critical, 1 high, 1 medium, 0 low
Action: 2 packages have patches available. Run 'repo-radar fix' to upgrade.
Enter fullscreen mode Exit fullscreen mode

Markdown format (for GitHub Issues/PRs):

# 🔍 RepoRadar Scan Report

**Scanned:** 2026-06-01 09:30 UTC  
**Project:** ~/code/my-saas  
**Dependencies:** 147 scanned across npm, python

## Vulnerabilities Found

| Severity | Package | Current | Fixed In | Advisory |
|----------|---------|---------|----------|----------|
| 🔴 Critical | express | 4.17.1 | 4.19.2 | [CVE-2024-29041](https://github.com/advisories/GHSA-rv95-8pc4-8p6g) |
| 🟠 High | axios | 0.21.4 | 1.6.0 | [CVE-2024-39338](https://github.com/advisories/GHSA-8hc4-vh62-cxwj) |
| 🟡 Medium | Pillow | 9.0.0 | 10.3.0 | [GHSA-3Q69-PCG2-4PC5](https://github.com/advisories/GHSA-3q69-pcg2-4pc5) |

## License Changes

| Package | Old License | New License | Version |
|---------|-------------|-------------|---------|
| faker | MIT | SEE LICENSE IN facker.js | 6.6.6 |

## Abandoned Dependencies

| Package | Last Commit | Stars | Suggested Alternative |
|---------|-------------|-------|----------------------|
| request | 3 years ago | 22k | undici, got, or axios |
| moment | 2 years ago | 47k | date-fns, dayjs, or luxon |
Enter fullscreen mode Exit fullscreen mode

JSON format (for CI/CD pipelines):

The JSON output includes exit codes — exit 0 if no critical/high vulnerabilities, exit 1 if any critical/high found. This means you can plug it directly into GitHub Actions:

# .github/workflows/security.yml
name: Dependency Security Scan
on:
  schedule:
    - cron: '0 9 * * 1'  # Every Monday at 9am
  push:
    paths:
      - 'package.json'
      - 'requirements.txt'
      - 'Cargo.toml'

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npx repo-radar scan --format json --fail-on high
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

The License Change Detector

One feature I'm particularly proud of: license change detection. When a dependency updates, RepoRadar checks if the license changed between versions. This matters because:

  • faker.js went from MIT to a custom restrictive license in v6
  • mdb-react-ui-kit switched from MIT to a commercial license
  • Several packages have added "no AI training" clauses
// src/scanners/license.ts
export async function checkLicenseChanges(
  deps: Dependency[]
): Promise<LicenseChange[]> {
  const changes: LicenseChange[] = [];

  for (const dep of deps) {
    const currentMeta = await fetchPackageMetadata(dep);
    const lockfileMeta = await fetchLockfileVersion(dep);

    if (currentMeta.license !== lockfileMeta.license) {
      changes.push({
        package: dep.name,
        oldLicense: lockfileMeta.license,
        newLicense: currentMeta.license,
        currentVersion: lockfileMeta.version,
        latestVersion: currentMeta.version,
        risk: assessLicenseRisk(lockfileMeta.license, currentMeta.license),
      });
    }
  }

  return changes;
}

function assessLicenseRisk(oldLicense: string, newLicense: string): string {
  const permissive = ['MIT', 'Apache-2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'ISC'];
  const weakCopyleft = ['LGPL-2.1', 'LGPL-3.0', 'MPL-2.0'];
  const strongCopyleft = ['GPL-2.0', 'GPL-3.0', 'AGPL-3.0'];

  if (permissive.includes(oldLicense) && strongCopyleft.includes(newLicense)) {
    return 'CRITICAL: Permissive → Strong Copyleft. May require open-sourcing your code.';
  }
  if (permissive.includes(oldLicense) && !permissive.includes(newLicense)) {
    return 'HIGH: License became more restrictive. Review before upgrading.';
  }
  return 'LOW: License change appears compatible.';
}
Enter fullscreen mode Exit fullscreen mode

What Copilot Actually Did (Honest Assessment)

I want to be specific about where GitHub Copilot helped and where it didn't, because I think that's more useful than a vague "Copilot is amazing" claim.

Where Copilot excelled:

  • Boilerplate and scaffolding. Generating the ecosystem scanner interface, the CLI argument parsing with commander, the test fixtures — all the repetitive but necessary code. Estimated time saved: 4-5 hours.
  • Regex patterns. Parsing requirements.txt, Cargo.toml, version ranges — Copilot generated working regex patterns on the first try that would have taken me 20+ minutes each to debug manually.
  • API integration patterns. The OSV.dev API integration, GitHub API pagination, webhook payload formatting — Copilot knew the exact request/response shapes because these are well-documented APIs.
  • Test generation. Given a function signature and a brief comment, Copilot generated meaningful test cases including edge cases I wouldn't have thought of (empty input, malformed versions, missing fields).

Where Copilot struggled:

  • Architecture decisions. When to use OSV vs GitHub Advisory API, how to handle rate limiting across multiple ecosystems, whether to use a plugin architecture or strategy pattern — these decisions needed human judgment. Copilot would suggest the most common pattern, not necessarily the right one for this specific use case.
  • Complex error handling. The retry logic with exponential backoff for API calls — Copilot generated a basic version, but I had to manually add jitter, circuit breaker patterns, and proper error classification.
  • Performance optimization. When I had 200+ dependencies to check and each needed an API call, Copilot didn't suggest batching or parallel processing. I had to implement Promise.allSettled with concurrency limiting myself.

Overall verdict: Copilot saved me roughly 40% of the coding time. The remaining 60% was design decisions, debugging edge cases, and the kind of "glue code" that connects components in ways specific to this project's architecture.

After: The Published Package

72 hours later, RepoRadar was published to npm:

# Install globally
npm install -g repo-radar

# Scan current project
repo-radar scan

# Scan with specific ecosystems
repo-radar scan --ecosystems npm,python

# Output as markdown for GitHub Issues
repo-radar scan --format markdown --output report.md

# Set up daily monitoring with Discord alerts
repo-radar monitor --interval 24h --webhook https://discord.com/api/webhooks/...
Enter fullscreen mode Exit fullscreen mode

The final stats:

Metric Before After
Lines of code 347 (1 file) 2,840 (38 files)
Ecosystems supported 1 (npm) 4 (npm, Python, Rust, Go)
Test coverage 0% 87%
Error handling None Comprehensive with retries
Output formats Raw JSON Table, JSON, Markdown
License detection No Yes, with risk assessment
CI/CD integration No GitHub Actions template
Published No npm + GitHub release

What I Learned

1. Abandoned code is harder to finish than new code. When you start fresh, you make decisions with full context. When you come back to old code, you spend hours just understanding what past-you was thinking. The TODO comments help, but they're never enough.

2. Copilot is a multiplier, not a replacement. It makes you faster at the things you already know how to do. It doesn't help with the hard parts — the design decisions, the edge cases, the "should I even build this?" questions.

3. The last 20% takes 80% of the time. Getting the scanner to "work" took one day. Getting it to handle errors gracefully, support four ecosystems, produce beautiful output, and be publishable as a package took two more days.

4. Security cleanup is non-negotiable. I almost shipped the tool with my GitHub token still in the git history. git filter-branch saved me, but it was a close call. Always audit your git history before publishing.

5. Real users need real output. JSON is for machines. Tables are for humans. Markdown is for GitHub. Supporting all three isn't bloat — it's respect for how people actually use tools.

Try It

If you have an abandoned project sitting in your GitHub, this weekend is a good time to finish it. You might be surprised how much faster it goes with an AI pair programmer — and how much you've learned about software engineering in the months you were away.


Built with GitHub Copilot, caffeine, and the stubborn refusal to let another good idea die in a private repo.

Top comments (0)