src/Analyzer.Cli/CommandLineParser.cs (319 lines of code) (raw):
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.CommandLine.Invocation;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.Azure.Templates.Analyzer.Core;
using Microsoft.Azure.Templates.Analyzer.Reports;
using Microsoft.Azure.Templates.Analyzer.Types;
using Microsoft.Azure.Templates.Analyzer.Utilities;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Microsoft.Azure.Templates.Analyzer.Cli
{
/// <summary>
/// Creates the command line for running Template Analyzer.
/// Instantiates arguments that can be passed and different commands that can be invoked.
/// </summary>
internal class CommandLineParser
{
private const string DefaultConfigFileName = "configuration.json";
private RootCommand rootCommand;
private TemplateAnalyzer templateAnalyzer;
private IReportWriter reportWriter;
private ILogger logger;
private SummaryLogger summaryLogger;
/// <summary>
/// Constructor for the command line parser. Sets up the command line API.
/// </summary>
public CommandLineParser()
{
SetupCommandLineAPI();
}
/// <summary>
/// Invoke the command line API using the provided arguments.
/// </summary>
/// <param name="args">Arguments sent in via the command line</param>
/// <returns>A Task that executes the command handler</returns>
public async Task<int> InvokeCommandLineAPIAsync(string[] args)
{
return await rootCommand.InvokeAsync(args).ConfigureAwait(false);
}
private void SetupCommandLineAPI()
{
// Command line API is setup using https://github.com/dotnet/command-line-api
rootCommand = new();
rootCommand.Description = "Analyze Azure Resource Manager (ARM) and Bicep Templates for security and best practice issues.";
// Setup analyze-template and analyze-directory commands
List<Command> allCommands = new()
{
SetupAnalyzeTemplateCommand(),
SetupAnalyzeDirectoryCommand()
};
// Add all commands to root command
allCommands.ForEach(rootCommand.AddCommand);
// Setup options that apply to all commands
SetupCommonOptionsForCommands(allCommands);
}
private Command SetupAnalyzeTemplateCommand()
{
Command analyzeTemplateCommand = new Command(
"analyze-template",
"Analyze a single template");
analyzeTemplateCommand.AddArgument(
new Argument<FileInfo>(
"template-file-path",
"The path of the template to analyze"));
analyzeTemplateCommand.AddOption(
new Option<FileInfo>(
new[] { "-p", "--parameters-file-path" },
"The parameter file to use when parsing the specified template")
);
// Assign handler method
analyzeTemplateCommand.Handler = CommandHandler.Create(
GetType().GetMethod(
nameof(AnalyzeTemplateCommandHandler),
BindingFlags.Instance | BindingFlags.NonPublic),
this);
return analyzeTemplateCommand;
}
private Command SetupAnalyzeDirectoryCommand()
{
Command analyzeDirectoryCommand = new Command(
"analyze-directory",
"Analyze all templates within a directory");
analyzeDirectoryCommand.AddArgument(
new Argument<DirectoryInfo>(
"directory-path",
"The directory to find templates"));
// Assign handler method
analyzeDirectoryCommand.Handler = CommandHandler.Create(
GetType().GetMethod(
nameof(AnalyzeDirectoryCommandHandler),
BindingFlags.Instance | BindingFlags.NonPublic),
this);
return analyzeDirectoryCommand;
}
private void SetupCommonOptionsForCommands(List<Command> commands)
{
List<Option> options = new()
{
new Option<FileInfo>(
new[] { "-c", "--config-file-path" },
"The configuration file to use when parsing the specified template"),
new Option<ReportFormat>(
"--report-format",
"Format of report to be generated"),
new Option<FileInfo>(
new[] { "-o", "--output-file-path" },
$"The report file path (required for --report-format {ReportFormat.Sarif})"),
new Option(
new[] { "-v", "--verbose" },
"Shows details about the analysis"),
new Option(
"--include-non-security-rules",
"Run all the rules against the templates, including non-security rules"),
new Option<FileInfo>(
"--custom-json-rules-path",
"The JSON rules file to use against the templates. If not specified, will use the default rule set that is shipped with the tool.")
};
commands.ForEach(c => options.ForEach(c.AddOption));
}
// Note: argument names must match command arguments/options (without "-" characters)
private int AnalyzeTemplateCommandHandler(
FileInfo templateFilePath,
FileInfo parametersFilePath,
FileInfo configFilePath,
ReportFormat reportFormat,
FileInfo outputFilePath,
bool includeNonSecurityRules,
bool verbose,
FileInfo customJsonRulesPath)
{
// Check that template file paths exist
if (!templateFilePath.Exists)
{
Console.Error.WriteLine("Invalid template file path: {0}", templateFilePath);
return (int)ExitCode.ErrorInvalidPath;
}
var setupResult = SetupAnalysis(configFilePath, directoryToAnalyze: null, reportFormat, outputFilePath, includeNonSecurityRules, verbose, customJsonRulesPath);
if (setupResult != ExitCode.Success)
{
return (int)setupResult;
}
// Verify the file is a valid template
if (!TemplateDiscovery.IsValidTemplate(templateFilePath))
{
logger.LogError("File is not a valid ARM Template. File path: {templateFilePath}", templateFilePath.FullName);
FinishAnalysis();
return (int)ExitCode.ErrorInvalidARMTemplate;
}
IEnumerable<TemplateAndParams> pairsToAnalyze =
parametersFilePath != null
? new[] { new TemplateAndParams(templateFilePath, parametersFilePath) }
: TemplateDiscovery.FindParameterFilesForTemplate(templateFilePath);
var exitCodes = new List<ExitCode>();
foreach (var templateAndParameters in pairsToAnalyze)
{
exitCodes.Add(AnalyzeTemplate(templateAndParameters));
}
FinishAnalysis();
return (int)AnalyzeExitCodes(exitCodes);
}
// Note: argument names must match command arguments/options (without "-" characters)
private int AnalyzeDirectoryCommandHandler(
DirectoryInfo directoryPath,
FileInfo configFilePath,
ReportFormat reportFormat,
FileInfo outputFilePath,
bool includeNonSecurityRules,
bool verbose,
FileInfo customJsonRulesPath)
{
if (!directoryPath.Exists)
{
Console.Error.WriteLine("Invalid directory: {0}", directoryPath);
return (int)ExitCode.ErrorInvalidPath;
}
var setupResult = SetupAnalysis(configFilePath, directoryPath, reportFormat, outputFilePath, includeNonSecurityRules, verbose, customJsonRulesPath);
if (setupResult != ExitCode.Success)
{
return (int)setupResult;
}
// Find files to analyze
var filesToAnalyze = TemplateDiscovery.DiscoverTemplatesAndParametersInDirectory(directoryPath, logger);
// Log root directory info to be analyzed
Console.WriteLine($"{Environment.NewLine}{Environment.NewLine}Directory: {directoryPath}");
var exitCodes = new List<ExitCode>();
foreach (var templateAndParameters in filesToAnalyze)
{
exitCodes.Add(AnalyzeTemplate(templateAndParameters));
}
int numOfFilesAnalyzed = exitCodes.Where(x => x == ExitCode.Success || x == ExitCode.Violation).Count();
Console.WriteLine($"{Environment.NewLine}Analyzed {numOfFilesAnalyzed} {(numOfFilesAnalyzed == 1 ? "file" : "files")} in the directory specified.");
var exitCode = AnalyzeExitCodes(exitCodes);
FinishAnalysis();
return (int)exitCode;
}
private ExitCode AnalyzeTemplate(TemplateAndParams templateAndParameters)
{
try
{
(string template, string parameters) = TemplateDiscovery.GetTemplateAndParameterContents(templateAndParameters);
IEnumerable<IEvaluation> evaluations = this.templateAnalyzer.AnalyzeTemplate(template, templateAndParameters.Template.FullName, parameters);
this.reportWriter.WriteResults(evaluations, (FileInfoBase)templateAndParameters.Template, (FileInfoBase)templateAndParameters.ParametersFile);
return evaluations.Any(e => !e.Passed) ? ExitCode.Violation : ExitCode.Success;
}
catch (Exception exception)
{
// Keeping separate LogError calls so formatting can use the recommended templating.
// https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2254
if (templateAndParameters.ParametersFile != null)
{
logger.LogError(exception, "An exception occurred while analyzing template {TemplatePath} with parameters file {ParametersPath}",
templateAndParameters.Template.FullName, templateAndParameters.ParametersFile.FullName);
}
else
{
logger.LogError(exception, "An exception occurred while analyzing template {TemplatePath}", templateAndParameters.Template.FullName);
}
return (exception.Message == TemplateAnalyzer.BicepCompileErrorMessage)
? ExitCode.ErrorInvalidBicepTemplate
: ExitCode.ErrorAnalysis;
}
}
private ExitCode SetupAnalysis(
FileInfo configurationFile,
DirectoryInfo directoryToAnalyze,
ReportFormat reportFormat,
FileInfo outputFilePath,
bool includeNonSecurityRules,
bool verbose,
FileInfo customJsonRulesPath)
{
// Output file path must be specified if SARIF was chosen as the report format
if (reportFormat == ReportFormat.Sarif && outputFilePath == null)
{
Console.Error.WriteLine("When using --report-format sarif flag, --output-file-path flag is required.");
return ExitCode.ErrorMissingPath;
}
this.reportWriter = GetReportWriter(reportFormat, outputFilePath, directoryToAnalyze?.FullName);
CreateLoggers(verbose);
this.templateAnalyzer = TemplateAnalyzer.Create(includeNonSecurityRules, this.logger, customJsonRulesPath);
if (!TryReadConfigurationFile(configurationFile, out var config))
{
return ExitCode.ErrorInvalidConfiguration;
}
// Success from TryReadConfigurationFile means there wasn't an error looking for the config.
// config could still be null if no path was specified in the command and no default exists.
if (config != null)
{
this.templateAnalyzer.FilterRules(config);
}
return ExitCode.Success;
}
private void FinishAnalysis()
{
this.summaryLogger.SummarizeLogs();
this.reportWriter?.Dispose();
}
private static IReportWriter GetReportWriter(ReportFormat reportFormat, FileInfo outputFile, string rootFolder = null) =>
reportFormat switch
{
ReportFormat.Sarif => new SarifReportWriter((FileInfoBase)outputFile, rootFolder),
_ => new ConsoleReportWriter()
};
private void CreateLoggers(bool verbose)
{
this.summaryLogger = new SummaryLogger(verbose);
using var loggerFactory = LoggerFactory.Create(builder =>
{
builder
.SetMinimumLevel(verbose ? LogLevel.Debug : LogLevel.Information)
.AddConsole(options =>
{
options.FormatterName = "ConsoleLoggerFormatter";
})
.AddProvider(new SummaryLoggerProvider(summaryLogger))
.AddConsoleFormatter<ConsoleLoggerFormatter, ConsoleLoggerFormatterOptions>(options => options.Verbose = verbose);
});
if (this.reportWriter is SarifReportWriter sarifWriter)
{
loggerFactory.AddProvider(new SarifNotificationLoggerProvider(sarifWriter.SarifLogger));
}
this.logger = loggerFactory.CreateLogger("TemplateAnalyzerCli");
}
/// <summary>
/// Reads a configuration file from disk. If no file was passed, checks the default directory for this file.
/// </summary>
/// <returns>True if:
/// - the specified configuration file was read successfully,
/// - no config was specified and the default file doesn't exist,
/// - the default file exists and was read successfully.
/// False otherwise.</returns>
private bool TryReadConfigurationFile(FileInfo configurationFile, out ConfigurationDefinition config)
{
config = null;
string configFilePath;
if (configurationFile != null)
{
if (!configurationFile.Exists)
{
// If a config file was specified in the command but doesn't exist, it's an error.
this.logger.LogError("Configuration file does not exist.");
return false;
}
configFilePath = configurationFile.FullName;
}
else
{
// Look for a config at the default location.
// It's not required to exist, so if it doesn't, just return early.
configFilePath = Path.Combine(AppContext.BaseDirectory, DefaultConfigFileName);
if (!File.Exists(configFilePath))
return true;
}
// At this point, an existing config file was found.
// If there are any problems reading it, it's an error.
this.logger.LogInformation($"Configuration File: {configFilePath}");
string configContents;
try
{
configContents = File.ReadAllText(configFilePath);
}
catch (Exception e)
{
this.logger.LogError(e, "Unable to read configuration file.");
return false;
}
if (string.IsNullOrWhiteSpace(configContents))
{
this.logger.LogError("Configuration is empty.");
return false;
}
try
{
config = JsonConvert.DeserializeObject<ConfigurationDefinition>(configContents);
return true;
}
catch (Exception e)
{
this.logger.LogError(e, "Failed to parse configuration file.");
return false;
}
}
private ExitCode AnalyzeExitCodes(List<ExitCode> exitCodes)
{
if (exitCodes.Count == 1)
return exitCodes[0];
bool issueReported = exitCodes.Any(x => x == ExitCode.Violation);
bool filesFailed = exitCodes.Any(x => x == ExitCode.ErrorAnalysis || x == ExitCode.ErrorInvalidBicepTemplate);
return filesFailed
? issueReported ? ExitCode.ErrorAndViolation : ExitCode.ErrorAnalysis
: issueReported ? ExitCode.Violation : ExitCode.Success;
}
}
}