← 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 + hash-chained exportable audit trail; out-of-database tamper-proof anchoring 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). This is the knob for a monorepo above 5,000 files: raise it (e.g. maxFiles: 7000) and the read cap follows it, so the scan is complete; a scan that hits this cap says so on every surface and can be refused with the partial_scan_max gate condition. The default stays 5,000 on purpose (operator decision 2026-09-15): a larger default changes every customer’s scan time and memory at once.
maxFilesRead number = maxFiles (5000) 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.
analyzerIsolation inline | worker inline Advanced hardening, opt‑in. worker runs each analyzer in a worker thread with a per‑analyzer time limit, so a single analyzer that gets stuck on one pathological file is stopped and reported as incomplete instead of holding up the scan. Everything else is unchanged: the same findings, in the same order. Costs a little start‑up time per scan. Per‑run override: PULLGUARD_ANALYZER_ISOLATION=worker; the per‑analyzer limit defaults to 180s and can be raised with PULLGUARD_ANALYZER_TIMEOUT_MS (clamped to 5–600s) if one analyzer legitimately needs longer on a very large repo. Run pullguard doctor to see which mode a repo resolves to.
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 (default off; env-gated). NOT sandboxed — an enabled plugin runs with full scan-process privileges; 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: false — hide 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.
ownership { groupBy, codeowners, mention } none / true / false Group the CI Step Summary by who owns the code. Off by default; @-mentions are a separate opt-in. See Ownership routing.
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        # unset: language-aware default (e.g. Java 25, TS/Go 15)
    maxCognitive: 25         # unset: language-aware default
    maxParameters: 6         # unset: language-aware default
    maxFunctionLength: 150   # unset: no per-length finding beyond the built-in
                             # 100-line monolithic-function rule (honest line count)
  duplication:
    minBlockSize: 8          # unset: 6-line matching + 10-line report floor; setting it pins BOTH to your value
    maxDuplicationPercent: 8 # unset: 10% per-file reporting floor; setting it replaces the floor
  monolithicFile:
    maxFileLines: 600        # unset: per-language default (Java 1000 / Go 400 / TS 600); set, applies to all languages
  nesting:
    maxDepth: 5              # unset: language-aware code-nesting default (4-5)
  typeCoverage:
    minCoveragePercent: 90   # unset: 70% warning floor
    maxAnyPerFile: 3         # unset: 10 `any` per file

Every knob is an override: unset knobs keep the language-aware defaults (Java 1000-line files / Go 400 / TS 600 etc. apply automatically); a knob you set applies globally across languages. Per-language overrides are roadmap.

React (.tsx / .jsx) is measured as components, not functions

In a .tsx or .jsx file, JSX markup lines do not count toward a function's length, and JSX conditional rendering ({cond && <X/>}, {a ? <X/> : <Y/>}) does not count as branching — it is declarative markup, not control flow. Logic inside a JSX callback still counts, and a guard clause that happens to render (if (loading) return <Spinner/>;) is still a branch.

A component that is large by markup volume rather than by logic is reported as large_component at minor severity, with the counts a front-end lead can act on — JSX elements, tree depth, hooks and event handlers — instead of a cyclomatic number that describes the design system. It escalates to moderate once the component holds too many concerns: more than 12 hooks, 15 event handlers, a JSX depth over 8, or more than 150 elements. A component whose branches live outside the markup is still reported as monolithic_function, unchanged.

