# Viruxa Cloud

Continuous security scanning and monitoring for systems you own.

Three ways in, one engine behind all of them:

* **A folder on your machine** — the scanner reads it where it lives.
* **A repository somewhere else** — GitHub, GitLab, Bitbucket, Azure DevOps.
  A shallow copy is cloned, scanned, and deleted.
* **A site you have deployed** — the front end and the API behind it, checked
  as they actually run, once you have proved the host is yours.

Plus a backend that keeps the decisions you make, a dashboard, and a desktop
app.

---

## Two rules this holds itself to

**Only what you own.** Nothing here can be pointed at somebody else's system.
The file scanners read a directory. The port inventory reads this machine's own
socket table and never sends a packet. The site audit **refuses to run** until
control of the host has been demonstrated by DNS record, a file, or a response
header — and the refusal is in the function, not in a policy that could be
relaxed. Unauthorised scanning is a crime in most countries and a product that
makes it easy is a liability rather than a feature.

**No dependencies.** A security tool with four hundred transitive packages is
its own attack surface, and the irony would be fatal to it. The scanner, the
API, the database layer, the dashboard and the site audit are Node's standard
library and two public APIs. The only dependency in the repository is Electron,
and only the desktop wrapper uses it.

---

## Requirements

Node 22.5 or later — the backend uses `node:sqlite`, which arrived in 22.5.
`git` on PATH, for scanning repositories. Nothing else.

```bash
npm install
```

---

## The command line

```
viruxa [path]                 scan a directory (default: .)
viruxa repo <url>             clone and scan a repository, then delete it
viruxa connect <server> <key> connect this machine to an account
viruxa status                 show what this machine is connected to
viruxa disconnect             forget the key on this machine
viruxa service install [path] scan on a schedule, without a terminal open

  --json          write the findings as JSON to stdout
  --format=<list> also write the scan as a file — pdf, html, markdown,
                  csv, json, sarif, sbom (comma-separated)
  --out=<file>    where to write it, when one format was asked for
  --offline       skip the checks that use the network
  --no-history    do not read what the commits added, only the files
  --verify        ask each vendor whether the key it found still works
  --baseline[=f]  gate on what this change introduced, not on the backlog
  --record-baseline[=f]  accept what is there now as the baseline
  --aws --gcp --azure --digitalocean --cloudflare --kubernetes
                  also read that account or cluster, read-only
  --cloud         read every cloud this machine has credentials for
  --exit-zero     report rather than gate: never exit 1 because of a finding
  --push          send the findings to the cloud after scanning
  --ref=<branch>  which branch to clone, for viruxa repo
  --history       clone deeply enough to read history, for viruxa repo
  --watch=<min>   keep scanning, every <min> minutes
```

It exits 1 when anything critical or high is found, so it can gate a build
without anybody parsing the output. Progress goes to stderr whenever `--json`
is on, so the document on stdout stays parseable.

```bash
viruxa .                                    # this project
viruxa repo OWASP/NodeGoat                  # a public repository
VIRUXA_GIT_TOKEN=ghp_… viruxa repo me/app   # a private one
```

For a private repository, prefer the environment variable: a token on the
command line is in your shell history and in the process list of every other
program on the machine. When git is invoked, the token goes in as a
configuration environment variable rather than in the clone URL, for the same
reason.

Reports are written to `reports/` **beside the tool**, never into the project
being scanned: a findings file is a map of that project's weak points, and it
must not end up committed by accident.

---

## What it checks

| Scanner | What it is looking for |
| --- | --- |
| **Committed credentials** | Around ninety-five vendor-anchored patterns — AWS, Google, GitHub, GitLab, npm, PyPI, Anthropic, OpenAI, Stripe, Slack, Twilio, SendGrid, Vault, Terraform, Doppler, private keys, database connection strings — graded by whether git is actually tracking the file. |
| **Vulnerable dependencies** | Every installed package checked against OSV, across fourteen lockfiles: npm, yarn (both formats), pnpm, pub, pip, poetry, pipenv, bundler, go, cargo, composer, nuget, gradle. Severity is the advisory's own, or computed from its CVSS vector, then re-ordered by what is actually being exploited. |
| **Listening ports** | This machine's own socket table, on Windows, macOS and Linux. Never sends a packet. Ignores the ephemeral range unless the port is a named service. |
| **Configuration** | Firestore and Storage rules, `.gitignore` gaps, Android manifests, a committed `.env`. |
| **Containers** | Dockerfiles and compose files: unpinned base images, credentials baked into layers, root by default, `privileged`, the Docker socket, a datastore published on every interface. |
| **Build pipeline** | GitHub Actions: `pull_request_target` checking out a fork's branch, untrusted text interpolated into a `run:` block, actions pinned to a branch, secrets echoed into the log. |
| **Infrastructure** | Terraform and Kubernetes: ingress from `0.0.0.0/0` graded by which port it opens, public buckets and databases, hardcoded provider credentials, privileged pods, hostPath volumes, Secrets carrying their values. |
| **Supply chain** | Install scripts that run before anybody reads them, dependencies fetched from outside the registry, package names one character from a popular one, and code that is trying not to be read — base64 handed to eval, strings written as character codes. |
| **Code** | The source, a statement at a time: queries built by joining strings, shell commands built from request data, deserialisation of untrusted input, certificate checks turned off, passwords hashed with something fast, tokens from a predictable random source, XSS sinks, debug mode left on. The injection rules follow a call across the lines a formatter wrapped it onto, **follow the value through a function it is passed to** — in this file or one that imports it — **and follow the value itself** — `const email = req.body.email` twenty lines above `db.query("… " + email)` is the shape injection actually takes in real code, and it matched nothing until the scanner started tracking where untrusted input goes after it arrives. |
| **Credentials with no vendor** | A credential-shaped name assigned a value with the randomness of a generated key. This is the answer to "what about our own internal token", and it is anchored on the variable rather than on the vendor so it stays a check worth running. |
| **Credentials in git history** | The same ninety-five patterns, run over what the commits *added* rather than what the files contain — the key that was committed on Tuesday, deleted on Wednesday, and never rotated. |
| **Base images** | The image a `FROM` line names, asked of the registry rather than read from the file: when it was actually built, the digest that would pin it, and whether the tag still exists. Six registries anybody has heard of, and no others — a `FROM` line names a host, and reaching for whatever host a file names is server-side request forgery with a Dockerfile in front of it. The rest of the reference is held to the character set the registry specification allows, for the same reason and one step further in: the host decides which server is asked, and the repository, tag and digest decide what it is asked for. |
| **Live site** | Certificate expiry and chain, TLS version, security headers, cookie flags, CORS, version disclosure, directory listing, and a fixed list of files that should never be served — `.env`, `.git`, heap dumps, database backups. |
| **AWS account** | The account itself rather than the Terraform describing it: an access key on the root user, root without MFA, user keys nobody has rotated in a year, buckets that do not block public access, security groups opening SSH or a database to `0.0.0.0/0`, and databases with a public address. Every enabled region, and it says which ones it read. Read-only, off unless asked for by name. |
| **Google Cloud project** | Buckets granted to `allUsers` or `allAuthenticatedUsers`, firewall rules letting the internet reach a management port, user-managed service account keys that never expire, and Cloud SQL instances with an authorised network of `0.0.0.0/0`. Read-only. |
| **Azure subscription** | Network security groups allowing the internet inbound to SSH, RDP or a database port, storage accounts that permit anonymous blob access or plain HTTP, and SQL servers whose firewall covers every address. Read-only. |
| **DigitalOcean team** | Droplets with a public address and no cloud firewall, and firewall rules opening a management port to `0.0.0.0/0`. Read-only. |
| **Cloudflare account** | Flexible TLS, which encrypts the browser half and sends the origin half in clear while the padlock still appears; TLS turned off entirely; and CNAMEs pointing at platforms that may no longer be yours, which is how subdomain takeover happens. Read-only. |
| **Kubernetes cluster** | The running cluster rather than the manifests: privileged pods, containers that may escalate, the node's network, process table or filesystem mounted in, service accounts bound to `cluster-admin`, and Secrets that are base64 rather than encrypted. Read-only, via the kubeconfig already on the machine. |
| **Attacks in progress** | The cloud audit trail, read for what somebody *did* rather than what the account *is*: the root user signing in, a run of failed sign-ins, audit logging being switched off, a new access key or identity, something being opened up, data being copied out, and bursts of refused calls. Each carries who, from which address, and when. Read-only, off unless asked for by name. |
| **This machine** | What is actually running here: containers that are privileged, share the host network or have the Docker socket mounted; a kubeconfig that skips certificate checks, holds a long-lived token, or is readable by other users; and private keys with permissions that let anybody on the box read them. Reads the local Docker socket and never connects to any cluster it finds. |

