Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,11 @@ store_as = "security.contact"
# A user-judgment key (feature 042, FR-005); see maintainers.
auto_detect = false
required = false
hint_sources = ["SECURITY.md", ".github/SECURITY.md"]
hint_sources = [
"SECURITY.md", "security.md",
".github/SECURITY.md", ".github/security.md",
"docs/SECURITY.md", "docs/security.md",
]
allow_sieve_hints = true
# Rejects detected values containing "example.com" or "example.org" before
# they can be stored or offered for confirmation (feature 039).
Expand Down Expand Up @@ -1688,6 +1692,9 @@ discover = [
"LICENSE", "LICENSE.md", "LICENSE.txt",
"LICENCE", "LICENCE.md", "LICENCE.txt",
"COPYING", "COPYING.md", "COPYING.txt",
"license", "license.md", "license.txt",
"licence", "licence.md", "licence.txt",
"copying", "copying.md", "copying.txt",
]
kind = "file"

Expand Down Expand Up @@ -1749,7 +1756,11 @@ help_md = """Use an OSI-approved open source license.
# Local check: Look for SPDX identifier in project files (no API needed)
[[controls."OSPS-LE-02.01".passes]]
handler = "regex"
files = ["LICENSE*", "COPYING*", "NOTICE*", "package.json", "pyproject.toml", "Cargo.toml", "go.mod"]
files = [
"LICENSE*", "LICENCE*", "COPYING*", "NOTICE*",
"license*", "licence*", "copying*", "notice*",
"package.json", "pyproject.toml", "Cargo.toml", "go.mod",
]
pattern = '(?i)(SPDX-License-Identifier\s*:\s*\S+|"license"\s*:\s*"[^"]+"|license\s*=\s*"[^"]+"|License\s*::\s*OSI)'
pass_if_any = true
expr = 'output.any_match'
Expand Down Expand Up @@ -1868,6 +1879,9 @@ discover = [
"LICENSE", "LICENSE.md", "LICENSE.txt",
"LICENCE", "LICENCE.md", "LICENCE.txt",
"COPYING", "COPYING.md", "COPYING.txt",
"license", "license.md", "license.txt",
"licence", "licence.md", "licence.txt",
"copying", "copying.md", "copying.txt",
]
kind = "file"

Expand Down Expand Up @@ -2286,7 +2300,11 @@ help_md = """Add security contacts to SECURITY.md.

[controls."OSPS-VM-02.01".locator]
project_path = "security.policy"
discover = ["SECURITY.md", ".github/SECURITY.md", "docs/SECURITY.md"]
discover = [
"SECURITY.md", "security.md",
".github/SECURITY.md", ".github/security.md",
"docs/SECURITY.md", "docs/security.md",
]
kind = "file"

[controls."OSPS-VM-02.01".locator.llm_hints]
Expand Down Expand Up @@ -2728,7 +2746,11 @@ help_md = """Create CVD policy in SECURITY.md.

[controls."OSPS-VM-01.01".locator]
project_path = "security.policy"
discover = ["SECURITY.md", ".github/SECURITY.md", "docs/SECURITY.md"]
discover = [
"SECURITY.md", "security.md",
".github/SECURITY.md", ".github/security.md",
"docs/SECURITY.md", "docs/security.md",
]
kind = "file"

[controls."OSPS-VM-01.01".locator.llm_hints]
Expand All @@ -2738,7 +2760,11 @@ look_for_urls = true

[[controls."OSPS-VM-01.01".passes]]
handler = "pattern"
files = ["SECURITY.md"]
files = [
"SECURITY.md", "security.md",
".github/SECURITY.md", ".github/security.md",
"docs/SECURITY.md", "docs/security.md",
]

[controls."OSPS-VM-01.01".passes.pattern.patterns]
disclosure = "(disclos|report|contact)"
Expand Down Expand Up @@ -2796,7 +2822,11 @@ help_md = """Enable private vulnerability reporting.