The same framing applies to file size (a long screen is reported as minor with its markup share stated), to duplication (repeated markup scaffolding folds into one observation; a copied render tree of 25+ lines and any duplicated logic still report normally), and to naming (PascalCase components + camelCase hooks/handlers + SCREAMING_SNAKE constants are one convention, not three competing ones). Nothing is suppressed — every finding stays in the JSON and SARIF output.

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*\\("
  # Per-position helper proofs (Java). Opt-in, default false — see below.
  calleePositionalProofs: false

sanitizers clears flows into the custom sinks you declare above. It does not clear a finding from PullGuard’s built-in rules, and it never has.

taint:
  scopedSanitizers:
    - pattern: "\\bDb\\.escape\\s*\\("
      classes: [sql_injection]

scopedSanitizers (v1.5.18+) declares a call that makes a value safe for the named classes only, and it applies to the built-in findings as well as your custom sinks. The example clears SQL injection through Db.escape(…), and the same value written into HTML is still reported as XSS. Allowed classes: sql_injection, xss, command_injection, argument_injection, path_traversal, ssrf, insecure_deserialization, tainted_flow. Any other class is refused when the configuration loads, and so is a pattern containing a control character (in YAML, write it in single quotes: a double-quoted "\b" is a backspace). It is applied where PullGuard follows data inside a function. Where it follows data across functions or files, it uses its built-in sanitizers only. The pattern-based SQL rule does not follow data flow, so no sanitizer changes it. A pull request that adds or widens an entry (a new class, a changed pattern, or a plain entry turned scoped) is scanned without it until the change is merged. A pattern may appear in only one of the two lists.

calleePositionalProofs (v1.5.11+, default false): a Java helper that returns one of its parameters (pick(label, value) { return label; }) proves the other positions never reach its return, so pick("Welcome", param) is treated as clean at that call site. It costs a second dataflow pass per helper, so it is off unless you turn it on. With it off, a request value passed through such a helper is reported — the switch only ever removes a finding it can prove away, never hides one it cannot.

taint:
  ssrfNarrowedDestinations: annotate   # or: downrank

ssrfNarrowedDestinations (v1.5.18+, default annotate): an SSRF flow whose request destination is provably narrowed is named as such on the finding. That covers a URL literal that fixes the scheme and host (fetch(`https://images.example.com/${size}.png`)) and a URL variable checked against a constant, literal-only allowlist with an early exit. The same applies to an XSS flow that writes out the response of such a request. With the default, the grade is unchanged. downrank also lowers those findings one step. They are still reported, and a request-chosen host is never lowered. Lowering a grade is a relaxation: it can move a pull request past a gate set at major, so it is opt-in.

A pull request cannot relax its own scan. When a scan runs on a pull request, a ssrfNarrowedDestinations: downrank, calleePositionalProofs: true or sqlInjection.patternSeverity: major that is new in the diff is not applied (v1.5.18+). It takes effect once the change is merged, and the scan says which setting it held back. Tightening a setting in a diff always applies immediately.

sqlInjection: — the severity of the taint-unaware SQL rule

sqlInjection:
  patternSeverity: critical   # or: major

patternSeverity (v1.5.14+, default critical): the SQL string-concat rule fires on any variable concatenated into SQL text and carries no dataflow evidence of its own (its findings are evidenceTier: pattern). It is kept because it uniquely finds a large share of real injections, but on an untuned repository a pattern-tier critical can outrank a proven major on the PR comment. major reports that rule one step lower when no proof exists; every finding is kept on every surface and records the option that moved it (configuredSeverity). A flow the engine can prove locally derived, or whose String parameter every call site in the repository passes as a literal, is already reported at major with its proof attached whatever this is set to; a flow the taint engine proves from a request is reported by that engine's own sink and is not affected. The option cannot go below major.

threatRules: — the curated zero-day pass

threatRules:
  enabled: true

The curated zero-day rules (Log4Shell, Text4Shell, SpEL/OGNL injection and others) are delivered through the signed live rules bundle and run after the analyzers. They are on by default; enabled: false skips the pass on a CI budget. The scan log records the skip, and every scan logs what the pass evaluated and how long it took.

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.

coverage: — test coverage from JaCoCo, Cobertura or lcov (v1.5.18+)

coverage:
  reports:
    - target/site/jacoco/jacoco.xml   # JaCoCo (e.g. mvn test jacoco:report), run before the scan
    - coverage/lcov.info              # lcov (Jest, nyc, c8, coverage.py lcov, gcov)
    - coverage.xml                    # Cobertura (coverage.py xml, Istanbul cobertura, coverlet, gcovr)
  format: auto                        # default: each report is read as the format its content is;
                                      # jacoco | lcov | cobertura enforces one (a mismatch is unavailable)
  newCodeMinimum: 80                  # optional: gate the lines this change adds or modifies

PullGuard reads each report, maps each source file to your repository, and states the line coverage on the report and in the PR comment. A JaCoCo entry is mapped by its package path; an lcov or Cobertura entry by its file path — exactly, as an absolute path inside the project, under a Cobertura <source> root, or by a path suffix that matches exactly one scanned file. A report that cannot be used is shown as unavailable with the reason, never as a number: a path outside the project (including through a link), a report committed to the repository (coverage reports are build outputs, and a committed one could be edited by hand), a file over 50 MiB, XML that declares an ENTITY or a DOCTYPE other than the format's own DTD reference (never resolved), or a report that is not the configured format. A source entry that matches no scanned file, or more than one (two modules with the same package), is counted, not guessed; a source file is matched under a standard source root (src/main/java, kotlin, src…). Kotlin files whose package does not match their directory are counted as unmapped. The committed-report check follows the file the path resolves to, so another spelling of the path or a link cannot bypass it, and if git cannot answer the report is not used. At most 8 reports.

New-code coverage. On a change with a merge-base (check out with fetch-depth: 0), PullGuard also reports the coverage of the lines the change added or modified that the report instruments. With newCodeMinimum set, a new_code_coverage_below_minimum finding (major) is raised when it is lower. The gate cannot be relaxed by the change it judges: a pull request that lowers or removes newCodeMinimum in its own .driftrc.yml is held to the base branch's value, a changed reports list is ignored in favour of the base branch's, and the finding is not removed by delta mode, .pullguardignore, a committed baseline or the scan cache; deleting or shadowing the configuration file in the change keeps the base branch's settings. A change that cannot be measured (no merge-base: check out with fetch-depth: 0) is treated as not meeting the minimum. Changed source files the report does not cover at all are counted and shown, not gated. To fail the build on it, set fail-on-severity: major. Trust boundary: the report is an output of your build, which runs the change's own code; produce it in a workflow the change cannot alter and keep the JaCoCo plugin configuration under code-owner review.

plugins: — explicit allowlist

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

Plugin auto-discovery is intentionally disabled, and the plugin system as a whole is off by default: each plugin must be explicitly named here, installed in the runner, match the scope allowlist, and the runner must set PULLGUARD_ENABLE_EXPERIMENTAL_PLUGINS=1. Be aware that an enabled plugin runs with the full privileges of the scan process (filesystem, environment, network) — it is not sandboxed today; a capability-scoped sandbox is on the roadmap (ADR-0036). Only enable plugins you have vetted as you would any dependency with CI access.

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.

You can also generate this file locally — pullguard init --full writes a fully-commented .driftrc.yml containing every supported option, all commented out, so it changes nothing until you uncomment something. It is generated from the scanner’s own schema, so it can never list an option the build does not support.

# .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: 5000             # advanced: defaults to maxFiles and follows it; lower = prefix-truncated scan
                               #   (a "Partial scan" banner tells you when it bites)

# ── Analyzer execution (advanced hardening, opt-in) ───────────────────────────
analyzerIsolation: inline      # default inline. "worker" runs each analyzer in a
                               #   worker thread with a per-analyzer time limit, so
                               #   one analyzer stuck on a pathological file is
                               #   stopped and listed as incomplete rather than
                               #   holding up the whole scan. Same findings either
                               #   way. Per-run override: PULLGUARD_ANALYZER_ISOLATION.

# ── 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:                    # every knob OVERRIDES a language-aware default when set
  complexity:
    maxCyclomatic: 15          # unset: language-aware (Java 25 / TS 15 / Go 15)
    maxCognitive: 20           # unset: language-aware
    maxParameters: 5           # unset: language-aware
    maxFunctionLength: 300     # unset: only the built-in 100-line monolithic rule applies
  duplication:
    minBlockSize: 6            # unset: 6-line matching + 10-line report floor; set, it pins BOTH (6 = pre-v1.5.4 behaviour)
    maxDuplicationPercent: 5   # unset: 10% per-file reporting floor
  monolithicFile:
    maxFileLines: 500          # unset: per-language (Java 1000 / Go 400 / TS 600)
  nesting:
    maxDepth: 4                # unset: language-aware code-nesting default
  typeCoverage:
    minCoveragePercent: 80     # unset: 70% warning floor
    maxAnyPerFile: 10          # unset: 10 `any` per file

# ── 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

# ── Self-hosted PullGuard server (optional; see docs/self-hosted-server) ─────
server:
  pullTriage: false            # default false — true makes this scan honour the
                               # triage decisions your team recorded on your own
                               # PullGuard server, so a false positive dismissed on
                               # the dashboard stops blocking the merge without
                               # anyone editing a file twice.
                               #
                               # Only takes effect when server-url + server-token
                               # are already configured (the same pair used to
                               # upload results), and a committed .pullguardignore
                               # entry always wins for the same finding.
                               # Security findings keep their protection: a
                               # dismissal that would hide one is refused, and a
                               # reasoned false-positive override stays fully
                               # visible — it only lifts the block.
                               # If the server is unreachable the scan proceeds
                               # with no decisions applied and says so.
# ── 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

# ── Ownership routing (who owns the finding) ─────────────────────────────────
ownership:
  groupBy: none                # default none — "owner" adds a by-owner table to
                               # the CI Step Summary and (collapsed) the PR comment
  codeowners: true             # default true — fall back to CODEOWNERS when git
                               # blame cannot attribute the line
  mention: false               # default false — @-mentioning pings real people,
                               # so it is a separate, explicit opt-in

# ── 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
  # Infer "a tool this repository runs" from package.json scripts / bin: a security
  # finding there whose value comes from the process's own command line (process.argv)
  # is reported one step lower (never below moderate), annotated, never removed.
  inferFromPackageScripts: true   # default true; false disables the inference
    - "**/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:                  # clears flows into YOUR custom sinks only (any language); never a built-in finding
    - '\bSecurityUtils\.escapeHtml\s*\('
  scopedSanitizers:            # clears ONLY the named classes, including built-in findings (v1.5.18+)
    - pattern: '\bDb\.escape\s*\('
      classes: [sql_injection]  # sql_injection, xss, command_injection, argument_injection, path_traversal, ssrf, insecure_deserialization, tainted_flow
  calleePositionalProofs: false # per-position helper proofs (Java); opt-in, second dataflow pass per helper
  ssrfNarrowedDestinations: annotate # or downrank: narrowed SSRF destinations one step lower (never dropped)
sqlInjection:
  patternSeverity: critical    # or major: the taint-unaware SQL rule one step lower when unproven (never dropped)

# ── 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

# ── Dependency (CVE) reachability tiering (opt-in) ─────────────────────────
# Every CVE finding already tells you whether the vulnerable package is
# actually used: reached (a function the advisory names is called), imported,
# declared (in a manifest, never imported), transitive, or unknown. That
# verdict is always shown — on the PR comment row, in the JSON report
# (scaReachability) and in SARIF — and costs you nothing to read.
# Each used CVE also names its ROUTE EXPOSURE (routeExposure): the HTTP routes
# whose file leads, within 4 relative-import hops, to the file that imports or
# calls the package, with the authentication state the missing-auth analysis
# gave each route — or, when none connects, the reason (no route extractor for
# the framework, no cross-file resolution for the language, the hop bound).
# File-level: a route file serves many routes, so one route is named only when
# the file declares exactly one. Imports through tsconfig/jsconfig `paths`,
# workspace packages and Go module paths (go.mod) are followed for this search,
# and Next.js file routes and Go route registrations (net/http, gorilla, gin,
# echo, chi, fiber) are found; their authentication is not judged, so those
# routes say "authentication state unknown" — never "no guard".
# Each route also says whether its registration
# or handler names the package (or the module that uses it); routes that do are
# listed first, and the rest are "not attributed" — never "unaffected". It
# never changes a severity or the gate.
# Each CVE also carries UPGRADE ADVICE (upgrade, v1.5.18+): the lowest version
# that fixes every advisory on it, whether that version is on the release line
# you already run (same major; same major.minor under 0.x) or a new major, and
# the lines of your code that use the package (a call or member access through
# its import), production code first. It never says an upgrade is "safe":
# PullGuard does not read the library's changes, so a same-line fix is "on your
# line" and a major upgrade lists what to review. Display-only.
# Turn this on and unreached vulnerabilities are shown ONE severity band
# lower, annotated with their original severity and the reason. NOTHING IS
# HIDDEN: every CVE still appears on every surface, and this is the only
# place reachability affects the merge gate (the gate reads the shown
# severity). An `unknown` verdict never lowers a severity, and a
# vulnerability on the CISA KEV catalog is never lowered at all.
dependencies:
  reachabilityTiering: false   # default false

  # ── Dependency licence policy ────────────────────────────────────────────
  # PullGuard builds a dependency inventory (direct + transitive, across npm,
  # PyPI, Go, Maven and RubyGems) on every scan and resolves each component's
  # licence from open public data. This is where you say what your company can
  # actually ship. Patterns are SPDX ids or one trailing wildcard ("AGPL-*").
  #
  # Out of the box: AGPL and SSPL are DENIED (they can require publishing the
  # source of a service that uses them); GPL, LGPL, MPL-2.0 and CC-BY-NC ask
  # for a REVIEW (their obligations depend on how you consume the component);
  # anything else is allowed. Your lists below OVERRIDE that default for the
  # ids they name, so `allow: ["LGPL-2.1-or-later"]` is how you record a
  # decision your legal team has already made.
  #
  # A denied licence is a `license_violation` finding; a review or an
  # unresolved one is a `license_review_required` finding. Nothing is ever
  # removed from the inventory or the SBOM — the policy only decides what gets
  # raised in the pull request.
  licenses:
    deny: []                 # e.g. ["AGPL-*", "SSPL-*"]
    review: []               # e.g. ["GPL-*"]
    allow: []                # e.g. ["LGPL-2.1-or-later"]
    unknownIs: review        # allow | review | deny — what an unresolvable licence means

# ── 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 ─────────────────────────────────────────────────────────────
# The bundled high-performance engine is detected and used automatically when
# present (it ships in the official image); no configuration is needed or
# available. A scan without it reports itself as degraded in the output.

# ── 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; env-gated; UNSANDBOXED — full process privileges; 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 . --delta — auto-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. --full writes a fully-commented reference of every supported option instead (all commented out, so it changes nothing until you uncomment it).
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|accepted_risk). --for <days> time-boxes it; --list prints the current entries with their expiry state.
explain <fingerprint>Print the machine-readable fix contract for one finding — what the rule looks for, the source and sink of the flow, the invariant a fix must satisfy, and the reference fixtures the rule is held to. Built for coding agents; --format text for humans.
verify-fix <fingerprint>Re-scan and report whether that finding is fixed or still firing. Exit 0 fixed / 1 still firing / 2 no verdict. Always a full-project re-scan, and it refuses to answer rather than return a verdict it cannot stand over.
sbom [path]Export the dependency inventory as an SBOM — --format cyclonedx (default, CycloneDX 1.6 JSON) or --format spdx (SPDX 2.3 JSON). Direct and transitive components with their versions, package URLs, dependency graph and resolved licences. -o <file> writes to a file; --no-licences skips licence resolution so the command makes no external calls. Every document carries a coverage statement, so a partial inventory can never be read as a complete one.
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_REF → origin/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.
--fail-on-incompleteExit 1 when any analyzer did not complete, so a partial result fails the job instead of passing as a lower bound (also PULLGUARD_FAIL_ON_INCOMPLETE=true).
--diagnose-duplication <file>Write a source-free JSON description of what the duplication detector matched — block counts and shapes, no code — that is safe to share when asking about a duplication finding.
--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.

