← Getting Started

Configuration Reference

Every knob PullGuard exposes. All fields are optional — the scanner works out of the box with sensible defaults. Override only what you need.

On this page

Workflow inputs

Pass values via the with: block on pullguard-dev/pullguard-action@v1. Every input is optional.

Input Default What it does
license-key none Your pg_live_* token from Stripe checkout. Unlocks Pro (44 of 46 analyzers, 1 repo), Team (all 46, 10 repos), or Enterprise (all 46, repo bands + 4 business-hour SLA + SSO/RBAC; audit-log on the roadmap). Leave empty for the Free tier (14 analyzers).
fail-on-severity empty Fail the workflow if any finding at this severity or above exists. Values: info, minor, moderate, major, critical. Empty (default) means PullGuard only reports — never blocks merge.
hourly-rate 150 Developer hourly rate in USD. Used to convert finding-effort estimates into dollar figures in the cost-of-change report. Set to your fully-loaded engineer rate for an accurate ROI signal.
config auto-detect Path to a .driftrc.yml file relative to the repo root. If not set, PullGuard looks for .driftrc.yml at the repo root.
path . Subdirectory to scan, relative to the repo root. Default scans the whole repo. Useful for monorepos — run one PullGuard step per package, each with its own path + config.
report-to-app false Enterprise When true, post the report to the PullGuard GitHub App so findings render as native Check Run annotations on the PR Files-changed view. Requires the PullGuard App installed on the repository and an online (pg_live_…) license key — offline keys validate air-gapped and cannot authenticate to the App backend. Best-effort: if the post fails, the scan, PR comment and Step Summary are unaffected (the Action logs a one-line notice).
delta false Force “Clean as You Code” delta filtering — surface only findings this change introduced (plus security criticals) — on non-PR events (push, schedule, workflow_dispatch). On pull_request this is already automatic.
base-ref PR target Git ref to diff against when delta filtering off-PR (e.g. a release tag or origin/main). Ignored on pull_request (the PR target is used). Requires a deep checkout (fetch-depth: 0) so the ref is present.
image-pin 1 Which scanner image the Action runs. 1 (default) = the stable major line — always the newest signed v1.x release, re-pointed only when a release is cut, never per-merge. latest = the bleeding-edge image rebuilt on every merge. Pin an exact release tag (v1.4.1) or a sha256:<hex> digest to freeze one version for change-controlled / air-gapped environments (release tags are preserved forever).
update-baseline false On a push to your base branch, write/refresh .drift-baseline.json from a full scan so future PR deltas match your tier and engine exactly. Your workflow commits the file. Refuses to run on a PR/delta scan.
blame false Annotate each finding with its git-blame introduced date + author in the HTML report and dashboard. Requires a deep checkout — add fetch-depth: 0 to your actions/checkout step; on a shallow checkout PullGuard warns and leaves findings undated (it never fetches history on your behalf). Adds scan latency; off by default.
collapse-preexisting-security false Relocate security findings already in your committed baseline into a counted, collapsed section of the PR comment, so the inline list is just what this PR introduced. New security still shows inline; nothing is hidden from the full report, SARIF, or the fail-on-severity gate. Requires a tier-matched baseline.
server-url none Enterprise Base URL of your self-hosted PullGuard server — when set (with server-token), scan results (never source) upload to your central dashboard after each scan. Best-effort: a failed upload never fails your build.
server-token none Enterprise Ingest bearer token for your self-hosted server (pair with server-url). Store it as a repository secret.

Outputs (for downstream steps)

Output Type What it contains
steps.<id>.outputs.score integer 0–100 Drift score (lower is better). Drives Grade.
steps.<id>.outputs.grade A–F Letter grade derived from score and severity mix.
steps.<id>.outputs.findings integer Total finding count (all severities).

Example: fail builds on Major+, custom rate

      - id: pullguard
        uses: pullguard-dev/pullguard-action@v1
        with:
          license-key: ${{ secrets.PULLGUARD_LICENSE_KEY }}
          fail-on-severity: major
          hourly-rate: 220
          path: services/payments

      - name: Post score to internal dashboard
        if: always()
        run: |
          curl -X POST https://dashboard.example.com/scores \
            -d "score=${{ steps.pullguard.outputs.score }}" \
            -d "grade=${{ steps.pullguard.outputs.grade }}"

.driftrc.yml

Per-repository configuration file at the repo root. All fields are optional. An empty file is valid — the schema fills in defaults so you only specify what you want to override.

