I Built an AI Tool to Review Code for Security Issues. Here's What It Took to Make It Trustworthy

Published on
8 mins read
Written by

I spent two weeks building SecureStack AI, a tool that lets you upload a few files from your project, ask questions about them, and run a bounded security review, all grounded in the actual files you uploaded instead of whatever a general chatbot half-remembers about your stack. I built it as a full-stack/AI/security portfolio project, and the thing I underestimated going in was this: the AI part was the easy part. Getting an LLM to answer questions about code is maybe a day of work. Getting it to the point where I'd trust its answers took the other thirteen.

The problem I was actually trying to solve

Pasting your whole codebase into a general-purpose chatbot and hoping the answer is trustworthy is a bad workflow for two reasons. First, you're sending your source and config to a model with no memory of your specific project between sessions. Second, and more importantly, you have no way to check whether any given answer is grounded in your actual code or just a plausible-sounding guess. SecureStack AI's whole premise is that every answer should trace back to something the application itself verified, not something the model asserted.

The flow is simple: sign up, create a project, upload a few source or config files, ask a question grounded in those files with cited sources, then run a bounded security review that produces findings labeled as either deterministic (a real scanner ran) or AI-assessed (backed by a specific, verified tool result), never an unlabeled guess.

The wall I didn't expect: making citations actually mean something

Uploaded files get restricted to a small allowlist of text extensions, size-capped, and scanned for hardcoded-secret patterns before anything reaches OpenAI, so a rejected upload never touches the embeddings API. What survives gets chunked with overlap and embedded with text-embedding-3-small into pgvector, tagged by owner, project, and embedding model.

When you ask a question, it gets embedded with the same model, top-k chunks come back scoped to your owner/project/model, and the prompt assembles project metadata, the retrieved chunks, and your question into clearly delimited sections, with explicit instructions that document content is data, not commands. The model (gpt-5.4-mini, structured JSON output via the Responses API) answers with a confidence level, caveats, and inline [n] citations.

Here's the part that took longer than I expected to get right: a citation only survives if the model both lists it in a structured citedSources field and actually writes the matching [n] marker in the visible answer text. Not one or the other, the intersection of both. If a sentence in the summary depends on a citation that isn't in that verified set, the whole sentence gets dropped, not just the marker. I didn't want a user ever looking at a claim with no evidence standing next to it, even one word of one sentence.

Letting the model call tools without trusting it

The review agent can call exactly two tools while it works: one that reads a document's dependency map, and one that reads the names of environment variables referenced in the code, never the values. The model can only request a tool by name and arguments. The application decides whether to actually run it, validates the arguments against a narrow schema, re-checks that the calling user owns the referenced document, executes deterministic code (never anything the model generated), and returns a bounded result. Every call gets logged: tool name, document id, outcome, but never the content the tool actually read.

Keeping the review agent from running forever, or lying about its evidence

The bounded review agent chains tool calls across multiple rounds to plan and run a review, but it's deliberately not open-ended. There's a hard step budget across the entire review, not per turn. Once it's spent, the next model call drops tools entirely and forces a final answer with whatever evidence was already gathered, so there's no infinite loop and no runaway cost.

The bigger constraint: a finding can't carry free-form evidence text the model wrote itself. It has to name a specific tool call, an evidence kind, and an identifier. The application checks that the cited tool call ran, succeeded, and returned that exact identifier, and only then generates the displayed evidence string from that verified structure. A finding that doesn't check out gets dropped, and the summary gets reconciled to say how many were removed and why, instead of quietly showing a shorter list and hoping nobody notices.

The tools available to the agent are read-only by construction. There's no file-modification, shell, network, or infrastructure tool for it to call, not because a prompt tells it not to, but because those tools don't exist in the codebase.

Two channels that don't trust each other, and that's the point

Alongside the model, two deterministic scanners run that never depend on model output at all. One checks dependencies against a small curated table of real historical npm supply-chain compromises (event-stream, eslint-scope, rc/coa, ua-parser-js, node-ipc), using semver to distinguish a confirmed-affected exact version from a range that merely could resolve to one. The other does secret-pattern matching for AWS/OpenAI/Stripe-shaped keys and PEM blocks, quote- and file-type aware enough to recognize an environment-variable reference or a Terraform resource reference as safe indirection instead of flagging it as a leaked secret.

These two channels, the model and the deterministic scanners, never check each other's work, and I left it that way on purpose. In one of my verification runs, a deliberately unpinned dependency got flagged independently by both: the scanner called it out as a low-severity deterministic finding, and the model flagged it separately as medium severity, tied to an actual tool call it made. Neither one depended on the other to reach that conclusion. Watching two independent paths agree was a better trust signal than either one being confident on its own.

Deploying it surfaced two AWS problems I'd never hit before

I deployed the app to real AWS infrastructure (ECS Express Mode plus RDS Postgres) as a temporary demo for a recorded walkthrough, and ran into two things worth writing down.

Running database migrations as a one-off Fargate task against RDS failed immediately with SELF_SIGNED_CERT_IN_CHAIN. It turned out that a newer version of pg-connection-string treats sslmode=require as an alias for verify-full, meaning full certificate chain and hostname validation, and RDS's server certificate chains up to Amazon's own RDS CA, not a public root CA Node trusts by default. The fix was bundling AWS's published RDS CA bundle into the image and setting NODE_EXTRA_CA_CERTS, which keeps real certificate verification turned on instead of just disabling it to make the error go away.

The second one was stranger. A brand-new AWS account needs iam:CreateServiceLinkedRole the first time each service (RDS, ECS, ELB, Application Auto Scaling) gets used in that account. I'd already granted that permission in the IAM policy, but ECS's RunTask kept failing anyway with Unable to assume the service linked role, even after I confirmed the policy was saved correctly. What actually worked was calling aws iam create-service-linked-role --aws-service-name ecs.amazonaws.com directly myself, and doing the same for elasticloadbalancing.amazonaws.com and ecs.application-autoscaling.amazonaws.com, instead of trusting ECS to create those roles implicitly the first time it needed them.

What I'm not pretending is production-ready

No Terraform. The AWS resources were built by hand for a temporary demo, and writing Terraform that would never actually get applied against them felt like a paper exercise, so I documented the exact steps as a runbook instead.

Public sign-up has no email verification. That's an accepted risk for this specific deployment, which is restricted to my home IP and only staying up for a few days, not a general recommendation for how the product should work.

The dependency-advisory scanner is a small, hand-curated, illustrative table, not a live feed from a real advisory source.

It's a single-instance deployment with no autoscaling, which was a deliberate choice for cost predictability on a demo, not an oversight.

Where I landed

The actual project here wasn't "hook up an LLM to a chat box." It was building a verification layer around the model so that nothing it says gets trusted just because it sounds confident. Every AI-facing surface in this app treats model output the way the rest of the app already treats browser input: untrusted until it's checked. Citations get checked against what the model actually wrote. Evidence gets checked against tool calls that actually ran. Findings get labeled by which of those two checks they came from. None of that is exotic engineering. It's the same discipline you'd apply to any other untrusted input, just applied consistently to a part of the stack that's easy to treat as special.

The code is up at github.com/gumbyCode/securestack-ai if you want to see how the pieces fit together, including the two docs (docs/ai-architecture.md and docs/security.md) that go into more detail than this post does.