Antigravity × AI-Driven Security Audit Automation— Building an Agent Pipeline That Detects and Fixes Vulnerabilities
Learn how to build an automated security audit pipeline using Antigravity's multi-agent system. Covers dependency scanning, OWASP-based code reviews, and CI/CD integration for continuous security monitoring.
The first time I looked seriously at npm audit output on one of my own repos, I froze. A wall of "high" severity findings, most of them buried in transitive dependencies I had no idea I was pulling in. There was no obvious place to start, so I closed the tab and fixed exactly nothing that day.
Security work gets deferred not because developers don't care, but because the cost of triage is brutal. Audit tools are good at listing everything that might be dangerous. Deciding what is actually dangerous in your codebase is still left entirely to you.
That triage cost is exactly where Antigravity's multi-agent setup earns its keep. Give dependencies, application code, and infrastructure config to three separate agents, have them merge their findings into a single severity-ordered list, and leave humans with one decision: fix it or accept it. Since restructuring things this way, the number of audit reports I quietly ignore has dropped noticeably.
Here's what the pipeline in this guide does:
Design agents that automatically scan dependencies for known vulnerabilities
Automate code reviews based on the OWASP Top 10
Detect SQL injection, XSS, and authentication bypass patterns
Integrate continuous security auditing into your CI/CD pipeline
Who this is for: Intermediate to advanced developers who are comfortable with Antigravity's basics and understand multi-agent concepts.
Prerequisites
Before diving in, make sure you have the following set up:
Basic CI/CD configuration with GitHub Actions or Cloudflare Workers
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Automated security audit pipeline using AI agents with design and implementation details
✦Agent role distribution across vulnerability detection, classification, and remediation proposal stages
✦Operational practices for continuous security improvement and compliance management
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
By separating each agent's domain, you get both parallel execution speed and deep, specialized analysis for each security dimension.
Defining Agents in agents.md
Create .antigravity/agents.md at your project root:
# Security Audit ManagerYou are the security audit orchestrator.Manage the three specialized agents below and produce a comprehensive security report.## Delegation Rules1. Delegate dependency vulnerability checks to `dependency-scanner`2. Delegate source code security reviews to `code-reviewer`3. Delegate infrastructure and environment audits to `infra-checker`4. Aggregate all results into a severity-sorted report---# dependency-scannerYou are a dependency security specialist.- Analyze package.json and package-lock.json- Cross-reference against known CVE databases- Check if patched versions are available- Classify severity as Critical / High / Medium / Low---# code-reviewerYou are an application security specialist.Review source code against the OWASP Top 10:- SQL Injection- Cross-Site Scripting (XSS)- Broken Authentication & Authorization- Hardcoded Secrets- Insecure Deserialization---# infra-checkerYou are an infrastructure security specialist.- Verify environment variable management- Validate CORS configuration- Confirm HTTPS enforcement- Audit security headers (CSP, HSTS, etc.)
Automating Dependency Scanning
Integrating with npm audit
Have Antigravity's agent parse npm audit results and suggest concrete fixes.
In the Antigravity terminal, you can prompt the agent like this:
@dependency-scanner Analyze package.json and package-lock.json
for known vulnerabilities. For each one, include whether a fix
is available and your recommended action.
The agent parses the npm audit output and generates a structured report:
## Dependency Vulnerability Report| Package | Severity | CVE | Reachable? | Fixed In | Action ||-----------|----------|-----------|-------------------------|----------|---------------------------|| lodash | High | (CVE id) | Direct dep, call path | 4.17.22 | `npm update lodash` || express | Medium | (CVE id) | Transitive, no path | 4.19.3 | Absorb in next bump cycle |
I've left the CVE identifiers as placeholders since they'll differ for every project. The column worth noticing is Reachable?.
The severity field from npm audit describes how bad a flaw is for that package, not how bad it is for your app. A vulnerability in a build-time toolchain and one sitting directly on a request path that handles user input both show up as "High." Treating them with equal urgency is how you end up buried in findings and fixing none of them.
So the request I send to dependency-scanner always asks for a reachability verdict:
@dependency-scanner For each npm audit finding, walk the import graph and
determine whether the vulnerable function is actually reachable from this
repository's source. Classify each as reachable / not reachable / undetermined,
and list the reachable ones first.
That single addition usually narrows the "look at this today" list down to a handful of entries. The rest don't become safe — but you can now separate what to fix now from what to absorb in the next routine dependency bump.
OWASP-Based Code Security Reviews
SQL Injection Detection Patterns
Teach the agent pattern-matching rules for common vulnerability types.
// lib/security-patterns.ts// Security pattern detection libraryexport const SQL_INJECTION_PATTERNS = [ // String concatenation in queries (dangerous) /`SELECT.*\$\{.*\}`/g, /['"]SELECT.*['"] \+ /g, /query\(.*\+.*\)/g, // Direct template literal interpolation (dangerous) /\.query\(`[^`]*\$\{[^}]+\}[^`]*`\)/g, // eval or Function constructor (extremely dangerous) /eval\s*\(/g, /new\s+Function\s*\(/g,] as const;export const XSS_PATTERNS = [ // Unsanitized input to innerHTML /\.innerHTML\s*=\s*(?!['"`])/g, // dangerouslySetInnerHTML (React) /dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html:\s*(?!DOMPurify)/g, // document.write /document\.write\s*\(/g,] as const;export const AUTH_PATTERNS = [ // Hardcoded secrets /(?:password|secret|api[_-]?key|token)\s*[:=]\s*['"][^'"]{8,}['"]/gi, // Skipping JWT verification /verify\s*:\s*false/g,] as const;// Math.random() is genuinely unsafe for token generation — but it matches just// as often in animation jitter, retry backoff, and shuffle logic. Mixing it into// critical buries the findings that actually matter, so it gets its own bucket.export const WEAK_RANDOM_PATTERNS = [ /Math\.random\(\)/g,] as const;/** * Scan a file for security issues */export function scanFile( content: string, filePath: string): SecurityIssue[] { const issues: SecurityIssue[] = []; const lineStarts = buildLineIndex(content); for (const rule of RULES) { for (const pattern of rule.patterns) { // matchAll requires the g flag. Clone so we never dirty the source // regex's lastIndex between files. const re = new RegExp( pattern.source, pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g" ); for (const match of content.matchAll(re)) { issues.push({ type: rule.type, severity: rule.severity, file: filePath, line: lineFromIndex(lineStarts, match.index!), message: rule.message, recommendation: rule.recommendation, }); } } } const order = { critical: 0, high: 1, medium: 2, low: 3 } as const; return issues.sort((a, b) => order[a.severity] - order[b.severity]);}interface SecurityIssue { type: string; severity: "critical" | "high" | "medium" | "low"; file: string; line: number; message: string; recommendation: string;}/** Rule table. Adding a check should be a one-line change. */const RULES = [ { patterns: SQL_INJECTION_PATTERNS, type: "SQL_INJECTION", severity: "critical", message: "Unsanitized input directly interpolated in SQL query", recommendation: "Use parameterized queries or an ORM instead", }, { patterns: XSS_PATTERNS, type: "XSS", severity: "high", message: "Unsanitized input may be injected into the DOM", recommendation: "Use DOMPurify or text nodes instead", }, { // Forget to wire this in and hardcoded secrets sail straight through patterns: AUTH_PATTERNS, type: "AUTH", severity: "critical", message: "Hardcoded credentials or skipped verification suspected", recommendation: "Move to environment variables and re-enable verification", }, { patterns: WEAK_RANDOM_PATTERNS, type: "WEAK_RANDOM", severity: "low", message: "Math.random() is not cryptographically secure", recommendation: "For tokens use crypto.randomUUID() or randomBytes(). For visual effects, ignore this.", },] as const satisfies readonly { patterns: readonly RegExp[]; type: string; severity: SecurityIssue["severity"]; message: string; recommendation: string;}[];/** Index newline offsets once, then binary-search for the line number. */function buildLineIndex(content: string): number[] { const starts = [0]; for (let i = 0; i < content.length; i++) { if (content[i] === "\n") starts.push(i + 1); } return starts;}function lineFromIndex(starts: number[], pos: number): number { let lo = 0; let hi = starts.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (starts[mid] <= pos) lo = mid; else hi = mid - 1; } return lo + 1;}// Actual run against a 4-line sample, sorted by severity:// critical SQL_INJECTION line 1// critical AUTH line 2// high XSS line 3// low WEAK_RANDOM line 4
Three Things the First Version Got Wrong
The code above is the version that survived three rounds of fixes after I actually ran it. Here's what it looked like before — if you write a scanner of your own, you'll probably trip on the same three things.
1. A rule you define but never wire in is indistinguishable from a rule that doesn't exist
My first version defined AUTH_PATTERNS and then looped only over SQL and XSS inside scanFile. No type error, no lint warning. When I fed it a test file containing a hardcoded sk_live_... key, it dutifully reported the SQL and XSS issues and said nothing at all about the secret sitting in plain sight.
That's why the patterns now live in a RULES table instead of being looped individually. Adding a check becomes a single array entry, and there's no separate wiring step left to forget.
2. Line-number lookup scales with the square of the file size
content.substring(0, index).split("\n").length reads beautifully. It also rebuilds the entire prefix of the file on every single match, so cost climbs sharply as match counts grow.
Measured on a 20,000-line file with 20,000 matches:
Line-number strategy
Time for 20,000 matches
substring().split() per match
5,791 ms
Indexed newlines + binary search
22 ms
On a typical few-hundred-line file you'll never feel the difference. You feel it the moment CI scans the whole repository — before this fix, the scan step alone was adding minutes to every workflow run.
3. Putting Math.random() in critical makes the report stop getting read
Using Math.random() for anything cryptographic is genuinely dangerous. In real codebases, though, it shows up far more often in harmless places: animation jitter, retry backoff, shuffling a list. In my own code, four matches turned up and exactly one of them was a real problem.
Three false positives sitting next to your highest-severity findings will cost you the reader's trust in the whole report. So WEAK_RANDOM_PATTERNS became its own bucket at severity: "low", printed last. Don't delete noisy rules — demote them. That's been my default move for every check I've added since.
Automated Fixes via Agents
When a vulnerability is detected, the agent can propose a fix automatically:
@code-reviewer A SQL injection vulnerability was detected in the
following file. Please refactor to use parameterized queries.
Before:
const user = await db.query(`SELECT * FROM users WHERE id = ${userId}`);
After (agent-generated):
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
Infrastructure Security Auditing
Security Headers Checker
// scripts/check-security-headers.ts// Validates security headers for a web applicationinterface SecurityHeaderCheck { header: string; expected: string | RegExp; severity: "critical" | "high" | "medium"; description: string;}const REQUIRED_HEADERS: SecurityHeaderCheck[] = [ { header: "Strict-Transport-Security", expected: /max-age=\d{8,}/, severity: "critical", description: "HSTS: Enforce HTTPS (max-age of at least 1 year recommended)", }, { header: "Content-Security-Policy", expected: /default-src/, severity: "high", description: "CSP: Defend against XSS and data injection attacks", }, { header: "X-Content-Type-Options", expected: "nosniff", severity: "medium", description: "Prevent MIME-type sniffing", }, { header: "X-Frame-Options", expected: /DENY|SAMEORIGIN/, severity: "medium", description: "Prevent clickjacking attacks", }, { header: "Referrer-Policy", expected: /strict-origin|no-referrer/, severity: "medium", description: "Prevent referrer information leakage", },];async function checkSecurityHeaders(url: string): Promise<number> { // GET, not HEAD. If CSP is attached on the HTML-rendering path, // a HEAD request skips that path entirely and reports "not set." const response = await fetch(url, { method: "GET" }); await response.arrayBuffer(); // Discard the body, but drain it to free the socket console.log(`\n🛡️ Security Headers Audit: ${url}`); console.log("=".repeat(60)); let issues = 0; for (const check of REQUIRED_HEADERS) { const value = response.headers.get(check.header); const passed = value ? typeof check.expected === "string" ? value === check.expected : check.expected.test(value) : false; const status = passed ? "✅" : "❌"; console.log(`${status} ${check.header}`); if (!passed) { console.log(` Severity: ${check.severity}`); console.log(` ${check.description}`); console.log(` Current value: ${value || "(not set)"}`); issues++; } } console.log(`\nTotal: ${issues} issue(s) found`); return issues;}// CI entry point. Read TARGET_URL and exit non-zero on findings —// without this, the job always passes no matter what it finds.const targetUrl = process.env.TARGET_URL;if (!targetUrl) { console.error("TARGET_URL is not set"); process.exit(2);}const found = await checkSecurityHeaders(targetUrl);process.exit(found > 0 ? 1 : 0);// Expected output:// 🛡️ Security Headers Audit: https://your-app.example.com// ============================================================// ✅ Strict-Transport-Security// ❌ Content-Security-Policy// Severity: high// CSP: Defend against XSS and data injection attacks// Current value: (not set)// ✅ X-Content-Type-Options// ✅ X-Frame-Options// ✅ Referrer-Policy//// Total: 1 issue(s) found
The HEAD trap was the first thing this script cost me. Here's the smallest reproduction I could build:
// csp-head-vs-get.mjs — headers can differ between HEAD and GETimport http from "node:http";const srv = http.createServer((req, res) => { const headers = { "Strict-Transport-Security": "max-age=31536000", "X-Content-Type-Options": "nosniff", }; // Mimics a setup that attaches CSP only on the HTML-rendering path if (req.method === "GET") headers["Content-Security-Policy"] = "default-src 'self'"; res.writeHead(200, headers); res.end(req.method === "GET" ? "<html></html>" : undefined);});await new Promise((r) => srv.listen(0, r));const url = `http://127.0.0.1:${srv.address().port}/`;for (const method of ["HEAD", "GET"]) { const r = await fetch(url, { method }); if (method === "GET") await r.arrayBuffer(); console.log(method.padEnd(4), "CSP =", r.headers.get("content-security-policy") ?? "(reported as not set)");}srv.close();// Output:// HEAD CSP = (reported as not set)// GET CSP = default-src 'self'
Production was serving CSP correctly the whole time; only the audit script insisted otherwise. I spent half a day suspecting the CDN config before I found it. Check headers with GET, drain the body, throw it away. Those two habits will save you that half day.
Environment Variable Safety Checks
// scripts/check-env-security.ts// Audits environment variable management and secret handlingimport * as fs from "fs";import * as path from "path";import { execSync } from "child_process";const SENSITIVE_PATTERNS = [ /API[_-]?KEY/i, /SECRET/i, /PASSWORD/i, /TOKEN/i, /PRIVATE[_-]?KEY/i, /DATABASE[_-]?URL/i, /STRIPE[_-]?SECRET/i,];function auditEnvSecurity(projectRoot: string): number { const issues: string[] = []; // Check .gitignore. A naive includes(".env") also matches ".env.example", // so an unprotected repo reports as safe. Match whole lines instead. const gitignorePath = path.join(projectRoot, ".gitignore"); const ignoredLines = fs.existsSync(gitignorePath) ? fs .readFileSync(gitignorePath, "utf-8") .split("\n") .map((l) => l.trim()) : []; const envIgnored = ignoredLines.some((l) => [".env", ".env*", "*.env", ".env.local"].includes(l) ); if (!envIgnored) { issues.push("⚠️ CRITICAL: .env is not ignored by .gitignore"); } // Stronger evidence than .gitignore: what git is actually tracking. // Adding a .gitignore rule after the fact does not untrack existing files. const tracked = execSync("git ls-files", { cwd: projectRoot, encoding: "utf-8" }) .split("\n") .filter((f) => /(^|\/)\.env($|\.)/.test(f) && !/\.example$/.test(f)); for (const f of tracked) { issues.push(`🚨 CRITICAL: ${f} is tracked by git (needs history rewrite)`); } // Scan source code for hardcoded secrets const srcDir = path.join(projectRoot, "src"); if (fs.existsSync(srcDir)) { scanDirectory(srcDir, issues); } console.log("\n🔐 Environment Variable Security Audit"); console.log("=".repeat(50)); if (issues.length === 0) { console.log("✅ No issues detected"); } else { issues.forEach((issue) => console.log(issue)); } return issues.length;}function scanDirectory(dir: string, issues: string[]): void { const files = fs.readdirSync(dir, { withFileTypes: true }); for (const file of files) { const fullPath = path.join(dir, file.name); if (file.isDirectory() && file.name !== "node_modules") { scanDirectory(fullPath, issues); } else if ( file.isFile() && /\.(ts|js|tsx|jsx)$/.test(file.name) ) { const content = fs.readFileSync(fullPath, "utf-8"); const lines = content.split("\n"); lines.forEach((line, index) => { SENSITIVE_PATTERNS.forEach((pattern) => { if ( pattern.test(line) && /['"][^'"]{8,}['"]/.test(line) ) { issues.push( `⚠️ HIGH: ${fullPath}:${index + 1} — ` + `Possible hardcoded secret detected` ); } }); }); } }}// CI entry point. Exit non-zero when anything is found.process.exit(auditEnvSecurity(process.cwd()) > 0 ? 1 : 0);// Output:// 🔐 Environment Variable Security Audit// ==================================================// ⚠️ HIGH: src/config/database.ts:15 — Possible hardcoded secret detected// 🚨 CRITICAL: .env.local is tracked by git (needs history rewrite)
Switching the .gitignore check from includes(".env") to whole-line matching wasn't a stylistic choice — here's what each version actually reports:
.gitignore contents
includes(".env")
Whole-line match
!.env.example only (.env not ignored)
true (reports safe)
false (warns correctly)
.env present
true
true
.env* present
true
true
Committing a .env.example is completely ordinary practice. Which means the original implementation went quiet in precisely the case you'd most want it to shout. And since .gitignore only governs what git will start tracking — not what it already tracks — the git ls-files check earns its place alongside it.
CI/CD Pipeline Integration
GitHub Actions Workflow
Integrate security auditing into your CI/CD so every pull request gets automatically checked.
# .github/workflows/security-audit.ymlname: Security Auditon: pull_request: branches: [main] push: branches: [main] schedule: # Run weekly on Mondays at 9:00 JST - cron: "0 0 * * 1"jobs: dependency-scan: name: Dependency Vulnerability Scan runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20" - run: npm ci - name: Run npm audit run: | # --json exits non-zero when findings exist; absorb that on the report step npm audit --json > audit-report.json || true # Gate here. Adding `|| true` to this line means the job passes # no matter what the scan finds. npm audit --audit-level=high - name: Upload audit report uses: actions/upload-artifact@v4 with: name: audit-report path: audit-report.json code-security-review: name: Code Security Review runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20" - run: npm ci - name: Run security pattern scan # Fails the job when scanFile returns anything at critical severity run: npx tsx scripts/security-audit.ts - name: Check for hardcoded secrets run: | # Detect hardcoded sensitive values if grep -rn \ -e "API_KEY\s*=\s*['\"][^'\"]*['\"]" \ -e "SECRET\s*=\s*['\"][^'\"]*['\"]" \ --include="*.ts" --include="*.js" \ --exclude-dir=node_modules \ src/; then echo "::error::Hardcoded secrets detected!" exit 1 fi security-headers: name: Security Headers Check runs-on: ubuntu-latest if: github.event_name == 'schedule' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20" - run: npm ci - name: Check production security headers # The script reads TARGET_URL and returns 0 = clean / 1 = findings / 2 = unset run: npx tsx scripts/check-security-headers.ts env: TARGET_URL: ${{ secrets.PRODUCTION_URL }}
Connecting the CI/CD Loop with Antigravity Agents
Here's the workflow for feeding CI/CD results back to Antigravity agents for automated remediation:
1. Create a PR → GitHub Actions runs
2. Security scan results are posted as PR comments
3. Open the fix branch in Antigravity
4. Share scan results with @code-reviewer
5. Agent generates fix code
6. Commit fixes → re-scan → merge
A Note from an Indie Developer
Running this pipeline against my own repositories paid off somewhere I wasn't expecting.
What I hoped for was "it finds vulnerabilities I'd missed." What actually changed was how often I defer a decision. Before, every audit report meant reasoning from scratch: is this worth fixing, can it wait, does it even touch my code? Now that findings arrive sorted by reachability and severity, I read the top three, push the rest to the next cycle, and I'm done in minutes. The audit didn't get faster so much as it stopped feeling heavy.
I've also been burned by trusting an agent's automated fix.
I handed code-reviewer a SQL injection finding on this line:
// Flagged: sort column interpolated into the queryconst rows = await db.query( `SELECT * FROM wallpapers ORDER BY ${sortColumn} DESC LIMIT 50`);
It confidently returned a placeholder-based rewrite:
// Agent's proposal (does not work)const rows = await db.query( "SELECT * FROM wallpapers ORDER BY $1 DESC LIMIT 50", [sortColumn]);
Placeholders bind values, not identifiers like column or table names. Apply this and the SQL is still accepted, but the ordering now applies to a constant string — effectively no ordering at all. No error, no exception, just silently wrong result order. Without a test covering it, you find out in production.
The correct fix was to collapse the identifier through an allowlist:
const SORTABLE = { created: "created_at", downloads: "download_count", rating: "rating_avg",} as const;// Unknown keys fall back to the default; only vetted values reach the SQLconst column = SORTABLE[sortColumn as keyof typeof SORTABLE] ?? "created_at";const rows = await db.query( `SELECT * FROM wallpapers ORDER BY ${column} DESC LIMIT 50`);
That incident changed how I prompt for fixes. Every remediation request now ends with: "explain why this fix is safe, including an example of the attacker input it blocks." Forcing the explanation surfaces the assumptions a model tends to conflate — like the difference between an identifier and a literal. Bad fixes usually fall apart at the explanation stage.
Automated remediation isn't a way to skip review. It's a way to find what needs reviewing and hand you a starting draft. Once I drew that line for myself, I could finally let the agents run without wondering what they were quietly breaking.
Wrapping Up — What to Do Next
The real work in automating security audits turned out not to be adding detection rules. It was cutting the findings down to a volume a human will actually process. Filter by reachability, sort by severity, and demote noisy rules rather than deleting them. Those three changes are what made the reports get read.
If you only change one thing today, make it checkSecurityHeaders: switch HEAD to GET and return a real exit code. A few lines turn that CI job from decoration into an actual gate. Once that's working, layering in the rest of the agents is straightforward.
I'm still growing this pipeline myself. If this saves you from the three mistakes I made, that's a good outcome.
For a deeper dive into multi-agent design patterns, check out the Multi-Agent Orchestration Production Guide. And for best practices on managing secrets safely, see the Environment Variables & Secrets Management Guide.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.