Schema validation runs on load. Invalid types or out-of-range values produce a clear error before the scan starts (no silent ignores).

Top-level fields

Field Type Default Purpose
exclude string[] standard ignores Glob patterns appended to the built-in exclude list (node_modules, dist, build, .git, etc.). User patterns add to defaults — you cannot accidentally start scanning node_modules.
maxDepth number 15 Maximum directory depth to traverse. Guards against symlink loops.
maxFiles number 5000 Cap on files collected during traversal (monorepo safety valve).
maxFilesRead number 1000 Cap on files whose content is analyzed — the one that decides coverage. On a large repo every scan reports coverage and shows a “Partial scan” banner if this cap bites; raise it (higher = slower) or scan per‑package.
analyzers map {} Per-analyzer enable / severity / options overrides. See Analyzers block.
thresholds map see below Numeric thresholds for complexity / duplication / monolithic-file / nesting / type-coverage.
output map see below Format, minimum severity, grouping, remediation toggle.
plugins string[] [] Explicit plugin allowlist. Plugins are sandboxed; auto-discovery is disabled by design.
baseline string none Path to a baseline report — the scanner reports only NEW findings vs the baseline.
cost { enabled, hourlyRate } enabled: true, hourlyRate: 150 hourlyRate — centrally-managed dev rate for the cost estimate (alternative to the workflow input). enabled: falsehide the cost-of-fix breakdown entirely (the “Est. fix cost” headline and the Cost Breakdown section). Findings, severities, and counts still render — useful for security-focused teams that prefer no dollar figures.
compliance map 5-framework summary All 5 classic frameworks get a compact PASS/CONCERN/FAIL summary on every PR; set hipaa/pci-dss/nist/iso-27001 to true for full per-control detail tables. The 3 AI-era frameworks (eu-ai-act/iso-42001/nist-ai-rmf) are separate opt-ins, off by default and absent from the compact strip until enabled — see Compliance below.
telemetry boolean true Anonymous usage counter, free tier only (paid and offline-licensed scans never send it). Set false, or the environment variable PULLGUARD_TELEMETRY=off / DO_NOT_TRACK=1, to opt out.
repo { type } auto-detect Override the repo-type heuristic that gates OSS-hygiene checks.
taint map {} Custom taint sources, sinks, and sanitizers for proprietary frameworks.
db map OSV remote Local vulnerability database location and freshness policy. See Air-gapped mode.

analyzers: — per-analyzer overrides

Keyed by analyzer ID (e.g. builtin/complexity, builtin/security). Each entry can disable the analyzer, override its default severity, or pass analyzer-specific options.

analyzers:
  builtin/complexity:
    enabled: true
    severity: minor          # downgrade complexity findings
  builtin/duplication:
    enabled: false           # turn this analyzer off entirely
  builtin/security:
    enabled: true            # security analyzers cannot be disabled
                             # (validation rejects this with a clear error)
  builtin/dependency-freshness:
    options:
      mode: offline          # opt-in: skip registry lookups entirely —
                             # byte-identical scans on identical input,
                             # air-gap-consistent (default: online)
  builtin/naming:
    severity: info
    options:
      ignorePatterns:
        - "_test\\.ts$"

Note: security-category analyzers cannot be disabled or downgraded below their built-in floor — the schema rejects attempts to do so. This is the integrity guarantee enterprise buyers (CISO / SOC auditor) rely on.

thresholds: — numeric tuning

thresholds:
  complexity:
    maxCyclomatic: 20        # default 15
    maxCognitive: 25         # default 20
    maxParameters: 6         # default 5
    maxFunctionLength: 150   # default 300 (honest line count, not raw)
  duplication:
    minBlockSize: 8          # unset: 6-line matching + 10-line report floor; setting it pins BOTH to your value
    maxDuplicationPercent: 8 # default 5 (% of total LoC)
  monolithicFile:
    maxFileLines: 600        # default per-language; this overrides for all
    maxExports: 30           # default 20
  nesting:
    maxDepth: 5              # default 4
  typeCoverage:
    minCoveragePercent: 90   # default 80
    maxAnyPerFile: 3         # default 10

Per-language defaults (Java 1000 / Go 400 / TS 600 etc.) apply automatically. Override with a single thresholds.monolithicFile.maxFileLines value to apply globally; per-language overrides are roadmap.

output: — format and noise control

