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
with: block on pullguard-action@v1.drift-baseline.json format and “only new findings” modesscan flags
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.
|
| 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). |
- 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.ymlPer-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).
| 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: 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. |
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 tuningthresholds:
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 controloutput:
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-inscompliance:
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, sanitizersEnterprise 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 ratecost:
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 allowlistplugins:
- "@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.
.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
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”.
| Mode | Shows | How |
|---|---|---|
| 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.
.drift-baseline.json fileA 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)"
}
]
}
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.
--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.
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.
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.
| Command | What it does |
|---|---|
scan [path] | Analyze a project and output a report (text / json / sarif / markdown / html). |
init | Create 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. |
trend | Show the compliance-score trend over time. |
ignore <fingerprint> | Add a finding to .pullguardignore (--status wontfix|false_positive|acknowledged). |
list-rules | List 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|status | Manage the local CVE database for offline / air-gapped scanning. |
scan options| Flag | Effect |
|---|---|
-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-baseline | Ignore any committed baseline — show the full inventory. |
--update-baseline | Write/refresh .drift-baseline.json from this scan (run a full scan on your base branch). Refuses on a PR/delta scan. |
--delta / --full | Force PR-delta filtering / force full output (delta auto-on for PR events). |
--blame | Annotate each finding with its git-blame “introduced” date. |
--base-ref <ref> | Git ref to diff against (default $GITHUB_BASE_REF → origin/main). |
--collapse-preexisting-security | In 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). |
--cache | Incremental analysis — cache results in .drift-cache.json and re-analyze only files changed since the last run. |
--no-color | Disable colored output (the NO_COLOR environment variable is also respected). |
-q, --quiet / -v, --verbose | Score 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:
# fingerprints come from the "fingerprint" field of the JSON report
pullguard scan . --format json > pullguard-report.json
pullguard ignore <fingerprint> "reason shown to auditors"
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.)
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.
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) |
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:
Acme/API vs acme/api) count as the same repo.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.
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:
db.path / --db-path is set — no network calls;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".
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.
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/
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.
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).
| 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. |
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 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.