Every rule is anchored on something somebody decided — `AKIA`, `ghp_`,
`sk_live_`, a Terraform block, a Kubernetes kind. That is what makes them
precise, and it left one gap this used to state and not close: a credential
with no distinguishing shape, an internal service's own thirty-two random
characters, belongs to no vendor and is exactly as dangerous.

**The credentials with no shape** are now found by moving the anchor rather
than removing it. Entropy alone gets roughly one in five right — the rest are
hashes, minified names, lockfile digests and inline images — so the *value* is
judged statistically and the *name* is judged the way everything else here is.
`apiKey = "<40 random characters>"` is a finding; forty random characters on
their own are not. Reading a key out of the environment never is, which was
found by running this against its own source, where it flagged the line that
does exactly that.

Every finding also carries a **CWE and an OWASP category**. GitHub renders the
CWE on the alert and groups by it; the OWASP category is the first question
asked in most reviews and in every security questionnaire. Both come from the
rule id, which this already knew — not answering them was withholding
something free. What is *not* a weakness in anybody's code — an unreachable
database, a licence question, a truncated history — deliberately carries
neither, rather than appearing in an audit's statistics as though it were one.

### What "complete coverage" actually means

It cannot be a hundred per cent, and a tool that claims otherwise is selling
something. Whether an arbitrary program has a security property is
undecidable, so no scanner can prove code is safe; and any list of signatures
is a list of what was known yesterday. Both halves of that gap are worked from
here rather than papered over:

* **For what is known** — around ninety-five credential patterns, fourteen
  lockfile formats, every OSV advisory, the CIS Foundations Benchmarks for
  each cloud, and the exploitation datasets below.
* **For what is not** — the supply-chain and code scanners match *shapes*
  rather than names. A malicious package published this morning is in no
  database, but it still has an install script, still fetches and evaluates
  something, and is still named one character away from what it imitates. That
  is what those rules look for, and it is why they say "worth a look" rather
  than claiming a verdict.

Where a scan could not check something it says so — an unreachable OSV, a
skipped network check, a file too large to read, a symlink pointing out of the
tree — because a silent gap reads exactly like a clean result.

Symlinks inside the tree are followed, which matters more than it sounds: a
directory entry that is a link is neither a file nor a directory to the
operating system, so the obvious way to write a directory walk skips every one
of them without a word. Monorepos are full of them.

### Is the key you found still live?

```bash
viruxa . --verify
```

Every other check answers "does this look like a credential". This one asks
the vendor whether it authenticates *right now*. A repository with three
hundred findings and four working keys is four problems — and everything else
can wait until Monday.

| | What it means |
| --- | --- |
| **live** | The vendor accepted it. Not a finding — an incident, promoted to critical and put at the top. |
| **dead** | Refused. Softened to low rather than dropped: it was committed, and whatever put it there will do so again. |
| **unknown** | No verifier for that vendor, or the network was down. Said out loud, because a key wrongly reported as dead is the most dangerous thing this could print. |

**It is off unless you ask for it, and it always will be.** Verifying a
credential means sending it to the vendor, which is the one thing the rest of
this architecture exists to avoid — a feature that quietly broke that promise
because it was useful would be worse than not having it. So:

* **One host per rule, written down in the source.** A GitHub token is offered
  to `api.github.com` and to nothing else. No configuration, no discovery, no
  redirects — a verifier that could be pointed elsewhere is a credential
  exfiltration feature with a friendly name.
* **The least authenticated call each vendor has** — `GET /user`, `auth.test`,
  `GetCallerIdentity`. Nothing that writes.
* **Nothing is stored, and the raw credential never leaves the scan.** It goes
  from the scanner to the verifier and no further; findings carry redacted
  evidence, as they always have.
* **`--offline` wins**, whatever else was asked for.