output:
  format: markdown           # text | json | sarif | markdown
  minSeverity: moderate      # info | minor | moderate | major | critical
  groupBy: severity          # category | severity | file
  showRemediation: true
  sarif:
    toolName: "PullGuard"
    toolVersion: "1.0.0"

Note: html and compliance are CLI --format values, not config values — neither is a valid output.format in .driftrc.yml (the schema rejects them). Use pullguard scan . --format html for the HTML report and the compliance: block below for framework opt-ins.

minSeverity filters the rendered output but does NOT change what is scanned — the JSON artifact always contains every finding for archive / audit purposes.

compliance: — framework opt-ins

compliance:
  hipaa: true
  pci-dss: true
  nist: false
  iso-27001: false
  eu-ai-act: false
  iso-42001: false
  nist-ai-rmf: false

Every PR shows a compact summary of the five classic frameworks — SOC 2, HIPAA, PCI DSS, NIST 800-53, and ISO 27001 — one PASS / CONCERN / FAIL line each, with no configuration required. Setting a framework to true adds its full per-control evidence table to the report (SOC 2's full grid always renders; the keys are hipaa, pci-dss, nist, iso-27001). Each section emits a "provides evidence for" disclaimer — PullGuard helps auditors; it does not grant compliance. The HIPAA / PCI / NIST / ISO control catalog is fetched at scan time (signed and verified) and degrades to a minimal embedded set, flagged "limited coverage", if your runner can't reach pullguard.dev.

Three AI-era frameworks — EU AI Act, ISO/IEC 42001, and NIST AI RMF (eu-ai-act / iso-42001 / nist-ai-rmf) — are separate, per-framework opt-ins, off by default. Unlike the classic five, they don't even appear in the compact PR-comment strip until you enable them: a US-only customer never sees EU AI Act language unless they turn it on, and an EU/Irish team turns it on deliberately. Each section is framed as “evidence supporting” an obligation (e.g. EU AI Act Art. 50 AI-authorship transparency) — never “compliant” or “certified”; PullGuard produces evidence an auditor or regulator can use, not a compliance verdict.

repo.type: — hygiene-check gating

Some checks are appropriate for public OSS but irrelevant for private customer-delivery repos (e.g. missing LICENSE or SECURITY.md). PullGuard auto-detects via GitHub Actions env + local signals; override here when the heuristic is wrong.

repo:
  type: customer-delivery    # public-oss | private-enterprise |
                             # customer-delivery | internal-service |
                             # fork-research

taint: — custom sources, sinks, sanitizers

Enterprise CMS platforms, internal RPC layers, and proprietary frameworks have their own taint surfaces that the built-in patterns do not know about. Add them here. Patterns are language-keyed regular expressions.

taint:
  sources:
    java:
      - "\\bContentManagementData\\.get\\s*\\("
    python:
      - "\\binternal_rpc_call\\s*\\("
  sinks:
    java:
      - "\\bTemplateEngine\\.render\\s*\\("
  sanitizers:
    - "\\bSecurityUtils\\.escape\\s*\\("

cost: — centrally-managed hourly rate

cost:
  hourlyRate: 220

Same effect as the hourly-rate workflow input but lives in the repo so platform teams can manage rate centrally without editing every workflow file.

plugins: — explicit allowlist

plugins:
  - "@acme/pullguard-plugin-payments-rules"
  - "@acme/pullguard-plugin-internal-rpc"

Plugin auto-discovery is intentionally disabled. Each plugin must be explicitly named here AND installed in the runner. Plugins execute in a sandboxed context with a limited API surface — supply-chain defence by design.

Complete .driftrc.yml reference

Every option, with defaults and a one-line comment each. All keys are optional — an empty file is valid; set only what you want to override. Copy this as a starting point. (.driftrc.yaml / .driftrc.json are also accepted.) Baseline / delta options are covered under the schema table above and on the Reports & Dashboard page.

# .driftrc.yml — PullGuard configuration reference (every option, with defaults).
# Place at the root of the repo PullGuard scans. All keys are OPTIONAL — an empty
# file is valid. Set only what you want to override.

# ── File discovery ───────────────────────────────────────────────────────────
exclude:                       # APPENDED to built-ins (node_modules, dist, .git, target, build…)
  - "**/generated/**"
  - "**/*.min.js"
maxDepth: 15                   # default 15 — max directory depth
maxFiles: 5000                 # default 5000 — files COLLECTED (traversal cap)
maxFilesRead: 1000             # default 1000 — files ANALYZED; raise on large repos
                               #   (a "Partial scan" banner tells you when it bites)

# ── Per-analyzer overrides (key = analyzer ID; run `pullguard list-rules`) ─────
analyzers:
  builtin/naming-conventions:
    enabled: true              # default true
    severity: minor            # escalate/downgrade (info|minor|moderate|major|critical)
  builtin/duplication:
    enabled: false             # turn an analyzer off entirely
  # Bring-your-own policy rules (Enterprise). pattern = "must NOT match";
  # mustContain = "must match". `pullguard import-semgrep <rules.yml>` emits this.
  builtin/custom-rules:
    options:
      rules:
        - id: no-system-out
          description: "Use the logger, not System.out.println"
          pattern: '\bSystem\.out\.println\s*\('   # violation when present
          severity: major
          files: '**/*.java'

# ── Numeric thresholds ────────────────────────────────────────────────────────
thresholds:
  complexity:
    maxCyclomatic: 15          # default 15
    maxCognitive: 20           # default 20
    maxParameters: 5           # default 5
    maxFunctionLength: 300     # default 300 (comments/blanks stripped first)
  duplication:
    minBlockSize: 6            # unset: 6-line matching + 10-line report floor; set, it pins BOTH (6 = pre-v1.5.4 behaviour)
    maxDuplicationPercent: 5   # default 5
  monolithicFile:
    maxFileLines: 500          # default 500
    maxExports: 20             # default 20
  nesting:
    maxDepth: 4                # default 4
  typeCoverage:
    minCoveragePercent: 80     # default 80
    maxAnyPerFile: 10          # default 10

# ── Output ────────────────────────────────────────────────────────────────────
output:
  format: markdown             # text (default) | json | sarif | markdown
                               # ('html' and 'compliance' are CLI --format values, not config values)
  minSeverity: moderate        # default info — set 'moderate' to cut CI noise
  groupBy: file                # category (default) | severity | file
  showRemediation: true        # default true
  sarif:
    toolName: PullGuard        # default PullGuard
    toolVersion: "1.4.1"

# ── Cost-of-change estimate ───────────────────────────────────────────────────
cost:
  enabled: true                # default true — false suppresses all $ figures
  hourlyRate: 150              # default 150 (USD)

# ── Over-time dashboard (pullguard dashboard → self-contained HTML) ───────────
dashboard:
  retainScans: 50              # default 50 — per-scan findings kept for drill-down
                               # in .drift-scan-details.json; 0 = keep all scans

# ── SLA / aging gate (flag findings open past a per-severity age budget) ──────
# Age measured from firstSeenAt (.drift-history.json — commit it).
sla:
  critical: 7                  # days — flag a critical open longer than this
  major: 30
  moderate: 90                 # all five severities are supported —
  minor: 180                   # omit a severity to set no SLA for it
  info: 365
  failBuild: false             # default false — true = exit non-zero on any breach

# ── Compliance evidence (SOC 2 always on; these add framework tables) ─────────
compliance:
  hipaa: false                 # default false
  pci-dss: false               # default false  (note the hyphen)
  nist: false                  # default false
  iso-27001: false             # default false
  # AI-era frameworks — separate opt-ins, OFF by default, absent from the PR
  # strip until enabled. Each renders "evidence supporting", never "compliant".
  eu-ai-act: false              # default false — EU AI Act evidence (Art. 50 etc.)
  iso-42001: false              # default false — ISO/IEC 42001 AI-management evidence
  nist-ai-rmf: false            # default false — NIST AI RMF evidence

# ── Dev-tooling tiering (opt-in, v1.5.1) ──────────────────────────────────────
# Findings under these repo-relative paths are down-ranked exactly ONE severity
# step and annotated with their original severity — never dropped from any
# surface or machine-readable output. Committed credentials and AI-agent attack
# findings are exempt and keep full severity. Nothing is ever inferred from
# path names; only paths you list here are tiered. Invalid entries (absolute
# paths / traversal) are ignored, failing toward full severity.
devTooling:
  paths:                       # default [] — up to 200 entries
    - tools/preview
    - test/containers
    - "**/provision"           # any-depth segments are supported

# ── Repo-type hint (overrides auto-detected hygiene gating) ───────────────────
repo:
  type: private-enterprise     # public-oss | private-enterprise | customer-delivery | internal-service | fork-research

# ── Custom taint: sources / sinks / sanitizers (appended to built-in 7-lang) ──
# Keyed by LANGUAGE; values are REGEX. Use single quotes so backslashes survive.
taint:
  sources:                     # framework/internal APIs returning attacker-controlled data
    java:
      - '\bHttpServletRequest\.getParameter\s*\('
    javascript:                # covers .js AND .ts
      - '\bctx\.request\.body\b'
  sinks:                       # dangerous APIs that must not receive tainted data
    java:
      - '\bTemplateEngine\.render\s*\('
  sanitizers:                  # escaping/validation that neutralises taint (any language)
    - '\bSecurityUtils\.escapeHtml\s*\('

# ── Vendored/bundled third-party findings (opt-in visibility) ─────────────────
# Default 'hidden' drops non-actionable findings on vendored/minified/bundled
# third-party files (committed credentials always keep full severity). Set
# 'info' to keep those findings VISIBLE at informational severity instead —
# annotated with their original severity, excluded from the grade, present on
# every machine-readable surface.
vendored:
  visibility: hidden           # hidden (default) | info

# ── AI-authorship provenance (EU AI Act Art. 50 evidence — opt-in) ────────────
# When enabled, PullGuard records THREE evidence layers, all informational:
#   1. marker strings in source comments (list below, user-editable);
#   2. the same markers in git COMMIT TRAILERS (e.g. the Co-Authored-By
#      disclosure AI coding tools write by default), aggregated per marker;
#   3. C2PA / Content Credentials provenance manifests on committed image
#      assets (.png/.jpg/.svg) — reported as present (unverified); signature
#      validation is on the roadmap.
# Evidence, not authorship proof: a mark means content may have been produced
# or processed by an AI tool; absence of a mark proves nothing.
aiProvenance:
  enabled: false               # default false
  markers:                     # case-insensitive substrings (comments + trailers)
    - "AI-generated"
    - "Generated by GitHub Copilot"

# ── Baseline (report only NEW findings vs a saved report) ─────────────────────
baseline: null                 # default null — e.g. ".drift-baseline.json"
collapsePreexistingSecurity: false   # default false — in delta mode, collapse baseline
                                     # (pre-existing) security findings into a counted
                                     # PR-comment section; nothing leaves the full
                                     # report, SARIF, or the fail-on-severity gate

# ── Wasm hot-path (compute-heavy analysis acceleration) ───────────────────────
wasm:
  enabled: false                # default false — set true to route supported
                               # analyzers through the bundled high-performance
                               # engine (degrades gracefully when unavailable)

# ── Anonymous free-tier usage counter ──────────────────────────────────────────
telemetry: true                # default true — free tier only (paid/offline never
                               # send it); set false or PULLGUARD_TELEMETRY=off to opt out

# ── Plugins (explicit opt-in; sandboxed; no auto-discovery) ───────────────────
plugins: []                    # e.g. ["@acme/pullguard-rules"]

# ── Local vuln DB (offline / air-gapped CVE scanning) ─────────────────────────
db:
  path: "~/.drift-detector/db" # default ~/.drift-detector/db
  maxAgeDays: 7                # default 7 — warn if DB is staler
  sourceUrl: "https://osv-vulnerabilities.storage.googleapis.com"   # OSV mirror override

Baseline & delta

By default a scan reports the whole codebase. On a busy repo that means every pull request shows the same long list and developers stop reading it. Baseline and delta modes fix that — they show only what changed, so a PR comment reads “this change introduced 3 findings” instead of “the repo has 200”.

Three ways to scope output

ModeShowsHow
Full (default off-PR) The entire inventory. scan .  or  scan . --full
Baseline diff Only findings new vs a committed snapshot. scan . -b .drift-baseline.json
PR delta (“Clean as You Code”) Only findings the pull request introduced. scan . --deltaauto-on for PR events

Resolution order when more than one applies: an explicit --baseline <file> → the baseline: key in .driftrc.yml → an auto-detected .drift-baseline.json at the repo root. Opt out with --no-baseline. Critical and security findings always surface even in delta mode — an injection, secret, or RCE is never hidden just because it is “old” or sits in an unchanged file.

The .drift-baseline.json file

A fingerprinted snapshot of an accepted scan. Each finding carries a stable fingerprint that survives line-number shifts — an edit above a finding does not make it look “new”. Commit the file so every later scan can diff against it.

{
  "version": "1.4.1",
  "generatedAt": "2026-06-23T10:14:55.812Z",
  "projectPath": "/work",
  "score": 22,
  "grade": "B",
  "findingCount": 3,
  "findings": [
    {
      "fingerprint": "kx9p2a_87",
      "ruleId": "complexity",
      "type": "high_complexity",
      "file": "src/main/java/com/example/OrderReconciler.java",
      "severity": "moderate",
      "excerpt": "Function 'reconcile' has cyclomatic complexity 21 (threshold 15)"
    },
    {
      "fingerprint": "3mf0wq_112",
      "ruleId": "duplication",
      "type": "code_duplication",
      "file": "src/main/java/com/example/LegacyImporter.java",
      "severity": "minor",
      "excerpt": "Duplicated block (18 lines) also in BulkImporter.java"
    },
    {
      "fingerprint": "9aa7zk_64",
      "ruleId": "monolithic_file",
      "type": "monolithic_file",
      "file": "src/main/java/com/example/ContentService.java",
      "severity": "moderate",
      "excerpt": "File has 1240 lines (threshold 1000)"
    }
  ]
}

Generate & commit it

Recommended (CI): let the scan that already runs on your base branch maintain the baseline. Add update-baseline: true to a run triggered on a push to your integration branch — it writes .drift-baseline.json from that scan’s full inventory, automatically at the right tier, image, and context, so the baseline always matches what your PR scans compare against. No separate key-handling step:

# .github/workflows/pullguard-baseline.yml — runs on the base branch
on:
  push:
    branches: [develop]        # your integration branch
permissions:
  contents: write
jobs:
  baseline:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: pullguard-dev/pullguard-action@v1
        with:
          license-key: ${{ secrets.PULLGUARD_LICENSE_KEY }}
          update-baseline: true
      # commit the refreshed baseline
      - run: |
          git config user.name  github-actions[bot]
          git config user.email 41898282+github-actions[bot]@users.noreply.github.com
          git add .drift-baseline.json
          git diff --cached --quiet || git commit -m "chore: refresh PullGuard baseline"
          git push

Your PR workflow needs no change — on a pull request the scan auto-detects the committed .drift-baseline.json and shows only new findings.

Alternative (one-off / local): the dedicated command writes the same file directly.

pullguard baseline . --license-key "$KEY"   # generate at your scan tier

# In Docker (offline / air-gapped form)
# Export the key + pass it through with `-e PULLGUARD_LICENSE_KEY` (no =value) so
# it is not visible on the process command line (ps / /proc/<pid>/cmdline).
export PULLGUARD_LICENSE_KEY="$KEY"
docker run --rm -v "$PWD:/work" -e PULLGUARD_LICENSE_KEY \
  --entrypoint node ghcr.io/pullguard-dev/pullguard:1 \
  /app/dist/bin/drift-detector.js baseline /work -o /work/.drift-baseline.json

git add .drift-baseline.json && git commit -m "chore: PullGuard baseline"

Refresh it the same way after your integration branch moves — regenerating is just a scan written to disk.

Generate the baseline at your scan tier, with the same image. A baseline is only valid for the engine and license tier that produced it. Pass your license (--license-key / PULLGUARD_LICENSE_KEY) and generate it with the same Action/image you scan with — otherwise the delta can’t match your scan’s findings and a PR shows a near-full report. Keep .drift-baseline.json tracked (not git-ignored) so CI can read it.

Security always surfaces. The delta hides pre-existing tech-debt on untouched files, but security findings appear on every PR by design. To keep a security-heavy repo’s PR view focused, set collapsePreexistingSecurity: true (or the collapse-preexisting-security Action input) — pre-existing security findings move to a collapsed, counted section while new ones stay inline; nothing is removed from the full report, SARIF, or the build gate.

What a delta scan prints

Every comparison prints a one-line summary; the report body then contains only the new findings:

Baseline comparison: 3 new, 5 fixed, 42 unchanged (delta: -2)

The committed history files that power finding ages and the over-time dashboard (.drift-history.json, .drift-scan-details.json, .drift-trend.json) are covered on the Reports & Dashboard page.

CLI reference

The Action wraps the same CLI shipped in the image. Run it directly as pullguard <command> (or node /app/dist/bin/drift-detector.js <command> inside the container). --help works on any command.

Commands

CommandWhat it does
scan [path]Analyze a project and output a report (text / json / sarif / markdown / html).
initCreate a starter .driftrc.yml.
baseline [path]Write a fingerprinted snapshot of current findings (default .drift-baseline.json).
dashboard [path]Render the self-contained over-time HTML dashboard. --org <dir> renders a portfolio view across many repos.
trendShow the compliance-score trend over time.
ignore <fingerprint>Add a finding to .pullguardignore (--status wontfix|false_positive|acknowledged).
list-rulesList every registered analyzer and its metadata.
import-semgrep <file>Convert the regex subset of a Semgrep ruleset into .driftrc.yml custom rules. Enterprise
db update|statusManage the local CVE database for offline / air-gapped scanning.

scan options

FlagEffect
-f, --format <fmt>text | json | sarif | markdown | compliance | html | ai-bom (default text). ai-bom Enterprise emits a CycloneDX AI Bill of Materials — see AI Bill of Materials.
-o, --output <file>Write the report to a file instead of stdout.
-c, --config <path>Path to a .driftrc.yml (auto-detected at the root otherwise).
--min-severity <level>info | minor | moderate | major | critical.
-b, --baseline <file>Show only findings new vs a baseline (auto-detects .drift-baseline.json).
--no-baselineIgnore any committed baseline — show the full inventory.
--update-baselineWrite/refresh .drift-baseline.json from this scan (run a full scan on your base branch). Refuses on a PR/delta scan.
--delta / --fullForce PR-delta filtering / force full output (delta auto-on for PR events).
--blameAnnotate each finding with its git-blame “introduced” date.
--base-ref <ref>Git ref to diff against (default $GITHUB_BASE_REForigin/main).
--collapse-preexisting-securityIn delta mode, collapse pre-existing (baseline) security findings into a separate counted section of the PR comment — nothing is removed from the full report, SARIF, or the gate. See Baseline & delta.
--cost / --hourly-rate <n>Show a technical-debt cost estimate (default rate 150).
--input <file>Reformat an existing JSON report (skip scanning).
--license-key <key>License key (or set PULLGUARD_LICENSE_KEY).
--cacheIncremental analysis — cache results in .drift-cache.json and re-analyze only files changed since the last run.
--no-colorDisable colored output (the NO_COLOR environment variable is also respected).
-q, --quiet / -v, --verboseScore line only / analyzer timing + debug.

Full per-command help: pullguard scan --help.

.pullguardignore

A YAML suppression file at the repo root for known-acceptable findings. Each entry suppresses one specific finding by its stable fingerprint (from the JSON report), with an audit-visible reason. Add entries with the CLI rather than by hand:

Add a suppression (CLI)

# fingerprints come from the "fingerprint" field of the JSON report
pullguard scan . --format json > pullguard-report.json
pullguard ignore <fingerprint> "reason shown to auditors"

File format

version: 1
entries:
  - fingerprint: "sha256:abc123…"      # the finding's stable fingerprint
    reason: "Generated code — vendored, reviewed"
    ruleId: "builtin/duplication:type-1-clone"
    status: false_positive             # wontfix | false_positive | acknowledged
    addedBy: "alice@example.com"
    addedAt: "2026-05-15T10:30:00Z"
    expiresAt: null                    # optional — auto-revert after this date

Hard floor (enforced): a security finding can never be silently hidden. A wontfix or status-less ignore entry targeting one is rejected with a clear error. (To exclude a path from scanning entirely, use exclude: in .driftrc.yml.)

False positive on a security finding? Add an entry with status: false_positive and a reason. The finding stays fully visible in the report, SARIF, Step Summary and PR comment — tagged as an overridden false positive with your reason and name — but it no longer blocks the merge. Nothing is hidden; the entry is the audit record. Keep .pullguardignore under CODEOWNERS so every security override is reviewed, and set expiresAt so it re-surfaces later. (A wontfix — a real issue you won’t fix yet — keeps blocking by design.)

Adding suppressions from a PR comment

Team Enterprise Comment /pullguard ignore <rule-id> on a PR. The PullGuard App opens a follow-up PR adding the entry to .pullguardignore with the original finding linked in the body for audit purposes. Reviewer of that follow-up PR is the auditor of record.

Tier limits

Each plan has a different scope. Tier limits are enforced at scan time via online validation; the scanner falls back to Free tier with a clear banner when a cap is hit, so a scan never silently breaks.

Plan Analyzers Repositories Contributors Enforcement
Free 14 Unlimited public; 1 private Unlimited Hard — analyzers gated client + server side
Pro 44 of 46 1 private (bound at first scan) Unlimited Hard — three-layer repo binding (Worker + scanner + Worker server-side)
Team All 44 Up to 10 private Unlimited Hard — repos accumulate on first scan; 11th repo runs Free until a slot is freed or the customer upgrades
Enterprise All 44 Unlimited Unlimited Contractual (no code-level cap)

Team-tier 10-repo cap behaviour

When a Team-tier license scans a repository, that repository is recorded against the license. The customer can scan up to 10 distinct repositories. Behaviour at the boundary:

No contributor cap

PullGuard does not cap contributors on any tier. A 5-developer team and a 50-developer team on the same set of repositories pay the same price. Repository count is the single tier dimension.

Air-gapped mode

For runners behind a firewall or with no outbound internet, PullGuard ships a local vulnerability database. Populate the database directory from a connected machine, copy that directory across your air-gap, and run scans with no network calls.

This step is required, not an optimisation. CVE detection needs a vulnerability source. PullGuard resolves one in this order:

  1. the local database, when db.path / --db-path is set — no network calls;
  2. otherwise the OSV API, which needs outbound HTTPS to api.osv.dev.

On an air-gapped runner with no local database, neither is available — so PullGuard cannot check your dependencies. It does not report them as safe: the scan is marked limitedCoverage, builtin/dependency-vulnerabilities is listed in incompleteAnalyzers (both also surface in the SARIF run properties and the GitHub Security tab), and the log warns that CVE results are incomplete. Zero CVE findings without a database is "not checked", never "clean".

1. Populate the DB directory (connected machine)

docker run --rm -v "$PWD/db:/db" \
  ghcr.io/pullguard-dev/pullguard:latest \
  db update --db-path /db

Check freshness any time with db status --db-path /db. Restrict which ecosystems download with --ecosystems npm,PyPI,Go,Maven,RubyGems.

2. Transfer the directory across the air-gap

Copy the whole db/ directory to the air-gapped runner (e.g. rsync, removable media, or your artifact channel). There is no archive step — the directory is the database.

rsync -a ./db/ user@airgapped-runner:/opt/pullguard/db/

3. Point .driftrc.yml at the local DB

db:
  path: /opt/pullguard/db   # absolute path on the runner
  maxAgeDays: 30            # warn (do not fail) when DB exceeds this

With db.path set (or --db-path on the CLI), no outbound calls to OSV are made. On-disk files are created with restrictive permissions.

4. Verify CVE scanning actually ran

Don't infer it from a low finding count — confirm it. Check the database is present and fresh on the runner:

pullguard db status --db-path /opt/pullguard/db

Then confirm the scan itself was not degraded. In pullguard-report.json, a correctly-provisioned air-gapped scan has no limitedCoverage flag and no dependency-vulnerabilities entry in incompleteAnalyzers:

jq '{limitedCoverage, incompleteAnalyzers}' pullguard-report.json
# healthy  -> { "limitedCoverage": null, "incompleteAnalyzers": null }
# degraded -> { "limitedCoverage": true,
#               "incompleteAnalyzers": ["builtin/dependency-vulnerabilities"] }

If it shows degraded, the runner reached neither the local database nor OSV — re-check db.path (it must be an absolute path on the runner, and the directory must be mounted into the container).

Keep the database fresh: a stale database is a silent recall gap for CVEs published since the last update. Re-run step 1 on your connected machine on a schedule and re-sync. maxAgeDays controls when PullGuard warns about staleness (it warns; it does not fail the scan).

Secrets & permissions

Repository secrets

Secret name Required for Value
PULLGUARD_LICENSE_KEY Pro / Team / Enterprise Your pg_live_* token from Stripe checkout email.
GITHUB_TOKEN All tiers Auto-provided by GitHub Actions — you do not create this.

Workflow permissions

PullGuard needs the following permissions: block at the workflow or job level:

permissions:
  contents: read           # to read your code
  pull-requests: write     # to post the PR comment
  checks: write            # to write Check Runs (Enterprise: report-to-app)

If your organisation default permissions are restrictive (recommended), the block above is required. If your defaults are permissive (read-write), the workflow runs without an explicit block.

Repository-level vs organisation-level secrets

Repository-level is the CISO-friendly default — each repo gets its own secret, with explicit per-repo audit trail and no cross-repo blast radius. Organisation-level with a repo allowlist works for platform teams managing many repos. Both models are supported; the workflow file is identical.


← Getting Started PullGuard home →