Repository: warpnet/salt-lint

Issue:
Feature Request: Add rule for cron state validation
**Is your feature request related to a problem? Please describe.**

[`cron.{present,absent}`](https://docs.saltstack.com/en/master/ref/states/all/salt.states.cron.html#salt.states.cron.present) changes include some surprising gotchas, this is because the identifier defaults to the `- name:`
```bash
identifier
Custom-defined identifier for tracking the cron line for future crontab edits. This defaults to the state name
```

Require `- identifier:` to be present for `cron.{present,absent}` states which would avoid coupling action changing from identifier changing.


**Describe the solution you'd like**
Since name defines both identifier and action by default, I'd recommend that the linter would verify that `- identifier:` was present. It would also verify that the `- identifier:` does not use any jinja templating as it's ideal that the data is not changed external the declaration.

This would make changing the actions not create duplicate crons.

**Describe alternatives you've considered**
My personal opinion is that salt shouldn't be combining the identifier for uniqueness and the command you are running, but I feel that would be a fairly large and breaking api change. 



Comments:

Patch:
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index fc39d25..abf935e 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -20,7 +20,7 @@ jobs:
 
   docker:
     runs-on: ubuntu-latest
-    steps: 
+    steps:
     - name: Checkout
       uses: actions/checkout@v2
     - name: dockerlint
@@ -57,7 +57,7 @@ jobs:
     - name: Lint with codespell
       run: |
         pip install codespell
-        codespell --skip="./.git*"
+        codespell --skip="./.git*,./saltlint/rules/FileManagedReplaceContentRule.py"
 
   pylint:
     runs-on: ubuntu-latest
diff --git a/README.md b/README.md
index 5c2f86e..81878e2 100644
--- a/README.md
+++ b/README.md
@@ -184,6 +184,7 @@ Rule | Description
 [212](https://github.com/warpnet/salt-lint/wiki/212) | Most files should not contain irregular spaces
 [213](https://github.com/warpnet/salt-lint/wiki/213) | SaltStack recommends using `cmd.run` together with `onchanges`, rather than `cmd.wait`
 [214](https://github.com/warpnet/salt-lint/wiki/214) | SLS file with a period in the name (besides the suffix period) can not be referenced
+[215](https://github.com/warpnet/salt-lint/wiki/215) | Using `replace: False` is required when not specifying content
 
 ### Jinja
 
diff --git a/saltlint/rules/FileManagedReplaceContentRule.py b/saltlint/rules/FileManagedReplaceContentRule.py
new file mode 100644
index 0000000..5eca8c3
--- /dev/null
+++ b/saltlint/rules/FileManagedReplaceContentRule.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020 Warpnet B.V.
+
+import re
+from saltlint.linter.rule import Rule
+from saltlint.utils import get_rule_skips_from_text
+from saltlint.utils import LANGUAGE_SLS
+
+
+class FileManagedReplaceContentRule(Rule):
+    id = '215'
+    shortdesc = "Using 'replace: False' is required when not specifying content"
+    description = "Using 'replace: False' is required when not specifying content"
+
+    severity = 'HIGH'
+    languages = [LANGUAGE_SLS]
+    tags = ['formatting']
+    version_added = 'develop'
+
+    # Find the full file.managed state
+    regex = re.compile(r"^\s{2}file\.managed:.*(?:\n\s{4}.+)*", re.MULTILINE)
+    # Regex for finding the content source option
+    regex_options= re.compile(
+        r"^\s{4}-\s(?:source:|contents:|contents_pillar:|contents_grains:|replace:\s[F|f]alse).*$",
+        re.MULTILINE
+    )
+
+    def matchtext(self, file, text):
+        results = []
+
+        # Find all file.managed states in the specified sls file
+        for match in re.finditer(self.regex, text):
+            # Continue if the file.managed state includes a content source
+            # or replace is set to False
+            if re.search(self.regex_options, match.group(0)):
+                continue
+
+            # Get the location of the regex match
+            start = match.start()
+            end = match.end()
+
+            # Get the line number of the first character
+            lines = text[:start].splitlines()
+            line_no = len(lines) + 1
+
+            # Skip result if noqa for this rule ID is found in section
+            section = text[start:end]
+            if self.id in get_rule_skips_from_text(section):
+                continue
+
+            # Append the match to the results
+            results.append((line_no, section.splitlines()[0], self.shortdesc))
+
+        return results
diff --git a/saltlint/rules/JinjaCommentHasSpacesRule.py b/saltlint/rules/JinjaCommentHasSpacesRule.py
index 7c54719..cb93f30 100644
--- a/saltlint/rules/JinjaCommentHasSpacesRule.py
+++ b/saltlint/rules/JinjaCommentHasSpacesRule.py
@@ -10,8 +10,8 @@ from saltlint.utils import LANGUAGE_JINJA, LANGUAGE_SLS
 
 class JinjaCommentHasSpacesRule(Rule):
     id = '209'
-    shortdesc = 'Jinja comment should have spaces before and after: {# comment #}'
-    description = 'Jinja comment should have spaces before and after: ``{# comment #}``'
+    shortdesc = "Jinja comment should have spaces before and after: '{# comment #}'"
+    description = "Jinja comment should have spaces before and after: '{# comment #}'"
     severity = 'LOW'
     languages = [LANGUAGE_SLS, LANGUAGE_JINJA]
     tags = ['formatting', 'jinja']
diff --git a/saltlint/rules/JinjaStatementHasSpacesRule.py b/saltlint/rules/JinjaStatementHasSpacesRule.py
index 59992e9..0ef024e 100644
--- a/saltlint/rules/JinjaStatementHasSpacesRule.py
+++ b/saltlint/rules/JinjaStatementHasSpacesRule.py
@@ -10,8 +10,8 @@ from saltlint.utils import LANGUAGE_JINJA, LANGUAGE_SLS
 
 class JinjaStatementHasSpacesRule(Rule):
     id = '202'
-    shortdesc = 'Jinja statement should have spaces before and after: {% statement %}'
-    description = 'Jinja statement should have spaces before and after: ``{% statement %}``'
+    shortdesc = "Jinja statement should have spaces before and after: '{% statement %}'"
+    description = "Jinja statement should have spaces before and after: '{% statement %}'"
     severity = 'LOW'
     languages = [LANGUAGE_SLS, LANGUAGE_JINJA]
     tags = ['formatting', 'jinja']
diff --git a/saltlint/rules/JinjaVariableHasSpacesRule.py b/saltlint/rules/JinjaVariableHasSpacesRule.py
index 53fd0eb..946222b 100644
--- a/saltlint/rules/JinjaVariableHasSpacesRule.py
+++ b/saltlint/rules/JinjaVariableHasSpacesRule.py
@@ -10,8 +10,8 @@ from saltlint.utils import LANGUAGE_JINJA, LANGUAGE_SLS
 
 class JinjaVariableHasSpacesRule(Rule):
     id = '206'
-    shortdesc = 'Jinja variables should have spaces before and after: {{ var_name }}'
-    description = 'Jinja variables should have spaces before and after: ``{{ var_name }}``'
+    shortdesc = "Jinja variables should have spaces before and after: '{{ var_name }}'"
+    description = "Jinja variables should have spaces before and after: '{{ var_name }}'"
     severity = 'LOW'
     languages = [LANGUAGE_SLS, LANGUAGE_JINJA]
     tags = ['formatting', 'jinja']
diff --git a/saltlint/rules/YamlHasOctalValueRule.py b/saltlint/rules/YamlHasOctalValueRule.py
index 57b4f82..2001e49 100644
--- a/saltlint/rules/YamlHasOctalValueRule.py
+++ b/saltlint/rules/YamlHasOctalValueRule.py
@@ -10,8 +10,8 @@ from saltlint.utils import LANGUAGE_SLS
 
 class YamlHasOctalValueRule(Rule):
     id = '210'
-    shortdesc = 'Numbers that start with `0` should always be encapsulated in quotation marks'
-    description = 'Numbers that start with `0` should always be encapsulated in quotation marks'
+    shortdesc = "Numbers that start with '0' should always be encapsulated in quotation marks"
+    description = "Numbers that start with '0' should always be encapsulated in quotation marks"
     severity = 'HIGH'
     languages = [LANGUAGE_SLS]
     tags = ['formatting']


Test patch:
diff --git a/tests/unit/TestFileManagedReplaceContentRule.py b/tests/unit/TestFileManagedReplaceContentRule.py
new file mode 100644
index 0000000..76d453e
--- /dev/null
+++ b/tests/unit/TestFileManagedReplaceContentRule.py
@@ -0,0 +1,107 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020 Warpnet B.V.
+
+import unittest
+
+from saltlint.linter.collection import RulesCollection
+from saltlint.rules.FileManagedReplaceContentRule import FileManagedReplaceContentRule
+from tests import RunFromText
+
+
+GOOD_FILE_STATE = '''
+cis_grub.cfg:
+  file.managed:
+    - name: /boot/grub.cfg
+    - user: root
+    - group: root
+    - mode: '0700'
+    - source: salt://grub/files/grub.cfg
+
+cis_systemid_only_set_once:
+  file.managed:
+    - name: /tmp/systemid
+    - user: root
+    - group: root
+    - replace: False
+    - contents_grains: osmajorrelease
+
+user:
+  user.present:
+    - name: "salt-lint"
+  file.managed:
+    - name: /user/salt-lint/.bashrc
+    - user: root
+    - group: root
+    - mode: '0700'
+    - contents_pillar: bashrc
+
+cis_grub.cfg_managerights:
+  file.managed:
+    - name: /boot/grub.cfg
+    - user: root
+    - group: root
+    - mode: '0700'
+    - replace: False
+
+cis_grub_permissions:
+  file.managed:
+    - name: /boot/grub.cfg
+    - replace: false
+    - user: root
+    - group: root
+    - mode: '0700'
+'''
+
+BAD_FILE_STATE = '''
+cis_grub.cfg:
+  file.managed:
+    - name: /boot/grub.cfg
+    - user: root
+    - group: root
+    - mode: '0700'
+
+cis_systemid_only_set_once:
+  file.managed:
+    - name: /tmp/systemid
+    - user: root
+    - group: root
+    - replace: True
+
+user:
+  user.present:
+    - name: "salt-lint"
+  file.managed:
+    - name: /user/salt-lint/.bashrc
+    - user: root
+    - group: root
+    - mode: '0700'
+
+cis_grub_permissions:
+  file.managed: # noqa: 215
+    - name: /boot/grub.cfg
+    - user: root
+    - group: root
+    - mode: '0700'
+'''
+
+
+class TestFileManagedReplaceContentRule(unittest.TestCase):
+    collection = RulesCollection()
+
+    def setUp(self):
+        self.collection.register(FileManagedReplaceContentRule())
+
+    def test_statement_positive(self):
+        runner = RunFromText(self.collection)
+        results = runner.run_state(GOOD_FILE_STATE)
+        self.assertEqual(0, len(results))
+
+    def test_statement_negative(self):
+        runner = RunFromText(self.collection)
+        results = runner.run_state(BAD_FILE_STATE)
+        self.assertEqual(3, len(results))
+
+        # Check line numbers of the results
+        self.assertEqual(3, results[0].linenumber)
+        self.assertEqual(10, results[1].linenumber)
+        self.assertEqual(19, results[2].linenumber)