secure-review-extension 1.0.9 → 1.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "secure-review-extension",
3
3
  "displayName": "Secure Review",
4
4
  "description": "Run deep static and Docker-based dynamic secure code reviews directly inside VS Code.",
5
- "version": "1.0.9",
5
+ "version": "1.0.11",
6
6
  "publisher": "Ankit-QI",
7
7
  "icon": "media/shield.png",
8
8
  "license": "MIT",
@@ -272,6 +272,16 @@
272
272
  "default": true,
273
273
  "description": "If cppcheck is installed locally, include C and C++ static analysis findings."
274
274
  },
275
+ "secureReview.enableShellcheck": {
276
+ "type": "boolean",
277
+ "default": true,
278
+ "description": "If ShellCheck is installed locally, include shell script lint and safety findings."
279
+ },
280
+ "secureReview.enableAnsibleLint": {
281
+ "type": "boolean",
282
+ "default": true,
283
+ "description": "If ansible-lint is installed locally, include Ansible playbook findings."
284
+ },
275
285
  "secureReview.enableDotnetVuln": {
276
286
  "type": "boolean",
277
287
  "default": true,
@@ -8,61 +8,88 @@ function detectWorkspace(workspaceRoot) {
8
8
  const languages = new Set();
9
9
  const frameworks = new Set();
10
10
 
11
- if (exists("package.json")) {
11
+ const packageJsonFiles = [...manifestNames].filter((file) => path.basename(file) === "package.json");
12
+ if (packageJsonFiles.length) {
12
13
  languages.add("javascript");
13
- const packageJson = readJson(path.join(workspaceRoot, "package.json"));
14
- const dependencies = {
15
- ...(packageJson.dependencies || {}),
16
- ...(packageJson.devDependencies || {}),
17
- ...(packageJson.peerDependencies || {})
18
- };
19
-
20
- if (dependencies.react) frameworks.add("react");
21
- if (dependencies.next) frameworks.add("nextjs");
22
- if (dependencies.vue) frameworks.add("vue");
23
- if (dependencies["@angular/core"]) frameworks.add("angular");
24
- if (dependencies.express) frameworks.add("express");
25
- if (dependencies["@nestjs/core"]) frameworks.add("nestjs");
26
- }
27
-
28
- if (exists("requirements.txt") || exists("pyproject.toml") || exists("Pipfile")) {
14
+ for (const packageJsonPath of packageJsonFiles) {
15
+ const packageJson = readJson(path.join(workspaceRoot, packageJsonPath));
16
+ const dependencies = {
17
+ ...(packageJson.dependencies || {}),
18
+ ...(packageJson.devDependencies || {}),
19
+ ...(packageJson.peerDependencies || {})
20
+ };
21
+
22
+ if (dependencies.react) frameworks.add("react");
23
+ if (dependencies.next) frameworks.add("nextjs");
24
+ if (dependencies.vue) frameworks.add("vue");
25
+ if (dependencies["@angular/core"]) frameworks.add("angular");
26
+ if (dependencies.express) frameworks.add("express");
27
+ if (dependencies["@nestjs/core"]) frameworks.add("nestjs");
28
+ if (dependencies.svelte) frameworks.add("svelte");
29
+ if (dependencies.koa) frameworks.add("koa");
30
+ if (dependencies.fastify) frameworks.add("fastify");
31
+ }
32
+ }
33
+
34
+ const pythonManifestFiles = [...manifestNames].filter((file) => {
35
+ const base = path.basename(file);
36
+ return base === "requirements.txt" || base === "pyproject.toml" || base === "Pipfile";
37
+ });
38
+ if (pythonManifestFiles.length) {
29
39
  languages.add("python");
30
- const pythonText = readTextIfExists("requirements.txt") || readTextIfExists("pyproject.toml") || "";
31
- if (/django/i.test(pythonText)) frameworks.add("django");
32
- if (/flask/i.test(pythonText)) frameworks.add("flask");
33
- if (/fastapi/i.test(pythonText)) frameworks.add("fastapi");
40
+ for (const manifestPath of pythonManifestFiles) {
41
+ const pythonText = readTextIfExists(manifestPath);
42
+ if (/django/i.test(pythonText)) frameworks.add("django");
43
+ if (/flask/i.test(pythonText)) frameworks.add("flask");
44
+ if (/fastapi/i.test(pythonText)) frameworks.add("fastapi");
45
+ }
34
46
  }
35
47
 
36
- if (exists("go.mod")) {
48
+ const goModFiles = [...manifestNames].filter((file) => path.basename(file) === "go.mod");
49
+ if (goModFiles.length) {
37
50
  languages.add("go");
38
- const goMod = readTextIfExists("go.mod");
39
- if (/gin-gonic\/gin/i.test(goMod)) frameworks.add("gin");
40
- if (/labstack\/echo/i.test(goMod)) frameworks.add("echo");
51
+ for (const goModPath of goModFiles) {
52
+ const goMod = readTextIfExists(goModPath);
53
+ if (/gin-gonic\/gin/i.test(goMod)) frameworks.add("gin");
54
+ if (/labstack\/echo/i.test(goMod)) frameworks.add("echo");
55
+ if (/gofiber\/fiber/i.test(goMod)) frameworks.add("fiber");
56
+ }
41
57
  }
42
58
 
43
- if (exists("Cargo.toml")) {
59
+ const cargoTomlFiles = [...manifestNames].filter((file) => path.basename(file) === "Cargo.toml");
60
+ if (cargoTomlFiles.length) {
44
61
  languages.add("rust");
45
- const cargoToml = readTextIfExists("Cargo.toml");
46
- if (/actix-web/i.test(cargoToml)) frameworks.add("actix-web");
47
- if (/\baxum\b/i.test(cargoToml)) frameworks.add("axum");
62
+ for (const cargoTomlPath of cargoTomlFiles) {
63
+ const cargoToml = readTextIfExists(cargoTomlPath);
64
+ if (/actix-web/i.test(cargoToml)) frameworks.add("actix-web");
65
+ if (/\baxum\b/i.test(cargoToml)) frameworks.add("axum");
66
+ if (/rocket/i.test(cargoToml)) frameworks.add("rocket");
67
+ }
48
68
  }
49
69
 
50
- if (exists("pom.xml") || exists("build.gradle") || exists("build.gradle.kts")) {
70
+ const javaManifestFiles = [...manifestNames].filter((file) => {
71
+ const base = path.basename(file);
72
+ return base === "pom.xml" || base === "build.gradle" || base === "build.gradle.kts";
73
+ });
74
+ if (javaManifestFiles.length) {
51
75
  languages.add("java");
52
- const javaText = readTextIfExists("pom.xml") || readTextIfExists("build.gradle") || readTextIfExists("build.gradle.kts") || "";
53
- if (/spring/i.test(javaText)) frameworks.add("spring");
76
+ for (const manifestPath of javaManifestFiles) {
77
+ const javaText = readTextIfExists(manifestPath);
78
+ if (/spring/i.test(javaText)) frameworks.add("spring");
79
+ if (/quarkus/i.test(javaText)) frameworks.add("quarkus");
80
+ }
54
81
  }
55
82
 
56
- if (exists("CMakeLists.txt") || exists("Makefile")) {
83
+ if (manifestNames.has("CMakeLists.txt") || manifestNames.has("Makefile")) {
57
84
  languages.add("c");
58
85
  languages.add("cpp");
59
86
  }
60
87
 
61
- if (exists("composer.json")) {
88
+ if ([...manifestNames].some((file) => path.basename(file) === "composer.json")) {
62
89
  languages.add("php");
63
90
  }
64
91
 
65
- if (exists("Gemfile")) {
92
+ if ([...manifestNames].some((file) => path.basename(file) === "Gemfile")) {
66
93
  languages.add("ruby");
67
94
  frameworks.add("rails");
68
95
  }
@@ -71,6 +98,10 @@ function detectWorkspace(workspaceRoot) {
71
98
  languages.add("csharp");
72
99
  }
73
100
 
101
+ if ([...manifestNames].some((file) => path.basename(file) === "ansible.cfg")) {
102
+ frameworks.add("ansible");
103
+ }
104
+
74
105
  for (const file of manifestNames) {
75
106
  const ext = path.extname(file).toLowerCase();
76
107
  if ([".c", ".h"].includes(ext)) languages.add("c");
@@ -82,7 +113,9 @@ function detectWorkspace(workspaceRoot) {
82
113
  if (ext === ".php") languages.add("php");
83
114
  if (ext === ".rb") languages.add("ruby");
84
115
  if (ext === ".cs") languages.add("csharp");
116
+ if ([".sh", ".bash", ".zsh", ".ksh"].includes(ext)) languages.add("shell");
85
117
  if ([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"].includes(ext)) languages.add("javascript");
118
+ if ([".yml", ".yaml"].includes(ext) && looksLikeAnsibleFile(workspaceRoot, file)) frameworks.add("ansible");
86
119
  }
87
120
 
88
121
  return {
@@ -202,6 +235,24 @@ function buildInstallPlan(profile) {
202
235
  });
203
236
  }
204
237
 
238
+ if (hasAny(profile.languages, ["shell"])) {
239
+ addTool(plan, seen, {
240
+ tool: "ShellCheck",
241
+ requiredFor: ["Shell scripts"],
242
+ install: resolveShellcheckInstall(),
243
+ verify: ["shellcheck", "--version"]
244
+ });
245
+ }
246
+
247
+ if (hasAny(profile.frameworks, ["ansible"])) {
248
+ addTool(plan, seen, {
249
+ tool: "ansible-lint",
250
+ requiredFor: ["Ansible / playbooks"],
251
+ install: resolvePythonInstall("ansible-lint"),
252
+ verify: ["ansible-lint", "--version"]
253
+ });
254
+ }
255
+
205
256
  if (hasAny(profile.languages, ["csharp"])) {
206
257
  addTool(plan, seen, {
207
258
  tool: ".NET package audit",
@@ -294,6 +345,13 @@ function resolveCppcheckInstall() {
294
345
  return { kind: "manual", note: "Install cppcheck with your system package manager." };
295
346
  }
296
347
 
348
+ function resolveShellcheckInstall() {
349
+ if (hasCommand("shellcheck")) return { kind: "already-installed" };
350
+ if (os.platform() === "linux") return { kind: "command", command: ["sudo", "apt-get", "install", "-y", "shellcheck"] };
351
+ if (hasCommand("brew")) return { kind: "command", command: ["brew", "install", "shellcheck"] };
352
+ return { kind: "manual", note: "Install shellcheck with your system package manager." };
353
+ }
354
+
297
355
  function resolveSpotBugsInstall() {
298
356
  if (hasCommand("spotbugs")) return { kind: "already-installed" };
299
357
  if (hasCommand("brew")) return { kind: "command", command: ["brew", "install", "spotbugs"] };
@@ -354,6 +412,33 @@ function readJson(filePath) {
354
412
  }
355
413
  }
356
414
 
415
+ function looksLikeAnsibleFile(workspaceRoot, relativePath) {
416
+ const normalizedPath = relativePath.toLowerCase();
417
+ if (/(^|\/)(playbooks?|roles|tasks|handlers|group_vars|host_vars)\//.test(normalizedPath)) {
418
+ return true;
419
+ }
420
+
421
+ if (/(^|\/)(site|playbook|deploy|provision)\.ya?ml$/.test(normalizedPath)) {
422
+ return true;
423
+ }
424
+
425
+ const fullPath = path.join(workspaceRoot, relativePath);
426
+ if (!fs.existsSync(fullPath)) {
427
+ return false;
428
+ }
429
+
430
+ try {
431
+ const content = fs.readFileSync(fullPath, "utf8").toLowerCase();
432
+ return (
433
+ /(^|\n)\s*hosts\s*:\s*.+/m.test(content)
434
+ && /(^|\n)\s*(tasks|handlers)\s*:/m.test(content)
435
+ ) || /ansible\.builtin\./.test(content)
436
+ || /(^|\n)\s*become\s*:\s*(true|yes)\b/m.test(content);
437
+ } catch {
438
+ return false;
439
+ }
440
+ }
441
+
357
442
  module.exports = {
358
443
  detectWorkspace,
359
444
  buildInstallPlan,
@@ -1,3 +1,8 @@
1
+ const ANSIBLE_PATH_PATTERNS = [
2
+ /(^|\/)(playbooks?|roles|tasks|handlers|group_vars|host_vars)\//i,
3
+ /(^|\/)(site|playbook|deploy|provision)\.ya?ml$/i
4
+ ];
5
+
1
6
  const STATIC_RULES = [
2
7
  {
3
8
  id: "hardcoded-secret",
@@ -776,6 +781,279 @@ const STATIC_RULES = [
776
781
  suggestion: "Call subprocesses with validated argument lists and strict allowlisting.",
777
782
  whyItMatters: "Shell execution expands the attack surface for command injection and quoting bugs.",
778
783
  standards: ["CWE-78", "OWASP:A03"]
784
+ },
785
+ {
786
+ id: "shell-unsafe-variable-expansion",
787
+ title: "Shell script may use unsafe variable expansion in a command path",
788
+ severity: "high",
789
+ confidence: "medium",
790
+ category: "Code Execution",
791
+ subcategory: "Shell Expansion",
792
+ reviewDomain: "security",
793
+ regex: /\b(rm|cp|mv|cat|grep|sed|awk|find|tar|chmod|chown|mkdir)\b[^\n"'#]*\$(\{?[A-Za-z_][A-Za-z0-9_]*\}?)/g,
794
+ remediation: "Quote shell variables and validate command arguments before using them in file or command operations.",
795
+ suggestion: "Use double-quoted expansions and strict allowlists for file paths, patterns, and command inputs.",
796
+ whyItMatters: "Unsafe shell expansion can lead to path confusion, globbing surprises, or injection-like behavior in automation.",
797
+ standards: ["CWE-78", "Shell Safety Review"],
798
+ includeExtensions: [".sh", ".bash", ".zsh", ".ksh"]
799
+ },
800
+ {
801
+ id: "shell-unquoted-variable",
802
+ title: "Shell script appears to use an unquoted variable expansion",
803
+ severity: "medium",
804
+ confidence: "medium",
805
+ category: "Reliability",
806
+ subcategory: "Shell Quoting",
807
+ reviewDomain: "reliability",
808
+ regex: /(^|[=\s(])\$(\{?[A-Za-z_][A-Za-z0-9_]*\}?)(?=\s|$|\/|\*)/gm,
809
+ remediation: "Quote shell variables unless word splitting and glob expansion are explicitly intended and safe.",
810
+ suggestion: "Wrap variable expansions in double quotes and document the rare cases where unquoted expansion is required.",
811
+ whyItMatters: "Unquoted shell variables can split into multiple arguments or expand globs unexpectedly.",
812
+ standards: ["Shell Safety Review"],
813
+ includeExtensions: [".sh", ".bash", ".zsh", ".ksh"]
814
+ },
815
+ {
816
+ id: "shell-dangerous-eval",
817
+ title: "Shell script uses eval",
818
+ severity: "high",
819
+ confidence: "high",
820
+ category: "Code Execution",
821
+ subcategory: "Unsafe Execution",
822
+ reviewDomain: "security",
823
+ regex: /\beval\s+["'$({A-Za-z_]/g,
824
+ remediation: "Avoid eval in shell scripts and replace it with structured command dispatch or explicit argument handling.",
825
+ suggestion: "Use arrays, case statements, or vetted command maps instead of string-built eval execution.",
826
+ whyItMatters: "eval makes shell scripts highly sensitive to injection and quoting mistakes.",
827
+ standards: ["CWE-95", "Shell Safety Review"],
828
+ includeExtensions: [".sh", ".bash", ".zsh", ".ksh"]
829
+ },
830
+ {
831
+ id: "shell-insecure-temp-file",
832
+ title: "Shell script may use insecure temporary file handling",
833
+ severity: "high",
834
+ confidence: "medium",
835
+ category: "File Handling",
836
+ subcategory: "Temporary Files",
837
+ reviewDomain: "security",
838
+ regex: /(\/tmp\/[A-Za-z0-9._-]+|mktemp\s+-u\b)/g,
839
+ remediation: "Use secure mktemp patterns and avoid predictable paths in world-writable temporary directories.",
840
+ suggestion: "Create temporary files with mktemp and restrictive permissions, and clean them up predictably.",
841
+ whyItMatters: "Predictable or unsafe temporary files can be abused through race conditions or file replacement.",
842
+ standards: ["CWE-377", "Shell Safety Review"],
843
+ includeExtensions: [".sh", ".bash", ".zsh", ".ksh"]
844
+ },
845
+ {
846
+ id: "shell-curl-pipe-sh",
847
+ title: "Shell script pipes remote content directly into a shell",
848
+ severity: "high",
849
+ confidence: "high",
850
+ category: "Code Execution",
851
+ subcategory: "Remote Script Execution",
852
+ reviewDomain: "security",
853
+ regex: /\b(curl|wget)\b[^\n|]*\|\s*(bash|sh)\b/g,
854
+ remediation: "Download remote scripts explicitly, verify integrity, and execute only trusted local content.",
855
+ suggestion: "Use signed artifacts, checksums, and explicit local review instead of streaming remote content into a shell.",
856
+ whyItMatters: "Piping remote content into a shell creates a strong supply-chain and command-execution risk.",
857
+ standards: ["CWE-494", "Shell Safety Review"],
858
+ includeExtensions: [".sh", ".bash", ".zsh", ".ksh"]
859
+ },
860
+ {
861
+ id: "shell-disabled-error-handling",
862
+ title: "Shell script disables strict error handling",
863
+ severity: "medium",
864
+ confidence: "medium",
865
+ category: "Reliability",
866
+ subcategory: "Error Handling",
867
+ reviewDomain: "reliability",
868
+ regex: /(^|\n)\s*set\s+\+[eEuU]\b|(^|\n)\s*set\s+\+o\s+pipefail\b/gm,
869
+ remediation: "Keep strict error handling enabled unless there is a documented and tightly scoped exception.",
870
+ suggestion: "Prefer set -euo pipefail and temporarily suppress specific failures only where necessary.",
871
+ whyItMatters: "Disabled shell error handling can mask failed commands and produce unsafe partial execution.",
872
+ standards: ["Reliability Review", "Shell Safety Review"],
873
+ includeExtensions: [".sh", ".bash", ".zsh", ".ksh"]
874
+ },
875
+ {
876
+ id: "shell-unsafe-permissions",
877
+ title: "Shell script applies unsafe permission changes",
878
+ severity: "high",
879
+ confidence: "high",
880
+ category: "Configuration",
881
+ subcategory: "Permissions",
882
+ reviewDomain: "security",
883
+ regex: /\bchmod\s+(?:-R\s+)?(?:777|666)\b/g,
884
+ remediation: "Use the least-permissive file mode needed for the task instead of world-writable permissions.",
885
+ suggestion: "Set explicit owner/group and minimal modes such as 640, 644, 750, or 755 as appropriate.",
886
+ whyItMatters: "Overly broad file permissions increase tampering and data exposure risk on shared systems.",
887
+ standards: ["CWE-732", "Shell Safety Review"],
888
+ includeExtensions: [".sh", ".bash", ".zsh", ".ksh"]
889
+ },
890
+ {
891
+ id: "shell-world-writable-creation",
892
+ title: "Shell script may create world-writable files or directories",
893
+ severity: "high",
894
+ confidence: "medium",
895
+ category: "Configuration",
896
+ subcategory: "Permissions",
897
+ reviewDomain: "security",
898
+ regex: /\b(mkdir|install)\b[^\n]*(?:-m\s*["']?(0777|0666|777|666)["']?)/g,
899
+ remediation: "Create files and directories with minimal required permissions and review umask handling.",
900
+ suggestion: "Use restrictive modes for creation and widen access only where it is explicitly justified.",
901
+ whyItMatters: "World-writable file creation can enable unauthorized tampering or data exposure.",
902
+ standards: ["CWE-732", "Shell Safety Review"],
903
+ includeExtensions: [".sh", ".bash", ".zsh", ".ksh"]
904
+ },
905
+ {
906
+ id: "ansible-shell-module",
907
+ title: "Ansible playbook uses the shell module",
908
+ severity: "medium",
909
+ confidence: "medium",
910
+ category: "Configuration",
911
+ subcategory: "Ansible Execution",
912
+ reviewDomain: "security",
913
+ regex: /^\s*(ansible\.builtin\.)?shell\s*:/gm,
914
+ remediation: "Use command or a more specific Ansible module unless shell features are explicitly required.",
915
+ suggestion: "Prefer idempotent purpose-built modules and reserve shell for the few cases that truly require it.",
916
+ whyItMatters: "Shell tasks in playbooks increase quoting, injection, and idempotence risk compared with dedicated modules.",
917
+ standards: ["Ansible Review"],
918
+ includeExtensions: [".yml", ".yaml"],
919
+ includeFrameworks: ["ansible"],
920
+ includePathPatterns: ANSIBLE_PATH_PATTERNS
921
+ },
922
+ {
923
+ id: "ansible-ignore-errors",
924
+ title: "Ansible task ignores execution errors",
925
+ severity: "medium",
926
+ confidence: "high",
927
+ category: "Reliability",
928
+ subcategory: "Error Handling",
929
+ reviewDomain: "reliability",
930
+ regex: /^\s*ignore_errors\s*:\s*(true|yes)\b/gmi,
931
+ remediation: "Avoid ignore_errors unless the failure is truly non-critical and well documented.",
932
+ suggestion: "Handle expected failure cases explicitly with conditionals or register/when logic instead of suppressing all errors.",
933
+ whyItMatters: "Ignoring errors can hide failed automation steps and leave systems in unsafe partial states.",
934
+ standards: ["Ansible Review"],
935
+ includeExtensions: [".yml", ".yaml"],
936
+ includeFrameworks: ["ansible"],
937
+ includePathPatterns: ANSIBLE_PATH_PATTERNS
938
+ },
939
+ {
940
+ id: "ansible-command-idempotence",
941
+ title: "Ansible shell or command task may be missing creates/removes guards",
942
+ severity: "medium",
943
+ confidence: "low",
944
+ category: "Configuration",
945
+ subcategory: "Idempotence",
946
+ reviewDomain: "reliability",
947
+ regex: /^\s*(ansible\.builtin\.)?(shell|command)\s*:/gm,
948
+ remediation: "Add creates/removes or use an idempotent module so repeated runs stay safe and predictable.",
949
+ suggestion: "Treat shell and command tasks as exceptions and make their side effects explicit with creates/removes guards.",
950
+ whyItMatters: "Command-style Ansible tasks are more likely to be non-idempotent and can drift or repeat unsafe actions.",
951
+ standards: ["Ansible Review"],
952
+ includeExtensions: [".yml", ".yaml"],
953
+ includeFrameworks: ["ansible"],
954
+ includePathPatterns: ANSIBLE_PATH_PATTERNS
955
+ },
956
+ {
957
+ id: "ansible-plaintext-secret",
958
+ title: "Ansible playbook may define plaintext secret material",
959
+ severity: "high",
960
+ confidence: "medium",
961
+ category: "Secrets",
962
+ subcategory: "Credential Exposure",
963
+ reviewDomain: "security",
964
+ regex: /^\s*(password|passwd|token|secret|api[_-]?key|vault_password)\s*:\s*["']?[^"'!\n][^#\n]{7,}$/gmi,
965
+ remediation: "Move secrets to Ansible Vault, environment-backed injection, or a proper secret manager and rotate exposed values.",
966
+ suggestion: "Keep secret values out of playbook YAML and reference them through vault, vars files, or secret lookups instead.",
967
+ whyItMatters: "Plaintext secrets in playbooks are easy to leak through source control and automation logs.",
968
+ standards: ["CWE-798", "Ansible Review"],
969
+ includeExtensions: [".yml", ".yaml"],
970
+ includeFrameworks: ["ansible"],
971
+ includePathPatterns: ANSIBLE_PATH_PATTERNS
972
+ },
973
+ {
974
+ id: "ansible-become-true",
975
+ title: "Ansible playbook uses privilege escalation",
976
+ severity: "medium",
977
+ confidence: "low",
978
+ category: "Configuration",
979
+ subcategory: "Privilege Escalation",
980
+ reviewDomain: "security",
981
+ regex: /^\s*become\s*:\s*(true|yes)\b/gmi,
982
+ remediation: "Review whether privilege escalation is necessary for the task and scope it as narrowly as possible.",
983
+ suggestion: "Use become only on the tasks that need it and document the elevated action being performed.",
984
+ whyItMatters: "Broad privilege escalation can make automation mistakes more damaging and widen the blast radius of compromise.",
985
+ standards: ["Ansible Review"],
986
+ includeExtensions: [".yml", ".yaml"],
987
+ includeFrameworks: ["ansible"],
988
+ includePathPatterns: ANSIBLE_PATH_PATTERNS
989
+ },
990
+ {
991
+ id: "ansible-open-permissions",
992
+ title: "Ansible playbook may set open file permissions",
993
+ severity: "high",
994
+ confidence: "high",
995
+ category: "Configuration",
996
+ subcategory: "Permissions",
997
+ reviewDomain: "security",
998
+ regex: /^\s*mode\s*:\s*["']?(0777|0666|777|666)["']?/gmi,
999
+ remediation: "Use least-privilege file modes and avoid world-writable permissions in managed resources.",
1000
+ suggestion: "Set explicit restrictive modes that match the intended owner and access requirements.",
1001
+ whyItMatters: "Open file permissions make configuration drift and unauthorized tampering easier on shared systems.",
1002
+ standards: ["CWE-732", "Ansible Review"],
1003
+ includeExtensions: [".yml", ".yaml"],
1004
+ includeFrameworks: ["ansible"],
1005
+ includePathPatterns: ANSIBLE_PATH_PATTERNS
1006
+ },
1007
+ {
1008
+ id: "ansible-external-download",
1009
+ title: "Ansible playbook downloads external content",
1010
+ severity: "medium",
1011
+ confidence: "medium",
1012
+ category: "Configuration",
1013
+ subcategory: "External Download",
1014
+ reviewDomain: "security",
1015
+ regex: /^\s*(ansible\.builtin\.)?(get_url|uri)\s*:/gmi,
1016
+ remediation: "Validate remote sources, pin checksums where possible, and avoid executing downloaded artifacts without review.",
1017
+ suggestion: "Treat downloaded artifacts as supply-chain inputs and require integrity verification before use.",
1018
+ whyItMatters: "External downloads in automation can introduce supply-chain and remote content trust risks.",
1019
+ standards: ["CWE-494", "Ansible Review"],
1020
+ includeExtensions: [".yml", ".yaml"],
1021
+ includeFrameworks: ["ansible"],
1022
+ includePathPatterns: ANSIBLE_PATH_PATTERNS
1023
+ },
1024
+ {
1025
+ id: "ansible-no-checksum",
1026
+ title: "Ansible get_url task may be missing checksum validation",
1027
+ severity: "medium",
1028
+ confidence: "low",
1029
+ category: "Configuration",
1030
+ subcategory: "Integrity Validation",
1031
+ reviewDomain: "security",
1032
+ regex: /^\s*(ansible\.builtin\.)?get_url\s*:/gmi,
1033
+ remediation: "Add checksum validation for downloaded artifacts where the source and workflow support it.",
1034
+ suggestion: "Use checksums or signed artifacts so automation can verify integrity before using downloaded content.",
1035
+ whyItMatters: "Downloaded artifacts without integrity checks are more vulnerable to tampering and mirror compromise.",
1036
+ standards: ["CWE-494", "Ansible Review"],
1037
+ includeExtensions: [".yml", ".yaml"],
1038
+ includeFrameworks: ["ansible"],
1039
+ includePathPatterns: ANSIBLE_PATH_PATTERNS
1040
+ },
1041
+ {
1042
+ id: "ansible-validate-certs-disabled",
1043
+ title: "Ansible task disables TLS certificate validation",
1044
+ severity: "high",
1045
+ confidence: "high",
1046
+ category: "Configuration",
1047
+ subcategory: "TLS Validation",
1048
+ reviewDomain: "security",
1049
+ regex: /^\s*validate_certs\s*:\s*(false|no)\b/gmi,
1050
+ remediation: "Keep TLS validation enabled and fix trust-chain issues instead of suppressing certificate checks.",
1051
+ suggestion: "Use trusted certificates or a managed CA bundle rather than disabling transport verification.",
1052
+ whyItMatters: "Disabling TLS validation makes automation vulnerable to tampering and man-in-the-middle attacks.",
1053
+ standards: ["CWE-295", "Ansible Review"],
1054
+ includeExtensions: [".yml", ".yaml"],
1055
+ includeFrameworks: ["ansible"],
1056
+ includePathPatterns: ANSIBLE_PATH_PATTERNS
779
1057
  }
780
1058
  ];
781
1059
 
@@ -38,9 +38,14 @@ const ALLOWED_EXTENSIONS = new Set([
38
38
  ".toml",
39
39
  ".tf",
40
40
  ".sh",
41
+ ".bash",
42
+ ".zsh",
43
+ ".ksh",
41
44
  ".env",
42
45
  ".properties",
43
- ".xml"
46
+ ".xml",
47
+ ".cfg",
48
+ ".conf"
44
49
  ]);
45
50
 
46
51
  const LOCKFILE_NAMES = new Set([
@@ -59,6 +64,18 @@ const ENV_LIKE_NAMES = new Set([
59
64
  ".env.test"
60
65
  ]);
61
66
 
67
+ const SPECIAL_SINGLE_FILE_NAMES = new Set([
68
+ "Dockerfile",
69
+ "docker-compose.yml",
70
+ "docker-compose.yaml",
71
+ "CMakeLists.txt",
72
+ "Makefile",
73
+ "swagger.yaml",
74
+ "swagger.yml",
75
+ "swagger.json",
76
+ "ansible.cfg"
77
+ ]);
78
+
62
79
  const ENV_ALLOWED_RULES = new Set([
63
80
  "hardcoded-secret",
64
81
  "aws-access-key",
@@ -152,15 +169,38 @@ async function scanFileAtPath(filePath, options = {}) {
152
169
  maxFiles: options.maxFiles || 400
153
170
  });
154
171
 
155
- if (!shouldAnalyzeFile(filePath)) {
172
+ if (!shouldAnalyzeSingleFile(filePath)) {
156
173
  return [];
157
174
  }
158
175
 
159
- return dedupeFindings(scanContent(filePath, content, workspaceRoot, {
176
+ const ext = path.extname(filePath).toLowerCase();
177
+ const relativePath = path.relative(workspaceRoot, filePath).split(path.sep).join("/");
178
+ const fileRecord = {
179
+ fsPath: filePath,
180
+ relativePath,
181
+ baseName: path.basename(filePath),
182
+ ext,
183
+ content
184
+ };
185
+
186
+ const contentFindings = scanContent(filePath, content, workspaceRoot, {
160
187
  languages: workspaceProfile.languages,
161
188
  frameworks: workspaceProfile.frameworks,
162
189
  isSecureReviewWorkspace: workspaceProfile.isSecureReviewWorkspace
163
- }));
190
+ });
191
+ const repoFindings = analyzeRepository([fileRecord], workspaceRoot);
192
+
193
+ return dedupeFindings([...contentFindings, ...repoFindings]);
194
+ }
195
+
196
+ function shouldAnalyzeSingleFile(filePath) {
197
+ const baseName = path.basename(filePath);
198
+ if (LOCKFILE_NAMES.has(baseName) || ENV_LIKE_NAMES.has(baseName) || SPECIAL_SINGLE_FILE_NAMES.has(baseName)) {
199
+ return true;
200
+ }
201
+
202
+ const ext = path.extname(filePath).toLowerCase();
203
+ return ALLOWED_EXTENSIONS.has(ext);
164
204
  }
165
205
 
166
206
  function scanProfile(workspaceProfile, config) {
@@ -85,6 +85,18 @@ function createScannerRegistry(config, workspaceProfile) {
85
85
  applies: () => hasLanguage(workspaceProfile, ["c", "cpp"]),
86
86
  run: () => runCppcheck(workspaceProfile, config)
87
87
  },
88
+ {
89
+ id: "shellcheck",
90
+ enabled: config.get("enableShellcheck", true),
91
+ applies: () => hasLanguage(workspaceProfile, ["shell"]),
92
+ run: () => runShellcheck(workspaceProfile, config)
93
+ },
94
+ {
95
+ id: "ansible-lint",
96
+ enabled: config.get("enableAnsibleLint", true),
97
+ applies: () => hasFramework(workspaceProfile, ["ansible"]),
98
+ run: () => runAnsibleLint(workspaceProfile, config)
99
+ },
88
100
  {
89
101
  id: "dotnet-vuln",
90
102
  enabled: config.get("enableDotnetVuln", true),
@@ -715,6 +727,96 @@ async function runCppcheck(workspaceProfile, config) {
715
727
  });
716
728
  }
717
729
 
730
+ async function runShellcheck(workspaceProfile, config) {
731
+ const shellFiles = workspaceProfile.files.filter((file) =>
732
+ [".sh", ".bash", ".zsh", ".ksh"].includes(file.ext)
733
+ || looksLikeShellScript(file)
734
+ );
735
+
736
+ if (!shellFiles.length) {
737
+ return [];
738
+ }
739
+
740
+ const args = ["--format=json1", ...shellFiles.map((file) => file.relativePath)];
741
+ const result = await execFileAsync("shellcheck", args, scannerOptions(workspaceProfile, config));
742
+
743
+ if (result.error && !result.stdout) {
744
+ throw result.error;
745
+ }
746
+
747
+ const payload = parseJson(result.stdout);
748
+ const comments = payload.comments || [];
749
+ return comments.map((item) => normalizeStaticFinding({
750
+ workspaceRoot: workspaceProfile.workspaceRoot,
751
+ tool: "shellcheck",
752
+ title: item.message || `ShellCheck ${item.code || "finding"}`,
753
+ severity: mapShellcheckLevel(item.level),
754
+ confidence: "medium",
755
+ category: mapShellcheckCategory(item.code),
756
+ subcategory: "ShellCheck",
757
+ reviewDomain: mapShellcheckReviewDomain(item.code),
758
+ relativePath: relativize(workspaceProfile.workspaceRoot, item.file),
759
+ line: item.line || 1,
760
+ column: item.column || 1,
761
+ code: item.code ? `SC${item.code}` : "shellcheck",
762
+ message: item.message || "ShellCheck finding",
763
+ evidence: item.fix?.replacements?.[0]?.replacement || "",
764
+ remediation: "Review the shell script for quoting, expansion, and command-safety issues and apply the ShellCheck guidance.",
765
+ suggestion: "Treat shell script findings in automation and deployment paths as production-impacting until reviewed.",
766
+ whyItMatters: "Shell scripts are especially sensitive to quoting, expansion, and command-construction mistakes that can become security or reliability issues.",
767
+ standards: ["ShellCheck"]
768
+ }));
769
+ }
770
+
771
+ async function runAnsibleLint(workspaceProfile, config) {
772
+ const targetFiles = workspaceProfile.files
773
+ .filter((file) => isLikelyAnsibleFile(file))
774
+ .map((file) => file.relativePath);
775
+
776
+ if (!targetFiles.length) {
777
+ return [];
778
+ }
779
+
780
+ const result = await execFileAsync("ansible-lint", ["-f", "json", ...targetFiles], scannerOptions(workspaceProfile, config));
781
+
782
+ if (result.error && !result.stdout) {
783
+ throw result.error;
784
+ }
785
+
786
+ const payload = parseJson(result.stdout);
787
+ const items = Array.isArray(payload) ? payload : payload.issues || payload.results || [];
788
+
789
+ return items.map((item) => {
790
+ const location = item.location || {};
791
+ const begin = location.positions?.begin || {};
792
+ const rule = item.rule || {};
793
+ const ruleId = item.rule?.id || item.check_name || item.tag || "ansible-lint";
794
+ const severity = mapAnsibleLintSeverity(item.severity || item.level || rule.severity || ruleId);
795
+ const message = item.description || item.message || item.name || rule.shortdesc || rule.description || ruleId;
796
+
797
+ return normalizeStaticFinding({
798
+ workspaceRoot: workspaceProfile.workspaceRoot,
799
+ tool: "ansible-lint",
800
+ title: message,
801
+ severity,
802
+ confidence: severity === "high" ? "high" : "medium",
803
+ category: "Configuration",
804
+ subcategory: "Ansible",
805
+ reviewDomain: /(security|risky|yaml|command-shell|latest|no-changed-when|package-latest|risky-file-permissions)/i.test(ruleId) ? "security" : "code-quality",
806
+ relativePath: relativize(workspaceProfile.workspaceRoot, location.path || item.path || item.filename || ""),
807
+ line: begin.line || item.line || 1,
808
+ column: begin.column || item.column || 1,
809
+ code: ruleId,
810
+ message,
811
+ evidence: item.details || item.url || rule.description || "",
812
+ remediation: "Review the playbook task and replace the risky or non-idiomatic pattern with an Ansible-safe equivalent.",
813
+ suggestion: "Prefer explicit modules, validated downloads, secure permissions, and idempotent task design in playbooks.",
814
+ whyItMatters: "Ansible automation can introduce security and operational risk when shell execution, permissions, downloads, or error handling are unsafe.",
815
+ standards: ["ansible-lint"]
816
+ });
817
+ });
818
+ }
819
+
718
820
  async function runDotnetVulnerabilityCheck(workspaceProfile, config) {
719
821
  const target = findExistingPath(workspaceProfile.workspaceRoot, listDotnetScanCandidates(workspaceProfile.workspaceRoot));
720
822
  if (!target) {
@@ -838,6 +940,14 @@ function deriveScannerFindingQuality(input) {
838
940
  };
839
941
  }
840
942
 
943
+ if (["shellcheck", "ansible-lint"].includes(tool)) {
944
+ return {
945
+ findingType: reviewDomain === "security" ? "contextual-warning" : "recommendation",
946
+ evidenceStrength: "medium",
947
+ manualReviewRecommended: true
948
+ };
949
+ }
950
+
841
951
  return {
842
952
  findingType: reviewDomain === "dependency-risk" ? "dependency-risk" : "recommendation",
843
953
  evidenceStrength: "medium",
@@ -972,6 +1082,71 @@ function mapBrakemanConfidence(value) {
972
1082
  return "low";
973
1083
  }
974
1084
 
1085
+ function mapShellcheckLevel(value) {
1086
+ const normalized = String(value || "").toLowerCase();
1087
+ if (normalized === "error") {
1088
+ return "high";
1089
+ }
1090
+ if (normalized === "warning") {
1091
+ return "medium";
1092
+ }
1093
+ return "low";
1094
+ }
1095
+
1096
+ function mapShellcheckReviewDomain(code) {
1097
+ const numeric = Number(code || 0);
1098
+ if ([2086, 2046, 2090, 2091, 2164, 2155, 2115].includes(numeric)) {
1099
+ return "security";
1100
+ }
1101
+ return "reliability";
1102
+ }
1103
+
1104
+ function mapShellcheckCategory(code) {
1105
+ return mapShellcheckReviewDomain(code) === "security" ? "Security" : "Reliability";
1106
+ }
1107
+
1108
+ function mapAnsibleLintSeverity(value) {
1109
+ const normalized = String(value || "").toLowerCase();
1110
+ if (/(error|high|critical|risky|syntax-check)/.test(normalized)) {
1111
+ return "high";
1112
+ }
1113
+ if (/(warn|medium|major)/.test(normalized)) {
1114
+ return "medium";
1115
+ }
1116
+ return "low";
1117
+ }
1118
+
1119
+ function looksLikeShellScript(file) {
1120
+ if (!file || !file.content) {
1121
+ return false;
1122
+ }
1123
+
1124
+ const baseName = path.basename(file.relativePath || file.fsPath || "");
1125
+ return !path.extname(baseName) && /^#!.*\b(bash|sh|zsh|ksh)\b/m.test(file.content);
1126
+ }
1127
+
1128
+ function isLikelyAnsibleFile(file) {
1129
+ if (!file || ![".yml", ".yaml"].includes(file.ext)) {
1130
+ return false;
1131
+ }
1132
+
1133
+ const normalizedPath = String(file.relativePath || "").toLowerCase();
1134
+ if (/(^|\/)(playbooks?|roles|tasks|handlers|group_vars|host_vars)\//.test(normalizedPath)) {
1135
+ return true;
1136
+ }
1137
+
1138
+ if (/(^|\/)(site|playbook|deploy|provision)\.ya?ml$/.test(normalizedPath)) {
1139
+ return true;
1140
+ }
1141
+
1142
+ const lower = String(file.content || "").toLowerCase();
1143
+ return (
1144
+ /(^|\n)\s*hosts\s*:\s*.+/m.test(lower)
1145
+ && /(^|\n)\s*(tasks|handlers)\s*:/m.test(lower)
1146
+ ) || /ansible\.builtin\./.test(lower)
1147
+ || /(^|\n)\s*become\s*:\s*(true|yes)\b/m.test(lower);
1148
+ }
1149
+
975
1150
  module.exports = {
976
1151
  runRegisteredScanners,
977
1152
  createScannerRegistry
@@ -36,7 +36,12 @@ const LANGUAGE_BY_EXTENSION = new Map([
36
36
  [".xml", "config"],
37
37
  [".tf", "config"],
38
38
  [".properties", "config"],
39
- [".sh", "shell"]
39
+ [".sh", "shell"],
40
+ [".bash", "shell"],
41
+ [".zsh", "shell"],
42
+ [".ksh", "shell"],
43
+ [".cfg", "config"],
44
+ [".conf", "config"]
40
45
  ]);
41
46
 
42
47
  const SPECIAL_FILENAMES = new Set([
@@ -63,7 +68,8 @@ const SPECIAL_FILENAMES = new Set([
63
68
  "Gemfile",
64
69
  "CMakeLists.txt",
65
70
  "Makefile",
66
- "global.json"
71
+ "global.json",
72
+ "ansible.cfg"
67
73
  ]);
68
74
 
69
75
  const DEFAULT_EXCLUDE_GLOBS = [
@@ -303,6 +309,9 @@ function detectFrameworks(files) {
303
309
 
304
310
  for (const file of files) {
305
311
  const lower = file.content.toLowerCase();
312
+ if (file.baseName === "ansible.cfg") {
313
+ frameworks.add("ansible");
314
+ }
306
315
  if (file.baseName === "requirements.txt" || file.baseName === "pyproject.toml") {
307
316
  addIfText(frameworks, lower, "django", "django");
308
317
  addIfText(frameworks, lower, "flask", "flask");
@@ -322,6 +331,9 @@ function detectFrameworks(files) {
322
331
  addIfText(frameworks, lower, "rocket", "rocket");
323
332
  addIfText(frameworks, lower, "axum", "axum");
324
333
  }
334
+ if (looksLikeAnsibleFile(file)) {
335
+ frameworks.add("ansible");
336
+ }
325
337
  }
326
338
 
327
339
  return frameworks;
@@ -384,6 +396,28 @@ function addIfText(frameworks, haystack, needle, frameworkName) {
384
396
  }
385
397
  }
386
398
 
399
+ function looksLikeAnsibleFile(file) {
400
+ if (![".yml", ".yaml"].includes(file.ext)) {
401
+ return false;
402
+ }
403
+
404
+ const normalizedPath = file.relativePath.toLowerCase();
405
+ if (/(^|\/)(playbooks?|roles|tasks|handlers|group_vars|host_vars)\//.test(normalizedPath)) {
406
+ return true;
407
+ }
408
+
409
+ if (/\/(site|playbook|deploy|provision)\.ya?ml$/.test(normalizedPath) || /(^|\/)(site|playbook|deploy|provision)\.ya?ml$/.test(normalizedPath)) {
410
+ return true;
411
+ }
412
+
413
+ const lower = file.content.toLowerCase();
414
+ return (
415
+ /(^|\n)\s*hosts\s*:\s*.+/m.test(lower)
416
+ && /(^|\n)\s*(tasks|handlers)\s*:/m.test(lower)
417
+ ) || /ansible\.builtin\./.test(lower)
418
+ || /(^|\n)\s*become\s*:\s*(true|yes)\b/m.test(lower);
419
+ }
420
+
387
421
  async function readText(filePath) {
388
422
  try {
389
423
  return await fs.readFile(filePath, "utf8");