Directories treated as non-deployed code

PullGuard runs two kinds of security checks. Taint analysis follows a request value from where it enters to the operation it reaches, and it runs on every source file the scan collects. Pattern rules match a single line or statement (a weak cipher or hash, an insecure cookie flag, a debug switch) and, because they carry no data-flow evidence, they are not run on test and fixture code. A file is treated as test or fixture code when it sits under a test, fixture, mock or story directory, or is named as a test:

test/  tests/  __tests__/  spec/  specs/  e2e/  cypress/
fixtures/  fixture/  testdata/  test-data/  test_data/  mocks/  __mocks__/
stories/  storybook/
*.test.*  *.spec.*  *_test.go  *_test.py  test_*.py  *_test.cc

Code under scripts/, bin/, examples/, docs/, benchmark/, migrations/ and seeds/ is real code and gets the pattern rules (since 1.5.18; earlier releases skipped those directories too). A test or fixture file still gets every taint finding, every secret finding and every dependency finding; only the line-pattern security rules are skipped there. Terraform keeps every rule unless it sits under a real test or fixture directory.

The skip errs toward scanning: a test-shaped file in a location these rules have always checked (for example src/androidTest/, or a fixtures directory at the repository root) keeps the pattern rules, so no file is checked less than in an earlier release.

