Skip to content

Feat (ci-rules) : Add AZ-CI-001..004 CI/CD workflow security rules (issue #259 PR 1/3) - #280

Open
emon22-ts wants to merge 1 commit into
openshield-org:devfrom
emon22-ts:feat/259-ci-workflow-rules
Open

Feat (ci-rules) : Add AZ-CI-001..004 CI/CD workflow security rules (issue #259 PR 1/3)#280
emon22-ts wants to merge 1 commit into
openshield-org:devfrom
emon22-ts:feat/259-ci-workflow-rules

Conversation

@emon22-ts

Copy link
Copy Markdown
Collaborator

What does this PR do?

Implements controls 1–4 from issue #259 (DevSecOps and supply-chain security). Adds a GitHub API client, shared workflow YAML helpers, four scanner rules, remediation playbooks, and 46 tests.

Type of change

  • New scan rule
  • Remediation playbook
  • CI and Testing
  • Compliance mapping

Rules added

Rule Severity Control
AZ-CI-001 HIGH Long-lived Azure credentials instead of workload identity federation
AZ-CI-002 HIGH Unnecessarily broad workflow token permissions
AZ-CI-003 HIGH Third-party action not pinned to immutable commit SHA
AZ-CI-004 HIGH Untrusted PR input reaches privileged workflow context

New files

  • scanner/github_client.py — GitHub App installation auth with GITHUB_TOKEN PAT fallback, returns None on missing permissions (UNKNOWN handling)
  • scanner/rules/_workflow_common.py — shared YAML parsing, action pinning detection, permission analysis, dangerous trigger detection
  • scanner/rules/az_ci_001.py — long-lived credential detection
  • scanner/rules/az_ci_002.py — broad token permission detection
  • scanner/rules/az_ci_003.py — unpinned action detection
  • scanner/rules/az_ci_004.py — pwn-request pattern detection
  • playbooks/cli/fix_az_ci_001..004.sh — remediation playbooks
  • tests/test_rules_ci_workflow.py — 46 tests

Compliance mappings

  • CIS: N/A-CI-001..004 (no verified CIS Azure Benchmark number for CI/CD controls)
  • NIST: PR.AC-1, PR.AC-4, PR.IP-1 (evidence-backed per OpenSSF Scorecard, SLSA)
  • ISO 27001: A.9.2.4, A.9.2.3, A.12.1.2, A.14.2.5
  • SOC2: CC6.1, CC6.3, CC7.1, CC8.1

Testing

  • 46 tests passing — secure/insecure YAML, action pinning, pwn-request patterns, empty repos, unsupported providers, malformed YAML, permission failures
  • No hardcoded credentials or secrets
  • ruff check and ruff format clean

Related issue

Refs #259 (PR 1 of 3 - controls 1 - 4)

Comment thread scanner/github_client.py Fixed
Comment thread scanner/github_client.py Fixed
@emon22-ts
emon22-ts force-pushed the feat/259-ci-workflow-rules branch 2 times, most recently from 761774e to cb168e8 Compare August 18, 2026 12:25

@TFT444 TFT444 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks solid structure overall, the N/A-CI-* convention is right, tests are thorough, and the NIST mapping fix (PR.DS-5 → PR.AC-4) looks good. All CI is green. However there is one blocking integration bug plus a few correctness gaps that need fixing before merge:

Blocking

1. The four rules are loaded by the scan engine but can never run.
engine.py glob-loads every az_*.py in scanner/rules/ and calls rule.scan(self.client, self.subscription_id) with an AzureClient (engine.py:71, engine.py:114). The AZ-CI rules declare scan(github_client, owner, repo) three required params so every production scan raises TypeError for all four rules, incrementing RULE_ERRORS_TOTAL and logging 4 errors per scan, while the rules silently never execute. Even if the wiring lands in PR 3/3, this PR must either exclude az_ci_* from the engine loader or provide an engine-compatible adapter, otherwise every scan between the two merges is polluted.

Should fix

2. github_client.py get_workflows() does not paginate.
/repos/{owner}/{repo}/actions/workflows returns 30 items per page by default. Repos with >30 workflows are silently truncated → false negatives on exactly the large repos this feature targets. Follow the Link header or pass per_page=100 + loop.

3. _workflow_common.py collect_uses() misses job-level uses: and crashes on empty jobs.
Reusable workflow calls (jobs.<id>.uses) are never collected, so AZ-CI-003 can't flag an unpinned reusable workflow. Also jobs: {build: null}None.get("steps")AttributeError.

4. az_ci_001.py OIDC anywhere in the file suppresses the long-lived-credential finding.
A workflow using azure/login with OIDC and separately referencing ARM_ACCESS_KEY passes clean. The suppression should require the credential to be absent, not merely OIDC to be present. Related: uses_workload_identity() matches the literal string "id-token: write", which breaks on extra spacing or quoted YAML — check the parsed permissions dict instead.

Nits

**5. github_client.py get_workflow_content():** for files >1 MB the contents API returns an empty contentfield; the code decodes""and caches it as valid content, so an oversized workflow scans as compliant instead of UNKNOWN. Treat emptycontentwith a presentdownload_url`/size as a failure.

6. tests/test_rules_ci_workflow.py test_no_token_returns_none_from_get_token: assert token is None or isinstance(token, str) is always true; assert token is None under the cleared env instead.

@emon22-ts
emon22-ts force-pushed the feat/259-ci-workflow-rules branch from 612156a to 81936b9 Compare August 20, 2026 00:58
@emon22-ts

Copy link
Copy Markdown
Collaborator Author

Hi @TFT444 @Vishnu2707 @H-Sihan , all 6 review items addressed:

  1. Blocking - engine.py now excludes az_ci_* from the AzureClient loader with an explicit startswith("az_ci_") guard and debug log
  2. get_workflows() now passes per_page=100 to avoid 30-item truncation
  3. collect_uses() handles job-level uses (reusable workflows) and null job definitions safely
  4. az_ci_001 suppression now requires the specific credential to be absent, not just OIDC to be present; uses_workload_identity() checks parsed permissions dict instead of string-matching
  5. Large file handling — empty content with download_url present is treated as UNKNOWN
  6. test_no_token_returns_none now asserts token is None under a fully cleared env

46 tests passing, ruff clean, single signed commit.

Ready for re-review. Thank you!

@TFT444 TFT444 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@emon22-ts good progress: engine exclusion, collect_uses hardening, the 1 MB guard, and the token test are all properly fixed. One blocker remains:

AZ-CI-001 OIDC suppression is still broken, and the new comment claims otherwise. az_ci_001.py says "A workflow using OIDC but also referencing ARM_ACCESS_KEY still fails", but uses_workload_identity() checks only _LONGTERM_CRED_PATTERNS[0] (AZURE_CLIENT_SECRET). OIDC + ARM_ACCESS_KEY/ARM_CLIENT_SECRET/AZURE_STORAGE_KEY/AZURE_PASSWORD still passes clean. Make the suppression require no credential match at all (e.g. gate on not creds in the scan, or check every pattern), and add a regression test for OIDC + ARM_ACCESS_KEY, since the absence of that test is exactly why this slipped.

Smaller items, same push: get_workflows() sends per_page=100 but still doesn't follow the Link header (docstring says "Paginates"; >100 workflows still truncate), uses_workload_identity() ignores job-level permissions so job-scoped id-token: write false-positives, and the four framework JSONs lost their trailing newlines.

Fix the AZ-CI-001 suppression and this is mergeable from my side.

@emon22-ts
emon22-ts force-pushed the feat/259-ci-workflow-rules branch from 81936b9 to 2f9482e Compare August 22, 2026 12:02
@emon22-ts

Copy link
Copy Markdown
Collaborator Author

Hi @TFT444 @Vishnu2707 , all remaining items addressed:

  1. AZ-CI-001 suppression fixed - uses_workload_identity() now checks ALL _LONGTERM_CRED_PATTERNS (not just [0]), so OIDC + ARM_ACCESS_KEY/ARM_CLIENT_SECRET/AZURE_STORAGE_KEY/AZURE_PASSWORD all still flag correctly
  2. Regression test added - test_oidc_with_arm_access_key_still_fails verifies OIDC + ARM_ACCESS_KEY returns exactly 1 finding
  3. get_workflows() now follows Link header for full pagination beyond 100 workflows
  4. uses_workload_identity() job-level permissions - suppression gates on parsed dict, not string match
  5. Trailing newlines restored on all 4 compliance JSON files

47 tests passing, ruff clean, single signed commit.

Ready for final review. Thank you!

@parthrohit22 parthrohit22 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Architecturally this is sound: github_client.py matches the codebase's existing try/except -> logger.error -> None-means-UNKNOWN convention exactly, no RULE_ID collisions against the ~90 existing rules, all 4 playbooks exist and pass bash -n, compliance framework JSON additions match the existing schema, and the YAML-1.1 on: parsing as boolean True is handled correctly (workflow.get("on") or workflow.get(True)) - an easy, common mistake in GitHub Actions tooling that this PR avoids.

But for a compliance scanner, misclassifying a secure config as a violation is a correctness bug, not a nit, and I confirmed 4 real false-positive paths by feeding realistic workflow YAML directly into each rule's scan() rather than just reading the code - all 4 are in code this PR adds. Left inline comments on each with the specific repro.

One more, documented as intentional rather than an oversight (az_ci_003.py's REMEDIATION explicitly calls Docker actions exempt) but worth a decision either way: is_action_pinned unconditionally treats every docker:// reference as pinned, so uses: docker://alpine:latest (a mutable tag) is never flagged - confirmed with a direct repro (0 findings). This is a real gap against the rule's own stated threat model, and OpenSSF Scorecard's Pinned-Dependencies check (cited as this PR's own compliance evidence source) does flag it. Worth confirming whether this is deliberate scope-narrowing for 1/3 or should require @sha256:... digests for Docker actions too.

Also flagging for visibility, not blocking: scanner/engine.py explicitly excludes az_ci_* from the automatic rule loader with a comment claiming a separate CI/CD scan entry point invokes them - I grepped the whole repo (api/, scanner/worker.py, everything) and no such entry point exists anywhere yet, on this branch or dev. Not a bug (nothing crashes), but since this is titled "1/3," worth confirming reviewers know this ships tested logic with no way to actually run it in the product until a follow-up wires it up. Separately, TestGitHubClient only covers trivial success/failure - the Link-header pagination in get_workflows, the GitHub App JWT-auth flow in _get_app_token, and the >1MB download_url branch in get_workflow_content have no coverage, worth closing before 2/3 builds on top of this client.

Full suite (after installing this environment's missing azure-*/prometheus-client/python-dotenv packages, an environment gap unrelated to the PR): 758 passed, 3 skipped. tests/test_rules_ci_workflow.py: 47/47 passed - the existing tests don't catch any of the 4 false positives below because none of them construct a workflow that's secure-by-a-more-specific-mechanism-than-the-naive-check (safe pull_request_target via base-ref checkout, job-level OIDC alongside an unrelated job's real secret, a credential name only in a comment, or per-job explicit least-privilege with no top-level default).

Comment thread scanner/rules/az_ci_004.py Outdated

def _checks_out_pr_code(content: str) -> bool:
"""Return True if the workflow appears to check out PR branch code."""
return any(p in content for p in _CHECKOUT_PATTERNS) and (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only checks whether actions/checkout and github.event.pull_request.head/head_ref co-occur anywhere in the raw file text - it never checks whether the checkout step's ref: is actually set to the untrusted value. Repro: a pull_request_target workflow that checks out the base ref (safe - no ref: override) and only interpolates github.event.pull_request.head.sha into a PR comment (exactly GitHub's own documented-safe mitigation) still gets flagged HIGH. test_safe_prt_without_checkout_no_finding only covers the trivial no-checkout-at-all case, so this gap isn't caught.

Failure scenario: any label/triage/comment workflow on pull_request_target that references head.sha for display purposes gets a false HIGH "pwn-request" finding.

Comment thread scanner/rules/_workflow_common.py Outdated
if parsed is not None:
perms = parsed.get("permissions") or {}
if isinstance(perms, dict):
has_oidc_permission = perms.get("id-token", "").lower() == "write"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only reads the top-level permissions block. Job-level permissions: (GitHub's own recommended least-privilege pattern) is invisible here. Repro: a deploy job with permissions: id-token: write set at the job level, using azure/login with no client-secret (genuine OIDC), alongside an unrelated tf-state job that legitimately uses ARM_ACCESS_KEY for a Terraform remote-state backend - az_ci_001.py still emits a false HIGH "long-lived credential" finding on the deploy job because uses_workload_identity can't see the job-level grant that makes it real OIDC.

Comment thread scanner/rules/_workflow_common.py Outdated
has_azure_login = "azure/login" in yaml_content
# Check ALL long-lived credential patterns — OIDC suppression requires
# every credential to be absent, not just AZURE_CLIENT_SECRET.
has_any_long_lived_cred = any(p.search(yaml_content) for p in _LONGTERM_CRED_PATTERNS)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This scans the raw YAML text, including comments. Repro: a workflow with proper top-level id-token: write + azure/login (no secret) and no long-lived credential actually used, but with a migration comment like # Old workflow used ARM_CLIENT_SECRET, replaced by federated identity below. - the credential-name regex matches inside the comment, has_any_long_lived_cred becomes true, and az_ci_001 flags it HIGH even though the workflow is genuinely OIDC-only. Any team that leaves a changelog-style comment when migrating to OIDC (a natural thing to do) hits this.


def is_permissions_broad(permissions: Optional[Dict[str, str]]) -> bool:
"""Return True if permissions grant write-all or multiple broad scopes."""
if permissions is None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An absent top-level permissions: block is unconditionally treated as broad here, even when every individual job explicitly restricts its own permissions - which is itself a real, commonly-recommended secure pattern (explicit per-job least privilege instead of a workflow-level default). Repro: a workflow with no top-level permissions block but where every job declares permissions: contents: read still gets flagged HIGH by az_ci_002, which directly contradicts that rule's own REMEDIATION text ("Override per-job only where broader access is needed") - the rule's own guidance describes a pattern its own detection logic flags as a violation.

@emon22-ts
emon22-ts force-pushed the feat/259-ci-workflow-rules branch 2 times, most recently from eccdc5a to 51ad175 Compare August 26, 2026 01:11
@emon22-ts

Copy link
Copy Markdown
Collaborator Author

Hi @TFT444 @parthrohit22 @Vishnu2707 , all 4 false positives fixed with regression tests:

1.FP1 (AZ-CI-004) - _checks_out_pr_code now uses the parsed workflow dict and checks whether the checkout step's ref: value actually contains a PR head reference. A pull_request_target workflow that checks out the base ref and only references head.sha in a run step (GitHub's documented-safe pattern) no longer flags.

2.FP2 (AZ-CI-001)- uses_workload_identity() now checks both top-level and job-level permissions blocks for id-token: write, so a deploy job with per-job OIDC permissions alongside other jobs is correctly recognised as OIDC.

3.FP3 (AZ-CI-001) - has_long_lived_credentials() now strips YAML comment lines before scanning, so a migration comment like # Old workflow used ARM_CLIENT_SECRET no longer triggers a finding.

4.FP4 (AZ-CI-002) - is_permissions_broad() now returns False when the top-level permissions block is absent but every job declares explicit permissions - per-job least privilege is correctly recognised as secure.

Each fix has a dedicated regression test. 51 tests passing, ruff clean, single signed commit.

On the docker:// pinning question: treating docker:// as always-pinned is intentional scope-narrowing for PR 1/3 - the REMEDIATION text already notes this. Will track docker @sha256: digest checking as a follow-up in PR 2/3 or 3/3.

Ready for final review. Thank you!

TFT444
TFT444 previously approved these changes Aug 28, 2026

@TFT444 TFT444 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good to go

@m-khan-97

Copy link
Copy Markdown
Collaborator

@emon22-ts, thanks for the false-positive fixes. Before I re-review them, please rebase onto current dev and rerun the complete CI suite; this head is now well behind the integration branch. Please ping me with the new head SHA once it is ready.

openshield-org#259 PR 1/3)

Implements controls 1-4 from issue openshield-org#259:
- AZ-CI-001: Long-lived Azure credentials instead of workload identity federation
- AZ-CI-002: Unnecessarily broad workflow token permissions
- AZ-CI-003: Third-party action not pinned to immutable commit SHA
- AZ-CI-004: Untrusted PR input reaches privileged workflow context

False positive fixes:
- FP1 az_ci_004: _checks_out_pr_code uses parsed dict, checks checkout ref: value
- FP2 az_ci_001: uses_workload_identity checks job-level permissions blocks
- FP3 az_ci_001: credential scan excludes YAML comment lines
- FP4 az_ci_002: absent top-level permissions not broad when all jobs declare explicit perms

New files:
- scanner/github_client.py: GitHub App + PAT auth, Link-header pagination, >1MB guard
- scanner/rules/_workflow_common.py: shared YAML helpers
- scanner/rules/az_ci_001..004.py: four scanner rule modules
- playbooks/cli/fix_az_ci_001..004.sh: remediation playbooks
- tests/test_rules_ci_workflow.py: 51 tests including 4 FP regression tests

ci.yml: add chromadb CVE-2026-45830 and CVE-2026-45833 to pip-audit ignore list
Engine: az_ci_* excluded from AzureClient loader.
Compliance: CIS N/A-CI-*, NIST, ISO 27001, SOC2 with trailing newlines.

Refs openshield-org#259

Signed-off-by: Mahfuzur Rahman Emon <mahfuzur.emon01@gmail.com>
@emon22-ts

Copy link
Copy Markdown
Collaborator Author

@m-khan-97 Hi sir , Rebased onto current dev and CI is running. Head SHA: 1a7db10

Merge conflicts in all 4 compliance framework JSONs were resolved — upstream controls preserved and AZ-CI-001..004 mappings retained (110 controls each).

@parthrohit22 @TFT444 ready for re-review

@m-khan-97 m-khan-97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Emon, the four reported false-positive fixes survived the rebase: PR-head detection is scoped to checkout ref, job-level OIDC is recognized, comment-only credential names are ignored, and fully job-scoped permissions no longer fail merely because the top-level block is absent. CI is green.

I found one remaining permission-model false negative in AZ-CI-002. is_permissions_broad() treats an absent top-level block as safe whenever every job merely has a non-null permissions field; it never evaluates what those job-level values grant. Therefore a workflow where every job declares permissions: write-all, or three sensitive *: write scopes, returns compliant. Likewise, a harmless top-level contents: read plus a job-level permissions: write-all is never examined because only the top-level block is passed into the check. Those are exactly the over-broad token permissions this rule claims to detect.

Please evaluate the effective permissions at both levels: flag write-all/write and the broad-scope threshold in any job override, while preserving the valid per-job least-privilege case. Add regression tests for (1) no top-level block with every job declaring write-all, (2) safe top-level permissions overridden by one broad job, and (3) multiple jobs with narrow explicit permissions remaining clean. Then rerun the suite and request rereview.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants