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 + 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.
|
| 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). 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 tuningthresholds:
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.
.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 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*\\("
# 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 rulesqlInjection:
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 passthreatRules:
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 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.
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 allowlistplugins:
- "@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.
.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
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. --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. |
trend | Show 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-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. |
--fail-on-incomplete | Exit 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-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.
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:
# 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
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.
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.
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
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.
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).
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:
SLA badge on the HTML report row, and
slaBreached / slaAgeDays on every finding in
the JSON report so your own dashboards can use it;failBuild: true, a non-zero exit — an aging
gate that is separate from the severity gate, so “nothing new is
critical” and “nothing old is rotting” are enforced
independently.
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.
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.
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.
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:
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.