postura

command module
v0.2.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 21, 2026 License: MIT Imports: 1 Imported by: 0

README

postura

A deterministic CLI that audits your GitHub enterprise, orgs, and repos
against a security baseline you own — rules are data, the bar is per-target.

Go Reference Go Report Card Test Release MIT License


postura audits a GitHub organization's security posture — branch protection, 2FA enforcement, secret scanning, outside collaborators, org-role grants, and more — against a per-target-configurable policy baseline. Rules are data (CEL expressions in YAML); the same ruleset can hold a different bar for each enterprise, org, or repo. It is read-only — it never mutates anything on GitHub — and deterministic, so it gates cleanly in CI.

$ postura run --org acme --enterprise acme-inc --rules examples/rules.yaml
# GitHub audit

_3 fail, 1 unknown across 12 repos + org + enterprise_

### ORG-1 — Two-factor authentication is enforced org-wide (error)

_1 fail, 0 unknown of 1 checked_

| Target | Status  | Notes                                   |
| ------ | ------- | --------------------------------------- |
| acme   | ❌ fail | two_factor_requirement_enabled is false |

**Fix:** Org Settings → Authentication security: require two-factor authentication

### REPO-1 — Default branch requires a pull request before merging (error)

_2 fail, 0 unknown of 12 checked_

| Target   | Status  | Notes                                  |
| -------- | ------- | -------------------------------------- |
| acme/web | ❌ fail | required_approving_review_count is 0   |
| acme/api | ❌ fail | no branch protection on default branch |

**Fix:** Settings → Branches: require a pull request before merging

### ENT-1 — SAML single sign-on is configured for the enterprise (error)

_0 fail, 1 unknown of 1 checked_

| Target   | Status     | Notes                                             |
| -------- | ---------- | ------------------------------------------------- |
| acme-inc | ⚠️ unknown | needs an enterprise-owner token (read:enterprise) |

Why postura

Tools like legitify apply a global policy — you can disable a check everywhere, but not hold a different bar for different targets. OSSF Scorecard does take per-repo config, but it's decentralized (a .github/scorecard.yml each repo owns) and scoped to OSS/supply-chain repo health — not org/enterprise settings like 2FA enforcement, outside collaborators, or org-role grants.

postura's difference is centralized, layered per-target configuration: one ruleset a security team operates, where acme may allow 8 owners while acme-labs allows 3, a legacy repo can be exempted from a rule, and enterprise + org + repo all audit from the same policy — no forking, no per-repo file to trust.

Judgment calls (admin-count proportion, acceptable outside collaborators, allowed broad org-roles) are expressed as tunable thresholds and allowlists, so the audit stays fully deterministic and CI-gateable.

postura
Read-only Never writes to GitHub — pure audit
Rules are data CEL expressions in YAML, compiled at load (typos fail fast)
Per-target bar Same ruleset, different thresholds per enterprise / org / repo
Honest about gaps A fact it can't read → unknown, never a false pass
CI-native --fail-on gate + SARIF output for code scanning
Three scopes Enterprise account settings, org settings, per-repo posture

Install

go install github.com/jackchuka/postura@latest

Or grab a prebuilt binary from Releases (Linux / macOS / Windows, amd64 + arm64).

Authentication

postura needs a GitHub token via GITHUB_TOKEN / GH_TOKEN, or a logged-in gh (gh auth token is used as a fallback).

Scope of audit Token requirement
Repo settings repo read
Org settings, owners, roles admin:org read
Enterprise account settings enterprise-owner token (read:enterprise)

Without the right scope, the relevant facts report as unknown rather than a false pass.

Usage

postura run --rules R [owner/repo ...]    # collect + evaluate + report (the common case)
postura collect [owner/repo ...]          # write facts as JSON (cache / offline eval)
postura eval --rules R <facts.json>       # evaluate a facts file — pure, no network
postura rules list --rules R              # list every rule
postura rules explain --rules R <id>      # show a rule's CEL, config knobs, and fix hint

--rules is required for every command except collect, which only gathers facts: collect writes facts; eval and run apply rules to them. postura ships no built-in ruleset — you always pass your own. A generic GitHub security baseline you can copy and tune lives in examples/rules.yaml.