**Twenty-eight of the patterns can be checked**, which is most of the ones
that matter: the three cloud providers, GitHub, GitLab, npm, PyPI, Stripe,
Slack, Twilio, SendGrid, Anthropic, OpenAI, Datadog, Doppler, Supabase,
Notion, Figma, Linear, Mailgun, Heroku, Square, Terraform, crates.io,
RubyGems. The rest report `unknown` and say so, because a key wrongly
reported as dead is worse than one never checked.

**Why it is worth putting on a schedule.** A credential in a test file is
softened to `low` and titled "in a test fixture" — right almost every time,
because a suite is where a deliberately fake key belongs and reporting each
one as critical is how somebody learns to ignore the scanner. But that
softening reads the **path**, not the key: a real one committed to a test file
is reported as `low` and sits among a hundred others where nobody will look.
Verification is the only thing that can tell those apart, because the vendor
is the only party that knows. A live answer promotes the finding to critical
whichever file it was found in.

```bash
viruxa service install . --verify
```

Off unless that word is typed, and never implied by `--push` or `--cloud`:
a background task quietly posting your keys to Stripe every hour is not
something anybody should discover from an access log. The installer says what
it will do before it schedules it.

The three that are not a bearer token in a header are written out rather than
skipped. AWS needs both halves, so the pair is found in the same file and the
request is signed with Signature Version 4. A Google service account is a
private key, so a JWT is signed with it and exchanged — the key itself is
never sent. An Azure storage key is paired with its account name from the same
connection string and used to sign a SharedKey HMAC. Each is a few dozen lines
against three hundred packages to send one request.

### Reading the packages themselves

```bash
viruxa . --deep
```

The ordinary scan skips `node_modules`, and for the ordinary question that is
right: the dependency check asks OSV what is *known* to be wrong with every
installed package, which is a better answer than reading it would be.

It is the wrong answer to a different question. A package published this
morning to steal your environment is in no advisory database — there has not
been time. It still has an install script, still fetches and evaluates
something, and is still named one character from what it imitates. None of
that is in a lockfile. It is in the code, and until now nothing here opened
the code.

On this repository that is **550 files and 272,000 lines** of somebody else's
source, against 213 files and 74,000 lines of its own — four times the
reading, and about three seconds more.

**Two rules run over it, not all of them.** Supply-chain patterns are the
point: decode-and-run inside a package you installed is the shape of the
attack. Credentials matter too, because a key committed by a package author
ships to everybody. The rest do not — reporting SQL injection in a library you
cannot edit is thousands of findings nobody can act on, which is the failure
this product spends its life arguing against.

A finding here is marked with the package it is in and says the fix is
different: your own file gets edited, and a dependency gets removed, pinned or
reported to its registry — editing it in place is undone by the next install.

### How much was actually read

Every scan now says the volume, in the terminal and in every report:

```
213 files · 74,451 lines · 14.5MB
```

This exists because of a fair suspicion. A scan of two hundred files that
finishes in four seconds looks like a tool that read the *names*. It read
seventy-four thousand lines — about eighteen thousand a second, which is
ordinary for matching patterns against text — but a reader with a stopwatch
and no denominator cannot tell a fast scan from a shallow one, and will
assume the second. Counting the lines costs one extra read of the tree, around
a twentieth of the scan, and it is the difference between a number you can
check and one you have to believe.

### The credentials that are not in the files any more

Every other check here reads the working tree, which is the right thing to
read and answers the wrong half of the question. A key that was committed on
Tuesday and deleted on Wednesday is gone from the files and still in the
repository — in every clone, in every fork, on every build runner that checked
it out, and in GitHub's own object store where a deleted commit stays
fetchable by its hash for months.

That is not a corner case. It is the most common way a real credential leaks:
somebody notices, deletes the line, commits the deletion, and believes the
matter is closed.

So the history is read too — what the commits *added*, with the same rules.
It is on by default for a directory on this machine, because the objects are
already on the disk and it costs about a second. It is off by default for a
repository the cloud clones, because reading history needs a deeper clone and
that is bandwidth and disk on somebody else's server; `--history` on the
command line, or the toggle on a connected repository, turns it on.

Three limits keep it honest — a commit cap, a byte cap and a timeout — and all
three are reported when they bite. So is a shallow clone, and so is having
been turned off. A check that did not run and does not say so reads exactly
like a check that found nothing.

A credential that is *still* in the files is reported once, not twice: the
working-tree finding is the emergency and already carries the right advice.

### Handing a scan over

Seven formats, one list. The window and the dashboard both draw their chooser
from it, so a format added once appears in all three places and no surface can
quietly offer fewer.

```bash
viruxa . --format=pdf                              # for a person, or an audit
viruxa . --format=pdf,csv,sarif                    # several at once
viruxa . --format=markdown --out=for-the-ticket.md # one, somewhere specific
viruxa repo owner/app --format=html
```

| | For | What it is |
| --- | --- | --- |
| **PDF** | an audit, a customer | Paginated, printable, the same on every machine |
| **Web page** | a colleague | One self-contained file that opens anywhere |
| **Markdown** | a ticket, a wiki | Pasteable, with a table that reads in three seconds |
| **Spreadsheet** | analysis | Every field of every finding, as CSV |
| **JSON** | a script | This product's own shape |
| **SARIF** | a pipeline | What GitHub shows inline on a pull request |
| **Bill of materials** | procurement | CycloneDX — everything installed |

**The PDF is written by hand.** Every library that produces one is a
dependency, so this is the format itself: numbered objects, a stream of
drawing operators, a table of byte offsets. It uses two of the fourteen fonts
every reader is required to have, which is why nothing is embedded and the
file is kilobytes. It carries no JavaScript, no embedded files and no external
references — a PDF can have all three and a security report has no business
with any of them.

**The web page** is self-contained: no script, no network, no fonts to fetch.
It reads on paper and on a dark screen without anybody choosing, and it is a
document rather than an application — a security report gets forwarded to
people who will not run anything you send them.

**SARIF** is what GitHub reads. Upload one from a workflow and the findings
appear inline on the pull request that introduced them, next to the lines they
are about, for people who were never going to open a separate dashboard —
which is most of them. The fingerprints are the same ones the cloud uses, so a
finding dismissed in the dashboard and the same finding in a SARIF upload are
recognisably one thing.

**The bill of materials** is CycloneDX: everything installed, whether or not
anything is wrong with it, with the known advisories attached to the
components they are about.