.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

# time-boxed (recommended): reverts automatically after 30 days
pullguard ignore <fingerprint> "reason shown to auditors" --for 30

# open-ended (no expiry)
pullguard ignore <fingerprint> "reason shown to auditors"

# see what is suppressed today, and what is about to lapse
pullguard ignore --list

Suppressions expire — and PullGuard says so

A suppression you granted in March for “we’ll fix this next sprint” should not still be silencing a finding in November. --for <days> (1–365, 30 suggested) sets an expiry; when it passes the entry stops suppressing and the finding comes back on its own. Nothing is silent about it:

An expiresAt PullGuard cannot parse is treated as expired, not as “forever”: a time-box that cannot be read is not a time-box, and the safe direction is for the finding to come back.

Acknowledgements re-alert when the code changes

status: acknowledged keeps a finding visible and marks it reviewed rather than hiding it. That review was of specific code — so PullGuard binds it to that code. If the file changes afterwards, the reviewed badge is dropped for that scan and the finding renders “code changed since acknowledgement — re-review”. It only ever removes a reassurance: nothing is hidden, no severity moves, no gate is affected.

accepted_risk — a decision with a review date

status: accepted_risk says “this is real, we accept it until a date”. It suppresses exactly like wontfix — including the security floor, which it can never bypass — with two differences: an expiry is required (1–365 days) and so is a reason. When the date passes the entry stops applying on its own and the finding comes back. An accepted_risk entry written without an expiry is treated as already expired, because an accepted risk with no review date is simply a permanent dismissal under another name.