[controls."OSPS-VM-03.01".locator]
project_path = "security.policy"
discover = ["SECURITY.md", ".github/SECURITY.md"]
discover = [
"SECURITY.md", "security.md",
".github/SECURITY.md", ".github/security.md",
"docs/SECURITY.md", "docs/security.md",
]
kind = "file"

[controls."OSPS-VM-03.01".locator.llm_hints]
Expand All @@ -2816,7 +2846,11 @@ concludes = ["pass"]
# PatternPass: Check for private contact methods in SECURITY.md
[[controls."OSPS-VM-03.01".passes]]
handler = "pattern"
files = ["SECURITY.md", ".github/SECURITY.md"]
files = [
"SECURITY.md", "security.md",
".github/SECURITY.md", ".github/security.md",
"docs/SECURITY.md", "docs/security.md",
]

[controls."OSPS-VM-03.01".passes.pattern.patterns]
private_contact = "(private|confidential|security advisory|PGP|GPG|security@|email)"
Expand Down Expand Up @@ -3074,7 +3108,12 @@ help_md = """Document secrets management policy.

[[controls."OSPS-BR-07.02".passes]]
handler = "pattern"
files = ["SECURITY.md", "docs/security.md", "docs/SECRETS-POLICY.md", "CONTRIBUTING.md"]
files = [
"SECURITY.md", "security.md",
".github/SECURITY.md", ".github/security.md",
"docs/SECURITY.md", "docs/security.md",
"docs/SECRETS-POLICY.md", "CONTRIBUTING.md",
]

[controls."OSPS-BR-07.02".passes.pattern.patterns]
secrets_policy = "(secret|credential|token|key).*(management|policy|handling|rotation)"
Expand Down Expand Up @@ -3436,7 +3475,12 @@ Run: `create_security_policy()` or `ensure_vex_policy()`

[[controls."OSPS-VM-04.02".passes]]
handler = "pattern"
files = ["SECURITY.md", ".github/SECURITY.md", "docs/VEX-POLICY.md"]
files = [
"SECURITY.md", "security.md",
".github/SECURITY.md", ".github/security.md",
"docs/SECURITY.md", "docs/security.md",
"docs/VEX-POLICY.md",
]

[controls."OSPS-VM-04.02".passes.pattern.patterns]
vex_policy = "(vex|vulnerability.exploitability|exploitability.exchange)"
Expand Down Expand Up @@ -3500,7 +3544,12 @@ help_md = """Define SCA remediation policy.

[[controls."OSPS-VM-05.01".passes]]
handler = "pattern"
files = ["SECURITY.md", "docs/security.md", "docs/SCA-POLICY.md"]
files = [
"SECURITY.md", "security.md",
".github/SECURITY.md", ".github/security.md",
"docs/SECURITY.md", "docs/security.md",
"docs/SCA-POLICY.md",
]

[controls."OSPS-VM-05.01".passes.pattern.patterns]
sca_policy = "(dependency|SCA|software composition|remediat)"
Expand Down Expand Up @@ -3717,7 +3766,12 @@ help_md = """Define SAST remediation policy.

[[controls."OSPS-VM-06.01".passes]]
handler = "pattern"
files = ["SECURITY.md", "docs/SAST-POLICY.md"]
files = [
"SECURITY.md", "security.md",
".github/SECURITY.md", ".github/security.md",
"docs/SECURITY.md", "docs/security.md",
"docs/SAST-POLICY.md",
]

[controls."OSPS-VM-06.01".passes.pattern.patterns]
sast_policy = "(SAST|static analysis|code scan|remediat)"
Expand Down Expand Up @@ -4336,7 +4390,12 @@ help_md = """Perform and document security assessment.

[[controls."OSPS-SA-03.01".passes]]
handler = "pattern"
files = ["SECURITY.md", "docs/security.md", "CONTRIBUTING.md", "docs/SECURITY-ASSESSMENT.md"]
files = [
"SECURITY.md", "security.md",
".github/SECURITY.md", ".github/security.md",
"docs/SECURITY.md", "docs/security.md",
"CONTRIBUTING.md", "docs/SECURITY-ASSESSMENT.md",
]

