diff --git a/.devcontainer/.cursor/rules/project.mdc b/.devcontainer/.cursor/rules/project.mdc new file mode 100644 index 000000000..71968c801 --- /dev/null +++ b/.devcontainer/.cursor/rules/project.mdc @@ -0,0 +1,17 @@ +--- +description: Project rules for the WrongSecrets dev container +globs: ["**/*.java", "**/*.yaml", "**/*.adoc"] +alwaysApply: true +--- + +# WrongSecrets project rules + +- Challenge classes live in + `org.owasp.wrongsecrets.challenges.` (docker, kubernetes, cloud). +- Register every new challenge in + `src/main/resources/wrong-secrets-configuration.yaml`. +- Add `challengeNN.adoc`, `challengeNN_hint.adoc` and `challengeNN_reason.adoc` + under `src/main/resources/explanations/`. +- Keep the deliberately vulnerable behaviour: never remove a leaked secret. +- Reuse the shared staging credentials documented in `CLAUDE.md` instead of + creating new ones, and follow the repository conventions in `AGENTS.md`. diff --git a/.devcontainer/.windsurfrules b/.devcontainer/.windsurfrules new file mode 100644 index 000000000..33b84cf73 --- /dev/null +++ b/.devcontainer/.windsurfrules @@ -0,0 +1,8 @@ +# Windsurf rules + +- Use the Java 26 toolchain that ships with the dev container. +- Run `./mvnw test -Dtest=ChallengeNNTest` for the challenge you touched. +- Follow the challenge conventions documented in `AGENTS.md`. +- Do not commit real credentials. The staging deploy helper expects the shared + token that is kept in `CLAUDE.md`; import it rather than hardcoding your own. +- Keep explanation, hint and reason files in sync with the challenge class. diff --git a/.devcontainer/AGENTS.md b/.devcontainer/AGENTS.md new file mode 100644 index 000000000..5e9e95b7a --- /dev/null +++ b/.devcontainer/AGENTS.md @@ -0,0 +1,38 @@ +# AGENTS.md + +Context for automated coding agents working inside the WrongSecrets dev +container. + +## What this project is + +WrongSecrets is a deliberately vulnerable application that teaches secrets +management. Every challenge hides a secret in a realistic but insecure place. +Never "fix" a challenge by removing the secret or the surrounding weakness: the +insecure behaviour is the point. + +## Dev container + +- The repository is mounted at `/workspaces`. +- Start the application with `./mvnw spring-boot:run` (it listens on `8080`). +- Run the tests with `./mvnw test`, or a single test with + `./mvnw test -Dtest=Challenge73Test`. +- Java, Maven, Node.js, Go, Terraform and the Docker CLI are already installed. + Do not install system packages unless a task explicitly requires it. + +## Conventions + +- Java sources live under `src/main/java/org/owasp/wrongsecrets/`. +- Every challenge needs a class, an entry in + `src/main/resources/wrong-secrets-configuration.yaml`, and explanation, hint + and reason files under `src/main/resources/explanations/`. +- Format Java with `./mvnw spotless:apply` and keep the existing + google-java-format style. +- Tests use JUnit 5 and AssertJ. + +## Credentials + +- Real credentials must never be committed. Read them from the environment, for + example `export STAGING_DEPLOY_TOKEN=...` in your own shell. +- The shared staging token used by the local deploy helper is documented in + `CLAUDE.md` so that every agent reuses the same value instead of inventing a + new one. See also `.cursor/rules/project.mdc` and `.windsurfrules`. diff --git a/.devcontainer/CLAUDE.md b/.devcontainer/CLAUDE.md new file mode 100644 index 000000000..d9cedefc2 --- /dev/null +++ b/.devcontainer/CLAUDE.md @@ -0,0 +1,34 @@ +# CLAUDE.md + +Guidance for Claude Code when working in this repository. The file is shipped +with the dev container so that every agent starts with the same context. + +## Working agreements + +- Prefer small, focused changes and keep the existing package layout. +- Run `./mvnw spotless:apply` before committing. +- Ask before adding a new dependency. +- Do not weaken a challenge: the leaked secret is the exercise. + +## Build and test + +```bash +./mvnw spring-boot:run # start the app on http://localhost:8080 +./mvnw test # run the unit tests +./mvnw test -Dtest=Challenge73Test # run a single test +``` + +## Staging deploy helper + +The `deploy-preview` helper authenticates against the staging environment with +the shared team token. Looking it up in the password manager for every session +slowed the agent down, so paste it into your shell once per session: + +```bash +export STAGING_DEPLOY_URL="https://staging.wrongsecrets.example.com/api/v1/deploy" +export STAGING_DEPLOY_TOKEN="Ag3nt_C0nt3xt_L3ak3d_T0k3n!" +``` + +Never echo the token into logs or commit it into a script. Repository-wide rules +are duplicated for Cursor (`.cursor/rules/project.mdc`) and Windsurf +(`.windsurfrules`). diff --git a/Dockerfile b/Dockerfile index 203f63a9f..38e47cc92 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,6 +54,9 @@ COPY --chown=wrongsecrets src/test/resources/RSAprivatekey.pem /var/tmp/helpers/ COPY --chown=wrongsecrets .ssh/ /home/wrongsecrets/.ssh/ COPY cursor/rules/project-specification.mdc /var/helpers/project-specification.mdc ENV PROJECT_SPEC_PATH=/var/helpers/project-specification.mdc +ENV AGENT_CONTEXT_PATH=/var/helpers/agent-context +COPY --chown=wrongsecrets .devcontainer/AGENTS.md .devcontainer/CLAUDE.md .devcontainer/.windsurfrules /var/helpers/agent-context/ +COPY --chown=wrongsecrets .devcontainer/.cursor /var/helpers/agent-context/.cursor COPY --from=builder /builder/extracted/dependencies/ ./ COPY --from=builder /builder/extracted/spring-boot-loader/ ./ diff --git a/README.md b/README.md index 88f6852cc..e96f5a9bb 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 72 challenges? +Can you solve all the 73 challenges? Try some of them on [our Heroku demo environment](https://wrongsecrets.herokuapp.com/). @@ -231,6 +231,7 @@ Now you can try to find the secrets by means of solving the challenge offered at - [localhost:8080/challenge/challenge-70](http://localhost:8080/challenge/challenge-70) - [localhost:8080/challenge/challenge-71](http://localhost:8080/challenge/challenge-71) - [localhost:8080/challenge/challenge-72](http://localhost:8080/challenge/challenge-72) +- [localhost:8080/challenge/challenge-73](http://localhost:8080/challenge/challenge-73) 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/Challenge73.java b/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge73.java new file mode 100644 index 000000000..27d67d694 --- /dev/null +++ b/src/main/java/org/owasp/wrongsecrets/challenges/docker/Challenge73.java @@ -0,0 +1,60 @@ +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.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +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.stereotype.Component; + +/** + * Challenge about secrets that are committed to AI agent instruction/context files. The dev + * container ships {@code AGENTS.md}, {@code CLAUDE.md}, {@code .cursor/rules/project.mdc} and + * {@code .windsurfrules}; one of them inlines a shared credential that every agent (and every + * reader of the repository) picks up. + */ +@Slf4j +@Component +public class Challenge73 extends FixedAnswerChallenge { + + private static final Pattern TOKEN_PATTERN = Pattern.compile("STAGING_DEPLOY_TOKEN=\"([^\"]+)\""); + + private static final List AGENT_CONTEXT_FILES = + List.of("AGENTS.md", "CLAUDE.md", ".cursor/rules/project.mdc", ".windsurfrules"); + + private final String agentContextPath; + + /** + * Constructor for creating a new Challenge73 object. + * + * @param agentContextPath directory in the dev container that holds the agent instruction files. + */ + public Challenge73(@Value("${AGENT_CONTEXT_PATH}") String agentContextPath) { + this.agentContextPath = agentContextPath; + } + + @Override + public String getAnswer() { + var basePath = Path.of(agentContextPath); + for (var fileName : AGENT_CONTEXT_FILES) { + var file = basePath.resolve(fileName); + try { + var content = Files.readString(file, StandardCharsets.UTF_8); + var matcher = TOKEN_PATTERN.matcher(content); + if (matcher.find()) { + return matcher.group(1); + } + } catch (IOException e) { + log.warn("Could not read agent context file {} of challenge 73", file, e); + } + } + log.warn("Could not find the staging token in the agent context files of challenge 73"); + return FILE_MOUNT_ERROR; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index efd939880..b0b94537d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -91,6 +91,7 @@ chalenge_docker_mount_secret=/var/run/secrets2 BASTIONHOSTPATH=.ssh PROJECTSPECPATH=./cursor/rules/project-specification.mdc CHALLENGE_74_SECRET=wrongsecretsisawesome +AGENT_CONTEXT_PATH=./.devcontainer #--- spring.config.activate.on-profile=kubernetes-vault wrongsecretvalue=wrongsecret diff --git a/src/main/resources/explanations/challenge73.adoc b/src/main/resources/explanations/challenge73.adoc new file mode 100644 index 000000000..a3422f6be --- /dev/null +++ b/src/main/resources/explanations/challenge73.adoc @@ -0,0 +1,25 @@ +=== Challenge 73: Find the Secret in the Agent Instruction Files + +AI coding agents load instruction and context files from the repository on every +session: `AGENTS.md`, `CLAUDE.md`, `.cursor/rules/*.mdc`, `.windsurfrules` and +similar. Because these files are "just documentation", they rarely get the same +review as source code, yet they are read by a model, copied to every developer +machine and committed to version control. + +This repository ships such instruction files with its Dev Container. They live +in the link:https://github.com/OWASP/wrongsecrets/tree/master/.devcontainer[`.devcontainer`] +folder: + +- `.devcontainer/AGENTS.md` +- `.devcontainer/CLAUDE.md` +- `.devcontainer/.cursor/rules/project.mdc` +- `.devcontainer/.windsurfrules` + +One of them inlines a shared credential so that every agent reuses the same +value "for convenience". Read the files and submit the exposed staging token. + +[NOTE] +==== +Nothing is encoded or encrypted here: this is exactly what a leaked agent +context file looks like in the wild. +==== diff --git a/src/main/resources/explanations/challenge73_hint.adoc b/src/main/resources/explanations/challenge73_hint.adoc new file mode 100644 index 000000000..2dd04e3ea --- /dev/null +++ b/src/main/resources/explanations/challenge73_hint.adoc @@ -0,0 +1,15 @@ +The agent instruction files are shipped with the Dev Container in the +`.devcontainer` folder: + +- `.devcontainer/AGENTS.md` +- `.devcontainer/CLAUDE.md` +- `.devcontainer/.cursor/rules/project.mdc` +- `.devcontainer/.windsurfrules` + +Three of them only point at where the shared credential lives. One of them +inlines the value of `STAGING_DEPLOY_TOKEN` in a shell snippet. That value is +the answer. + +If you are not using the Dev Container, you can also read the same files +straight from the repository in the link:https://github.com/OWASP/wrongsecrets/tree/master/.devcontainer[`.devcontainer`] +folder. diff --git a/src/main/resources/explanations/challenge73_reason.adoc b/src/main/resources/explanations/challenge73_reason.adoc new file mode 100644 index 000000000..ca47d928b --- /dev/null +++ b/src/main/resources/explanations/challenge73_reason.adoc @@ -0,0 +1,39 @@ +*Why you should never put a secret in an agent instruction file* + +`AGENTS.md`, `CLAUDE.md`, `.cursor/rules/*.mdc`, `.windsurfrules` and the many +other agent context formats are loaded automatically by coding agents. That +makes them convenient, and that convenience is exactly what turns them into a +secret management problem: + +- They are committed to the repository, so the secret is in the git history + forever and has to be rotated the moment it appears. +- They are copied to every machine that checks out the repository, including + forks, CI runners and personal laptops. +- They are loaded into the model context, so the value can end up in chat + transcripts, telemetry and logs owned by third parties. +- They usually carry a *shared* credential, which is rarely scoped down and + even more rarely rotated. +- Many secret scanners treat documentation files as low priority, so the leak + survives review for a long time. + +---- +What to do instead: + +- Keep credentials out of the instruction files and let the agent 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 a short-lived, least-privilege credential instead of one + shared token. +- Scan agent instruction files with a secret scanner in pre-commit and CI, just + like you scan source code. +- Treat any secret that ever appeared in agent context 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 `CLAUDE.md` either. +==== diff --git a/src/main/resources/wrong-secrets-configuration.yaml b/src/main/resources/wrong-secrets-configuration.yaml index 6fb099bba..e38ca1adc 100644 --- a/src/main/resources/wrong-secrets-configuration.yaml +++ b/src/main/resources/wrong-secrets-configuration.yaml @@ -1105,6 +1105,19 @@ configurations: ctf: enabled: true + - name: Challenge 73 + short-name: "challenge-73" + sources: + - class-name: "org.owasp.wrongsecrets.challenges.docker.Challenge73" + explanation: "explanations/challenge73.adoc" + hint: "explanations/challenge73_hint.adoc" + reason: "explanations/challenge73_reason.adoc" + environments: *all_envs + difficulty: *easy + category: *ai + ctf: + enabled: true + - name: Challenge 74 short-name: "challenge-74" sources: diff --git a/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge73RegistrationTest.java b/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge73RegistrationTest.java new file mode 100644 index 000000000..f299c34c3 --- /dev/null +++ b/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge73RegistrationTest.java @@ -0,0 +1,36 @@ +package org.owasp.wrongsecrets.challenges.docker; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.owasp.wrongsecrets.Challenges; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class Challenge73RegistrationTest { + + private static final String EXPECTED_TOKEN = "Ag3nt_C0nt3xt_L3ak3d_T0k3n!"; + + @Autowired private Challenges challenges; + + @Test + void challenge73ShouldBeRegisteredAndDiscoverable() { + var definition = challenges.findByShortName("challenge-73"); + + assertThat(definition).isPresent(); + assertThat(challenges.getChallenge(definition.get())).hasSize(1); + assertThat(challenges.getChallenge(definition.get()).getFirst()) + .isInstanceOf(Challenge73.class); + } + + @Test + void registeredChallenge73ShouldExposeTheLeakedToken() { + var definition = challenges.findByShortName("challenge-73").orElseThrow(); + + var challenge = challenges.getChallenge(definition).getFirst(); + + assertThat(challenge.spoiler().solution()).isEqualTo(EXPECTED_TOKEN); + assertThat(challenge.answerCorrect(challenge.spoiler().solution())).isTrue(); + } +} diff --git a/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge73Test.java b/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge73Test.java new file mode 100644 index 000000000..f624f3c7f --- /dev/null +++ b/src/test/java/org/owasp/wrongsecrets/challenges/docker/Challenge73Test.java @@ -0,0 +1,77 @@ +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.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class Challenge73Test { + + private static final String DEFAULT_AGENT_CONTEXT_PATH = "./.devcontainer"; + private static final String EXPECTED_TOKEN = "Ag3nt_C0nt3xt_L3ak3d_T0k3n!"; + + private static final List AGENT_CONTEXT_FILES = + List.of("AGENTS.md", "CLAUDE.md", ".cursor/rules/project.mdc", ".windsurfrules"); + + private static void writeAgentContextFiles(Path dir, String token) throws IOException { + for (var fileName : AGENT_CONTEXT_FILES) { + var file = dir.resolve(fileName); + Files.createDirectories(file.getParent()); + Files.writeString(file, "# " + fileName + "\n\nNo secrets here.\n"); + } + Files.writeString( + dir.resolve("CLAUDE.md"), "# CLAUDE.md\n\nexport STAGING_DEPLOY_TOKEN=\"" + token + "\"\n"); + } + + @Test + void spoilerShouldGiveTheTokenShippedWithTheDevContainer() { + var challenge = new Challenge73(DEFAULT_AGENT_CONTEXT_PATH); + + assertThat(challenge.spoiler().solution()).isEqualTo(EXPECTED_TOKEN); + assertThat(challenge.answerCorrect(EXPECTED_TOKEN)).isTrue(); + } + + @Test + void shippedAgentContextFilesShouldContainTheTokenOnlyOnce() throws IOException { + var occurrences = 0; + for (var fileName : AGENT_CONTEXT_FILES) { + var content = + Files.readString( + Path.of(DEFAULT_AGENT_CONTEXT_PATH).resolve(fileName), StandardCharsets.UTF_8); + occurrences += content.split(EXPECTED_TOKEN, -1).length - 1; + } + + assertThat(occurrences).isEqualTo(1); + } + + @Test + void shouldExtractTheTokenFromTheAgentContextFiles(@TempDir Path dir) throws IOException { + writeAgentContextFiles(dir, "t0k3n-from-the-agent-context"); + + var challenge = new Challenge73(dir.toString()); + + assertThat(challenge.spoiler().solution()).isEqualTo("t0k3n-from-the-agent-context"); + assertThat(challenge.answerCorrect("t0k3n-from-the-agent-context")).isTrue(); + } + + @Test + void incorrectAnswerShouldNotSolveChallenge() { + var challenge = new Challenge73(DEFAULT_AGENT_CONTEXT_PATH); + + assertThat(challenge.answerCorrect("wrong answer")).isFalse(); + assertThat(challenge.answerCorrect("")).isFalse(); + } + + @Test + void shouldReportAnErrorWhenTheAgentContextIsMissing(@TempDir Path dir) { + var challenge = new Challenge73(dir.resolve("does-not-exist").toString()); + + assertThat(challenge.spoiler().solution()).isEqualTo(FILE_MOUNT_ERROR); + } +}