Three things every one of them does. Evidence arrives already redacted from
the scanners, so a report identifies a finding without carrying the
credential, and no file content from the scanned project appears anywhere.
Everything that came out of somebody's repository is escaped for the format it
is being put into — including the **spreadsheet**, where a cell beginning `=`,
`+`, `-` or `@` is a formula Excel will run, and a security tool shipping a
file that executes a payload from the code it was scanning would be the
delivery mechanism rather than the warning. And the file from the dashboard is
fetched with your session rather than linked, so a token never lands in a URL,
a browser history or an access log.

### What a dependency is, apart from whether it is vulnerable

OSV answers one question: has somebody published a vulnerability against this
version. Two others matter and are asked of
[deps.dev](https://deps.dev), which takes the same package name and version
and nothing else about the project:

* **The author has told you to stop.** A deprecated package is a maintainer
  saying, in the registry itself, that it is no longer looked after. Scanning
  OWASP NodeGoat turns up `bcrypt-nodejs`, `request` and `csurf` among
  others — none of them a vulnerability today, all of them where the next one
  will be, because nobody is watching.
* **Whether you are allowed to use it.** A dependency that declares no licence
  may not legally be used by anybody, and strong copyleft inside something you
  distribute is a question for a lawyer. Neither is a security finding and both
  say so — but it is the thing a security review is most often asked for and
  least often able to answer.

Checked for the first 200 packages, and it says when it stopped.

### Which of these actually matters

The dependency scan is exhaustive, and that is its problem. A real repository
comes back with three hundred known-vulnerable packages ordered by a score
somebody assigned imagining the worst case in the abstract. Nobody fixes three
hundred things; they fix nothing, and the list becomes wallpaper.

Two public datasets answer the question the score does not, and both are
consulted on every dependency scan:

* **[CISA's Known Exploited Vulnerabilities catalogue](https://www.cisa.gov/known-exploited-vulnerabilities-catalog)**
  — about seventeen hundred vulnerabilities observed being used against real
  systems. Anything on it is reported as critical whatever it scored, says so
  in the finding, and carries the date it was added and the federal
  remediation deadline. The disagreement between "medium in theory" and
  "somebody is doing this today" is not a close call.
* **[EPSS](https://www.first.org/epss/)**, from FIRST — a daily probability
  that a vulnerability will be exploited in the next thirty days, built from
  observed attack telemetry rather than a rubric. Above 50% raises a middling
  finding; below 5% the finding says so, which is the sentence that lets
  somebody defend leaving two hundred things until next week.

Only identifiers leave the machine — no package names, no versions, nothing
about the project. The catalogue is cached beside the tool and refreshed twice
a day, and a stale copy is used rather than nothing when the network is down.
Neither feed can fail a scan.

The report collapses a package's advisories into one entry, because the answer
to all seven advisories against one `lodash` is the same single upgrade.

### Paths

A list of forty problems leaves somebody to work out which two of them, put
together, are how an attacker actually gets in. Almost nobody has time, so
almost nobody does it.

Findings that are worse in each other's company are reported first, at the
severity the combination deserves rather than the highest of its parts — a
reachable database and a credential in the repository, untrusted code and a
runner you own, an exposed service inside a container that is not a boundary.
The rules are deliberately conservative, and they live in exactly one file, so
the terminal, the dashboard and the desktop app cannot disagree about the same
scan.

### Saying "I know, and it is fine"

```js
const key = 'AKIA…';  // viruxa:ignore — fixture, see test/helpers.js
```

The comment works on the finding's line or the line above it, in any comment
syntax, because none of them are parsed. Every run prints how many findings
were suppressed — a tool that can be silenced invisibly is a tool whose
"nothing found" is worth nothing.

Credentials in test files are already reported quietly rather than as
incidents, with the assumption stated in the finding so anybody reading it can
disagree.

---

## The cloud

```bash
npm run serve          # http://localhost:8787
```

| Variable | Meaning |
| --- | --- |
| `PORT` | Defaults to 8787. |
| `HTTPS` | Set to `true` behind TLS, so HSTS is sent. |
| `TRUST_PROXY` | Set to `true` behind a proxy, so `x-forwarded-for` is believed. |
| `VIRUXA_SECRET` | 32+ random characters. Required only to store repository tokens. |
| `VIRUXA_SMTP_HOST` | The mail relay. Without it, email alerts are off and the page says so. |
| `VIRUXA_SMTP_PORT` | 587 by default. 465 is TLS from the first byte. |
| `VIRUXA_SMTP_USER` | Optional. Sent with `AUTH`, after TLS and never before it. |
| `VIRUXA_SMTP_PASS` | The password for that user. |
| `VIRUXA_SMTP_FROM` | The sender. Falls back to `VIRUXA_SMTP_USER` when that is an address. |
| `VIRUXA_SMTP_FROM_NAME` | The display name. Defaults to the product name. |
| `VIRUXA_SMTP_INSECURE` | `true` stops certificates being checked. For an internal relay with a private CA, and nothing else. |

Leave the first three unset when running it directly. Believing
`x-forwarded-for` by default would let anybody defeat the rate limit with one
header, and sending HSTS from a development server pins the browser to https
for localhost.

### Connecting an agent

Create an account in the dashboard, issue a key under **Agents**, then on the
machine you want scanned:

```bash
viruxa connect http://localhost:8787 vxa_…
viruxa . --push
```

**Agents** authenticate with a key and may only push findings. A key that leaks
cannot read anybody's results, change a decision, or touch the account — which
is what makes it safe to leave on a laptop or in a build. **People**
authenticate with a session and may read and decide.

### Connecting a repository

Under **Repositories**, connect `owner/repo`. The server clones a shallow copy
of one branch, scans it, and deletes it — on a schedule, whether or not
anybody's laptop is on.

It is worth being exact, because the headline promise is that source never
leaves the machine it lives on:

* With the **agent**, that promise is unchanged. Code is read on your own
  machine and only findings are sent.
* With a **connected repository**, the code is already on somebody else's
  machine — it is on GitHub — and this server holds a shallow copy of one
  branch for the length of one scan. Nothing is cached, nothing is indexed, and
  no file content is ever written to the database.

Both are legitimate. They are not the same, and the dashboard says so rather
than blurring it.

A private repository needs a token, and a token needs somewhere safe to live.
Set `VIRUXA_SECRET` and it is sealed with AES-256-GCM under a key that is in
the server's environment rather than in the database. Without that variable the
server **refuses the token** instead of storing it in clear text. The better
option where it fits: scan private repositories with the agent, on a machine
that already has credentials for them, and there is no token here to protect.

### Connecting a site

Under **Sites**, add your front end or your API. Nothing is requested from it
until you have proved the host is yours, in any one of three ways:

```
DNS     TXT  _viruxa.example.com  →  viruxa-site-verification=…
File    https://example.com/.well-known/viruxa-verification.txt
Header  x-viruxa-verification: viruxa-site-verification=…
```

DNS is the strongest — it shows control of the domain rather than one path on
one server. The header exists for an API with no static files to serve.

Once verified, the audit is about twenty plain GET requests made one at a time.
Nothing is fuzzed, no parameter is manipulated, no payload is sent. It is a
health check with opinions, not a penetration test, and the difference is
deliberate: the second one needs a contract and a person, not a button.

**Follow links** reads more than the front page. Up to twenty-five pages, on
the verified origin only, obeying `robots.txt` — which is not treated as a
security control, because a path listed there is often the most interesting
one on the site and this is exactly the wrong product to make an exception
with. What it is looking for is the difference: a policy on the marketing page
and none on the application is the common shape, and the application is where
an injected script would have something to steal. A site with no headers
anywhere is still one finding rather than twenty-five.

**Sign in with** carries a session cookie or a bearer token, so the audit sees
the pages behind the login instead of the one in front of it — which is where
the cookie flags that matter are actually set. Three headers are ever sent,
whatever is supplied: `authorization`, `cookie`, `x-api-key`. Anything that
chooses a destination — `host`, `referer`, `x-forwarded-for` — is dropped,
because a general "send any header" would make this a proxy with a scanner's
name on it, and a newline in a value is stripped so one request cannot become
two. The credential is sealed with the workspace secret at rest, unsealed for
the request, and the plain-http probe deliberately goes without it: sending a
session cookie over http to find out whether http answers would do the exact
thing the finding warns about.

### More than one person

An account used to be simultaneously a person and a company: one email, one
password, one set of findings. Two people at the same place shared a password,
and there was no way to say "she can decide about findings, he can only read
them".

An account is still a **person**, and every account also owns a **workspace** —
which is what repositories, sites and findings actually belong to. Somebody
who works alone has one membership they never think about, and nothing about
their experience changes.

| Role | Can |
| --- | --- |
| **Owner** | Everything, including adding and removing people. |
| **Member** | Connect repositories and sites, decide about findings. |
| **Viewer** | Read the findings and download reports. Change nothing. |

**Invitations are codes, not emails.** The obvious design needs a mail server
this product does not have and should not acquire — a security tool that sends
mail is a security tool with an SMTP credential to protect — and an endpoint
that answers differently for a registered address than an unregistered one is
an account-enumeration hole. So an owner mints a code, shown once and stored
hashed, and sends it however they already talk to that person. It works once
and it expires.

Two things the model refuses to allow: a workspace can never lose its last
owner, and a membership is checked on **every request** rather than trusted
from the session — somebody removed from a workspace loses it immediately, not
whenever they next sign in.

### Is it getting better or worse?

Every findings query in this product used to end `ORDER BY started_at DESC
LIMIT 1`, while the database held every scan ever run. The dashboard now draws
all of them as stacked bars, counted by severity and open findings only, so a
line that does not move when the team fixes something is impossible. A scan
that found nothing is a hairline rather than a gap — the day the count reaches
zero is the one somebody most wants to see.

### Being told

Under **Agents**, add an address to notify. Every scan is compared with the one
before it and only what is **new** is sent — a notification that repeats last
night's three hundred findings gets a mail rule within a week, after which this
product is silent in a way nobody notices.

```
POST https://your-receiver/hook
x-viruxa-timestamp: 2026-08-28T05:12:44.918Z
x-viruxa-signature: v1=<hmac-sha256 of "timestamp.body">
```

Verify it with the secret shown once when you add the address:

```js
const signed = req.headers['x-viruxa-timestamp'] + '.' + rawBody;
const expected = 'v1=' + createHmac('sha256', SECRET).update(signed).digest('hex');
// then timingSafeEqual against req.headers['x-viruxa-signature']
```

The timestamp is signed along with the body, or a captured notification replays
forever and somebody chases yesterday's incident.

Four things this deliberately does:

* **https only.** The payload is a list of your security findings and a
  signature over them; sending that in clear undoes the point of signing it.
* **No evidence in the payload.** A webhook goes to somebody else's server,
  usually a chat provider. An alert's job is to get a person to the dashboard,
  not to be the report.
* **A finding you already decided about is not news the second time.** Nor is
  one that moved down a file because somebody added an import above it — the
  comparison is on the fingerprint, the same one triage uses.
* **Twenty consecutive failures stops it**, rather than this server posting to
  a deleted URL every five minutes forever. It is disabled, not deleted, so you
  can see why and start it again.

The address itself is treated as hostile, because it is the one place a member
hands this server a URL and asks it to make a request: private addresses are
refused when you add them, the hostname is resolved and checked again at
delivery, and redirects are not followed at all.

#### By email

A webhook is the better channel and this is the one most people have. Set a
relay in the table above, then add an address under **Alerts by email**.

An address gets exactly one message before it is confirmed: a six-digit code
that expires in half an hour. Without that step, "add a recipient" is a button
that makes this server send mail to an address a stranger never asked about, on
a schedule, about an account they have nothing to do with — which is a mail
bomb with a login page in front of it, and it ends with this relay and every
real alert with it in a spam folder.

The client is written out rather than installed, for the reason everything here
is: a security scanner that ships a hundred transitive packages is asking you to
trust the exact thing it sells you a tool to distrust. SMTP submission is a
dozen commands and a body.

Three things it will not do:

* **Send in the clear.** A relay that does not offer `STARTTLS` is an error,
  not a downgrade — the password travels in `AUTH` and the alert names the
  account worth attacking. The one exception is a relay on this machine's own
  loopback interface, where there is no wire to read.
* **Put a line break in a header.** Every value is refused rather than
  stripped: silently removing a newline turns an attempt at injection into a
  message that looks fine and went somewhere else.
* **Carry the evidence.** Mail passes through several machines on the way to
  you. The alert's job is to get you to the dashboard, where the report is read
  over TLS by somebody who signed in.

A permanent refusal — "no such user" — disables the address at once rather than
generating bounces against somebody else's mail server nightly. A busy relay is
retried ten times before it gives up, and an owner can start it again.

### Code scanned as it is written

Choose **Monitoring** on a folder on this machine and two things run at once.

The first is the pass on a timer, which reads the whole tree and is the
complete answer. The second is a **watch on the files themselves**: the
operating system already knows the instant a file changes, so it is asked, and
what changed is read on its own — usually in milliseconds, because it is one
file rather than two hundred. A credential pasted into a config at 10:01 is a
finding at 10:01, not at 10:15 after it has been committed and pushed.

The live pass reads only the checks that make sense for one file — credentials,
unsafe code, supply chain, configuration, containers, pipelines,
infrastructure. Not the machine's open ports, which are not a property of a
file and would bury the thing that actually changed. Not the network checks,
which would ask OSV about the whole dependency tree every time somebody hit
save. Not git history, because nothing was committed. Not correlation, which
needs the whole picture to be honest.

**Which is why it stands beside the full pass rather than replacing it.** A
file watcher misses things: watches get dropped under load, and a file created
inside a directory created in the same instant can be missed entirely. The
window says which of the two it is showing, and the live strip reports the
files that changed rather than "the project" — a live pass that said *clean*
would be claiming a tree had been read when one file was.

Three things it refuses to absorb quietly:

* **A checkout.** `git checkout` or `npm install` changes thousands of files
  at once. Scanning the first sixty and saying nothing about the rest would
  leave you believing your tree had been read, so a burst is reported as a
  burst and left to the next full pass.
* **A system that will not watch a tree.** Recursive watching is not available
  everywhere. Watching the top directory alone would see nothing in `src/` and
  report nothing about it, which reads exactly like a tree with nothing wrong
  in it — so it says it could not start.
* **A watch that dies.** If the handle is lost the window is told, rather than
  going on showing a live indicator over nothing.

An ordinary save produces several events — editors write a temporary file,
rename it over the original and adjust the mode — so they are settled into one
batch before anything is read, and a path that has been renamed away by the
time it is looked at is dropped rather than reported as a failure.

The desktop window does the same thing locally. When it is watching a folder,
a new critical or high finding raises a notification from the operating system
— which is the point of watching, since the window is the thing you minimise
and forget.

### Finding one thing among three hundred

The findings list has a search box. Every word has to appear somewhere in the
finding, in any field and in any order — `lodash critical` and
`critical lodash` are the same search — and it matches the start of a word
rather than the middle of one, so `port` finds the open ports instead of
every card whose text mentions a report. The CWE is searchable too, worked out
from the rule, because the number in the report is what somebody has in front
of them when they come looking.

It runs entirely in the page. The findings are already here, so filtering them
is not a round trip: a request per keystroke would be slower, would put a file
path from somebody's repository into a query string, and with two in flight at
once the answer for `por` can arrive after the answer for `port` and
overwrite it. Escape clears.

### Triage

A decision is keyed on a fingerprint of the finding rather than its row, so
marking something accepted survives the next scan and the line moving. Without
that the tool re-asks about everything already dismissed, which is the fastest
way to teach somebody to ignore it.

Repository findings are recorded against paths relative to the repository, not
to the temporary clone — otherwise every finding would have a new identity on
every scan and no decision would ever stick.

### Uptime

A plain GET against a URL the account has claimed, no faster than once a
minute, sequential rather than fanned out. Private, loopback, link-local and
IPv6 unique-local addresses are refused — and so is every address a redirect
leads to, checked one hop at a time, and every address a hostname resolves to,
because `db.example.com` is an ordinary name that can answer 10.0.0.5. Without that the monitor list is a
server-side request forgery tool: a member controls a public host, answers
`302 Location: http://169.254.169.254/`, and reads the cloud credentials of the
machine running this. That guard is shared with the repository scanner and the
site audit, because three copies of it is two copies that never get fixed.

---

## Interfaces

* **Dashboard** — served by the API itself. No framework, no build step. Five
  views: findings, repositories, sites, uptime, agents. The session token lives
  in memory rather than `localStorage`, which is exactly the mistake this
  product exists to find in other people's software.
* **Report** — seven formats, above. One file, sent to whoever asked, from
  any of the three surfaces and identical from all of them.
* **Desktop** — `npm run desktop`. Three columns: the sessions you have run
  down the left with the account at the bottom, the scan itself as a
  transcript in the middle, and the objective and live file queue on the
  right. Choose a folder on this machine or a repository anywhere, then choose
  whether to scan once or keep watching — a continuous scan runs for as long
  as the window is open and files each pass as its own session, so "when did
  this start failing" has an answer. Signing in takes an email and a password
  rather than a pasted key: the window exchanges them for a key this machine
  keeps, and the password is never stored. Sandboxed, context-isolated, and
  with no filesystem bridge at all — the window asks for a scan and receives
  findings, and has no way to ask for the contents of anything.

### In a pipeline

```yaml
- uses: viruxaai-hash/viruxacloud@v1
  id: viruxa
  with:
    history: 'true'      # read what the commits added
    fail-on: high        # or `none`, to report without blocking

- uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: ${{ steps.viruxa.outputs.sarif }}
```

A composite action that runs one file with the runner's own Node. Nothing
installed, nothing fetched, no container image — a security action that pulls
something down before it can check anything has put a supply chain in front of
a supply chain scanner.

It writes a run summary GitHub renders at the top of the job, outputs
`findings`, `worst` and `sarif` for the next step, and relativises every path
so a finding is not about `/home/runner/work/…` on a machine that no longer
exists. This repository runs it on itself, which is both the example and the
only honest way to claim it works.

### Attacks in progress, and what that can honestly mean

```bash
viruxa . --threats
```

Every other check here reads something still and answers *could somebody get
in*. This one answers *is somebody trying*, which is a different question —
and "detects attacks" is the most oversold sentence in this industry, so it is
worth being exact.

An attack is only visible where it leaves a record. The one this can read is
the **cloud audit trail**: every API call made against the account, with the
identity that made it, the address it came from, and whether it was refused.
That is where a real account compromise is visible while it is still
happening. A stolen access key changes no configuration in its first hour —
it makes calls, and the shape of them is unmistakable: a burst of refusals,
then a new user, then a security group opening.

The signals are the ones that are either never legitimate or legitimate so
rarely that being asked is cheap. An alert that fires on ordinary Tuesday work
is one somebody mutes within a week, after which the product is silent in a
way that looks exactly like nothing happening — so a single failed sign-in
says nothing and twelve say something, and one refused call says nothing while
twenty say a credential is being explored by somebody who does not know what
it can reach.

**What it is not.** It is not an intrusion detection system. It does not read
network traffic, it is not a web application firewall, and it cannot see an
attack that leaves no entry in a log it can reach — a SQL injection against
your own application never touches a cloud API, so it appears in your web
server's logs and nowhere this can look. CloudTrail is also a few minutes
behind, and object-level events are off by default in most accounts. All three
of those are stated in the findings rather than left to be discovered.

Anybody who tells you a scanner sees all of that without agents on your hosts
is selling something.

**Being told.** A live threat is delivered the moment it is seen, under its
own event type — `threat.live` rather than `findings.new` — because one of
those can wait for the morning and the other cannot. Any HTTPS endpoint
receives it: Slack, Teams and Discord webhooks directly, and a WhatsApp or
SMS relay the same way. Confirmed email addresses receive it too, worded as
something happening rather than something found. The payload carries who, where and when, and
deliberately not the evidence: a webhook goes to somebody else's server, and
an alert's job is to get a person to the dashboard rather than to be the
report.

### The account, not the Terraform

```bash
viruxa . --aws --gcp --azure
viruxa . --cloud
```

`--cloud` reads whichever clouds this machine can actually authenticate to,
and names the ones it could not — a flag somebody puts in a scheduled job once
and does not edit again as their infrastructure changes. If it finds nothing
at all it says so rather than producing a scan that looks exactly like one
with no cloud flag on it.

Adding a provider is a module beside the others and one line in the registry;
the command line, the scheduler and the tests all read that registry rather
than keeping a list of their own.

Every other scanner here reads files, and the infrastructure scanner reads the
Terraform and Kubernetes that describe an account. That answers whether the
*code* is correct, which is worth knowing and is not the question anybody gets
breached by. The two diverge the first afternoon somebody opens the console to
fix something at five on a Friday — and almost every cloud incident anybody can
name is in that gap. The bucket was not public in the Terraform. The security
group was not open to the world in the module. The access key still valid three
years after the contractor left was never in any repository at all.

It runs on your machine, with credentials that machine already has, against the
account they already belong to. Nothing is uploaded, no credential is stored,
and the server never does this — the difference between a tool somebody points
at their own account and a service holding the keys to everybody's.

Every call is a read. The smallest permission set that works is the AWS-managed
`SecurityAudit` policy, and a credential allowed to read IAM but not EC2 gets
the IAM findings plus a line saying the rest was not looked at — rather than a
failed scan, which would punish exactly the least-privileged credential
somebody ought to be using.

Off unless named, like verification, because it authenticates to an account
rather than reading a directory.

**On a schedule**, which is where "continuous" earns the word for the account:

```bash
viruxa service install . --every=60 --cloud --push
```

A repository changes when somebody commits, and a scan an hour later is a scan
of what they committed. An account changes when anybody with a console clicks
something at three in the morning — the bucket goes public, the security group
opens, the key is issued — and none of that produces a commit, a review or a
notification. Read once from a terminal, it is caught only if somebody
happened to be scanning at the time.

It stays on your machine deliberately. The server could do this and would then
be holding cloud credentials for every account it watched, which is the
difference between a tool you point at your own account and the most valuable
target in the industry. The agent reads the account with the credentials
already there, and only findings travel.

### Turning it on where there is already a backlog

```bash
viruxa . --record-baseline      # accept what is there today
git add .viruxa-baseline.json
```

```yaml
- uses: viruxaai-hash/viruxacloud@v1
  with:
    baseline: .viruxa-baseline.json
    fail-on: high
    # Where the pipeline has cloud credentials, usually issued over OIDC.
    # Off by default: most runners have none, and a scan that authenticates
    # to an account nobody named is not a scan of a repository.
    # cloud: aws          # or: all
```

A severity bar on its own only works on a repository with no findings. Point
one at a codebase that has existed for three years and `fail-on: high` fails
the first build and every build after it — there is no order in which three
hundred findings can be fixed that makes the pipeline green tomorrow. So the
gate gets turned off, or never turned on, and both of those are the tool not
being used.

A baseline makes the gate **"did this change make it worse"**, which is one a
team will switch on this afternoon. Everything is still reported and still in
the SARIF; the backlog just stops failing the build. What was fixed since is
counted, because the number going down is what makes anybody keep the file
current.

The file holds fingerprints and nothing else — no paths, no evidence, no
titles. It gets committed, and a baseline listing `config.js:12 — AWS access
key ID` would be a map to every unfixed weakness in the repository, sitting in
the repository.

It records **which checks ran**, as well as what they found. That distinction
matters more than it sounds: a baseline recorded with `--gcp` and compared
against a scan run without it used to report every Google finding as *fixed* —
and then advise re-recording, which would have dropped them for good, so they
came back as **new** the day the credentials worked again. Reporting "3 fixed
since" when nothing was fixed is worse than reporting nothing: a silent gap
reads like a clean result, and that reads like somebody's week of work. Those
findings are now counted separately, as not checked this run, and nothing is
claimed about them either way.

It is never rewritten automatically. A tool that silently re-baselines accepts
every new finding the moment it appears, which is the failure this exists to
avoid; updating it is a command somebody runs and a diff somebody reviews. A
baseline that was asked for and is not there fails the job rather than
continuing with no gate at all — a mistyped path must not look like a green
build.

### Scanning that outlives the terminal

```bash
viruxa service install . --every=60
viruxa service install . --verify     # and ask the vendors, on the same clock
viruxa service status
viruxa service uninstall
```

`--watch` runs for as long as a shell is open and the desktop window runs for
as long as nobody minimises it. Both are "continuous" in the sense that they
repeat, and neither in the sense anybody means. This asks the operating system
instead: a **scheduled task** on Windows, a **launch agent** on macOS, a
**systemd user timer** on Linux.

Three things it deliberately does not do. It never asks for administrator —
all three are per-user, and a scanner that wants root to read a directory you
already own has asked for something it does not need. It never writes a
credential into a task definition, a plist or a unit file, all three of which
are world-readable. And it removes exactly what it wrote, because the thing
nobody checks about a background job is whether it can be stopped.

The scheduled scan passes `--exit-zero`: without it, Task Scheduler shows
`Last Result: 1` and systemd shows a failed unit, so the only thing the
operating system ever tells you about your background scanner is that it is
broken, on the days it worked.

### Packaging the desktop app

```bash
npm run package
```

Produces `dist/ViruxaCloud-<version>-<platform>-<arch>/` and a zip beside it.
Electron already ships a complete runtime; packaging is copying it, putting the
application where Electron looks for one, renaming the executable and
compressing the result. The zip writer, the zip *reader*, and the icon are all
written here from the standard library rather than taken as build-time
dependencies — a build tool is code that runs on the machine producing the
thing everybody installs, which makes it the most valuable place in the whole
pipeline to compromise.

```bash
npm run package -- --platform=linux --arch=x64
node scripts/package.mjs --check=dist/ViruxaCloud-0.1.1-linux-x64 --platform=linux
```

Building for a platform you are not on means fetching that platform's Electron
distribution, which is a hundred and fifty megabytes downloaded over the
network and then executed by everybody who installs the result. So it is
**checked against the SHA-256 published with the release** before it is
unpacked, and a mismatch stops the build rather than warning about it. The
`--check` pass then opens the finished directory and confirms it is a build
that will actually start: the binary, the resources, the application's own
entry point, and — on Unix, when built on Unix — the executable bit.

**macOS cannot be assembled on Windows**, and the build says so rather than
producing something broken: a `.app` bundle is mostly symlinks, and Windows
refuses to create one without Developer Mode. The release workflow builds all
four targets on Linux, where they all work.

`.github/workflows/release.yml` runs the suite on all three operating
systems, then builds four targets — Windows x64, Linux x64, and macOS on both
Apple silicon and Intel — checks each one, publishes SHA-256 checksums, and
attaches a second copy of each under a name with no version in it, so a
download button can point at
`releases/latest/download/ViruxaCloud-macos-arm64.zip` and keep working. The
release is a draft: publishing is the one action here that cannot be taken
back once people have downloaded it, so a person presses the last button.

The download page is `site/`, one static file that loads nothing from anywhere
— no analytics, no font service, no CDN. A page selling a security product
that phones home to four companies before it has said anything has lost the
argument it is making, and its workflow fails if anything external appears in
it.

**Publishing it needs a public repository or a paid plan.** GitHub Pages is
unavailable on a private repository on the free plan — Settings → Pages says
*Upgrade or make this repository public to enable Pages* and offers no source
to choose. Nothing in a workflow can change that: creating a Pages site is an
admin-level API call, and `GITHUB_TOKEN` is never granted admin however many
permissions the file declares.

So the workflow **skips rather than fails** when Pages is not there. The page
is still built and still checked for anything external on every push — that
guard is worth running whether or not there is anywhere to publish to — and
the run summary says the site was not published and why. A workflow that goes
red on every push for something nobody intends to change is how a team learns
to ignore red, and then misses the run that mattered.

Two things it deliberately does not do, both stated in its own output:

* **It does not sign anything.** Code signing needs a certificate held by a
  person, and a script that could sign on its own would mean the certificate is
  sitting on a build machine. Windows will warn about the unsigned executable
  and it is right to.
* **It does not rewrite the executable's own icon**, which means editing the
  resource section of a Windows binary. The window and taskbar icons come from
  the `.ico` it draws; the file icon in Explorer stays Electron's until there
  is a signing step to hang the rest of it from.

There is no asar, on purpose. It is not a security boundary — it is a tar file
— and shipping readable source is consistent with a product whose whole
argument is that you should be able to read what you run.

---

## Tests

```bash
npm test
```

Node's own test runner, no framework, no network. The suite covers what the
product promises rather than what the code happens to do: that evidence is
redacted, that an agent key cannot read findings, that one account cannot see
another's, that a decision survives a rescan, that a site cannot be audited
without proof of ownership, that neither the monitor nor a notification can be
pointed at the cloud metadata service, that a repository token never appears in
an error message, that a notification says what happened without carrying the
evidence, that a depth-one clone cannot make the history check report the whole
working tree twice, that a spreadsheet does not execute what it was scanning,
that a credential is offered to exactly one host and never followed to a
second, that a workspace can never lose its last owner and a removed member
loses access on their next request rather than their next sign-in,
that the PDF it writes has a cross-reference table pointing at the objects it
claims to, and that OSV is sent nothing but package names.

Several of those tests were written after finding the bug they now guard. One
of them — that every element id the two front ends reach for exists in their
own markup — found a dead error path in the desktop window within a minute of
being written, which is the sort of thing a compiler would have caught in a
project that had one.

---

## What this is not

It does not fuzz, exploit or attempt to break into anything, and it will not —
that needs a contract and a person. It does not read your source in the cloud
unless you connect a repository, and then only for the length of one scan.
Most of its configuration rules ask one question about one file; two of them
read the tree together, because the misconfigurations that cost the most are
the ones where every file is individually correct — a `.gitignore` that
excludes `.env`, a `.env` that exists as it should, and a `COPY . .` that
bakes it into an image anyway. Anything needing a third file to understand is
still missed. Its port inventory is this machine
only. A hostname is resolved, every address it answers with is checked, and
the connection is then pinned to one of the addresses that passed — so a name
that answers publicly for the check and privately a microsecond later has
nothing left to rebind, because there is no second resolution to disagree with
the first.

It reads history rather than rewriting it: it will tell you a credential was
committed in March and it will not offer to remove it, because rewriting
history breaks every clone and does not rotate the key. Rotating the key is the
fix. Its history check reads the most recent two thousand commits, and says so
when there were more.

Its packaging produces unsigned builds, and refuses to cross-build rather than
producing a directory that looks right and does not start.

Verification says what a key can reach where the vendor said so on the reply
that verified it — GitHub's scopes, SendGrid's, an AWS caller identity, a
GitLab administrator flag, a Slack workspace — and no credential is sent
anywhere extra to find out. Where a vendor does not say, the answer is that it
is not known, never that there are no permissions. Nothing here enumerates a
policy: reading what an ordinary AWS user may actually do means asking IAM
with the credential, and one authenticated request is the whole budget.

A scan that found nothing has checked what it knows how to check. That is not
the same as there being nothing there, and the report says so.

---

Private and unlicensed. All rights reserved.
