Feat (ci-rules) : Add AZ-CI-001..004 CI/CD workflow security rules (issue #259 PR 1/3) - #280
Feat (ci-rules) : Add AZ-CI-001..004 CI/CD workflow security rules (issue #259 PR 1/3)#280emon22-ts wants to merge 1 commit into
Conversation
761774e to
cb168e8
Compare
There was a problem hiding this comment.
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.
612156a to
81936b9
Compare
|
Hi @TFT444 @Vishnu2707 @H-Sihan , all 6 review items addressed:
46 tests passing, ruff clean, single signed commit. Ready for re-review. Thank you! |
TFT444
left a comment
There was a problem hiding this comment.
@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.
81936b9 to
2f9482e
Compare
|
Hi @TFT444 @Vishnu2707 , all remaining items addressed:
47 tests passing, ruff clean, single signed commit. Ready for final review. Thank you! |
parthrohit22
left a comment
There was a problem hiding this comment.
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).
|
|
||
| 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 ( |
There was a problem hiding this comment.
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.
| if parsed is not None: | ||
| perms = parsed.get("permissions") or {} | ||
| if isinstance(perms, dict): | ||
| has_oidc_permission = perms.get("id-token", "").lower() == "write" |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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.
eccdc5a to
51ad175
Compare
|
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! |
|
@emon22-ts, thanks for the false-positive fixes. Before I re-review them, please rebase onto current |
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>
51ad175 to
1a7db10
Compare
|
@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
left a comment
There was a problem hiding this comment.
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.
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
Rules added
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 detectionscanner/rules/az_ci_001.py— long-lived credential detectionscanner/rules/az_ci_002.py— broad token permission detectionscanner/rules/az_ci_003.py— unpinned action detectionscanner/rules/az_ci_004.py— pwn-request pattern detectionplaybooks/cli/fix_az_ci_001..004.sh— remediation playbookstests/test_rules_ci_workflow.py— 46 testsCompliance mappings
Testing
Related issue
Refs #259 (PR 1 of 3 - controls 1 - 4)