With no repo arguments, every non-forked repo in the org is audited. Pass owner/repo tokens to scope to specific repos. --org is repeatable, so several orgs can be audited in one run.

Flags

Shared by run and eval:

Flag Default Meaning
--config policy-config YAML with per-target overrides
--rules — (required) ruleset YAML (see examples/rules.yaml)
--format md md | json | sarif
--fail-on error (run), none (eval) exit non-zero on a fail at/above this severity (error|warn|info|none)

Shared by run and collect (the commands that talk to GitHub):

Flag Default Meaning
--org org(s) to audit — repeatable/comma-separated, or inferred from owner/repo args; optional for an enterprise-only audit
--enterprise enterprise slug to also audit (needs an enterprise-owner token)
--scope all applicable restrict to scopes: enterprise | org | repos (repeatable, e.g. --scope org,repos)

eval takes its org/enterprise from the facts file, so it has none of the GitHub-facing flags above. SARIF output is uploadable to GitHub code scanning and usable as audit evidence.

Examples

# Enterprise + org + every repo (default scope = all applicable)
postura run --org acme --enterprise acme-inc --rules examples/rules.yaml

# Several orgs in one run (repeatable or comma-separated --org)
postura run --org acme --org acme-labs --rules examples/rules.yaml
postura run --org acme,acme-labs --rules examples/rules.yaml

# Enterprise only — no --org needed (scope defaults to enterprise)
postura run --enterprise acme-inc --rules examples/rules.yaml

# Org-level checks only (skip the per-repo sweep)
postura run --org acme --scope org --rules examples/rules.yaml

# Specific repos (org inferred from the args), audited alongside the enterprise
postura run --enterprise acme-inc acme/web acme/api --rules examples/rules.yaml

# Collect enterprise facts to JSON, then evaluate offline
postura collect --enterprise acme-inc --scope enterprise > facts.json
postura eval --rules examples/rules.yaml facts.json

# CI gate: fail on any warn-or-worse, emit SARIF for code scanning
postura run --org acme --enterprise acme-inc --scope enterprise,org \
  --fail-on warn --format sarif --rules examples/rules.yaml > postura.sarif

--scope is repeatable and comma-separated (--scope org,repos == --scope org --scope repos). With no --scope, postura audits everything nameable: org + its repos when an org is given, plus the enterprise when --enterprise is given. Each scope needs its input — so --scope enterprise without --enterprise (or --scope org with no org) is an error.

An enterprise is audited only for its account-level settings; it does not enumerate the orgs beneath it. Audit those by naming them with --org — repos auto-expand from an org, but orgs are named entities.

The ruleset