[controls."OSPS-SA-03.01".passes.pattern.patterns]
security_assessment = "(security.*(assessment|review|audit)|pre-release.*security)"
Expand Down Expand Up @@ -4489,7 +4548,11 @@ tags = { level = 1, domain = "VM", "stage1-ref" = true }
# orchestrator so both properties hold; documented for follow-up.
[[controls."STAGE1-REF-SECURITY-01".passes]]
handler = "file_exists"
files = ["SECURITY.md", "docs/SECURITY.md", ".github/SECURITY.md"]
files = [
"SECURITY.md", "security.md",
".github/SECURITY.md", ".github/security.md",
"docs/SECURITY.md", "docs/security.md",
]
authority = "dispositive"

[[controls."STAGE1-REF-SECURITY-01".passes]]
Expand Down
39 changes: 29 additions & 10 deletions packages/darnit/src/darnit/context/auto_detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from darnit.core.logging import get_logger
from darnit.core.repo_files import read_repository_text
from darnit.core.utils import resolve_case_exact_path

if TYPE_CHECKING:
from darnit.config.context_schema import ContextDefinition
Expand Down Expand Up @@ -183,16 +184,34 @@ def detect_license_type(local_path: str) -> str | None:
"apache-2.0", "mit", "bsd-3-clause", "gpl", "isc", "mpl-2.0",
"lgpl", "unlicense", or None if unknown.
"""
license_path = Path(local_path) / "LICENSE"
if not license_path.is_file():
# Also check LICENSE.md, LICENSE.txt
for alt in ("LICENSE.md", "LICENSE.txt"):
alt_path = Path(local_path) / alt
if alt_path.is_file():
license_path = alt_path
break
else:
return None
candidates = (
"LICENSE",
"LICENSE.md",
"LICENSE.txt",
"LICENCE",
"LICENCE.md",
"LICENCE.txt",
"COPYING",
"COPYING.md",
"COPYING.txt",
"license",
"license.md",
"license.txt",
"licence",
"licence.md",
"licence.txt",
"copying",
"copying.md",
"copying.txt",
)
license_path: Path | None = None
for candidate in candidates:
exact = resolve_case_exact_path(candidate, local_path)
if exact and os.path.isfile(exact):
license_path = Path(exact)
break
if license_path is None:
return None

try:
content = read_repository_text(local_path, license_path, errors="replace")[:1000]
Expand Down
17 changes: 17 additions & 0 deletions tests/darnit/context/test_auto_detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,23 @@ def test_prefers_license_over_license_md(self, tmp_path):
(tmp_path / "LICENSE.md").write_text("# MIT License")
assert detect_license_type(str(tmp_path)) == "apache-2.0"

def test_lowercase_license_detected(self, tmp_path):
"""Issue #717: detect_license_type recognizes lowercase license file."""
(tmp_path / "license").write_text("MIT License\n\nCopyright (c) 2026")
assert detect_license_type(str(tmp_path)) == "mit"

def test_lowercase_license_md_detected(self, tmp_path):
(tmp_path / "license.md").write_text("Apache License\nVersion 2.0")
assert detect_license_type(str(tmp_path)) == "apache-2.0"

def test_licence_detected(self, tmp_path):
(tmp_path / "licence").write_text("MIT License\n\nCopyright (c) 2026")
assert detect_license_type(str(tmp_path)) == "mit"

def test_copying_detected(self, tmp_path):
(tmp_path / "copying").write_text("GNU General Public License\nVersion 3")
assert detect_license_type(str(tmp_path)) == "gpl"


class TestCollectAutoContext:
def test_collects_all(self, tmp_path):
Expand Down
48 changes: 44 additions & 4 deletions tests/darnit_baseline/controls/test_license_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,33 @@ def _make_ctx(local_path: Path, control_id: str) -> CheckContext:
@pytest.mark.parametrize("control_id", LICENSE_CONTROLS)
@pytest.mark.parametrize(
"filename",
["LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE", "LICENCE.md", "LICENCE.txt", "COPYING"],
[
"LICENSE",
"LICENSE.md",
"LICENSE.txt",
"LICENCE",
"LICENCE.md",
"LICENCE.txt",
"COPYING",
"COPYING.md",
"COPYING.txt",
"license",
"license.md",
"license.txt",
"licence",
"licence.md",
"licence.txt",
"copying",
"copying.md",
"copying.txt",
],
)
def test_license_filename_variants_found(
tmp_path: Path,
control_id: str,
filename: str,
) -> None:
"""Both controls accept US and British spellings and common suffixes.
"""Both controls accept US and British spellings, lowercase names, and common suffixes.

