claudeos-core 2.5.1 → 2.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -1
- package/README.de.md +1 -1
- package/README.es.md +1 -1
- package/README.fr.md +1 -1
- package/README.hi.md +1 -1
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.ru.md +1 -1
- package/README.vi.md +1 -1
- package/README.zh-CN.md +1 -1
- package/lib/env-parser.js +150 -4
- package/package.json +1 -1
- package/plan-installer/index.js +17 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Quick navigation to recent releases:
|
|
6
6
|
|
|
7
|
+
- [`2.5.2`](#252--2026-09-09) — Safety patch: URL passwords the masking rule cannot reach are dropped whole (raw `/` `?` `#` space, digit-leading, `@` in a query string), `host`/`apiTarget` never carry the sentinel, `init` names the dropped keys. Scanner byte-identical to 2.5.1.
|
|
7
8
|
- [`2.5.1`](#251--2026-09-08) — Legacy JVM detection (non-Boot Spring 1.x–6.x, Ant / Ivy, Eclipse / IntelliJ / NetBeans metadata, `WEB-INF/lib` jars, `web.xml`, Maven multi-module and sibling projects, eGovFrame), `.env` key-name redaction gaps closed, `.env.example` DB detection, LF line endings
|
|
8
9
|
- [`2.5.0`](#250--2026-09-07) — Correctness release: append-only `decision-log.md`, read-only `health`, user-owned rules survive `--force`, flat-layout Java detection, Java multi-module + Next.js route groups, orchestrator-written path allowlist, `.env` credential masking
|
|
9
10
|
- [`2.4.4`](#244--2026-05-04) — Translation polish for `docs/{lang}/` × 8 non-Korean languages + broken `#quick-start` anchor fix
|
|
@@ -28,6 +29,49 @@ For older entries scroll past v1.5.0 or use the GitHub blame view.
|
|
|
28
29
|
|
|
29
30
|
---
|
|
30
31
|
|
|
32
|
+
## [2.5.2] — 2026-09-09
|
|
33
|
+
|
|
34
|
+
Safety patch. v2.5.0 introduced URL credential masking and v2.5.1 closed its key-name gaps; this release closes the shapes the masking rule itself cannot reach. **Scope is deliberately narrow.** `plan-installer/scanners/scan-java.js` and `plan-installer/stack-detector.js` are **byte-identical to v2.5.1**, so no detection, domain or counting behavior changes at all. The Java layout work that was originally staged for this version (eGovFrame `web/` HTTP layers, `*/impl/` sub-layers, directory-fallback corrections) is withdrawn and deferred: independent review reproduced hard failures in it, including a scanner crash and a zero-domain `init` abort on trees v2.5.1 handled correctly. Shipping the credential fixes on their own is worth more than shipping them attached to a scanner regression.
|
|
35
|
+
|
|
36
|
+
`project-analysis.json` gains one optional array (`stack.envInfo.credentialWarnings`); two existing fields (`stack.envInfo.host`, `stack.envInfo.apiTarget`) can now be `null` where they previously carried a sentinel string. Test suite: 974 → **981 / 981** pass (7 added).
|
|
37
|
+
|
|
38
|
+
### Fixed — safety
|
|
39
|
+
|
|
40
|
+
- **A URL password the userinfo rule cannot rewrite is no longer written verbatim.** v2.5.0 masks userinfo between `://` and the last `@` of the authority, and deliberately does not rewrite an `@` that appears after the first `/`, because such an `@` normally belongs to the path (`https://cdn.example.com/npm/@scope/pkg`) and rewriting it would replace the real host. `postgres://app:pa/ss@db:5432/app` therefore passed through unchanged, and because its key is `DATABASE_URL` the key-name rule did not backstop it either. That was the remaining combination able to put a plaintext password into `project-analysis.json`, which the Pass 3/4 prompts instruct the model to read. v2.5.1 recorded it as an accepted limit on the grounding that such a value is not a valid URL; the base64 alphabet contains `/`, so generated passwords hit the shape routinely.
|
|
41
|
+
|
|
42
|
+
`hasUnmaskedUrlCredentials` locates the `@` that actually terminates an authority: the last one followed by something that can be a host, optionally `:port`, then a delimiter or end of value. If the text before it holds no `/`, the userinfo rule already handled the value. If it does, the real authority ran past the point where that rule was forced to stop, and the **whole value** is dropped (`***REDACTED***`) rather than partially rewritten. Once the authority is ambiguous the host cannot be located reliably, and a lost host is a far cheaper failure than a leaked credential.
|
|
43
|
+
|
|
44
|
+
Three shapes beyond the original `/` case are caught. A password **beginning with digits** (`postgres://user:12345/6@db/app`) defeats any check that reasons from the truncated authority, since `user:12345` reads as a valid `host:port`. A password holding a **space** is invisible to the userinfo rule, whose character class excludes whitespace. And a DSN that also carries an `@` **inside a query parameter** — an email address, a redirect URL — must not end the search early; the scan continues past that `@` to the earlier one that terminates the real authority.
|
|
45
|
+
|
|
46
|
+
`user:12345` and `a.com:8080` are syntactically indistinguishable, so where the ambiguity is irreducible the scheme breaks the tie: for a DSN the value is treated as credentials, for `http`/`https`/`ws`/`wss` — where an `@` inside a path is routine — a head that is already a complete `host[:port]` means the authority ended there. Three further guards keep credential-free values intact: a bracketed IPv6 literal can only be a host, an `@` that sits in a query string after a path has begun is query content, and an unexpanded `${VAR}` template holds no live secret. Both corpora are pinned as tests: thirteen flagged shapes and twenty-six that must not flag.
|
|
47
|
+
|
|
48
|
+
- **`envInfo.host` and `envInfo.apiTarget` never carry the sentinel.** Both are rendered straight into CLAUDE.md §3, and the scaffold's only sentinel instruction covers `envInfo.vars`, so a dropped value reached the generated document as a literal `| API Proxy Target | ***REDACTED*** |` row. They are `null` instead, which the scaffold already handles by omitting the row.
|
|
49
|
+
|
|
50
|
+
- **`init` says which key it dropped.** Losing a `DATABASE_URL` silently looks like a detection bug. Phase 1 now names the affected keys — key names only, never any part of the value — for the root `.env` **and** for a sub-directory SPA's own `.env`, which the first cut of this warning did not read.
|
|
51
|
+
|
|
52
|
+
### Withdrawn
|
|
53
|
+
|
|
54
|
+
- The Java layout work staged for v2.5.2 is not in this release. Independent review of it reproduced, against v2.5.1 as the baseline: a `TypeError` crash out of `scanJavaDomains` on a class name containing `Constructor`; a zero-domain result that aborts `init` on a single-domain legacy tree v2.5.1 scanned correctly; and five shapes that silently dropped real domains or their files, including numbered eGovFrame controllers (`EgovSample2Controller`) and any domain whose package name appears in an internal skip list. The 1,016-test suite in that revision passed while every one of those defects was live. The feature is worth having and will return once it is verified against those shapes rather than against the layouts it was designed for.
|
|
55
|
+
|
|
56
|
+
### Behavior changes
|
|
57
|
+
|
|
58
|
+
Three observable changes, all in `.env` handling:
|
|
59
|
+
|
|
60
|
+
- A URL value whose userinfo the masking rule cannot rewrite is dropped whole instead of passing through. This covers a password holding a raw `/`, `?`, `#` or space, or one beginning with digits. The v2.5.0 assertion pinning the old pass-through result for `postgres://app:pa/ss@db:5432/app` is superseded, not relaxed. Percent-encode the password (`/` as `%2F`) to keep scheme, host and path visible in the generated docs.
|
|
61
|
+
- `envInfo.host` and `envInfo.apiTarget` are `null` rather than `"***REDACTED***"` when their value was dropped, so the corresponding CLAUDE.md §3 row is omitted instead of showing the sentinel.
|
|
62
|
+
- `init` prints a Phase 1 warning naming the keys it dropped. No value material is printed.
|
|
63
|
+
|
|
64
|
+
### Known limits
|
|
65
|
+
|
|
66
|
+
- Where a URL's authority is genuinely ambiguous the scheme decides, so an `https://` value whose password both begins with digits and contains a `/` (`https://user:12345/6@host/x`) is read as a path and left masked-through. Ordinary HTTP basic-auth credentials, and every DSN scheme, are caught.
|
|
67
|
+
- Oracle-style JDBC DSNs (`jdbc:oracle:thin:scott/tiger@//dbhost:1521/ORCL`) are still not masked by any rule: the `://` gate cannot match them, the scheme-less branch fires only on the Go/MySQL `@tcp(`/`@unix(` shape, and neither `SPRING_DATASOURCE_URL` nor `JDBC_URL` trips a key-name rule. This shape is common in the legacy-Java trees v2.5.1 opened up and is the largest remaining hole; it is called out here rather than left implicit.
|
|
68
|
+
- Gradle build files are still not comment-stripped (Maven poms are), so a `//`-commented Spring coordinate still counts, as it always has for Boot.
|
|
69
|
+
- Cross-file inheritance (a corporate parent pom that itself extends `spring-boot-starter-parent`; a version held only in a parent pom outside the repository) remains out of scope; the resulting `null` falls through to LLM-side analysis.
|
|
70
|
+
|
|
71
|
+
### Migration
|
|
72
|
+
|
|
73
|
+
None required, and no scanner output changes: domains, counts and patterns are exactly what v2.5.1 produced. If a project's `.env` holds a URL password containing a raw `/`, `?`, `#` or space, or one beginning with digits, that value is now dropped from `project-analysis.json` and `init` says so in Phase 1. Percent-encode the password and re-run to restore the host.
|
|
74
|
+
|
|
31
75
|
## [2.5.1] — 2026-09-08
|
|
32
76
|
|
|
33
77
|
Patch release. Two things: the v2.5.0 credential-masking headline had a hole in it, and `init` on pre-Boot Java projects — the shape of most enterprise/SI codebases — reported "No language detected" and stopped. One new file (`plan-installer/jvm-detect.js`, pure text functions). `project-analysis.json` gains optional, additive fields only (`stack.packaging`, `stack.springFrameworkVersion`, `stack.sourceLayout`); no existing field changes type or meaning, and the `detected` array of every Spring Boot project is byte-identical to v2.5.0 output. Test suite: 825 → **974 / 974** pass (149 added, 0 changed).
|
|
@@ -50,7 +94,7 @@ Patch release. Two things: the v2.5.0 credential-masking headline had a hole in
|
|
|
50
94
|
- **IDE metadata as evidence.** `.settings/org.eclipse.jdt.core.prefs` `compiler.compliance` (outranks the JRE container name), custom JRE names in `.classpath` (`jdk1.6.0_45`, `jdk-17.0.2`), `.classpath` `kind="lib"` / `kind="var"` jar paths (jars that are *not committed* — `M2_REPO/…/spring-webmvc-3.0.5.RELEASE.jar` still names the version), IntelliJ `.idea/misc.xml` `languageLevel="JDK_1_7"`, NetBeans `nbproject/project.properties` (`javac.source`, `file.reference.*.jar`). Jars under `lib/spring/`, `lib/db/` sub-folders are found (recursive under the lib roots).
|
|
51
95
|
- **Deployment descriptor and Spring XML.** `WEB-INF/web.xml` naming `org.springframework.web.servlet.DispatcherServlet` / `ContextLoaderListener` is Spring MVC evidence and a `war` signal; `ActionServlet` / `StrutsPrepareAndExecuteFilter` add a `struts` / `struts2` tag; `<web-app version="2.5">` adds `servlet 2.5`. `src/test/**` is excluded (a test-resources `web.xml` is not deployment evidence), and the whole block is skipped for Spring Boot projects — Boot's WAR packaging comes from the build file, and a Boot project's `detected` array stays byte-identical to v2.5.0; Struts / JSF coordinate tags are likewise not added to Boot projects. Spring XML configs' schema locations (`spring-beans-3.0.xsd`) yield a **major.minor** version — the lowest-fidelity source, consulted only when nothing else pinned a version, never overriding one (`spring-xsd 3.0` in `detected`).
|
|
52
96
|
- **Precedence against Node / Python.** All of the above runs *after* the Node and Python blocks and may only fill a language nobody claimed — or reclaim a *provisional* one: a root `package.json` that exists for gulp / jQuery / Tailwind tooling (no Node framework, no frontend framework detected) beside `build.xml`, a `.project` with `javanature`, a sibling pom/gradle, or a `WEB-INF/web.xml`. A Next.js / Express / Django project with a stray `.idea/misc.xml` or a vendored `tools/lib/*.jar` is never flipped to Java; a jar directory with no `*.java` sources claims nothing (no language, no framework, no DB). The reclaim is *parked*, not committed: if no JVM block then actually claims the project — a `build.xml` that is Phing or an empty stub, no `*.java` anywhere — the Node language is restored unchanged, and a `<project>` root element alone is not Ant (a `<javac>` task or `*.java` sources is required). A reclaimed Java project takes the JVM package manager (`ant` / `maven`), not the asset tooling's `npm`. EUC-KR-encoded poms with Korean comments detect normally (coordinates are ASCII).
|
|
53
|
-
- **eGovFrame (
|
|
97
|
+
- **eGovFrame (Korea's e-Government Standard Framework).** `egovframework.rte[.*]` coordinates (Maven or Gradle, `${egovframework.rte.version}` resolved) are reported as `spring-framework` — the templates and prompts understand Spring, and eGovFrame is Spring MVC underneath — plus an `egovframe <version>` tag in `detected`. Its `<spring.maven.version>` property is on the Framework-version property list.
|
|
54
98
|
- **`scan-java` reads legacy source roots.** Every pattern in the scanner is written against `src/main/java` / `src/main/resources`; those two leading segments are now rewritten per discovered root instead of being literal. Roots, in order: every `[<module>/]src/main/java` (unchanged, and if any exists the legacy fallbacks are **not** consulted — a modern tree with a stray top-level `src/` cannot be mis-rooted); otherwise `.classpath` `<classpathentry kind="src" path="…"/>` entries (test folders excluded — including a plain `test/` that `src/test/**` ignore rules would not catch), `build.xml` `<javac srcdir="${src}">` with `<property>` resolution, then `src/java`, `src`, `JavaSource`, `java`, `WebContent/WEB-INF/src` when they hold `*.java` (nested pairs keep the deeper one). For a legacy root the resources root is the java root itself — iBatis-era projects keep sqlmap XML beside the classes. `stack.sourceLayout: "legacy"` is set when a legacy root is in use. An Ant + `WEB-INF/lib` project with `src/com/acme/erp/{controller,service,dao}/` now yields `java 6 · spring-framework 3.0.5.RELEASE · oracle · ibatis · ant · war` and its three Pattern C domains; before, `init` reported "No language detected".
|
|
55
99
|
|
|
56
100
|
### Changed
|
package/README.de.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/claudeos-core)
|
|
4
4
|
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
5
|
-
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
6
6
|
[](https://nodejs.org/)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://www.npmjs.com/package/claudeos-core)
|
package/README.es.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/claudeos-core)
|
|
4
4
|
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
5
|
-
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
6
6
|
[](https://nodejs.org/)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://www.npmjs.com/package/claudeos-core)
|
package/README.fr.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/claudeos-core)
|
|
4
4
|
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
5
|
-
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
6
6
|
[](https://nodejs.org/)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://www.npmjs.com/package/claudeos-core)
|
package/README.hi.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/claudeos-core)
|
|
4
4
|
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
5
|
-
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
6
6
|
[](https://nodejs.org/)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://www.npmjs.com/package/claudeos-core)
|
package/README.ja.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/claudeos-core)
|
|
4
4
|
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
5
|
-
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
6
6
|
[](https://nodejs.org/)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://www.npmjs.com/package/claudeos-core)
|
package/README.ko.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/claudeos-core)
|
|
4
4
|
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
5
|
-
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
6
6
|
[](https://nodejs.org/)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://www.npmjs.com/package/claudeos-core)
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/claudeos-core)
|
|
4
4
|
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
5
|
-
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
6
6
|
[](https://nodejs.org/)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://www.npmjs.com/package/claudeos-core)
|
package/README.ru.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/claudeos-core)
|
|
4
4
|
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
5
|
-
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
6
6
|
[](https://nodejs.org/)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://www.npmjs.com/package/claudeos-core)
|
package/README.vi.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/claudeos-core)
|
|
4
4
|
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
5
|
-
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
6
6
|
[](https://nodejs.org/)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://www.npmjs.com/package/claudeos-core)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/claudeos-core)
|
|
4
4
|
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
5
|
-
[](https://github.com/claudeos-core/claudeos-core/actions/workflows/test.yml)
|
|
6
6
|
[](https://nodejs.org/)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://www.npmjs.com/package/claudeos-core)
|
package/lib/env-parser.js
CHANGED
|
@@ -301,6 +301,108 @@ function isSensitiveVarName(name) {
|
|
|
301
301
|
* Scheme, host, port, path and query are preserved so consumers can still
|
|
302
302
|
* identify the DB engine / host. Non-URL values pass through unchanged.
|
|
303
303
|
*/
|
|
304
|
+
// The userinfo rule maskUrlCredentials() applies. Shared so the backstop below
|
|
305
|
+
// can ask "did this already get masked?" instead of guessing from the result.
|
|
306
|
+
const USERINFO_RE = /^([a-z][a-z0-9+.:-]*:\/\/)([^/?#\s]*)@([^@/?#\s]+)/i;
|
|
307
|
+
// Schemes whose values routinely carry an `@` inside a PATH: scoped npm
|
|
308
|
+
// packages, image variants, `/users/@me`. A DSN scheme essentially never does.
|
|
309
|
+
const WEB_SCHEME_RE = /^(?:https?|wss?)$/i;
|
|
310
|
+
// A token that can follow the `@` terminating an authority: a host, an IPv6
|
|
311
|
+
// literal in brackets, optionally `:port`, then end-of-value or a delimiter.
|
|
312
|
+
const HOST_AFTER_AT_RE = /^(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9._~%-]+)(?::\d+)?(?:[/?#]|$)/;
|
|
313
|
+
// A complete `host[:port]` with a NUMERIC port.
|
|
314
|
+
const HOST_PORT_RE = /^(?:[A-Za-z0-9._~-]+)(?::\d+)?$/;
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* v2.5.2 — True when a `scheme://…` value carries userinfo that
|
|
318
|
+
* maskUrlCredentials() could not rewrite, because the password contains a raw
|
|
319
|
+
* `/`, `?`, `#` or space and so pushed the real authority past the point where
|
|
320
|
+
* the userinfo rule is forced to stop.
|
|
321
|
+
*
|
|
322
|
+
* The earlier implementation guessed from the TRUNCATED authority — the text
|
|
323
|
+
* before the first `/?#` — and asked whether the part after its `:` was
|
|
324
|
+
* all-digits. That inverted the test for the most common leak of all: in
|
|
325
|
+
* `postgres://user:12345/6@db/app` the truncated authority is `user:12345`,
|
|
326
|
+
* whose tail IS all digits, so a password merely BEGINNING with digits (which
|
|
327
|
+
* base64-generated passwords routinely do) was waved through. It also could
|
|
328
|
+
* not see a password holding a space, since the userinfo rule's own character
|
|
329
|
+
* class excludes whitespace.
|
|
330
|
+
*
|
|
331
|
+
* This version instead locates the `@` that actually terminates an authority
|
|
332
|
+
* and decides from there. `user:12345` and `a.com:8080` are syntactically
|
|
333
|
+
* indistinguishable, so where ambiguity is irreducible the scheme breaks the
|
|
334
|
+
* tie: for a DSN the value is treated as credentials, for http/https/ws/wss as
|
|
335
|
+
* a path.
|
|
336
|
+
*
|
|
337
|
+
* postgres://u:p/w@host/db true password holds `/`
|
|
338
|
+
* postgres://user:12345/6@db/app true digit-leading password
|
|
339
|
+
* postgres://u:pa ss@host/db true password holds a space
|
|
340
|
+
* redis://:pw?x@host/0 true password holds `?`
|
|
341
|
+
* postgres://u:p@host/db false the userinfo rule handles it
|
|
342
|
+
* https://cdn.example.com/npm/@x/y false no credential shape at all
|
|
343
|
+
* http://a.com:8080/img/@2x.png false web scheme, complete host:port
|
|
344
|
+
* http://[::1]:8080/img/@2x.png false bracketed IPv6 is a host
|
|
345
|
+
* mongodb://h:port/db?x=a@b false the `@` sits in the query
|
|
346
|
+
* https://api:${PORT}/v1/@me false unexpanded template
|
|
347
|
+
*/
|
|
348
|
+
function hasUnmaskedUrlCredentials(value) {
|
|
349
|
+
if (typeof value !== "string") return false;
|
|
350
|
+
const m = value.match(/^([a-z][a-z0-9+.-]*(?::[a-z][a-z0-9+.-]*)*):\/\/(.*)$/i);
|
|
351
|
+
if (!m) return false;
|
|
352
|
+
const rest = m[2];
|
|
353
|
+
if (!rest || !rest.includes("@")) return false;
|
|
354
|
+
// An unexpanded `${VAR}` is a template, not a live secret. parseEnvContent
|
|
355
|
+
// deliberately does not expand these, so redacting one loses a host for
|
|
356
|
+
// nothing.
|
|
357
|
+
if (/\$\{[^}]*\}/.test(rest)) return false;
|
|
358
|
+
// Already masked by the userinfo rule — nothing is hidden.
|
|
359
|
+
if (USERINFO_RE.test(value)) return false;
|
|
360
|
+
|
|
361
|
+
const scheme = m[1].split(":")[0].toLowerCase(); // `jdbc:postgresql` → `jdbc`
|
|
362
|
+
// What a strict parser would read as the authority: everything up to the
|
|
363
|
+
// first `/`, `?` or `#`.
|
|
364
|
+
const head = rest.slice(0, rest.search(/[/?#]/) === -1 ? rest.length : rest.search(/[/?#]/));
|
|
365
|
+
// A bracketed IPv6 literal can only ever be a host, never userinfo.
|
|
366
|
+
if (head.startsWith("[")) return false;
|
|
367
|
+
// No `user:secret` shape at all. The empty-user form (`redis://:pw@host`)
|
|
368
|
+
// starts with `:`, so it is admitted here.
|
|
369
|
+
if (!head.includes(":")) return false;
|
|
370
|
+
// For a web scheme, a head that is ALREADY a complete `host:port` means the
|
|
371
|
+
// authority ended there and the `@` belongs to the path.
|
|
372
|
+
if (WEB_SCHEME_RE.test(scheme) && HOST_PORT_RE.test(head)) return false;
|
|
373
|
+
|
|
374
|
+
// The `@` that would terminate the real authority: the last one followed by
|
|
375
|
+
// something that can be a host.
|
|
376
|
+
// The loop stops at `i > 0`, NOT `i !== -1`. `String.prototype.lastIndexOf`
|
|
377
|
+
// clamps a negative `fromIndex` to 0 rather than returning -1, so an `@`
|
|
378
|
+
// sitting at index 0 that fails the host test re-finds itself forever and
|
|
379
|
+
// hangs `init` at 100% CPU with no error (`postgres://@ :x` reproduces it).
|
|
380
|
+
// Stopping at index 0 loses nothing: an `@` there means empty userinfo.
|
|
381
|
+
for (let i = rest.lastIndexOf("@"); i > 0; i = rest.lastIndexOf("@", i - 1)) {
|
|
382
|
+
// `continue`, NOT `return false`. Abandoning the whole search on the first
|
|
383
|
+
// `@` that turns out to be query content let a DSN carrying an email or a
|
|
384
|
+
// redirect URL in its query string defeat the backstop entirely:
|
|
385
|
+
// `postgres://app:pa/ss@db/app?redirect=user@host` starts at the LAST `@`,
|
|
386
|
+
// decides it is query content, and never examines the earlier `@` that
|
|
387
|
+
// terminates the real authority.
|
|
388
|
+
if (!HOST_AFTER_AT_RE.test(rest.slice(i + 1))) continue;
|
|
389
|
+
// If a `?` precedes this `@` AND a `/` precedes that `?`, then a path had
|
|
390
|
+
// already begun before the query started, so the authority was long since
|
|
391
|
+
// over and this `@` is query content (`mongodb://h:port/db?x=a@b`). When
|
|
392
|
+
// the `?` comes before any `/` it is inside the password instead
|
|
393
|
+
// (`redis://:pw?x@host/0`).
|
|
394
|
+
const q = rest.indexOf("?");
|
|
395
|
+
if (q !== -1 && q < i && rest.slice(0, q).includes("/")) continue;
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const REDACTED = "***REDACTED***";
|
|
402
|
+
// Scalar env-derived fields (host, apiTarget) are rendered directly into
|
|
403
|
+
// generated docs, so the sentinel must never become their value.
|
|
404
|
+
const nullIfRedacted = (v) => (v === REDACTED ? null : v);
|
|
405
|
+
|
|
304
406
|
function maskUrlCredentials(value) {
|
|
305
407
|
if (typeof value !== "string") return value;
|
|
306
408
|
// Scheme may itself contain `:` (`jdbc:postgresql://`, `jdbc:mysql://`).
|
|
@@ -318,9 +420,36 @@ function maskUrlCredentials(value) {
|
|
|
318
420
|
// The parameter NAME is kept, the value becomes `***`.
|
|
319
421
|
const PARAM_RE = /([?&;](?:password|passwd|pwd|pass|secret|token|access[_-]?key|secret[_-]?key|api[_-]?key|sas|signature)=)[^&;\s]*/gi;
|
|
320
422
|
if (/^[a-z][a-z0-9+.:-]*:\/\//i.test(value)) {
|
|
321
|
-
|
|
322
|
-
|
|
423
|
+
const userinfoMasked = USERINFO_RE.test(value);
|
|
424
|
+
const masked = value
|
|
425
|
+
.replace(USERINFO_RE, "$1***:***@$3")
|
|
323
426
|
.replace(PARAM_RE, "$1***");
|
|
427
|
+
// v2.5.2 — last-resort backstop for a password containing a raw `/`, `?`
|
|
428
|
+
// or `#` (`postgres://u:p/w@host/db`). The rule above deliberately does
|
|
429
|
+
// not rewrite an `@` that appears after the first `/` — such an `@`
|
|
430
|
+
// normally belongs to the path (`https://cdn.example.com/npm/@scope/pkg`)
|
|
431
|
+
// and masking it would replace the real host. The value therefore passed
|
|
432
|
+
// through verbatim, and because its key is `DATABASE_URL` the key-name
|
|
433
|
+
// rule did not backstop it either: it was the one combination that could
|
|
434
|
+
// still write a plaintext password into project-analysis.json, which
|
|
435
|
+
// Pass 3/4 prompts instruct the model to read.
|
|
436
|
+
//
|
|
437
|
+
// Base64-generated passwords contain `/` routinely, so this is not an
|
|
438
|
+
// exotic shape. When the value carries the tell (see
|
|
439
|
+
// hasUnmaskedUrlCredentials) the WHOLE value is dropped rather than
|
|
440
|
+
// rewritten — the host cannot be located reliably once the authority is
|
|
441
|
+
// ambiguous, and a lost host is a far cheaper failure than a leaked
|
|
442
|
+
// credential. Callers are told which key it was via
|
|
443
|
+
// envInfo.credentialWarnings.
|
|
444
|
+
//
|
|
445
|
+
// Gated on the USERINFO rule not having fired — NOT on `masked === value`.
|
|
446
|
+
// PARAM_RE may rewrite a query parameter on the same value
|
|
447
|
+
// (`postgres://u:p/w@host/db?sslmode=require&password=x`); a
|
|
448
|
+
// "did anything change" gate would then skip the backstop and let `p/w`
|
|
449
|
+
// through verbatim. Whether the userinfo rule matched is the only signal
|
|
450
|
+
// that says the authority itself was masked.
|
|
451
|
+
if (!userinfoMasked && hasUnmaskedUrlCredentials(value)) return "***REDACTED***";
|
|
452
|
+
return masked;
|
|
324
453
|
}
|
|
325
454
|
// Scheme-less credentials are recognized ONLY in the Go/MySQL DSN shape
|
|
326
455
|
// (`user:pw@tcp(host:3306)/db`, `user:pw@unix(/path)/db`); the password may
|
|
@@ -377,9 +506,25 @@ function readStackEnvInfo(root) {
|
|
|
377
506
|
return {
|
|
378
507
|
source: file,
|
|
379
508
|
vars: redactSensitiveVars(vars),
|
|
509
|
+
// v2.5.2 — the keys whose value `maskUrlCredentials` dropped whole rather
|
|
510
|
+
// than partially masking. The credential is not leaked, but the user loses
|
|
511
|
+
// the host for that key, so `init` names the keys in its Phase 1 summary
|
|
512
|
+
// instead of silently swallowing them. Key NAMES only, never any part of
|
|
513
|
+
// the value.
|
|
514
|
+
//
|
|
515
|
+
// Derived from what `maskUrlCredentials` ACTUALLY returned, so the list can
|
|
516
|
+
// never name a key whose value was kept (masked) rather than dropped. The
|
|
517
|
+
// `!== REDACTED` guard covers an env value that is literally the sentinel.
|
|
518
|
+
credentialWarnings: Object.keys(vars).filter(
|
|
519
|
+
k => !isSensitiveVarName(k) && vars[k] !== REDACTED && maskUrlCredentials(vars[k]) === REDACTED
|
|
520
|
+
),
|
|
380
521
|
port: extractPort(vars),
|
|
381
|
-
|
|
382
|
-
|
|
522
|
+
// A value the backstop dropped whole must not travel on as the literal
|
|
523
|
+
// sentinel: `host` / `apiTarget` are rendered straight into CLAUDE.md §3,
|
|
524
|
+
// and the scaffold's only sentinel guard covers `envInfo.vars`. Null makes
|
|
525
|
+
// the row simply absent, which is what the scaffold already handles.
|
|
526
|
+
host: nullIfRedacted(maskUrlCredentials(extractHost(vars))),
|
|
527
|
+
apiTarget: nullIfRedacted(maskUrlCredentials(extractApiTarget(vars))),
|
|
383
528
|
};
|
|
384
529
|
}
|
|
385
530
|
|
|
@@ -394,6 +539,7 @@ module.exports = {
|
|
|
394
539
|
isSensitiveVarName,
|
|
395
540
|
redactSensitiveVars,
|
|
396
541
|
maskUrlCredentials,
|
|
542
|
+
hasUnmaskedUrlCredentials,
|
|
397
543
|
// Exported for test visibility:
|
|
398
544
|
ENV_FILE_ORDER,
|
|
399
545
|
PORT_VAR_KEYS,
|
package/package.json
CHANGED
package/plan-installer/index.js
CHANGED
|
@@ -52,7 +52,23 @@ async function main() {
|
|
|
52
52
|
console.log(` Database: ${stack.database || "none"}`);
|
|
53
53
|
}
|
|
54
54
|
console.log(` ORM: ${stack.orm || "none"}`);
|
|
55
|
-
console.log(` PackageMgr: ${stack.packageManager || "none"}
|
|
55
|
+
console.log(` PackageMgr: ${stack.packageManager || "none"}`);
|
|
56
|
+
// v2.5.2 — a URL value whose password holds a raw `/`, `?` or `#` cannot
|
|
57
|
+
// have its userinfo rewritten without risking the host, so the whole value
|
|
58
|
+
// is dropped. Say so: silently losing a DATABASE_URL would otherwise look
|
|
59
|
+
// like a detection bug. Key names only, never any part of the value.
|
|
60
|
+
// Both env sources are reported. A sub-directory SPA keeps its own
|
|
61
|
+
// `frontend/.env`, read into `stack.frontendEnvInfo`, and reading only
|
|
62
|
+
// `stack.envInfo` swallowed exactly the drop this warning exists to announce.
|
|
63
|
+
for (const info of [stack.envInfo, stack.frontendEnvInfo]) {
|
|
64
|
+
const credWarn = (info && info.credentialWarnings) || [];
|
|
65
|
+
if (!credWarn.length) continue;
|
|
66
|
+
console.warn(`\n ⚠️ Credential-shaped value dropped from ${credWarn.join(", ")} (${info.source}).`);
|
|
67
|
+
console.warn(" The password contains a raw '/', '?', '#' or space, which makes the URL's host");
|
|
68
|
+
console.warn(" ambiguous, so the value was redacted whole rather than partially masked.");
|
|
69
|
+
console.warn(" Percent-encode the password (e.g. '/' as %2F) to keep the host visible.");
|
|
70
|
+
}
|
|
71
|
+
console.log("");
|
|
56
72
|
|
|
57
73
|
// Phase 2: Structure scan
|
|
58
74
|
console.log(" [Phase 2] Scanning structure...");
|