You supply the ruleset with --rules; postura embeds none. Each rule's pass is a CEL expression over the scope's fact fields (bound as top-level variables) plus cfg (the rule's merged config):

- id: ORG-2
  scope: org
  severity: warn
  title: Owner/admin count is proportionate to org size
  config:
    max_admins: 5 # overridable per enterprise/org/repo
    max_admin_ratio: 0.25
  pass: >-
    admin_count <= cfg.max_admins &&
    (member_count == 0 || double(admin_count) / double(member_count) <= cfg.max_admin_ratio)
  fix_hint: "Org Settings → People: review who holds Owner"
  • severityerror | warn | info. Drives report ordering, the --fail-on gate, and the SARIF level (error → error, warn → warning, info → note).
  • applies_when — scope a rule to matching targets (e.g. public repos only).
  • enabled: false — off by default; opt in per target via config.

The whole ruleset is CEL-compiled at load, so a typo'd field name (an undeclared variable) fails fast there. When a fact a rule needs can't be read (e.g. missing token scope), the collector omits it and postura reports unknown (⚠️) for that target automatically — no rule annotation needed.

Per-target configuration

A policy-config file (--config) overrides rule settings, merged most-specific-wins: rule default → enterprise → org → repo.

# config.yaml
orgs:
  acme:
    ORG-2: { max_admins: 8 } # raise the bar
    ORG-3: { allowed_collaborators: [contractor-x] } # allowlist → deterministic
  acme-labs:
    REPO-8: { enabled: true } # opt into an off-by-default rule
repos:
  acme/legacy-thing:
    REPO-1: { enabled: false } # exempt one repo

Within a rule's block, enabled (bool) and severity (string) tune the rule; every other key is merged into its cfg.

Facts available to rules

Rules read these fields (bound as top-level CEL variables), grouped by scope. The source of truth is internal/collect/schema.go; a test fails if this list drifts from it. A field that couldn't be read is omitted (→ the rule reports unknown).

Enterprise scope
Field Meaning
enterprise enterprise slug (the audited target)
saml_sso_url configured SAML SSO URL, or null
default_repository_permission enterprise base repo permission (NONE/READ/WRITE/ADMIN/NO_POLICY)
ip_allow_list_enabled IP allow list setting (ENABLED/DISABLED)
allow_private_repository_forking private/internal forking policy (ENABLED/DISABLED/NO_POLICY)
outside_collaborators outside-collaborator logins
outside_collaborator_count number of outside collaborators
Org scope
Field Meaning
org org login (the audited target)
two_factor_requirement_enabled whether 2FA is enforced org-wide
default_repository_permission base member repo permission (none/read/write/admin)
members_can_create_repositories whether members may create repos
members_can_fork_private_repositories whether members may fork private/internal repos
secret_scanning_enabled_for_new_repositories new-repo default: secret scanning
secret_scanning_push_protection_enabled_for_new_repositories new-repo default: push protection
dependabot_alerts_enabled_for_new_repositories new-repo default: Dependabot alerts
admins org owner logins
admin_count number of org owners
members org member logins
member_count number of members
outside_collaborators outside-collaborator logins
outside_collaborator_count number of outside collaborators
teams org teams, each {slug, privacy}
organization_roles org-role assignments, each {role, teams, users}
invitations pending invitations, each {login, age_days}
Repo scope
Field Meaning
name owner/repo
owner owning org
archived whether the repo is archived
visibility public / private / internal
secret_scanning secret-scanning status (enabled/disabled)
secret_scanning_push_protection push-protection status (enabled/disabled)
vulnerability_alerts whether Dependabot alerts are enabled
dependabot_config whether a Dependabot version-updates config exists (.github/dependabot.yml)
renovate_config whether a Renovate config exists (renovate.json, .renovaterc*, or under .github/)
codeowners whether a CODEOWNERS file exists
license SPDX license id, or null
teams teams with a direct grant, each {slug, permission}
ruleset_count active branch rulesets on the default branch
protection branch-protection facts: {required_pull_request_reviews, required_approving_review_count, require_code_owner_reviews, pr_bypass_actor_types}

Note: dependabot_config / renovate_config detect only committed config. A repo onboarded to Renovate via a shared org preset (in the org's .github repo) or the Renovate App's default, with no repo-level file, is not detected.

How it works

            ┌───────────┐      ┌──────────────┐      ┌───────────────┐
 GitHub ──► │  collect  │ ───► │     eval     │ ───► │    report     │
 REST +     │  (facts)  │ json │ (CEL × cfg)  │      │ md/json/sarif │
 GraphQL    └───────────┘      └──────────────┘      └───────────────┘
                read-only        deterministic          + exit code

run chains all three. Splitting collect (the only networked step) from eval lets you cache facts and re-evaluate offline against different rulesets. Branch protection is read from both classic protection and repository rulesets, folding the stricter outcome.

License

MIT — see LICENSE.

Documentation

The Go Gopher

There is no documentation for this package.

Directories

Path Synopsis
Package cmd implements the postura CLI.
Package cmd implements the postura CLI.
internal
collect
Package collect gathers facts from the GitHub API into fully-normalized JSON objects.
Package collect gathers facts from the GitHub API into fully-normalized JSON objects.
engine
Package engine evaluates a ruleset against collected facts using CEL and produces findings.
Package engine evaluates a ruleset against collected facts using CEL and produces findings.
report
Package report renders engine findings as Markdown, JSON, or SARIF, and decides the process exit code from a --fail-on severity threshold.
Package report renders engine findings as Markdown, JSON, or SARIF, and decides the process exit code from a --fail-on severity threshold.
rules
Package rules loads and validates the postura policy ruleset.
Package rules loads and validates the postura policy ruleset.
version
Package version holds build-time version information, stamped by GoReleaser.
Package version holds build-time version information, stamped by GoReleaser.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL