diff --git a/.env.example b/.env.example
index e03fe41..ec0462b 100644
--- a/.env.example
+++ b/.env.example
@@ -12,3 +12,7 @@ GEMINI_API_KEY=
# Optional
NVD_API_KEY=
SENTRY_DSN=
+
+# Optional - enables AZ-SC-007/008 (Azure DevOps pipeline scanning)
+AZURE_DEVOPS_ORG_URL=
+AZURE_DEVOPS_PROJECT=
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index f5b031c..2da9e0d 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -26,6 +26,7 @@
Closes #
## Checklist
+- [ ] Every commit includes a DCO `Signed-off-by` trailer (`git commit -s`; see `docs/dco.md`)
- [ ] My code follows the rule template in CONTRIBUTING.md
- [ ] I added or updated the matching CLI playbook
- [ ] I added or updated all four compliance framework mappings
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8d47b1c..9374a64 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -595,7 +595,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
- node-version: "22"
+ node-version: "22.22.0"
cache: npm
cache-dependency-path: frontend/package-lock.json
@@ -608,6 +608,9 @@ jobs:
- name: Run aiSettings tests
run: node src/utils/aiApi.test.mjs
+ - name: Run accessibility and internationalization checks
+ run: npm run test:a11y && npm run test:i18n
+
- name: Build
run: npm run build
diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml
new file mode 100644
index 0000000..ac25e31
--- /dev/null
+++ b/.github/workflows/dco.yml
@@ -0,0 +1,24 @@
+name: Developer Certificate of Origin
+
+on:
+ pull_request:
+ branches: [dev, main]
+
+permissions:
+ contents: read
+
+jobs:
+ signoff:
+ name: DCO sign-off
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout pull request history
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ with:
+ fetch-depth: 0
+
+ - name: Verify every pull request commit
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: python scripts/check_dco.py "$BASE_SHA" "$HEAD_SHA"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6db3909..8661e9b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,21 +1,85 @@
-name: Release
+name: Signed Release
on:
push:
tags:
- - 'v*'
+ - "v*"
permissions:
contents: write
+ id-token: write
+ attestations: write
jobs:
release:
runs-on: ubuntu-latest
+ env:
+ TAG: ${{ github.ref_name }}
steps:
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ - name: Checkout signed tag
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
- - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
+
+ - name: Verify annotated tag signature
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ tag_object=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq '.object.sha')
+ object_type=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq '.object.type')
+ if [ "$object_type" != "tag" ]; then
+ echo "Release tags must be signed annotated tags; ${TAG} is ${object_type}."
+ exit 1
+ fi
+ verified=$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object}" --jq '.verification.verified')
+ if [ "$verified" != "true" ]; then
+ echo "GitHub could not verify the signature on ${TAG}."
+ exit 1
+ fi
+
+ - name: Install Syft
+ env:
+ SYFT_VERSION: "1.46.0"
+ SYFT_SHA256: d654f678b709eb53c393d38519d5ed7d2e57205529404018614cfefa0fb2b5ca
+ run: |
+ set -euo pipefail
+ archive="syft_${SYFT_VERSION}_linux_amd64.tar.gz"
+ curl --fail --silent --show-error --location \
+ --output "$archive" \
+ "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${archive}"
+ echo "${SYFT_SHA256} ${archive}" | sha256sum --check --strict
+ sudo tar --extract --gzip --file "$archive" --directory /usr/local/bin syft
+
+ - name: Build deterministic release artifacts
+ run: |
+ set -euo pipefail
+ mkdir -p dist
+ syft dir:. --source-name openshield --source-version "$TAG" \
+ -o "cyclonedx-json=dist/openshield-${TAG}-sbom.cyclonedx.json"
+ git archive --format=tar --prefix="openshield-${TAG}/" "$TAG" | \
+ gzip --no-name > "dist/openshield-${TAG}.tar.gz"
+ cd dist
+ sha256sum "openshield-${TAG}.tar.gz" "openshield-${TAG}-sbom.cyclonedx.json" > SHA256SUMS
+
+ - name: Attest source archive
+ uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
+ with:
+ subject-path: dist/openshield-${{ env.TAG }}.tar.gz
+
+ - name: Attest SBOM
+ uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
+ with:
+ subject-path: dist/openshield-${{ env.TAG }}-sbom.cyclonedx.json
+
+ - name: Attest checksum manifest
+ uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
+ with:
+ subject-path: dist/SHA256SUMS
+
+ - name: Publish release
+ uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
generate_release_notes: true
make_latest: true
+ files: dist/*
diff --git a/.github/workflows/sbom-release.yml b/.github/workflows/sbom-release.yml
deleted file mode 100644
index 3748dba..0000000
--- a/.github/workflows/sbom-release.yml
+++ /dev/null
@@ -1,40 +0,0 @@
-name: SBOM Release
-
-# Attach a CycloneDX SBOM to each published GitHub Release (issue #156).
-on:
- release:
- types: [published]
-
-permissions:
- contents: write # required to upload assets to the release
-
-jobs:
- attach-sbom:
- name: Generate and attach SBOM
- runs-on: ubuntu-latest
- steps:
- - name: Checkout repository
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
-
- - name: Install syft
- env:
- SYFT_VERSION: "1.46.0"
- SYFT_SHA256: d654f678b709eb53c393d38519d5ed7d2e57205529404018614cfefa0fb2b5ca
- run: |
- SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"
- curl --fail --silent --show-error --location \
- --output "$SYFT_ARCHIVE" \
- "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${SYFT_ARCHIVE}"
- echo "${SYFT_SHA256} ${SYFT_ARCHIVE}" | sha256sum --check --strict
- sudo tar --extract --gzip --file "$SYFT_ARCHIVE" --directory /usr/local/bin syft
-
- - name: Generate SBOM
- env:
- TAG: ${{ github.event.release.tag_name }}
- run: syft dir:. --source-name openshield --source-version "$TAG" -o "cyclonedx-json=openshield-${TAG}-sbom.cyclonedx.json"
-
- - name: Upload SBOM to release
- env:
- GH_TOKEN: ${{ github.token }}
- TAG: ${{ github.event.release.tag_name }}
- run: gh release upload "$TAG" "openshield-${TAG}-sbom.cyclonedx.json" --clobber
diff --git a/.gitignore b/.gitignore
index 87074d6..03a8d80 100644
--- a/.gitignore
+++ b/.gitignore
@@ -219,3 +219,6 @@ __marimo__/
ai/vectorstore/
.vercel
.env*
+
+# Node (root package.json exists solely to track react-router for Dependabot)
+node_modules/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 408c9da..9f85576 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,7 @@ OpenShield uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Security
+- Upgraded cryptography to 50.0.0 to address CVE-2026-69247
- AI provider errors no longer expose upstream response details
- Request body limits, AI rate limiting, and playbook path validation added
- GitHub Actions dependencies pinned to immutable commit SHAs
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7c234f4..ebc3e55 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -50,13 +50,9 @@ from typing import Any, Dict, List
RULE_ID = "AZ-STOR-001"
RULE_NAME = "Public Blob Access Enabled on Storage Account"
-SEVERITY = "HIGH" # HIGH / MEDIUM / LOW / INFO
-CATEGORY = "Storage" # Storage / Network / Identity / Database / Compute / Key Vault / Kubernetes
-FRAMEWORKS = {
- "CIS": "3.5",
- "NIST": "PR.AC-3",
- "ISO27001": "A.9.4.1"
-}
+SEVERITY = "HIGH" # HIGH / MEDIUM / LOW / INFO
+CATEGORY = "Storage" # Storage / Network / Identity / Database / Compute / Key Vault / Kubernetes
+FRAMEWORKS = {"CIS": "3.5", "NIST": "PR.AC-3", "ISO27001": "A.9.4.1"}
DESCRIPTION = (
"Storage accounts with public blob access enabled allow anyone on the "
"internet to read data without authentication. This can lead to data "
@@ -72,20 +68,22 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]:
for account in azure_client.get_storage_accounts():
if getattr(account, "allow_blob_public_access", False):
- findings.append({
- "rule_id": RULE_ID,
- "rule_name": RULE_NAME,
- "severity": SEVERITY,
- "category": CATEGORY,
- "resource_id": account.id,
- "resource_name": account.name,
- "resource_type": "Microsoft.Storage/storageAccounts",
- "description": DESCRIPTION,
- "remediation": REMEDIATION,
- "playbook": PLAYBOOK,
- "frameworks": FRAMEWORKS,
- "metadata": {}
- })
+ findings.append(
+ {
+ "rule_id": RULE_ID,
+ "rule_name": RULE_NAME,
+ "severity": SEVERITY,
+ "category": CATEGORY,
+ "resource_id": account.id,
+ "resource_name": account.name,
+ "resource_type": "Microsoft.Storage/storageAccounts",
+ "description": DESCRIPTION,
+ "remediation": REMEDIATION,
+ "playbook": PLAYBOOK,
+ "frameworks": FRAMEWORKS,
+ "metadata": {},
+ }
+ )
return findings
```
diff --git a/README.md b/README.md
index 0d9c900..09ecd71 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,9 @@
[](https://opensource.org/licenses/MIT)
[](CHANGELOG.md)
+Release artifacts include SHA-256 checksums, an SBOM, and identity-bound
+provenance attestations. See [release verification](docs/release-verification.md).
+
> **Open source Cloud Security Posture Management (CSPM) for Azure - detect misconfigurations, map to CIS/NIST/ISO27001/SOC2, fix them with one command, and identify cryptographic assets requiring quantum-safe migration.**
[](https://github.com/openshield-org/openshield/stargazers)
diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json
index 78112c3..25146ec 100644
--- a/compliance/frameworks/cis_azure_benchmark.json
+++ b/compliance/frameworks/cis_azure_benchmark.json
@@ -287,6 +287,46 @@
"control_id": "TBD-IDN-015",
"control_name": "Managed Identity least privilege (not mapped in CIS Azure Foundations 2.0.0)",
"description": "Microsoft recommends least-privilege roles and scopes for managed identities. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark."
+ },
+ "AZ-SC-001": {
+ "control_id": "TBD-SC-001",
+ "control_name": "Container Registry Admin User Enabled placeholder",
+ "description": "Numbered placeholder pending maintainer approval of a direct CIS mapping."
+ },
+ "AZ-SC-002": {
+ "control_id": "TBD-SC-002",
+ "control_name": "Container Registry Public Network Access Enabled placeholder",
+ "description": "Numbered placeholder pending maintainer approval of a direct CIS mapping."
+ },
+ "AZ-SC-003": {
+ "control_id": "TBD-SC-003",
+ "control_name": "Container Registry Allows Anonymous Pull placeholder",
+ "description": "Numbered placeholder pending maintainer approval of a direct CIS mapping."
+ },
+ "AZ-SC-004": {
+ "control_id": "TBD-SC-004",
+ "control_name": "Container Registry Missing Retention or Quarantine Policy placeholder",
+ "description": "Numbered placeholder pending maintainer approval of a direct CIS mapping."
+ },
+ "AZ-SC-005": {
+ "control_id": "TBD-SC-005",
+ "control_name": "Terraform State Storage Container Publicly Readable placeholder",
+ "description": "Numbered placeholder pending maintainer approval of a direct CIS mapping."
+ },
+ "AZ-SC-006": {
+ "control_id": "TBD-SC-006",
+ "control_name": "Terraform State Storage Account Missing Versioning or Soft Delete placeholder",
+ "description": "Numbered placeholder pending maintainer approval of a direct CIS mapping."
+ },
+ "AZ-SC-007": {
+ "control_id": "TBD-SC-007",
+ "control_name": "Pipeline Service Connection Scoped to Subscription placeholder",
+ "description": "Numbered placeholder pending maintainer approval of a direct CIS mapping."
+ },
+ "AZ-SC-008": {
+ "control_id": "TBD-SC-008",
+ "control_name": "Pipeline Service Connection Uses Password Instead of Federated Credential placeholder",
+ "description": "Numbered placeholder pending maintainer approval of a direct CIS mapping."
}
}
}
diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json
index c4b88d8..0ab3c86 100644
--- a/compliance/frameworks/iso27001.json
+++ b/compliance/frameworks/iso27001.json
@@ -287,6 +287,46 @@
"control_id": "A.9.2.3",
"control_name": "Management of privileged access rights",
"description": "Subscription Owner and Contributor assignments to managed identities require least-privilege reduction."
+ },
+ "AZ-SC-001": {
+ "control_id": "A.9.2.1",
+ "control_name": "User registration and de-registration",
+ "description": "The Container Registry admin user is enabled, providing a shared credential that bypasses individual identity management and cannot be attributed to a single user."
+ },
+ "AZ-SC-002": {
+ "control_id": "A.13.1.1",
+ "control_name": "Network controls",
+ "description": "The Container Registry is reachable from the public internet, leaving the network boundary that protects the organization's built container images uncontrolled."
+ },
+ "AZ-SC-003": {
+ "control_id": "A.9.2.1",
+ "control_name": "User registration and de-registration",
+ "description": "The Container Registry allows anonymous pull, letting any client access every image without an authenticated, individually attributable identity."
+ },
+ "AZ-SC-004": {
+ "control_id": "A.12.1.2",
+ "control_name": "Change management",
+ "description": "The Container Registry has no retention or quarantine policy, so stale images accumulate and newly pushed images are deployable before any vulnerability scan evaluates them."
+ },
+ "AZ-SC-005": {
+ "control_id": "A.13.1.1",
+ "control_name": "Network controls",
+ "description": "A Terraform remote state container is publicly readable, leaving the network boundary around infrastructure layout and captured secrets uncontrolled."
+ },
+ "AZ-SC-006": {
+ "control_id": "A.12.3.1",
+ "control_name": "Information backup",
+ "description": "A storage account holding Terraform remote state has neither versioning nor soft delete enabled, so an overwritten or deleted state file cannot be recovered."
+ },
+ "AZ-SC-007": {
+ "control_id": "A.9.2.3",
+ "control_name": "Management of privileged access rights",
+ "description": "A pipeline service connection is scoped to the entire subscription rather than a single resource group, so every pipeline that uses it inherits subscription-wide access beyond what it needs."
+ },
+ "AZ-SC-008": {
+ "control_id": "A.9.4.3",
+ "control_name": "Password management system",
+ "description": "A pipeline service connection authenticates with a stored service principal secret instead of a federated credential, leaving a static credential to rotate and potentially leak."
}
}
}
diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json
index 563c614..69167b3 100644
--- a/compliance/frameworks/nist_csf.json
+++ b/compliance/frameworks/nist_csf.json
@@ -287,6 +287,46 @@
"control_id": "PR.AC-4",
"control_name": "Access permissions and authorizations are managed",
"description": "Managed identities should receive only the minimum role and scope required by their workloads."
+ },
+ "AZ-SC-001": {
+ "control_id": "PR.AC-1",
+ "control_name": "Identities and credentials are issued, managed, verified, revoked, and audited",
+ "description": "The Container Registry admin user is enabled, providing a shared credential that bypasses individual identity management and cannot be attributed to a single user."
+ },
+ "AZ-SC-002": {
+ "control_id": "PR.AC-5",
+ "control_name": "Network integrity is protected",
+ "description": "The Container Registry is reachable from the public internet, leaving the network boundary that protects the organization's built container images uncontrolled."
+ },
+ "AZ-SC-003": {
+ "control_id": "PR.AC-1",
+ "control_name": "Identities and credentials are issued, managed, verified, revoked, and audited",
+ "description": "The Container Registry allows anonymous pull, letting any client access every image without an authenticated, individually attributable identity."
+ },
+ "AZ-SC-004": {
+ "control_id": "PR.IP-1",
+ "control_name": "A baseline configuration is created and maintained",
+ "description": "The Container Registry has no retention or quarantine policy, so stale images accumulate and newly pushed images are deployable before any vulnerability scan evaluates them."
+ },
+ "AZ-SC-005": {
+ "control_id": "PR.AC-5",
+ "control_name": "Network integrity is protected",
+ "description": "A Terraform remote state container is publicly readable, leaving the network boundary around infrastructure layout and captured secrets uncontrolled."
+ },
+ "AZ-SC-006": {
+ "control_id": "PR.IP-4",
+ "control_name": "Backups of information are conducted, maintained, and tested",
+ "description": "A storage account holding Terraform remote state has neither versioning nor soft delete enabled, so an overwritten or deleted state file cannot be recovered."
+ },
+ "AZ-SC-007": {
+ "control_id": "PR.AC-4",
+ "control_name": "Access permissions and authorizations are managed",
+ "description": "A pipeline service connection is scoped to the entire subscription rather than a single resource group, so every pipeline that uses it inherits subscription-wide access beyond what it needs."
+ },
+ "AZ-SC-008": {
+ "control_id": "PR.AC-1",
+ "control_name": "Identities and credentials are issued, managed, verified, revoked, and audited",
+ "description": "A pipeline service connection authenticates with a stored service principal secret instead of a federated credential, leaving a static credential to rotate and potentially leak."
}
}
}
diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json
index 5645793..ba19486 100644
--- a/compliance/frameworks/soc2.json
+++ b/compliance/frameworks/soc2.json
@@ -287,6 +287,46 @@
"control_id": "CC6.3",
"control_name": "Role-Based Access",
"description": "Managed identities should not receive broad subscription roles beyond workload requirements."
+ },
+ "AZ-SC-001": {
+ "control_id": "CC6.1",
+ "control_name": "Logical Access Security Measures",
+ "description": "The Container Registry admin user is enabled, providing a shared credential that bypasses individual identity management and cannot be attributed to a single user."
+ },
+ "AZ-SC-002": {
+ "control_id": "CC6.6",
+ "control_name": "Restricts Access from Outside the Network Boundary",
+ "description": "The Container Registry is reachable from the public internet, leaving the network boundary that protects the organization's built container images uncontrolled."
+ },
+ "AZ-SC-003": {
+ "control_id": "CC6.1",
+ "control_name": "Logical Access Security Measures",
+ "description": "The Container Registry allows anonymous pull, letting any client access every image without an authenticated, individually attributable identity."
+ },
+ "AZ-SC-004": {
+ "control_id": "CC7.1",
+ "control_name": "System Vulnerabilities are Identified and Managed",
+ "description": "The Container Registry has no retention or quarantine policy, so stale images accumulate and newly pushed images are deployable before any vulnerability scan evaluates them."
+ },
+ "AZ-SC-005": {
+ "control_id": "CC6.6",
+ "control_name": "Restricts Access from Outside the Network Boundary",
+ "description": "A Terraform remote state container is publicly readable, leaving the network boundary around infrastructure layout and captured secrets uncontrolled."
+ },
+ "AZ-SC-006": {
+ "control_id": "A1.2",
+ "control_name": "Environmental Threats and Recovery",
+ "description": "A storage account holding Terraform remote state has neither versioning nor soft delete enabled, so an overwritten or deleted state file cannot be recovered."
+ },
+ "AZ-SC-007": {
+ "control_id": "CC6.1",
+ "control_name": "Logical Access Security Measures",
+ "description": "A pipeline service connection is scoped to the entire subscription rather than a single resource group, so every pipeline that uses it inherits subscription-wide access beyond what it needs."
+ },
+ "AZ-SC-008": {
+ "control_id": "CC6.1",
+ "control_name": "Logical Access Security Measures",
+ "description": "A pipeline service connection authenticates with a stored service principal secret instead of a federated credential, leaving a static credential to rotate and potentially leak."
}
}
}
diff --git a/docs/access-continuity.md b/docs/access-continuity.md
new file mode 100644
index 0000000..f366598
--- /dev/null
+++ b/docs/access-continuity.md
@@ -0,0 +1,37 @@
+# Project Access Continuity
+
+OpenShield's public component ownership is recorded in `.github/CODEOWNERS`.
+OpenSSF continuity requires more than public names: organization owners must
+verify that the project can continue if any one person becomes unavailable.
+
+## Required capability matrix
+
+At least two currently available people must independently be able to perform
+each critical capability, or use a tested organization-controlled recovery
+process:
+
+| Capability | Primary confirmed | Backup confirmed | Last tested |
+|---|---|---|---|
+| Triage and close issues | Owner record | Owner record | Date required |
+| Review and merge approved changes | Owner record | Owner record | Date required |
+| Publish and verify a release | Owner record | Owner record | Date required |
+| Recover GitHub organization access | Owner record | Owner record | Date required |
+| Manage production deployment access | Owner record | Owner record | Date required |
+| Manage domain/DNS access, if applicable | Owner record | Owner record | Date required |
+| Rotate security-reporting access | Owner record | Owner record | Date required |
+
+Names and recovery details may remain in a private owner-controlled record when
+publishing them would increase risk. The public OpenSSF justification should
+state the date of verification and that two independent holders were confirmed,
+without exposing secrets.
+
+## Review process
+
+- Review the matrix at least every six months and before each major release.
+- Remove access promptly when a role ends and confirm the backup remains valid.
+- Test recovery without sharing credentials between individuals.
+- Store recovery material outside any single maintainer's personal account.
+- Record the review in issue #205 or another auditable owner-approved record.
+
+The continuity and bus-factor criteria must remain pending until an organization
+owner completes and records this verification. Documentation alone is not proof.
diff --git a/docs/accessibility-audit.md b/docs/accessibility-audit.md
new file mode 100644
index 0000000..026cfaa
--- /dev/null
+++ b/docs/accessibility-audit.md
@@ -0,0 +1,38 @@
+# Accessibility Audit
+
+Assessment date: 16 July 2026. Scope: React dashboard and static project
+website. Target: practical alignment with WCAG 2.2 AA; this is not a formal
+conformance certification.
+
+## Controls added
+
+- A keyboard-visible skip link targets the dashboard's main content.
+- Primary navigation and mobile navigation have accessible names.
+- Decorative navigation icons are hidden from assistive technology.
+- Icon-only close and status actions have accessible labels.
+- Popovers and connection errors expose dialog semantics and names.
+- Scan results and backend connectivity expose polite live status updates.
+- The off-screen mobile navigation is inert while closed.
+- A source-level CI check rejects positive tab order, non-semantic clickable
+ `div`/`span` elements, missing image alternative text, and missing document
+ language.
+
+## Keyboard review
+
+The expected keyboard path is: skip link, mobile menu when present, language
+selector, scan control, primary navigation, then page content. Native buttons,
+links, inputs and selects retain browser focus behavior. Escape handling remains
+available in the scan input. A full screen-reader/browser matrix remains a
+release-quality follow-up rather than a claim made by this audit.
+
+## Known limitations
+
+- Some data visualizations need separate screen-reader summaries as their
+ components evolve.
+- Focus trapping and restoration for every future modal must be checked during
+ component review.
+- Colour contrast should be rechecked whenever theme tokens change.
+- The static website has its own editing surface and needs repeat manual review
+ when that interface changes.
+
+Run `npm run test:a11y` from `frontend/` for the automated source checks.
diff --git a/docs/adding-a-rule.md b/docs/adding-a-rule.md
index e62f43b..65fd5e1 100644
--- a/docs/adding-a-rule.md
+++ b/docs/adding-a-rule.md
@@ -24,27 +24,26 @@ logger = logging.getLogger(__name__)
# ── Required module-level constants ─────────────────────────────────────────
-RULE_ID = "AZ-XXXX-000" # Unique ID. Check existing rules to avoid clashes.
-RULE_NAME = "Human-readable name" # Shown in the dashboard and reports.
-SEVERITY = "HIGH" # HIGH | MEDIUM | LOW | INFO
-CATEGORY = "Storage" # Storage | Network | Identity | Database | Compute | Key Vault | Kubernetes
+RULE_ID = "AZ-XXXX-000" # Unique ID. Check existing rules to avoid clashes.
+RULE_NAME = "Human-readable name" # Shown in the dashboard and reports.
+SEVERITY = "HIGH" # HIGH | MEDIUM | LOW | INFO
+CATEGORY = "Storage" # Storage | Network | Identity | Database | Compute | Key Vault | Kubernetes
FRAMEWORKS = {
- "CIS": "3.5", # CIS Azure Benchmark control ID
- "NIST": "PR.AC-3", # NIST CSF subcategory
- "ISO27001": "A.9.4.1", # ISO 27001 Annex A control
+ "CIS": "3.5", # CIS Azure Benchmark control ID
+ "NIST": "PR.AC-3", # NIST CSF subcategory
+ "ISO27001": "A.9.4.1", # ISO 27001 Annex A control
}
DESCRIPTION = (
"Explain WHY this is a security risk. One or two sentences. "
"What can an attacker do if this misconfiguration exists?"
)
-REMEDIATION = (
- "Explain HOW to fix it. What setting to change, or what command to run."
-)
+REMEDIATION = "Explain HOW to fix it. What setting to change, or what command to run."
PLAYBOOK = "playbooks/cli/fix_az_xxxx_000.sh" # path to the matching fix script
# ── Required scan function ───────────────────────────────────────────────────
+
def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]:
"""Return a list of findings. Return [] if no issues are found.
@@ -74,20 +73,22 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]:
continue
if status is False:
- findings.append({
- "rule_id": RULE_ID,
- "rule_name": RULE_NAME,
- "severity": SEVERITY,
- "category": CATEGORY,
- "resource_id": resource_id,
- "resource_name": resource_name,
- "resource_type": "Microsoft.Storage/storageAccounts", # ← update
- "description": DESCRIPTION,
- "remediation": REMEDIATION,
- "playbook": PLAYBOOK,
- "frameworks": FRAMEWORKS,
- "metadata": {},
- })
+ findings.append(
+ {
+ "rule_id": RULE_ID,
+ "rule_name": RULE_NAME,
+ "severity": SEVERITY,
+ "category": CATEGORY,
+ "resource_id": resource_id,
+ "resource_name": resource_name,
+ "resource_type": "Microsoft.Storage/storageAccounts", # ← update
+ "description": DESCRIPTION,
+ "remediation": REMEDIATION,
+ "playbook": PLAYBOOK,
+ "frameworks": FRAMEWORKS,
+ "metadata": {},
+ }
+ )
return findings
```
@@ -130,12 +131,19 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]:
| `azure_client.get_subscription_role_assignments()` | Subscription RBAC assignments, or `None` on API failure |
| `azure_client.get_service_principals()` | List of RoleAssignment objects for service principals |
| `azure_client.get_conditional_access_policies()` | List of CA policy dicts from MS Graph |
+| `azure_client.get_container_registries()` | List of ACR Registry objects, or `None` on API failure |
+| `azure_client.get_blob_containers(rg, account)` | List of blob container items (with `public_access`), or `None` on API failure |
+| `azure_client.get_blob_service_properties(rg, account)` | BlobServiceProperties (versioning, soft delete), or `None` on API failure |
+| `azure_client.devops_client` | `DevOpsClient` instance, or `None` if `AZURE_DEVOPS_ORG_URL`/`AZURE_DEVOPS_PROJECT` are not configured |
+| `azure_client.devops_client.get_service_endpoints()` | List of Azure DevOps service connections, or `None` on API failure |
| `azure_client.parse_resource_id(id)` | Dict with `resource_group` and `name` |
List methods return an empty list on failure. Single-resource methods return `None` when the resource cannot be fetched. Three-state checks, such as `get_storage_lifecycle_policy()`, return `True` for compliant, `False` for non-compliant, and `None` when the scanner cannot determine the state.
When a helper returns `None`, skip the resource and log a warning. Never create a finding from an unknown state.
+`azure_client.devops_client` is `None` whenever Azure DevOps is not configured for the scanned subscription — treat that the same as "not applicable" and return no findings, not as an indeterminate failure.
+
---
## Write the Remediation Playbook
diff --git a/docs/architecture.md b/docs/architecture.md
index 2249851..f8fa9f5 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -2,7 +2,7 @@
## Overview
-OpenShield is a modular, open source Cloud Security Posture Management (CSPM) platform for Azure. It scans your Azure subscription against 51 security rules, maps findings to compliance frameworks (CIS, NIST CSF, ISO 27001, SOC 2), stores results in PostgreSQL, and exposes posture data through a Flask REST API consumed by a live React dashboard.
+OpenShield is a modular, open source Cloud Security Posture Management (CSPM) platform for Azure. It scans your Azure subscription against 65 security rules, maps findings to compliance frameworks (CIS, NIST CSF, ISO 27001, SOC 2), stores results in PostgreSQL, and exposes posture data through a Flask REST API consumed by a live React dashboard.
---
@@ -43,8 +43,9 @@ OpenShield is a modular, open source Cloud Security Posture Management (CSPM) pl
┌───────────▼──────────────────────────────────────────────────────┐
│ Rule Modules (scanner/rules/) │
│ │
-│ 51 rule files across Storage, Network, Identity, Database, │
-│ Compute, Key Vault, AKS, and post-quantum cryptography │
+│ 65 rule files across Storage, Network, Identity, Database, │
+│ Compute, Key Vault, AKS, post-quantum cryptography, and │
+│ Supply Chain (Container Registry, IaC state, DevOps pipelines) │
└───────────┬───────────────────────────────────────────────────────┘
│ calls
┌───────────▼──────────────────────────────────────────────────────┐
@@ -110,18 +111,19 @@ result = engine.run_scan()
### 4. Current Rule Modules
-There are 51 rule files in `scanner/rules/`. See `docs/rules-reference.md` for the full table.
+There are 65 rule files in `scanner/rules/`. See `docs/rules-reference.md` for the full table.
| Category | Count | Rules |
|---|---|---|
| Storage | 5 | AZ-STOR-001 to 005 |
| Network | 15 | AZ-NET-001 to 015 |
-| Identity | 4 | AZ-IDN-001 to 004 |
+| Identity | 15 | AZ-IDN-001 to 015 |
| Database | 4 | AZ-DB-001 to 004 |
| Compute | 4 | AZ-CMP-001 to 004 |
| Key Vault | 5 | AZ-KV-001 to 005 |
| Kubernetes | 6 | AZ-AKS-001 to 006 |
| Post-quantum | 3 | AZ-PQC-001 to 003 |
+| Supply Chain | 8 | AZ-SC-001 to 008 |
Every rule has a matching Azure CLI playbook in `playbooks/cli/`.
@@ -131,20 +133,20 @@ Every finding returned by a rule must conform to this schema:
```python
{
- "rule_id": str, # e.g. "AZ-STOR-001"
- "rule_name": str,
- "severity": str, # HIGH | MEDIUM | LOW | INFO
- "category": str, # Storage | Network | Identity | Database | Compute | Key Vault
- "resource_id": str, # full Azure resource ID
+ "rule_id": str, # e.g. "AZ-STOR-001"
+ "rule_name": str,
+ "severity": str, # HIGH | MEDIUM | LOW | INFO
+ "category": str, # Storage | Network | Identity | Database | Compute | Key Vault
+ "resource_id": str, # full Azure resource ID
"resource_name": str,
- "resource_type": str, # e.g. "Microsoft.Storage/storageAccounts"
- "description": str,
- "remediation": str,
- "playbook": str, # path to the CLI remediation script
- "frameworks": dict, # {"CIS": "3.5", "NIST": "PR.AC-3", "ISO27001": "A.9.4.1"}
- "metadata": dict, # optional rule-specific context
- "detected_at": str, # ISO 8601, added by engine
- "scan_id": str, # UUID, added by engine
+ "resource_type": str, # e.g. "Microsoft.Storage/storageAccounts"
+ "description": str,
+ "remediation": str,
+ "playbook": str, # path to the CLI remediation script
+ "frameworks": dict, # {"CIS": "3.5", "NIST": "PR.AC-3", "ISO27001": "A.9.4.1"}
+ "metadata": dict, # optional rule-specific context
+ "detected_at": str, # ISO 8601, added by engine
+ "scan_id": str, # UUID, added by engine
}
```
diff --git a/docs/azure-setup.md b/docs/azure-setup.md
index 63e345e..36d21f9 100644
--- a/docs/azure-setup.md
+++ b/docs/azure-setup.md
@@ -282,6 +282,27 @@ The AZ-DB-002 remediation playbook writes SQL audit logs to a storage account. T
---
+## Step 10 — Configure Azure DevOps Pipeline Scanning (Optional)
+
+AZ-SC-007 and AZ-SC-008 check Azure DevOps pipeline service connections for
+subscription-wide sharing and password-based authentication. Azure DevOps is
+a separate system from Azure Resource Manager, so it needs two additional
+environment variables. Both must be set or neither rule will produce
+findings — this is treated as "not applicable," not an error.
+
+```bash
+AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-org
+AZURE_DEVOPS_PROJECT=your-project-name
+```
+
+The scanner reuses the same service principal configured in Step 3, requesting
+a token scoped to Azure DevOps' well-known resource ID
+(`499b84ac-1321-427f-aa17-267ca6975798`). Grant that service principal at
+least **Reader** access to the target Azure DevOps project's service
+connections (Project Settings > Service connections > Security).
+
+---
+
## Troubleshooting
| Problem | Fix |
@@ -291,3 +312,4 @@ The AZ-DB-002 remediation playbook writes SQL audit logs to a storage account. T
| `psycopg2.OperationalError` | Check your PostgreSQL container is running and `DATABASE_URL` is correct |
| Empty findings | Verify the service principal has `Reader` role on the subscription |
| AZ-IDN-002 always fires | The service principal needs `Policy.Read.All` Graph permission — see Step 4 |
+| AZ-SC-007/008 never fire | Confirm `AZURE_DEVOPS_ORG_URL` and `AZURE_DEVOPS_PROJECT` are both set — see Step 10 |
diff --git a/docs/ci-pipeline.md b/docs/ci-pipeline.md
index 0b5c015..a0047a1 100644
--- a/docs/ci-pipeline.md
+++ b/docs/ci-pipeline.md
@@ -27,7 +27,7 @@ This document explains each job, how to reproduce every check locally before ope
`.github/workflows/codeql.yml` (separate workflow, PRs to `dev`/`main` + weekly cron): **Analyze (python)** and **Analyze (javascript)** — CodeQL semantic/taint analysis.
-`.github/workflows/sbom-release.yml` (triggered `on: release: published`): generates a CycloneDX SBOM from the tagged code and uploads it to the GitHub Release assets.
+`.github/workflows/release.yml` (triggered by `v*` tags): requires a verified signed annotated tag, builds a deterministic source archive and CycloneDX SBOM, publishes SHA-256 checksums and identity-bound provenance attestations, then creates the GitHub Release.
The **Container Scan** job is intentionally **not** a required check yet: no `Dockerfile` exists, so it has nothing to scan. It activates automatically once INFRA 1 (#154) adds one.
diff --git a/docs/dco.md b/docs/dco.md
new file mode 100644
index 0000000..9876244
--- /dev/null
+++ b/docs/dco.md
@@ -0,0 +1,33 @@
+# Developer Certificate of Origin
+
+OpenShield uses the [Developer Certificate of Origin 1.1](https://developercertificate.org/)
+as its contribution authorization mechanism. A `Signed-off-by` trailer states
+that the contributor is legally entitled to submit the work under the project's
+license and agrees to the DCO certification.
+
+Add the trailer automatically when committing:
+
+```bash
+git commit -s -m "feat: describe the change"
+```
+
+The name and email in the trailer should identify the contributor and should
+match the commit author unless a documented contribution workflow requires a
+different authorized signer. Every non-merge commit introduced by a pull
+request is checked; a sign-off only in the pull request description is
+insufficient.
+
+Merge commits (e.g. from running `git merge origin/dev` to bring your branch
+up to date) are exempt — they carry Git's own default message, not your
+authorship, so there is nothing for you to sign off on. Only commits you
+authored yourself need the trailer.
+
+To repair the latest local commit before review:
+
+```bash
+git commit --amend --signoff --no-edit
+git push --force-with-lease
+```
+
+For multiple commits, use an interactive rebase and add a sign-off to each
+commit. Do not add another person's sign-off without their authorization.
diff --git a/docs/internationalization.md b/docs/internationalization.md
new file mode 100644
index 0000000..f848083
--- /dev/null
+++ b/docs/internationalization.md
@@ -0,0 +1,22 @@
+# Internationalization
+
+The dashboard uses message catalogs through `I18nContext`. English is the
+fallback language and Spanish demonstrates a second complete catalog for core
+navigation, page titles, scan controls, status messages and theme controls.
+
+The selected locale is stored locally, applied to the document `lang`
+attribute, and used with `Intl.DateTimeFormat` and `Intl.NumberFormat`. An
+unsupported locale falls back to English. Security identifiers, Azure resource
+names, findings and compliance control IDs are data and are never translated.
+
+## Adding a locale
+
+1. Add a catalog to `frontend/src/i18n/messages.js` using exactly the English
+ keys.
+2. Translate meaning rather than word order; retain `{name}` placeholders.
+3. Add the language's self-name to each catalog.
+4. Run `npm run test:i18n`; missing keys fail the catalog test.
+5. Review navigation at narrow and wide widths and verify date/number output.
+
+The website remains English-first. Additional website locales should reuse the
+same terminology but must not duplicate security data or rule definitions.
diff --git a/docs/release-verification.md b/docs/release-verification.md
new file mode 100644
index 0000000..d1244df
--- /dev/null
+++ b/docs/release-verification.md
@@ -0,0 +1,50 @@
+# Verifying OpenShield Releases
+
+OpenShield release artifacts are produced only from a GitHub-verified signed
+annotated tag. GitHub Actions generates a deterministic source archive, a
+CycloneDX SBOM and SHA-256 checksums, then creates identity-bound Sigstore
+provenance attestations before uploading the files to the release.
+
+## Verify checksums
+
+Download all release assets into one directory, then run:
+
+```bash
+sha256sum --check SHA256SUMS
+```
+
+## Verify provenance
+
+Install the GitHub CLI and verify each artifact against this repository:
+
+```bash
+gh attestation verify openshield-vX.Y.Z.tar.gz \
+ --repo openshield-org/openshield \
+ --signer-workflow openshield-org/openshield/.github/workflows/release.yml
+
+gh attestation verify openshield-vX.Y.Z-sbom.cyclonedx.json \
+ --repo openshield-org/openshield \
+ --signer-workflow openshield-org/openshield/.github/workflows/release.yml
+
+gh attestation verify SHA256SUMS \
+ --repo openshield-org/openshield \
+ --signer-workflow openshield-org/openshield/.github/workflows/release.yml
+```
+
+Successful verification proves that the artifact digest was attested by the
+OpenShield release workflow for this public repository. It does not mean that
+GitHub or Sigstore audited the source code.
+
+## Maintainer release procedure
+
+1. Confirm the release commit is on the approved `main` history and CI passes.
+2. Create a signed annotated tag: `git tag -s vX.Y.Z -m "OpenShield vX.Y.Z"`.
+ SSH signing may be used when Git is configured with `gpg.format=ssh`.
+3. Verify locally with `git tag -v vX.Y.Z` using the project's trusted signer
+ configuration.
+4. Push only the tag: `git push origin vX.Y.Z`.
+5. The workflow independently asks GitHub to verify the tag signature. A
+ lightweight or unverified tag fails before artifacts are produced.
+6. After publication, download and verify every asset using the commands above.
+
+Existing historical lightweight tags are not retroactively described as signed.
diff --git a/docs/rules-reference.md b/docs/rules-reference.md
index 8b2b4f5..22288cf 100644
--- a/docs/rules-reference.md
+++ b/docs/rules-reference.md
@@ -1,6 +1,6 @@
-# Rules Reference
+# Rules Reference
-OpenShield currently ships 51 Azure scan rules. This table is generated from the module-level constants in `scanner/rules/`.
+OpenShield currently ships 65 Azure scan rules. This table is generated from the module-level constants in `scanner/rules/`.
| Rule ID | Name | Severity | Category | CIS | NIST | ISO 27001 |
|---|---|---|---|---|---|---|
@@ -61,6 +61,14 @@ OpenShield currently ships 51 Azure scan rules. This table is generated from the
| AZ-AKS-004 | AKS Workload Identity Not Fully Enabled | MEDIUM | Kubernetes | N/A-AKS-004 | PR.AC-4 | A.9.2.3 |
| AZ-AKS-005 | AKS Azure Policy Add-on Not Enabled | MEDIUM | Kubernetes | N/A-AKS-005 | PR.IP-1 | A.12.1.2 |
| AZ-AKS-006 | AKS Node OS Automatic Upgrades Disabled | HIGH | Kubernetes | N/A-AKS-006 | PR.IP-12 | A.12.6.1 |
+| AZ-SC-001 | Container Registry Admin User Enabled | HIGH | Supply Chain | TBD-SC-001 | PR.AC-1 | A.9.2.1 |
+| AZ-SC-002 | Container Registry Public Network Access Enabled | HIGH | Supply Chain | TBD-SC-002 | PR.AC-5 | A.13.1.1 |
+| AZ-SC-003 | Container Registry Allows Anonymous Pull | HIGH | Supply Chain | TBD-SC-003 | PR.AC-1 | A.9.2.1 |
+| AZ-SC-004 | Container Registry Missing Retention or Quarantine Policy | MEDIUM | Supply Chain | TBD-SC-004 | PR.IP-1 | A.12.1.2 |
+| AZ-SC-005 | Terraform State Storage Container Publicly Readable | CRITICAL | Supply Chain | TBD-SC-005 | PR.AC-5 | A.13.1.1 |
+| AZ-SC-006 | Terraform State Storage Account Missing Versioning or Soft Delete | HIGH | Supply Chain | TBD-SC-006 | PR.IP-4 | A.12.3.1 |
+| AZ-SC-007 | Pipeline Service Connection Scoped to Subscription | HIGH | Supply Chain | TBD-SC-007 | PR.AC-4 | A.9.2.3 |
+| AZ-SC-008 | Pipeline Service Connection Uses Password Instead of Federated Credential | MEDIUM | Supply Chain | TBD-SC-008 | PR.AC-1 | A.9.4.3 |
SOC 2 mappings are maintained in `compliance/frameworks/soc2.json`.
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index cab1e07..b96d895 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,11 +9,11 @@
"version": "0.0.0",
"dependencies": {
"autoprefixer": "^10.5.0",
- "postcss": "^8.5.15",
- "react": "^19.2.6",
- "react-dom": "^19.2.6",
+ "postcss": "^8.5.18",
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7",
"react-icons": "^5.6.0",
- "react-router-dom": "^7.16.0",
+ "react-router": "^8.3.0",
"recharts": "^3.8.1"
},
"devDependencies": {
@@ -27,6 +27,9 @@
"globals": "^17.6.0",
"tailwindcss": "^3.4.19",
"vite": "^8.0.16"
+ },
+ "engines": {
+ "node": ">=22.22.0"
}
},
"node_modules/@alloc/quick-lru": {
@@ -1186,15 +1189,16 @@
}
},
"node_modules/brace-expansion": {
- "version": "5.0.6",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
- "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
- "node": "18 || 20 || >=22"
+ "node": "20 || >=22"
}
},
"node_modules/braces": {
@@ -1328,17 +1332,11 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
- "node_modules/cookie": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
- "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
- }
+ "node_modules/cookie-es": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
+ "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
+ "license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
@@ -2512,15 +2510,16 @@
}
},
"node_modules/nanoid": {
- "version": "3.3.12",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
- "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
+ "license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -2676,9 +2675,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.15",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
- "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "version": "8.5.25",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+ "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"funding": [
{
"type": "opencollective",
@@ -2693,8 +2692,9 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "license": "MIT",
"dependencies": {
- "nanoid": "^3.3.12",
+ "nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -2868,22 +2868,24 @@
]
},
"node_modules/react": {
- "version": "19.2.6",
- "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
- "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+ "license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
- "version": "19.2.6",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
- "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+ "license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
- "react": "^19.2.6"
+ "react": "^19.2.8"
}
},
"node_modules/react-icons": {
@@ -2923,19 +2925,19 @@
}
},
"node_modules/react-router": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz",
- "integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==",
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz",
+ "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==",
+ "license": "MIT",
"dependencies": {
- "cookie": "^1.0.1",
- "set-cookie-parser": "^2.6.0"
+ "cookie-es": "^3.1.1"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.22.0"
},
"peerDependencies": {
- "react": ">=18",
- "react-dom": ">=18"
+ "react": ">=19.2.7",
+ "react-dom": ">=19.2.7"
},
"peerDependenciesMeta": {
"react-dom": {
@@ -2943,21 +2945,6 @@
}
}
},
- "node_modules/react-router-dom": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz",
- "integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==",
- "dependencies": {
- "react-router": "7.16.0"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "react": ">=18",
- "react-dom": ">=18"
- }
- },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -3140,11 +3127,6 @@
"semver": "bin/semver.js"
}
},
- "node_modules/set-cookie-parser": {
- "version": "2.7.2",
- "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
- "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
- },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 631146a..87822b6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -3,19 +3,24 @@
"private": true,
"version": "0.0.0",
"type": "module",
+ "engines": {
+ "node": ">=22.22.0"
+ },
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --max-warnings=0",
+ "test:i18n": "node src/i18n/messages.test.mjs",
+ "test:a11y": "node scripts/accessibility-check.mjs",
"preview": "vite preview"
},
"dependencies": {
"autoprefixer": "^10.5.0",
- "postcss": "^8.5.15",
- "react": "^19.2.6",
- "react-dom": "^19.2.6",
+ "postcss": "^8.5.18",
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7",
"react-icons": "^5.6.0",
- "react-router-dom": "^7.16.0",
+ "react-router": "^8.3.0",
"recharts": "^3.8.1"
},
"devDependencies": {
diff --git a/frontend/scripts/accessibility-check.mjs b/frontend/scripts/accessibility-check.mjs
new file mode 100644
index 0000000..35f8150
--- /dev/null
+++ b/frontend/scripts/accessibility-check.mjs
@@ -0,0 +1,27 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+
+const root = path.resolve(import.meta.dirname, '..');
+const sourceRoot = path.join(root, 'src');
+
+function filesUnder(directory) {
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
+ const full = path.join(directory, entry.name);
+ return entry.isDirectory() ? filesUnder(full) : [full];
+ });
+}
+
+const html = fs.readFileSync(path.join(root, 'index.html'), 'utf8');
+assert.match(html, /]+lang="[a-z]{2}"/i, 'frontend HTML must declare a language');
+
+for (const file of filesUnder(sourceRoot).filter((item) => /\.(jsx?|html)$/.test(item))) {
+ const source = fs.readFileSync(file, 'utf8');
+ assert.doesNotMatch(source, /tabIndex=["'{]?[1-9]/, `${file} uses a positive tab order`);
+ assert.doesNotMatch(source, /<(div|span)\b[^>]*\bonClick=/, `${file} uses a non-semantic clickable element`);
+ for (const image of source.matchAll(/
]*>/g)) {
+ assert.match(image[0], /\balt=/, `${file} contains an image without alt text`);
+ }
+}
+
+console.log('accessibility static checks passed');
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 206e84f..0964a2c 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
-import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
+import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
import { DarkModeProvider } from './contexts/DarkModeContext';
+import { I18nProvider } from './contexts/I18nContext';
import { api } from './utils/api';
import Layout from './components/layout/Layout';
import Discovery from './pages/Discovery';
@@ -25,8 +26,9 @@ export default function App() {
return (
-
-
+
+
+
}>
} />
} />
@@ -37,8 +39,9 @@ export default function App() {
} />
} />
-
-
+
+
+
);
}
diff --git a/frontend/src/components/compliance/ComplianceTable.jsx b/frontend/src/components/compliance/ComplianceTable.jsx
index f1d18e0..dc04f64 100644
--- a/frontend/src/components/compliance/ComplianceTable.jsx
+++ b/frontend/src/components/compliance/ComplianceTable.jsx
@@ -1,4 +1,4 @@
-import { useNavigate } from 'react-router-dom';
+import { useNavigate } from 'react-router';
import { FiCheckCircle, FiXCircle, FiMinusCircle, FiArrowRight } from 'react-icons/fi';
import SeverityBadge from '../shared/SeverityBadge';
diff --git a/frontend/src/components/layout/Header.jsx b/frontend/src/components/layout/Header.jsx
index feb635f..53c41f0 100644
--- a/frontend/src/components/layout/Header.jsx
+++ b/frontend/src/components/layout/Header.jsx
@@ -1,27 +1,23 @@
import { useEffect, useRef, useState } from 'react';
-import { useLocation } from 'react-router-dom';
+import { useLocation } from 'react-router';
import {
FiMenu, FiAlertTriangle, FiX,
FiLoader, FiZap, FiCheckCircle, FiAlertCircle, FiClock,
} from 'react-icons/fi';
import { api } from '../../utils/api';
+import { useI18n } from '../../i18n/I18nState';
-const PAGE_TITLES = {
- '/monitoring': { title: 'Security Monitoring', subtitle: 'Overall health score and trends' },
- '/discovery': { title: 'Resource Discovery', subtitle: 'All resources across your Azure environment' },
- '/prioritization': { title: 'Risk Prioritization', subtitle: 'What to fix first based on risk and effort' },
- '/scan': { title: 'Detailed Scan', subtitle: 'Findings with step-by-step remediation playbooks' },
- '/compliance': { title: 'Compliance', subtitle: 'Framework tracking and control status' },
- '/drift': { title: 'Configuration Drift', subtitle: 'Detect unexpected changes to your environment' },
- '/ai': { title: 'AI Assistant', subtitle: 'Ask questions about your security posture' },
+const PAGE_KEYS = {
+ '/monitoring': 'monitoring', '/discovery': 'discovery', '/prioritization': 'prioritization',
+ '/scan': 'scan', '/compliance': 'compliance', '/drift': 'drift', '/ai': 'ai',
};
// ── Connection-error popup ─────────────────────────────────────────────────
function ConnectionErrorPopup({ apiBase, onClose }) {
return (
<>
-
-
+
+
@@ -29,11 +25,11 @@ function ConnectionErrorPopup({ apiBase, onClose }) {
-
Unable to connect
+
Unable to connect
Backend API unreachable
-
@@ -68,7 +64,7 @@ function ScanToast({ result, error, onClose }) {
const isSuccess = !!result;
return (
-
+
@@ -126,10 +122,10 @@ function ScanInputPopover({ onConfirm, onCancel }) {
return (
<>
-
-
+
+
-
Run Azure Scan
+
Run Azure Scan
Leave blank to use the subscription configured on the backend.
@@ -172,7 +168,11 @@ function ScanInputPopover({ onConfirm, onCancel }) {
// ── Main Header ────────────────────────────────────────────────────────────
export default function Header({ onMenuToggle }) {
const { pathname } = useLocation();
- const page = PAGE_TITLES[pathname] || { title: 'OpenShield', subtitle: '' };
+ const { locale, locales, setLocale, t, formatDate } = useI18n();
+ const pageKey = PAGE_KEYS[pathname];
+ const page = pageKey
+ ? { title: t(`page.${pageKey}.title`), subtitle: t(`page.${pageKey}.subtitle`) }
+ : { title: 'OpenShield', subtitle: '' };
const [showConnErr, setConnErr] = useState(false);
const [scanning, setScanning] = useState(false);
@@ -194,13 +194,13 @@ export default function Header({ onMenuToggle }) {
const latest = data?.scans?.[0];
const raw = latest?.started_at || latest?.startedAt;
if (raw) {
- setLastScanAt(new Date(raw).toLocaleString(undefined, {
+ setLastScanAt(formatDate(raw, {
month: 'short', day: 'numeric', year: 'numeric',
hour: 'numeric', minute: '2-digit',
}));
}
}).catch(() => {});
- }, [isLive]);
+ }, [formatDate, isLive]);
const closeConnErr = () => setConnErr(false);
@@ -250,7 +250,7 @@ export default function Header({ onMenuToggle }) {
@@ -268,6 +268,15 @@ export default function Header({ onMenuToggle }) {
{/* Right: controls */}
+
+
{/* Run Scan button + popover wrapper */}
@@ -282,7 +291,7 @@ export default function Header({ onMenuToggle }) {
:
}
- {scanning ? `Scanning… ${elapsed}s` : 'Run Scan'}
+ {scanning ? t('scan.scanning', { seconds: elapsed }) : t('scan.run')}
@@ -298,12 +307,14 @@ export default function Header({ onMenuToggle }) {
{lastScanAt && isLive && (
- Last scanned: {lastScanAt}
+ {t('scan.last', { date: lastScanAt })}
)}
{/* Live / Reconnecting status dot */}
@@ -316,7 +327,7 @@ export default function Header({ onMenuToggle }) {
)}
- {isLive ? 'Live' : 'Reconnecting'}
+ {isLive ? t('status.live') : t('status.reconnecting')}
diff --git a/frontend/src/components/layout/Layout.jsx b/frontend/src/components/layout/Layout.jsx
index 01a23c4..fc1d99a 100644
--- a/frontend/src/components/layout/Layout.jsx
+++ b/frontend/src/components/layout/Layout.jsx
@@ -1,16 +1,23 @@
import { useState } from 'react';
-import { Outlet } from 'react-router-dom';
+import { Outlet } from 'react-router';
import Sidebar from './Sidebar';
import Header from './Header';
+import { useI18n } from '../../i18n/I18nState';
export default function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
+ const { t } = useI18n();
return (
+
+ {t('skip.content')}
+
{/* Mobile overlay */}
{sidebarOpen && (
-
setSidebarOpen(false)}
/>
@@ -20,7 +27,7 @@ export default function Layout() {
setSidebarOpen((v) => !v)} />
-
+
diff --git a/frontend/src/components/layout/Sidebar.jsx b/frontend/src/components/layout/Sidebar.jsx
index e79074a..f11e1ac 100644
--- a/frontend/src/components/layout/Sidebar.jsx
+++ b/frontend/src/components/layout/Sidebar.jsx
@@ -1,34 +1,36 @@
-import { NavLink } from 'react-router-dom';
+import { NavLink } from 'react-router';
import {
FiActivity, FiSearch, FiTarget, FiZap,
FiShield, FiGitBranch, FiCpu, FiSun, FiMoon, FiX,
} from 'react-icons/fi';
import { useDarkMode } from '../../contexts/DarkModeContext';
+import { useI18n } from '../../i18n/I18nState';
import Logo from '../shared/Logo';
const navItems = [
- { path: '/monitoring', label: 'Monitor', Icon: FiActivity },
- { path: '/discovery', label: 'Discover', Icon: FiSearch },
- { path: '/prioritization', label: 'Prioritize', Icon: FiTarget },
- { path: '/scan', label: 'Scan', Icon: FiZap },
- { path: '/compliance', label: 'Comply', Icon: FiShield },
- { path: '/drift', label: 'Drift', Icon: FiGitBranch },
- { path: '/ai', label: 'AI', Icon: FiCpu },
+ { path: '/monitoring', key: 'monitoring', Icon: FiActivity },
+ { path: '/discovery', key: 'discovery', Icon: FiSearch },
+ { path: '/prioritization', key: 'prioritization', Icon: FiTarget },
+ { path: '/scan', key: 'scan', Icon: FiZap },
+ { path: '/compliance', key: 'compliance', Icon: FiShield },
+ { path: '/drift', key: 'drift', Icon: FiGitBranch },
+ { path: '/ai', key: 'ai', Icon: FiCpu },
];
export default function Sidebar({ isOpen, onClose }) {
const { isDark, toggle } = useDarkMode();
+ const { t } = useI18n();
return (
<>
{/* ── Desktop sidebar (always visible on lg+) ── */}
-
{/* Drawer nav */}
-
diff --git a/frontend/src/components/prioritization/QuickRemediation.jsx b/frontend/src/components/prioritization/QuickRemediation.jsx
index c4e5ab4..813c44c 100644
--- a/frontend/src/components/prioritization/QuickRemediation.jsx
+++ b/frontend/src/components/prioritization/QuickRemediation.jsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { FiLayout, FiTerminal, FiClock, FiArrowRight, FiTool, FiAlertTriangle } from 'react-icons/fi';
-import { useNavigate } from 'react-router-dom';
+import { useNavigate } from 'react-router';
const EFFORT_ETA = { 1: '15–30 mins', 2: '1–2 hours', 3: '2–4 hours', 4: '~1 day', 5: '2–3 days' };
diff --git a/frontend/src/components/scan/AskAIButton.jsx b/frontend/src/components/scan/AskAIButton.jsx
index fbd8988..8102c95 100644
--- a/frontend/src/components/scan/AskAIButton.jsx
+++ b/frontend/src/components/scan/AskAIButton.jsx
@@ -1,5 +1,5 @@
import { FiCpu } from 'react-icons/fi';
-import { useNavigate } from 'react-router-dom';
+import { useNavigate } from 'react-router';
export default function AskAIButton({ finding }) {
const navigate = useNavigate();
diff --git a/frontend/src/components/shared/Card.jsx b/frontend/src/components/shared/Card.jsx
index bafe013..363fb70 100644
--- a/frontend/src/components/shared/Card.jsx
+++ b/frontend/src/components/shared/Card.jsx
@@ -1,10 +1,15 @@
export default function Card({ children, className = '', onClick }) {
+ const classes = `rounded-2xl border border-border-light dark:border-border-dark bg-bg-primary dark:bg-bg-dark-secondary p-6 shadow-soft hover:shadow-soft-lg transition-all duration-200 ${onClick ? 'cursor-pointer' : ''} ${className}`;
+ if (onClick) {
+ return (
+
+ {children}
+
+ );
+ }
return (
-
+
{children}
);
diff --git a/frontend/src/contexts/I18nContext.jsx b/frontend/src/contexts/I18nContext.jsx
new file mode 100644
index 0000000..f56e34f
--- /dev/null
+++ b/frontend/src/contexts/I18nContext.jsx
@@ -0,0 +1,36 @@
+import { useEffect, useMemo, useState } from 'react';
+import { DEFAULT_LOCALE, messages, translate } from '../i18n/messages';
+import { I18nState } from '../i18n/I18nState';
+const STORAGE_KEY = 'openshield.locale';
+
+function initialLocale() {
+ const stored = window.localStorage.getItem(STORAGE_KEY);
+ if (stored && messages[stored]) return stored;
+ const browserLocale = window.navigator.language?.split('-')[0];
+ return messages[browserLocale] ? browserLocale : DEFAULT_LOCALE;
+}
+
+export function I18nProvider({ children }) {
+ const [locale, setLocaleState] = useState(initialLocale);
+
+ const setLocale = (nextLocale) => {
+ const supported = messages[nextLocale] ? nextLocale : DEFAULT_LOCALE;
+ window.localStorage.setItem(STORAGE_KEY, supported);
+ setLocaleState(supported);
+ };
+
+ useEffect(() => {
+ document.documentElement.lang = locale;
+ }, [locale]);
+
+ const value = useMemo(() => ({
+ locale,
+ locales: Object.keys(messages),
+ setLocale,
+ t: (key, values) => translate(locale, key, values),
+ formatDate: (value, options) => new Intl.DateTimeFormat(locale, options).format(new Date(value)),
+ formatNumber: (value, options) => new Intl.NumberFormat(locale, options).format(value),
+ }), [locale]);
+
+ return
{children};
+}
diff --git a/frontend/src/i18n/I18nState.js b/frontend/src/i18n/I18nState.js
new file mode 100644
index 0000000..5a4283f
--- /dev/null
+++ b/frontend/src/i18n/I18nState.js
@@ -0,0 +1,9 @@
+import { createContext, useContext } from 'react';
+
+export const I18nState = createContext(null);
+
+export function useI18n() {
+ const context = useContext(I18nState);
+ if (!context) throw new Error('useI18n must be used inside I18nProvider');
+ return context;
+}
diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js
new file mode 100644
index 0000000..dfe1ebe
--- /dev/null
+++ b/frontend/src/i18n/messages.js
@@ -0,0 +1,41 @@
+export const DEFAULT_LOCALE = 'en';
+
+export const messages = {
+ en: {
+ 'nav.monitoring': 'Monitor', 'nav.discovery': 'Discover', 'nav.prioritization': 'Prioritize',
+ 'nav.scan': 'Scan', 'nav.compliance': 'Comply', 'nav.drift': 'Drift', 'nav.ai': 'AI',
+ 'theme.dark': 'Dark mode', 'theme.light': 'Light mode', 'theme.toggle': 'Toggle colour theme',
+ 'menu.open': 'Open menu', 'menu.close': 'Close menu', 'nav.primary': 'Primary navigation',
+ 'language.label': 'Language', 'language.en': 'English', 'language.es': 'Español',
+ 'page.monitoring.title': 'Security Monitoring', 'page.monitoring.subtitle': 'Overall health score and trends',
+ 'page.discovery.title': 'Resource Discovery', 'page.discovery.subtitle': 'All resources across your Azure environment',
+ 'page.prioritization.title': 'Risk Prioritization', 'page.prioritization.subtitle': 'What to fix first based on risk and effort',
+ 'page.scan.title': 'Detailed Scan', 'page.scan.subtitle': 'Findings with step-by-step remediation playbooks',
+ 'page.compliance.title': 'Compliance', 'page.compliance.subtitle': 'Framework tracking and control status',
+ 'page.drift.title': 'Configuration Drift', 'page.drift.subtitle': 'Detect unexpected changes to your environment',
+ 'page.ai.title': 'AI Assistant', 'page.ai.subtitle': 'Ask questions about your security posture',
+ 'scan.run': 'Run Scan', 'scan.scanning': 'Scanning… {seconds}s', 'scan.last': 'Last scanned: {date}',
+ 'status.live': 'Live', 'status.reconnecting': 'Reconnecting', 'skip.content': 'Skip to main content',
+ },
+ es: {
+ 'nav.monitoring': 'Monitorear', 'nav.discovery': 'Descubrir', 'nav.prioritization': 'Priorizar',
+ 'nav.scan': 'Escanear', 'nav.compliance': 'Cumplimiento', 'nav.drift': 'Cambios', 'nav.ai': 'IA',
+ 'theme.dark': 'Modo oscuro', 'theme.light': 'Modo claro', 'theme.toggle': 'Cambiar tema de color',
+ 'menu.open': 'Abrir menú', 'menu.close': 'Cerrar menú', 'nav.primary': 'Navegación principal',
+ 'language.label': 'Idioma', 'language.en': 'English', 'language.es': 'Español',
+ 'page.monitoring.title': 'Monitoreo de seguridad', 'page.monitoring.subtitle': 'Puntuación general y tendencias',
+ 'page.discovery.title': 'Descubrimiento de recursos', 'page.discovery.subtitle': 'Recursos del entorno de Azure',
+ 'page.prioritization.title': 'Priorización de riesgos', 'page.prioritization.subtitle': 'Qué corregir primero según riesgo y esfuerzo',
+ 'page.scan.title': 'Escaneo detallado', 'page.scan.subtitle': 'Hallazgos y guías de corrección',
+ 'page.compliance.title': 'Cumplimiento', 'page.compliance.subtitle': 'Controles y marcos de cumplimiento',
+ 'page.drift.title': 'Cambios de configuración', 'page.drift.subtitle': 'Cambios inesperados del entorno',
+ 'page.ai.title': 'Asistente de IA', 'page.ai.subtitle': 'Preguntas sobre la postura de seguridad',
+ 'scan.run': 'Ejecutar escaneo', 'scan.scanning': 'Escaneando… {seconds}s', 'scan.last': 'Último escaneo: {date}',
+ 'status.live': 'En línea', 'status.reconnecting': 'Reconectando', 'skip.content': 'Saltar al contenido principal',
+ },
+};
+
+export function translate(locale, key, values = {}) {
+ const template = messages[locale]?.[key] ?? messages[DEFAULT_LOCALE][key] ?? key;
+ return Object.entries(values).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), template);
+}
diff --git a/frontend/src/i18n/messages.test.mjs b/frontend/src/i18n/messages.test.mjs
new file mode 100644
index 0000000..5d09033
--- /dev/null
+++ b/frontend/src/i18n/messages.test.mjs
@@ -0,0 +1,11 @@
+import assert from 'node:assert/strict';
+import { DEFAULT_LOCALE, messages, translate } from './messages.js';
+
+const referenceKeys = Object.keys(messages[DEFAULT_LOCALE]).sort();
+for (const [locale, catalog] of Object.entries(messages)) {
+ assert.deepEqual(Object.keys(catalog).sort(), referenceKeys, `${locale} must contain the complete message catalog`);
+}
+assert.equal(translate('es', 'nav.monitoring'), 'Monitorear');
+assert.equal(translate('unknown', 'nav.monitoring'), 'Monitor');
+assert.equal(translate('en', 'scan.scanning', { seconds: 12 }), 'Scanning… 12s');
+console.log('i18n catalogs valid');
diff --git a/frontend/src/pages/AILayer.jsx b/frontend/src/pages/AILayer.jsx
index ed37840..6efa514 100644
--- a/frontend/src/pages/AILayer.jsx
+++ b/frontend/src/pages/AILayer.jsx
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
-import { useLocation } from 'react-router-dom';
+import { useLocation } from 'react-router';
import { FiCpu, FiX, FiAlertCircle, FiKey, FiCheckCircle } from 'react-icons/fi';
import { api } from '../utils/api';
import { aiApi, aiSettings } from '../utils/aiApi';
diff --git a/frontend/src/pages/DetailedScan.jsx b/frontend/src/pages/DetailedScan.jsx
index 41c8fee..a84c43f 100644
--- a/frontend/src/pages/DetailedScan.jsx
+++ b/frontend/src/pages/DetailedScan.jsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
-import { useLocation, useNavigate } from 'react-router-dom';
+import { useLocation, useNavigate } from 'react-router';
import { FiArrowLeft, FiX, FiAlertTriangle } from 'react-icons/fi';
import { api } from '../utils/api';
import FindingHeader from '../components/scan/FindingHeader';
diff --git a/package-lock.json b/package-lock.json
index 3185cd8..c309db6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5,21 +5,14 @@
"packages": {
"": {
"dependencies": {
- "react-router-dom": "^7.18.0"
+ "react-router": "^8.3.0"
}
},
- "node_modules/cookie": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
- "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
- }
+ "node_modules/cookie-es": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
+ "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
+ "license": "MIT"
},
"node_modules/react": {
"version": "19.2.7",
@@ -31,69 +24,26 @@
"node": ">=0.10.0"
}
},
- "node_modules/react-dom": {
- "version": "19.2.7",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
- "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "scheduler": "^0.27.0"
- },
- "peerDependencies": {
- "react": "^19.2.7"
- }
- },
"node_modules/react-router": {
- "version": "7.18.0",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
- "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==",
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz",
+ "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==",
"license": "MIT",
"dependencies": {
- "cookie": "^1.0.1",
- "set-cookie-parser": "^2.6.0"
+ "cookie-es": "^3.1.1"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.22.0"
},
"peerDependencies": {
- "react": ">=18",
- "react-dom": ">=18"
+ "react": ">=19.2.7",
+ "react-dom": ">=19.2.7"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
- },
- "node_modules/react-router-dom": {
- "version": "7.18.0",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz",
- "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==",
- "license": "MIT",
- "dependencies": {
- "react-router": "7.18.0"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "react": ">=18",
- "react-dom": ">=18"
- }
- },
- "node_modules/scheduler": {
- "version": "0.27.0",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
- "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
- "license": "MIT",
- "peer": true
- },
- "node_modules/set-cookie-parser": {
- "version": "2.7.2",
- "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
- "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
- "license": "MIT"
}
}
}
diff --git a/package.json b/package.json
index ab50d95..aab11f6 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,5 @@
{
"dependencies": {
- "react-router-dom": "^7.18.0"
+ "react-router": "^8.3.0"
}
}
diff --git a/playbooks/cli/fix_az_sc_001.sh b/playbooks/cli/fix_az_sc_001.sh
new file mode 100644
index 0000000..52277b1
--- /dev/null
+++ b/playbooks/cli/fix_az_sc_001.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+
+set -euo pipefail
+
+REGISTRY_NAME="${1:-}"
+RESOURCE_GROUP="${2:-}"
+
+if [[ -z "$REGISTRY_NAME" || -z "$RESOURCE_GROUP" ]]; then
+ echo "Usage: $0
"
+ exit 1
+fi
+
+echo "Disabling admin user for Container Registry: $REGISTRY_NAME (RG: $RESOURCE_GROUP)"
+
+az acr update \
+ --name "$REGISTRY_NAME" \
+ --resource-group "$RESOURCE_GROUP" \
+ --admin-enabled false
+
+echo "Admin user disabled successfully."
+echo "Next step: Authenticate with Azure AD identities or a managed identity instead."
diff --git a/playbooks/cli/fix_az_sc_002.sh b/playbooks/cli/fix_az_sc_002.sh
new file mode 100644
index 0000000..884cf73
--- /dev/null
+++ b/playbooks/cli/fix_az_sc_002.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+
+set -euo pipefail
+
+REGISTRY_NAME="${1:-}"
+RESOURCE_GROUP="${2:-}"
+
+if [[ -z "$REGISTRY_NAME" || -z "$RESOURCE_GROUP" ]]; then
+ echo "Usage: $0 "
+ exit 1
+fi
+
+echo "Disabling public network access for Container Registry: $REGISTRY_NAME (RG: $RESOURCE_GROUP)"
+
+az acr update \
+ --name "$REGISTRY_NAME" \
+ --resource-group "$RESOURCE_GROUP" \
+ --public-network-enabled false
+
+echo "Public network access disabled successfully."
+echo "Next step: Configure a private endpoint if the registry needs to be reachable from a VNet."
diff --git a/playbooks/cli/fix_az_sc_003.sh b/playbooks/cli/fix_az_sc_003.sh
new file mode 100644
index 0000000..800ab64
--- /dev/null
+++ b/playbooks/cli/fix_az_sc_003.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+
+set -euo pipefail
+
+REGISTRY_NAME="${1:-}"
+RESOURCE_GROUP="${2:-}"
+
+if [[ -z "$REGISTRY_NAME" || -z "$RESOURCE_GROUP" ]]; then
+ echo "Usage: $0 "
+ exit 1
+fi
+
+echo "Disabling anonymous pull for Container Registry: $REGISTRY_NAME (RG: $RESOURCE_GROUP)"
+echo "If this registry is intentionally used for public OCI distribution, do not run this script."
+
+az acr update \
+ --name "$REGISTRY_NAME" \
+ --resource-group "$RESOURCE_GROUP" \
+ --anonymous-pull-enabled false
+
+echo "Anonymous pull disabled successfully."
diff --git a/playbooks/cli/fix_az_sc_004.sh b/playbooks/cli/fix_az_sc_004.sh
new file mode 100644
index 0000000..c270710
--- /dev/null
+++ b/playbooks/cli/fix_az_sc_004.sh
@@ -0,0 +1,23 @@
+#!/bin/bash
+
+set -euo pipefail
+
+REGISTRY_NAME="${1:-}"
+RESOURCE_GROUP="${2:-}"
+RETENTION_DAYS="${3:-30}"
+
+if [[ -z "$REGISTRY_NAME" || -z "$RESOURCE_GROUP" ]]; then
+ echo "Usage: $0 [retention-days]"
+ exit 1
+fi
+
+echo "Enabling untagged-manifest retention for Container Registry: $REGISTRY_NAME (RG: $RESOURCE_GROUP)"
+
+az acr config retention update \
+ --registry "$REGISTRY_NAME" \
+ --status enabled \
+ --days "$RETENTION_DAYS"
+
+echo "Retention policy enabled ($RETENTION_DAYS days)."
+echo "Quarantine policy has no dedicated Azure CLI command and requires the Premium SKU."
+echo "Enable it via ARM/Bicep by setting properties.policies.quarantinePolicy.status to 'enabled'."
diff --git a/playbooks/cli/fix_az_sc_005.sh b/playbooks/cli/fix_az_sc_005.sh
new file mode 100644
index 0000000..63417ff
--- /dev/null
+++ b/playbooks/cli/fix_az_sc_005.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+
+set -euo pipefail
+
+ACCOUNT_NAME="${1:-}"
+CONTAINER_NAME="${2:-}"
+
+if [[ -z "$ACCOUNT_NAME" || -z "$CONTAINER_NAME" ]]; then
+ echo "Usage: $0 "
+ exit 1
+fi
+
+echo "Setting public access to Off for container: $CONTAINER_NAME (account: $ACCOUNT_NAME)"
+
+az storage container set-permission \
+ --name "$CONTAINER_NAME" \
+ --account-name "$ACCOUNT_NAME" \
+ --public-access off
+
+echo "Public access disabled successfully."
+echo "Next step: confirm no anonymous access policy or SAS token grants broader access than intended."
diff --git a/playbooks/cli/fix_az_sc_006.sh b/playbooks/cli/fix_az_sc_006.sh
new file mode 100644
index 0000000..9349c73
--- /dev/null
+++ b/playbooks/cli/fix_az_sc_006.sh
@@ -0,0 +1,23 @@
+#!/bin/bash
+
+set -euo pipefail
+
+ACCOUNT_NAME="${1:-}"
+RESOURCE_GROUP="${2:-}"
+RETENTION_DAYS="${3:-30}"
+
+if [[ -z "$ACCOUNT_NAME" || -z "$RESOURCE_GROUP" ]]; then
+ echo "Usage: $0 [retention-days]"
+ exit 1
+fi
+
+echo "Enabling blob versioning and soft delete for storage account: $ACCOUNT_NAME (RG: $RESOURCE_GROUP)"
+
+az storage account blob-service-properties update \
+ --account-name "$ACCOUNT_NAME" \
+ --resource-group "$RESOURCE_GROUP" \
+ --enable-versioning true \
+ --enable-delete-retention true \
+ --delete-retention-days "$RETENTION_DAYS"
+
+echo "Blob versioning and soft delete ($RETENTION_DAYS-day retention) enabled successfully."
diff --git a/playbooks/cli/fix_az_sc_007.sh b/playbooks/cli/fix_az_sc_007.sh
new file mode 100644
index 0000000..0e948e6
--- /dev/null
+++ b/playbooks/cli/fix_az_sc_007.sh
@@ -0,0 +1,25 @@
+#!/bin/bash
+
+set -euo pipefail
+
+echo "Azure DevOps service connections cannot have their scope changed in place; the"
+echo "Azure CLI and REST API only support deleting and re-creating them."
+echo
+echo "1. Identify which pipelines actually need this service connection:"
+echo " az pipelines list --project "
+echo
+echo "2. Create a new service connection scoped to only the resource group each"
+echo " pipeline needs, instead of the whole subscription:"
+echo " az devops service-endpoint azurerm create \\"
+echo " --azure-rm-service-principal-id \\"
+echo " --azure-rm-subscription-id \\"
+echo " --azure-rm-subscription-name \\"
+echo " --azure-rm-tenant-id \\"
+echo " --name \\"
+echo " --project "
+echo
+echo "3. Update each pipeline's YAML or classic definition to reference the new,"
+echo " narrowly scoped connection instead of the shared subscription-wide one."
+echo
+echo "4. Once no pipeline references the old connection, delete it:"
+echo " az devops service-endpoint delete --id --project --yes"
diff --git a/playbooks/cli/fix_az_sc_008.sh b/playbooks/cli/fix_az_sc_008.sh
new file mode 100644
index 0000000..2c98323
--- /dev/null
+++ b/playbooks/cli/fix_az_sc_008.sh
@@ -0,0 +1,27 @@
+#!/bin/bash
+
+set -euo pipefail
+
+echo "Azure DevOps service connections cannot have their authentication scheme"
+echo "changed in place; a password-based connection must be re-created as federated."
+echo
+echo "1. Create a new service connection using workload identity federation:"
+echo " az devops service-endpoint azurerm create \\"
+echo " --azure-rm-service-principal-id \\"
+echo " --azure-rm-subscription-id \\"
+echo " --azure-rm-subscription-name \\"
+echo " --azure-rm-tenant-id \\"
+echo " --service-principal-type federated \\"
+echo " --name \\"
+echo " --project "
+echo
+echo " (Federated auth is also available directly in the Azure DevOps UI:"
+echo " Project Settings > Service connections > New service connection >"
+echo " Azure Resource Manager > Workload identity federation.)"
+echo
+echo "2. Update each pipeline's YAML or classic definition to reference the new,"
+echo " federated connection instead of the password-based one."
+echo
+echo "3. Once no pipeline references the old connection, delete it and revoke the"
+echo " underlying service principal secret it depended on:"
+echo " az devops service-endpoint delete --id --project --yes"
diff --git a/requirements.txt b/requirements.txt
index 321fcb1..bb2abcc 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -21,11 +21,13 @@ pyjwt==2.13.0
requests==2.34.2
PyYAML==6.0.3
gunicorn==26.0.0
-cryptography==49.0.0
+cryptography==50.0.0
msrest==0.7.1
azure-mgmt-postgresqlflexibleservers==1.0.0b1
azure-keyvault-certificates==4.8.0
azure-keyvault-keys==4.9.0
+azure-mgmt-containerregistry==15.0.0
+azure-devops==7.1.0b4
chromadb==0.4.24
numpy<2.0
prometheus-client>=0.19.0
diff --git a/scanner/azure_client.py b/scanner/azure_client.py
index f0e4abb..7e85d52 100644
--- a/scanner/azure_client.py
+++ b/scanner/azure_client.py
@@ -54,6 +54,25 @@ def __init__(self, subscription_id: str, credential: Optional[Any] = None) -> No
self._applications_cache: Any = _UNSET
self._managed_identity_principals_cache: Any = _UNSET
self._subscription_role_assignments_cache: Any = _UNSET
+ self._container_registries_cache: Any = _UNSET
+ self.devops_client = self._build_devops_client()
+
+ def _build_devops_client(self) -> Optional[Any]:
+ """Return a DevOpsClient if AZURE_DEVOPS_ORG_URL and AZURE_DEVOPS_PROJECT
+ are configured, else None. Absence is a deliberate opt-out (not every
+ subscription has an associated Azure DevOps organization), so rules
+ that depend on this must treat None as "not configured, skip" rather
+ than "unknown, indeterminate"."""
+ import os
+
+ org_url = os.environ.get("AZURE_DEVOPS_ORG_URL")
+ project = os.environ.get("AZURE_DEVOPS_PROJECT")
+ if not org_url or not project:
+ return None
+
+ from scanner.devops_client import DevOpsClient
+
+ return DevOpsClient(org_url, project, credential=self.credential)
# ------------------------------------------------------------------ #
# Static helpers #
@@ -662,3 +681,74 @@ def get_network_watcher_regions(self) -> List[str]:
except Exception as exc:
logger.error("get_network_watcher_regions failed: %s", exc)
return []
+
+ # ------------------------------------------------------------------ #
+ # Supply chain: Container Registry, IaC state #
+ # ------------------------------------------------------------------ #
+
+ def get_container_registries(self) -> Optional[List[Any]]:
+ """List Azure Container Registries, preserving an indeterminate failure state.
+
+ Cached for the lifetime of this client because all AZ-SC container
+ registry rules evaluate the same subscription-level collection.
+
+ Returns:
+ A list (including an empty list) when Azure responds successfully,
+ or ``None`` when permissions, networking, or the SDK prevent the
+ collection from being evaluated. Callers must never interpret
+ ``None`` as a compliant result.
+ """
+ if self._container_registries_cache is not _UNSET:
+ return self._container_registries_cache
+
+ try:
+ from azure.mgmt.containerregistry import ContainerRegistryManagementClient
+
+ client = ContainerRegistryManagementClient(self.credential, self.subscription_id)
+ self._container_registries_cache = list(client.registries.list())
+ except Exception as exc:
+ logger.error("get_container_registries failed: %s", exc)
+ self._container_registries_cache = None
+ return self._container_registries_cache
+
+ def get_blob_containers(self, resource_group: str, account_name: str) -> Optional[List[Any]]:
+ """List blob containers for a storage account, including per-container access level.
+
+ Not cached: unlike the subscription-wide collections above, this is
+ called once per storage account rather than once per scan.
+
+ Returns:
+ A list of container items, or ``None`` when the call fails
+ (permissions, throttling, storage account not found).
+ """
+ try:
+ client = StorageManagementClient(self.credential, self.subscription_id)
+ return list(client.blob_containers.list(resource_group, account_name))
+ except Exception as exc:
+ logger.error(
+ "get_blob_containers(%s/%s) failed: %s",
+ resource_group,
+ account_name,
+ exc,
+ )
+ return None
+
+ def get_blob_service_properties(self, resource_group: str, account_name: str) -> Optional[Any]:
+ """Return account-wide blob service properties (versioning, soft-delete retention).
+
+ Not cached, for the same reason as get_blob_containers above.
+
+ Returns:
+ A BlobServiceProperties object, or ``None`` when the call fails.
+ """
+ try:
+ client = StorageManagementClient(self.credential, self.subscription_id)
+ return client.blob_services.get_service_properties(resource_group, account_name)
+ except Exception as exc:
+ logger.error(
+ "get_blob_service_properties(%s/%s) failed: %s",
+ resource_group,
+ account_name,
+ exc,
+ )
+ return None
diff --git a/scanner/devops_client.py b/scanner/devops_client.py
new file mode 100644
index 0000000..382a5de
--- /dev/null
+++ b/scanner/devops_client.py
@@ -0,0 +1,79 @@
+"""Azure DevOps API wrapper for Supply Chain pipeline/service-connection rules.
+
+Separate from AzureClient because Azure DevOps lives at dev.azure.com, not
+Azure Resource Manager, and needs its own configuration (organization URL,
+project name) that an ARM subscription ID does not carry. Reuses the same
+DefaultAzureCredential already used for the Azure subscription, scoped to
+Azure DevOps' well-known Entra ID resource ID, so no separate stored
+credential (e.g. a PAT) is required.
+"""
+
+import logging
+from typing import Any, List, Optional
+
+logger = logging.getLogger(__name__)
+
+# Well-known Entra ID resource ID for Azure DevOps. Used as the OAuth scope
+# when requesting a token from the same credential AzureClient already uses.
+AZURE_DEVOPS_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"
+
+_UNSET = object()
+
+
+class DevOpsClient:
+ """Wraps the Azure DevOps Service Endpoint API for pipeline scan rules.
+
+ Instantiate once per scan (alongside AzureClient) and share across all
+ Supply Chain rule modules that need Azure DevOps data. Every method logs
+ on failure and returns None so an unreachable or unconfigured Azure
+ DevOps organization never crashes the scan engine.
+ """
+
+ def __init__(self, organization_url: str, project: str, credential: Optional[Any] = None) -> None:
+ """
+ Args:
+ organization_url: e.g. "https://dev.azure.com/my-org".
+ project: The Azure DevOps project name or ID to scan.
+ credential: A TokenCredential. Defaults to DefaultAzureCredential,
+ matching AzureClient's default.
+ """
+ self.organization_url = organization_url
+ self.project = project
+ if credential is None:
+ from azure.identity import DefaultAzureCredential
+
+ credential = DefaultAzureCredential()
+ self.credential = credential
+ self._service_endpoints_cache: Any = _UNSET
+
+ def _get_connection(self) -> Any:
+ from azure.devops.connection import Connection
+ from azure.devops.credentials import OAuthTokenAuthentication
+
+ access_token = self.credential.get_token(f"{AZURE_DEVOPS_RESOURCE_ID}/.default")
+ auth = OAuthTokenAuthentication(AZURE_DEVOPS_RESOURCE_ID, {"access_token": access_token.token})
+ return Connection(base_url=self.organization_url, creds=auth)
+
+ def get_service_endpoints(self) -> Optional[List[Any]]:
+ """Return pipeline service connections for the configured project.
+
+ Cached for the lifetime of this client because both AZ-SC service
+ connection rules evaluate the same project-level collection.
+
+ Returns:
+ A list (including an empty list) when Azure DevOps responds
+ successfully, or ``None`` when auth, permissions, or the SDK
+ prevent the collection from being evaluated. Callers must never
+ interpret ``None`` as a compliant result.
+ """
+ if self._service_endpoints_cache is not _UNSET:
+ return self._service_endpoints_cache
+
+ try:
+ connection = self._get_connection()
+ client = connection.clients_v7_1.get_service_endpoint_client()
+ self._service_endpoints_cache = list(client.get_service_endpoints(project=self.project))
+ except Exception as exc:
+ logger.error("get_service_endpoints failed for project %s: %s", self.project, exc)
+ self._service_endpoints_cache = None
+ return self._service_endpoints_cache
diff --git a/scanner/rules/az_idn_006.py b/scanner/rules/az_idn_006.py
index ebffe97..231f357 100644
--- a/scanner/rules/az_idn_006.py
+++ b/scanner/rules/az_idn_006.py
@@ -71,9 +71,8 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]:
already_expired = end_dt < now
except ValueError:
logger.debug(
- "AZ-IDN-006: Invalid endDateTime for app_id=%s: %r",
+ "AZ-IDN-006: Invalid endDateTime format for app_id=%s",
app_id,
- end_dt_str,
)
if not (age_days >= EXPIRY_THRESHOLD_DAYS or no_expiry or already_expired):
diff --git a/scanner/rules/az_sc_001.py b/scanner/rules/az_sc_001.py
new file mode 100644
index 0000000..f24b7fc
--- /dev/null
+++ b/scanner/rules/az_sc_001.py
@@ -0,0 +1,70 @@
+"""AZ-SC-001: Azure Container Registry admin user enabled."""
+
+import logging
+from typing import Any, Dict, List
+
+RULE_ID = "AZ-SC-001"
+RULE_NAME = "Container Registry Admin User Enabled"
+SEVERITY = "HIGH"
+CATEGORY = "Supply Chain"
+FRAMEWORKS = {"CIS": "TBD-SC-001", "NIST": "PR.AC-1", "ISO27001": "A.9.2.1", "SOC2": "CC6.1"}
+
+DESCRIPTION = (
+ "The Azure Container Registry has the admin user enabled. The admin account is a single "
+ "shared, non-attributable credential that bypasses Azure RBAC entirely, so registry pushes "
+ "and pulls made with it cannot be tied to an individual identity or revoked without affecting "
+ "every other user of the same credential."
+)
+
+REMEDIATION = (
+ "Disable the admin user and authenticate to the registry with Azure AD identities or a "
+ "managed identity instead: az acr update --name --admin-enabled false"
+)
+
+PLAYBOOK = "playbooks/cli/fix_az_sc_001.sh"
+
+logger = logging.getLogger(__name__)
+
+
+def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]:
+ """Detect Container Registries with the admin user enabled."""
+ findings: List[Dict[str, Any]] = []
+
+ registries = azure_client.get_container_registries()
+ if registries is None:
+ logger.warning("%s: container registries could not be enumerated", RULE_ID)
+ return findings
+
+ for registry in registries:
+ props = getattr(registry, "properties", None)
+ if props is None:
+ continue
+
+ admin_enabled = getattr(props, "admin_user_enabled", None)
+ if admin_enabled is None:
+ logger.warning("%s: admin_user_enabled unknown for %s", RULE_ID, getattr(registry, "name", "?"))
+ continue
+
+ if admin_enabled:
+ parsed = azure_client.parse_resource_id(getattr(registry, "id", ""))
+ findings.append(
+ {
+ "rule_id": RULE_ID,
+ "rule_name": RULE_NAME,
+ "severity": SEVERITY,
+ "category": CATEGORY,
+ "resource_id": getattr(registry, "id", ""),
+ "resource_name": getattr(registry, "name", ""),
+ "resource_type": "Microsoft.ContainerRegistry/registries",
+ "description": DESCRIPTION,
+ "remediation": REMEDIATION,
+ "playbook": PLAYBOOK,
+ "frameworks": FRAMEWORKS,
+ "metadata": {
+ "resource_group": parsed.get("resource_group", ""),
+ "admin_user_enabled": True,
+ },
+ }
+ )
+
+ return findings
diff --git a/scanner/rules/az_sc_002.py b/scanner/rules/az_sc_002.py
new file mode 100644
index 0000000..f943382
--- /dev/null
+++ b/scanner/rules/az_sc_002.py
@@ -0,0 +1,71 @@
+"""AZ-SC-002: Azure Container Registry allows public network access."""
+
+import logging
+from typing import Any, Dict, List
+
+from scanner.azure_client import enum_str
+
+RULE_ID = "AZ-SC-002"
+RULE_NAME = "Container Registry Public Network Access Enabled"
+SEVERITY = "HIGH"
+CATEGORY = "Supply Chain"
+FRAMEWORKS = {"CIS": "TBD-SC-002", "NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}
+
+DESCRIPTION = (
+ "The Azure Container Registry is reachable from the public internet. A registry that holds "
+ "the container images an organization builds and deploys should only be reachable from "
+ "trusted networks, the same way source code and build systems are."
+)
+
+REMEDIATION = (
+ "Disable public network access and expose the registry through a private endpoint instead: "
+ "az acr update --name --public-network-enabled false"
+)
+
+PLAYBOOK = "playbooks/cli/fix_az_sc_002.sh"
+
+logger = logging.getLogger(__name__)
+
+
+def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]:
+ """Detect Container Registries with public network access enabled."""
+ findings: List[Dict[str, Any]] = []
+
+ registries = azure_client.get_container_registries()
+ if registries is None:
+ logger.warning("%s: container registries could not be enumerated", RULE_ID)
+ return findings
+
+ for registry in registries:
+ props = getattr(registry, "properties", None)
+ if props is None:
+ continue
+
+ public_access = enum_str(getattr(props, "public_network_access", None))
+ if not public_access:
+ logger.warning("%s: public_network_access unknown for %s", RULE_ID, getattr(registry, "name", "?"))
+ continue
+
+ if public_access.lower() == "enabled":
+ parsed = azure_client.parse_resource_id(getattr(registry, "id", ""))
+ findings.append(
+ {
+ "rule_id": RULE_ID,
+ "rule_name": RULE_NAME,
+ "severity": SEVERITY,
+ "category": CATEGORY,
+ "resource_id": getattr(registry, "id", ""),
+ "resource_name": getattr(registry, "name", ""),
+ "resource_type": "Microsoft.ContainerRegistry/registries",
+ "description": DESCRIPTION,
+ "remediation": REMEDIATION,
+ "playbook": PLAYBOOK,
+ "frameworks": FRAMEWORKS,
+ "metadata": {
+ "resource_group": parsed.get("resource_group", ""),
+ "public_network_access": public_access,
+ },
+ }
+ )
+
+ return findings
diff --git a/scanner/rules/az_sc_003.py b/scanner/rules/az_sc_003.py
new file mode 100644
index 0000000..ff7bd9c
--- /dev/null
+++ b/scanner/rules/az_sc_003.py
@@ -0,0 +1,71 @@
+"""AZ-SC-003: Azure Container Registry allows anonymous pull."""
+
+import logging
+from typing import Any, Dict, List
+
+RULE_ID = "AZ-SC-003"
+RULE_NAME = "Container Registry Allows Anonymous Pull"
+SEVERITY = "HIGH"
+CATEGORY = "Supply Chain"
+FRAMEWORKS = {"CIS": "TBD-SC-003", "NIST": "PR.AC-1", "ISO27001": "A.9.2.1", "SOC2": "CC6.1"}
+
+DESCRIPTION = (
+ "The Azure Container Registry allows anonymous pull, so any client on the network can pull "
+ "every image in the registry without authenticating. Repository-scoped tokens cannot limit "
+ "this once it is enabled; the setting applies registry-wide. If this registry is intentionally "
+ "used for public OCI distribution, treat this finding as an accepted exception rather than a "
+ "defect."
+)
+
+REMEDIATION = (
+ "Disable anonymous pull unless the registry is deliberately used for public distribution: "
+ "az acr update --name --anonymous-pull-enabled false"
+)
+
+PLAYBOOK = "playbooks/cli/fix_az_sc_003.sh"
+
+logger = logging.getLogger(__name__)
+
+
+def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]:
+ """Detect Container Registries with anonymous pull enabled."""
+ findings: List[Dict[str, Any]] = []
+
+ registries = azure_client.get_container_registries()
+ if registries is None:
+ logger.warning("%s: container registries could not be enumerated", RULE_ID)
+ return findings
+
+ for registry in registries:
+ props = getattr(registry, "properties", None)
+ if props is None:
+ continue
+
+ anonymous_pull = getattr(props, "anonymous_pull_enabled", None)
+ if anonymous_pull is None:
+ logger.warning("%s: anonymous_pull_enabled unknown for %s", RULE_ID, getattr(registry, "name", "?"))
+ continue
+
+ if anonymous_pull:
+ parsed = azure_client.parse_resource_id(getattr(registry, "id", ""))
+ findings.append(
+ {
+ "rule_id": RULE_ID,
+ "rule_name": RULE_NAME,
+ "severity": SEVERITY,
+ "category": CATEGORY,
+ "resource_id": getattr(registry, "id", ""),
+ "resource_name": getattr(registry, "name", ""),
+ "resource_type": "Microsoft.ContainerRegistry/registries",
+ "description": DESCRIPTION,
+ "remediation": REMEDIATION,
+ "playbook": PLAYBOOK,
+ "frameworks": FRAMEWORKS,
+ "metadata": {
+ "resource_group": parsed.get("resource_group", ""),
+ "anonymous_pull_enabled": True,
+ },
+ }
+ )
+
+ return findings
diff --git a/scanner/rules/az_sc_004.py b/scanner/rules/az_sc_004.py
new file mode 100644
index 0000000..4d40fb1
--- /dev/null
+++ b/scanner/rules/az_sc_004.py
@@ -0,0 +1,91 @@
+"""AZ-SC-004: Azure Container Registry has no image retention or quarantine policy."""
+
+import logging
+from typing import Any, Dict, List
+
+RULE_ID = "AZ-SC-004"
+RULE_NAME = "Container Registry Missing Retention or Quarantine Policy"
+SEVERITY = "MEDIUM"
+CATEGORY = "Supply Chain"
+FRAMEWORKS = {"CIS": "TBD-SC-004", "NIST": "PR.IP-1", "ISO27001": "A.12.1.2", "SOC2": "CC7.1"}
+
+DESCRIPTION = (
+ "The Azure Container Registry has no retention policy for untagged manifests, so stale and "
+ "orphaned images accumulate indefinitely, widening the pool of images that can be deployed. "
+ "On Premium-tier registries that also lack a quarantine policy, a newly pushed image is "
+ "pullable and deployable before any vulnerability scan has evaluated it."
+)
+
+REMEDIATION = (
+ "Enable a retention policy to purge untagged manifests after a defined window: "
+ "az acr config retention update --registry --status enabled --days 30. "
+ "On Premium-tier registries, also enable the quarantine policy so pushed images are held "
+ "until scanned."
+)
+
+PLAYBOOK = "playbooks/cli/fix_az_sc_004.sh"
+
+logger = logging.getLogger(__name__)
+
+_PREMIUM_TIER = "premium"
+
+
+def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]:
+ """Detect Container Registries missing a retention policy, or (Premium only) a quarantine policy."""
+ findings: List[Dict[str, Any]] = []
+
+ registries = azure_client.get_container_registries()
+ if registries is None:
+ logger.warning("%s: container registries could not be enumerated", RULE_ID)
+ return findings
+
+ for registry in registries:
+ props = getattr(registry, "properties", None)
+ if props is None:
+ continue
+
+ sku = getattr(registry, "sku", None)
+ tier = str(getattr(sku, "tier", "") or "").lower() if sku is not None else ""
+ is_premium = tier == _PREMIUM_TIER
+
+ policies = getattr(props, "policies", None)
+ retention_status = None
+ quarantine_status = None
+ if policies is not None:
+ retention = getattr(policies, "retention_policy", None)
+ quarantine = getattr(policies, "quarantine_policy", None)
+ retention_status = getattr(retention, "status", None) if retention is not None else None
+ quarantine_status = getattr(quarantine, "status", None) if quarantine is not None else None
+
+ retention_enabled = str(retention_status or "").lower() == "enabled"
+ quarantine_enabled = str(quarantine_status or "").lower() == "enabled"
+
+ # Quarantine is a Premium-only feature; a Basic/Standard registry can
+ # never satisfy it, so only require it on Premium registries.
+ is_missing = not retention_enabled or (is_premium and not quarantine_enabled)
+
+ if is_missing:
+ parsed = azure_client.parse_resource_id(getattr(registry, "id", ""))
+ findings.append(
+ {
+ "rule_id": RULE_ID,
+ "rule_name": RULE_NAME,
+ "severity": SEVERITY,
+ "category": CATEGORY,
+ "resource_id": getattr(registry, "id", ""),
+ "resource_name": getattr(registry, "name", ""),
+ "resource_type": "Microsoft.ContainerRegistry/registries",
+ "description": DESCRIPTION,
+ "remediation": REMEDIATION,
+ "playbook": PLAYBOOK,
+ "frameworks": FRAMEWORKS,
+ "metadata": {
+ "resource_group": parsed.get("resource_group", ""),
+ "sku_tier": tier,
+ "retention_policy_enabled": retention_enabled,
+ "quarantine_policy_enabled": quarantine_enabled if is_premium else None,
+ },
+ }
+ )
+
+ return findings
diff --git a/scanner/rules/az_sc_005.py b/scanner/rules/az_sc_005.py
new file mode 100644
index 0000000..1c0230c
--- /dev/null
+++ b/scanner/rules/az_sc_005.py
@@ -0,0 +1,85 @@
+"""AZ-SC-005: Terraform state storage container is publicly readable."""
+
+import logging
+import re
+from typing import Any, Dict, List
+
+RULE_ID = "AZ-SC-005"
+RULE_NAME = "Terraform State Storage Container Publicly Readable"
+SEVERITY = "CRITICAL"
+CATEGORY = "Supply Chain"
+FRAMEWORKS = {"CIS": "TBD-SC-005", "NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}
+
+DESCRIPTION = (
+ "A blob container that appears to hold Terraform remote state (matched by name) allows "
+ "public read access. Terraform state files commonly contain resource IDs, connection "
+ "strings, and in some provider configurations plaintext secrets. Public read access on the "
+ "state backend can expose the full infrastructure layout and any secrets it captured."
+)
+
+REMEDIATION = (
+ "Set the container's public access level to Private and confirm no anonymous read policy is "
+ "attached: az storage container set-permission --name --account-name "
+ "--public-access off"
+)
+
+PLAYBOOK = "playbooks/cli/fix_az_sc_005.sh"
+
+logger = logging.getLogger(__name__)
+
+# Matches container names commonly used for a Terraform remote state backend,
+# e.g. "tfstate", "terraform-state", "tf-state-prod".
+_TFSTATE_NAME_PATTERN = re.compile(r"tf[-_]?state|terraform[-_]?state", re.IGNORECASE)
+
+
+def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]:
+ """Detect publicly readable blob containers that look like Terraform state backends."""
+ findings: List[Dict[str, Any]] = []
+
+ for account in azure_client.get_storage_accounts():
+ account_id = getattr(account, "id", "")
+ account_name = getattr(account, "name", "")
+ if not account_id or not account_name:
+ continue
+
+ parsed = azure_client.parse_resource_id(account_id)
+ resource_group = parsed.get("resource_group", "")
+ if not resource_group:
+ continue
+
+ containers = azure_client.get_blob_containers(resource_group, account_name)
+ if containers is None:
+ logger.warning("%s: blob containers unknown for %s", RULE_ID, account_name)
+ continue
+
+ for container in containers:
+ container_name = getattr(container, "name", "")
+ if not _TFSTATE_NAME_PATTERN.search(container_name):
+ continue
+
+ props = getattr(container, "container_properties", None) or container
+ public_access = str(getattr(props, "public_access", "") or "").lower()
+ if public_access in ("container", "blob"):
+ findings.append(
+ {
+ "rule_id": RULE_ID,
+ "rule_name": RULE_NAME,
+ "severity": SEVERITY,
+ "category": CATEGORY,
+ "resource_id": f"{account_id}/blobServices/default/containers/{container_name}",
+ "resource_name": f"{account_name}/{container_name}",
+ "resource_type": "Microsoft.Storage/storageAccounts/blobServices/containers",
+ "description": DESCRIPTION,
+ "remediation": REMEDIATION,
+ "playbook": PLAYBOOK,
+ "frameworks": FRAMEWORKS,
+ "metadata": {
+ "resource_group": resource_group,
+ "storage_account": account_name,
+ "container_name": container_name,
+ "public_access": public_access,
+ },
+ }
+ )
+
+ return findings
diff --git a/scanner/rules/az_sc_006.py b/scanner/rules/az_sc_006.py
new file mode 100644
index 0000000..dec2cd4
--- /dev/null
+++ b/scanner/rules/az_sc_006.py
@@ -0,0 +1,91 @@
+"""AZ-SC-006: Terraform state storage account has no versioning or soft delete."""
+
+import logging
+import re
+from typing import Any, Dict, List
+
+RULE_ID = "AZ-SC-006"
+RULE_NAME = "Terraform State Storage Account Missing Versioning or Soft Delete"
+SEVERITY = "HIGH"
+CATEGORY = "Supply Chain"
+FRAMEWORKS = {"CIS": "TBD-SC-006", "NIST": "PR.IP-4", "ISO27001": "A.12.3.1", "SOC2": "A1.2"}
+
+DESCRIPTION = (
+ "A storage account holding a container that appears to be a Terraform remote state backend "
+ "has neither blob versioning nor blob soft delete enabled. Azure does not support these "
+ "settings per container, only account-wide. Without either, an overwritten or accidentally "
+ "deleted state file cannot be recovered, which can leave Terraform unable to reconcile its "
+ "understanding of deployed infrastructure with reality."
+)
+
+REMEDIATION = (
+ "Enable blob versioning and soft delete on the storage account: "
+ "az storage account blob-service-properties update --account-name "
+ "--enable-versioning true --enable-delete-retention true --delete-retention-days 30"
+)
+
+PLAYBOOK = "playbooks/cli/fix_az_sc_006.sh"
+
+logger = logging.getLogger(__name__)
+
+_TFSTATE_NAME_PATTERN = re.compile(r"tf[-_]?state|terraform[-_]?state", re.IGNORECASE)
+
+
+def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]:
+ """Detect Terraform state storage accounts missing versioning or soft delete."""
+ findings: List[Dict[str, Any]] = []
+
+ for account in azure_client.get_storage_accounts():
+ account_id = getattr(account, "id", "")
+ account_name = getattr(account, "name", "")
+ if not account_id or not account_name:
+ continue
+
+ parsed = azure_client.parse_resource_id(account_id)
+ resource_group = parsed.get("resource_group", "")
+ if not resource_group:
+ continue
+
+ containers = azure_client.get_blob_containers(resource_group, account_name)
+ if containers is None:
+ logger.warning("%s: blob containers unknown for %s", RULE_ID, account_name)
+ continue
+
+ has_state_container = any(
+ _TFSTATE_NAME_PATTERN.search(getattr(container, "name", "") or "") for container in containers
+ )
+ if not has_state_container:
+ continue
+
+ blob_service = azure_client.get_blob_service_properties(resource_group, account_name)
+ if blob_service is None:
+ logger.warning("%s: blob service properties unknown for %s", RULE_ID, account_name)
+ continue
+
+ versioning_enabled = bool(getattr(blob_service, "is_versioning_enabled", False))
+ retention_policy = getattr(blob_service, "delete_retention_policy", None)
+ soft_delete_enabled = bool(getattr(retention_policy, "enabled", False)) if retention_policy else False
+
+ if not (versioning_enabled or soft_delete_enabled):
+ findings.append(
+ {
+ "rule_id": RULE_ID,
+ "rule_name": RULE_NAME,
+ "severity": SEVERITY,
+ "category": CATEGORY,
+ "resource_id": account_id,
+ "resource_name": account_name,
+ "resource_type": "Microsoft.Storage/storageAccounts",
+ "description": DESCRIPTION,
+ "remediation": REMEDIATION,
+ "playbook": PLAYBOOK,
+ "frameworks": FRAMEWORKS,
+ "metadata": {
+ "resource_group": resource_group,
+ "versioning_enabled": versioning_enabled,
+ "soft_delete_enabled": soft_delete_enabled,
+ },
+ }
+ )
+
+ return findings
diff --git a/scanner/rules/az_sc_007.py b/scanner/rules/az_sc_007.py
new file mode 100644
index 0000000..e41bdba
--- /dev/null
+++ b/scanner/rules/az_sc_007.py
@@ -0,0 +1,75 @@
+"""AZ-SC-007: Pipeline service connection scoped to the whole subscription."""
+
+import logging
+from typing import Any, Dict, List
+
+RULE_ID = "AZ-SC-007"
+RULE_NAME = "Pipeline Service Connection Scoped to Subscription"
+SEVERITY = "HIGH"
+CATEGORY = "Supply Chain"
+FRAMEWORKS = {"CIS": "TBD-SC-007", "NIST": "PR.AC-4", "ISO27001": "A.9.2.3", "SOC2": "CC6.1"}
+
+DESCRIPTION = (
+ "An Azure DevOps service connection is scoped to the entire subscription rather than a "
+ "single resource group. A service principal deploying a single App Service does not need "
+ "Contributor on the whole subscription; every pipeline that uses this connection inherits "
+ "subscription-wide access, including pipelines that only need to touch one resource group."
+)
+
+REMEDIATION = (
+ "Re-create the service connection scoped to the resource group each pipeline actually needs "
+ "instead of the whole subscription. See Azure DevOps: Project Settings > Service connections > "
+ "New service connection > Azure Resource Manager > Resource Group."
+)
+
+PLAYBOOK = "playbooks/cli/fix_az_sc_007.sh"
+
+logger = logging.getLogger(__name__)
+
+
+def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]:
+ """Detect Azure DevOps service connections scoped to the whole subscription."""
+ findings: List[Dict[str, Any]] = []
+
+ devops_client = getattr(azure_client, "devops_client", None)
+ if devops_client is None:
+ return findings
+
+ endpoints = devops_client.get_service_endpoints()
+ if endpoints is None:
+ logger.warning("%s: Azure DevOps service endpoints could not be enumerated", RULE_ID)
+ return findings
+
+ for endpoint in endpoints:
+ endpoint_type = getattr(endpoint, "type", "") or ""
+ if endpoint_type.lower() != "azurerm":
+ continue
+
+ data = getattr(endpoint, "data", None) or {}
+ scope_level = str(data.get("scopeLevel", "")).lower()
+
+ if scope_level == "subscription":
+ endpoint_id = getattr(endpoint, "id", "")
+ endpoint_name = getattr(endpoint, "name", "")
+ findings.append(
+ {
+ "rule_id": RULE_ID,
+ "rule_name": RULE_NAME,
+ "severity": SEVERITY,
+ "category": CATEGORY,
+ "resource_id": f"azuredevops:serviceendpoint/{endpoint_id}",
+ "resource_name": endpoint_name,
+ "resource_type": "AzureDevOps/ServiceEndpoint",
+ "description": DESCRIPTION,
+ "remediation": REMEDIATION,
+ "playbook": PLAYBOOK,
+ "frameworks": FRAMEWORKS,
+ "metadata": {
+ "scope_level": scope_level,
+ "is_shared_with_other_projects": bool(getattr(endpoint, "is_shared", False)),
+ "subscription_id": data.get("subscriptionId", ""),
+ },
+ }
+ )
+
+ return findings
diff --git a/scanner/rules/az_sc_008.py b/scanner/rules/az_sc_008.py
new file mode 100644
index 0000000..42856aa
--- /dev/null
+++ b/scanner/rules/az_sc_008.py
@@ -0,0 +1,81 @@
+"""AZ-SC-008: Pipeline service connection uses a password/secret instead of a federated credential."""
+
+import logging
+from typing import Any, Dict, List
+
+RULE_ID = "AZ-SC-008"
+RULE_NAME = "Pipeline Service Connection Uses Password Instead of Federated Credential"
+SEVERITY = "MEDIUM"
+CATEGORY = "Supply Chain"
+FRAMEWORKS = {"CIS": "TBD-SC-008", "NIST": "PR.AC-1", "ISO27001": "A.9.4.3", "SOC2": "CC6.1"}
+
+DESCRIPTION = (
+ "An Azure DevOps service connection authenticates with a stored service principal secret "
+ "instead of a secretless authentication scheme (workload identity federation or a managed "
+ "identity). A secretless scheme has nothing to rotate or leak. A stored secret expires after "
+ "a fixed period, must be rotated manually, and can be exposed through pipeline logs or "
+ "variable misconfiguration in the meantime."
+)
+
+REMEDIATION = (
+ "Re-create the service connection using workload identity federation or a managed identity "
+ "instead of a service principal secret. See: az devops service-endpoint azurerm create with "
+ "--service-principal-type federated, or the equivalent option in the Azure DevOps UI."
+)
+
+PLAYBOOK = "playbooks/cli/fix_az_sc_008.sh"
+
+logger = logging.getLogger(__name__)
+
+# Both schemes are secretless: workload identity federation (OIDC) and managed
+# identity. Only a plain "ServicePrincipal" scheme relies on a stored secret.
+_SECRETLESS_SCHEMES = {"workloadidentityfederation", "managedserviceidentity"}
+
+
+def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]:
+ """Detect Azure DevOps service connections using a password/secret scheme."""
+ findings: List[Dict[str, Any]] = []
+
+ devops_client = getattr(azure_client, "devops_client", None)
+ if devops_client is None:
+ return findings
+
+ endpoints = devops_client.get_service_endpoints()
+ if endpoints is None:
+ logger.warning("%s: Azure DevOps service endpoints could not be enumerated", RULE_ID)
+ return findings
+
+ for endpoint in endpoints:
+ endpoint_type = getattr(endpoint, "type", "") or ""
+ if endpoint_type.lower() != "azurerm":
+ continue
+
+ authorization = getattr(endpoint, "authorization", None)
+ scheme = str(getattr(authorization, "scheme", "") or "").lower() if authorization else ""
+ if not scheme:
+ logger.warning("%s: authorization scheme unknown for %s", RULE_ID, getattr(endpoint, "name", "?"))
+ continue
+
+ if scheme not in _SECRETLESS_SCHEMES:
+ endpoint_id = getattr(endpoint, "id", "")
+ endpoint_name = getattr(endpoint, "name", "")
+ findings.append(
+ {
+ "rule_id": RULE_ID,
+ "rule_name": RULE_NAME,
+ "severity": SEVERITY,
+ "category": CATEGORY,
+ "resource_id": f"azuredevops:serviceendpoint/{endpoint_id}",
+ "resource_name": endpoint_name,
+ "resource_type": "AzureDevOps/ServiceEndpoint",
+ "description": DESCRIPTION,
+ "remediation": REMEDIATION,
+ "playbook": PLAYBOOK,
+ "frameworks": FRAMEWORKS,
+ "metadata": {
+ "authorization_scheme": scheme,
+ },
+ }
+ )
+
+ return findings
diff --git a/scripts/check_dco.py b/scripts/check_dco.py
new file mode 100644
index 0000000..1e5eb8a
--- /dev/null
+++ b/scripts/check_dco.py
@@ -0,0 +1,68 @@
+"""Require a Developer Certificate of Origin sign-off on each PR commit."""
+
+import re
+import subprocess
+import sys
+from typing import Iterable
+
+
+SIGNOFF = re.compile(r"^Signed-off-by:\s+.+\s+<[^<>@\s]+@[^<>\s]+>$", re.IGNORECASE | re.MULTILINE)
+
+
+def has_signoff(message: str) -> bool:
+ """Return whether a commit message contains a well-formed DCO trailer."""
+ return SIGNOFF.search(message) is not None
+
+
+def commits_between(base: str, head: str) -> list[str]:
+ """Return commits introduced between the pull request base and head.
+
+ Excludes merge commits: a `git merge origin/dev` inside a long-running PR
+ branch produces a commit with Git's default merge message and no
+ Signed-off-by trailer, through no fault of the author's own commits.
+ GitHub's own DCO app skips merge commits for the same reason.
+ """
+ output = subprocess.check_output(
+ ["git", "rev-list", "--reverse", "--no-merges", f"{base}..{head}"],
+ text=True,
+ )
+ return [item for item in output.splitlines() if item]
+
+
+def commit_message(commit: str) -> str:
+ """Read one commit message without interpreting its contents as a command."""
+ return subprocess.check_output(
+ ["git", "show", "--no-patch", "--format=%B", commit],
+ text=True,
+ )
+
+
+def unsigned_commits(commits: Iterable[str]) -> list[str]:
+ """Return commit IDs that lack a valid Signed-off-by trailer."""
+ return [commit for commit in commits if not has_signoff(commit_message(commit))]
+
+
+def main() -> int:
+ if len(sys.argv) != 3:
+ print("Usage: check_dco.py ", file=sys.stderr)
+ return 2
+
+ commits = commits_between(sys.argv[1], sys.argv[2])
+ if not commits:
+ print("No pull request commits found.", file=sys.stderr)
+ return 1
+
+ missing = unsigned_commits(commits)
+ if missing:
+ print("The following commits lack a valid DCO Signed-off-by trailer:", file=sys.stderr)
+ for commit in missing:
+ print(f" {commit}", file=sys.stderr)
+ print("Recreate or amend them with: git commit -s", file=sys.stderr)
+ return 1
+
+ print(f"DCO sign-off verified for {len(commits)} commit(s).")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/helpers/mock_azure.py b/tests/helpers/mock_azure.py
index 7cb3aa2..75a6cc9 100644
--- a/tests/helpers/mock_azure.py
+++ b/tests/helpers/mock_azure.py
@@ -85,6 +85,11 @@ def __init__(self) -> None:
self._applications: Optional[List[Dict[str, Any]]] = []
self._managed_identity_principals: Optional[List[Dict[str, Any]]] = []
self._subscription_role_assignments: Optional[List[Any]] = []
+ self._container_registries: Optional[List[Any]] = []
+ self._blob_containers: Dict[Tuple[str, str], Optional[List[Any]]] = {}
+ self._blob_service_properties: Dict[Tuple[str, str], Optional[Any]] = {}
+ # None by default, matching AzureClient.devops_client's "not configured" state.
+ self.devops_client: Optional[Any] = None
# Some rules read azure_client.subscription_id when constructing an
# SDK management client inside scan() (e.g. AZ-NET-007..010).
self.subscription_id = "00000000-0000-0000-0000-000000000001"
@@ -122,6 +127,34 @@ def set_subscription_role_assignments(self, assignments: Optional[List[Any]]) ->
def get_subscription_role_assignments(self) -> Optional[List[Any]]:
return self._subscription_role_assignments
+ def set_container_registries(self, registries: Optional[List[Any]]) -> "MockAzureClient":
+ """Configure Container Registry inventory; ``None`` represents an API failure."""
+ self._container_registries = registries
+ return self
+
+ def get_container_registries(self) -> Optional[List[Any]]:
+ return self._container_registries
+
+ def set_blob_containers(
+ self, resource_group: str, account_name: str, containers: Optional[List[Any]]
+ ) -> "MockAzureClient":
+ """Configure per-account blob container listing; ``None`` represents an API failure."""
+ self._blob_containers[(resource_group, account_name)] = containers
+ return self
+
+ def get_blob_containers(self, resource_group: str, account_name: str) -> Optional[List[Any]]:
+ return self._blob_containers.get((resource_group, account_name), None)
+
+ def set_blob_service_properties(
+ self, resource_group: str, account_name: str, properties: Optional[Any]
+ ) -> "MockAzureClient":
+ """Configure per-account blob service properties; ``None`` represents an API failure."""
+ self._blob_service_properties[(resource_group, account_name)] = properties
+ return self
+
+ def get_blob_service_properties(self, resource_group: str, account_name: str) -> Optional[Any]:
+ return self._blob_service_properties.get((resource_group, account_name), None)
+
def set_network_security_groups(self, nsgs: List[Any]) -> "MockAzureClient":
self._network_security_groups = nsgs
return self
diff --git a/tests/test_azure_client_management.py b/tests/test_azure_client_management.py
index a0f6552..179fb6f 100644
--- a/tests/test_azure_client_management.py
+++ b/tests/test_azure_client_management.py
@@ -94,3 +94,62 @@ def test_vm_extensions_normalize_sdk_page_and_failure(client):
assert client.get_vm_extensions("rg", "vm")[0].name == "agent"
constructor.return_value.virtual_machine_extensions.list.side_effect = RuntimeError("denied")
assert client.get_vm_extensions("rg", "vm") is None
+
+
+def test_get_container_registries_returns_results_and_caches(client):
+ with patch("azure.mgmt.containerregistry.ContainerRegistryManagementClient") as constructor:
+ constructor.return_value.registries.list.return_value = [SimpleNamespace(name="acr1")]
+ result = client.get_container_registries()
+ assert result is not None
+ assert [r.name for r in result] == ["acr1"]
+
+ # cached: second call must not hit the SDK again
+ constructor.return_value.registries.list.side_effect = RuntimeError("should not be called")
+ assert [r.name for r in client.get_container_registries()] == ["acr1"]
+
+
+def test_get_container_registries_failure_returns_none(client):
+ with patch("azure.mgmt.containerregistry.ContainerRegistryManagementClient") as constructor:
+ constructor.return_value.registries.list.side_effect = RuntimeError("denied")
+ assert client.get_container_registries() is None
+
+
+def test_get_blob_containers_and_service_properties(client):
+ with patch("scanner.azure_client.StorageManagementClient") as constructor:
+ constructor.return_value.blob_containers.list.return_value = [SimpleNamespace(name="tfstate")]
+ result = client.get_blob_containers("rg", "sa1")
+ assert result is not None
+ assert result[0].name == "tfstate"
+
+ constructor.return_value.blob_containers.list.side_effect = RuntimeError("denied")
+ assert client.get_blob_containers("rg", "sa1") is None
+
+ constructor.return_value.blob_services.get_service_properties.return_value = SimpleNamespace(
+ is_versioning_enabled=True
+ )
+ props = client.get_blob_service_properties("rg", "sa1")
+ assert props.is_versioning_enabled is True
+
+ constructor.return_value.blob_services.get_service_properties.side_effect = RuntimeError("denied")
+ assert client.get_blob_service_properties("rg", "sa1") is None
+
+
+def test_devops_client_not_built_without_env_vars(monkeypatch):
+ monkeypatch.delenv("AZURE_DEVOPS_ORG_URL", raising=False)
+ monkeypatch.delenv("AZURE_DEVOPS_PROJECT", raising=False)
+ from scanner.azure_client import AzureClient
+
+ fresh_client = AzureClient("sub-1", credential=MagicMock())
+ assert fresh_client.devops_client is None
+
+
+def test_devops_client_built_when_env_vars_present(monkeypatch):
+ monkeypatch.setenv("AZURE_DEVOPS_ORG_URL", "https://dev.azure.com/test-org")
+ monkeypatch.setenv("AZURE_DEVOPS_PROJECT", "test-project")
+ from scanner.azure_client import AzureClient
+ from scanner.devops_client import DevOpsClient
+
+ fresh_client = AzureClient("sub-1", credential=MagicMock())
+ assert isinstance(fresh_client.devops_client, DevOpsClient)
+ assert fresh_client.devops_client.organization_url == "https://dev.azure.com/test-org"
+ assert fresh_client.devops_client.project == "test-project"
diff --git a/tests/test_dco_check.py b/tests/test_dco_check.py
new file mode 100644
index 0000000..c062c29
--- /dev/null
+++ b/tests/test_dco_check.py
@@ -0,0 +1,36 @@
+"""Tests for the dependency-free DCO enforcement helper."""
+
+from unittest.mock import patch
+
+from scripts.check_dco import commits_between, has_signoff, unsigned_commits
+
+
+def test_has_signoff_accepts_standard_dco_trailer():
+ assert has_signoff("feat: change\n\nSigned-off-by: Tanvir Farhad \n")
+
+
+def test_has_signoff_rejects_missing_or_malformed_trailer():
+ assert not has_signoff("feat: unsigned change")
+ assert not has_signoff("Signed-off-by: anonymous")
+ assert not has_signoff("Signed-off-by: Name ")
+
+
+def test_unsigned_commits_checks_each_commit():
+ messages = {
+ "a": "fix: one\n\nSigned-off-by: A User ",
+ "b": "fix: two",
+ }
+ with patch("scripts.check_dco.commit_message", side_effect=messages.get):
+ assert unsigned_commits(["a", "b"]) == ["b"]
+
+
+def test_commits_between_excludes_merge_commits():
+ """A `git merge origin/dev` inside a PR branch has no Signed-off-by
+ trailer and isn't the author's own commit — it must never be checked,
+ or a legitimate PR gets blocked for merging the base branch in."""
+ with patch("scripts.check_dco.subprocess.check_output", return_value="abc123\ndef456\n") as mock_run:
+ result = commits_between("base-sha", "head-sha")
+
+ assert result == ["abc123", "def456"]
+ called_args = mock_run.call_args.args[0]
+ assert "--no-merges" in called_args
diff --git a/tests/test_devops_client.py b/tests/test_devops_client.py
new file mode 100644
index 0000000..58f2f9c
--- /dev/null
+++ b/tests/test_devops_client.py
@@ -0,0 +1,54 @@
+"""Direct tests for DevOpsClient and its failure states."""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from scanner.devops_client import DevOpsClient
+
+
+@pytest.fixture
+def client():
+ return DevOpsClient("https://dev.azure.com/test-org", "test-project", credential=MagicMock())
+
+
+def test_get_service_endpoints_returns_results_and_caches(client):
+ client.credential.get_token.return_value = MagicMock(token="fake-token")
+
+ with patch("azure.devops.connection.Connection") as conn_ctor:
+ fake_conn = MagicMock()
+ conn_ctor.return_value = fake_conn
+ fake_conn.clients_v7_1.get_service_endpoint_client.return_value.get_service_endpoints.return_value = [
+ MagicMock(name="ep1")
+ ]
+ result = client.get_service_endpoints()
+ assert result is not None
+ assert len(result) == 1
+
+ # cached: second call must not rebuild the connection
+ conn_ctor.side_effect = RuntimeError("should not be called")
+ assert client.get_service_endpoints() is result
+
+
+def test_get_service_endpoints_auth_failure_returns_none():
+ client = DevOpsClient("https://dev.azure.com/test-org", "test-project", credential=MagicMock())
+ client.credential.get_token.side_effect = RuntimeError("auth failed")
+ assert client.get_service_endpoints() is None
+
+
+def test_get_service_endpoints_api_failure_returns_none(client):
+ client.credential.get_token.return_value = MagicMock(token="fake-token")
+ with patch("azure.devops.connection.Connection") as conn_ctor:
+ fake_conn = MagicMock()
+ conn_ctor.return_value = fake_conn
+ fake_conn.clients_v7_1.get_service_endpoint_client.return_value.get_service_endpoints.side_effect = (
+ RuntimeError("denied")
+ )
+ assert client.get_service_endpoints() is None
+
+
+def test_default_credential_used_when_none_provided():
+ with patch("azure.identity.DefaultAzureCredential") as cred_ctor:
+ cred_ctor.return_value = MagicMock()
+ client = DevOpsClient("https://dev.azure.com/test-org", "test-project")
+ assert client.credential is cred_ctor.return_value
diff --git a/tests/test_rules_identity.py b/tests/test_rules_identity.py
index b2ea214..3c88f83 100644
--- a/tests/test_rules_identity.py
+++ b/tests/test_rules_identity.py
@@ -289,11 +289,12 @@ def test_idn_006_noncompliant_secret_no_expiry_returns_finding(mock_azure, subsc
def test_idn_006_malformed_end_date_time_does_not_log_key_id(mock_azure, subscription_id, monkeypatch, caplog):
- """CodeQL: clear-text logging of sensitive information. keyId is a Graph
- API credential-slot identifier (not the secret itself), but the debug log
- for a malformed endDateTime must not include it regardless — the value
- isn't needed to diagnose a date-parsing failure."""
+ """CodeQL alert #31 (py/clear-text-logging-sensitive-data): the debug log
+ for a malformed endDateTime must not include the raw endDateTime value or
+ keyId — neither is needed to diagnose a date-parsing failure, and Graph
+ API credential fields should never be echoed into logs verbatim."""
sentinel_key_id = "sentinel-key-id-should-not-appear-in-logs"
+ sentinel_end_date = "sentinel-malformed-end-date-should-not-appear-in-logs"
apps = {
"value": [
{
@@ -305,7 +306,7 @@ def test_idn_006_malformed_end_date_time_does_not_log_key_id(mock_azure, subscri
"keyId": sentinel_key_id,
"hint": "ab",
"startDateTime": "2020-01-01T00:00:00Z",
- "endDateTime": "not-a-valid-date",
+ "endDateTime": sentinel_end_date,
}
],
}
@@ -318,6 +319,7 @@ def test_idn_006_malformed_end_date_time_does_not_log_key_id(mock_azure, subscri
# Malformed endDateTime alone doesn't matter here: the secret is already stale by age.
assert len(findings) == 1
assert sentinel_key_id not in caplog.text
+ assert sentinel_end_date not in caplog.text
# ── AZ-IDN-007: active user with no MFA registered ──────────────────────────
diff --git a/tests/test_rules_supply_chain.py b/tests/test_rules_supply_chain.py
new file mode 100644
index 0000000..05db472
--- /dev/null
+++ b/tests/test_rules_supply_chain.py
@@ -0,0 +1,294 @@
+"""Rule regression tests for the Supply Chain rules AZ-SC-001 .. AZ-SC-008."""
+
+from types import SimpleNamespace
+
+import scanner.rules.az_sc_001 as az_sc_001
+import scanner.rules.az_sc_002 as az_sc_002
+import scanner.rules.az_sc_003 as az_sc_003
+import scanner.rules.az_sc_004 as az_sc_004
+import scanner.rules.az_sc_005 as az_sc_005
+import scanner.rules.az_sc_006 as az_sc_006
+import scanner.rules.az_sc_007 as az_sc_007
+import scanner.rules.az_sc_008 as az_sc_008
+from tests.helpers.mock_azure import make_resource
+
+_SUB = "00000000-0000-0000-0000-000000000001"
+_RG = "rg-test"
+
+
+def _acr_id(name):
+ return f"/subscriptions/{_SUB}/resourceGroups/{_RG}/providers/Microsoft.ContainerRegistry/registries/{name}"
+
+
+def _sa_id(name):
+ return f"/subscriptions/{_SUB}/resourceGroups/{_RG}/providers/Microsoft.Storage/storageAccounts/{name}"
+
+
+def _make_registry(
+ name, admin_enabled=False, public_access="Disabled", anonymous_pull=False, policies=None, sku_tier=None
+):
+ props = make_resource(
+ admin_user_enabled=admin_enabled,
+ public_network_access=public_access,
+ anonymous_pull_enabled=anonymous_pull,
+ policies=policies,
+ )
+ sku = make_resource(tier=sku_tier) if sku_tier is not None else None
+ return make_resource(id=_acr_id(name), name=name, properties=props, sku=sku)
+
+
+def _make_policies(retention_status="enabled", quarantine_status="enabled"):
+ return make_resource(
+ retention_policy=make_resource(status=retention_status),
+ quarantine_policy=make_resource(status=quarantine_status),
+ )
+
+
+def _make_container(name, public_access="None"):
+ return make_resource(name=name, container_properties=make_resource(public_access=public_access))
+
+
+# ── AZ-SC-001: ACR admin user enabled ───────────────────────────────────────
+
+
+def test_sc_001_admin_enabled_returns_finding(mock_azure, subscription_id):
+ mock_azure.set_container_registries([_make_registry("acr1", admin_enabled=True)])
+ findings = az_sc_001.scan(mock_azure, subscription_id)
+ assert len(findings) == 1
+ assert findings[0]["rule_id"] == "AZ-SC-001"
+ assert findings[0]["severity"] == "HIGH"
+ assert findings[0]["category"] == "Supply Chain"
+
+
+def test_sc_001_admin_disabled_returns_no_findings(mock_azure, subscription_id):
+ mock_azure.set_container_registries([_make_registry("acr1", admin_enabled=False)])
+ assert az_sc_001.scan(mock_azure, subscription_id) == []
+
+
+def test_sc_001_inventory_failure_returns_no_findings(mock_azure, subscription_id):
+ mock_azure.set_container_registries(None)
+ assert az_sc_001.scan(mock_azure, subscription_id) == []
+
+
+# ── AZ-SC-002: ACR public network access ────────────────────────────────────
+
+
+def test_sc_002_public_access_returns_finding(mock_azure, subscription_id):
+ mock_azure.set_container_registries([_make_registry("acr1", public_access="Enabled")])
+ findings = az_sc_002.scan(mock_azure, subscription_id)
+ assert len(findings) == 1
+ assert findings[0]["rule_id"] == "AZ-SC-002"
+
+
+def test_sc_002_private_access_returns_no_findings(mock_azure, subscription_id):
+ mock_azure.set_container_registries([_make_registry("acr1", public_access="Disabled")])
+ assert az_sc_002.scan(mock_azure, subscription_id) == []
+
+
+# ── AZ-SC-003: ACR anonymous pull ───────────────────────────────────────────
+
+
+def test_sc_003_anonymous_pull_returns_finding(mock_azure, subscription_id):
+ mock_azure.set_container_registries([_make_registry("acr1", anonymous_pull=True)])
+ findings = az_sc_003.scan(mock_azure, subscription_id)
+ assert len(findings) == 1
+ assert findings[0]["rule_id"] == "AZ-SC-003"
+
+
+def test_sc_003_anonymous_pull_disabled_returns_no_findings(mock_azure, subscription_id):
+ mock_azure.set_container_registries([_make_registry("acr1", anonymous_pull=False)])
+ assert az_sc_003.scan(mock_azure, subscription_id) == []
+
+
+# ── AZ-SC-004: ACR missing retention/quarantine policy ──────────────────────
+
+
+def test_sc_004_missing_retention_returns_finding(mock_azure, subscription_id):
+ registry = _make_registry("acr1", policies=_make_policies(retention_status="disabled"))
+ mock_azure.set_container_registries([registry])
+ findings = az_sc_004.scan(mock_azure, subscription_id)
+ assert len(findings) == 1
+ assert findings[0]["rule_id"] == "AZ-SC-004"
+
+
+def test_sc_004_basic_tier_missing_quarantine_is_compliant(mock_azure, subscription_id):
+ """Quarantine is Premium-only; a Basic-tier registry must not be flagged for
+ lacking a feature it cannot enable, as long as retention is configured."""
+ registry = _make_registry(
+ "acr1", sku_tier="Basic", policies=_make_policies(retention_status="enabled", quarantine_status="disabled")
+ )
+ mock_azure.set_container_registries([registry])
+ assert az_sc_004.scan(mock_azure, subscription_id) == []
+
+
+def test_sc_004_premium_tier_missing_quarantine_returns_finding(mock_azure, subscription_id):
+ """On a Premium registry, quarantine is available, so lacking it must be flagged."""
+ registry = _make_registry(
+ "acr1",
+ sku_tier="Premium",
+ policies=_make_policies(retention_status="enabled", quarantine_status="disabled"),
+ )
+ mock_azure.set_container_registries([registry])
+ findings = az_sc_004.scan(mock_azure, subscription_id)
+ assert len(findings) == 1
+ assert findings[0]["metadata"]["quarantine_policy_enabled"] is False
+
+
+def test_sc_004_premium_tier_both_policies_enabled_returns_no_findings(mock_azure, subscription_id):
+ registry = _make_registry("acr1", sku_tier="Premium", policies=_make_policies())
+ mock_azure.set_container_registries([registry])
+ assert az_sc_004.scan(mock_azure, subscription_id) == []
+
+
+def test_sc_004_missing_policies_object_returns_finding(mock_azure, subscription_id):
+ """A registry with no `policies` block at all has no retention policy and must be flagged."""
+ registry = _make_registry("acr1", policies=None)
+ mock_azure.set_container_registries([registry])
+ findings = az_sc_004.scan(mock_azure, subscription_id)
+ assert len(findings) == 1
+
+
+# ── AZ-SC-005: Terraform state container publicly readable ─────────────────
+
+
+def test_sc_005_public_tfstate_container_returns_finding(mock_azure, subscription_id):
+ account = make_resource(id=_sa_id("sa1"), name="sa1")
+ mock_azure.set_storage_accounts([account])
+ mock_azure.set_blob_containers(_RG, "sa1", [_make_container("tfstate-prod", public_access="Container")])
+ findings = az_sc_005.scan(mock_azure, subscription_id)
+ assert len(findings) == 1
+ assert findings[0]["rule_id"] == "AZ-SC-005"
+ assert findings[0]["severity"] == "CRITICAL"
+
+
+def test_sc_005_private_tfstate_container_returns_no_findings(mock_azure, subscription_id):
+ account = make_resource(id=_sa_id("sa1"), name="sa1")
+ mock_azure.set_storage_accounts([account])
+ mock_azure.set_blob_containers(_RG, "sa1", [_make_container("tfstate-prod", public_access="None")])
+ assert az_sc_005.scan(mock_azure, subscription_id) == []
+
+
+def test_sc_005_non_tfstate_container_name_ignored(mock_azure, subscription_id):
+ account = make_resource(id=_sa_id("sa1"), name="sa1")
+ mock_azure.set_storage_accounts([account])
+ mock_azure.set_blob_containers(_RG, "sa1", [_make_container("app-uploads", public_access="Container")])
+ assert az_sc_005.scan(mock_azure, subscription_id) == []
+
+
+def test_sc_005_container_listing_failure_returns_no_findings(mock_azure, subscription_id):
+ account = make_resource(id=_sa_id("sa1"), name="sa1")
+ mock_azure.set_storage_accounts([account])
+ mock_azure.set_blob_containers(_RG, "sa1", None)
+ assert az_sc_005.scan(mock_azure, subscription_id) == []
+
+
+# ── AZ-SC-006: Terraform state account missing versioning/soft delete ──────
+
+
+def test_sc_006_no_versioning_or_soft_delete_returns_finding(mock_azure, subscription_id):
+ account = make_resource(id=_sa_id("sa1"), name="sa1")
+ mock_azure.set_storage_accounts([account])
+ mock_azure.set_blob_containers(_RG, "sa1", [_make_container("terraform-state")])
+ blob_service = make_resource(
+ is_versioning_enabled=False,
+ delete_retention_policy=make_resource(enabled=False),
+ )
+ mock_azure.set_blob_service_properties(_RG, "sa1", blob_service)
+ findings = az_sc_006.scan(mock_azure, subscription_id)
+ assert len(findings) == 1
+ assert findings[0]["rule_id"] == "AZ-SC-006"
+
+
+def test_sc_006_versioning_enabled_returns_no_findings(mock_azure, subscription_id):
+ account = make_resource(id=_sa_id("sa1"), name="sa1")
+ mock_azure.set_storage_accounts([account])
+ mock_azure.set_blob_containers(_RG, "sa1", [_make_container("terraform-state")])
+ blob_service = make_resource(
+ is_versioning_enabled=True,
+ delete_retention_policy=make_resource(enabled=False),
+ )
+ mock_azure.set_blob_service_properties(_RG, "sa1", blob_service)
+ assert az_sc_006.scan(mock_azure, subscription_id) == []
+
+
+def test_sc_006_no_state_container_returns_no_findings(mock_azure, subscription_id):
+ account = make_resource(id=_sa_id("sa1"), name="sa1")
+ mock_azure.set_storage_accounts([account])
+ mock_azure.set_blob_containers(_RG, "sa1", [_make_container("app-uploads")])
+ assert az_sc_006.scan(mock_azure, subscription_id) == []
+
+
+# ── AZ-SC-007 / AZ-SC-008: Azure DevOps service connections ────────────────
+
+
+class _FakeDevOpsClient:
+ def __init__(self, endpoints):
+ self._endpoints = endpoints
+
+ def get_service_endpoints(self):
+ return self._endpoints
+
+
+def _make_endpoint(name, scope_level="ResourceGroup", is_shared=False, scheme="WorkloadIdentityFederation"):
+ return SimpleNamespace(
+ id=f"endpoint-{name}",
+ name=name,
+ type="AzureRM",
+ is_shared=is_shared,
+ data={"scopeLevel": scope_level, "subscriptionId": _SUB},
+ authorization=SimpleNamespace(scheme=scheme),
+ )
+
+
+def test_sc_007_subscription_scoped_returns_finding(mock_azure, subscription_id):
+ mock_azure.devops_client = _FakeDevOpsClient([_make_endpoint("conn1", scope_level="Subscription")])
+ findings = az_sc_007.scan(mock_azure, subscription_id)
+ assert len(findings) == 1
+ assert findings[0]["rule_id"] == "AZ-SC-007"
+
+
+def test_sc_007_subscription_scoped_not_shared_still_returns_finding(mock_azure, subscription_id):
+ """Scope is the risk, not cross-project sharing (is_shared) - a subscription-scoped
+ connection is over-privileged whether or not it is shared with other projects."""
+ mock_azure.devops_client = _FakeDevOpsClient([_make_endpoint("conn1", scope_level="Subscription", is_shared=False)])
+ findings = az_sc_007.scan(mock_azure, subscription_id)
+ assert len(findings) == 1
+
+
+def test_sc_007_resource_group_scoped_returns_no_findings(mock_azure, subscription_id):
+ mock_azure.devops_client = _FakeDevOpsClient([_make_endpoint("conn1", scope_level="ResourceGroup")])
+ assert az_sc_007.scan(mock_azure, subscription_id) == []
+
+
+def test_sc_007_devops_not_configured_returns_no_findings(mock_azure, subscription_id):
+ mock_azure.devops_client = None
+ assert az_sc_007.scan(mock_azure, subscription_id) == []
+
+
+def test_sc_007_endpoint_listing_failure_returns_no_findings(mock_azure, subscription_id):
+ mock_azure.devops_client = _FakeDevOpsClient(None)
+ assert az_sc_007.scan(mock_azure, subscription_id) == []
+
+
+def test_sc_008_password_based_returns_finding(mock_azure, subscription_id):
+ mock_azure.devops_client = _FakeDevOpsClient([_make_endpoint("conn1", scheme="ServicePrincipal")])
+ findings = az_sc_008.scan(mock_azure, subscription_id)
+ assert len(findings) == 1
+ assert findings[0]["rule_id"] == "AZ-SC-008"
+
+
+def test_sc_008_federated_returns_no_findings(mock_azure, subscription_id):
+ mock_azure.devops_client = _FakeDevOpsClient([_make_endpoint("conn1", scheme="WorkloadIdentityFederation")])
+ assert az_sc_008.scan(mock_azure, subscription_id) == []
+
+
+def test_sc_008_managed_identity_returns_no_findings(mock_azure, subscription_id):
+ """Managed identity is a second secretless scheme distinct from workload
+ identity federation and must not be flagged as a stored-secret connection."""
+ mock_azure.devops_client = _FakeDevOpsClient([_make_endpoint("conn1", scheme="ManagedServiceIdentity")])
+ assert az_sc_008.scan(mock_azure, subscription_id) == []
+
+
+def test_sc_008_devops_not_configured_returns_no_findings(mock_azure, subscription_id):
+ mock_azure.devops_client = None
+ assert az_sc_008.scan(mock_azure, subscription_id) == []
diff --git a/tests/test_signed_release_workflow.py b/tests/test_signed_release_workflow.py
new file mode 100644
index 0000000..f296042
--- /dev/null
+++ b/tests/test_signed_release_workflow.py
@@ -0,0 +1,44 @@
+"""Safety checks for the signed release workflow."""
+
+from pathlib import Path
+
+import yaml
+
+
+WORKFLOW = Path(__file__).parents[1] / ".github" / "workflows" / "release.yml"
+
+
+def _workflow():
+ return yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
+
+
+def test_release_workflow_has_keyless_attestation_permissions():
+ assert _workflow()["permissions"] == {
+ "contents": "write",
+ "id-token": "write",
+ "attestations": "write",
+ }
+
+
+def test_release_requires_verified_annotated_tag():
+ source = WORKFLOW.read_text(encoding="utf-8")
+ assert 'object_type" != "tag"' in source
+ assert ".verification.verified" in source
+ assert 'verified" != "true"' in source
+
+
+def test_release_attests_every_distributed_manifest():
+ source = WORKFLOW.read_text(encoding="utf-8")
+ action = "actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373"
+ assert source.count(action) == 3
+ assert "openshield-${{ env.TAG }}.tar.gz" in source
+ assert "openshield-${{ env.TAG }}-sbom.cyclonedx.json" in source
+ assert "subject-path: dist/SHA256SUMS" in source
+
+
+def test_release_artifact_is_deterministic_and_checksums_are_published():
+ source = WORKFLOW.read_text(encoding="utf-8")
+ assert "git archive --format=tar" in source
+ assert "gzip --no-name" in source
+ assert "sha256sum" in source
+ assert "files: dist/*" in source