Metadata-Version: 2.5
Name: lavalabs-secscan
Version: 1.6.0
Summary: Deterministic security scanner for GitHub, GitLab, and Bitbucket repositories: secrets, code patterns, IaC, dependency CVEs, licenses, and repository settings.
Project-URL: Homepage, https://github.com/LavaLabsAI/securityscanner
Project-URL: Documentation, https://github.com/LavaLabsAI/securityscanner#readme
Project-URL: Issues, https://github.com/LavaLabsAI/securityscanner/issues
Project-URL: Changelog, https://github.com/LavaLabsAI/securityscanner/releases
Author-email: Lava Labs <security@mail.lavalabs.ai>
License-Expression: MIT
License-File: LICENSE
Keywords: compliance,cve,devsecops,osv,sast,sbom,scanner,secrets,security
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Information Technology
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.11
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# secscan

Deterministic security scanner for GitHub repositories.

It finds leaked credentials, dangerous code patterns, vulnerable dependencies,
and weak repository settings. Every finding comes with a concrete fix.

No API keys. No LLM. No build step. The same input gives the same findings.

```console
$ secscan scan owner/repo
CRITICAL (2)
  critical  secret-aws-access-key-id  app/config.py:15
    An AWS access key ID is committed to the repository.
    evidence: AWS_ACCESS_KEY_ID = "AKIA************NP3V"
    fix: Rotate the key in IAM immediately, remove it from history, and load
         credentials from the environment or an instance role.

MEDIUM (1)
  medium    py-md5-sha1  app/auth.py:88
    MD5 is broken for collision resistance and too fast for password hashing.
    fix: Use SHA-256 for integrity. Use bcrypt or argon2 for passwords.

Summary  score 71/100 (C)   1 critical  1 medium
```

## Quick start

```bash
pipx install lavalabs-secscan        # or: pip install lavalabs-secscan (Python 3.11+, no dependencies)
secscan init                # guided setup: config, CI pipeline, alerts, baseline
secscan scan /path/to/repo
secscan scan owner/repo
secscan scan https://github.com/owner/repo
secscan scan https://gitlab.com/group/subgroup/project
secscan scan bitbucket:workspace/repo
```

