diff --git a/src/skillspector/nodes/analyzers/pattern_defaults.py b/src/skillspector/nodes/analyzers/pattern_defaults.py index dcece108..dad3b7a1 100644 --- a/src/skillspector/nodes/analyzers/pattern_defaults.py +++ b/src/skillspector/nodes/analyzers/pattern_defaults.py @@ -88,6 +88,7 @@ class PatternCategory(StrEnum): "SC4": "Dependency has known vulnerabilities (CVEs). Using packages with unpatched security flaws exposes the environment to known exploits.", "SC5": "Dependency appears abandoned or unmaintained. Abandoned packages no longer receive security patches, leaving known and future vulnerabilities unaddressed.", "SC6": "Package name closely resembles a popular package, suggesting possible typosquatting. Attackers publish malicious packages with similar names to trick developers into installing them.", + "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.", # 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.", @@ -175,6 +176,7 @@ class PatternCategory(StrEnum): "SC4": PatternCategory.SUPPLY_CHAIN.value, "SC5": PatternCategory.SUPPLY_CHAIN.value, "SC6": PatternCategory.SUPPLY_CHAIN.value, + "SC7": PatternCategory.SUPPLY_CHAIN.value, "TR1": PatternCategory.TRIGGER_ABUSE.value, "TR2": PatternCategory.TRIGGER_ABUSE.value, "TR3": PatternCategory.TRIGGER_ABUSE.value, @@ -250,6 +252,7 @@ class PatternCategory(StrEnum): "SC4": "Known Vulnerable Dependency", "SC5": "Abandoned Dependency", "SC6": "Typosquatting Dependency", + "SC7": "Untrusted Container Image", "TR1": "Overly Broad Trigger", "TR2": "Shadow Command Trigger", "TR3": "Keyword Baiting Trigger", @@ -332,6 +335,7 @@ class PatternCategory(StrEnum): "SC4": "Update the dependency to a patched version that addresses the known CVE. Check OSV (osv.dev) or NVD for details on the vulnerability.", "SC5": "Replace the abandoned dependency with an actively maintained alternative. Check the package's repository for last commit date and open issues.", "SC6": "Verify the package name is correct and not a typosquatting variant. Compare against the official package name on PyPI or npm.", + "SC7": "Keep image signature verification (Docker Content Trust / cosign) and registry TLS enabled. Pull only signed images from trusted registries; never disable content-trust or use insecure registries in skill code.", # Trigger Abuse "TR1": "Use specific, narrow trigger patterns that match only the skill's intended use case. Avoid single-word or common-phrase triggers.", "TR2": "Choose triggers that do not conflict with built-in commands or other skills. Prefix with a unique namespace if necessary.", diff --git a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py index 3d9f8382..322d0a1b 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py +++ b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py @@ -13,12 +13,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Static patterns: supply chain (SC1–SC6) and trigger analysis (TR1–TR3). +"""Static patterns: supply chain (SC1–SC7) and trigger analysis (TR1–TR3). SC1–SC3: regex-based pattern matching (original implementation). SC4: Known vulnerable dependencies — live OSV.dev lookup with static fallback. SC5: Abandoned dependencies — flags known-abandoned or archived packages. SC6: Typosquatting — flags package names similar to popular packages. +SC7: Untrusted container image — flags image signature / registry-verification bypass. TR1–TR3: Trigger analysis — flags overly broad, shadowing, or baiting triggers. Node and analyze() in one module. @@ -96,6 +97,17 @@ (r"decode\s+(?:this|the)\s+(?:base64|hex)\s+(?:and\s+)?(?:run|execute)", 0.8), ] +# SC7: Untrusted Container Image — pulling images with signature/registry +# verification turned off. These flags disable image trust regardless of the +# registry, so they are a strong supply-chain signal with near-zero FP. +# (`--tls-verify=false` is intentionally omitted: TM3's `verify=False` already +# covers it; SC7 targets the image-specific bypasses TM3 does not see.) +SC7_PATTERNS = [ + (r"--disable-content-trust", 0.85), # Docker Content Trust signature check off + (r"DOCKER_CONTENT_TRUST\s*=\s*0", 0.85), # signature verification disabled via env + (r"--insecure-registry", 0.8), # registry TLS verification off +] + # --------------------------------------------------------------------------- # SC4: Known Vulnerable Dependencies # @@ -504,7 +516,7 @@ def parts(v: str) -> tuple[int, ...]: def analyze(content: str, file_path: str, file_type: str) -> list[AnalyzerFinding]: - """Analyze content for supply chain patterns (SC1–SC3).""" + """Analyze content for supply chain patterns (SC1–SC3, SC7).""" findings: list[AnalyzerFinding] = [] def loc(ln: int) -> Location: @@ -573,6 +585,22 @@ def ctx(start: int) -> str: matched_text=match.group(0)[:200], ) ) + # SC7: untrusted container image. Example filtering is delegated to the runner. + for pattern, confidence in SC7_PATTERNS: + for match in re.finditer(pattern, content, re.IGNORECASE | re.MULTILINE): + line_num = get_line_number(content, match.start()) + findings.append( + AnalyzerFinding( + rule_id="SC7", + message="Untrusted Container Image", + severity=Severity.HIGH, + location=loc(line_num), + confidence=confidence, + tags=tag, + context=ctx(match.start()), + matched_text=match.group(0)[:200], + ) + ) return findings diff --git a/tests/nodes/analyzers/test_static_patterns.py b/tests/nodes/analyzers/test_static_patterns.py index b0e3454c..48d41eba 100644 --- a/tests/nodes/analyzers/test_static_patterns.py +++ b/tests/nodes/analyzers/test_static_patterns.py @@ -188,6 +188,72 @@ def test_sc2_curl_bash_produces_finding(self): assert len(sc2) >= 1 assert sc2[0].severity == "HIGH" + def test_sc7_disable_content_trust_produces_finding(self): + """docker pull --disable-content-trust yields SC7, HIGH severity.""" + state = { + "components": ["setup.sh"], + "file_cache": { + "setup.sh": "docker pull --disable-content-trust registry.io/base:latest" + }, + } + findings = static_runner.run_static_patterns(state, [supply_chain_module]) + sc7 = [f for f in findings if f.rule_id == "SC7"] + assert len(sc7) >= 1 + assert sc7[0].severity == "HIGH" + + def test_sc7_content_trust_env_produces_finding(self): + """DOCKER_CONTENT_TRUST=0 yields SC7.""" + state = { + "components": ["setup.sh"], + "file_cache": {"setup.sh": "export DOCKER_CONTENT_TRUST=0"}, + } + findings = static_runner.run_static_patterns(state, [supply_chain_module]) + assert any(f.rule_id == "SC7" for f in findings) + + def test_sc7_insecure_registry_produces_finding(self): + """--insecure-registry yields SC7.""" + state = { + "components": ["setup.sh"], + "file_cache": {"setup.sh": "docker pull --insecure-registry 10.0.0.5:5000/tools"}, + } + findings = static_runner.run_static_patterns(state, [supply_chain_module]) + assert any(f.rule_id == "SC7" for f in findings) + + def test_sc7_documentation_example_excluded(self): + """Verification-bypass flags in documentation do not yield SC7.""" + state = { + "components": ["README.md"], + "file_cache": { + "README.md": "For example, never use --disable-content-trust in production." + }, + } + findings = static_runner.run_static_patterns(state, [supply_chain_module]) + assert not any(f.rule_id == "SC7" for f in findings) + + def test_sc7_benign_pull_no_finding(self): + """A normal docker pull with verification on does not yield SC7.""" + state = { + "components": ["setup.sh"], + "file_cache": {"setup.sh": "docker pull nginx:1.25"}, + } + findings = static_runner.run_static_patterns(state, [supply_chain_module]) + assert not any(f.rule_id == "SC7" for f in findings) + + def test_sc7_example_marker_in_executable_still_fires(self): + """An 'example' marker near a bypass in an executable .sh must NOT suppress SC7. + + Example filtering belongs to the runner, which only downweights (does not + skip) executables — so a nearby '# for example' cannot be used to evade SC7. + """ + state = { + "components": ["setup.sh"], + "file_cache": { + "setup.sh": "# for example\ndocker pull --disable-content-trust registry.io/x", + }, + } + findings = static_runner.run_static_patterns(state, [supply_chain_module]) + assert any(f.rule_id == "SC7" for f in findings) + class TestRunStaticPatternsAgentSnoopingAdditional: """run_static_patterns with agent_snooping: AS1, AS2, AS3."""