Skip to content

Commit 7e57015

Browse files
fix: prevent jscpd and secretlint report-folder race
TextReporter runs per linter the moment it finishes, so JsCpdLinter's rmtree of megalinter-reports/copy-paste fired while secretlint was still scanning the workspace in project mode. secretlint had already globbed the jscpd report into its target set, crashed with ENOENT reading it, and its stack trace was then parsed as SARIF and counted as a finding. jscpd now writes its report to a temp folder outside the workspace and copies it in only when clones are found, so MegaLinter never deletes files other linters are scanning. secretlint gets a generated ignore file in the report folder that merges the user's patterns with an exclusion for that folder alone; narrowing a secrets scanner further would hide credentials baked into build artifacts. - fix: report a linter with unparsable SARIF as a warning, not a success - A linter exiting 0 while emitting a stack trace instead of SARIF was counted as zero findings and shown green; affects linters declaring can_output_sarif (REPOSITORY_SECRETLINT, REPOSITORY_SEMGREP, REPOSITORY_TRIVY). A linter that dies before producing SARIF now fails with its raw output instead of a phantom finding. - fix(secretlint): honor ignore patterns when REPORT_OUTPUT_FOLDER is off - The generated ignore file was only written when reports could be written; otherwise it fell back to passing a base name, so a .secretlintignore under LINTER_RULES_PATH silently applied nothing. It now goes to a temp folder outside the workspace, and the --secretlintignore=<value> argument form is parsed too. - fix(secretlint): stop falling back to .gitignore for a missing ignore file - A typo in --secretlintignore used to promote .gitignore patterns to authoritative status, excluding the very files a secrets scanner exists to inspect. It now degrades with a warning and applies no fallback. - fix(config): always exclude REPORT_OUTPUT_FOLDER from linted directories - An EXCLUDED_DIRECTORIES override could drop it, making MegaLinter lint its own output while reporters were still writing to it. - test: cover jscpd report wiring and isolate ambient env in unit tests - docs: document the secretlint ignore model and report-folder exclusion Fixes: #3979 Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Trevor Vaughan <peiriannydd@gmail.com>
1 parent 1e997fb commit 7e57015

16 files changed

Lines changed: 1607 additions & 80 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ Note: Can be used with `oxsecurity/megalinter@beta` in your GitHub Action mega-l
3131
- Linters enhancements
3232