Available everywhere the other statuses are: pullguard ignore <fingerprint> --status accepted_risk --for 90 --reason "…", the /pullguard ignore <fingerprint> status:accepted_risk for:90d <reason> PR comment, and the Accept risk (until …) button on a self-hosted server dashboard.

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 | accepted_risk
    addedBy: "alice@example.com"
    addedAt: "2026-05-15T10:30:00Z"
    expiresAt: "2026-10-01T00:00:00Z"  # optional — the entry auto-reverts after this

Class entries — one decision for a whole type + path

A single entry can cover every finding of one type under a path glob, instead of one fingerprint at a time. A legacy area carrying two hundred instances of the same accepted judgement is stated once, with a reason and (optionally) a review date:

entries:
  - type: monolithic_function          # the finding type this rule covers
    pathGlob: "src/legacy/**/*.tsx"     # ** crosses directories; * and ? do not
    status: wontfix                    # wontfix | acknowledged | accepted_risk
    reason: "Legacy UI, scheduled rewrite in Q1"
    addedBy: "alice@example.com"
    expiresAt: "2026-12-01T00:00:00Z"  # required for accepted_risk

A class entry can never cover a security finding. A blanket rule applies to findings that do not exist yet, so it would dispose of the next one automatically — the scanner refuses such a match outright and the finding stays visible and keeps blocking. There is deliberately no false_positive class form either: a security override is bound to the evidence of one specific finding, and a glob has no evidence to bind to. Class entries are authored in this file or on a self-hosted server dashboard; /pullguard ignore is unchanged and stays a per-finding command — a standing rule over a directory deserves a reviewed diff. A per-fingerprint entry always wins over a class entry for the same finding.

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.)

