# Security signals

A deterministic pattern registry in repowise core that flags dangerous calls and committed credentials, with a history mode and line verification.

Repowise records a small set of security signals while it indexes:
pattern matches over source text, and symbol names that look
security-relevant. Pure regex and SQL. No LLM calls, no network, no
dependency resolution. How the hosted platform handles your code is a
separate page: [Hosted security and data handling](https://docs.repowise.dev/hosted/data-handling).

## Which security page you want

Three different things on this site share the word "security", so start
here.

| Page | What it covers |
|---|---|
| [Security and data handling](https://docs.repowise.dev/security) | What repowise processes, what it persists, what leaves your machine, and telemetry. The page a security reviewer signs off on. |
| [Security suite](https://docs.repowise.dev/hosted/security) | The paid product on the hosted platform: CVE and OSV matching, CISA KEV and EPSS priority, secret revocation links, SBOM and VEX export, compliance reports, audit trail. |
| This page | The free, deterministic pattern layer in OSS core. Twenty-five patterns plus a symbol-name scan, run locally during indexing and as a CI gate. |

> **Warning**
>
> This is a floor, not a scanner. The registry has no model of your
> framework, no notion of which inputs are attacker-controlled, and no
> dataflow: it cannot tell a parameterised query from a concatenated one
> beyond what the surrounding characters give away, and it cannot tell
> whether a dangerous call is reachable. Run a real SAST tool alongside
> it. Nothing here replaces one.

Everything below is written so you can see exactly where that floor
sits, because a security surface that overstates itself is worse than
one that reports nothing.

## Running it

```bash
repowise init                       # working-tree signals populate during indexing
repowise security scan --history    # walk every tracked revision for leaked secrets
repowise security scan --history --since v1.0.0 --to HEAD
repowise security scan --history --all-patterns --format json
```

```text
repowise security scan --history

  8 findings across 412 commits

  high   hardcoded_secret     config/settings.py:14      a3f19c2  2025-11-04
  high   hardcoded_password   deploy/bootstrap.sh:31     91b7ee0  2025-08-22
  high   hardcoded_secret     tests/fixtures/auth.py:7   4c02da8  2025-06-13
```

From an agent, through the risk surface:

```python
get_risk(targets=["src/api/"])  # includes a security_signals block for the target
```

Findings also appear on the Security tab of the code-health page, and at
`GET /api/repos/{repo_id}/security`.

## Gating a change: `repowise security check`

A separate command from `scan`, built for CI and pre-commit: it needs no
index, and it gates a change instead of reporting on a whole repo. It scans
the files a change touched and keeps findings on changed lines; secret kinds
are also checked across every commit in the change, so a key committed then
deleted still fails. Snippets are masked in every output format.

```bash
repowise security check origin/main...HEAD
repowise security check --staged
repowise security check --format sarif > security.sarif
repowise security check --fail-on med --format github
repowise security check --write-baseline .security-baseline.json
repowise security check origin/main...HEAD --baseline .security-baseline.json
```

`--fail-on` sets the lowest failing severity (`high` by default). Exit codes:
`0` passed, `1` failed, `2` could not evaluate (not a git repo, unknown
revision, missing merge-base in a shallow clone, unreadable baseline); a git
error is never read as a clean change. Formats are `table`, `json`,
`markdown`, `github` (annotations plus job summary), `sarif` and `gitlab`
(Code Quality), all with masked snippets. A baseline entry is keyed on file,
kind and masked line, so it survives line shifts, and only new findings fail.
`--fail-on-baseline-growth` also fails a change whose baseline lists a finding
the same file at the change's base does not, so re-saving the baseline cannot
hide new findings.
`repowise hook install --security` adds a pre-commit hook that runs the
staged check and blocks a commit adding a high-severity finding.
[Suppressing a finding](https://docs.repowise.dev/intelligence/security-signals#suppressing-a-finding) covers the inline markers.

See [CLI gates](https://docs.repowise.dev/cli/gates#repowise-security-check) for every flag, and
[CI](https://docs.repowise.dev/ci) for wiring it into a pipeline.

## What the registry catches

Twenty-five patterns plus a symbol-name scan, giving twenty-six kinds across
three severities. Nothing is scored, ranked, or aggregated. Severity is fixed
per kind, with two exceptions: a finding in test material (or under a
directory named `test`, `tests`, `fixtures`, `spec`, `mocks`, `examples` and
similar) is recorded at `low`, and a non-secret finding in a script, tool,
benchmark, migration or docs example is `low`. A secret keeps its severity
everywhere.

| Kind | Severity | Matches |
|---|---|---|
| `eval_call` | high | `eval(...)`, including a receiver chain (`vm.eval(`) |
| `exec_call` | high | `exec(...)`. Outside Python, only a call through a name bound to `child_process` (`exec`, `execSync`). `execFile` runs no shell and does not fire |
| `pickle_loads` | high | `pickle.loads`, except a round trip of an own object (`pickle.loads(pickle.dumps(x))`) |
| `subprocess_shell_true` | high | `subprocess.*` with `shell=True`, also across lines |
| `os_system` | high | `os.system` |
| `hardcoded_password` | high | a quoted literal assigned to a name containing `password`, any case |
| `hardcoded_secret` | high | the same for `api_key`, `apikey`, `secret`, `token`, `access_key` |
| `aws_access_key` | high | `AKIA` or `ASIA` plus 16 characters, any variable name |
| `github_token` | high | `ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_` and `github_pat_` tokens |
| `slack_token` | high | `xoxb-`, `xoxa-`, `xoxp-`, `xoxr-`, `xoxs-` tokens |
| `google_api_key` | high | `AIza` plus 35 characters, not from inside a base64 run |
| `stripe_key` | high | `sk_` or `rk_` then `live_`, `test_` or `prod_`. Publishable `pk_` keys do not fire |
| `gitlab_token` | high | `glpat-` (classic and routable), `gldt-`, `glrt-`, `glptt-`, `gloas-`, `glimt-`, `glcbt-` and runner registration tokens |
| `azure_devops_pat` | high | the 84-character Azure DevOps PAT. The legacy 52-character PAT is caught only by `hardcoded_secret` |
| `git_url_credentials` | high | a password in a git remote URL (`https://user:password@host/repo`) on a GitHub, GitLab, Bitbucket or Azure DevOps host, or a path ending in `.git`. Database URLs, local and `example.*` hosts, variables and word-like passwords do not fire |
| `private_key_pem` | high | a `-----BEGIN ... PRIVATE KEY-----` header followed by a base64 body line |
| `public_env_secret` | high | `API_KEY`, `SECRET`, `TOKEN` or `PASSWORD` behind `NEXT_PUBLIC_` or `VITE_`, excluding `..._ANON_...`; an empty assignment is dropped |
| `new_function_call` | high | `new Function(...)` |
| `fstring_sql` | med | a Python f-string query (`SELECT` with `FROM`) that interpolates a value after a comparison or `LIKE`. Interpolating a table, column or placeholder list does not fire |
| `concat_sql` | med | `.execute("SELECT ... +` |
| `template_literal_sql` | med | a JS/TS template literal with `SELECT`+`FROM` or `UPDATE`+`SET` and an interpolation |
| `tls_verify_false` | med | `verify=False` passed to an HTTP client call or set as `verify`, in a file that imports an HTTP client library (`requests`, `httpx`, `aiohttp`, `urllib3`, boto3) |
| `reject_unauthorized_false` | med | `rejectUnauthorized: false` |
| `unsafe_inner_html` | med | `__html:` (React's `dangerouslySetInnerHTML`) assigned a non-literal |
| `weak_hash` | low | MD5 or SHA-1 in use in code: a call (`hashlib.md5(`), a type (`MD5.Create()`) or the algorithm named to a hashing call (`createHash("sha1")`). A call passing `usedforsecurity=False` does not fire |
| `security_sensitive_symbol` | low | a symbol named `auth`, `password`, `jwt` or `crypto`. A bare `session` or `token` is usually a database session or a lexer token and does not fire |

The last one is informational. It flags nothing wrong; it marks where
the security-relevant code lives.

The eleven **secret kinds** are `hardcoded_password`, `hardcoded_secret`,
`git_url_credentials` and the eight vendor shapes (AWS, GitHub, GitLab, Azure
DevOps, Slack, Google, Stripe, PEM). The vendor shapes fire on the
credential's own shape, not on a variable name, so a key bound to an unlisted
name or passed inline is still caught. Add shapes of your own under
`security.patterns` in `.repowise/config.yaml`; each becomes the secret kind
`custom:<name>`, scanned and masked like the built-in ones, and an invalid
pattern stops the check with exit 2. See
[Configuration](https://docs.repowise.dev/installation/configuration#the-security-block).

**How matching works.** Python `eval` and `exec` are found from the AST, so a
match is a real call. Most other kinds match over source with comments and
string literals masked out, so text describing a call does not fire. Vendor
key shapes match anywhere, comments included: a real key in a comment is
still a leak. Prose, generated, vendored and `.d.ts` files are scanned for the
secret kinds only, and placeholder values (`<your key>`, `changeme`, shell or
template expansions) are skipped.

## What the registry does not catch

Stated plainly, because the table above reads like more coverage than it
is.

**Most languages.** The registry is shaped by Python, with five patterns
added for JavaScript and TypeScript. `pickle.loads`, `os.system`,
`subprocess(shell=True)` and `verify=False` are Python-only idioms;
`fstring_sql` is a Python f-string. On a Go, Rust, or Java codebase,
`eval` / `exec` and the secret kinds are most of what can fire. A
repo in one of those languages reporting few findings is reporting the
registry's shape, not its own health.

**Apex.** The secret-scan registry has no Apex-specific patterns. SOQL injection, a class with queries and no sharing keyword and hardcoded record Ids surface as advisory [code-health markers](https://docs.repowise.dev/intelligence/code-health#language-specific-advisory-markers) instead.

**Anything needing framework semantics.** Missing authorization on a
route handler, permissive CORS on a mutating endpoint, an unvalidated
redirect, an object reference with no ownership check. These require
knowing what a route handler is, which ones mutate, and what counts as a
gate. A regex cannot do it and this layer does not try.

**Anything needing dataflow.** Whether attacker-controlled input
actually reaches a dangerous call is not computed. `eval_call` fires the
same on a constant and on a request parameter.

**Real secret detection, mostly.** `hardcoded_password` and
`hardcoded_secret` match only a literal assigned to a name that looks like a
credential. The eight vendor shapes fire on the credential's own shape, the
same approach gitleaks and trufflehog use. There is no entropy scoring: a
high-entropy string with no recognized shape is invisible unless a custom
pattern names it. For broader secret scanning, run gitleaks or trufflehog
alongside this. History mode below is complementary to those, not a
replacement.

**Dependencies.** No CVE lookup, no advisory feed, no SBOM. Nothing here
looks outside your source. That work lives in the
[hosted Security suite](https://docs.repowise.dev/hosted/security).

**False positives are expected.** `weak_hash` cannot tell an MD5 checksum
from a password hash unless the call passes `usedforsecurity=False`.
`hardcoded_password` fires on fixture credentials, at `low` under test paths.
The layer reports signals for a human to read.

## The value gate and the placeholder filter

The five JavaScript and TypeScript patterns were measured the same way
as the `exec_call` and secret-case fixes above: a 17-repository,
1109-file corpus, every hit adjudicated by hand. Three of the five
needed tightening before they were worth shipping.

| Kind | First cut | Verified real | After tightening | The noise |
|---|---|---|---|---|
| `unsafe_inner_html` | 15 | 0 | 4 | every hit a source-pinned stylesheet constant referenced by name |
| `template_literal_sql` | 10 | 2 | 2 | prose (`` `Order update failed: ${status}` ``) and a Tailwind class (`select-none`) |
| `public_env_secret` | 9 | 3 | 3 | Supabase anon keys, public by design, protected by row-level security |
| `new_function_call` | 0 | 0 | 0 | none |
| `reject_unauthorized_false` | 0 | 0 | 0 | none |

`unsafe_inner_html` ships at `med` rather than `high` because of that
first row: a name reference cannot be told apart from a pinned constant
without dataflow, so the pattern is a places-to-read signal, not a
confirmed sink. The value test is `__html:` followed by an identifier,
`$`, or `(`, written as a positive lookahead rather than a negative one
excluding quotes. Tried at the position *before* the preceding `\s*`, a
negative lookahead would trivially pass and let a string literal through
anyway.

`public_env_secret` excludes any name containing `ANON`, which is the
one carve-out narrow enough to state as a rule rather than a heuristic:
an `..._ANON_...` key is meant to be public. Nothing else in the corpus
noise was worth a similarly specific exclusion.

`template_literal_sql` requires the SQL verb's companion clause,
`SELECT`+`FROM` or `UPDATE`+`SET`, because the bare verb is an ordinary
English word and fires on both prose and a Tailwind utility class
otherwise.

## Suppressing a finding

| Mechanism | Effect |
|---|---|
| `repowise-security-ignore` on the finding's line (`repowise-security-ignore: kind, kind` for specific kinds) | Leaves the stored scan, so it does not reach the dashboard, `get_risk` or REST. `security check` still lists it as suppressed and never fails on it |
| Other scanners' markers: `pragma: allowlist secret` (or `allowlist nextline secret` on the line above), `gitleaks:allow`, `trufflehog:ignore`, `nosec`, `noqa: S105`, `S106` or `S107` | Excludes the secret finding from the stored scan and the gate. Non-secret findings on the same line still report |
| The repository's root `.gitleaks.toml` | A path its `[allowlist]` or `[[allowlists]]` `paths` match (plus gitleaks' default paths under `useDefault = true`) has no secret findings in the stored scan, the history scan or the gate. Entries scoped by `targetRules`, or by `condition = "AND"`, are not applied |
| A baseline file | `security check --baseline FILE` accepts what it lists |

There is no next-line or whole-file form of the repowise marker, and a marker
inside a string does not silence a finding.

## Working tree versus history

Two scan surfaces share the registry and the storage.

**Working tree** runs during `repowise init` and `repowise update`, over
the files as they exist now. Rows are stored with an empty `commit_sha`.
There is no separate command for it: `repowise security scan` without
`--history` is a stub that prints a hint, because re-scanning the
working tree outside indexing would duplicate what indexing just did.

**History** runs only on `repowise security scan --history`, walking
every tracked revision of every source file. Rows carry the SHA and
author date of the commit that introduced the match. This finds what the
working tree cannot: a credential committed in March and removed in
April is absent from HEAD and present in the clone forever.

History mode reports only the eleven secret kinds by default. The reasoning is asymmetry of decay. A commit that once
called `eval()` is history doing what history does: the code changed, that
is the point, and reporting every such moment across every revision buries
the surface in noise. A committed secret does not decay. It stays valid
until someone rotates it, and whoever cloned the repo still has it. Pass
`--all-patterns` to get the code-smell kinds across history too, and
expect volume.

History findings are deduplicated across file versions: a secret that stays
in a file through many commits is one finding, recorded at the earliest commit
in history order that introduced it.

Both paths land in the same `security_findings` table, with a unique
constraint on `(repository_id, file_path, kind, line_number,
commit_sha)`. Re-running either scan is idempotent. An existing index is
rescanned when the scanner changes, and every update rescans the files it
changed.

## Line verification

A finding's `line_number` is written at scan time, and the file moves
on. A wrong line on a security finding is worse than none: it sends the
reader to innocent code while looking authoritative. So the line is
re-checked against the live file every time a finding is served, using
the stored snippet, which is the first 120 characters of the matched
line and therefore always a substring of the line it came from.

Three outcomes, on every finding the API returns:

| `line_verified` | `line_number` | Meaning |
|---|---|---|
| `true` | a line | The snippet is on that line. Either it never moved, or it moved and the line was corrected. |
| `false` | a line | The snippet occurs more than once in the file. The line is a guess and the surface should mark it as one. |
| `false` | `null` | The snippet is gone from the file. The finding is stale; showing a line would point at unrelated code. |

`security_sensitive_symbol` is the exception. Its snippet is a bare
identifier rather than a line of code, and an identifier recurs
throughout a file, so relocating on it would land somewhere arbitrary.
Those findings are checked in place and never relocated or withdrawn: if
the name is not on the stored line they come back unverified rather than
treated as gone.

When the file cannot be read at all, because the repo is not checked out
where the server expects it, the stored line is passed through
unverified rather than claimed as correct.

## Storage

One table, `security_findings`, written by both scan paths:

| Column | Notes |
|---|---|
| `file_path` | Repo-relative |
| `kind` | One of the twenty-six kinds above, or `custom:<name>` |
| `severity` | `high`, `med`, `low`, fixed per pattern |
| `snippet` | Matched line, trimmed to 120 characters, every credential value masked to its first four characters and `****`; a symbol name for `security_sensitive_symbol`. The raw value is never stored |
| `line_number` | As of scan time; verified at serve time |
| `commit_sha` | Empty for working-tree rows, the introducing commit for history rows |
| `commit_at` | Author date, history rows only |

Indexing against a database that has not yet migrated the table skips
persistence silently. Every other write failure is a real error.

## Reading the surface honestly

A useful order to read a repo's findings in:

1. **Any `hardcoded_secret` or `hardcoded_password` from history mode.**
   These are the findings most likely to be both true and actionable.
   Rotate first, remove from history second.
2. **The high-severity working-tree kinds**, as a list of places to read
   rather than a list of bugs. The layer found a call; you decide
   whether it is reachable.
3. **`security_sensitive_symbol` as a map.** Where the auth and crypto
   code lives is useful context for review, and for
   [`get_risk`](https://docs.repowise.dev/mcp/get-risk) when you are about to change one of
   those files.
4. **Low findings last.** `weak_hash` cannot tell a checksum from a
   password hash.

And once more, because it is the point of the page: a low finding count
means the registry did not match. On most repos, in most languages, that
is what it means and nothing more.

## See also

- [Security and data handling](https://docs.repowise.dev/security): what leaves your
  machine, and the answers a security review asks for.
- [Security suite](https://docs.repowise.dev/hosted/security): the hosted product with CVE
  matching, reachability triage, SBOM and VEX, and revocation links.
- [Code health](https://docs.repowise.dev/intelligence/code-health): the page whose Security
  tab shows these findings.
- [`get_risk`](https://docs.repowise.dev/mcp/get-risk): the tool that carries the
  `security_signals` block.

On Hosted, from the Pro plan up, the Security suite adds dependency CVE matching with usage and reachability triage, secret detection across full git history, and SBOM and VEX export.

[Security suite](https://docs.repowise.dev/hosted/security)

[Try hosted](https://repowise.dev/login?mode=signup)