Or download one file and run it with any Python 3.11+, with nothing to install.
`secscan.pyz` is attached to every
[release](https://github.com/LavaLabsAI/securityscanner/releases), with its
SHA-256 checksum:

```bash
curl -LO https://github.com/LavaLabsAI/securityscanner/releases/latest/download/secscan.pyz
curl -LO https://github.com/LavaLabsAI/securityscanner/releases/latest/download/secscan.pyz.sha256
sha256sum -c secscan.pyz.sha256
python3 secscan.pyz scan .        # or chmod +x secscan.pyz && ./secscan.pyz scan .
```

On Windows, `py secscan.pyz scan .` (`Get-FileHash secscan.pyz` to check it).
Or run the container image:

```bash
docker run --rm -v "$PWD:/src" ghcr.io/lavalabsai/secscan:1 scan .
```

From a checkout of this repository, `python3 -m secscan scan .` works as is.

Remote targets are cloned shallowly into `~/.cache/secscan/repos`.
Set `SECSCAN_CACHE` to use another directory. GitHub, GitLab (gitlab.com and
self-managed), and Bitbucket Cloud are supported; see
[GitLab and Bitbucket](#gitlab-and-bitbucket).

## What it checks

| Category | Coverage | Needs network |
| --- | --- | --- |
| Secrets | 32 rules: provider formats plus entropy-gated generics, git history with `--history`, and live checks with `--verify` | No (`--verify` does) |
| Code patterns | 85 line rules and 3 file rules across Python, JS/TS, Go, Ruby, PHP, Java, shell, Docker, GitHub Actions, Kubernetes, and Helm | No |
| Infrastructure as code | 35 resource checks for Terraform (AWS, Google Cloud, Azure) and CloudFormation: open ports, public databases and buckets, encryption at rest, IMDSv1, audit logging | No |
| Dependencies | Exact pins from Python, JavaScript, Java, .NET, Go, Rust, Ruby, and PHP manifests and lockfiles (see below), checked against OSV and prioritized with CISA KEV and EPSS | Yes |
| Licenses | Copyleft, restricted, and unknown licenses in production dependencies, or your own allow and deny lists | Yes (lockfile licenses work offline) |
| Repo hardening | Secret scanning and its open alerts, push protection, branch protection, force push, admin bypass, Dependabot alerts and updates, code scanning alerts, Actions token defaults, fork pull request workflows, deploy keys, webhooks, outside admins, production environments, org 2FA, SECURITY.md, CODEOWNERS | Token |

The full rule list is in [docs/RULES.md](https://github.com/LavaLabsAI/securityscanner/blob/main/docs/RULES.md), or run `secscan rules`.

### Secrets

Provider formats: AWS, GitHub, GitLab, Slack, Stripe, Google, private keys,
JWTs, npm, SendGrid, Twilio, OpenAI, Anthropic, Hugging Face, DigitalOcean,
Shopify, Discord, Telegram, Azure storage keys, Azure AD (Entra ID) client
secrets, Datadog, Okta, Atlassian, Mailgun, PagerDuty, and database URLs.
Formats without a distinctive prefix (Datadog, Okta, PagerDuty, newer Mailgun
keys) are only matched next to the provider's name, such as `DD_API_KEY=` or
`Authorization: SSWS`.
Generic rules use Shannon entropy and a placeholder filter to keep noise down.

Values are masked before they reach any report. A scan log is safe to share.

#### Checking whether a key is live (`--verify`)

```bash
secscan scan . --verify
```

With `--verify`, each detected credential of a supported type is checked with
one read-only request to its provider: GitHub, GitLab, Slack, Stripe, OpenAI,
Anthropic, Hugging Face, npm, SendGrid, DigitalOcean, Telegram, PagerDuty,
Mailgun, and Datadog API keys.

- A key the provider accepts becomes **critical**, and its message starts with
  "Verified live".
- A key the provider rejects drops to **low**: it is probably revoked, but it
  should still come out of the code.
- No answer, a rate limit, or anything unclear leaves the finding as it was.

Providers that run separate instances or regions never conclude "rejected"
from a single no. A GitLab token may belong to a self-managed server, so it can
be confirmed live but not dead. PagerDuty, Mailgun, and Datadog are asked in
every region before a key counts as rejected.

The requests are reads of identity or listing endpoints, never anything that
posts a message or sends mail. They go only to the provider's own HTTPS API,
redirects are not followed, and the unmasked value is held in memory for the
request and never written anywhere. AWS keys, Okta, Atlassian, and Azure AD
secrets need a second value (a secret key, an org URL, an email, a tenant) to
check, so they are counted in a scan note instead. `--verify` is off by
default, and cannot be combined with `--offline`.

Some proxies attach their own credentials to requests for a host, which would
make every key look live. Before trusting a provider, `--verify` sends it one
made-up value; if that is accepted too, the provider's answers are not used
and a scan note says why.

Database URLs that point at `localhost` are not reported. One whose host is a
Docker Compose service (`postgres://app:pass@db:5432/app` in a
`docker-compose*.yml` or `compose*.yml`) is reported as low: it is a local
development default, and the risk is reusing that password for a real
database. The same URL in a Kubernetes manifest, in a compose file with `prod`
in its name, or with a real DNS name stays high. A `[severity]` override for
`secret-database-url` takes precedence.

### Code patterns

Command injection, SQL injection, unsafe deserialization, disabled TLS
verification, weak crypto, XSS sinks, JWT verification gaps, SSRF, open
redirects, Docker secrets, `pull_request_target` checkout attacks, privileged
Kubernetes pods, mounted Docker sockets, and more.

### Infrastructure as code

Terraform and CloudFormation are parsed into resources, and each resource is
checked on its own attributes. A finding names the resource
(`aws_db_instance.orders`, or a CloudFormation logical id) and points at the
line that causes it, or at the resource when a setting is missing.

| Check | Terraform | CloudFormation |
| --- | --- | --- |
| Ports open to `0.0.0.0/0` or `::/0`: SSH and RDP (critical), database and cache ports (critical), all ports (critical), anything else but 80 and 443 (medium) | AWS security groups and rules, Google firewalls, Azure NSGs | security groups and ingress rules |
| Public databases | RDS, Redshift, DMS, Cloud SQL authorized networks, Azure SQL / PostgreSQL / MySQL firewall rules | RDS, Redshift |
| Database storage not encrypted | RDS, Aurora, DocumentDB, Neptune | RDS, Aurora, DocumentDB |
| EBS volumes not encrypted | volumes, and explicit `encrypted = false` on instances | volumes |
| IMDSv1 allowed | instances, launch templates, launch configurations | launch templates, launch configurations |
| Public buckets | weakened public access blocks, `Principal "*"` policies, `allUsers` GCS bindings, public Azure blobs | weakened public access blocks, public ACLs, `Principal "*"` policies |
| Audit logging | CloudTrail logging off or without log file validation, EKS without audit logs | same |
| Other | EKS API open to the internet, KMS keys without rotation | same, plus IAM `Action: "*"` |

Values secscan cannot read, such as `var.encrypted`, a module output, or a
CloudFormation `!Ref`, are never guessed at: the check stays quiet. Read
replicas, restores from snapshots, and Aurora instances inherit encryption
from their source, so they are not reported. An account-wide
`aws_ebs_encryption_by_default` or an `aws_ec2_instance_metadata_defaults`
requiring IMDSv2 anywhere in the repository covers volumes and instances that
leave the setting unset.

Rule ids are `tf-*` for Terraform and `cfn-*` for CloudFormation. A
`# secscan:ignore[tf-db-unencrypted]` comment works on the flagged line or on
the line above the resource. A file that cannot be parsed gets a scan note
and no findings, never a guess.

### Dependencies

Each package version is checked against the [OSV](https://osv.dev) database.
Findings include the advisory id, the severity, and the first safe version.

| Ecosystem | Read from |
| --- | --- |
| Python | `requirements*.txt`, `pyproject.toml`, `poetry.lock`, `uv.lock`, `Pipfile.lock` |
| JavaScript | `package.json`, `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lock` |
| Java (Maven) | `pom.xml`, with properties and `<dependencyManagement>` versions from the repository's other poms |
| Java (Gradle) | `gradle.lockfile`, `build.gradle(.kts)` string and map notation, `gradle/libs.versions.toml` |
| .NET (NuGet) | `*.csproj` / `*.fsproj` / `*.vbproj`, `Directory.Packages.props`, `packages.config`, `packages.lock.json` |
| Go, Rust, Ruby, PHP | `go.mod`, `Cargo.lock`, `Gemfile.lock`, `composer.lock` |

Only exact pins are checked. Ranges are skipped on purpose, because guessing a
lower bound produces false matches. Commit a lockfile and the whole tree is
checked. For Java, a version that comes from a parent pom or BOM outside the
repository can't be seen either; Gradle's dependency locking
(`gradle.lockfile`) records the resolved tree. Maven `test` scope, Gradle test
and annotation-processor configurations, and NuGet `PrivateAssets="all"`
packages count as dev only.

Findings are ordered so the ones to fix first come first:

- **Actively exploited.** A CVE in CISA's
  [Known Exploited Vulnerabilities](https://www.cisa.gov/known-exploited-vulnerabilities-catalog)
  catalog is raised to critical and listed first, with ransomware use noted.
  The advisory's own severity is kept in the JSON as `advisory_severity`.
- **Exploit likelihood.** Each CVE's [EPSS](https://www.first.org/epss/)
  score, the chance of exploitation in the next 30 days, orders findings within
  a severity. It does not change the severity.
- **Severity.** The advisory's reviewed label, or the CVSS v3 base score
  computed from its vector when there is no label.
- **Scope.** Direct or transitive, production or dev only, where the manifest
  says. Dev-only findings sort after production ones at the same severity.
  A transitive finding's fix explains how to override it.

OSV often lists one vulnerability under several ids (a GHSA and a PYSEC record,
say). They are merged into one finding. The KEV catalog and EPSS scores are
cached for a day under `~/.cache/secscan/exploits`; if either is unreachable the
scan continues and says so.

### Licenses

Each pinned production dependency's license is checked, for code you do not
want to open-source:

| Rule | Severity | Licenses |
| --- | --- | --- |
| `license-network-copyleft` | high | AGPL, SSPL: network use can oblige you to publish your source |
| `license-strong-copyleft` | medium | GPL (not LGPL): distributing a combined work can |
| `license-restricted` | medium | BUSL, Elastic, CC-BY-NC, PolyForm Noncommercial |
| `license-unknown` | low | none declared, or not a standard SPDX license |

- Licenses come from the lockfile when it records them (package-lock v2+,
  composer.lock), then from [deps.dev](https://deps.dev) (PyPI, npm, Go,
  Cargo, Maven, NuGet) or the RubyGems API. For PyPI packages deps.dev cannot map, the PEP
  639 expression, the license classifiers, and clear free text ("GNU AFFERO
  GPL 3.0") are used. Lookups are cached for good, since a version's license
  never changes.
- Older NuGet packages (many `System.*` 4.x releases) declare only a license
  URL, which has no SPDX id, so they show as `license-unknown`. Record them in
  `.secscan-accepted.toml` once you have checked the URL.
- `MIT OR GPL-3.0` passes, because you may choose MIT. `AND` takes the worst.
  A `WITH` linking exception (Classpath, GCC runtime) clears GPL.
- Dev-only dependencies are skipped. A repository whose own `LICENSE` is GPL
  or AGPL is not warned about copyleft.
- A package that is not in a public registry, or whose lookup failed, is noted
  rather than reported as unknown.
- `--no-licenses` skips the check. `secscan sbom` fills in the same licenses.

### Repository hardening

With a token, `secscan` reads the repository settings GitHub already stores and
reports the gaps that enable real attacks: an unprotected default branch, no
secret scanning, write-by-default workflow tokens, and an org without 2FA.

It also checks who and what can change the repository:

| Check | Severity | Why it matters |
| --- | --- | --- |
| `gh-secret-scanning-alerts` | critical if a credential is still valid or leaked publicly, else high | GitHub already found credentials in the repository and they are not resolved |
| `gh-outside-admin` | high | people outside the organization can change settings and delete the repository, outside the org's member policies |
| `gh-webhook-insecure` | high | deliveries over plain HTTP or with certificate checks off |
| `gh-fork-pr-secrets` | high | private repository: workflows from forks receive secrets or write tokens |
| `gh-deploy-key-write` | medium | a key that can push, not tied to a person; unused keys are called out |
| `gh-webhook-no-secret` | medium | the receiver cannot tell real deliveries from forged ones |
| `gh-environment-unprotected` | medium | a production environment any branch can deploy to without approval |
| `gh-fork-pr-approval` | low | public repository: only accounts new to GitHub need approval before their pull requests run workflows |
| `repo-codeowners` | low | no CODEOWNERS, so nobody is asked to review workflow or infrastructure changes |

Webhook findings name the receiving host, never the URL, because webhook URLs
often carry a token. Deploy keys, webhooks, collaborators, and fork settings
need admin access to read (Administration: read on a fine-grained token).
When a check cannot run, the scan notes say which and what access it needs, so
a missing finding is never mistaken for a pass.

The local tree is checked for `SECURITY.md`, automated dependency updates
(`.github/dependabot.yml` or a Renovate config), and `CODEOWNERS`. These local
checks run for GitLab and Bitbucket repositories too, which also get their own
settings checks (see [GitLab settings](#gitlab-settings) and
[Bitbucket settings](#bitbucket-settings)).

## Guided setup

`secscan init` sets secscan up for a repository in a minute. It detects where
the repository is hosted and which CI it uses, asks a few questions, shows the
files it would write, and writes them when you confirm:

```console
$ secscan init
secscan init: /work/payments
  acme/payments on github; GitHub Actions

Fail CI on findings at or above which severity? [critical/high/medium/low/none] (high):
Set up which CI? [github/gitlab/bitbucket/none] (github):
Judge pull requests only on what they change (existing issues do not block them)? [Y/n]:
Scan the default branch nightly and check for newly published CVEs every hour? [Y/n]:
Send new findings to a Slack channel? [y/N]: y
Send new findings to a webhook of your own? [y/N]:
File Jira tickets in which project? (key such as SEC, blank to skip) (none): SEC
Record today's findings as a baseline, so only new ones fail CI? (runs a scan now) [y/N]: y

  create   .secscan.toml
  create   .github/workflows/secscan.yml
  create   .secscan-baseline.json  (from a scan run now)
Write these files? [Y/n]:

Next:
  1. Add these CI secrets or variables: SLACK_WEBHOOK_URL, JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN
  2. Commit them: git add .secscan.toml .github/workflows/secscan.yml .secscan-baseline.json && git commit -m "Set up secscan"
  3. Try it now: secscan scan .
```

The pipeline it writes gates pull requests (with `--diff`, or on the whole
repository), scans the default branch on every push and nightly, runs
`secscan watch` hourly for newly published CVEs, and sends new findings to the
alerts you chose. For GitHub Actions it is `.github/workflows/secscan.yml`, with
actions pinned to commits and each step given only the secrets it uses. For
GitLab it is `.gitlab/secscan.gitlab-ci.yml`, included from `.gitlab-ci.yml`;
for Bitbucket, `bitbucket-pipelines.yml` (printed to merge if you already have
one). The secscan version is pinned, so upgrades are a reviewed change.

Existing files are never replaced without `--force`. Every question has a flag
(`--ci`, `--fail-on`, `--full-pr-scan`, `--no-schedule`, `--slack`, `--webhook`,
`--jira PROJECT`, `--baseline`), and `--yes` takes the defaults for the rest
without asking, so the same setup can be scripted across many repositories.
`--dry-run` prints the files instead of writing them.

## Usage

```bash
# Gate CI on high and critical findings
secscan scan . --fail-on high

# Audit a remote clone, including git history
secscan scan owner/repo --history 500

# Markdown for a pull request comment
secscan scan . --format markdown --output report.md

# SARIF for GitHub code scanning
secscan scan . --format sarif --output secscan.sarif

# No network calls (secrets, patterns, and local checks only)
secscan scan . --offline

# Skip whole scanners
secscan scan . --no-deps --no-hardening

# Only look at high confidence findings
secscan scan . --min-confidence high
```

Accepted targets: a local directory, `owner/repo` (GitHub), `gitlab:group/project`,
`bitbucket:workspace/repo`, or an https or SSH URL on any of the three. With no
argument, the current directory is used.

### Executive report

`--format html` writes a security memo for people who will
not read SARIF or Markdown. It opens with a letterhead and a To / From / Date /
Re block, then a short summary that states the score, the critical and high
counts, and anything that needs action now (actively exploited CVEs and exposed
credentials). The numbered sections follow: fix first, framework status,
findings by area, the riskiest dependencies, and accepted risks with their
owners and expiry dates. Every finding is listed in an appendix.

```bash
secscan scan . --output report.html             # the format follows the file name
secscan scan-org acme --output acme-report.html # one memo for the whole org
secscan scan . -o memo.html --memo-to "Board of directors" --memo-from "CISO"
secscan scan . -o memo.html --logo brand.png    # your own letterhead logo
```

| Option | Default |
| --- | --- |
| `--logo PATH` | the bundled Lava Labs logo; `none` leaves the letterhead without one. PNG, JPEG, GIF, or WebP under 1 MB |
| `--memo-to TEXT` | Engineering leadership |
| `--memo-from TEXT` | Security engineering |

The logo is read only from the command line, never from `.secscan.toml`, so a
scanned repository cannot make secscan embed one of your local files in a
report you then share. The file is checked to be a raster image and embedded
inline; SVG is refused because it can carry script.

To get a PDF, open the file in a browser and print it. Pages are A4 with
"Confidential" and "Page N of M" in the footer, tables repeat their headers,
and the appendix starts on a new page. The file has no scripts and loads
nothing from the network, so it opens offline and can be emailed. Everything
taken from the scanned repository is escaped, and a content security policy
blocks scripts and allows images only as inline data, so a hostile repository
cannot put code in the report. `SOURCE_DATE_EPOCH` fixes the date and timestamp
for reproducible output.

### Compliance mapping

Every finding is mapped to the framework controls it is evidence against, so
a scan doubles as audit evidence:

| Framework | Mapped through |
| --- | --- |
| [OWASP Top 10 (2021)](https://owasp.org/Top10/) | each rule's CWE, using OWASP's per-category CWE lists |
| SOC 2 Trust Services Criteria | Common Criteria CC6.1, CC6.3, CC6.6, CC6.7, CC6.8, CC7.1, CC8.1 |
| PCI DSS v4.0 | requirements such as 6.2.4, 6.3.1, 6.3.3, and 8.6.2 (no hard-coded credentials) |
| [NIST SSDF](https://csrc.nist.gov/projects/ssdf) (SP 800-218) | practices such as PW.5, PW.7, RV.1, PS.1 |
| [NIST SP 800-53 Rev. 5](https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final) (`nist-800-53`) | base controls: AC-2, AC-6, AU-2, AU-9, CM-3, CM-6, CM-7, IA-2, IA-5, RA-5, SA-10, SA-11, SC-7, SC-8, SC-12, SC-13, SC-28, SI-2, SI-7, SI-10; code weaknesses add SI-10 (injection) or SC-13 (weak crypto) by CWE |
| [NIST CSF 2.0](https://www.nist.gov/cyberframework) (`nist-csf`) | subcategories in Identify (ID.RA) and Protect (PR.AA, PR.DS, PR.IR, PR.PS) |
| [OpenSSF Scorecard](https://github.com/ossf/scorecard/blob/main/docs/checks.md) | checks such as Branch-Protection, Token-Permissions, Pinned-Dependencies |

```bash
# A report grouped by framework and control, with accepted risks and their owners
secscan scan . --format compliance --output compliance.md

# Only some frameworks
secscan scan . --format compliance --framework pci --framework soc2

# NIST only: SP 800-53 and the Cybersecurity Framework
secscan scan . --format compliance --framework nist-800-53 --framework nist-csf
```

Every security rule maps to at least one NIST SP 800-53 control and one CSF
subcategory, and a test enforces that for new rules. The 800-53 mapping names
base controls, not enhancements or baselines (low, moderate, high), and says
nothing about controls that are organizational (policy, training, incident
response): those need evidence a scanner cannot produce.

The JSON report adds a `compliance` object to each finding and a per-control
count to the summary. SARIF rules carry tags such as `OWASP A03:2021` and
`PCI DSS 8.6.2` or `NIST 800-53 SC-7`, which GitHub code scanning can filter on. `-v` prints the
controls under each finding.

A mapping is evidence, not an audit: a control with no findings only means
secscan's checks found nothing for it. License findings are legal obligations,
so they are not mapped.

### Software bill of materials

`secscan sbom` writes an inventory of every pinned dependency, for customers,
auditors, and regulations that ask for one (US EO 14028, the EU Cyber
Resilience Act).

```bash
# CycloneDX 1.5 (the default)
secscan sbom . -o sbom.cdx.json

# SPDX 2.3, picked from the file name or with --format spdx
secscan sbom . -o sbom.spdx.json

# CycloneDX with known vulnerabilities, KEV status, and EPSS
secscan sbom . --vulns -o sbom.cdx.json
```

- Every package carries a [package URL](https://github.com/package-url/purl-spec),
  and direct or transitive and dev-only status where the manifest records it.
  Dev-only packages have CycloneDX scope `excluded`, and are
  `DEV_DEPENDENCY_OF` the root in SPDX.
- The inventory is the exact pins from the lockfiles, so commit lockfiles for a
  complete SBOM. Ranges with no lockfile beside them are counted in a note.
- Packages in `ignore_packages` still appear: that setting quiets advisories,
  it does not hide inventory.
- Output is reproducible. The serial number comes from the content, and the
  timestamp follows `SOURCE_DATE_EPOCH` when set.
- Licenses are filled in from the same lookups as the license check. One that
  is not on the SPDX list is written as a `LicenseRef` with its original text.
  `--no-licenses` leaves them out.

Both formats pass the official validators (the CycloneDX 1.5 strict JSON
schema and `pyspdxtools`).

### Diff mode

`--diff` reports only what a change introduces, so a pull request is not
blocked by issues that were already on the base branch.

```bash
# Changes since the merge base with origin's default branch
secscan scan . --diff --fail-on high

# Against a specific base
secscan scan . --diff origin/release-2.4
```

- Code and secret findings count when their line was added or modified.
  File-level rules count when the file changed.
- Dependency findings count when the change pins a vulnerable version that the
  base did not.
- Every commit in the change is also scanned for secrets, so a key that was
  committed and then deleted is still reported.
- Repository settings are skipped, since a change cannot alter them.
- Uncommitted and untracked files are included, so it works before you push.

In CI the base is the target branch of the pull or merge request
(`GITHUB_BASE_REF`, `CI_MERGE_REQUEST_TARGET_BRANCH_NAME`, or
`BITBUCKET_PR_DESTINATION_BRANCH`). The checkout needs that branch's history:

```yaml
- uses: actions/checkout@v7
  with:
    fetch-depth: 0
- run: secscan scan . --diff --fail-on high
```

GitLab CI and Bitbucket Pipelines are under [GitLab and Bitbucket](#gitlab-and-bitbucket).

Use diff mode to gate, not for SARIF uploads. A diff-mode SARIF leaves out
existing issues, and code scanning can read that as those alerts being fixed.

### Scanning a whole organization

`scan-org` lists every repository in a GitHub org or GitLab group, scans each
one, and prints one roll-up: a score per repository (worst first), the coverage
gaps that span repositories, and org-level settings such as 2FA.

```bash
# Every active repository (archived repos and forks are skipped by default)
secscan scan-org acme

# Markdown roll-up, plus a full JSON report per repository
secscan scan-org acme --format markdown --output org.md --per-repo-dir reports/

# Only some repositories, eight at a time
secscan scan-org acme --include-repo 'payments-*' --exclude-repo '*-sandbox' --jobs 8

# A GitLab group and all its subgroups (patterns match the path below the group)
secscan scan-org gitlab:acme --exclude-repo 'sandbox/*'
secscan scan-org bitbucket:acme
secscan scan-org https://gitlab.example.com/groups/platform
```

```console
REPOSITORY     SCORE      CRIT  HIGH  MED  LOW
acme/payments    0 (F)       6    17    8    4
acme/web        98 (A)       0     0    0    2

COVERAGE GAPS
  No branch protection     1 repo   payments
  No SECURITY.md           1 repo   web
```

It needs a token that can read the org's repositories (`repo` scope for
private ones; on GitLab, `read_api` and `read_repository`; on Bitbucket, a
workspace access token with repository read, or admin for settings). Each repository's own `.secscan.toml` applies, unless `--config`
gives one policy for all of them. Repositories are re-cloned on every run so
results are current; `--reuse-clones` scans the cached copies instead. A
repository that fails to clone is listed in the report and does not stop the
rest. The exit code follows `--fail-on` across all repositories.

### Jira tickets

`secscan jira` files one Jira issue per finding in a JSON report, and never
files a second one for a finding that already has an issue:

```bash
secscan scan . -f json -o report.json
secscan jira report.json --project SEC --dry-run   # preview
secscan jira report.json --project SEC
```

It works the same on a `scan-org -f json` report, with one issue per finding
per repository, plus the organization settings findings.

- **No duplicates.** Each issue gets a label `secscan-<id>`, derived from the
  finding's fingerprint and its repository. Before creating anything, every
  candidate label is searched for in all projects the token can see, so an
  issue moved to another project still counts. An issue in any status means no
  new one. Closed issues whose finding is still detected are listed, because
  that usually means a fix regressed or a ticket was closed too early.
- **One issue per package upgrade.** All advisories for one vulnerable package
  version share an issue that lists each advisory and the version that fixes
  it. A still-vulnerable newer version gets a new issue.
- **New advisories are commented.** When a package with an open issue gains an
  advisory the issue does not mention yet (a CVE published since it was filed,
  for example by `secscan watch`), secscan adds one comment that names it, its
  aliases, its severity, and the version that fixes everything on the issue.
  The issue itself is the record, so the next run sees the id and stays quiet,
  and the same vulnerability under another id (a PYSEC record for a CVE the
  issue already names) is not new. At most `--max-comments` (default 25) issues
  get a comment per run; `--max-comments 0` turns this off. `--dry-run` lists
  the comments it would add.
- **Most important first.** Findings at or above `--min-severity` (default
  high) are filed by severity, then actively exploited, then EPSS, and at most
  `--max-new` (default 25) per run. The rest follow on later runs, so a first
  run on a large backlog does not flood the project.
- **Fields.** The summary names the severity, title, repository, and file. The
  description holds the finding, location, code, fix, compliance controls, and
  references. Priority follows severity (critical is Highest); if the project
  does not allow setting it, issues are filed without it. Add labels with
  `--label`, and change the type with `--issue-type` (default Task).

| Variable | Jira Cloud | Jira Data Center |
| --- | --- | --- |
| `JIRA_URL` | `https://acme.atlassian.net` | `https://jira.acme.example` |
| Credentials | `JIRA_EMAIL` and `JIRA_API_TOKEN` | `JIRA_PAT` (a personal access token) |
| API | REST v3, descriptions in Atlassian Document Format | REST v2, wiki markup |

Credentials are read only from the environment, never from flags, which end up
in shell history and process lists, and `JIRA_URL` must be https. Text taken
from the repository is sent as plain text (or escaped wiki markup), so a file
name cannot add a link or a macro to the issue.

In CI, run it after the scan on the default branch, for example nightly:

```yaml
- run: secscan scan . -f json -o report.json --fail-on none
- run: secscan jira report.json --project SEC
  env:
    JIRA_URL: https://acme.atlassian.net
    JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
    JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
```

secscan does not close issues when a finding goes away; close them when the
fix ships, or let a later run's "closed but still detected" list show
regressions. Accepted risks are not in the report's findings, so they never
get an issue.

### Slack and webhook alerts

`secscan notify` sends new findings from a JSON report to a Slack channel, a
webhook of your own, or both, and tells each one about a finding only once:

```bash
secscan scan . -f json -o report.json --fail-on none
secscan notify report.json --slack --webhook --dry-run   # preview
secscan notify report.json --slack --webhook
```

| Destination | Flag | Configured by |
| --- | --- | --- |
| Slack incoming webhook | `--slack` | `SLACK_WEBHOOK_URL` |
| Your own endpoint | `--webhook` | `SECSCAN_WEBHOOK_URL`, and `SECSCAN_WEBHOOK_SECRET` to sign deliveries |

- **Once per finding.** Chat has no history to search, so a state file
  (`--state`, default `.secscan-notify.json`) records what each destination
  was told. Keep it between runs, for example with `actions/cache`. A finding
  that disappears is cleared, so if it comes back it alerts again. Findings
  that are only out of view stay recorded: those of a repository that failed
  to scan, and everything when the report is from diff mode.
- **New advisories alert again.** Advisories for one package version are one
  finding, so a second CVE does not open a second alert thread. But when a
  package that already alerted gains a new advisory, it alerts again, titled
  "New advisory GHSA-...". The same vulnerability under another id (a PYSEC
  record for a CVE already alerted) does not count as new.
- **What is sent.** Findings at or above `--min-severity` (default high), most
  severe first, as one message per run and nothing when there is nothing new.
  Slack gets the severity, title, repository, file, and fix for up to 20
  findings plus a count of the rest, and a link to the GitHub Actions run when
  there is one (`--link` to set it). The webhook gets every new finding as
  JSON, with the same ids `secscan jira` uses.
- **Delivery failures.** A destination that fails is not marked as told, so
  the next run retries it; the others are unaffected. The command exits 2.

Webhook URLs are secrets (anyone with a Slack incoming webhook URL can post to
the channel), so they are read only from the environment and never printed,
even in errors. https is required and redirects are not followed. Text from the
repository is escaped for Slack, so a finding title such as `<!channel>` cannot
notify a channel.

Verify a signed webhook delivery in the receiver with the raw request body:

```python
import hashlib, hmac

def verified(body: bytes, header: str, secret: bytes) -> bool:
    expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(header or "", expected)  # header: X-Secscan-Signature-256
```

In GitHub Actions, on a schedule:

```yaml
- uses: actions/cache@v6
  with:
    path: .secscan-notify.json
    key: secscan-notify-${{ github.run_id }}
    restore-keys: secscan-notify-
- run: secscan scan . -f json -o report.json --fail-on none
- run: secscan notify report.json --slack
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
```

### Watching for new vulnerabilities

Every scan asks OSV.dev about the exact versions you pin, so a scan run after
an advisory is published reports it. `secscan watch` closes the gap between
scans: it reads the package lists of earlier scans and asks OSV again, without
cloning or reading code, so it is cheap enough to run every hour across every
repository.

```bash
# Once (daily, or on every push to main): scan and keep the JSON report
secscan scan-org acme -f json -o org.json --fail-on none

# Hourly: re-check the packages from that report, alert on anything new
secscan watch org.json -o watch.json
secscan notify watch.json --slack
secscan jira watch.json --project SEC
```

```console
acme/payments  (412 packages)
  NEW high     jinja2 2.10  GHSA-cpwx-vrp4-4pq7  published 2026-10-24, fixed in 3.1.6
      medium   requests 2.19.0  GHSA-9wx4-h78v-vm56  published 2024-05-20, fixed in 2.32.0

2 vulnerable package version(s) in 1 of 14 repositories; 1 published in the last 72 hours
```

- **Inputs.** JSON reports from `scan` or `scan-org` (they list the packages
  that were checked; diff-mode reports do not), or CycloneDX or SPDX JSON
  SBOMs from any tool, read through their package URLs.
- **Output.** Text, or JSON (`-f json`, or an `-o` ending in `.json`) in the
  shape `notify` and `jira` read. Each known vulnerability is reported every
  time; `notify` and `jira` turn that into "once per destination" and "one
  ticket", and a new advisory for an already-alerted package alerts again and
  is commented on its open Jira issue.
  `NEW` in the text marks advisories published in the last `--recent` hours
  (default 72). `--fail-on` sets the exit code (default: never fail).
- **Freshness.** OSV usually lists an advisory within hours of GitHub, PyPI,
  npm, Go, or RustSec publishing it. secscan re-reads a cached advisory as
  soon as OSV says it changed (a fix released, a severity raised), and reloads
  CISA's Known Exploited Vulnerabilities catalog every four hours.

In GitHub Actions, a daily scan saves the inventory and an hourly watch reads it:

```yaml
on:
  schedule:
    - cron: "17 3 * * *"   # scan
    - cron: "41 * * * *"   # watch
jobs:
  scan:
    if: github.event.schedule == '17 3 * * *'
    runs-on: ubuntu-latest
    steps:
      - run: pip install lavalabs-secscan
      - run: secscan scan-org acme -f json -o org.json --fail-on none
        env:
          GITHUB_TOKEN: ${{ secrets.ORG_READ_TOKEN }}
      - uses: actions/cache/save@v6
        with:
          path: org.json
          key: secscan-inventory-${{ github.run_id }}
  watch:
    if: github.event.schedule == '41 * * * *'
    runs-on: ubuntu-latest
    steps:
      - run: pip install lavalabs-secscan
      - uses: actions/cache/restore@v6
        with:
          path: org.json
          key: secscan-inventory-
          restore-keys: secscan-inventory-
      - uses: actions/cache@v6
        with:
          path: .secscan-notify.json
          key: secscan-notify-${{ github.run_id }}
          restore-keys: secscan-notify-
      - run: secscan watch org.json -o watch.json
      - run: secscan notify watch.json --slack
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
```

For a single repository, the hourly job can simply check out the code and run
`secscan scan . --no-secrets --no-sast --no-hardening --no-licenses -f json -o
report.json` before `notify`. GitLab scheduled pipelines and Bitbucket
scheduled pipelines work the same way, with their own cache to keep the
inventory and the notify state between runs.

### Baselines

A baseline hides issues you have already triaged, so new ones stand out.

```bash
secscan scan . --write-baseline .secscan-baseline.json
secscan scan . --baseline .secscan-baseline.json --fail-on high
```

Fingerprints ignore line numbers, so code moving around does not resurrect old
findings. A changed line does, because it may be a new issue.

### Accepting a risk

When a finding is real but will not be fixed now, record who accepted it, why,
and until when in `.secscan-accepted.toml` at the target root:

```toml
[[accept]]
rule = "py-md5-sha1"
path = "app/cache.py"          # glob; or fingerprint = "..." for one finding
reason = "MD5 is a cache key here, never a security boundary"
owner = "jane@acme.example"
expires = 2026-11-10
ticket = "SEC-41"              # optional

[[accept]]
rule = "dep-vulnerable-package"
advisory = "GHSA-xxxx-xxxx-xxxx"   # an advisory id or CVE alias
reason = "The vulnerable function is never called"
owner = "sam@acme.example"
expires = 2027-01-15
```

- `rule`, `reason`, `owner`, and `expires` are required. An entry missing one
  is a configuration error, not a silent skip.
- Accepted findings leave the score and the exit code, but every report still
  lists them with their owner and expiry.
- After its expiry date, an acceptance stops applying. The finding comes back
  with the owner named and blocks the build again until it is fixed or renewed.
- The scan warns 30 days before an acceptance expires, and flags entries that
  no longer match anything.
- `secscan accepted` lists every acceptance with its status and exits 1 when
  any has expired, which suits a scheduled audit job.

Keep the file in git and add it to CODEOWNERS, so accepting a risk needs the
security team's review and the history shows who approved what. `--accepted
PATH` or `[scan] accepted = "path"` points at a different file.

Baselines are still there for bulk triage. Use acceptances where an auditor
needs to see a decision.

### Suppressing a finding

Inline, next to the code. The rule id is optional.

```python
result = eval(user_input)  # secscan:ignore[py-eval-exec]
```

A file can opt out entirely with a `secscan:ignore-file` comment in its first
2 KB, in the file's own comment style (`# secscan:ignore-file`,
`// secscan:ignore-file`, `<!-- secscan:ignore-file -->`, and so on). Code or
text that merely mentions the marker, such as a string, does not count.
Suppressions are meant for generated code and deliberate fixtures, not for
silencing real findings.

## Configuration

`.secscan.toml` in the target root. CLI flags win over the file.

```toml
[scan]
exclude = ["tests/fixtures/**", "vendor/**"]
disabled_rules = ["docker-latest-tag"]
min_confidence = "medium"
fail_on = "high"
max_file_bytes = 1500000

[severity]
# Re-rank one rule without disabling it
gha-unpinned-action = "medium"

[secrets]
entropy_threshold = 3.0
allow = ["my-known-inert-test-token"]

[dependencies]
ignore_packages = ["internal-fork"]

[licenses]
deny = ["SSPL-*", "AGPL-*"]          # always flagged, high
# allow = ["MIT", "Apache-2.0", "BSD-*", "ISC"]   # only these pass
include_dev = false                  # also check dev-only dependencies
# project = "GPL"                    # override LICENSE-file detection ("none" turns it off)

[tests]
# "downgrade" (default) caps test-code findings at low, "ignore" drops them,
# "report" keeps their normal severity.
findings = "downgrade"
# Extra globs to treat as test code, on top of the built-in detection.
paths = ["qa/**"]
```

### Test code

Findings in test code are capped at `low` by default, so a deliberately unsafe
test does not fail the build. Test code means a `test`, `tests`, `__tests__`,
`spec`, `testdata`, or `e2e` directory, or a test file name such as
`test_*.py`, `*_test.go`, `*.spec.ts`, or `*Test.java`. This applies to code
patterns and to the generic secret rules. Provider-format secrets (AWS, Stripe,
GitHub, and so on) keep their full severity, because a live key in a test file
is still a leaked key.

## Exit codes

| Code | Meaning |
| --- | --- |
| 0 | No findings at or above `--fail-on` |
| 1 | Findings at or above `--fail-on` |
| 2 | Scan could not run (bad target, bad config) |

`--fail-on` defaults to `high`. Use `--fail-on none` to always exit 0.

## GitHub Actions

Use the composite action:

```yaml
- uses: LavaLabsAI/securityscanner@v1
  with:
    args: scan . --fail-on high
```

Or run it directly, which is what [this repo's workflow](https://github.com/LavaLabsAI/securityscanner/blob/main/.github/workflows/secscan.yml)
does:

```yaml
permissions:
  actions: read
  contents: read
  pull-requests: write
  security-events: write

steps:
  - uses: actions/checkout@v7
  - uses: actions/setup-python@v7
    with:
      python-version: "3.12"
  - run: pip install .
  - run: secscan scan . --format markdown --output report.md --fail-on high
    env:
      GITHUB_TOKEN: ${{ github.token }}
  - run: cat report.md >> "$GITHUB_STEP_SUMMARY"
  - run: secscan scan . --no-hardening --format sarif --output secscan.sarif
  - uses: github/codeql-action/upload-sarif@v4
    with:
      sarif_file: secscan.sarif
```

The SARIF upload is skipped on private repositories, because code scanning needs
GitHub Advanced Security there. The Markdown report and the exit code still apply.

Private repositories need a token with `repo` and, for alert counts,
`security_events`. The default `GITHUB_TOKEN` is enough for settings checks in
the same repository.

## GitLab and Bitbucket

Repositories on GitLab (gitlab.com or self-managed) and Bitbucket Cloud get the
same secret, code, IaC, dependency, and license scans. The local checks accept
Renovate in place of Dependabot. Bitbucket Cloud has no CODEOWNERS, so that
check does not run there. Both providers also get settings checks (below).

```bash
secscan scan gitlab:group/subgroup/project
secscan scan https://gitlab.example.com/team/service      # self-managed
secscan scan git@bitbucket.org:workspace/repo.git
```

A host is recognised as GitLab when it is gitlab.com, its first label contains
`gitlab` (gitlab.example.com), or it is listed in `SECSCAN_GITLAB_HOSTS`
(comma-separated, for hosts such as code.example.com).

Private repositories need a token for their own provider. `--token` goes to the
target's provider; without it:

| Provider | Credentials |
| --- | --- |
| GitHub | `GITHUB_TOKEN`, `GH_TOKEN`, or `gh auth token` |
| GitLab | `GITLAB_TOKEN` (`read_repository`, plus `read_api` for settings), else `CI_JOB_TOKEN` inside GitLab CI on the same server (clone only) |
| Bitbucket | `BITBUCKET_TOKEN` (a repository, project, or workspace access token), or an Atlassian API token: `BITBUCKET_API_TOKEN` with `BITBUCKET_EMAIL` (the email is needed for settings checks) |

Tokens are only ever sent to their own provider's host, through the
environment rather than the command line, and never appear in reports: a
local clone whose `origin` URL embeds a CI job token is reported by host and
path only. A GitLab token belongs to one instance, so `GITLAB_TOKEN` is only
sent to `GITLAB_HOST` (as with `glab`), else the instance running the CI job,
else gitlab.com. Set `GITLAB_HOST=gitlab.example.com` for a self-managed server.

### GitLab settings

With `GITLAB_TOKEN` (scope `read_api`), secscan reads the project's settings:

| Check | Severity | Why it matters |
| --- | --- | --- |
| `gl-branch-protection` | high | the default branch can be pushed, force pushed, or deleted by any Developer |
| `gl-group-2fa` | high | members of the top-level group can sign in with a password alone |
| `gl-webhook-insecure` | high | deliveries over plain HTTP or with SSL verification off |
| `gl-ci-variable-unprotected` | medium | a credential variable reaches pipelines on unprotected branches, which any Developer can push |
| `gl-job-token-unrestricted` | medium | the CI job token allowlist is off, so jobs in other projects can use their token here |
| `gl-deploy-key-write` | medium | deploy keys or tokens that can push, not tied to a person |
| `gl-environment-unprotected` | medium | a production environment any Developer can deploy to |
| `gl-force-push-allowed` | medium | the default branch's history can be rewritten |
| `gl-push-protection` | medium | secret push protection is off (Ultimate) |
| `gl-ci-variable-unmasked` | low | a credential variable is printed in clear if a job echoes it |
| `gl-branch-direct-push` | low | Developers can push to the default branch without a merge request |
| `gl-mr-approval-required` | low | no approval rule requires an approval (Premium) |
| `gl-mr-self-approval` | low | authors can approve their own merge requests (Premium) |
| `gl-pipeline-not-required` | low | merge requests can merge while the pipeline fails |
| `gl-public-pipelines` | low | job logs and artifacts of a public or internal project are visible to non-members |

Group-level CI/CD variables get the same two variable checks
(`gl-group-variable-unprotected`, `gl-group-variable-unmasked`). Variables are
judged by name (TOKEN, SECRET, PASSWORD, ...); their values are dropped as soon
as the API returns them and are never reported or stored.

Most settings are only visible to the Maintainer role, so use a project or group
access token with the Maintainer role. Anything the token cannot read, or that
the instance's tier does not have, is listed in the scan notes rather than
passed silently.

### GitLab CI

```yaml
secscan:
  image: python:3.12
  variables:
    GIT_DEPTH: 0   # diff mode needs the target branch's history
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  script:
    - pip install lavalabs-secscan
    - git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME:refs/remotes/origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
    - secscan scan . --diff --fail-on high --format markdown --output secscan.md
  artifacts:
    when: always
    paths: [secscan.md]
```

For a full scan on the default branch, drop `--diff`, the fetch, and the
`rules`, and add `--format json --output secscan.json` for `secscan jira` or
`secscan notify`. To include the settings checks, add a project access token
(Maintainer role, `read_api`) as a masked, protected CI/CD variable named
`GITLAB_TOKEN`; the job token cannot read settings.

### Bitbucket settings

With `BITBUCKET_TOKEN` (an access token), or `BITBUCKET_EMAIL` and
`BITBUCKET_API_TOKEN`, secscan reads the repository's settings:

| Check | Severity | Why it matters |
| --- | --- | --- |
| `bb-branch-protection` | high | no branch restriction applies to the main branch |
| `bb-webhook-insecure` | high | deliveries over plain HTTP or with certificate checks skipped |
| `bb-pipeline-variable-unsecured` | medium | a credential repository variable is stored unsecured: readable in settings, unmasked in logs |
| `bb-deployment-variable-unsecured` | medium | the same, for a deployment environment's variables |
| `bb-public-forks` | medium | a private repository that anyone with read access can fork publicly |
| `bb-force-push-allowed` | medium | the main branch's history can be rewritten |
| `bb-webhook-no-secret` | medium | deliveries are not signed, so the receiver cannot tell them from forgeries |
| `bb-branch-direct-push` | low | anyone with write access can push to the main branch without a pull request |
| `bb-pr-approval-required` | low | pull requests can merge without an approval |
| `bb-builds-not-required` | low | pull requests can merge with a failing pipeline |
| `bb-merge-checks-not-enforced` | low | merge checks are warnings only (enforcement needs Premium) |

Workspace variables and webhooks get the same checks
(`bb-workspace-variable-unsecured`, `bb-workspace-webhook-insecure`,
`bb-workspace-webhook-no-secret`), once per workspace in `scan-org`. Variables
are judged by name; values of unsecured variables are dropped as soon as the
API returns them and never reported. Branch restrictions, webhooks and
variables need admin access: an access token with `repository:admin` and
`pipeline:variable` scopes, or an API token of an admin. Anything it cannot read
is listed in the scan notes. Workspace 2FA enforcement has no API, so it is not
checked.

### Bitbucket Pipelines

```yaml
image: python:3.12
clone:
  depth: full   # diff mode needs the destination branch's history
pipelines:
  pull-requests:
    "**":
      - step:
          name: secscan
          script:
            - pip install lavalabs-secscan
            - git fetch origin "$BITBUCKET_PR_DESTINATION_BRANCH:refs/remotes/origin/$BITBUCKET_PR_DESTINATION_BRANCH"
            - secscan scan . --diff --fail-on high --format markdown --output secscan.md
          artifacts:
            - secscan.md
```

To include the settings checks, add a repository access token (scopes
`repository:admin` and `pipeline:variable`) as a secured repository variable
named `BITBUCKET_TOKEN`.

## How it works

```
resolve target -> walk files -> static pass (secrets + patterns)
              -> manifests -> OSV advisories
              -> GitHub settings
              -> policy filter (disabled rules, confidence, baseline)
              -> score -> report
```

The score starts at 100. Each finding subtracts its severity weight
(critical 25, high 10, medium 4, low 1) scaled by confidence. A score of 0 is
possible and means exactly what it says.

## Limits

- **No dataflow analysis.** Patterns are line based. A hit means "look here",
  not "exploitable". Confidence labels say how sure the rule is.
- **Ranges are not resolved.** Commit a lockfile. Maven versions inherited from
  a parent pom or BOM outside the repository are skipped, with a note.
- **Secret detection proves a format, not a live key.** Treat every hit as real
  until you verify it, with `--verify` where the provider allows. Rotate first,
  investigate second.
- **Settings checks need access.** Without a suitable token (GitHub, GitLab
  with the Maintainer role, or a Bitbucket admin) you get a note instead of
  findings.
- **History scanning is bounded.** `--history N` looks at the last N commits.

## Development

```bash
# tests (any of these works)
uv run --with pytest pytest
# or: pip install -e '.[dev]' && pytest

# lint
uvx ruff check .

# scan this repository
python3 -m secscan scan .
```

```
secscan/
  cli.py          argument parsing and exit codes
  scanner.py      orchestration
  files.py        walking, ignores, binary detection
  secrets.py      secret rules and entropy filtering
  patterns.py     code pattern rules
  static.py       single pass over the tree
  manifests.py    dependency manifest parsers
  osv.py          OSV API client, advisory merging
  cvss.py         CVSS v3 base scores
  exploits.py     CISA KEV and EPSS enrichment
  hardening.py    GitHub API and local hygiene checks
  scoring.py      score and grade
  org.py          org listing, parallel scans, roll-up
  diff.py         diff mode: changed lines, base package versions
  accept.py       risk acceptance: owner, reason, expiry
  sbom.py         CycloneDX and SPDX export
  feed.py         finding identity shared by Jira and alerts
  jira.py         Jira issues without duplicates
  notify.py       Slack and webhook alerts, once per finding
  licenses.py     license lookup and policy
  compliance.py   framework control mapping
  spdx_ids.py     SPDX license list (generated)
  report/         text, markdown, json, sarif, org roll-up
tests/
  fixtures/vulnerable/   positive fixture, excluded from this repo's own scan
```

## Container image

`ghcr.io/lavalabsai/secscan` is published for linux/amd64 and linux/arm64 with
every release, tagged `1.6.0`, `1.6`, `1`, and `latest`. It contains secscan,
Python, and git, runs as an unprivileged user (uid 10001), and reads the
repository from `/src`:

```bash
# Scan the current directory
docker run --rm -v "$PWD:/src" ghcr.io/lavalabsai/secscan:1 scan . --fail-on high

# Write reports into the directory as yourself
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/src" ghcr.io/lavalabsai/secscan:1 \
  scan . -f html -o secscan.html

# Pass tokens through the environment, never as arguments
docker run --rm -e GITHUB_TOKEN -v "$PWD:/src" ghcr.io/lavalabsai/secscan:1 scan .
```

In GitLab CI, clear the entrypoint so the job's script runs:

```yaml
secscan:
  image:
    name: ghcr.io/lavalabsai/secscan:1
    entrypoint: [""]
  script:
    - secscan scan . --fail-on high
```

Each image carries build provenance and an SBOM in the registry, and, from a
public repository, a signed GitHub attestation: `gh attestation verify
oci://ghcr.io/lavalabsai/secscan:1 --owner LavaLabsAI`.

## Releasing

The version is set in one place, `secscan/__init__.py`; the wheel, reports, and
SBOMs all read it from there. To release:

1. Set `__version__` to the new version and merge.
2. Tag that commit `vX.Y.Z` and move `v1` to it.

The [release workflow](https://github.com/LavaLabsAI/securityscanner/blob/main/.github/workflows/release.yml)
then checks that the tag matches `__version__`, runs the tests, and publishes
the wheel and sdist to PyPI, the image to GHCR, and `secscan.pyz` with its
checksum to the GitHub release. If the release does not exist yet, it is
created as a draft with the file attached, ready for notes. Nothing is
published if the version or the tests fail. `python scripts/build_pyz.py`
builds the same file locally; the build is reproducible, so its checksum
matches the release's.

One-time setup:

- **PyPI**: on pypi.org, add a trusted publisher (a "pending publisher" before
  the first release) for project `lavalabs-secscan`: owner `LavaLabsAI`, repository
  `securityscanner`, workflow `release.yml`, environment `pypi`. No API token
  is stored anywhere; PyPI trusts this workflow's identity instead.
- **GHCR**: after the first push, set the `secscan` package's visibility to
  public (organization Packages settings), so anyone can pull it.

## License

MIT
