Linux-native Python recon + token-validation toolkit for authorized SCM bug-bounty engagements. Targets GitHub (cloud), GitLab (cloud + self-hosted), and Bitbucket Cloud.
covenant is a clean-room reimplementation of the read-only reconnaissance ideas
in IBM X-Force's SCMKit (see NOTICE),
rebuilt for Linux and scoped down to the modules that are safe and useful inside
an authorized bug-bounty engagement.
covenant is for authorized security testing only. You must have explicit,
written authorization to test every SCM organization, repository, and account
you point it at. Every invocation requires a --scope-file; covenant refuses to
contact any host that is not listed in that file. Using this tool against
systems you are not authorized to test is illegal. You are solely responsible
for staying within the bounds of your engagement.
v0.1 ships read-only modules only. There are no persistence or state-modifying modules.
Requires Python 3.13+.
git clone https://github.com/bugsyhewitt/covenant
cd covenant
python -m venv .venv && source .venv/bin/activate
pip install -e .
covenant --helpTokens are read from an environment variable (never passed on the command line,
so they don't leak into shell history or process listings). The default
variable is COVENANT_TOKEN; override with --token-env.
export COVENANT_TOKEN="ghp_your_personal_access_token"A plain-text file listing the SCM hosts / orgs / repos you are authorized to
test, one per line. Blank lines and # comments are ignored. covenant extracts
the hostname from each entry and refuses any target whose host is not listed.
# scope.txt — authorized targets for ENGAGEMENT-1234
github.com/acme-corp
gitlab.com/acme-corp
bitbucket.org/acme-corp
List the web-facing host you reason about — github.com, bitbucket.org —
not the API subdomain. covenant talks to GitHub's and Bitbucket's REST APIs on
api.github.com / api.bitbucket.org, and it canonicalizes those to their web
host when matching scope, so a scope file listing github.com authorizes the
default GitHub recon run (and likewise for bitbucket.org). Listing the api.*
form works too — the two are treated as the same host. GitLab serves its API
from gitlab.com directly, so no aliasing is involved there. This is
canonicalization only: it never widens scope to a host you did not list, and the
org/workspace narrowing below still applies against the canonical host.
A scope entry may be a bare host (bitbucket.org) or carry an
org/group/workspace path (bitbucket.org/acme-corp). The path is no longer
discarded:
- If a host appears as a bare entry anywhere, it stays host-wide — any org on it is authorized (this is the original v0.1 behavior, unchanged).
- If a host appears only with org paths, covenant treats it as
org-restricted: a recon target that names a different org on that host
is refused with exit code
2, even though the host itself is listed.
This closes a real authorization gap. Bitbucket code search is workspace-scoped
(recon-code --workspace <slug>); previously, listing bitbucket.org/acme and
then running --workspace victim would happily search the victim workspace,
because only the host (api.bitbucket.org) was ever checked. Now the
--workspace slug is verified against the authorized orgs for the host. The
GitHub/GitLab --org flag (see "Single-org narrowing" below) is verified the
same way — and on an org-restricted host a recon run that names no org is
refused too, so the narrowing can't be silently bypassed.
# Org-restricted: only the acme workspace on bitbucket.org is authorized
bitbucket.org/acme
# --workspace acme → allowed
# --workspace victim → refused (exit 2), even though bitbucket.org is "listed"
To authorize an entire host regardless of org, list it bare (bitbucket.org).
Each SCM exposes the same read-only modules:
| Module | What it does |
|---|---|
recon-repo |
Search accessible repositories matching a query |
recon-code |
Search code across accessible repositories |
validate-token |
Enumerate what the supplied token can access |
All output is JSON on stdout. Exit codes: 0 success, 1 operational error,
2 target out of scope.
Pass --scan-secrets to recon-code to scan the text fragments returned by
the SCM's code-search API for leaked credentials. Results gain a
"secret_findings" array; each finding has rule_id, description,
secret, start, end, and fragment_index.
By default the secret field is redacted. Recon output routinely lands in
engagement logs, terminal scrollback, shell pipelines, and shared report
artifacts — so emitting a live credential verbatim would make covenant's own
output a new place that secret leaks to. Instead, the secret field is a
share-safe fingerprint: a short type-revealing prefix, the length, and a
truncated SHA-256, e.g. "AKIA…[20 chars, sha256:9f3a]". The prefix still
encodes the credential type (AKIA, ghp_, sk_l, …) and the hash lets you
correlate duplicate findings, but the live value never appears.
For the rare case where you genuinely need the raw value (e.g. immediate
verification), pass --show-secrets to opt back into the full credential.
--show-secrets implies --scan-secrets. Use it deliberately — its output is
unsafe to paste into shared artifacts.
# Default: redacted fingerprints
covenant github recon-code --scope-file scope.txt --query "api_key" --scan-secrets
# Opt in to full raw secrets (handle with care)
covenant github recon-code --scope-file scope.txt --query "api_key" --show-secretsPowered by necromancer-patterns — the suite-wide shared credential-detection library. Rules currently cover: AWS access keys, Stripe secret keys, and generic high-entropy secrets.
Requires the scan extra:
pip install -e ".[scan]"Example (GitHub):
covenant github recon-code \
--scope-file scope.txt \
--query "api_key" \
--scan-secretsExample output with a finding (default redacted secret):
{
"scm": "github",
"query": "api_key",
"results": [
{
"name": "config.py",
"path": "src/config.py",
"visibility": "private",
"url": "https://github.com/acme/infra/blob/main/src/config.py",
"repository": "infra",
"secret_findings": [
{
"rule_id": "aws-access-key-id",
"description": "AWS access key ID",
"secret": "AKIA…[20 chars, sha256:9f3a]",
"start": 17,
"end": 37,
"fragment_index": 0
}
]
}
]
}--scan-secrets reports candidate credentials by pattern match — but a string
that looks like an AWS key may be expired, a placeholder, or example data. Pass
--verify-secrets (implies --scan-secrets) to actually authenticate each
detected credential against its issuing provider with a single read-only,
non-destructive probe, turning "here are strings that look like keys" into
"here are live keys."
Each finding gains a "verified" field:
| Value | Meaning |
|---|---|
true |
the credential authenticated (provider returned 200 — or 429: a rate-limited credential is still live) |
false |
the provider rejected the credential (401/403 — expired or invalid) |
null |
covenant has no verifier for this credential type, or the probe could not complete |
Supported credential types and their probes:
| Credential type | Probe (read-only) |
|---|---|
| AWS access key | sts:GetCallerIdentity (the canonical zero-impact "who am I" call) |
| Stripe secret key | GET /v1/balance |
Guardrails:
- Dedupe by secret — fifty copies of the same key trigger exactly one probe, so verification can't trip a target's rate-limits or anomaly detection by hammering the same credential.
- Redaction still applies —
--verify-secretsprobes the raw key internally but, unless you also pass--show-secrets, the emittedsecretfield stays a redacted fingerprint. The live value is verified without leaking into output. - Read-only only — both probes are non-mutating "who am I / what's my balance" calls.
⚠️ Verification transmits the candidate secret OFF-BOX to the third-party provider (AWS / Stripe). Only use--verify-secretswhen the target host is in scope and you are authorized to test it.
# Detect AND live-verify credentials (output stays redacted)
covenant github recon-code \
--scope-file scope.txt \
--query "api_key" \
--verify-secretsExample finding with verification:
{
"rule_id": "aws-access-key-id",
"description": "AWS access key ID",
"secret": "AKIA…[20 chars, sha256:9f3a]",
"start": 17,
"end": 37,
"fragment_index": 0,
"verified": true
}recon-repo and recon-code walk all result pages, not just the first.
Each SCM's pagination contract is followed automatically — GitHub's RFC-5988
Link header, GitLab's X-Next-Page header, and Bitbucket's next envelope
key. Without page-walking an operator could query a target, see a handful of
hits on page one, and wrongly conclude the target is clean when hundreds of
matches exist on later pages.
The walk is bounded by --max-pages (default 10, hard ceiling 100 to
respect GitHub's documented ~100-page search cap). Raise it for fuller recall
at the cost of more API calls; lower it (e.g. --max-pages 1) to fetch only
the first page:
# Walk up to 50 pages of code-search results
covenant github recon-code \
--scope-file scope.txt \
--query "internal_api" \
--max-pages 50The JSON output shape is unchanged — results is simply a longer flat array.
recon-code exposes two knobs that sharpen signal-to-noise without changing
any detection logic.
--pattern-set {minimal,aws,full,…} picks which
necromancer-patterns rule
bundle --scan-secrets applies. The default is full (every rule, including
the generic high-entropy detector). On an AWS-only engagement, --pattern-set aws drops the generic rule and the false positives it produces. The value is
validated against the rule sets the installed library actually ships — an
unknown set exits with the list of valid choices. It only takes effect together
with --scan-secrets / --show-secrets / --verify-secrets.
--exclude TERM (repeatable) appends a NOT TERM negative qualifier to the
search query before it hits the API, so you can strip demo/test/localhost noise
without hand-crafting query strings — and stretch the per-query page budget
against GitHub's search cap. Excludes apply to GitHub and GitLab (which
honor NOT in their search grammar); they are ignored for Bitbucket, whose
code-search syntax differs. The query field in the JSON output still echoes
the query you typed, not the augmented one.
# AWS-only scan, excluding obvious sample/test files
covenant github recon-code \
--scope-file scope.txt \
--query "AKIA" \
--scan-secrets \
--pattern-set aws \
--exclude example \
--exclude testThis sends AKIA NOT example NOT test to GitHub's code search and scans the
results with only the AWS rule set.
recon-repo and recon-code on GitHub and GitLab accept --org SLUG to
narrow a recon run to one organization (GitHub) or group (GitLab) instead of
searching every repository the token can reach. On GitHub the slug is appended
as an org:<slug> search qualifier; on GitLab covenant switches to the
group-scoped endpoints (/groups/<slug>/projects, /groups/<slug>/search), so
a GitLab slug may be a full group path like acme/platform. Bitbucket already
has its own mandatory --workspace flag for the same purpose, so --org is not
offered there.
--org is not just a precision knob — it is scope-enforced, closing the
same authorization gap --workspace already closes for Bitbucket. When the
scope file authorizes a host only for specific orgs (an org-path entry like
github.com/acme-corp with no bare-host entry), covenant now:
- refuses
--org victimon that host with exit code2(out of scope), even though the host itself is listed; and - refuses a run with no
--orgon that host with exit code2— an org-restricted host requires you to name an authorized org, because a no-org recon would otherwise search the entire host you only partially authorized.
A bare host entry (github.com) stays host-wide: any --org, or none, is
allowed (the original v0.1 behavior, unchanged). The query field in the JSON
output still echoes the query you typed, not the org-augmented one.
# GitHub: search only the acme-corp org for "api_key"
covenant github recon-code \
--scope-file scope.txt \
--query "api_key" \
--org acme-corp
# GitLab: search only the acme/platform group (full group path)
covenant gitlab recon-code \
--scope-file scope.txt \
--query "api_key" \
--org acme/platformSCM search APIs are aggressively throttled — GitHub's search surface in
particular has a low secondary-rate-limit ceiling, and page-walking
(--max-pages) hits it fast. covenant treats a 429 (or GitHub's secondary
403) as "wait and retry," not a hard failure: it reads the server's retry
hint and resumes automatically. A recon tool that died on the first 429 would
be unreliable on exactly the large targets where it matters most.
How it works, with no flags to set:
- On a rate-limit response covenant honors, in priority order, the standard
Retry-Afterheader, then the reset-epoch headers GitHub (X-RateLimit-Reset) and GitLab (RateLimit-Reset) return; if none is present it falls back to a bounded exponential backoff. - It retries up to 3 times per request. Every wait is clamped (never longer than 60s) so a misbehaving or hostile API can't stall covenant indefinitely.
- If the retry budget is exhausted while the server is still throttling,
covenant stops the page-walk cleanly — it keeps the results gathered so
far instead of discarding the whole run — and adds a non-fatal
"warnings"array to the output so you know recall was truncated, not complete.
{
"scm": "github",
"query": "internal_api",
"results": [ ... partial results ... ],
"warnings": [
"rate limited (HTTP 429) at https://api.github.com/search/code: retry budget of 3 exhausted; results may be partial"
]
}When a run succeeds (even after retrying), no warnings key appears — its
presence is the explicit signal that the result set may be incomplete.
validate-token now reports what kind of token you hold, not just its
scopes. The token's class is encoded in its prefix, and the class is decisive
for blast-radius reasoning — a fine-grained PAT's scope list reads completely
differently from a classic PAT's, and a GitHub App installation token (ghs_)
implies App reach across an installation rather than a single user's access.
covenant derives this offline from the token's shape (no extra API call, no
rate-limit cost) and adds three fields to the output:
| Field | Meaning |
|---|---|
token_type |
e.g. github-pat-classic, gitlab-oauth, bitbucket-app-password, or unknown |
token_note |
human-readable blast-radius / caveat note |
token_type_confidence |
high (matched prefix), medium (legacy hex heuristic), low (unrecognized shape) |
Recognized classes include GitHub ghp_/gho_/ghu_/ghs_/ghr_/
github_pat_/legacy-40-hex, GitLab glpat-/gloas-/glptt-, and Bitbucket
API tokens (ATCTT) vs the deprecated app-password form (ATBB, with
creation ended 2025-09-09 and full cutover to API tokens completing 2026-06-09).
For GitLab, token_note also reminds you that effective access is bounded by
scopes AND the user's role — a permissive scopes list is not by itself a
grant.
The classifier reasons only about the token's shape and never echoes the raw
credential into its output, so validate-token results stay safe to share.
Example output:
{
"scopes": ["repo", "read:org"],
"user": "spellcaster",
"admin": false,
"token_type": "github-pat-classic",
"token_note": "classic personal access token; scopes apply org-wide to every resource the user can reach",
"token_type_confidence": "high"
}Identity, scopes, and token class tell you what a credential is; the most
actionable next recon question is what it can actually reach. Pass
--enumerate-orgs to validate-token and covenant additionally walks the
token's organizational blast radius with a read-only membership query and
adds an orgs array to the output:
| SCM | Organizational unit | Endpoint walked |
|---|---|---|
| GitHub | organizations | GET /user/orgs |
| GitLab | groups | GET /api/v4/groups |
| Bitbucket | workspaces | GET /2.0/workspaces |
Each entry is normalized to {"name", "url"}. GitLab groups use their
full_path so nested groups are unambiguous; Bitbucket entries use the
workspace slug — the exact value the recon-code --workspace flag wants, so
this is how you discover which workspaces are searchable. The walk is bounded by
the same --max-pages flag (default 10) and honors the scope guardrail and the
automatic rate-limit retry/backoff. The feature is purely additive: without the
flag the output is unchanged, and with it the v0.1 scopes/user/admin
fields are untouched — only the orgs array is added.
covenant github validate-token \
--scope-file scope.txt \
--enumerate-orgs \
--token-env COVENANT_TOKENExample output:
{
"scopes": ["repo", "read:org"],
"user": "spellcaster",
"admin": false,
"token_type": "github-pat-classic",
"token_note": "...",
"token_type_confidence": "high",
"orgs": [
{ "name": "acme-corp", "url": "https://api.github.com/orgs/acme-corp" },
{ "name": "wizards-inc", "url": "https://api.github.com/orgs/wizards-inc" }
]
}Organizational reach tells you where a credential can act; its registered keys
tell you how it can persist and impersonate. Pass --enumerate-keys to
validate-token and covenant additionally walks the account's public SSH
and GPG keys with read-only key queries and adds a keys array to the
output:
| SCM | Endpoints walked | Key types |
|---|---|---|
| GitHub | GET /user/keys, GET /user/gpg_keys |
SSH + GPG |
| GitLab | GET /api/v4/user/keys, GET /api/v4/user/gpg_keys |
SSH + GPG |
| Bitbucket | GET /2.0/users/{uuid}/ssh-keys |
SSH only |
Why this matters for recon: an account's registered SSH keys reveal which machines can push as that identity (a persistence foothold), and its GPG keys reveal which keys can produce "Verified"-badged commits in its name (a supply-chain / trust signal). Bitbucket Cloud has no public GPG-key API, so only SSH keys are returned there.
Each entry is normalized to {"type", "id", "title", "fingerprint"} where
type is "ssh" or "gpg". Only public key metadata is emitted — covenant
never reads or echoes private key material, and the full public-key/armor body is
deliberately reduced to a bounded single-line fingerprint to keep findings compact
and share-safe. The walk is bounded by the same --max-pages flag and honors the
scope guardrail and the automatic rate-limit retry/backoff. Like --enumerate-orgs
the feature is purely additive (the two flags may be combined): without the flag
the output is unchanged, and with it the v0.1 scopes/user/admin fields are
untouched — only the keys array is added.
covenant github validate-token \
--scope-file scope.txt \
--enumerate-keys \
--token-env COVENANT_TOKENExample output (combined with --enumerate-orgs):
{
"scopes": ["repo", "read:org"],
"user": "spellcaster",
"admin": false,
"token_type": "github-pat-classic",
"token_note": "...",
"token_type_confidence": "high",
"orgs": [
{ "name": "acme-corp", "url": "https://api.github.com/orgs/acme-corp" }
],
"keys": [
{ "type": "ssh", "id": 1, "title": "laptop", "fingerprint": "ssh-ed25519 AAAA... laptop" },
{ "type": "gpg", "id": "ABCDEF0123456789", "title": "release-signing", "fingerprint": "ABCDEF0123456789" }
]
}Where keys tell you how a credential can persist, gists and snippets tell you
what it has already leaked. Pass --enumerate-gists to validate-token and
covenant additionally walks the gists (GitHub) / snippets (GitLab, Bitbucket)
owned by the account with a read-only query and adds a gists array to
the output:
| SCM | Endpoint walked | Unit |
|---|---|---|
| GitHub | GET /gists |
gist |
| GitLab | GET /api/v4/snippets |
snippet |
| Bitbucket | GET /2.0/snippets |
snippet |
Why this matters for recon: gists and snippets are a notorious leaked-credential
vector — developers paste .env excerpts, config files and ad-hoc deploy scripts
into "secret" gists that are in fact readable by anyone who has the URL. Mapping
which gists an identity owns, and especially their filenames, is a high-signal
recon move: a gist named credentials.json or .env is itself a finding before
you ever read a byte of its content.
Each entry is normalized to {"id", "description", "visibility", "url", "files"}
where files is the list of filenames in the gist/snippet. Only filenames
are emitted — covenant never reads or echoes the file CONTENT. It maps the
attack surface; it does not exfiltrate it. GitHub's "secret" gists are reported
with visibility: "secret" (an honest label: a "secret" gist is unlisted, not
private). The walk is bounded by the same --max-pages flag and honors the scope
guardrail and the automatic rate-limit retry/backoff. Like the other --enumerate-*
flags the feature is purely additive (all three may be combined): without the flag
the output is unchanged, and with it the v0.1 scopes/user/admin fields are
untouched — only the gists array is added.
covenant github validate-token \
--scope-file scope.txt \
--enumerate-gists \
--token-env COVENANT_TOKENExample gists array:
{
"gists": [
{ "id": "gist1", "description": "deploy helper", "visibility": "public", "url": "https://gist.github.com/spellcaster/gist1", "files": ["deploy.sh"] },
{ "id": "gist2", "description": "scratch env", "visibility": "secret", "url": "https://gist.github.com/spellcaster/gist2", "files": [".env", "notes.md"] }
]
}Where keys tell you how a credential persists and gists tell you what it has
leaked, webhooks tell you where its events flow — and that is both an
exfiltration channel and an SSRF primitive. Pass --enumerate-webhooks
to validate-token and covenant walks the organizations/groups/workspaces the
token can reach (the same set surfaced by --enumerate-orgs) and, for each,
lists its webhooks with a read-only query, adding a webhooks array:
| SCM | Endpoint walked | Scope |
|---|---|---|
| GitHub | GET /orgs/{org}/hooks |
org |
| GitLab | GET /api/v4/groups/{id}/hooks |
group |
| Bitbucket | GET /2.0/workspaces/{slug}/hooks |
workspace |
Why this matters for recon: an org/group/workspace webhook POSTs every matching event — pushes, pull/merge requests, membership changes — to a configured URL. That URL is a data-exfiltration destination (the payloads carry repo content and metadata) and, when it points at internal infrastructure, an SSRF target. A captured token that can read or edit these hooks can quietly redirect or clone the event stream, so the destination URL is exactly the blast-radius signal an operator needs.
Each entry is normalized to {"scope", "owner", "id", "url", "events", "active"}, where url is the hook's destination and events is the normalized
list of subscribed event types (GitLab's per-event boolean flags are collapsed
into this list). The hook SECRET is never requested or echoed — covenant
surfaces the destination, not the signing key. The walk is bounded by the same
--max-pages flag and honors the scope guardrail and the automatic rate-limit
retry/backoff. Like the other --enumerate-* flags the feature is purely
additive (all four may be combined): without the flag the output is unchanged,
and with it the v0.1 scopes/user/admin fields are untouched — only the
webhooks array is added.
covenant github validate-token \
--scope-file scope.txt \
--enumerate-webhooks \
--token-env COVENANT_TOKENExample webhooks array:
{
"webhooks": [
{ "scope": "org", "owner": "acme-corp", "id": 100, "url": "https://hooks.example.com/acme-corp", "events": ["push", "pull_request"], "active": true }
]
}Where --enumerate-keys covers the keys on the account, --enumerate-deploy-keys
covers the keys bolted onto individual repositories. A deploy key is an SSH
key scoped to a single repo, often with write access, that grants Git access
independent of any human credential — it survives a password reset or an
off-boarding, which makes a writable one a classic persistence and
supply-chain foothold (an attacker pushes to the repo as the repo). Pass
--enumerate-deploy-keys to validate-token and covenant walks the repositories
the token can reach and, for each, lists its deploy keys with a read-only
query, adding a deploy_keys array:
| SCM | Repos walked | Keys endpoint |
|---|---|---|
| GitHub | GET /user/repos |
GET /repos/{owner}/{repo}/keys |
| GitLab | GET /api/v4/projects?membership=true |
GET /api/v4/projects/{id}/deploy_keys |
| Bitbucket | GET /2.0/repositories?role=member |
GET /2.0/repositories/{full_name}/deploy-keys |
Each entry is normalized to {"repo", "id", "title", "read_only", "fingerprint"}.
read_only is the decisive blast-radius signal — false means the key can
push. GitHub returns it directly; GitLab's can_push is inverted into it
(read_only == not can_push); Bitbucket Cloud access keys are read-only by
design, so read_only is always true there and the signal is the key's
existence and reach rather than a push flag. Only PUBLIC key metadata is
shown — covenant never reads private key material (the APIs do not expose it).
The walk is bounded by the same --max-pages flag and honors the scope guardrail
and the automatic rate-limit retry/backoff. Like the other --enumerate-* flags
the feature is purely additive (all may be combined): without the flag the output
is unchanged, and with it the v0.1 scopes/user/admin fields are untouched —
only the deploy_keys array is added.
covenant github validate-token \
--scope-file scope.txt \
--enumerate-deploy-keys \
--token-env COVENANT_TOKENExample deploy_keys array:
{
"deploy_keys": [
{ "repo": "acme-corp/spellbook", "id": 300, "title": "ci-deploy", "read_only": false, "fingerprint": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA deploy" }
]
}Every other validate-token flag maps a captured token's offensive reach —
the keys it owns, the repos it can push to, the webhooks it can redirect.
--audit-branch-protection is the defensive counterpart: it reports whether
that reach would actually land. A writable deploy key or a permissive PAT is
only a supply-chain win if the target's protected branches let an unreviewed,
unsigned, or admin-bypassing push through. Pass --audit-branch-protection to
validate-token and covenant walks the repositories the token can reach and, for
each, audits its protected branches with a read-only query, adding a
branch_protection array:
| SCM | Repos walked | Protection endpoint |
|---|---|---|
| GitHub | GET /user/repos |
GET /repos/{owner}/{repo}/branches?protected=true + .../branches/{branch}/protection |
| GitLab | GET /api/v4/projects?membership=true |
GET /api/v4/projects/{id}/protected_branches (+ /approvals, /push_rule) |
| Bitbucket | GET /2.0/repositories?role=member |
GET /2.0/repositories/{full_name}/branch-restrictions |
Each entry is normalized to {"repo", "branch", "required_reviews", "required_review_count", "dismiss_stale_reviews", "require_signed_commits", "enforce_admins"}. required_reviews=false (or a zero required_review_count)
on a reachable repo is the high-signal finding — an attacker who can push can do
so unreviewed. The providers model protection differently, so the fields are
mapped to the nearest equivalent signal:
- GitHub maps directly:
required_pull_request_reviews,required_signatures.enabled, andenforce_admins.enabled. - GitLab keeps review/sign policy at the project level —
required_reviewsandrequired_review_countcome fromapprovals_before_merge,dismiss_stale_reviewsfromreset_approvals_on_push,require_signed_commitsfrom thereject_unsigned_commitspush rule, andenforce_adminsfrom the protected branch disallowing force push. Endpoints a low-privilege token can't read fail soft to the safe defaults so the audit still lists the branches it can see. - Bitbucket Cloud models protection as a flat list of restrictions keyed by
branch pattern; covenant aggregates them per pattern.
require_approvals_to_mergedrives the review fields,reset_pullrequest_approvals_on_changedrivesdismiss_stale_reviews, and aforcerestriction drivesenforce_admins. Bitbucket Cloud has no signed-commit restriction, sorequire_signed_commitsis alwaysfalsethere. Thebranchfield carries the restriction pattern, which may be a glob (e.g.release/*).
The audit is read-only — it only GETs policy metadata and never alters
protection. The walk is bounded by the same --max-pages flag and honors the
scope guardrail and the automatic rate-limit retry/backoff. Like the
--enumerate-* flags the feature is purely additive (all may be combined):
without the flag the output is unchanged, and with it the v0.1
scopes/user/admin fields are untouched — only the branch_protection array
is added.
covenant github validate-token \
--scope-file scope.txt \
--audit-branch-protection \
--token-env COVENANT_TOKENExample branch_protection array (a weak and a strong branch):
{
"branch_protection": [
{ "repo": "acme-corp/spellbook", "branch": "main", "required_reviews": false, "required_review_count": 0, "dismiss_stale_reviews": false, "require_signed_commits": false, "enforce_admins": false },
{ "repo": "acme-corp/grimoire", "branch": "main", "required_reviews": true, "required_review_count": 2, "dismiss_stale_reviews": true, "require_signed_commits": true, "enforce_admins": true }
]
}Where --enumerate-deploy-keys maps the keys a token can reach and
--enumerate-webhooks maps the exfiltration surface, CI/CD secrets are the
credential surface that powers the build pipeline: cloud keys, registry
passwords, signing tokens and deploy credentials all live here. An attacker who
can read or — via a malicious workflow — exfiltrate them inherits exactly the
lateral-movement and supply-chain reach the pipeline had, so a repo or org
carrying a long list of secrets is a high-value, high-blast-radius target. Pass
--enumerate-actions-secrets to validate-token and covenant walks both the
org/group/workspace axis and the repo/project axis the token can reach with a
read-only query, adding an actions_secrets array:
| SCM | Org-level (scope org) |
Repo-level (scope repo) |
|---|---|---|
| GitHub | GET /orgs/{org}/actions/secrets |
GET /repos/{owner}/{repo}/actions/secrets |
| GitLab | GET /api/v4/groups/{id}/variables |
GET /api/v4/projects/{id}/variables |
| Bitbucket | GET /2.0/workspaces/{slug}/pipelines-config/variables |
GET /2.0/repositories/{full_name}/pipelines-config/variables |
Each entry is normalized to {"scope", "owner", "name", "protected"}.
Only the secret NAME and metadata are surfaced — the secret VALUE is never read or echoed. The provider APIs deliberately omit the values of secured secrets, and covenant emits names only. The NAME is the excessive-exposure signal (e.g. an
AWS_*orNPM_TOKENname tells you the blast radius without ever touching the credential).
The protected field flags a tighter blast radius: for GitHub it is true
when an org secret's visibility is restricted to selected repositories; for
GitLab it maps the variable's protected flag; for Bitbucket it maps the
variable's secured flag. Endpoints a low-privilege token can't read (CI/CD
variables typically require Maintainer/admin) fail soft to an empty result so the
audit still reports what it can see. The walk is bounded by the same --max-pages
flag and honors the scope guardrail and automatic rate-limit retry/backoff. Like
the other --enumerate-* flags the feature is purely additive (all may be
combined): without the flag the output is unchanged, and with it the v0.1
scopes/user/admin fields are untouched — only the actions_secrets array is
added.
covenant github validate-token \
--scope-file scope.txt \
--enumerate-actions-secrets \
--token-env COVENANT_TOKENExample actions_secrets array (org-wide and repo-scoped secrets, names only):
{
"actions_secrets": [
{ "scope": "org", "owner": "acme-corp", "name": "ORG_AWS_KEY", "protected": false },
{ "scope": "org", "owner": "acme-corp", "name": "ORG_NPM_TOKEN", "protected": true },
{ "scope": "repo", "owner": "acme-corp/spellbook", "name": "DEPLOY_TOKEN", "protected": false }
]
}Where the --enumerate-* flags map a captured token's offensive reach (the
keys it owns, the repos it can push to, the CI/CD secrets it can read),
--audit-repo-visibility reports the exposure posture: which reachable repos
are public. A public repo is the org's external attack surface — its full
source, history, issues and any leaked secrets are world-readable — and is
exactly where covenant's own recon-code secret scanning has the most to find.
An unexpectedly public repo sitting alongside private siblings is a direct
leak/supply-chain risk worth triaging first. Pass --audit-repo-visibility to
validate-token and covenant walks the repos the token can reach with a
read-only query, adding a repo_visibility array:
| SCM | Endpoint | public derived from |
|---|---|---|
| GitHub | GET /user/repos |
the boolean private flag |
| GitLab | GET /api/v4/projects?membership=true |
the visibility string (public/internal/private) |
| Bitbucket | GET /2.0/repositories?role=member |
the boolean is_private flag |
Each entry is normalized to {"repo", "visibility", "public"}.
GitLab
internalprojects are flaggedpublic: true. Aninternalproject is readable by any authenticated user of the instance — a broader-than-it-looks exposure on a shared or self-managed GitLab — so the audit treats anything other thanprivateas exposure rather than hiding it.
Only repo metadata (name + visibility) is read — no code, no secrets. The walk is
bounded by the same --max-pages flag and honors the scope guardrail and
automatic rate-limit retry/backoff. Like the other --enumerate-*/--audit-*
flags the feature is purely additive (all may be combined): without the flag the
output is unchanged, and with it the v0.1 scopes/user/admin fields are
untouched — only the repo_visibility array is added.
covenant github validate-token \
--scope-file scope.txt \
--audit-repo-visibility \
--token-env COVENANT_TOKENExample repo_visibility array (a public repo flagged among private siblings):
{
"repo_visibility": [
{ "repo": "acme-corp/spellbook", "visibility": "private", "public": false },
{ "repo": "acme-corp/grimoire", "visibility": "public", "public": true }
]
}A CODEOWNERS file is the control that routes mandatory review to a named owner per path: GitHub's "Require review from Code Owners", GitLab's code-owner approval, and Bitbucket's "Code owners approval" merge check all read it. That makes it the partner control to branch protection — a protected branch's require-code-owner-review gate only bites for paths a CODEOWNERS rule matches, so auditing branch protection without auditing CODEOWNERS leaves a blind spot:
- A reachable repo with no CODEOWNERS file (
present: false) cannot gate review by owner at all, however strict its branch protection looks. - A repo whose CODEOWNERS has rules but no catch-all
*(has_global_owner: false) leaves every path matched by no rule — including a file an attacker newly adds in a PR — with no required owner. This is the silent owner-coverage gap that--audit-branch-protectionalone does not reveal.
Pass --audit-codeowners to validate-token and covenant walks the repos the
token can reach, probing the standard CODEOWNERS locations (in precedence order:
the provider directory, then the repo root, then docs/) with a read-only
file fetch and reporting the first one found:
| SCM | Endpoint | Locations probed |
|---|---|---|
| GitHub | GET /repos/{owner}/{repo}/contents/{path} |
.github/CODEOWNERS, CODEOWNERS, docs/CODEOWNERS |
| GitLab | GET /api/v4/projects/{id}/repository/files/{path}?ref={branch} |
.gitlab/CODEOWNERS, CODEOWNERS, docs/CODEOWNERS |
| Bitbucket | GET /2.0/repositories/{full_name}/src/HEAD/{path} |
CODEOWNERS, docs/CODEOWNERS |
Each entry is normalized to
{"repo", "present", "path", "rule_count", "has_global_owner"}.
Only the posture is surfaced — never the owners. The owner account/team handles inside CODEOWNERS are not echoed (they are not the audit's signal), and no other repository content is read. covenant reports only how many ownership rules exist and whether a catch-all
*rule covers otherwise- unmatched paths.
The walk is bounded by the same --max-pages flag and honors the scope guardrail
and automatic rate-limit retry/backoff. Like the other --enumerate-*/--audit-*
flags the feature is purely additive (all may be combined): without the flag the
output is unchanged, and with it the v0.1 scopes/user/admin fields are
untouched — only the codeowners array is added. It is read-only and never
edits CODEOWNERS or any review setting.
covenant github validate-token \
--scope-file scope.txt \
--audit-codeowners \
--token-env COVENANT_TOKENExample codeowners array (a partial-coverage repo flagged alongside a covered
one):
{
"codeowners": [
{ "repo": "acme-corp/spellbook", "present": true, "path": ".github/CODEOWNERS", "rule_count": 2, "has_global_owner": false },
{ "repo": "acme-corp/grimoire", "present": true, "path": "CODEOWNERS", "rule_count": 2, "has_global_owner": true }
]
}Where --audit-branch-protection and --audit-codeowners report process
gaps (would a bad push be stopped before it lands?), this reports a
known-vulnerability attack surface: every open alert is a publicly-documented
CVE/GHSA in a dependency the repo actually ships, with a known severity and
(often) a known exploit. For an authorized engagement this is a high-signal
triage axis — a reachable repo carrying open critical alerts is where a real
intrusion is most likely to start, and the alert names the exact package and
advisory so the operator can map it to an exploit without further probing.
Pass --audit-dependabot-alerts to validate-token and covenant walks the repos
the token can reach, listing each one's open dependency alerts with a
read-only query:
| SCM | Endpoint | Surface mapped |
|---|---|---|
| GitHub | GET /repos/{owner}/{repo}/dependabot/alerts?state=open |
Dependabot alerts (open) |
| GitLab | GET /api/v4/projects/{id}/vulnerabilities?state=detected |
dependency-scanning vulnerabilities (detected) |
| Bitbucket | (none — no first-party dependency-alert API) | empty result + an explanatory warnings entry |
Each entry is normalized to
{"repo", "package", "ecosystem", "severity", "state", "identifier"}. severity
is the advisory's CVSS band (critical/high/medium/low, lower-cased for
cross-provider parity) and identifier is the GHSA/CVE advisory handle.
Only the advisory handle is surfaced — never a credential.
identifieris a public CVE/GHSA id, not a secret. A repo with the feature disabled or out of the token's scope answers403/404; covenant skips that repo rather than failing the whole audit, so a partial-permission token still reports every repo it can read. Bitbucket Cloud has no first-party dependency-alert API (its dependency security runs through third-party Pipelines integrations), so the result is empty there and a non-fatalwarningsnote explains that the empty result reflects a platform limitation, not a clean bill of health.
Only OPEN alerts are requested, and only alert metadata is read — covenant never
dismisses, fixes, or creates an alert. The walk is bounded by the same
--max-pages flag and honors the scope guardrail and automatic rate-limit
retry/backoff. Like the other --enumerate-*/--audit-* flags the feature is
purely additive (all may be combined): without the flag the output is unchanged,
and with it the v0.1 scopes/user/admin fields are untouched — only the
dependabot_alerts array is added.
covenant github validate-token \
--scope-file scope.txt \
--audit-dependabot-alerts \
--token-env COVENANT_TOKENExample dependabot_alerts array (an open critical alert; a repo with the
feature disabled is silently skipped, not listed):
{
"dependabot_alerts": [
{ "repo": "acme-corp/spellbook", "package": "requests", "ecosystem": "pip", "severity": "critical", "state": "open", "identifier": "GHSA-xxxx-yyyy-zzzz" }
]
}Where --audit-dependabot-alerts reports the known-vulnerable subset a
provider scanner has already flagged, this reports the full declared
dependency surface: every package a reachable repo's manifest files pull in,
named with its declared version and ecosystem. It is the software-supply-chain
inventory an operator needs before asking "which of these is vulnerable,
typosquatted, or abandoned?" — the surface a Dependabot audit can only annotate,
not enumerate. And unlike --audit-dependabot-alerts (a no-op on Bitbucket
Cloud), this works on all three SCMs, because it reads the manifests
directly rather than relying on a first-party alert API.
Pass --audit-packages to validate-token and covenant walks the repos the
token can reach, probing each one's repository root for the supported manifest
files with a read-only file fetch and parsing the declared dependencies:
| SCM | Endpoint | Body returned |
|---|---|---|
| GitHub | GET /repos/{owner}/{repo}/contents/{manifest} |
base64 JSON envelope |
| GitLab | GET /api/v4/projects/{id}/repository/files/{manifest}?ref=… |
base64 JSON envelope |
| Bitbucket | GET /2.0/repositories/{full_name}/src/HEAD/{manifest} |
raw file text |
The manifest files probed (and the ecosystem each declares):
| Manifest | Ecosystem | Source read |
|---|---|---|
package.json |
npm |
dependencies + devDependencies + peerDependencies + optionalDependencies |
requirements.txt |
pip |
requirement specifiers (-r/-c/--option directives skipped) |
pyproject.toml |
pip |
PEP 621 [project].dependencies + Poetry [tool.poetry.dependencies] |
Pipfile |
pip |
[packages] + [dev-packages] |
go.mod |
go |
require directives (single-line and block form) |
Gemfile |
rubygems |
gem "name", "constraint" declarations |
pom.xml |
maven |
each <dependency> block as groupId:artifactId |
Each declared package is normalized to
{"repo", "manifest", "ecosystem", "package", "version"}. version is the spec
the manifest declares (^1.2.3, ==1.0, v1.4.0, …) and is null when no
version is pinned for that package.
covenant inventories the declaration — it never resolves or installs it. The
versionis exactly what the manifest says, not a resolved lockfile pin. Only the named manifest files are read (never lockfile graphs, never source), and only package names + declared versions are surfaced — never any credential a manifest might inadvisedly contain. A repo with no recognized manifest contributes no rows; a malformed manifest contributes no rows for that file rather than aborting the audit.
The walk is bounded by the same --max-pages flag and honors the scope
guardrail and automatic rate-limit retry/backoff. Like the other
--enumerate-*/--audit-* flags the feature is purely additive (all may be
combined): without the flag the output is unchanged, and with it the v0.1
scopes/user/admin fields are untouched — only the packages array is
added.
covenant github validate-token \
--scope-file scope.txt \
--audit-packages \
--token-env COVENANT_TOKENExample packages array (npm + pip dependencies declared by one repo):
{
"packages": [
{ "repo": "acme-corp/spellbook", "manifest": "package.json", "ecosystem": "npm", "package": "left-pad", "version": "^1.3.0" },
{ "repo": "acme-corp/spellbook", "manifest": "requirements.txt", "ecosystem": "pip", "package": "requests", "version": "2.31.0" }
]
}Where --audit-dependabot-alerts reports a known-vulnerability surface in a
repo's third-party dependencies, --audit-code-scanning-alerts reports the
static-analyzer findings in the repo's own first-party source — an injection
sink, a crypto misuse, a path-traversal, a deserialization flaw. These are bugs
in code the org itself controls, named by rule and source location, so a
finding maps straight to where a real exploit is likely to live. It is the
code-flaw triage axis the dependency and credential audits do not cover.
Pass --audit-code-scanning-alerts to validate-token and covenant walks the
repos the token can reach, listing each one's open code-scanning alerts with
a read-only query:
| SCM | Endpoint | Surface read |
|---|---|---|
| GitHub | GET /repos/{owner}/{repo}/code-scanning/alerts?state=open |
code-scanning / CodeQL alerts |
| GitLab | GET /api/v4/projects/{id}/vulnerabilities?report_type=sast&state=detected |
SAST findings (Vulnerability Report) |
| Bitbucket | (unsupported — no first-party static-analysis-alert API; empty result + warning) | — |
Each alert is normalized to
{"repo", "rule_id", "rule_name", "severity", "state", "html_url"}. rule_id is
the analyzer's rule identifier (e.g. py/sql-injection), rule_name its human
description, and severity prefers the security-severity band
(critical/high/medium/low), falling back to the alert's generic severity
(error/warning/note) when the band is absent — the decisive triage field.
html_url points at the alert in the provider UI, at the finding's source
location — never at a credential.
Only open alerts, only metadata. A dismissed/fixed alert is not live surface, so only
state=open/detectedis requested. A repo with the feature disabled, never analyzed, or out of the token's scope answers HTTP 403/404 for that repo; covenant skips it rather than failing the whole audit, so a partial-permission token still reports every repo it can read. The audit is read-only — it only GETs alert metadata and never dismisses, fixes, or creates an alert. Bitbucket Cloud has no first-party static-analysis-alert API (it is delivered through third-party Pipelines integrations), so the result is empty there with an explanatory warning rather than a misleading clean bill of health.
The walk is bounded by the same --max-pages flag and honors the scope guardrail
and automatic rate-limit retry/backoff. Like the other --enumerate-*/--audit-*
flags the feature is purely additive (all may be combined): without the flag the
output is unchanged, and with it the v0.1 scopes/user/admin fields are
untouched — only the code_scanning_alerts array is added.
covenant github validate-token \
--scope-file scope.txt \
--audit-code-scanning-alerts \
--token-env COVENANT_TOKENExample code_scanning_alerts array (one open critical CodeQL finding):
{
"code_scanning_alerts": [
{
"repo": "acme-corp/spellbook",
"rule_id": "py/sql-injection",
"rule_name": "SQL query built from user-controlled sources",
"severity": "critical",
"state": "open",
"html_url": "https://github.com/acme-corp/spellbook/security/code-scanning/42"
}
]
}This is the publisher-side counterpart to the two consumer-side flags above.
Where --audit-dependabot-alerts reports a repo consuming the global advisory
database (a known CVE in one of its third-party dependencies) and
--audit-code-scanning-alerts reports static-analyzer findings in the repo's
own source, --audit-advisory-alerts reports the repo as a publisher of
advisories — the published repository security advisories the org's own
maintainers wrote up against their own product. Each is a GHSA the org
authored itself, naming a real (often already-patched) vulnerability in code the
org ships to others; on an unpatched or partially-deployed fleet the GHSA/CVE
handle and summary point straight at where a working exploit still lives. It is
the triage axis the dependency and code-flaw audits do not cover.
Pass --audit-advisory-alerts to validate-token and covenant walks the repos
the token can reach, listing each one's published advisories with a
read-only query:
| SCM | Endpoint | Surface read |
|---|---|---|
| GitHub | GET /repos/{owner}/{repo}/security-advisories?state=published |
maintainer-authored repo advisories |
| GitLab | (unsupported — advisory data is an instance-wide third-party feed; empty result + warning) | — |
| Bitbucket | (unsupported — no maintainer-authored repository advisory API; empty result + warning) | — |
Each advisory is normalized to
{"repo", "ghsa_id", "cve_id", "summary", "severity", "state", "html_url"}.
ghsa_id is the advisory handle and cve_id its CVE mapping when assigned (both
advisory identifiers, never a credential), severity the advisory's CVSS band
(critical/high/medium/low), and summary the maintainer's one-line
description. html_url points at the advisory in the provider UI.
Only published advisories, only metadata. A draft advisory is not yet a confirmed, public finding, so only
state=publishedis requested. A repo with no advisories, the feature unavailable, or out of the token's scope answers HTTP 403/404 for that repo; covenant skips it rather than failing the whole audit, so a partial-permission token still reports every repo it can read. The audit is read-only — it only GETs advisory metadata and never creates, edits, or publishes an advisory. GitLab and Bitbucket Cloud have no per-repo maintainer-authored advisory surface, so the result is empty there with an explanatory warning rather than a misleading clean bill of health.
The walk is bounded by the same --max-pages flag and honors the scope guardrail
and automatic rate-limit retry/backoff. Like the other --enumerate-*/--audit-*
flags the feature is purely additive (all may be combined): without the flag the
output is unchanged, and with it the v0.1 scopes/user/admin fields are
untouched — only the advisory_alerts array is added.
covenant github validate-token \
--scope-file scope.txt \
--audit-advisory-alerts \
--token-env COVENANT_TOKENExample advisory_alerts array (one published critical advisory):
{
"advisory_alerts": [
{
"repo": "acme-corp/spellbook",
"ghsa_id": "GHSA-spel-lboo-k123",
"cve_id": "CVE-2024-13337",
"summary": "Authentication bypass in spellbook session handling",
"severity": "critical",
"state": "published",
"html_url": "https://github.com/acme-corp/spellbook/security/advisories/GHSA-spel-lboo-k123"
}
]
}Where --audit-actions-environments gates which deployments may reach an
environment's secrets, --audit-actions-permissions audits what a workflow run
may do once it executes: whether Actions is enabled on the repo, the
allowed_actions policy, and — the decisive supply-chain field — the default
permissions of the automatic GITHUB_TOKEN granted to every run. It is the
execution-side counterpart to --audit-branch-protection: branch protection
gates code landing, this gates what automation may do once it runs.
The high-signal finding is a repo where default_workflow_permissions is
write: every workflow on that repo — including one introduced by a malicious
pull request, if the repo runs untrusted PR workflows — starts with a read/write
token over the repo's contents, releases and packages, a far wider blast radius
than the locked-down read default. A second amplifier is
can_approve_pull_request_reviews=true, which lets a workflow self-approve PRs
and so satisfy a branch-protection review gate without a human.
Pass --audit-actions-permissions to validate-token and covenant walks the
repos the token can reach, reading each one's Actions-permission posture with a
read-only query:
| SCM | Endpoint | Surface read |
|---|---|---|
| GitHub | GET /repos/{owner}/{repo}/actions/permissions + .../actions/permissions/workflow |
Actions on/off + workflow-token policy |
| GitLab | (unsupported — no single per-project Actions-permission API; empty result + warning) | — |
| Bitbucket | (unsupported — no per-repo Pipelines-permission API; empty result + warning) | — |
Each repo is normalized to
{"repo", "actions_enabled", "allowed_actions", "default_workflow_permissions", "can_approve_pull_request_reviews"}.
allowed_actions is the invocation policy (all/local_only/selected),
default_workflow_permissions is read or write (the decisive field), and
can_approve_pull_request_reviews is the PR self-approval flag.
Disabled repos and metadata only. When Actions is disabled on a repo there is no workflow-token policy to read, so
default_workflow_permissionsisnullandcan_approve_pull_request_reviewsisfalse(a disabled repo has no execution surface). A repo the token cannot administer answers HTTP 403/404; covenant skips it rather than failing the whole audit, so a partial-permission token still reports every repo it can read. The audit is read-only — it only GETs policy metadata and never enables, disables, or rewrites a permission. GitLab and Bitbucket Cloud have no single per-repo Actions-permission API, so the result is empty there with an explanatory warning rather than a misleading clean bill of health.
The walk is bounded by the same --max-pages flag and honors the scope guardrail
and automatic rate-limit retry/backoff. Like the other --enumerate-*/--audit-*
flags the feature is purely additive (all may be combined): without the flag the
output is unchanged, and with it the v0.1 scopes/user/admin fields are
untouched — only the actions_permissions array is added.
covenant github validate-token \
--scope-file scope.txt \
--audit-actions-permissions \
--token-env COVENANT_TOKENExample actions_permissions array (a write-token repo and a disabled repo):
{
"actions_permissions": [
{
"repo": "acme-corp/spellbook",
"actions_enabled": true,
"allowed_actions": "all",
"default_workflow_permissions": "write",
"can_approve_pull_request_reviews": true
},
{
"repo": "acme-corp/grimoire",
"actions_enabled": false,
"allowed_actions": null,
"default_workflow_permissions": null,
"can_approve_pull_request_reviews": false
}
]
}Where --audit-actions-permissions and --audit-actions-environments report
the policy posture a workflow run inherits (what it is allowed to do),
--audit-workflow-runs surfaces observed activity (what the pipeline
actually ran), so an operator can spot anomalous CI patterns before they
escalate. The decisive high-signal findings:
- a streak of
conclusion="failure"runs (active attack attempts, broken CI gates, or a runner under load); - a
conclusion="cancelled"/"STOPPED"run (operator or defender intervention killing a job in flight); and - an unexpected
event(trigger) distribution — a sudden burst ofworkflow_dispatch/MANUAL(manual-trigger abuse) or off-hoursschedule/SCHEDULEruns (a planted cron pipeline) is a CI-misuse signal a posture audit alone does not catch.
Pass --audit-workflow-runs to validate-token and covenant walks the repos
the token can reach and, for each, lists its recent pipeline runs — GitHub
GET /repos/{owner}/{repo}/actions/runs, GitLab
GET /api/v4/projects/{id}/pipelines, Bitbucket
GET /2.0/repositories/{full_name}/pipelines/. Each run is normalized to:
{
"repo": "acme-corp/spellbook",
"run_id": 900,
"name": "CI",
"event": "push",
"status": "completed",
"conclusion": "failure",
"created_at": "2026-05-28T10:00:00Z"
}name is the workflow's human-readable name (GitHub) or the branch/ref the
pipeline ran on (GitLab/Bitbucket); event is the trigger
(push/pull_request/workflow_dispatch/schedule/...); status is the
lifecycle state and conclusion is the terminal outcome (None while the
run is still in progress). A repo with Actions/Pipelines disabled or out of
the token's scope answers 403/404 and is skipped (not fatal). Only run
metadata is surfaced — logs, artifact URLs, and any CI/CD variable values
the run saw are never fetched. Read-only; never re-runs, cancels, or
dispatches a workflow.
Where --audit-branch-protection walks each protected branch's classic
settings, --audit-branch-ruleset surfaces the newer named-rule-collection
model the two coexist with — GitHub branch rulesets (2023+) and GitLab push
rules. A posture audit that only checks classic branch protection misses a
ruleset-only configuration; this audit complements the existing one so the
operator sees both rule models side by side. The decisive high-signal findings:
enforcement="disabled"or"evaluate"— a configured ruleset that is not actively blocking is a paper tiger;bypass_actor_count > 0— every bypass actor is somebody who can route around the rule (a long list widens the bypass set); and- the
rule_typeslist missing core gates (pull_requestfor required review,required_signaturesfor signed commits,non_fast_forward/deletionfor anti-force-push) — a ruleset named "main protection" whose rules don't includepull_requestdoes not in fact require review.
Pass --audit-branch-ruleset to validate-token and covenant walks the repos
the token can reach and, for each, lists its rulesets — GitHub
GET /repos/{owner}/{repo}/rulesets (plus
GET /repos/{owner}/{repo}/rulesets/{id} for the rule and bypass-actor
detail), GitLab GET /api/v4/projects/{id}/push_rule (the per-project
push-rule object, the closest analogue). Each ruleset is normalized to:
{
"repo": "acme-corp/spellbook",
"ruleset_id": 600,
"name": "main-protection",
"enforcement": "active",
"target": "branch",
"rule_types": ["deletion", "non_fast_forward", "pull_request", "required_signatures"],
"bypass_actor_count": 0
}rule_types is the sorted, de-duplicated list of rule-type strings (GitHub's
pull_request / required_signatures / non_fast_forward / deletion /
creation / ...; GitLab's commit_message_regex / branch_name_regex /
prevent_secrets / reject_unsigned_commits / member_check / ...) — the
shape of the rule set, not the parameter values inside any one rule.
Only ruleset posture is surfaced: bypass-actor identities are
deliberately never echoed (only the count is the high-signal blast-radius
value; a long list of names would be noise), no rule parameter values (regex
patterns, exact reviewer counts, named required status checks) are surfaced
beyond their presence in rule_types, and no repository content is read. A
repo whose rulesets endpoint answers 403/404 (rulesets unavailable on the
plan, or token lacks the admin scope) is skipped — the audit returns the
rulesets from the repos it CAN read rather than aborting on the first 404.
Bitbucket Cloud has no named-ruleset API (its policy lives in
branch-restrictions, already covered by --audit-branch-protection); the
result is empty there with an explanatory warnings entry so the empty list
is not misread as a clean bill of health. Read-only — covenant never creates,
edits, or deletes a ruleset.
Where --enumerate-actions-secrets maps the credential surface that powers a
build pipeline, --enumerate-runners maps the execution-host surface: the
self-hosted machines those pipelines actually run on. Every job dispatched to a
runner executes on the runner's host and sees that run's GITHUB_TOKEN (or
GitLab CI job token, or Bitbucket Pipelines variables), the checked-out source,
and the build artifacts. A compromised, planted, or unhardened self-hosted
runner is therefore a persistence and lateral-movement foothold — once an
attacker can place a job onto it, every subsequent job is also exposed — and a
runner registered at org / group / workspace scope is the broadest, because
any repo in the org can dispatch to it.
Pass --enumerate-runners to validate-token and covenant walks both axes the
token can reach with a read-only query, adding a runners array:
| SCM | Org/group/workspace-level (scope org) |
Repo/project-level (scope repo) |
|---|---|---|
| GitHub | GET /orgs/{org}/actions/runners |
GET /repos/{owner}/{repo}/actions/runners |
| GitLab | GET /api/v4/groups/{id}/runners |
GET /api/v4/projects/{id}/runners |
| Bitbucket | GET /2.0/workspaces/{slug}/pipelines-config/runners |
GET /2.0/repositories/{full_name}/pipelines-config/runners |
Each entry is normalized to
{"scope", "owner", "id", "name", "labels", "self_hosted"}. labels is the
list of label names a workflow may target the runner with (GitHub's
{id, name, type} label objects are reduced to bare names; GitLab's tag_list
and Bitbucket's flat label list are surfaced verbatim).
self_hostedis the decisive distinction. The GitHub Actions and Bitbucket Pipelines runner endpoints list only self-hosted runners by design — platform-managed runners do not appear — soself_hostedis alwaystrueon those providers. GitLab lists every runner reachable from the group/project, including the platform-managed shared SaaS runners (is_shared: true); covenant maps those toself_hosted: falseso an operator can tell the org-controlled infrastructure apart from the shared compute it does not operate.
Only runner identity and labels are surfaced — never a credential. The runner registration token, GitHub Actions runner removal token, and Bitbucket Pipelines runner OAuth client secret are all read-only invisible to covenant. Endpoints a low-privilege token can't read fail soft to an empty result rather than failing the whole audit.
The walk is bounded by the same --max-pages flag and honors the scope
guardrail and automatic rate-limit retry/backoff. Like the other
--enumerate-*/--audit-* flags the feature is purely additive (all may be
combined): without the flag the output is unchanged, and with it the v0.1
scopes/user/admin fields are untouched — only the runners array is
added.
covenant github validate-token \
--scope-file scope.txt \
--enumerate-runners \
--token-env COVENANT_TOKENExample runners array (an org-scoped self-hosted runner alongside a
repo-attached one):
{
"runners": [
{
"scope": "org",
"owner": "acme-corp",
"id": 700,
"name": "acme-org-runner-1",
"labels": ["self-hosted", "linux", "production"],
"self_hosted": true
},
{
"scope": "repo",
"owner": "acme-corp/spellbook",
"id": 710,
"name": "spellbook-repo-runner",
"labels": ["self-hosted"],
"self_hosted": true
}
]
}A deployment environment (GitHub Actions "Environments", GitLab project environments, Bitbucket Pipelines deployment environments) is where the most sensitive CI/CD secrets are scoped — production cloud keys, registry passwords, deploy tokens. The environment's protection rules are the gate that decides whether a workflow may deploy to it and thereby read those secrets: required-reviewers force a human to approve the deployment, a wait timer delays it, and a deployment branch policy restricts which branches may deploy at all.
--audit-actions-environments is the environment-scoped, secret-exfiltration
counterpart to --audit-branch-protection: where branch protection gates code
landing on a branch, this gates deployments reaching the secrets. The
high-signal finding is an environment with required_reviewers: false and a
permissive branch_policy of "all" — any branch (including an attacker's
feature branch carrying a malicious workflow) can deploy to that environment and
exfiltrate its secrets unreviewed. Pass --audit-actions-environments to
validate-token and covenant walks the repos the token can reach with a
read-only query, adding an actions_environments array:
| SCM | Endpoint(s) | Gate signal mapped to required_reviewers |
|---|---|---|
| GitHub | GET /repos/{owner}/{repo}/environments |
a required_reviewers protection rule (with wait_timer + branch policy) |
| GitLab | GET /api/v4/projects/{id}/environments + .../protected_environments |
a protected environment with required_approval_count > 0 |
| Bitbucket | GET /2.0/repositories/{full_name}/environments |
the deploy gate's restrictions.admin_only flag |
Each entry is normalized to {"repo", "environment", "required_reviewers", "required_reviewer_count", "wait_timer", "branch_policy"}. branch_policy is
"protected" (only protected branches may deploy), "custom" (a custom branch
allow-list), or "all" (no branch restriction — the weakest posture).
Provider parity caveats. GitLab has no per-environment deploy wait timer, so
wait_timeris always0there. Bitbucket Cloud exposes no per-environment reviewer count, wait timer, or branch policy, sorequired_reviewer_count/wait_timerare0andbranch_policyis"all"on Bitbucket — the fields are kept for cross-provider shape parity, andrequired_reviewersreflects theadmin_onlydeploy gate.
Only policy metadata is read — covenant never creates, edits, or triggers a
deployment, and no secret value is ever read. The walk is bounded by the same
--max-pages flag and honors the scope guardrail and automatic rate-limit
retry/backoff. Like the other --enumerate-*/--audit-* flags the feature is
purely additive (all may be combined): without the flag the output is unchanged,
and with it the v0.1 scopes/user/admin fields are untouched — only the
actions_environments array is added.
covenant github validate-token \
--scope-file scope.txt \
--audit-actions-environments \
--token-env COVENANT_TOKENExample actions_environments array (a weakly-gated production environment
beside a strongly-gated one):
{
"actions_environments": [
{
"repo": "acme-corp/spellbook",
"environment": "production",
"required_reviewers": false,
"required_reviewer_count": 0,
"wait_timer": 0,
"branch_policy": "all"
},
{
"repo": "acme-corp/grimoire",
"environment": "production",
"required_reviewers": true,
"required_reviewer_count": 2,
"wait_timer": 30,
"branch_policy": "protected"
}
]
}Custom deployment protection rules are the third-party GitHub Apps an
environment delegates its deploy gate to. Each rule is a separately-installed
GitHub App that the environment consults before a deployment proceeds; the app
returns approve/reject and the deployment lands or is held. Where
--audit-actions-environments reports the built-in environment gate
(required reviewers, wait timer, branch policy), this audit lists the
delegated third-party gates — a supply-chain trust bond the built-in posture
does not cover. Operators usually know the built-in policy but lose track of
which custom apps were ever installed as a gate, so listing them surfaces the
third parties that hold the deploy keys; conversely, an installed rule with
enabled: false is a gate the operator thinks they have but does not.
Pass --audit-deployment-protection to validate-token and covenant walks
the repos the token can reach with a read-only query, adding a
deployment_protection array — one entry per installed custom rule.
| SCM | Endpoint(s) | What's surfaced |
|---|---|---|
| GitHub | GET /repos/{owner}/{repo}/environments + GET /repos/{owner}/{repo}/environments/{env}/deployment_protection_rules |
per-rule {rule_id, app_slug, app_id, enabled} |
| GitLab | no equivalent — empty result + an explanatory warnings note |
n/a |
| Bitbucket | no equivalent — empty result + an explanatory warnings note |
n/a |
Each entry is normalized to {"repo", "environment", "rule_id", "app_slug", "app_id", "enabled"}. An environment with no custom rules contributes nothing
(empty environments are not noise); an environment whose listing endpoint
answers 403/404 (token lacks admin reach on the repo) is skipped rather than
failing the whole audit, so a partial-permission token still reports every
rule it can read.
Provider parity caveat. GitLab and Bitbucket Cloud have no per-environment custom-rule-app API: GitLab's analogous gating is split across protected-environment approval rules (already surfaced by
--audit-actions-environmentsviarequired_approval_count), deployment freeze windows, and external MR-approval integrations; Bitbucket Cloud's analogue is the built-inrestrictions.admin_onlydeploy gate (also surfaced by--audit-actions-environments). The audit therefore returns an emptydeployment_protectionarray on those SCMs and records a non-fatalwarningsnote so the empty result is not misread as a clean bill of health.
Only the rule metadata and the gating app's identity (slug + id) are
surfaced — covenant never echoes an installation token, webhook secret, app
private key, or any other credential, and the audit never installs, removes,
enables, or disables a protection rule. The walk is bounded by the same
--max-pages flag and honors the scope guardrail and automatic rate-limit
retry/backoff. Like the other --enumerate-*/--audit-* flags the feature
is purely additive (all may be combined): without the flag the output is
unchanged, and with it the v0.1 scopes/user/admin fields are untouched
— only the deployment_protection array is added.
covenant github validate-token \
--scope-file scope.txt \
--audit-deployment-protection \
--token-env COVENANT_TOKENExample deployment_protection array (one actively-delegated third-party
gate beside a stale rule that is installed but disabled):
{
"deployment_protection": [
{
"repo": "acme-corp/spellbook",
"environment": "production",
"rule_id": 9001,
"app_slug": "third-party-gate",
"app_id": 4242,
"enabled": true
},
{
"repo": "acme-corp/spellbook",
"environment": "production",
"rule_id": 9002,
"app_slug": "stale-gate",
"app_id": 4343,
"enabled": false
}
]
}Where --enumerate-webhooks maps the destination URLs of the
org/group/workspace webhooks a token can reach (the exfil/SSRF surface — where
event payloads are POSTed), --audit-webhook audits the defensive posture
of each hook: the three controls that decide whether a captured-URL hook is
actually trustable.
| Finding | What it means |
|---|---|
has_secret: false |
The hook was wired with no HMAC shared secret. The receiver has no way to verify a POST really came from the SCM; an attacker who learns the URL can forge events and a captured payload can be silently replayed. |
insecure_ssl: true |
The hook is configured with TLS certificate verification disabled. An attacker-in-the-middle on the delivery path can intercept, modify, or replay the payload, and a self-signed/expired/rogue cert at the destination silently authenticates. |
active: true + wildcard_events: true |
The hook subscribes to every event the org emits (events: ["*"]) on an active subscription — the widest possible exfiltration channel. A surgical hook lists only the events it needs. |
Pass --audit-webhook to validate-token and covenant walks the
orgs/groups/workspaces the token can reach with a read-only query, adding a
webhook_configuration array — one entry per webhook.
| SCM | Endpoint(s) | What's surfaced |
|---|---|---|
| GitHub | GET /orgs/{org}/hooks |
per-hook {has_secret, insecure_ssl, active, events, wildcard_events} |
| GitLab | no equivalent — empty result + an explanatory warnings note |
n/a |
| Bitbucket | no equivalent — empty result + an explanatory warnings note |
n/a |
Each entry is normalized to {"scope", "owner", "id", "url", "has_secret", "insecure_ssl", "active", "events", "wildcard_events"}. wildcard_events is
the convenience boolean covenant derives from "*" in events so the operator
can triage at a glance.
Provider parity caveat. GitLab's group-hook API does not expose a uniform per-hook posture record covering secret presence, TLS verification, and event scope to a recon-grade token, and Bitbucket Cloud's workspace-hook API exposes neither a per-hook secret-presence boolean nor a TLS-verification flag (Bitbucket gates delivery on a provider IP allow-list instead). The audit therefore returns an empty
webhook_configurationarray on those SCMs and records a non-fatalwarningsnote so the empty result is not misread as a clean bill of health.
The secret value is never echoed. GitHub exposes
config.secretas a"********"placeholder when one is configured and omits the field entirely when it is not. covenant surfaces ONLY the boolean PRESENCE (has_secret), never the placeholder, the URL's query string, or any header — the audit is not itself a new copy of the leak. The walk is bounded by the same--max-pagesflag and honors the scope guardrail and automatic rate-limit retry/backoff. Like the other--enumerate-*/--audit-*flags the feature is purely additive (all may be combined): without the flag the output is unchanged, and with it the v0.1scopes/user/adminfields are untouched — only thewebhook_configurationarray is added.
covenant github validate-token \
--scope-file scope.txt \
--audit-webhook \
--token-env COVENANT_TOKENExample webhook_configuration array (one strongly-posture hook beside a
weakly-posture one — no secret, TLS verification disabled, wildcard event
scope):
{
"webhook_configuration": [
{
"scope": "org",
"owner": "acme-corp",
"id": 100,
"url": "https://hooks.example.com/acme-corp",
"has_secret": true,
"insecure_ssl": false,
"active": true,
"events": ["push", "pull_request"],
"wildcard_events": false
},
{
"scope": "org",
"owner": "wizards-inc",
"id": 100,
"url": "https://hooks.example.com/wizards-inc",
"has_secret": false,
"insecure_ssl": true,
"active": true,
"events": ["*"],
"wildcard_events": true
}
]
}Where the rest of the --audit-* family reports repo-internal posture
(branch protection, environment gates, webhook hygiene, CODEOWNERS coverage),
--audit-ip-allowlist reports the org-level network perimeter — the
GitHub IP-allowlist setting that gates which source IPs may authenticate to
the org's API/Git surface, and the often-overlooked installed-apps gate that
is off by default even when the org-wide allowlist itself is on. A captured
token authenticating from an unexpected source IP, or a compromised GitHub
App reaching the org from the vendor's network, is exactly the threat this
perimeter defends against; a missing or half-configured allowlist leaves
every other gate exposed to any source IP a leaked token can dial in from.
The decisive high-signal findings:
ip_allow_list_enabled=false— the org has no IP perimeter at all: a token leaked to a coffee-shop laptop, a third-party CI runner, or an attacker-controlled host authenticates from any IP without friction. The single highest-signal finding the audit surfaces.ip_allow_list_enabled_for_installed_apps=false— even when the org-wide allowlist is on, installed GitHub Apps are by default not gated by it: an attacker who compromises an app's installation token (or wires a malicious app the org installs) reaches the org from the app vendor's network, bypassing the perimeter the org operator thought they had configured. A frequently-overlooked gap.
Pass --audit-ip-allowlist to validate-token and covenant walks the orgs
the token can reach and, for each, GETs /orgs/{org} — the same per-org
metadata endpoint the rest of the client already relies on. Each org is
normalized to:
{
"scope": "org",
"owner": "acme-corp",
"ip_allow_list_enabled": true,
"ip_allow_list_enabled_for_installed_apps": true
}The boolean fields fall back to false when the API omits them (older orgs,
free plans where the feature is unavailable, or a recon-grade token without
admin:org scope), so a missing field never silently masquerades as a clean
bill of health. The allowlist entries themselves are deliberately not
echoed — listing them requires the org-admin GraphQL endpoint, and the
entry CIDRs (vendor netblocks, office subnets, VPN egress ranges) are
sensitive operational metadata that recon-grade access should not exfiltrate;
the boolean posture is the audit signal. An org whose metadata endpoint
answers 403/404 is skipped — the audit returns posture for the orgs it CAN
read rather than aborting on the first 404. GitLab (the IP-perimeter setting
lives at the instance / SAML-SSO level, not a per-group field a recon-grade
token can read) and Bitbucket Cloud (the workspace IP allowlist is admin-UI
only with no public REST endpoint) have no equivalent surface, so the result
is empty there with an explanatory warnings entry so the empty list is not
misread as a clean bill of health. Read-only — covenant only GETs org
metadata; never enables, disables, or edits an allowlist entry.
covenant github validate-token \
--scope-file scope.txt \
--audit-ip-allowlist \
--token-env COVENANT_TOKENExample ip_allowlist array (one strong-posture org beside a weak-posture
one — no perimeter at all):
{
"ip_allowlist": [
{
"scope": "org",
"owner": "acme-corp",
"ip_allow_list_enabled": true,
"ip_allow_list_enabled_for_installed_apps": true
},
{
"scope": "org",
"owner": "wizards-inc",
"ip_allow_list_enabled": false,
"ip_allow_list_enabled_for_installed_apps": false
}
]
}Multi-factor authentication requirement is the identity authentication baseline
that underpins every other org-level control: branch protection, IP allowlist, and
environment gates are all meaningless if a member account can be taken over with a
single stolen password. --audit-org-mfa audits whether MFA/2FA is required
for all members of each org/group the token can reach.
The high-signal finding is two_factor_required: false — the org does NOT mandate
MFA, meaning every member account is one phished or brute-forced password away from
full compromise and everything it can reach.
What is surfaced: only the boolean enforcement flag — never member usernames, emails, recovery codes, or any other identity metadata.
Provider coverage:
- GitHub — reads
two_factor_requirement_enabledfromGET /orgs/{org}. - GitLab — reads
require_two_factor_authenticationfromGET /api/v4/groups/{id}. - Bitbucket Cloud — the workspace two-step-verification enforcement flag is an admin-UI-only setting with no public REST endpoint; the audit returns empty with an explanatory warning.
export COVENANT_TOKEN="ghp_..."
covenant github validate-token \
--audit-org-mfa \
--scope-file scope.txtExample output:
{
"scopes": ["repo", "read:org"],
"user": "spelunker",
"admin": false,
"org_mfa": [
{
"scope": "org",
"owner": "acme-corp",
"two_factor_required": true
},
{
"scope": "org",
"owner": "wizards-inc",
"two_factor_required": false
}
]
}two_factor_required: false on wizards-inc means every wizard can be one-factored
into providing full org access.
Where the rest of the --enumerate-* family maps what this token reaches (its
keys, its repos, the secrets it can read), --enumerate-members maps the people
who share that reach: the other members of each org/group/workspace the token
belongs to, and at what role. That is the lateral-movement surface — the set
of additional identities an operator could target to widen a foothold (phishing,
credential reuse, a weaker teammate token) — and, for the role: "admin" set (the
org owners), the accounts whose compromise grants administrative control of the
whole org. Pass --enumerate-members to validate-token and covenant walks the
orgs the token can reach (the same set surfaced by --enumerate-orgs) and, for
each, lists its members with a read-only directory query, adding a members
array:
| SCM | Endpoint | role: "admin" derived from |
|---|---|---|
| GitHub | GET /orgs/{org}/members?role=admin|member |
the admin (org owner) role view |
| GitLab | GET /api/v4/groups/{id}/members/all |
the numeric access_level (>= 50 Owner) |
| Bitbucket | GET /2.0/workspaces/{slug}/members |
the permission field (owner) |
Each entry is normalized to {"scope", "owner", "username", "role"}, where
scope is org/group/workspace and role is admin or member.
Identity and role only — never a credential. covenant surfaces the member's username and role; it never reads or echoes an email, SSH/GPG key, or any token. This is a directory query, not a data dump.
The walk is bounded by the same --max-pages flag and honors the scope guardrail
and automatic rate-limit retry/backoff. Like the other --enumerate-*/--audit-*
flags the feature is purely additive (all may be combined): without the flag the
output is unchanged, and with it the v0.1 scopes/user/admin fields are
untouched — only the members array is added.
covenant github validate-token \
--scope-file scope.txt \
--enumerate-members \
--token-env COVENANT_TOKENExample members array (an org owner alongside ordinary members — the owner is
the highest-value lateral target):
{
"members": [
{ "scope": "org", "owner": "acme-corp", "username": "acme-corp-owner", "role": "admin" },
{ "scope": "org", "owner": "acme-corp", "username": "spellcaster", "role": "member" },
{ "scope": "org", "owner": "acme-corp", "username": "apprentice", "role": "member" }
]
}Maps the organisational structure of each org/group the captured token can
reach: which sub-units (GitHub teams, GitLab subgroups) exist, their slug (used in
CODEOWNERS entries and branch-protection review requirements), their privacy
level (GitHub "secret" teams are invisible to non-members), and the team
hierarchy (parent_slug for nested teams).
Useful for:
- CODEOWNERS coverage gap analysis — a
"secret"team that appears in CODEOWNERS is invisible to operators who are not members; knowing it exists lets an auditor verify the review chain is intact. - Permission blast-radius auditing — GitHub teams hold repository permission
grants that inherit down the hierarchy;
--enumerate-teamspaired with--enumerate-membersgives a complete picture. - Stale-team hygiene — a team with zero members still holds its repository permissions until explicitly deleted.
Pass --enumerate-teams to validate-token and covenant adds a teams array of
{scope, owner, team_name, team_slug, description, privacy, parent_slug} entries.
Only team metadata is surfaced — never member identities, tokens, or repository
credentials. Bitbucket Cloud has no team/subgroup sub-unit API; the result is
an empty array with a warnings entry explaining the platform limitation.
covenant github validate-token \
--scope-file scope.txt \
--enumerate-teams{
"scopes": ["repo", "read:org"],
"user": "necromancer",
"admin": false,
"token_type": "GitHub Classic PAT",
"token_note": "",
"token_type_confidence": "high",
"teams": [
{
"scope": "org",
"owner": "acme-corp",
"team_name": "acme-corp-sorcerers",
"team_slug": "acme-corp-sorcerers",
"description": "Core magic team",
"privacy": "secret",
"parent_slug": null
},
{
"scope": "org",
"owner": "acme-corp",
"team_name": "acme-corp-apprentices",
"team_slug": "acme-corp-apprentices",
"description": "Junior team",
"privacy": "closed",
"parent_slug": "acme-corp-sorcerers"
}
]
}Where --enumerate-members maps the people who share an org/group/workspace's
reach, --enumerate-collaborators is repo-scoped and surfaces the
higher-signal blast radius: the accounts granted access directly on a specific
repository rather than through org membership. These direct grants are the
classic ghost-account / ex-employee / leftover-contractor vector — a personal
account with standing access to a critical repo, invisible to an org-member audit,
that survives long after the person leaves. A write-or-above direct grant is a
direct supply-chain and persistence risk. Pass --enumerate-collaborators to
validate-token and covenant walks the repos the token can reach and, for each,
lists its per-repo grants with a read-only query, adding a collaborators
array:
| SCM | Endpoint | role / outside derived from |
|---|---|---|
| GitHub | GET /repos/{owner}/{repo}/collaborators?affiliation=outside |
the permissions map; every result is an outside collaborator |
| GitLab | GET /api/v4/projects/{id}/members (direct, non-/all) |
the numeric access_level; direct project members |
| Bitbucket | GET /2.0/repositories/{workspace}/{repo}/permissions-config/users |
the explicit permission (admin/write/read) |
Each entry is normalized to {"repo", "username", "role", "outside"}, where
role is the highest-privilege level (admin > maintain > write >
triage > read) and outside is true for these direct, outside-style grants
(the ghost-account signal). GitHub fetches with affiliation=outside so the result
set is the outside collaborators; GitLab's non-/all members endpoint returns
grants made on the project itself (excluding those inherited from a group); and
Bitbucket's explicit per-repo user-permission config is the equivalent direct
grant. Endpoints that require repo-admin (Bitbucket) fail soft for a
low-privilege token so the audit still reports the repos it can read.
Identity and permission level only — never a credential. covenant surfaces the collaborator's username and access level; it never reads or echoes an email, SSH/GPG key, or any token, and the audit never grants or revokes access.
The walk is bounded by the same --max-pages flag and honors the scope guardrail
and automatic rate-limit retry/backoff. Like the other --enumerate-*/--audit-*
flags the feature is purely additive (all may be combined): without the flag the
output is unchanged, and with it the v0.1 scopes/user/admin fields are
untouched — only the collaborators array is added.
covenant github validate-token \
--scope-file scope.txt \
--enumerate-collaborators \
--token-env COVENANT_TOKENExample collaborators array (a writable outside collaborator — a ghost account
with standing push access — alongside a read-only auditor):
{
"collaborators": [
{ "repo": "acme-corp/spellbook", "username": "ex-contractor", "role": "write", "outside": true },
{ "repo": "acme-corp/grimoire", "username": "auditor", "role": "read", "outside": true }
]
}--scan-secrets (on recon-code) only ever sees the current file content the
code-search API returns. But a credential scrubbed from a tracked file routinely
survives verbatim in the commit history — in a git commit -m "fix: rotate to AKIA..." subject, in a revert/merge body that quotes a diff, or in an automated
bump commit that echoes a token. Commit messages are a notorious, overlooked leak
vector. --scan-commits is the recon analogue for that history: pass it to
validate-token and covenant walks the recent commits the token can read on each
reachable repo, scanning every commit message for leaked credentials with the
same necromancer-patterns
engine that powers --scan-secrets, and adds a commit_findings array.
| SCM | Commit-list endpoint | author derived from |
|---|---|---|
| GitHub | GET /repos/{owner}/{repo}/commits |
the commit author's account login |
| GitLab | GET /api/v4/projects/{id}/repository/commits |
the commit's author_name |
| Bitbucket | GET /2.0/repositories/{workspace}/{repo}/commits |
the author's display nickname |
Only commits whose message actually matched a pattern are surfaced. Each entry
is normalized to {"repo", "sha", "author", "secret_findings"}, where
secret_findings is the standard scan-finding list (same shape as
--scan-secrets). Requires the scan extra (pip install 'covenant[scan]'), and
honors --pattern-set to narrow the rule bundle (e.g. aws) exactly as
recon-code does.
Share-safe by default, like
--scan-secrets. Each discovered secret is emitted as a redacted fingerprint (AKIA…[20 chars, sha256:…]) so covenant's own output never becomes a new place the live credential leaks. Pass--show-commit-secretsto emit the full raw value (use with care). The commit diff/patch is never fetched and no author email is surfaced — covenant maps the leak surface in history without dumping repository content.
The walk is bounded by the same --max-pages flag and honors the scope guardrail
and automatic rate-limit retry/backoff. Like the other --enumerate-*/--audit-*
flags the feature is purely additive (all may be combined): without the flag the
output is unchanged, and with it the v0.1 scopes/user/admin fields are
untouched — only the commit_findings array is added.
covenant github validate-token \
--scope-file scope.txt \
--scan-commits \
--token-env COVENANT_TOKENExample commit_findings array (a commit whose message leaked an AWS key, with the
secret redacted by default):
{
"commit_findings": [
{
"repo": "acme-corp/spellbook",
"sha": "a1b2c3d",
"author": "ex-contractor",
"secret_findings": [
{
"rule_id": "aws-access-key-id",
"secret": "AKIA…[20 chars, sha256:9f3a]",
"fragment_index": 0
}
]
}
]
}GitHub repo recon:
covenant github recon-repo \
--scope-file scope.txt \
--query "internal-secrets" \
--token-env COVENANT_TOKENGitLab repo recon (self-hosted via --target-url):
covenant gitlab recon-repo \
--scope-file scope.txt \
--query "internal-secrets" \
--target-url https://gitlab.acme-corp.example \
--token-env COVENANT_TOKENBitbucket token validation:
covenant bitbucket validate-token \
--scope-file scope.txt \
--token-env COVENANT_TOKENExample recon-repo output:
{
"scm": "github",
"query": "internal-secrets",
"results": [
{
"name": "acme-corp/internal-secrets",
"visibility": "private",
"url": "https://github.com/acme-corp/internal-secrets",
"description": "..."
}
]
}pip install -e ".[dev]"
pytestThe test suite stands up in-process mock SCM API servers on ephemeral ports
(socket.bind(('', 0))) so end-to-end smoke tests run with no live API calls.
- Privilege-escalation modules that modify SCM state (gated behind an explicit destructive-action authorization flag)
- Persistence modules (PAT / SSH-key planting)
- GitHub Enterprise Server specifics
- Webhook and CI/CD pipeline modules
See LICENSE. Prior-art attribution in NOTICE.