Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 70 additions & 4 deletions .github/workflows/container-remediation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ jobs:
runs-on: ubuntu-24.04
permissions:
contents: read
# For the llm-credentials action to exchange the job's OIDC token for model credentials.
id-token: write
timeout-minutes: 15
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
Expand All @@ -124,10 +126,35 @@ jobs:
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
pi --version

# Checked out rather than referenced as kernel/security-workflows/...@<ref>, because `uses`
# can't take an expression and the action must follow security-workflows-ref. Excluded from
# git so the agent's changes never include it and `git clean` keeps it for the stop step.
- name: Fetch credentials action
id: credentials-action
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: kernel/security-workflows
ref: ${{ inputs.security-workflows-ref }}
path: .security-workflows
sparse-checkout: .github/actions/llm-credentials
persist-credentials: false

- name: Keep the credentials action out of the agent's changes
run: echo /.security-workflows/ >> .git/info/exclude

- name: Model credentials
uses: ./.security-workflows/.github/actions/llm-credentials
with:
model: ${{ vars.PI_PREFERRED_MODEL }}
anthropic-federation-rule-id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
anthropic-organization-id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
anthropic-service-account-id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
openai-identity-provider-id: ${{ vars.OPENAI_IDENTITY_PROVIDER_ID }}
openai-service-account-id: ${{ vars.OPENAI_SERVICE_ACCOUNT_ID }}
openai-audience: ${{ vars.OPENAI_WIF_AUDIENCE }}

- name: Triage findings
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
PI_PREFERRED_MODEL: ${{ vars.PI_PREFERRED_MODEL }}
PI_SKIP_VERSION_CHECK: '1'
PI_TELEMETRY: '0'
Expand All @@ -140,6 +167,12 @@ jobs:
--model "$PI_PREFERRED_MODEL" --thinking max \
@"$RUNNER_TEMP/triage-prompt.md"

- name: Stop model credentials
if: ${{ always() && steps.credentials-action.outcome == 'success' }}
uses: ./.security-workflows/.github/actions/llm-credentials
with:
stop: true

- name: Upload triage results
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
Expand Down Expand Up @@ -230,6 +263,8 @@ jobs:
runs-on: ubuntu-24.04
permissions:
contents: read
# For the llm-credentials action to exchange the job's OIDC token for model credentials.
id-token: write
timeout-minutes: 30
outputs:
changed: ${{ steps.snapshot.outputs.changed }}
Expand Down Expand Up @@ -310,11 +345,36 @@ jobs:
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
pi --version

# Checked out rather than referenced as kernel/security-workflows/...@<ref>, because `uses`
# can't take an expression and the action must follow security-workflows-ref. Excluded from
# git so the agent's changes never include it and `git clean` keeps it for the stop step.
- name: Fetch credentials action
id: credentials-action
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: kernel/security-workflows
ref: ${{ inputs.security-workflows-ref }}
path: .security-workflows
sparse-checkout: .github/actions/llm-credentials
persist-credentials: false

- name: Keep the credentials action out of the agent's changes
run: echo /.security-workflows/ >> .git/info/exclude

- name: Model credentials
uses: ./.security-workflows/.github/actions/llm-credentials
with:
model: ${{ vars.PI_PREFERRED_MODEL }}
anthropic-federation-rule-id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
anthropic-organization-id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
anthropic-service-account-id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
openai-identity-provider-id: ${{ vars.OPENAI_IDENTITY_PROVIDER_ID }}
openai-service-account-id: ${{ vars.OPENAI_SERVICE_ACCOUNT_ID }}
openai-audience: ${{ vars.OPENAI_WIF_AUDIENCE }}

- name: Apply fixes
timeout-minutes: 20
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
PI_PREFERRED_MODEL: ${{ vars.PI_PREFERRED_MODEL }}
PI_SKIP_VERSION_CHECK: '1'
PI_TELEMETRY: '0'
Expand All @@ -331,6 +391,12 @@ jobs:
--model "$PI_PREFERRED_MODEL" --thinking max \
@"$RUNNER_TEMP/fix-prompt.rendered.md"

