444 lines
18 KiB
Go
444 lines
18 KiB
Go
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"path"
|
||
"reflect"
|
||
"strconv"
|
||
"time"
|
||
|
||
"srs.epita.fr/fic-server/admin/generation"
|
||
"srs.epita.fr/fic-server/admin/sync"
|
||
"srs.epita.fr/fic-server/libfic"
|
||
"srs.epita.fr/fic-server/settings"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
var IsProductionEnv = false
|
||
|
||
func declareSettingsRoutes(router *gin.RouterGroup) {
|
||
router.GET("/challenge.json", getChallengeInfo)
|
||
router.PUT("/challenge.json", saveChallengeInfo)
|
||
|
||
router.GET("/settings-ro.json", getROSettings)
|
||
router.GET("/settings.json", getSettings)
|
||
router.PUT("/settings.json", saveSettings)
|
||
router.DELETE("/settings.json", func(c *gin.Context) {
|
||
err := ResetSettings()
|
||
if err != nil {
|
||
log.Println("Unable to ResetSettings:", err.Error())
|
||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during setting reset."})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, true)
|
||
})
|
||
|
||
router.GET("/settings-next", listNextSettings)
|
||
|
||
apiNextSettingsRoutes := router.Group("/settings-next/:ts")
|
||
apiNextSettingsRoutes.Use(NextSettingsHandler)
|
||
apiNextSettingsRoutes.GET("", getNextSettings)
|
||
apiNextSettingsRoutes.DELETE("", deleteNextSettings)
|
||
|
||
router.POST("/reset", reset)
|
||
router.POST("/full-generation", fullGeneration)
|
||
|
||
router.GET("/prod", func(c *gin.Context) {
|
||
c.JSON(http.StatusOK, IsProductionEnv)
|
||
})
|
||
router.PUT("/prod", func(c *gin.Context) {
|
||
err := c.ShouldBindJSON(&IsProductionEnv)
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, IsProductionEnv)
|
||
})
|
||
}
|
||
|
||
func NextSettingsHandler(c *gin.Context) {
|
||
ts, err := strconv.ParseInt(string(c.Params.ByName("ts")), 10, 64)
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid next settings identifier"})
|
||
return
|
||
}
|
||
|
||
nsf, err := settings.ReadNextSettingsFile(path.Join(settings.SettingsDir, fmt.Sprintf("%d.json", ts)), ts)
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Next settings not found"})
|
||
return
|
||
}
|
||
|
||
c.Set("next-settings", nsf)
|
||
|
||
c.Next()
|
||
}
|
||
|
||
func fullGeneration(c *gin.Context) {
|
||
resp, err := generation.FullGeneration()
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
|
||
"errmsg": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
v, _ := io.ReadAll(resp.Body)
|
||
c.JSON(resp.StatusCode, gin.H{
|
||
"errmsg": string(v),
|
||
})
|
||
}
|
||
|
||
func getROSettings(c *gin.Context) {
|
||
syncMtd := "Disabled"
|
||
if sync.GlobalImporter != nil {
|
||
syncMtd = sync.GlobalImporter.Kind()
|
||
}
|
||
|
||
var syncId *string
|
||
if sync.GlobalImporter != nil {
|
||
syncId = sync.GlobalImporter.Id()
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"sync-type": reflect.TypeOf(sync.GlobalImporter).Name(),
|
||
"sync-id": syncId,
|
||
"sync": syncMtd,
|
||
})
|
||
}
|
||
|
||
func GetChallengeInfo() (*settings.ChallengeInfo, error) {
|
||
var challengeinfo string
|
||
var err error
|
||
if sync.GlobalImporter == nil {
|
||
if fd, err := os.Open(path.Join(settings.SettingsDir, settings.ChallengeFile)); err == nil {
|
||
defer fd.Close()
|
||
var buf []byte
|
||
buf, err = io.ReadAll(fd)
|
||
if err == nil {
|
||
challengeinfo = string(buf)
|
||
}
|
||
}
|
||
} else {
|
||
challengeinfo, err = sync.GetFileContent(sync.GlobalImporter, settings.ChallengeFile)
|
||
}
|
||
|
||
if err != nil {
|
||
log.Println("Unable to retrieve challenge.json:", err.Error())
|
||
return nil, fmt.Errorf("Unable to retrive challenge.json: %w", err)
|
||
}
|
||
|
||
s, err := settings.ReadChallengeInfo(challengeinfo)
|
||
if err != nil {
|
||
log.Println("Unable to ReadChallengeInfo:", err.Error())
|
||
return nil, fmt.Errorf("Unable to read challenge info: %w", err)
|
||
}
|
||
|
||
return s, nil
|
||
}
|
||
|
||
func getChallengeInfo(c *gin.Context) {
|
||
if s, err := GetChallengeInfo(); err != nil {
|
||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||
} else {
|
||
c.JSON(http.StatusOK, s)
|
||
}
|
||
}
|
||
|
||
func saveChallengeInfo(c *gin.Context) {
|
||
var info *settings.ChallengeInfo
|
||
err := c.ShouldBindJSON(&info)
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||
return
|
||
}
|
||
|
||
if sync.GlobalImporter != nil {
|
||
jenc, err := json.Marshal(info)
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||
return
|
||
}
|
||
|
||
err = sync.WriteFileContent(sync.GlobalImporter, "challenge.json", jenc)
|
||
if err != nil {
|
||
log.Println("Unable to SaveChallengeInfo:", err.Error())
|
||
// Ignore the error, try to continue
|
||
}
|
||
|
||
err = sync.ImportChallengeInfo(info, DashboardDir)
|
||
if err != nil {
|
||
log.Println("Unable to ImportChallengeInfo:", err.Error())
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Something goes wrong when trying to import related files: %s", err.Error())})
|
||
return
|
||
}
|
||
}
|
||
|
||
if err := settings.SaveChallengeInfo(path.Join(settings.SettingsDir, settings.ChallengeFile), info); err != nil {
|
||
log.Println("Unable to SaveChallengeInfo:", err.Error())
|
||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to save distributed challenge info: %s", err.Error())})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, info)
|
||
}
|
||
|
||
func getSettings(c *gin.Context) {
|
||
s, err := settings.ReadSettings(path.Join(settings.SettingsDir, settings.SettingsFile))
|
||
if err != nil {
|
||
log.Println("Unable to ReadSettings:", err.Error())
|
||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to read settings: %s", err.Error())})
|
||
return
|
||
}
|
||
|
||
s.WorkInProgress = !IsProductionEnv
|
||
c.Writer.Header().Add("X-FIC-Time", fmt.Sprintf("%d", time.Now().Unix()))
|
||
c.JSON(http.StatusOK, s)
|
||
}
|
||
|
||
func saveSettings(c *gin.Context) {
|
||
var config *settings.Settings
|
||
err := c.ShouldBindJSON(&config)
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||
return
|
||
}
|
||
|
||
// Is this a future setting?
|
||
if c.Request.URL.Query().Has("t") {
|
||
t, err := time.Parse(time.RFC3339, c.Request.URL.Query().Get("t"))
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||
return
|
||
}
|
||
|
||
// Load current settings to perform diff later
|
||
init_settings, err := settings.ReadSettings(path.Join(settings.SettingsDir, settings.SettingsFile))
|
||
if err != nil {
|
||
log.Println("Unable to ReadSettings:", err.Error())
|
||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to read settings: %s", err.Error())})
|
||
return
|
||
}
|
||
|
||
current_settings := init_settings
|
||
// Apply already registered settings
|
||
nsu, err := settings.MergeNextSettingsUntil(&t)
|
||
if err == nil {
|
||
current_settings = settings.MergeSettings(*init_settings, nsu)
|
||
} else {
|
||
log.Println("Unable to MergeNextSettingsUntil:", err.Error())
|
||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to merge next settings: %s", err.Error())})
|
||
return
|
||
}
|
||
|
||
// Keep only diff
|
||
diff := settings.DiffSettings(current_settings, config)
|
||
|
||
hasItems := false
|
||
for _, _ = range diff {
|
||
hasItems = true
|
||
break
|
||
}
|
||
|
||
if !hasItems {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "No difference to apply."})
|
||
return
|
||
}
|
||
|
||
if !c.Request.URL.Query().Has("erase") {
|
||
// Check if there is already diff to apply at the given time
|
||
if nsf, err := settings.ReadNextSettingsFile(path.Join(settings.SettingsDir, fmt.Sprintf("%d.json", t.Unix())), t.Unix()); err == nil {
|
||
for k, v := range nsf.Values {
|
||
if _, ok := diff[k]; !ok {
|
||
diff[k] = v
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Save the diff
|
||
settings.SaveSettings(path.Join(settings.SettingsDir, fmt.Sprintf("%d.json", t.Unix())), diff)
|
||
|
||
// Return current settings
|
||
c.JSON(http.StatusOK, current_settings)
|
||
} else {
|
||
// Just apply settings right now!
|
||
if err := settings.SaveSettings(path.Join(settings.SettingsDir, settings.SettingsFile), config); err != nil {
|
||
log.Println("Unable to SaveSettings:", err.Error())
|
||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to save settings: %s", err.Error())})
|
||
return
|
||
}
|
||
|
||
ApplySettings(config)
|
||
c.JSON(http.StatusOK, config)
|
||
}
|
||
}
|
||
|
||
func listNextSettings(c *gin.Context) {
|
||
nsf, err := settings.ListNextSettingsFiles()
|
||
if err != nil {
|
||
log.Println("Unable to ListNextSettingsFiles:", err.Error())
|
||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to list next settings files: %s", err.Error())})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, nsf)
|
||
}
|
||
|
||
func getNextSettings(c *gin.Context) {
|
||
c.JSON(http.StatusOK, c.MustGet("next-settings").(*settings.NextSettingsFile))
|
||
}
|
||
|
||
func deleteNextSettings(c *gin.Context) {
|
||
nsf := c.MustGet("next-settings").(*settings.NextSettingsFile)
|
||
|
||
err := os.Remove(path.Join(settings.SettingsDir, fmt.Sprintf("%d.json", nsf.Id)))
|
||
if err != nil {
|
||
log.Println("Unable to remove the file:", err.Error())
|
||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to remove the file: %s", err.Error())})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, true)
|
||
}
|
||
|
||
func ApplySettings(config *settings.Settings) {
|
||
fic.PartialValidation = config.PartialValidation
|
||
fic.UnlockedChallengeDepth = config.UnlockedChallengeDepth
|
||
fic.UnlockedChallengeUpTo = config.UnlockedChallengeUpTo
|
||
fic.DisplayAllFlags = config.DisplayAllFlags
|
||
fic.HideCaseSensitivity = config.HideCaseSensitivity
|
||
fic.UnlockedStandaloneExercices = config.UnlockedStandaloneExercices
|
||
fic.UnlockedStandaloneExercicesByThemeStepValidation = config.UnlockedStandaloneExercicesByThemeStepValidation
|
||
fic.UnlockedStandaloneExercicesByStandaloneExerciceValidation = config.UnlockedStandaloneExercicesByStandaloneExerciceValidation
|
||
fic.DisplayMCQBadCount = config.DisplayMCQBadCount
|
||
fic.FirstBlood = config.FirstBlood
|
||
fic.SubmissionCostBase = config.SubmissionCostBase
|
||
fic.HintCoefficient = config.HintCurCoefficient
|
||
fic.WChoiceCoefficient = config.WChoiceCurCoefficient
|
||
fic.ExerciceCurrentCoefficient = config.ExerciceCurCoefficient
|
||
fic.GlobalScoreCoefficient = config.GlobalScoreCoefficient
|
||
fic.SubmissionCostBase = config.SubmissionCostBase
|
||
fic.SubmissionUniqueness = config.SubmissionUniqueness
|
||
fic.CountOnlyNotGoodTries = config.CountOnlyNotGoodTries
|
||
|
||
if config.DiscountedFactor != fic.DiscountedFactor {
|
||
fic.DiscountedFactor = config.DiscountedFactor
|
||
if err := fic.DBRecreateDiscountedView(); err != nil {
|
||
log.Println("Unable to recreate exercices_discounted view:", err.Error())
|
||
}
|
||
}
|
||
}
|
||
|
||
func ResetSettings() error {
|
||
return settings.SaveSettings(path.Join(settings.SettingsDir, settings.SettingsFile), &settings.Settings{
|
||
WorkInProgress: IsProductionEnv,
|
||
FirstBlood: fic.FirstBlood,
|
||
SubmissionCostBase: fic.SubmissionCostBase,
|
||
ExerciceCurCoefficient: 1,
|
||
HintCurCoefficient: 1,
|
||
WChoiceCurCoefficient: 1,
|
||
GlobalScoreCoefficient: 1,
|
||
DiscountedFactor: 0,
|
||
UnlockedStandaloneExercices: 10,
|
||
UnlockedStandaloneExercicesByThemeStepValidation: 1,
|
||
UnlockedStandaloneExercicesByStandaloneExerciceValidation: 0,
|
||
AllowRegistration: false,
|
||
CanJoinTeam: false,
|
||
DenyTeamCreation: false,
|
||
DenyNameChange: false,
|
||
AcceptNewIssue: true,
|
||
QAenabled: false,
|
||
EnableResolutionRoute: false,
|
||
PartialValidation: true,
|
||
UnlockedChallengeDepth: 0,
|
||
SubmissionUniqueness: true,
|
||
CountOnlyNotGoodTries: true,
|
||
DisplayAllFlags: false,
|
||
DisplayMCQBadCount: false,
|
||
EventKindness: false,
|
||
})
|
||
}
|
||
|
||
func ResetChallengeInfo() error {
|
||
return settings.SaveChallengeInfo(path.Join(settings.SettingsDir, settings.ChallengeFile), &settings.ChallengeInfo{
|
||
Title: "Challenge forensic",
|
||
SubTitle: "sous le patronage du commandement de la cyberdéfense",
|
||
Authors: "Laboratoire SRS, ÉPITA",
|
||
VideosLink: "",
|
||
Description: `<p>Le challenge <em>forensic</em> vous place dans la peau de <strong>spécialistes en investigation numérique</strong>. Nous mettons à votre disposition une <strong>vingtaine de scénarios différents</strong>, dans lesquels vous devrez faire les différentes étapes <strong>de la caractérisation d’une réponse à incident</strong> proposées.</p>
|
||
<p>Chaque scénario met en scène un contexte d’<strong>entreprise</strong>, ayant découvert récemment qu’elle a été <strong>victime d’une cyberattaque</strong>. Elle vous demande alors de l’aider à <strong>caractériser</strong>, afin de mieux comprendre <strong>la situation</strong>, notamment le <strong>mode opératoire de l’adversaire</strong>, les <strong>impacts</strong> de la cyberattaque, le <strong>périmètre technique compromis</strong>, etc. Il faudra parfois aussi l’éclairer sur les premières étapes de la réaction.</p>`,
|
||
Rules: `<h3>Déroulement</h3>
|
||
<p>Pendant toute la durée du challenge, vous aurez <strong>accès à tous les scénarios</strong>, mais seulement à la première des 5 étapes. <strong>Chaque étape</strong> supplémentaire <strong>est débloquée lorsque vous validez l’intégralité de l’étape précédente</strong>. Toutefois, pour dynamiser le challenge toutes les étapes et tous les scénarios seront débloquées pour la dernière heure du challenge.</p>
|
||
<p>Nous mettons à votre disposition une <strong>plateforme</strong> sur laquelle vous pourrez <strong>obtenir les informations sur le contexte</strong> de l’entreprise et, généralement, une <strong>série de fichiers</strong> qui semblent appropriés pour avancer dans l’investigation.</p>
|
||
<p>La <strong>validation d’une étape</strong> se fait sur la plateforme, après avoir analysé les informations fournies, en <strong>répondant à des questions</strong> plus ou moins précises. Il s’agit le plus souvent des <strong>mots-clefs</strong> que l’on placerait dans un <strong>rapport</strong>.</p>
|
||
<p>Pour vous débloquer ou accélérer votre investigation, vous pouvez accéder à quelques <strong><em>indices</em></strong>, en échange d’une décote sur votre score d’un certain nombre de points préalablement affichés.</p>
|
||
<h3>Calcul des points, bonus, malus et classement</h3>
|
||
<p>Chaque équipe dispose d’un <strong>compteur de points</strong> dans l’intervalle ]-∞;+∞[ (aux détails techniques près), à partir duquel <strong>le classement est établi</strong>.</p>
|
||
<p>Vous <strong>perdez des points</strong> en <strong>dévoilant des indices</strong>, en <strong>demandant des propositions de réponses</strong> en remplacement de certains champs de texte, ou en <strong>essayant un trop grand nombre de fois une réponse</strong>.</p>
|
||
<p>Le nombre de points que vous fait perdre un indice dépend habituellement de l’aide qu’il vous apportera et est indiqué avant de le dévoiler, car il peut fluctuer en fonction de l’avancement du challenge.</p>
|
||
<p>Pour chaque champ de texte, vous disposez de 10 tentatives avant de perdre des points (vous perdez les points même si vous ne validez pas l’étape) pour chaque tentative supplémentaire : -0,25 point entre 11 et 20, -0,5 entre 21 et 30, -0,75 entre 31 et 40, …</p>
|
||
<p>La seule manière de <strong>gagner des points</strong> est de <strong>valider une étape d’un scénario dans son intégralité</strong>. Le nombre de points gagnés <strong>dépend de la difficulté théorique</strong> de l’étape ainsi que <strong>d’éventuels bonus</strong>. Un bonus de <strong>10 %</strong> est accordé à la première équipe qui valide une étape. D’<strong>autres bonus</strong> peuvent ponctuer le challenge, détaillé dans la partie suivante.</p>
|
||
<p>Le classement est établi par équipe, selon le nombre de points récoltés et perdus par tous les membres. En cas d’égalité au score, les équipes sont départagées en fonction de leur ordre d’arrivée à ce score.</p>
|
||
<h3>Temps forts</h3>
|
||
<p>Le challenge <em>forensic</em> est jalonné de plusieurs temps forts durant lesquels <strong>certains calculs</strong> détaillés dans la partie précédente <strong>peuvent être altérés</strong>. L’équipe d’animation du challenge vous <strong>avertira</strong> environ <strong>15 minutes avant</strong> le début de la modification.</p>
|
||
<p>Chaque modification se répercute instantanément dans votre interface, attendez simplement qu’elle apparaisse afin d’être certain d’en bénéficier. Un compte à rebours est généralement affiché sur les écrans pour indiquer la fin d’un temps fort. La fin d’application d’un bonus est déterminé par l’heure d’arrivée de votre demande sur nos serveurs.</p>
|
||
<p>Sans y être limité ou assuré, sachez que durant les précédentes éditions du challenge <em>forensic</em>, nous avons par exemple : <strong>doublé les points</strong> de défis peu tentés, <strong>doublé les points de tous les défis</strong> pendant 30 minutes, <strong>réduit le coût des indices</strong> pendant 15 minutes, etc.</p>
|
||
<p></p>
|
||
<p>Tous les étudiants de la majeure Système, Réseaux et Sécurité de l’ÉPITA, son équipe enseignante ainsi que le commandement de la cyberdéfense vous souhaitent bon courage pour cette nouvelle éditions du challenge !</p>`,
|
||
YourMission: `<h4>Bienvenue au challenge forensic !</h4>
|
||
<p>Vous voici aujourd'hui dans la peau de <strong>spécialistes en investigation numérique</strong>. Vous avez à votre disposition une vingtaine de scénarios différents dans lesquels vous devrez faire les différentes étapes <strong>de la caractérisation d’une réponse à incident</strong>.</p>
|
||
<p>Chaque scénario est découpé en 5 grandes <strong>étapes de difficulté croissante</strong>. Un certain nombre de points est attribué à chaque étape, avec un processus de validation automatique.</p>
|
||
<p>Un classement est établi en temps réel, tenant compte des différents bonus, en fonction du nombre de points de chaque équipe.</p>`,
|
||
})
|
||
}
|
||
|
||
func reset(c *gin.Context) {
|
||
var m map[string]string
|
||
err := c.ShouldBindJSON(&m)
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||
return
|
||
}
|
||
|
||
t, ok := m["type"]
|
||
if !ok {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Field type not found"})
|
||
}
|
||
|
||
switch t {
|
||
case "teams":
|
||
err = fic.ResetTeams()
|
||
case "challenges":
|
||
err = fic.ResetExercices()
|
||
case "game":
|
||
err = fic.ResetGame()
|
||
case "annexes":
|
||
err = fic.ResetAnnexes()
|
||
case "settings":
|
||
err = ResetSettings()
|
||
case "challengeInfo":
|
||
err = ResetChallengeInfo()
|
||
default:
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Unknown reset type"})
|
||
return
|
||
}
|
||
|
||
if err != nil {
|
||
log.Printf("Unable to reset (type=%q): %s", t, err)
|
||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to performe the reset: %s", err.Error())})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, true)
|
||
}
|