“No longer blocks” means every command. An overridden finding is excluded from the blocking decision consistently: pullguard scan no longer exits non-zero because of it, and pullguard gate excludes it from both the Quality Gate and the --fail-on-severity floor. Only the exit code changes — the finding is still counted, still graded, and still rendered on every surface. A critical without an override still fails the build, and an override stops applying the moment the reviewed code changes.

Adding suppressions from a PR comment

Team Enterprise Comment /pullguard ignore <fingerprint> for:30d <reason> on a PR — the fingerprints are printed in the comment’s Triage table. for:<N>d (1–365 days) time-boxes the entry so it reverts on its own; omit it for an open-ended one. An out-of-range window is refused with a message rather than quietly clamped. The PullGuard App commits the entry to the pull request’s own head branch, so the suppression is part of the diff the reviewer is already looking at — that reviewer is the auditor of record. The command is limited to repo collaborators, and it is refused on a fork PR (the head branch lives in the fork, so there is nothing safe to commit to).

SLA & aging

A critical finding that has been open for 90 days is a different risk from one found this morning. Give each severity a day budget and PullGuard flags every finding that has outlived it — measured from the date PullGuard first saw the finding, not from the file's modification time.

sla:
  critical: 7          # a critical may stay open 7 days
  major: 30
  moderate: 90
  minor: 180
  failBuild: false     # true = a breach fails the build (an aging gate)