Feature 041: finding the file is evidence; whether it concludes PASS
depends on the control's step declarations, so this asserts discovery
Expand All @@ -65,8 +84,8 @@ def test_license_filename_variants_found(
control = _load_control(control_id)
result = SieveOrchestrator(stop_on_llm=False).verify(control, _make_ctx(tmp_path, control_id))

assert result.status != "FAIL", f"{control_id} failed on {filename}: {result.details}"
assert result.evidence["relative_path"] == filename
assert result.status != "FAIL", f"{control_id} failed on {filename}: {result.message}"
assert result.evidence["relative_path"].lower() == filename.lower()


@pytest.mark.parametrize("control_id", LICENSE_CONTROLS)
Expand All @@ -89,3 +108,24 @@ def test_both_controls_share_one_discover_list() -> None:
lists = [config.controls[c].locator.discover for c in LICENSE_CONTROLS]
assert lists[0] == lists[1]
assert "LICENCE" in lists[0]
assert "license" in lists[0]


@pytest.mark.parametrize(
"filename",
["license", "license.md", "licence", "copying"],
)
def test_le_02_01_matches_lowercase_license_files(tmp_path: Path, filename: str) -> None:
"""Issue #717: OSPS-LE-02.01 regex pass detects SPDX identifiers in lowercase license files."""
(tmp_path / filename).write_text(
"// SPDX-License-Identifier: MIT\n" + MIT_TEXT,
encoding="utf-8",
)

control = _load_control("OSPS-LE-02.01")
result = SieveOrchestrator(stop_on_llm=False).verify(control, _make_ctx(tmp_path, "OSPS-LE-02.01"))

assert result.pass_history, f"No pass history for OSPS-LE-02.01 on {filename}"
pattern_attempt = result.pass_history[0]
assert pattern_attempt.result.outcome.value == "pass"
assert pattern_attempt.result.evidence.get("any_match") is True
10 changes: 8 additions & 2 deletions tests/darnit_baseline/controls/test_security_md_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

from pathlib import Path

import pytest

from darnit.sieve.handler_registry import (
HandlerContext,
)
Expand Down Expand Up @@ -70,16 +72,20 @@ def test_first_run_reports_fail_no_security_md(self, tmp_path: Path) -> None:
# file_exists ran and recorded the paths it checked.
assert "files_checked" in (result.evidence or {})

@pytest.mark.parametrize("filename", ["SECURITY.md", "security.md", ".github/security.md", "docs/security.md"])
def test_second_run_does_not_pass_on_presence_alone(
self,
tmp_path: Path,
filename: str,
) -> None:
"""Feature 041 (was US4 acceptance #3 of feature 025): with SECURITY.md
present, file_exists may not conclude PASS for a content control; the
control is WARN with the found file recorded as evidence.
"""
(tmp_path / "README.md").write_text("# proj\n")
(tmp_path / "SECURITY.md").write_text(
target_path = tmp_path / filename
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(
"# Security Policy\n\nReport issues to sec@example.com\n",
)

Expand All @@ -96,7 +102,7 @@ def test_second_run_does_not_pass_on_presence_alone(
assert result.status == "WARN"
assert result.authority == "suggestive"
assert result.concluded_by == "none"
assert result.evidence["relative_path"] == "SECURITY.md"
assert result.evidence["relative_path"].lower() == filename.lower()

def test_cli_and_direct_produce_equal_authority(self, tmp_path: Path) -> None:
"""US4 acceptance #4 + SC-004 (partial): the same control run two
Expand Down
Loading
Loading