- name: Stop model credentials
if: ${{ always() && steps.credentials-action.outcome == 'success' }}
uses: ./.security-workflows/.github/actions/llm-credentials
with:
stop: true

- name: Snapshot publishable changes
id: snapshot
env:
Expand Down
27 changes: 22 additions & 5 deletions scripts/test_container_remediation.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,15 @@ def test_actions_are_pinned_and_checkouts_do_not_persist_credentials(self):
for step in job["steps"]:
if "uses" not in step:
continue
self.assertRegex(step["uses"], r"^[\w.-]+/[\w.-]+@[0-9a-f]{40}$")
# The credentials action is this repo's own, checked out at security-workflows-ref.
self.assertRegex(step["uses"], r"^([\w.-]+/[\w.-]+@[0-9a-f]{40}|\./\.security-workflows/\.github/actions/llm-credentials)$")
if step["uses"].startswith("actions/checkout@"):
self.assertIs(step["with"]["persist-credentials"], False)
self.assertNotIn("token", step["with"])

def test_agent_jobs_have_read_only_credentials(self):
for job, agent in (("triage", "Triage findings"), ("fix", "Apply fixes")):
self.assertEqual(self.jobs[job]["permissions"], {"contents": "read"})
self.assertEqual(self.jobs[job]["permissions"], {"contents": "read", "id-token": "write"})
step = self.step(job, agent)
self.assertIn("pi --print --no-session --approve", step["run"])
self.assertIn('--model "$PI_PREFERRED_MODEL"', step["run"])
Expand All @@ -65,17 +66,33 @@ def test_agent_jobs_have_read_only_credentials(self):
self.assertNotIn("claude", yaml.safe_dump(step))
self.assertNotRegex(yaml.safe_dump(step.get("with", {})), r"permission-[\w-]+: write")

def test_agent_jobs_reference_only_model_secrets(self):
def test_agent_jobs_reference_no_secrets(self):
# A job's secrets stay in runner memory for the whole job, where an agent
# with sudo can read them, so revoking a minted token is not enough.
# with sudo can read them, so revoking a minted token is not enough. Model
# access comes from short-lived federated credentials instead.
agent_jobs = {
name for name, job in self.jobs.items()
if any("pi --print" in s.get("run", "") for s in job["steps"])
}
self.assertEqual(agent_jobs, {"triage", "fix"})
for name in agent_jobs:
refs = re.findall(r"\bsecrets\b(?:\.(\w+))?", yaml.safe_dump(self.jobs[name]))
self.assertLessEqual(set(refs), {"ANTHROPIC_API_KEY", "OPENAI_API_KEY"}, name)
self.assertEqual(refs, [], name)

def test_model_credentials_exist_only_while_the_agent_runs(self):
action = "./.security-workflows/.github/actions/llm-credentials"
for job, agent in (("triage", "Triage findings"), ("fix", "Apply fixes")):
fetch = self.step(job, "Fetch credentials action")
self.assertEqual(fetch["with"]["ref"], "${{ inputs.security-workflows-ref }}")
self.assertEqual(fetch["with"]["sparse-checkout"], ".github/actions/llm-credentials")
self.assertIn("/.security-workflows/", self.step(job, "Keep the credentials action out of the agent's changes")["run"])
start, stop = self.step(job, "Model credentials"), self.step(job, "Stop model credentials")
self.assertEqual((start["uses"], stop["uses"]), (action, action))
self.assertEqual(start["with"]["model"], "${{ vars.PI_PREFERRED_MODEL }}")
self.assertEqual(stop["with"], {"stop": True})
self.assertIn("always()", stop["if"])
self.assertEqual(self.index(job, "Model credentials") + 1, self.index(job, agent))
self.assertEqual(self.index(job, agent) + 1, self.index(job, "Stop model credentials"))

def test_private_modules_are_fetched_in_a_separate_job(self):
mint = self.step("go-modules", "Generate Go module read token")
Expand Down
Loading