3333
- Fixes
34+
- Fix sporadic `ENOENT` crashes when `COPYPASTE_JSCPD` and `REPOSITORY_SECRETLINT` run in parallel: jscpd now writes its report to a temporary folder outside the linted workspace, and MegaLinter no longer deletes report files while other linters are still scanning them, fixes [#3979](https://github.com/oxsecurity/megalinter/issues/3979). As jscpd no longer removes its report at the end of a clean run, a copy-paste report left by a previous run is now removed when jscpd starts, so the report folder never mixes results from two runs
35+
- Fix `REPOSITORY_SECRETLINT` silently narrowing its scan when `REPOSITORY_SECRETLINT_ARGUMENTS` names a `--secretlintignore` file that does not exist: MegaLinter no longer falls back to `.secretlintignore` or `.gitignore` in its place, as those patterns often exclude the very files a secrets scanner must inspect
36+
- Fix the `copy-paste` report folder being created unreadable to non-root users (such as a later artifact-upload step), as it inherited the private permissions of the temporary folder jscpd writes to
37+
- Stop `REPOSITORY_SECRETLINT` from scanning MegaLinter's own report folder, which could raise false positives on secrets echoed into other linters' reports, using an ignore file generated inside the report folder so nothing is ever written to or deleted from the linted sources. `EXCLUDED_DIRECTORIES` forwarding now goes through that same file, so secretlint no longer receives two `--secretlintignore` arguments (it keeps only the last) and no `.megalinter-secretlintignore` is written to the root of the linted repository
38+
- Always exclude `REPORT_OUTPUT_FOLDER` from linted directories, even when `EXCLUDED_DIRECTORIES` is overridden
39+
- Report a linter that crashes before producing SARIF output as a failure showing its raw output, instead of a spurious single finding accompanied by a SARIF parsing error
40+
- Stop reporting a linter that exits without parsable SARIF output as a clean success: its results could not be counted, so it is now reported as a warning rather than as zero findings. Applies to linters declaring `can_output_sarif` when SARIF output is enabled, including security scanners such as `REPOSITORY_SECRETLINT`, `REPOSITORY_SEMGREP` and `REPOSITORY_TRIVY`
41+
- Fix `REPOSITORY_SECRETLINT` ignoring a `.secretlintignore` located in `LINTER_RULES_PATH` (e.g. the default `.github/linters`): only its base name was passed, so secretlint resolved it from the workspace root and applied either no patterns at all or those of a different file with the same name
42+
- Keep applying `REPOSITORY_SECRETLINT` ignore patterns when `REPORT_OUTPUT_FOLDER` is disabled, by generating the merged ignore file in a temporary folder outside the linted sources instead of giving up on it
3443

3544
- Reporters
3645

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1301,7 +1301,7 @@ description: List of common variables that you can use to customize MegaLinter b
13011301
| [**ENABLE_ERRORS_LINTERS**](https://github.com/oxsecurity/megalinter/tree/main/docs/config-activation.md) | <!-- --> | List of enabled and blocking linters keys [(more info)](https://github.com/oxsecurity/megalinter/tree/main/docs/config-activation.md) |
13021302
| [**ENABLE**](https://github.com/oxsecurity/megalinter/tree/main/docs/config-activation.md) | <!-- --> | List of enabled descriptors keys [(more info)](https://github.com/oxsecurity/megalinter/tree/main/docs/config-activation.md) |
13031303
| [**ENABLE_LINTERS**](https://github.com/oxsecurity/megalinter/tree/main/docs/config-activation.md) | <!-- --> | List of enabled linters keys [(more info)](https://github.com/oxsecurity/megalinter/tree/main/docs/config-activation.md) |
1304-
| **EXCLUDED_DIRECTORIES** | \[…many values…\] | List of excluded directory basenames. They're excluded at any nested level. |
1304+
| **EXCLUDED_DIRECTORIES** | \[…many values…\] | List of excluded directory basenames. They're excluded at any nested level. The value of **REPORT_OUTPUT_FOLDER** is always excluded in addition to this list. |
13051305
| **EXTENDS** | <!-- --> | Base `mega-linter.yml` config file(s) to extend local configuration from. Can be a single URL or a list of `.mega-linter.yml` config files URLs. Later files take precedence. |
13061306
| **FAIL_IF_MISSING_LINTER_IN_FLAVOR** | `false` | If set to `true`, MegaLinter fails if a linter is missing in the selected flavor |
13071307
| **FAIL_IF_UPDATED_SOURCES** | `false` | If set to `true`, MegaLinter fails if a linter or formatter has autofixed sources, even if there are no errors |

docs/config-variables.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ description: List of common variables that you can use to customize MegaLinter b
2323
| [**ENABLE_ERRORS_LINTERS**](config-activation.md) | <!-- --> | List of enabled and blocking linters keys [(more info)](config-activation.md) |
2424
| [**ENABLE**](config-activation.md) | <!-- --> | List of enabled descriptors keys [(more info)](config-activation.md) |
2525
| [**ENABLE_LINTERS**](config-activation.md) | <!-- --> | List of enabled linters keys [(more info)](config-activation.md) |
26-
| **EXCLUDED_DIRECTORIES** | \[…many values…\] | List of excluded directory basenames. They're excluded at any nested level. |
26+
| **EXCLUDED_DIRECTORIES** | \[…many values…\] | List of excluded directory basenames. They're excluded at any nested level. The value of **REPORT_OUTPUT_FOLDER** is always excluded in addition to this list. |
2727
| **EXTENDS** | <!-- --> | Base `mega-linter.yml` config file(s) to extend local configuration from. Can be a single URL or a list of `.mega-linter.yml` config files URLs. Later files take precedence. |
2828
| **FAIL_IF_MISSING_LINTER_IN_FLAVOR** | `false` | If set to `true`, MegaLinter fails if a linter is missing in the selected flavor |
2929
| **FAIL_IF_UPDATED_SOURCES** | `false` | If set to `true`, MegaLinter fails if a linter or formatter has autofixed sources, even if there are no errors |

mega-linter-runner/lib/megalinter-vars.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8707,7 +8707,7 @@
87078707
"name": "EXCLUDED_DIRECTORIES",
87088708
"type": "array",
87098709
"title": "Excluded directories",
8710-
"description": "List of excluded directory basenames replacing MegaLinter default excluded directories. They are excluded at any nested level.",
8710+
"description": "List of excluded directory basenames replacing MegaLinter default excluded directories. They are excluded at any nested level. The value of REPORT_OUTPUT_FOLDER is always excluded in addition to this list.",
87118711
"examples": [
87128712
[
87138713
".github",

megalinter/Linter.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ def __init__(self, params=None, linter_config=None):
7272
self.linter_speed = 3
7373
self.can_output_sarif = False
7474
self.output_sarif = False
75+
# Sticky flag: set when SARIF output could not be parsed, so a linter that
76+
# exits 0 with unparsable SARIF is not reported as clean (see get_sarif_result_count)
77+
self.sarif_parse_failed = False
7578
# ex: https://eslint.org/
7679
self.linter_url = (
7780
"Field 'linter_url' must be overridden at custom linter class level"
@@ -1014,6 +1017,18 @@ def run(
10141017
if self.cli_lint_mode == "list_of_files":
10151018
self.update_files_lint_results(self.files, None, None, None, None, None)
10161019

1020+
# A linter that exited without parsable SARIF has not been measured, so
1021+
# counting zero results would report it as clean. Every reporter still shows
1022+
# it with zero errors and zero warnings, hence the log: the status alone
1023+
# cannot tell an operator that the results are missing rather than empty.
1024+
if self.status == "success" and self.sarif_parse_failed is True:
1025+
self.status = "warning"
1026+
logging.warning(
1027+
f"[{self.linter_name}] results could not be counted, as its SARIF "
1028+
"output was not parsable: reporting this linter as a warning rather "
1029+
"than as a clean success"
1030+
)
1031+
10171032
# Set return code to 0 if failures in this linter must not make the MegaLinter run fail
10181033
if self.return_code != 0:
10191034
# Disable errors: no failure, just warning
@@ -2109,8 +2124,20 @@ def get_sarif_result_count(self, stdout: str, level: str):
21092124
sarif_output = yaml.safe_load(sarif_file)
21102125
# SARIF is in stdout
21112126
else:
2112-
# SARIF is in stdout
2113-
sarif_output = yaml.safe_load(stdout)
2127+
# SARIF is in stdout. Require it to actually have SARIF shape:
2128+
# find_json_in_stdout returns "" unless the payload parses as JSON
2129+
# containing a "runs" key. A linter that crashed before producing
2130+
# SARIF leaves a stack trace here, which must surface as a failure
2131+
# rather than be parsed as results
2132+
sarif_stdout = utils.find_json_in_stdout(stdout)
2133+
if sarif_stdout == "":
2134+
logging.error(
2135+
f"[{self.linter_name}] exited without producing parsable SARIF "
2136+
f"output while counting {level}s.\nLinter output: {stdout}"
2137+
)
2138+
self.sarif_parse_failed = True
2139+
return 0
2140+
sarif_output = json.loads(sarif_stdout)
21142141

21152142
for run in sarif_output["runs"]:
21162143
rule_default_level_map = {}
@@ -2153,14 +2180,15 @@ def get_sarif_result_count(self, stdout: str, level: str):
21532180

21542181
return total_result
21552182
except Exception as e:
2156-
total_result = 1
2183+
# Return 0 rather than inventing a finding: get_total_number_errors
2184+
# already reports 1 error for any non-success linter, so a broken linter
2185+
# still fails the run without being attributed a phantom result
21572186
logging.error(
2158-
f"Error while getting total {level}s from SARIF output.\nError:"
2159-
+ str(e)
2160-
+ "\nstdout: "
2161-
+ stdout
2187+
f"[{self.linter_name}] unable to compute total {level}s from SARIF "
2188+
f"output.\nError: {str(e)}\nLinter output: {stdout}"
21622189
)
2163-
return total_result
2190+
self.sarif_parse_failed = True
2191+
return 0
21642192

21652193
# Build the CLI command to get linter version (can be overridden if --version is not the way to get the version)
21662194
def build_version_command(self):

megalinter/descriptors/repository.megalinter-descriptor.yml

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,15 @@ linters:
651651
- **Team Collaboration**: Shared configuration files ensure consistent secret detection across development teams
652652
- **Performance Optimized**: Fast scanning suitable for large repositories and automated workflows
653653
- **Open Source**: Community-driven with transparent detection rules and continuous updates
654+
655+
**Ignore patterns in MegaLinter:**
656+
657+
MegaLinter does not pass your ignore file to secretlint directly. It generates a `.secretlintignore-megalinter` file inside **REPORT_OUTPUT_FOLDER** and passes that instead, because secretlint applies only one ignore file.
658+
659+
- The generated file merges the patterns of your own ignore file: the one named by `REPOSITORY_SECRETLINT_ARGUMENTS: --secretlintignore <file>` if you set it, otherwise your `.secretlintignore`, otherwise your `.gitignore`. Your file is honored wherever it lives, including in **LINTER_RULES_PATH**.
660+
- MegaLinter's own report folder is appended last, so it is always excluded. Ignore rules are last match wins, so this cannot be undone by a negation pattern in your own file.
661+
- Your **EXCLUDED_DIRECTORIES** are merged in as well, like for every other project mode linter, but only the ones that exist at the root of your repository. Set `REPOSITORY_SECRETLINT_FORWARD_EXCLUDED_DIRECTORIES: false` to keep them scanned: build artifacts such as `.terraform` or `.terragrunt-cache` can contain real credentials baked in at generation time.
662+
- When **REPORT_OUTPUT_FOLDER** is disabled, the generated file is written to a temporary folder outside your sources instead, so your patterns still apply.
654663
linter_url: https://github.com/secretlint/secretlint
655664
linter_repo: https://github.com/secretlint/secretlint
656665
linter_banner_image_url: https://github.com/secretlint/secretlint/raw/master/docs/assets/SecretLintLP.png
@@ -670,17 +679,16 @@ linters:
670679
- project
671680
cli_lint_extra_args_after:
672681
# secretlint v13 introduced a ripgrep-based file walker that respects
673-
# .gitignore by default. --no-gitignore keeps the v12 scan breadth (scan
674-
# everything, filter only via .secretlintignore). The .secretlintignore
675-
# itself (which excludes .automation/test fixtures containing intentional
676-
# fake secrets) is applied via SecretLintLinter.get_ignore_arguments,
677-
# which passes it by base name because the v13 walker matches ignore
678-
# files by base name and drops an absolute path.
682+
# .gitignore by default. --no-gitignore keeps the v12 scan breadth: scan
683+
# everything, filter only via the ignore file MegaLinter generates in
684+
# SecretLintLinter.build_megalinter_ignore_file.
679685
# See https://github.com/secretlint/secretlint/releases (v13)
680686
- "--no-gitignore"
681-
cli_lint_mode_project_exclude_ignore_file_arg_name: "--secretlintignore"
682-
cli_lint_mode_project_exclude_ignore_file_seed_files: [".secretlintignore", ".gitignore"]
683-
cli_lint_mode_project_exclude_workspace_file_name: ".megalinter-secretlintignore"
687+
# The generic ignore-file forwarding is deliberately not declared here: it
688+
# would write a .megalinter-secretlintignore at the workspace root and pass a
689+
# second --secretlintignore, of which secretlint keeps only the last.
690+
# SecretLintLinter.build_megalinter_ignore_file merges the excluded
691+
# directories into the file it generates in the report folder instead.
684692
cli_lint_mode_project_extra_args_after:
685693
- "**/*"
686694
cli_sarif_args:

megalinter/descriptors/schemas/megalinter-configuration.jsonschema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10285,7 +10285,7 @@
1028510285
},
1028610286
"EXCLUDED_DIRECTORIES": {
1028710287
"$id": "#/properties/EXCLUDED_DIRECTORIES",
10288-
"description": "List of excluded directory basenames replacing MegaLinter default excluded directories. They are excluded at any nested level.",
10288+
"description": "List of excluded directory basenames replacing MegaLinter default excluded directories. They are excluded at any nested level. The value of REPORT_OUTPUT_FOLDER is always excluded in addition to this list.",
1028910289
"examples": [
1029010290
[
1029110291
".github",

0 commit comments

Comments
 (0)