diff --git a/README.md b/README.md index 575d80928f..e0eec9e8b8 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Welcome to the OWASP WrongSecrets game! The game is packed with real life examples of how to _not_ store secrets in your software. Each of these examples is captured in a challenge, which you need to solve using various tools and techniques. Solving these challenges will help you recognize common mistakes & can help you to reflect on your own secrets management strategy. -Can you solve all the 67 challenges? +Can you solve all the 69 challenges? Try some of them on [our Heroku demo environment](https://wrongsecrets.herokuapp.com/). @@ -161,7 +161,7 @@ docker run -p 8080:8080 -p 8090:8090 ghcr.io/owasp/wrongsecrets/wrongsecrets-mas ⚠️ **Warning**: This is a development version built from the latest master branch and may contain experimental features or instabilities. **πŸ“ Note on Ports:** -- Port **8080**: Main application (challenges 0-66) +- Port **8080**: Main application (challenges 0-68) - Port **8090**: MCP server (required for Challenge 60) **πŸ“ Note on Challenge 62 (Google Drive MCP):** @@ -226,6 +226,8 @@ Now you can try to find the secrets by means of solving the challenge offered at - [localhost:8080/challenge/challenge-64](http://localhost:8080/challenge/challenge-64) - [localhost:8080/challenge/challenge-65](http://localhost:8080/challenge/challenge-65) - [localhost:8080/challenge/challenge-66](http://localhost:8080/challenge/challenge-66) +- [localhost:8080/challenge/challenge-70](http://localhost:8080/challenge/challenge-70) +- [localhost:8080/challenge/challenge-71](http://localhost:8080/challenge/challenge-71) Note that these challenges are still very basic, and so are their explanations. Feel free to file a PR to make them look diff --git a/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge70.java b/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge70.java new file mode 100644 index 0000000000..b09a8b3059 --- /dev/null +++ b/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge70.java @@ -0,0 +1,48 @@ +package org.owasp.wrongsecrets.challenges.docker; + +import static org.owasp.wrongsecrets.Challenges.ErrorResponses.FILE_MOUNT_ERROR; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.regex.Pattern; +import lombok.extern.slf4j.Slf4j; +import org.owasp.wrongsecrets.challenges.FixedAnswerChallenge; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Component; + +/** + * Challenge based on a secret that is hardcoded in a Cursor skill. The skill is shipped as a plain + * {@code SKILL.md} file, so the secret is readable for anybody who receives the skill. + */ +@Slf4j +@Component +public class Challenge70 extends FixedAnswerChallenge { + + private static final Pattern DEPLOY_TOKEN_PATTERN = + Pattern.compile("STAGING_DEPLOY_TOKEN=\"([^\"]+)\""); + + private final Resource skillFile; + + public Challenge70( + @Value("classpath:challenges/challenge-70/cursor-skill/deploy-preview/SKILL.md") + Resource skillFile) { + this.skillFile = skillFile; + } + + @Override + public String getAnswer() { + try { + var skillContent = skillFile.getContentAsString(StandardCharsets.UTF_8); + var matcher = DEPLOY_TOKEN_PATTERN.matcher(skillContent); + if (!matcher.find()) { + log.warn("Could not find the deploy token in the Cursor skill of challenge 70"); + return FILE_MOUNT_ERROR; + } + return matcher.group(1); + } catch (IOException e) { + log.warn("Exception while reading the Cursor skill of challenge 70", e); + return FILE_MOUNT_ERROR; + } + } +} diff --git a/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge70Controller.java b/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge70Controller.java new file mode 100644 index 0000000000..25aa7a6e94 --- /dev/null +++ b/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge70Controller.java @@ -0,0 +1,45 @@ +package org.owasp.wrongsecrets.challenges.docker; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Hosts the Cursor skill of challenge 70 straight from the resource folder, so participants can + * read the skill the same way an agent would. + */ +@Slf4j +@RestController +public class Challenge70Controller { + + private static final MediaType MARKDOWN = + new MediaType("text", "markdown", StandardCharsets.UTF_8); + + private final Resource skillFile; + + public Challenge70Controller( + @Value("classpath:challenges/challenge-70/cursor-skill/deploy-preview/SKILL.md") + Resource skillFile) { + this.skillFile = skillFile; + } + + /** Returns the raw {@code SKILL.md} of the {@code deploy-preview} Cursor skill. */ + @GetMapping("/skills/cursor/deploy-preview/SKILL.md") + public ResponseEntity cursorSkill() { + try { + return ResponseEntity.ok() + .contentType(MARKDOWN) + .body(skillFile.getContentAsString(StandardCharsets.UTF_8)); + } catch (IOException e) { + log.warn("Unable to serve the Cursor skill of challenge 70", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } +} diff --git a/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge71.java b/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge71.java new file mode 100644 index 0000000000..922b5c06e8 --- /dev/null +++ b/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge71.java @@ -0,0 +1,54 @@ +package org.owasp.wrongsecrets.challenges.docker; + +import static org.owasp.wrongsecrets.Challenges.ErrorResponses.FILE_MOUNT_ERROR; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.regex.Pattern; +import lombok.extern.slf4j.Slf4j; +import org.owasp.wrongsecrets.challenges.FixedAnswerChallenge; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Component; + +/** + * Challenge based on a secret that is hardcoded in a Claude skill. The skill is distributed as a + * zip bundle, and the token does not sit in the {@code SKILL.md} itself but in one of the bundled + * scripts, base64 encoded to keep secret scanners quiet. + */ +@Slf4j +@Component +public class Challenge71 extends FixedAnswerChallenge { + + private static final Pattern UPLOAD_TOKEN_PATTERN = + Pattern.compile("UPLOAD_TOKEN_B64\\s*=\\s*\"([^\"]+)\""); + + private final Resource uploaderScript; + + public Challenge71( + @Value( + "classpath:challenges/challenge-71/claude-skill/incident-reporter/scripts/upload_report.py") + Resource uploaderScript) { + this.uploaderScript = uploaderScript; + } + + @Override + public String getAnswer() { + try { + var scriptContent = uploaderScript.getContentAsString(StandardCharsets.UTF_8); + var matcher = UPLOAD_TOKEN_PATTERN.matcher(scriptContent); + if (!matcher.find()) { + log.warn("Could not find the upload token in the Claude skill of challenge 71"); + return FILE_MOUNT_ERROR; + } + return new String(Base64.getDecoder().decode(matcher.group(1)), StandardCharsets.UTF_8); + } catch (IOException e) { + log.warn("Exception while reading the Claude skill of challenge 71", e); + return FILE_MOUNT_ERROR; + } catch (IllegalArgumentException e) { + log.warn("The upload token in the Claude skill of challenge 71 is not valid base64", e); + return FILE_MOUNT_ERROR; + } + } +} diff --git a/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge71Controller.java b/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge71Controller.java new file mode 100644 index 0000000000..186f005f5b --- /dev/null +++ b/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge71Controller.java @@ -0,0 +1,72 @@ +package org.owasp.wrongsecrets.challenges.docker; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Hosts the Claude skill of challenge 71. The skill files live in the resource folder and are + * zipped on request, so participants download the same kind of bundle that is passed around when a + * skill is shared. + */ +@Slf4j +@RestController +public class Challenge71Controller { + + static final String SKILL_ROOT = "challenges/challenge-71/claude-skill/"; + private static final String BUNDLE_NAME = "incident-reporter.zip"; + private static final MediaType ZIP = new MediaType("application", "zip"); + // Fixed timestamp (2024-01-01T00:00:00Z) so the generated bundle is reproducible. + private static final long FIXED_ENTRY_TIME = 1704067200000L; + + /** The files that make up the skill, named as they appear inside the bundle. */ + private static final List SKILL_FILES = + List.of( + "incident-reporter/SKILL.md", + "incident-reporter/references/runbook.md", + "incident-reporter/scripts/upload_report.py"); + + /** Returns the {@code incident-reporter} Claude skill as a downloadable zip bundle. */ + @GetMapping("/skills/claude/incident-reporter.zip") + public ResponseEntity claudeSkillBundle() { + try { + return ResponseEntity.ok() + .contentType(ZIP) + .headers( + headers -> + headers.setContentDisposition( + ContentDisposition.attachment().filename(BUNDLE_NAME).build())) + .body(zipSkill()); + } catch (IOException e) { + log.warn("Unable to package the Claude skill of challenge 71", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + /** Zips the skill files from the resource folder, keeping their paths inside the bundle. */ + byte[] zipSkill() throws IOException { + var bundle = new ByteArrayOutputStream(); + try (var zip = new ZipOutputStream(bundle)) { + for (String name : SKILL_FILES) { + var entry = new ZipEntry(name); + entry.setTime(FIXED_ENTRY_TIME); + zip.putNextEntry(entry); + try (var content = new ClassPathResource(SKILL_ROOT + name).getInputStream()) { + content.transferTo(zip); + } + zip.closeEntry(); + } + } + return bundle.toByteArray(); + } +} diff --git a/src/main/resources/challenges/challenge-70/challenge-70.snippet b/src/main/resources/challenges/challenge-70/challenge-70.snippet new file mode 100644 index 0000000000..5a6402e34d --- /dev/null +++ b/src/main/resources/challenges/challenge-70/challenge-70.snippet @@ -0,0 +1,36 @@ +
+

πŸ“„ Cursor skill: deploy-preview

+

This application ships a Cursor skill in its resource folder. The backend hosts it at /skills/cursor/deploy-preview/SKILL.md, which is exactly what an agent would read when the skill is installed.

+ +
+

Fetch the skill from the command line:

+
curl -s http://localhost:8080/skills/cursor/deploy-preview/SKILL.md
+ +

…or read it right here:

+ + Open in new tab + + +

πŸ’‘ The author inlined the shared staging deploy token so the skill would be "self-contained". Submit that token.

+
+
+ + + + diff --git a/src/main/resources/challenges/challenge-70/cursor-skill/deploy-preview/SKILL.md b/src/main/resources/challenges/challenge-70/cursor-skill/deploy-preview/SKILL.md new file mode 100644 index 0000000000..9ece562193 --- /dev/null +++ b/src/main/resources/challenges/challenge-70/cursor-skill/deploy-preview/SKILL.md @@ -0,0 +1,62 @@ +--- +name: deploy-preview +description: Deploy the branch that is currently checked out to the shared staging preview environment and report the preview URL back to the user. +--- + +# Deploy Preview + +Use this skill when the user asks you to "deploy a preview", "push this to +staging", or "get me a preview URL" for the branch that is currently checked +out. + +## When to use this skill + +- The user wants a throwaway preview environment for the current branch. +- The user wants to refresh a preview environment that already exists. + +Do **not** use this skill for production releases. Production deployments go +through the release pipeline and require a second approver. + +## Prerequisites + +The staging deploy API authenticates with a shared team token. Previously +everybody had to look this token up in the password manager, which slowed the +agent down, so the token is inlined here to keep the skill self-contained: + +```bash +export STAGING_DEPLOY_URL="https://staging.wrongsecrets.example.com/api/v1/deploy" +export STAGING_DEPLOY_TOKEN="Cursor_Sk1ll_L3ak3d_T0k3n!" +``` + +## Steps + +1. Verify the working tree is clean and the branch is pushed: + + ```bash + git status --porcelain + git push --set-upstream origin "$(git rev-parse --abbrev-ref HEAD)" + ``` + +2. Build the application: + + ```bash + ./mvnw --batch-mode --no-transfer-progress package -DskipTests + ``` + +3. Trigger the preview deployment: + + ```bash + curl --silent --show-error --request POST "$STAGING_DEPLOY_URL" \ + --header "Authorization: Bearer $STAGING_DEPLOY_TOKEN" \ + --header "Content-Type: application/json" \ + --data "{\"branch\": \"$(git rev-parse --abbrev-ref HEAD)\"}" + ``` + +4. Report the `preview_url` field from the response back to the user. + +## Troubleshooting + +- `401 Unauthorized`: the shared token was rotated. Ask the platform team for + the new value and update the `STAGING_DEPLOY_TOKEN` line above. +- `409 Conflict`: a preview for this branch is already being built. Wait for the + running deployment to finish and try again. diff --git a/src/main/resources/challenges/challenge-71/challenge-71.snippet b/src/main/resources/challenges/challenge-71/challenge-71.snippet new file mode 100644 index 0000000000..b036d28850 --- /dev/null +++ b/src/main/resources/challenges/challenge-71/challenge-71.snippet @@ -0,0 +1,17 @@ +
+

πŸ“¦ Claude skill bundle: incident-reporter

+

The skill files live in this application's resource folder. The backend packages them into a zip bundle on request, which is how a Claude skill is shared and installed.

+ +
+

Step 1 β€” download and unpack the bundle:

+
curl -sO http://localhost:8080/skills/claude/incident-reporter.zip
+unzip incident-reporter.zip
+find incident-reporter -type f
+ ⬇ Download incident-reporter.zip + +

Step 2 β€” the SKILL.md is clean. Look at what it tells the agent to run:

+
grep -r TOKEN incident-reporter/
+ +

πŸ’‘ The token you find is not the answer yet β€” the author "hid" it from the secret scanner. Submit the value the uploader actually authenticates with.

+
+
diff --git a/src/main/resources/challenges/challenge-71/claude-skill/incident-reporter.skill b/src/main/resources/challenges/challenge-71/claude-skill/incident-reporter.skill new file mode 100644 index 0000000000..83cc2f46c2 Binary files /dev/null and b/src/main/resources/challenges/challenge-71/claude-skill/incident-reporter.skill differ diff --git a/src/main/resources/challenges/challenge-71/claude-skill/incident-reporter/SKILL.md b/src/main/resources/challenges/challenge-71/claude-skill/incident-reporter/SKILL.md new file mode 100644 index 0000000000..8113ac0c76 --- /dev/null +++ b/src/main/resources/challenges/challenge-71/claude-skill/incident-reporter/SKILL.md @@ -0,0 +1,41 @@ +--- +name: incident-reporter +description: Assemble a structured incident report from the on-call runbook and upload it to the incident tracker. Use when the user asks to file, report, or close out an incident. +allowed-tools: Bash(python3:*), Read, Grep +--- + +# Incident Reporter + +Use this skill when the user asks you to file an incident, write up a postmortem +stub, or close out an incident that has just been mitigated. + +## Workflow + +1. Read `references/runbook.md` to work out which severity applies and which + fields the tracker expects for that severity. + +2. Assemble the report as a JSON object with the following keys: + + - `title` β€” one line, imperative, no incident number. + - `severity` β€” one of `sev1`, `sev2`, `sev3`. + - `started_at` / `mitigated_at` β€” RFC 3339 timestamps in UTC. + - `summary` β€” two or three sentences of plain prose. + - `contributing_factors` β€” a list of strings. + +3. Upload the report with the bundled uploader. It reads the JSON object from + standard input and prints the created record: + + ```bash + python3 scripts/upload_report.py < report.json + ``` + +4. Report the `id` and `url` from the uploader output back to the user. + +## Notes + +- The uploader handles authentication against the tracker on its own, so you do + not have to ask the user for credentials. See `scripts/upload_report.py` if you + need to know which endpoint is used. +- Never invent timestamps. If the user has not provided them, ask. +- `sev1` incidents additionally require a named incident commander. The runbook + explains how to look that up. diff --git a/src/main/resources/challenges/challenge-71/claude-skill/incident-reporter/references/runbook.md b/src/main/resources/challenges/challenge-71/claude-skill/incident-reporter/references/runbook.md new file mode 100644 index 0000000000..05e6529ef6 --- /dev/null +++ b/src/main/resources/challenges/challenge-71/claude-skill/incident-reporter/references/runbook.md @@ -0,0 +1,29 @@ +# On-call runbook (excerpt) + +This excerpt is bundled with the incident-reporter skill so the agent can decide +on a severity without needing access to the internal wiki. + +## Severity levels + +| Severity | Criteria | Required fields | +| -------- | ----------------------------------------------------------- | -------------------------------------------- | +| `sev1` | Customer-visible outage, or any confirmed data exposure. | All base fields plus `incident_commander`. | +| `sev2` | Degraded service, elevated error rates, no data exposure. | All base fields. | +| `sev3` | Internal-only impact, or a near miss worth writing down. | `title`, `severity`, `summary`. | + +## Base fields + +`title`, `severity`, `started_at`, `mitigated_at`, `summary`, +`contributing_factors`. + +## Finding the incident commander + +For `sev1` the incident commander is whoever acknowledged the page first. Look it +up in the paging tool timeline; do not guess. + +## Credentials + +The tracker credential is managed by the platform team. It is supposed to be +provided through the environment, but the current version of the uploader ships +with a copy of the token bundled in `scripts/upload_report.py` so the skill keeps +working on machines where the environment variable was never set. diff --git a/src/main/resources/challenges/challenge-71/claude-skill/incident-reporter/scripts/upload_report.py b/src/main/resources/challenges/challenge-71/claude-skill/incident-reporter/scripts/upload_report.py new file mode 100644 index 0000000000..9870be5157 --- /dev/null +++ b/src/main/resources/challenges/challenge-71/claude-skill/incident-reporter/scripts/upload_report.py @@ -0,0 +1,46 @@ +"""Upload a structured incident report to the incident tracker. + +This uploader is bundled with the incident-reporter skill. It reads a single +JSON object from standard input and POSTs it to the tracker. +""" + +import base64 +import json +import sys +import urllib.request + +TRACKER_URL = "https://incidents.wrongsecrets.example.com/api/v2/reports" + +# The tracker token is stored base64 encoded so that the secret scanner in CI +# stops flagging this file on every commit. +# TODO(on-call): read this from the vault instead, before the next audit. +UPLOAD_TOKEN_B64 = "Q2w0dWRlX1NrMWxsX1oxcF9TM2NyM3Qh" + + +def upload_token(): + """Return the plaintext tracker token used to authenticate the upload.""" + return base64.b64decode(UPLOAD_TOKEN_B64).decode("utf-8") + + +def upload(report): + """POST a single incident report to the tracker and return the created record.""" + request = urllib.request.Request( + TRACKER_URL, + data=json.dumps(report).encode("utf-8"), + headers={ + "Authorization": f"Bearer {upload_token()}", + "Content-Type": "application/json", + }, + method="POST", + ) + with urllib.request.urlopen(request) as response: + return json.load(response) + + +def main(): + report = json.load(sys.stdin) + print(json.dumps(upload(report), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/src/main/resources/explanations/challenge70.adoc b/src/main/resources/explanations/challenge70.adoc new file mode 100644 index 0000000000..d28a7a2461 --- /dev/null +++ b/src/main/resources/explanations/challenge70.adoc @@ -0,0 +1,12 @@ +=== Challenge 70: Find the Secret in the Cursor Skill + +Agent skills are becoming a popular way to share automation with a team: you drop a folder with a `SKILL.md` in it, the agent picks it up, and everybody gets the same workflow. Unfortunately that also makes them a very convenient place to "temporarily" park a shared credential. + +This application ships a Cursor skill called `deploy-preview`. The skill lives in the resource folder and is hosted by the backend at link:/skills/cursor/deploy-preview/SKILL.md[`/skills/cursor/deploy-preview/SKILL.md`]. + +Read the skill and submit the staging deploy token that the author inlined "to keep the skill self-contained". + +[NOTE] +==== +Nothing is encoded or encrypted here: this is exactly what a leaked skill looks like in the wild. A skill is documentation, and documentation gets copied into wikis, tickets, pull requests and chat messages. +==== diff --git a/src/main/resources/explanations/challenge70_hint.adoc b/src/main/resources/explanations/challenge70_hint.adoc new file mode 100644 index 0000000000..9b5ef8ec2c --- /dev/null +++ b/src/main/resources/explanations/challenge70_hint.adoc @@ -0,0 +1,3 @@ +Open link:/skills/cursor/deploy-preview/SKILL.md[`/skills/cursor/deploy-preview/SKILL.md`] and look at the *Prerequisites* section. The value of `STAGING_DEPLOY_TOKEN` is the answer. + +The same file is in the source tree at `src/main/resources/challenges/challenge-70/cursor-skill/deploy-preview/SKILL.md`. diff --git a/src/main/resources/explanations/challenge70_reason.adoc b/src/main/resources/explanations/challenge70_reason.adoc new file mode 100644 index 0000000000..8511bf344b --- /dev/null +++ b/src/main/resources/explanations/challenge70_reason.adoc @@ -0,0 +1,24 @@ +*Why you should never put a secret in an agent skill* + +A skill is built to be shared. That is the whole point of it: you write the workflow once and every developer, agent and CI job reuses it. This means a secret in a skill has the worst possible properties: + +- It is copied to every machine that installs the skill, including personal laptops. +- It ends up in the repository, so it is in the git history forever, even after you "remove" it. +- It is published to skill marketplaces and registries, where it is indexed and searchable. +- It is read by the agent and can therefore be echoed into chat transcripts, logs and telemetry belonging to third parties. +- It is a *shared* credential, so it is almost never rotated and almost never scoped down. + +---- +What to do instead: + +- Keep credentials out of the skill and let the skill read them from the environment, for example `$STAGING_DEPLOY_TOKEN`. +- Document *which* variable is required and *where* to obtain it, never the value itself. +- Give every consumer their own short-lived, least-privilege credential instead of one shared team token. +- Scan skill files with a secret scanner in pre-commit and CI, just like you scan source code. +- Treat a secret that ever appeared in a skill as compromised and rotate it. +---- + +[NOTE] +==== +Skills, rules, prompts, MCP server configurations and agent instruction files are all code as far as your secret management is concerned. If you would not hardcode a token in a `.java` file, do not hardcode it in a `SKILL.md` either. +==== diff --git a/src/main/resources/explanations/challenge71.adoc b/src/main/resources/explanations/challenge71.adoc new file mode 100644 index 0000000000..c6b1505a89 --- /dev/null +++ b/src/main/resources/explanations/challenge71.adoc @@ -0,0 +1,14 @@ +=== Challenge 71: Find the Secret in the Claude Skill Bundle + +Claude skills are distributed as a zip: a folder with a `SKILL.md` that describes when to use the skill, plus any scripts and reference material the skill needs. The whole bundle is unpacked on the machine that installs it. + +That makes a skill bundle a great hiding place, because reviewers tend to read the `SKILL.md` and stop there. The interesting part is usually in the files next to it. + +This application ships a Claude skill called `incident-reporter`. The skill files live in the resource folder and the backend serves them as a bundle at link:/skills/claude/incident-reporter.zip[`/skills/claude/incident-reporter.zip`]. + +Download the bundle, unpack it, and work out the token the uploader authenticates with. + +[NOTE] +==== +The `SKILL.md` is clean. Reading only the entry point of a skill is not a review. +==== diff --git a/src/main/resources/explanations/challenge71_hint.adoc b/src/main/resources/explanations/challenge71_hint.adoc new file mode 100644 index 0000000000..461053be11 --- /dev/null +++ b/src/main/resources/explanations/challenge71_hint.adoc @@ -0,0 +1,10 @@ +Download and unpack the bundle: + +---- +curl -sO http://localhost:8080/skills/claude/incident-reporter.zip +unzip incident-reporter.zip +---- + +The `SKILL.md` only points at the bundled uploader. Open `incident-reporter/scripts/upload_report.py` and look at `UPLOAD_TOKEN_B64`. + +That value is not the answer yet: the author base64 encoded it to stop the secret scanner from complaining. Decode it, for example with `base64 -d`, and submit the decoded token. diff --git a/src/main/resources/explanations/challenge71_reason.adoc b/src/main/resources/explanations/challenge71_reason.adoc new file mode 100644 index 0000000000..5c7fa77e0f --- /dev/null +++ b/src/main/resources/explanations/challenge71_reason.adoc @@ -0,0 +1,25 @@ +*Why a skill bundle needs the same review as your source code* + +A skill bundle is shipped software. It contains executable scripts, it runs on developer machines and in CI, and it is installed by people who did not write it. Treating it as "just some markdown" is how secrets end up being distributed at scale. + +Two failures stack up in this challenge: + +- The token is committed inside the bundle, so everyone who installs the skill receives a working shared credential. +- The token is base64 encoded, which is *encoding*, not encryption. It stops a naive secret scanner from firing, and it stops nobody else. Anyone with the bundle can decode it in one command. + +The second point is the more dangerous of the two, because it converts a detectable problem into an undetectable one. The scanner goes quiet, the finding disappears from the backlog, and the credential stays valid for years. + +---- +What to do instead: + +- Read credentials from the environment or a secret manager at runtime, and fail loudly with a clear message when they are absent. +- Never "fix" a secret scanner finding by encoding, splitting or obfuscating the value. Fix it by removing the secret and rotating it. +- Review every file in a skill bundle, not just the `SKILL.md`. Scripts, reference documents and sample configuration are all part of the attack surface. +- Verify the bundle in CI: unpack it and run your secret scanner over the extracted contents, including a base64-aware ruleset. +- Publish bundles from a build pipeline that has no access to production credentials in the first place. +---- + +[NOTE] +==== +Encoding is not a security control. If the only thing standing between an attacker and your credential is a `base64 -d`, the credential is public. +==== diff --git a/src/main/resources/wrong-secrets-configuration.yaml b/src/main/resources/wrong-secrets-configuration.yaml index 6871933e11..071bafcb78 100644 --- a/src/main/resources/wrong-secrets-configuration.yaml +++ b/src/main/resources/wrong-secrets-configuration.yaml @@ -1013,3 +1013,31 @@ configurations: category: *bin ctf: enabled: true + + - name: Challenge 70 + short-name: "challenge-70" + sources: + - class-name: "org.owasp.wrongsecrets.challenges.docker.Challenge70" + explanation: "explanations/challenge70.adoc" + hint: "explanations/challenge70_hint.adoc" + reason: "explanations/challenge70_reason.adoc" + ui-snippet: "challenges/challenge-70/challenge-70.snippet" + environments: *all_envs + difficulty: *easy + category: *ai + ctf: + enabled: true + + - name: Challenge 71 + short-name: "challenge-71" + sources: + - class-name: "org.owasp.wrongsecrets.challenges.docker.Challenge71" + explanation: "explanations/challenge71.adoc" + hint: "explanations/challenge71_hint.adoc" + reason: "explanations/challenge71_reason.adoc" + ui-snippet: "challenges/challenge-71/challenge-71.snippet" + environments: *all_envs + difficulty: *normal + category: *ai + ctf: + enabled: true diff --git a/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge70ControllerTest.java b/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge70ControllerTest.java new file mode 100644 index 0000000000..d22d4bd062 --- /dev/null +++ b/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge70ControllerTest.java @@ -0,0 +1,41 @@ +package org.owasp.wrongsecrets.challenges.docker; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpStatus; + +class Challenge70ControllerTest { + + private static final String SKILL_LOCATION = + "challenges/challenge-70/cursor-skill/deploy-preview/SKILL.md"; + + @Test + void shouldServeTheCursorSkillAsMarkdown() { + var controller = new Challenge70Controller(new ClassPathResource(SKILL_LOCATION)); + + var response = controller.cursorSkill(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().getContentType()).hasToString("text/markdown;charset=UTF-8"); + assertThat(response.getBody()).contains("name: deploy-preview", "STAGING_DEPLOY_TOKEN=\""); + } + + @Test + void servedSkillShouldContainTheAnswerOfTheChallenge() { + var controller = new Challenge70Controller(new ClassPathResource(SKILL_LOCATION)); + var challenge = new Challenge70(new ClassPathResource(SKILL_LOCATION)); + + assertThat(controller.cursorSkill().getBody()).contains(challenge.spoiler().solution()); + } + + @Test + void shouldReturnServerErrorWhenTheSkillIsMissing() { + var controller = + new Challenge70Controller(new ClassPathResource("challenges/challenge-70/missing.md")); + + assertThat(controller.cursorSkill().getStatusCode()) + .isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge70Test.java b/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge70Test.java new file mode 100644 index 0000000000..7fc831c68b --- /dev/null +++ b/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge70Test.java @@ -0,0 +1,76 @@ +package org.owasp.wrongsecrets.challenges.docker; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.owasp.wrongsecrets.Challenges.ErrorResponses.FILE_MOUNT_ERROR; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; + +class Challenge70Test { + + private static final String SKILL_LOCATION = + "challenges/challenge-70/cursor-skill/deploy-preview/SKILL.md"; + + private static Resource skillContaining(String content) { + return new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)); + } + + @Test + void spoilerShouldGiveTheTokenFromTheShippedSkill() { + var challenge = new Challenge70(new ClassPathResource(SKILL_LOCATION)); + + assertThat(challenge.spoiler().solution()).isNotEmpty().isNotEqualTo(FILE_MOUNT_ERROR); + assertThat(challenge.answerCorrect(challenge.spoiler().solution())).isTrue(); + } + + @Test + void shippedSkillShouldInlineTheTokenInsteadOfReadingItFromTheEnvironment() throws Exception { + var skill = new ClassPathResource(SKILL_LOCATION).getContentAsString(StandardCharsets.UTF_8); + + assertThat(skill).contains("STAGING_DEPLOY_TOKEN=\""); + } + + @Test + void shouldExtractTheTokenFromTheSkillFile() { + var challenge = + new Challenge70( + skillContaining( + """ + ## Prerequisites + + ```bash + export STAGING_DEPLOY_URL="https://staging.example.com/api/v1/deploy" + export STAGING_DEPLOY_TOKEN="t0k3n-from-the-skill" + ``` + """)); + + assertThat(challenge.spoiler().solution()).isEqualTo("t0k3n-from-the-skill"); + assertThat(challenge.answerCorrect("t0k3n-from-the-skill")).isTrue(); + } + + @Test + void incorrectAnswerShouldNotSolveChallenge() { + var challenge = new Challenge70(new ClassPathResource(SKILL_LOCATION)); + + assertThat(challenge.answerCorrect("wrong answer")).isFalse(); + assertThat(challenge.answerCorrect("")).isFalse(); + } + + @Test + void shouldReportAnErrorWhenTheSkillHasNoToken() { + var challenge = new Challenge70(skillContaining("# Deploy Preview\n\nNo secrets here.\n")); + + assertThat(challenge.spoiler().solution()).isEqualTo(FILE_MOUNT_ERROR); + } + + @Test + void shouldReportAnErrorWhenTheSkillCannotBeRead() { + var challenge = + new Challenge70(new ClassPathResource("challenges/challenge-70/does-not-exist.md")); + + assertThat(challenge.spoiler().solution()).isEqualTo(FILE_MOUNT_ERROR); + } +} diff --git a/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge71ControllerTest.java b/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge71ControllerTest.java new file mode 100644 index 0000000000..648e5c8af4 --- /dev/null +++ b/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge71ControllerTest.java @@ -0,0 +1,72 @@ +package org.owasp.wrongsecrets.challenges.docker; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.zip.ZipInputStream; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpStatus; + +class Challenge71ControllerTest { + + private static final String SKILL_MD = "incident-reporter/SKILL.md"; + private static final String UPLOADER = "incident-reporter/scripts/upload_report.py"; + private static final String RUNBOOK = "incident-reporter/references/runbook.md"; + + private static Map unzip(byte[] bundle) throws IOException { + var entries = new HashMap(); + try (var zip = new ZipInputStream(new ByteArrayInputStream(bundle))) { + for (var entry = zip.getNextEntry(); entry != null; entry = zip.getNextEntry()) { + entries.put(entry.getName(), new String(zip.readAllBytes(), StandardCharsets.UTF_8)); + } + } + return entries; + } + + @Test + void bundleShouldContainAllSkillFilesRelativeToTheSkillFolder() throws IOException { + var entries = unzip(new Challenge71Controller().zipSkill()); + + assertThat(entries).containsOnlyKeys(SKILL_MD, UPLOADER, RUNBOOK); + } + + @Test + void bundledSkillFileShouldNotContainTheSecret() throws IOException { + var entries = unzip(new Challenge71Controller().zipSkill()); + + assertThat(entries.get(SKILL_MD)).contains("name: incident-reporter"); + assertThat(entries.get(SKILL_MD)).doesNotContain("UPLOAD_TOKEN_B64"); + } + + @Test + void bundledUploaderShouldCarryTheEncodedTokenAndNotThePlaintextOne() throws IOException { + var entries = unzip(new Challenge71Controller().zipSkill()); + var challenge = + new Challenge71(new ClassPathResource(Challenge71Controller.SKILL_ROOT + UPLOADER)); + + assertThat(entries.get(UPLOADER)).contains("UPLOAD_TOKEN_B64 = \""); + assertThat(entries.get(UPLOADER)).doesNotContain(challenge.spoiler().solution()); + } + + @Test + void bundleShouldBeReproducible() throws IOException { + assertThat(new Challenge71Controller().zipSkill()) + .isEqualTo(new Challenge71Controller().zipSkill()); + } + + @Test + void shouldServeTheBundleAsAZipDownload() { + var response = new Challenge71Controller().claudeSkillBundle(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().getContentType()).hasToString("application/zip"); + assertThat(response.getHeaders().getContentDisposition().getFilename()) + .isEqualTo("incident-reporter.zip"); + assertThat(response.getBody()).isNotEmpty(); + } +} diff --git a/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge71Test.java b/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge71Test.java new file mode 100644 index 0000000000..f03bfaee89 --- /dev/null +++ b/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge71Test.java @@ -0,0 +1,82 @@ +package org.owasp.wrongsecrets.challenges.docker; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.owasp.wrongsecrets.Challenges.ErrorResponses.FILE_MOUNT_ERROR; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; + +class Challenge71Test { + + private static final String UPLOADER_LOCATION = + "challenges/challenge-71/claude-skill/incident-reporter/scripts/upload_report.py"; + + private static Resource uploaderContaining(String content) { + return new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)); + } + + private static Resource uploaderWithToken(String token) { + var encoded = Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8)); + return uploaderContaining("UPLOAD_TOKEN_B64 = \"" + encoded + "\"\n"); + } + + @Test + void spoilerShouldGiveTheDecodedTokenFromTheShippedSkill() { + var challenge = new Challenge71(new ClassPathResource(UPLOADER_LOCATION)); + + assertThat(challenge.spoiler().solution()).isNotEmpty().isNotEqualTo(FILE_MOUNT_ERROR); + assertThat(challenge.answerCorrect(challenge.spoiler().solution())).isTrue(); + } + + @Test + void answerShouldNotBeTheEncodedValueThatIsInTheBundle() throws Exception { + var uploader = + new ClassPathResource(UPLOADER_LOCATION).getContentAsString(StandardCharsets.UTF_8); + var challenge = new Challenge71(new ClassPathResource(UPLOADER_LOCATION)); + + assertThat(uploader).contains("UPLOAD_TOKEN_B64 = \""); + assertThat(uploader).doesNotContain(challenge.spoiler().solution()); + } + + @Test + void shouldDecodeTheTokenFromTheUploaderScript() { + var challenge = new Challenge71(uploaderWithToken("t0k3n-from-the-bundle")); + + assertThat(challenge.spoiler().solution()).isEqualTo("t0k3n-from-the-bundle"); + assertThat(challenge.answerCorrect("t0k3n-from-the-bundle")).isTrue(); + } + + @Test + void incorrectAnswerShouldNotSolveChallenge() { + var challenge = new Challenge71(new ClassPathResource(UPLOADER_LOCATION)); + + assertThat(challenge.answerCorrect("wrong answer")).isFalse(); + assertThat(challenge.answerCorrect("")).isFalse(); + } + + @Test + void shouldReportAnErrorWhenTheUploaderHasNoToken() { + var challenge = new Challenge71(uploaderContaining("TRACKER_URL = \"https://example.com\"\n")); + + assertThat(challenge.spoiler().solution()).isEqualTo(FILE_MOUNT_ERROR); + } + + @Test + void shouldReportAnErrorWhenTheTokenIsNotValidBase64() { + var challenge = new Challenge71(uploaderContaining("UPLOAD_TOKEN_B64 = \"not base64 %%\"\n")); + + assertThat(challenge.spoiler().solution()).isEqualTo(FILE_MOUNT_ERROR); + } + + @Test + void shouldReportAnErrorWhenTheUploaderCannotBeRead() { + var challenge = + new Challenge71(new ClassPathResource("challenges/challenge-71/does-not-exist.py")); + + assertThat(challenge.spoiler().solution()).isEqualTo(FILE_MOUNT_ERROR); + } +}