What you get:

Requires committed history. Ages come from .drift-history.json (first-seen dates), so commit it like any other state file — see Reports & Dashboard. A finding with no recorded first-seen date can never breach, and a severity with no budget is never checked: the policy only ever acts on evidence it has.

Ownership routing

On a large repo a scan lands as one long list and somebody has to triage it into work. Turn this on and the CI Step Summary adds a Findings by owner table — who has the criticals, who has the majors, across how many files — so the list routes itself. The pull-request comment carries the same table, collapsed and limited to the ten most urgent owners, and never as @-mentions there (the comment is updated on every push).

ownership:
  groupBy: owner       # none (default) | owner
  codeowners: true     # fall back to CODEOWNERS when git blame can't attribute
  mention: false       # render owners as @handles (default off)

The owner of a finding is the git-blame author of its line, falling back to the matching rule in .github/CODEOWNERS (or CODEOWNERS / docs/CODEOWNERS) for the path. Anything neither can attribute is grouped as unattributed rather than dropped.

mention is a separate opt-in and defaults to off. Turning a name into an @handle pings a real person on every scan, so PullGuard never does it on your behalf — with mention: false the handles render as plain text. Grouping is display-only: it never re-ranks, hides, or gates a finding, and it changes no score.

Blame requires real git history on the runner — a shallow clone (fetch-depth: 1) leaves most findings unattributed and relying on CODEOWNERS.

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.

Set PULLGUARD_OFFLINE=true in the scan environment — the one switch that keeps every remaining lookup local: the rule-catalog fetch stays on the image's signed embedded copy, CVE lookups use only the local database below, the registry freshness sweep is skipped, dependency-licence resolution falls back to what your own manifests declare (and reports the unresolved count), and any report-to-app opt-in is suppressed. Without it a scan still attempts those calls (they fail closed on a no-egress network, but attempted egress is not zero egress, and each attempt costs its timeout).

The Tier-2 zero-day threat rules need one more step. The image's embedded catalog does not carry them, so an offline scan does not run them unless you supply the signed bundle (scanner 1.5.17 or later): on a connected machine run curl -o rules-bundle.json https://pullguard.dev/api/rules, copy the file across, and set PULLGUARD_RULES_BUNDLE_PATH to its path. It is verified with the signing key the scanner already embeds and read with no network call; a modified, unsigned, oversized or unreadable file is refused, and the scan uses the embedded catalog and says so.

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 →