✨ feat(ui): 빈 디렉토리에 열린 폴더 아이콘 표시 #280
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Central bundled security gate for every ContextualWisdomLab repo. | |
| # | |
| # This is a REQUIRED org workflow (see the "CWL Central required workflows" | |
| # ruleset). It bundles the supply-chain / vulnerability / posture scanners into | |
| # one gate so they pass or fail as a unit: | |
| # | |
| # osv-scan HARD diff-scoped — fails on NEW vulns the PR introduces | |
| # dependency-review HARD diff-scoped — fails on vulnerable/denied deps the PR adds | |
| # trivy-fs HARD repo-wide — fails on FIXABLE MEDIUM/HIGH/CRITICAL findings | |
| # scorecard SOFT repo posture — uploaded for visibility, never blocks | |
| # | |
| # Gating is by the JOB result (a failed job fails this required workflow -> | |
| # merge blocked), NOT by the code_scanning ruleset rule. The code_scanning rule | |
| # stays CodeQL-only on purpose: requiring multiple code-scanning TOOLS there is | |
| # unsatisfiable because default-setup CodeQL uploads to refs/pull/N/head while | |
| # pull_request workflows upload to refs/pull/N/merge, so no single ref ever holds | |
| # all tools. Bundling at the workflow/check level is ref-independent. | |
| # | |
| # NOTE on dependency-review: dependency graph can be unavailable on some repos. | |
| # Treat that as "not enforceable here" instead of making the required workflow | |
| # unsatisfiable; keep medium-or-higher dependency findings hard-failing where the | |
| # API is supported. | |
| # | |
| # NOTE on trivy-fs: it scans the whole repo, so a pre-existing FIXABLE | |
| # MEDIUM/HIGH/CRITICAL finding blocks every PR in that repo until it is fixed. | |
| # Trivy itself exits 0 so SARIF is always available; the following parser prints | |
| # exact findings and then fails the job. | |
| name: Security Scan | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, reopened, ready_for_review, closed] | |
| branches: [main, master, develop] | |
| concurrency: | |
| group: >- | |
| security-scan-${{ | |
| github.event_name == 'pull_request' && github.event.pull_request.base.repo.full_name || github.repository }}-${{ | |
| github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} | |
| cancel-in-progress: true | |
| # Scorecard Token-Permissions (alert #42): workflow-level token stays | |
| # read-only. Every job that uploads SARIF (osv-scan, trivy-fs, scorecard) | |
| # already declares security-events:write at job scope, so granting it here as | |
| # well is redundant and over-broad. | |
| permissions: | |
| actions: read | |
| contents: read | |
| jobs: | |
| cancel-closed-pr-runs: | |
| if: github.event.action == 'closed' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - run: echo "PR closed; this run only cancels older runs through workflow concurrency." | |
| osv-scan: | |
| if: github.event.action != 'closed' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 25 | |
| permissions: | |
| actions: read | |
| contents: read | |
| security-events: write | |
| steps: | |
| - name: Explain OSV scan mode and timeout budget | |
| run: | | |
| echo "::notice::OSV hard gate scans direct manifest and lockfile evidence with --no-resolve so external transitive registry resolver stalls cannot hold the required-check queue indefinitely. The job is capped at 25 minutes; if this budget is exceeded, rerun after the upstream registry/service recovers or inspect the uploaded debug artifacts." | |
| - name: Checkout base | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| repository: ${{ github.event.pull_request.base.repo.full_name }} | |
| ref: ${{ github.event.pull_request.base.sha }} | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Scan base with OSV | |
| id: osv_base | |
| continue-on-error: true | |
| timeout-minutes: 8 | |
| uses: google/osv-scanner-action/osv-scanner-action@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a # v2.3.8 | |
| with: | |
| scan-args: | | |
| --format=json | |
| --output=old-results.json | |
| --maven-registry=https://maven-central.storage-download.googleapis.com/maven2 | |
| --no-resolve | |
| --allow-no-lockfiles | |
| -r | |
| ./ | |
| - name: Explain base OSV resolver fallback | |
| if: steps.osv_base.outcome == 'failure' | |
| run: | | |
| echo "::warning::OSV base scan failed or timed out before reporter output was trusted; retrying the --no-resolve direct manifest/lockfile scan. Direct manifest and lockfile vulnerability evidence remains enforced while external transitive registry resolution is intentionally avoided." | |
| - name: Retry base OSV without transitive resolution | |
| if: steps.osv_base.outcome == 'failure' | |
| continue-on-error: true | |
| timeout-minutes: 4 | |
| uses: google/osv-scanner-action/osv-scanner-action@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a # v2.3.8 | |
| with: | |
| scan-args: | | |
| --format=json | |
| --output=old-results.json | |
| --no-resolve | |
| --allow-no-lockfiles | |
| -r | |
| ./ | |
| - name: Checkout head | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| repository: ${{ github.event.pull_request.head.repo.full_name }} | |
| ref: ${{ github.event.pull_request.head.sha }} | |
| fetch-depth: 0 | |
| clean: false | |
| persist-credentials: false | |
| - name: Scan head with OSV | |
| id: osv_head | |
| continue-on-error: true | |
| timeout-minutes: 8 | |
| uses: google/osv-scanner-action/osv-scanner-action@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a # v2.3.8 | |
| with: | |
| scan-args: | | |
| --format=json | |
| --output=new-results.json | |
| --maven-registry=https://maven-central.storage-download.googleapis.com/maven2 | |
| --no-resolve | |
| --allow-no-lockfiles | |
| -r | |
| ./ | |
| - name: Explain head OSV resolver fallback | |
| if: steps.osv_head.outcome == 'failure' | |
| run: | | |
| echo "::warning::OSV head scan failed or timed out before reporter output was trusted; retrying the --no-resolve direct manifest/lockfile scan. Direct manifest and lockfile vulnerability evidence remains enforced while external transitive registry resolution is intentionally avoided." | |
| - name: Retry head OSV without transitive resolution | |
| if: steps.osv_head.outcome == 'failure' | |
| continue-on-error: true | |
| timeout-minutes: 4 | |
| uses: google/osv-scanner-action/osv-scanner-action@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a # v2.3.8 | |
| with: | |
| scan-args: | | |
| --format=json | |
| --output=new-results.json | |
| --no-resolve | |
| --allow-no-lockfiles | |
| -r | |
| ./ | |
| - name: Require OSV scan output | |
| run: | | |
| set -euo pipefail | |
| test -s old-results.json | |
| test -s new-results.json | |
| - name: Print OSV findings being compared | |
| shell: python3 {0} | |
| run: | | |
| import json | |
| from pathlib import Path | |
| def iter_findings(path): | |
| data = json.loads(Path(path).read_text(encoding="utf-8")) | |
| for result in data.get("results") or []: | |
| source = result.get("source", {}) | |
| source_name = source.get("path") or source.get("name") or "unknown" | |
| for package in result.get("packages", []): | |
| package_info = package.get("package", {}) | |
| package_name = package_info.get("name") or "unknown" | |
| package_version = package_info.get("version") or "unknown" | |
| for vulnerability in package.get("vulnerabilities", []): | |
| aliases = ", ".join(vulnerability.get("aliases") or []) | |
| summary = (vulnerability.get("summary") or "").replace("\n", " ").strip() | |
| yield { | |
| "source": source_name, | |
| "package": package_name, | |
| "version": package_version, | |
| "id": vulnerability.get("id") or "unknown", | |
| "aliases": aliases, | |
| "summary": summary, | |
| } | |
| for label, path in (("base", "old-results.json"), ("head", "new-results.json")): | |
| findings = list(iter_findings(path)) | |
| print(f"OSV {label} scan produced {len(findings)} finding(s) in {path}.") | |
| for finding in findings[:50]: | |
| alias_text = f" aliases={finding['aliases']}" if finding["aliases"] else "" | |
| summary_text = f" - {finding['summary']}" if finding["summary"] else "" | |
| print( | |
| f"- {finding['source']}: {finding['package']}@{finding['version']} " | |
| f"{finding['id']}{alias_text}{summary_text}" | |
| ) | |
| if len(findings) > 50: | |
| print(f"... {len(findings) - 50} additional {label} OSV finding(s) omitted from the log summary.") | |
| - name: Report PR-introduced OSV findings | |
| uses: google/osv-scanner-action/osv-reporter-action@8dc09193bb540e09b23da07ad7e30bd33bf87018 # v2.3.8 | |
| with: | |
| scan-args: | | |
| --output=results.sarif | |
| --old=old-results.json | |
| --new=new-results.json | |
| --gh-annotations=true | |
| --fail-on-vuln=true | |
| - name: Mark clean OSV SARIF as comprehensive | |
| if: always() && hashFiles('results.sarif') != '' | |
| shell: python3 {0} | |
| run: | | |
| import json | |
| from pathlib import Path | |
| sarif_path = Path("results.sarif") | |
| sarif = json.loads(sarif_path.read_text(encoding="utf-8")) | |
| total_results = 0 | |
| for run in sarif.get("runs", []): | |
| total_results += len(run.get("results", [])) | |
| run.setdefault("tool", {}).setdefault("driver", {})["isComprehensive"] = True | |
| temp_path = sarif_path.with_name(f"{sarif_path.name}.tmp") | |
| temp_path.write_text(json.dumps(sarif, indent=2), encoding="utf-8") | |
| temp_path.replace(sarif_path) | |
| print( | |
| "OSV reporter SARIF contains " | |
| f"{total_results} result(s); marked the code-scanning analysis " | |
| "comprehensive so fixed PR-introduced alerts close after a clean " | |
| "base/head comparison." | |
| ) | |
| - name: Upload OSV SARIF to code scanning | |
| id: upload_osv_sarif | |
| if: always() && hashFiles('results.sarif') != '' | |
| # The reporter above is the vulnerability gate. Preserve an upload | |
| # quota failure in this step's log without reclassifying it as a CVE. | |
| continue-on-error: true | |
| uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 | |
| with: | |
| sarif_file: results.sarif | |
| # results.sarif is produced after checkout of the pull request head. | |
| # Uploading it against refs/pull/*/merge can race GitHub's synthetic | |
| # merge ref and fail with "commit_oid is not a merge commit". | |
| ref: refs/pull/${{ github.event.pull_request.number }}/head | |
| sha: ${{ github.event.pull_request.head.sha }} | |
| wait-for-processing: false | |
| - name: Report OSV SARIF upload failure | |
| if: steps.upload_osv_sarif.outcome == 'failure' | |
| run: | | |
| echo "::warning::OSV SARIF upload to code scanning failed after the base/head comparison. The PR-introduced vulnerability reporter above remains the hard gate, so upload rate limits cannot hide OSV findings." | |
| - name: Upload OSV debug artifacts | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v6.0.0 | |
| with: | |
| name: osv-scan-debug | |
| path: | | |
| old-results.json | |
| new-results.json | |
| results.sarif | |
| if-no-files-found: ignore | |
| retention-days: 5 | |
| dependency-review: | |
| if: github.event.action != 'closed' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| persist-credentials: false | |
| - name: Check dependency review support | |
| id: dependency_review_support | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| REPOSITORY: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| api_url="${GITHUB_API_URL:-https://api.github.com}" | |
| response_file="$(mktemp)" | |
| status="$( | |
| curl -fsS -o "$response_file" -w '%{http_code}' \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "Authorization: Bearer ${GH_TOKEN}" \ | |
| -H "X-GitHub-Api-Version: 2022-11-28" \ | |
| "${api_url}/repos/${REPOSITORY}/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}" \ | |
| || true | |
| )" | |
| if [ "$status" = "200" ]; then | |
| echo "supported=true" >>"$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if [ "$status" = "403" ] || [ "$status" = "404" ]; then | |
| echo "::warning::Dependency review is unavailable for ${REPOSITORY}; skipping dependency-review hard gate." | |
| echo "supported=false" >>"$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "::error::Dependency review support check failed with HTTP ${status}." | |
| cat "$response_file" | |
| exit 1 | |
| - name: Dependency review | |
| if: steps.dependency_review_support.outputs.supported == 'true' | |
| uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 | |
| with: | |
| fail-on-severity: moderate | |
| comment-summary-in-pr: on-failure | |
| trivy-fs: | |
| if: github.event.action != 'closed' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| security-events: write | |
| actions: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| persist-credentials: false | |
| - name: Trivy filesystem scan | |
| uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0 | |
| with: | |
| scan-type: fs | |
| scan-ref: . | |
| scanners: vuln,secret,misconfig | |
| severity: CRITICAL,HIGH,MEDIUM | |
| ignore-unfixed: true | |
| format: sarif | |
| output: trivy-results.sarif | |
| exit-code: "0" | |
| # Without this, trivy-action rebuilds the SARIF scan with ALL | |
| # severities and the parser below would gate LOW findings too, | |
| # contradicting the documented MEDIUM-or-higher gate above. | |
| limit-severities-for-sarif: true | |
| - name: Require Trivy SARIF output | |
| run: | | |
| set -euo pipefail | |
| if [ ! -s trivy-results.sarif ]; then | |
| echo "::error::Trivy did not produce trivy-results.sarif; inspect the Trivy filesystem scan logs above." | |
| exit 1 | |
| fi | |
| - name: Print Trivy findings that failed the gate | |
| # SARIF-only output otherwise leaves failures as just "exit code 1". | |
| shell: python3 {0} | |
| run: | | |
| import json, pathlib | |
| sarif = json.loads(pathlib.Path("trivy-results.sarif").read_text(encoding="utf-8")) | |
| findings = [] | |
| for run in sarif.get("runs", []): | |
| rules = {r["id"]: r for r in run.get("tool", {}).get("driver", {}).get("rules", [])} | |
| for result in run.get("results", []): | |
| rule = rules.get(result.get("ruleId", ""), {}) | |
| severity = rule.get("properties", {}).get("security-severity", "?") | |
| lines = (result.get("message", {}).get("text") or "").strip().splitlines() | |
| fields = {} | |
| for entry in lines: | |
| key, sep, value = entry.partition(":") | |
| if sep: | |
| fields[key.strip().lower()] = value.strip() | |
| if fields.get("severity"): | |
| severity = f"{fields['severity']} (security-severity={severity})" | |
| message = fields.get("message") or (lines[0] if lines else result.get("ruleId", "")) | |
| locations = result.get("locations", []) | |
| if locations: | |
| phys = locations[0].get("physicalLocation", {}) | |
| uri = phys.get("artifactLocation", {}).get("uri", "?") | |
| line = phys.get("region", {}).get("startLine", "?") | |
| where = f"{uri}:{line}" | |
| else: | |
| where = "-" | |
| findings.append((severity, result.get("ruleId", "?"), where, message)) | |
| if not findings: | |
| print("Trivy filesystem scan completed with 0 CRITICAL/HIGH/MEDIUM findings in trivy-results.sarif.") | |
| else: | |
| print(f"Trivy filesystem scan reported {len(findings)} finding(s):") | |
| for severity, rule_id, where, message in findings: | |
| print(f" [{severity}] {rule_id} {where} - {message}") | |
| print("") | |
| print("Remediate each finding at the shared base branch so open PRs inherit the fix.") | |
| raise SystemExit(1) | |
| - name: Upload Trivy SARIF to code scanning | |
| id: upload_trivy_sarif | |
| if: always() && hashFiles('trivy-results.sarif') != '' | |
| # The parser above fails on every fixable Medium+ finding independently. | |
| continue-on-error: true | |
| uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 | |
| with: | |
| sarif_file: trivy-results.sarif | |
| category: trivy-fs | |
| wait-for-processing: false | |
| - name: Report Trivy SARIF upload failure | |
| if: steps.upload_trivy_sarif.outcome == 'failure' | |
| run: | | |
| echo "::warning::Trivy SARIF upload to code scanning failed after the filesystem scan. The Trivy finding log above remains the hard gate, so upload rate limits cannot hide CRITICAL/HIGH/MEDIUM findings." | |
| scorecard: | |
| if: github.event.action != 'closed' | |
| runs-on: ubuntu-latest | |
| # SOFT: posture findings are unrelated to the PR diff, so never block merge. | |
| continue-on-error: true | |
| permissions: | |
| security-events: write | |
| contents: read | |
| actions: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| persist-credentials: false | |
| - name: Run Scorecard | |
| uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 | |
| with: | |
| results_file: results.sarif | |
| results_format: sarif | |
| publish_results: false | |
| - name: Filter delegated PR-only Scorecard SARIF findings | |
| run: | | |
| python3 <<'PY' | |
| import json | |
| import pathlib | |
| PR_HARD_GATE_RULE_IDS = {"SASTID", "VulnerabilitiesID"} | |
| PR_GOVERNANCE_RULE_IDS = {"FuzzingID"} | |
| PR_DELEGATED_RULE_IDS = PR_HARD_GATE_RULE_IDS | PR_GOVERNANCE_RULE_IDS | |
| sarif_path = pathlib.Path("results.sarif") | |
| sarif = json.loads(sarif_path.read_text(encoding="utf-8")) | |
| hard_gate_delegated = 0 | |
| governance_delegated = 0 | |
| for run in sarif.get("runs", []): | |
| kept = [] | |
| for result in run.get("results", []): | |
| rule_id = result.get("ruleId") | |
| if rule_id in PR_DELEGATED_RULE_IDS: | |
| if rule_id in PR_HARD_GATE_RULE_IDS: | |
| hard_gate_delegated += 1 | |
| if rule_id in PR_GOVERNANCE_RULE_IDS: | |
| governance_delegated += 1 | |
| continue | |
| kept.append(result) | |
| run["results"] = kept | |
| filtered_path = sarif_path.with_name(f"{sarif_path.name}.filtered") | |
| filtered_path.write_text(json.dumps(sarif, indent=2), encoding="utf-8") | |
| filtered_path.replace(sarif_path) | |
| print( | |
| "Delegated " | |
| f"{hard_gate_delegated} PR-only Scorecard SAST/vulnerability finding(s) to " | |
| "CodeQL, OSV, Trivy, and dependency-review hard gates." | |
| ) | |
| print( | |
| "Delegated " | |
| f"{governance_delegated} PR-only Scorecard fuzzing posture finding(s) " | |
| "to default-branch governance tracking." | |
| ) | |
| PY | |
| - name: Upload Scorecard SARIF to code scanning | |
| id: upload_scorecard_sarif | |
| # Scorecard is soft repository-posture evidence; upload quota is external. | |
| continue-on-error: true | |
| uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 | |
| with: | |
| sarif_file: results.sarif | |
| category: scorecard | |
| wait-for-processing: false | |
| - name: Report Scorecard SARIF upload failure | |
| if: steps.upload_scorecard_sarif.outcome == 'failure' | |
| run: | | |
| echo "::warning::Scorecard SARIF upload to code scanning failed after delegated PR-only findings were filtered. Scorecard is PR posture evidence only; CodeQL, OSV, Trivy, and dependency-review remain the hard gates." |